- 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>
99 lines
3.7 KiB
Dart
99 lines
3.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import '../../../core/availability/mitra_availability_notifier.dart';
|
|
import '../../../core/constants.dart';
|
|
import '../../../core/pairing/pairing_notifier.dart';
|
|
|
|
/// Shown when a "Curhat lagi" attempt against a specific bestie can't proceed
|
|
/// — either a 409 `targeted_mitra_offline` response on the targeted POST, or
|
|
/// one of the intermediate WS events (`returning_chat_timeout`,
|
|
/// `returning_chat_rejected`).
|
|
///
|
|
/// CTAs:
|
|
/// - "Chat dengan bestie lain" — only rendered when
|
|
/// [mitraAvailabilityProvider] reports `available == true` at the time of
|
|
/// build. Tapping calls [Pairing.fallbackToBlast] (reuses the same payment
|
|
/// session — no double-charge) and closes the dialog. The caller is expected
|
|
/// to be the searching screen, which will transition into PairingSearchingData
|
|
/// and stay put.
|
|
/// - "Kembali" — pops dialog and routes home. Backend has already audit-logged
|
|
/// the targeted failure; payment session stays `confirmed` until the sweeper
|
|
/// expires it.
|
|
class BestieUnavailableDialog extends ConsumerWidget {
|
|
final String paymentSessionId;
|
|
final String mitraName;
|
|
final TopicSensitivity topicSensitivity;
|
|
|
|
const BestieUnavailableDialog({
|
|
super.key,
|
|
required this.paymentSessionId,
|
|
required this.mitraName,
|
|
required this.topicSensitivity,
|
|
});
|
|
|
|
/// Convenience: show this dialog and return when it closes. Per project
|
|
/// memory ("Riverpod ref.listen in build is unsafe"), callers should
|
|
/// invoke this from `ref.listenManual` callbacks in `initState`, not from
|
|
/// `build`.
|
|
static Future<void> show(
|
|
BuildContext context, {
|
|
required String paymentSessionId,
|
|
required String mitraName,
|
|
required TopicSensitivity topicSensitivity,
|
|
}) {
|
|
return showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => BestieUnavailableDialog(
|
|
paymentSessionId: paymentSessionId,
|
|
mitraName: mitraName,
|
|
topicSensitivity: topicSensitivity,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
// Snapshot at dialog-open time — we don't keep listening, we just check
|
|
// whether other bestie are around right now.
|
|
final availabilityAsync = ref.watch(mitraAvailabilityProvider);
|
|
final hasOtherAvailable = availabilityAsync.valueOrNull ?? false;
|
|
|
|
return AlertDialog(
|
|
title: const Text('Bestie sedang tidak online'),
|
|
content: Text(
|
|
'$mitraName sedang tidak bisa menerima chat saat ini. '
|
|
'Kamu bisa coba chat dengan bestie lain atau kembali ke beranda.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
// Reset pairing state and route home. Payment session stays
|
|
// confirmed until sweeper expires it — no extra API call needed.
|
|
ref.read(pairingProvider.notifier).reset();
|
|
Navigator.of(context).pop();
|
|
context.go('/home');
|
|
},
|
|
child: const Text('Kembali'),
|
|
),
|
|
if (hasOtherAvailable)
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
// Close the dialog first, then kick off the fallback. The
|
|
// searching screen will pick up the new PairingSearchingData
|
|
// state and render normally (no targeted overlay).
|
|
Navigator.of(context).pop();
|
|
// ignore: discarded_futures
|
|
ref.read(pairingProvider.notifier).fallbackToBlast(
|
|
paymentSessionId: paymentSessionId,
|
|
topicSensitivity: topicSensitivity,
|
|
);
|
|
},
|
|
child: const Text('Chat dengan bestie lain'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|