Phase 3.7: paid pairing flow + returning chat + extension flip
- Backend: payment_sessions + pairing_failures tables; payment.service.js and pairing-failure.service.js (new); rewritten pairing.service.js (payment-gated blast + targeted "Curhat lagi" + cancel + fallback); rewritten extension.service.js (data-driven auto-approve with offline safeguard, charge-at-approval); pricing.service.js (extension tiers without free trial); mitra-status.service.js (countAvailableMitras cached path); 60s sweeper for stale payment sessions - Backend routes: client.payment.routes, client.mitra-availability.routes, internal/failed-pairings.routes; client.chat.routes rewritten for payment-gated start + /returning + /cancel + /fallback-to-blast; internal/config.routes adds 4 new keys with Valkey invalidate publish - client_app: mitra-availability poll, payment screen + notifier, pairing notifier rewrite (PairingTargetedWaiting + PairingFailed states), targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi" CTA, failed-pairing terminal, extension via payment-session - mitra_app: PairingRequestType enum, returning-chat 20s countdown auto-dismiss, extension card "otomatis disetujui" copy - control_center: 4 new config rows in Settings, Failed Pairings page (filter + paginate + action menu), sidebar + route registered - Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4 pass), Maestro mobile scaffold (CLI install pending) - Bugs found via Playwright + fixed: LoginPage labels not associated with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE in allow-methods (silent settings breakage in browsers since Stage 4) - Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A), phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md (today's run results) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
|
||||
part 'mitra_availability_notifier.g.dart';
|
||||
|
||||
/// Customer-home availability poll.
|
||||
///
|
||||
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
|
||||
/// screen is in the foreground. Polling is gated by the home screen calling
|
||||
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
|
||||
/// - resumed → setActive(true)
|
||||
/// - paused/inactive → setActive(false)
|
||||
///
|
||||
/// On any HTTP error we emit `false` (never display stale state).
|
||||
///
|
||||
/// The endpoint also returns a `count`, but the customer UI must only read the
|
||||
/// binary `available` field — the count is for CC/debug only.
|
||||
@Riverpod(keepAlive: true)
|
||||
class MitraAvailability extends _$MitraAvailability {
|
||||
Timer? _pollTimer;
|
||||
bool _active = false;
|
||||
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
ref.onDispose(_stopPolling);
|
||||
// Default to disabled until the first poll returns. Never optimistically
|
||||
// show the CTA as enabled.
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Called by the home screen via `WidgetsBindingObserver` to gate polling
|
||||
/// to the foregrounded state. Polling is paused on `paused` / `inactive`
|
||||
/// and resumed on `resumed` (and an immediate poll fires on resume).
|
||||
void setActive(bool active) {
|
||||
if (_active == active) return;
|
||||
_active = active;
|
||||
if (_active) {
|
||||
_startPolling();
|
||||
// Fire-and-forget an immediate poll on resume so the CTA reflects
|
||||
// current availability without waiting up to 5s.
|
||||
// ignore: unawaited_futures
|
||||
_pollOnce();
|
||||
} else {
|
||||
_stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual one-shot refresh — used for pull-to-refresh on the home screen.
|
||||
Future<void> refresh() => _pollOnce();
|
||||
|
||||
void _startPolling() {
|
||||
_stopPolling();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollOnce());
|
||||
}
|
||||
|
||||
void _stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
Future<void> _pollOnce() async {
|
||||
bool available;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/client/mitra-availability');
|
||||
final data = response['data'] as Map<String, dynamic>?;
|
||||
available = data?['available'] as bool? ?? false;
|
||||
} catch (_) {
|
||||
// Poll failure → default to disabled. Never keep the last-known state.
|
||||
available = false;
|
||||
}
|
||||
// Skip the assignment when the value didn't change — Riverpod allocates a
|
||||
// new AsyncData each call, which would re-notify all listeners every 5s.
|
||||
if (state.valueOrNull == available) return;
|
||||
state = AsyncData(available);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'mitra_availability_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9';
|
||||
|
||||
/// Phase 3.7 §1: customer-home availability poll.
|
||||
///
|
||||
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
|
||||
/// screen is in the foreground. Polling is gated by the home screen calling
|
||||
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
|
||||
/// - resumed → setActive(true)
|
||||
/// - paused/inactive → setActive(false)
|
||||
///
|
||||
/// On any HTTP error we emit `false` (PRD §1.3: never display stale state).
|
||||
///
|
||||
/// The endpoint also returns a `count`, but per PRD §1.3 the customer UI must
|
||||
/// only read the binary `available` field — the count is for CC/debug only.
|
||||
///
|
||||
/// Copied from [MitraAvailability].
|
||||
@ProviderFor(MitraAvailability)
|
||||
final mitraAvailabilityProvider =
|
||||
AsyncNotifierProvider<MitraAvailability, bool>.internal(
|
||||
MitraAvailability.new,
|
||||
name: r'mitraAvailabilityProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$mitraAvailabilityHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$MitraAvailability = AsyncNotifier<bool>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
Reference in New Issue
Block a user