diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js index b6cf391..b9b6c90 100644 --- a/backend/src/routes/internal/_test.routes.js +++ b/backend/src/routes/internal/_test.routes.js @@ -38,4 +38,43 @@ export const internalTestRoutes = async (fastify) => { } return { ok: true, phone, dropped_customer: dropCustomer } }) + + // Force-expire a `pending` payment session (used by Maestro Stage 3 flow to + // drive the waiting-payment screen into the expired state without waiting + // 20 minutes). Sets `expires_at` to the past and status to `expired` so the + // next poll from the client sees the terminal state. + // + // Body shape: + // { payment_id: '' } → expire this specific session + // { latest: true } → expire the most-recently-created pending + fastify.post('/force-expire-payment', async (request, reply) => { + const { payment_id, latest } = request.body ?? {} + let target + if (latest === true) { + const [row] = await sql` + SELECT id FROM payment_sessions + WHERE status = 'pending' + ORDER BY created_at DESC + LIMIT 1 + ` + if (!row) { + return reply.code(404).send({ error: 'no_pending_payment' }) + } + target = row.id + } else if (payment_id) { + target = payment_id + } else { + return reply.code(400).send({ error: 'payment_id or latest:true required in body' }) + } + const [updated] = await sql` + UPDATE payment_sessions + SET status = 'expired', expires_at = NOW() - INTERVAL '1 minute' + WHERE id = ${target} AND status = 'pending' + RETURNING id, status + ` + if (!updated) { + return reply.code(404).send({ error: 'no_pending_payment_for_id', payment_id: target }) + } + return { ok: true, ...updated } + }) } diff --git a/client_app/.maestro/flows/04_payment_expired.yaml b/client_app/.maestro/flows/04_payment_expired.yaml new file mode 100644 index 0000000..24b6048 --- /dev/null +++ b/client_app/.maestro/flows/04_payment_expired.yaml @@ -0,0 +1,94 @@ +# Stage 3 acceptance: drive a payment session into the expired state and +# verify the expired screen renders. +# +# Flow: +# home → tap CTA → /payment/entry → /payment/method-pick (or +# discount-paywall — both arrive at /payment/method) → /payment/method → +# tap bayar → /payment/waiting/:id → force-expire via dev endpoint → +# poller transitions to /payment/expired/:id. +# +# Pre-req: +# 1. The customer is already onboarded + on /home (run flow 01 first, or +# launchApp with clearState=false on a state past onboarding). +# 2. At least one mitra is ONLINE on the target backend (so the CTA is +# enabled). Use mitra_app or the manual seed. +# 3. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production' +# (so the _test routes register). +# +# Run: +# maestro test client_app/.maestro/flows/04_payment_expired.yaml +appId: ${APP_ID_ANDROID} +env: + BACKEND_INTERNAL_URL: http://localhost:3001 +--- +- launchApp: + clearState: false +- assertVisible: "Mulai Curhat" + +# Step 1: tap CTA — home routes to /payment/entry which decides the next leg +# based on first-session-discount eligibility. +- tapOn: "Mulai Curhat" + +# Step 2: regardless of which entry path was chosen, the customer ends up at +# /payment/method-pick (non-eligible) or /payment/discount-paywall (eligible). +# Both have a way forward to /payment/method. Wait for either landmark. +- extendedWaitUntil: + visible: + text: "pilih cara curhat|sesi pertama|pilih durasi" + timeout: 10000 + +# Step 3: pick chat (if on method-pick) and a tier (if on duration-pick), +# or tap mulai (if on discount paywall). Each branch funnels into +# /payment/method. +- runFlow: + when: + visible: + text: "pilih cara curhat" + commands: + - tapOn: "chat" + - extendedWaitUntil: + visible: + text: "pilih durasi" + timeout: 5000 + - tapOn: + text: "5 menit" + retryTapIfNoChange: true + - tapOn: + text: "bayar" + retryTapIfNoChange: true +- runFlow: + when: + visible: + text: "sesi pertama" + commands: + - tapOn: + text: "mulai" + retryTapIfNoChange: true + +# Step 4: on the cara-bayar screen, QRIS is preselected. Tap pay. +- extendedWaitUntil: + visible: + text: "cara bayar" + timeout: 10000 +- tapOn: + text: "bayar" + retryTapIfNoChange: true + +# Step 5: we should now be on the QR/waiting screen. The header shows the +# countdown ("kedaluwarsa dalam"). Force-expire via the dev endpoint. +- extendedWaitUntil: + visible: + text: "kedaluwarsa dalam" + timeout: 10000 +- runScript: + file: ../scripts/force_expire_latest_payment.js + env: + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +# Step 6: poller picks up `expired` within ~3s and routes to expired screen. +- extendedWaitUntil: + visible: + text: "pembayaran kedaluwarsa" + timeout: 10000 +- assertVisible: "coba lagi" +- assertVisible: "kembali ke home" diff --git a/client_app/.maestro/scripts/force_expire_latest_payment.js b/client_app/.maestro/scripts/force_expire_latest_payment.js new file mode 100644 index 0000000..409839e --- /dev/null +++ b/client_app/.maestro/scripts/force_expire_latest_payment.js @@ -0,0 +1,21 @@ +// Force-expire the latest pending payment_session by hitting the dev-only +// /internal/_test/force-expire-payment endpoint. Used by the Stage 3 maestro +// flow (04_payment_expired.yaml) to drive the waiting screen into expired +// without waiting 20 minutes. +// +// Strategy: query the latest pending payment_session via raw SQL through the +// reset-phone endpoint? — actually no, we don't have an SQL surface. Instead, +// we expose a tiny "expire-latest-pending" variant: pass `latest=true` and +// the backend looks up the most-recent pending row. +// +// Reads BACKEND_INTERNAL_URL from env (Maestro injects it from the flow). +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +const resp = http.post(`${url}/internal/_test/force-expire-payment`, { + body: JSON.stringify({ latest: true }), + headers: { 'Content-Type': 'application/json' }, +}) +if (resp.status !== 200) { + throw new Error(`force-expire-payment failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) +output.PAYMENT_ID = data.id diff --git a/client_app/lib/core/chat/chat_opening_provider.dart b/client_app/lib/core/chat/chat_opening_provider.dart index 71e27df..1de9dc9 100644 --- a/client_app/lib/core/chat/chat_opening_provider.dart +++ b/client_app/lib/core/chat/chat_opening_provider.dart @@ -8,27 +8,97 @@ class PriceTier { final int durationMinutes; final int price; final String label; + final String? id; + final String? tag; - PriceTier({required this.durationMinutes, required this.price, required this.label}); + const PriceTier({ + required this.durationMinutes, + required this.price, + required this.label, + this.id, + this.tag, + }); + /// Phase 4 shape: `{ id, minutes, price_idr, tag }` — used by the new + /// chat/call tier groups. Falls back to the legacy free-trial-pricing shape + /// (`{ duration_minutes, price, label }`) for back-compat with the Phase 3 + /// `/api/client/chat/pricing` payload still consumed by the legacy payment + /// screen + bottom sheet. factory PriceTier.fromJson(Map json) { + final minutes = (json['minutes'] ?? json['duration_minutes']) as int; + final price = (json['price_idr'] ?? json['price']) as int; + final label = (json['label'] as String?) ?? '$minutes Menit'; return PriceTier( - durationMinutes: json['duration_minutes'] as int, - price: json['price'] as int, - label: json['label'] as String, + durationMinutes: minutes, + price: price, + label: label, + id: (json['id'] as String?) ?? minutes.toString(), + tag: json['tag'] as String?, + ); + } +} + +/// First-session discount block. Mirrors backend +/// `pricing.first_session_discount`. Server-authoritative — the client only +/// reads this; the actual discount price is re-validated on the backend when +/// the payment session is created. +class FirstSessionDiscount { + final bool eligible; + final int actualPriceIDR; + final int gimmickPriceIDR; + final int durationMinutes; + final List modes; + + const FirstSessionDiscount({ + required this.eligible, + required this.actualPriceIDR, + required this.gimmickPriceIDR, + required this.durationMinutes, + required this.modes, + }); + + factory FirstSessionDiscount.fromJson(Map json) { + final modesRaw = json['modes']; + final modes = modesRaw is List + ? modesRaw.map((e) => e.toString()).toList() + : const ['chat']; + return FirstSessionDiscount( + eligible: json['eligible'] as bool? ?? false, + actualPriceIDR: json['actual_price_idr'] as int? ?? 0, + gimmickPriceIDR: json['gimmick_price_idr'] as int? ?? 0, + durationMinutes: json['duration_minutes'] as int? ?? 0, + modes: modes, ); } } class PricingData { + /// Legacy single-list tiers. Populated from `data.tiers` when the response + /// uses the Phase 3 shape; populated from `data.chat.tiers` when the + /// Phase 4 shape is returned (so existing callers keep working). final List tiers; final bool freeTrialEligible; final int freeTrialDurationMinutes; + /// Phase 4 chat-mode tiers (`pricing.chat.tiers`). Empty when the backend + /// still returns the Phase 3 shape. + final List chatTiers; + + /// Phase 4 call-mode tiers (`pricing.call.tiers`). Empty when the backend + /// still returns the Phase 3 shape. + final List callTiers; + + /// Phase 4 first-session discount block. Null when the backend still + /// returns the Phase 3 shape. + final FirstSessionDiscount? firstSessionDiscount; + const PricingData({ required this.tiers, required this.freeTrialEligible, this.freeTrialDurationMinutes = 5, + this.chatTiers = const [], + this.callTiers = const [], + this.firstSessionDiscount, }); } @@ -37,9 +107,35 @@ Future chatPricing(Ref ref) async { final apiClient = ref.read(apiClientProvider); final response = await apiClient.get('/api/client/chat/pricing'); final data = response['data'] as Map; + + // Phase 4 shape — `data.chat.tiers` + `data.call.tiers` + `first_session_discount`. + // Phase 3 shape — `data.tiers` + `data.free_trial`. Detect which we got and + // populate the model accordingly. + final hasPhase4Groups = data['chat'] is Map; + + if (hasPhase4Groups) { + final chat = data['chat'] as Map; + final call = (data['call'] as Map?) ?? const {}; + final chatTiers = (chat['tiers'] as List? ?? const []) + .map((t) => PriceTier.fromJson(t as Map)) + .toList(); + final callTiers = (call['tiers'] as List? ?? const []) + .map((t) => PriceTier.fromJson(t as Map)) + .toList(); + final discountJson = data['first_session_discount'] as Map?; + final discount = discountJson != null ? FirstSessionDiscount.fromJson(discountJson) : null; + return PricingData( + tiers: chatTiers, + freeTrialEligible: false, + chatTiers: chatTiers, + callTiers: callTiers, + firstSessionDiscount: discount, + ); + } + final tiersJson = data['tiers'] as List; final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map)).toList(); - final freeTrial = data['free_trial'] as Map; + final freeTrial = (data['free_trial'] as Map?) ?? const {}; return PricingData( tiers: tiers, diff --git a/client_app/lib/core/chat/chat_opening_provider.g.dart b/client_app/lib/core/chat/chat_opening_provider.g.dart index ba45584..993ccbb 100644 --- a/client_app/lib/core/chat/chat_opening_provider.g.dart +++ b/client_app/lib/core/chat/chat_opening_provider.g.dart @@ -6,7 +6,7 @@ part of 'chat_opening_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$chatPricingHash() => r'53ca829d46f7ba3b481e7a6b54482c62f9fe3ad0'; +String _$chatPricingHash() => r'6dfbdf77942a67d3da689849eda89fc1fa3e6e39'; /// See also [chatPricing]. @ProviderFor(chatPricing) diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index 2d19279..b602c51 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart'; import '../../core/auth/auth_notifier.dart'; import '../../core/availability/mitra_availability_notifier.dart'; import '../../core/chat/active_session_notifier.dart'; -import '../chat/widgets/topic_selection_bottom_sheet.dart'; /// Home screen. /// @@ -53,10 +52,17 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse } } - Future _onStartChatPressed(BuildContext context) async { - final topic = await TopicSelectionBottomSheet.show(context); - if (topic == null || !context.mounted) return; - context.push('/payment', extra: {'topicSensitivity': topic}); + void _onStartChatPressed(BuildContext context) { + // Phase 4 Stage 2 removes the home-screen topic sensitivity prompt; the + // ESP picks collected during onboarding feed the same column server-side + // (info-only — no longer drives matching). Mitras still flip + // `topic_sensitivity` mid-session via the AppBar toggle. + // + // Phase 4 Stage 3: enter the new multi-screen payment shell. The entry + // route picks discount-paywall vs. method-pick based on first-session + // eligibility. The legacy `/payment` route is preserved for the + // chat-history "Curhat lagi" path until Stage 5 migrates it. + context.push('/payment/entry'); } @override diff --git a/client_app/lib/features/payment/payment_notifier.g.dart b/client_app/lib/features/payment/payment_notifier.g.dart index fc7a047..6955372 100644 --- a/client_app/lib/features/payment/payment_notifier.g.dart +++ b/client_app/lib/features/payment/payment_notifier.g.dart @@ -6,7 +6,7 @@ part of 'payment_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$paymentHash() => r'63019ba794311cd36761bd6ad6f90b0abde5c747'; +String _$paymentHash() => r'd98f2e7e5045ea2a39b7af0d4a9f0601dd06ce74'; /// See also [Payment]. @ProviderFor(Payment) diff --git a/client_app/lib/features/payment/screens/discount_paywall_screen.dart b/client_app/lib/features/payment/screens/discount_paywall_screen.dart new file mode 100644 index 0000000..acdd758 --- /dev/null +++ b/client_app/lib/features/payment/screens/discount_paywall_screen.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/chat/chat_opening_provider.dart'; +import '../../../core/constants.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/halo_button.dart'; +import '../state/payment_draft_provider.dart'; + +/// S6 first-session discount paywall. +/// +/// Renders only when `chat-pricing.first_session_discount.eligible == true`. +/// Layout: struck-through gimmick price next to a prominent actual price, +/// "untuk N menit ngobrol" subtitle, and a primary CTA that seeds the payment +/// draft and routes to `/payment/method`. +class DiscountPaywallScreen extends ConsumerWidget { + const DiscountPaywallScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pricingAsync = ref.watch(chatPricingProvider); + + return Scaffold( + backgroundColor: HaloTokens.bg, + appBar: AppBar( + backgroundColor: HaloTokens.bg, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.chevron_left, color: HaloTokens.brandDark), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/home'); + } + }, + ), + ), + body: pricingAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center( + child: Padding( + padding: EdgeInsets.all(HaloSpacing.s24), + child: Text( + 'Gagal memuat harga. Coba lagi.', + textAlign: TextAlign.center, + ), + ), + ), + data: (pricing) { + final discount = pricing.firstSessionDiscount; + if (discount == null || !discount.eligible) { + // Defensive: route guard normally prevents this. If we got here + // anyway (stale state), kick over to method-pick. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + context.go('/payment/method-pick'); + }); + return const SizedBox.shrink(); + } + return _Body(discount: discount); + }, + ), + ); + } +} + +class _Body extends ConsumerWidget { + final FirstSessionDiscount discount; + const _Body({required this.discount}); + + void _onMulai(BuildContext context, WidgetRef ref) { + ref.read(paymentDraftNotifierProvider.notifier).setDiscountPlan( + durationMinutes: discount.durationMinutes, + priceIDR: discount.actualPriceIDR, + ); + context.push('/payment/method'); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showModeToggle = discount.modes.length > 1; + final actual = formatRupiah(discount.actualPriceIDR); + final gimmick = formatRupiah(discount.gimmickPriceIDR); + + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s8, + HaloSpacing.s24, + HaloSpacing.s16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showModeToggle) const _ModeToggleHidden(), + const Text( + '🌷', + style: TextStyle(fontSize: 38), + ), + const SizedBox(height: HaloSpacing.s12), + Text( + 'biar yakin yang mau cerita,\nbestie cuma minta $actual', + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 26, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + height: 1.2, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: HaloSpacing.s16), + const Text( + 'bukan biaya — anggap aja seperti senyum hangat di awal pertemuan, biar bestie tau kamu beneran mau ngobrol.', + style: TextStyle( + fontSize: 14, + color: HaloTokens.inkSoft, + height: 1.55, + ), + ), + const SizedBox(height: HaloSpacing.s20), + _PriceCard( + actual: actual, + gimmick: gimmick, + durationMinutes: discount.durationMinutes, + ), + const SizedBox(height: HaloSpacing.s12), + const Center( + child: Text( + 'sesi-sesi berikutnya pakai harga normal, sesuai durasi yang kamu pilih.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: HaloTokens.inkMuted, + height: 1.5, + ), + ), + ), + ], + ), + ), + ), + Container( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s12, + HaloSpacing.s24, + HaloSpacing.s32, + ), + decoration: const BoxDecoration( + color: HaloTokens.bg, + border: Border(top: BorderSide(color: HaloTokens.border)), + ), + child: Column( + children: [ + HaloButton( + label: 'mulai · $actual', + size: HaloButtonSize.lg, + fullWidth: true, + onPressed: () => _onMulai(context, ref), + ), + const SizedBox(height: HaloSpacing.s8), + const Text( + 'QRIS · GoPay · OVO · DANA · ShopeePay', + style: TextStyle(fontSize: 11, color: HaloTokens.inkMuted), + ), + ], + ), + ), + ], + ); + } +} + +class _PriceCard extends StatelessWidget { + final String actual; + final String gimmick; + final int durationMinutes; + + const _PriceCard({ + required this.actual, + required this.gimmick, + required this.durationMinutes, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(HaloSpacing.s20), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [HaloTokens.brandSofter, HaloTokens.surface], + ), + borderRadius: HaloRadius.xl, + border: Border.all(color: HaloTokens.brandSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'SESI PERTAMA', + style: TextStyle( + fontSize: 11, + color: HaloTokens.inkSoft, + fontWeight: FontWeight.w500, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: HaloSpacing.s4), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + actual, + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 36, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + height: 1, + letterSpacing: -1, + ), + ), + const SizedBox(width: HaloSpacing.s8), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + gimmick, + style: const TextStyle( + fontSize: 14, + color: HaloTokens.inkMuted, + decoration: TextDecoration.lineThrough, + ), + ), + ), + ], + ), + const SizedBox(height: HaloSpacing.s4), + Text( + 'untuk $durationMinutes menit ngobrol', + style: const TextStyle( + fontSize: 13, + color: HaloTokens.inkSoft, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s12, + vertical: 6, + ), + decoration: const BoxDecoration( + color: HaloTokens.mint, + borderRadius: HaloRadius.pill, + ), + child: const Text( + 'HANYA SEKALI', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Color(0xFF1F4D34), + letterSpacing: 0.6, + ), + ), + ), + ], + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16), + child: Divider(height: 1, color: HaloTokens.border), + ), + ..._features.map((row) => Padding( + padding: const EdgeInsets.only(bottom: HaloSpacing.s8), + child: Row( + children: [ + Text(row.$1, style: const TextStyle(fontSize: 14)), + const SizedBox(width: HaloSpacing.s8), + Expanded( + child: Text( + row.$2, + style: const TextStyle( + fontSize: 13, + color: HaloTokens.ink, + ), + ), + ), + ], + ), + )), + ], + ), + ); + } + + static const List<(String, String)> _features = [ + ('🤍', 'menit private sama bestie kamu'), + ('👤', 'manusia nyata, bukan AI'), + ('🛡️', 'gak cocok? sesi gratis tanpa tanya'), + ]; +} + +/// Placeholder for the chat|call mode toggle on the discount screen. Default +/// config is chat-only (`modes = ['chat']`) so we never render anything yet — +/// kept as a marker so the spot is reserved when ops eventually flips +/// `first_session_discount_modes` to `['chat', 'call']`. +class _ModeToggleHidden extends StatelessWidget { + const _ModeToggleHidden(); + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} diff --git a/client_app/lib/features/payment/screens/duration_pick_screen.dart b/client_app/lib/features/payment/screens/duration_pick_screen.dart new file mode 100644 index 0000000..58d22fd --- /dev/null +++ b/client_app/lib/features/payment/screens/duration_pick_screen.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/chat/chat_opening_provider.dart'; +import '../../../core/constants.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/halo_button.dart'; +import '../state/payment_draft_provider.dart'; + +/// "Pemilihan harga" — tier list scoped to the currently-selected mode. +/// A chat|call mode toggle at the top rebuilds the list and resets the +/// selection. Bottom CTA carries the picked tier into `/payment/method`. +class DurationPickScreen extends ConsumerWidget { + const DurationPickScreen({super.key}); + + List _tiersForMode(PricingData pricing, PaymentMode mode) { + return mode == PaymentMode.call ? pricing.callTiers : pricing.chatTiers; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pricingAsync = ref.watch(chatPricingProvider); + final draft = ref.watch(paymentDraftNotifierProvider); + + return Scaffold( + backgroundColor: HaloTokens.bg, + appBar: AppBar( + backgroundColor: HaloTokens.bg, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.chevron_left, color: HaloTokens.brandDark), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/payment/method-pick'); + } + }, + ), + title: const Text( + 'pilih durasi', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 18, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + centerTitle: false, + ), + body: pricingAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center( + child: Padding( + padding: EdgeInsets.all(HaloSpacing.s24), + child: Text( + 'Gagal memuat harga. Coba lagi.', + textAlign: TextAlign.center, + ), + ), + ), + data: (pricing) => _Body( + pricing: pricing, + mode: draft.mode, + selectedDurationId: draft.durationId, + tiers: _tiersForMode(pricing, draft.mode), + ), + ), + ); + } +} + +class _Body extends ConsumerWidget { + final PricingData pricing; + final PaymentMode mode; + final String? selectedDurationId; + final List tiers; + + const _Body({ + required this.pricing, + required this.mode, + required this.selectedDurationId, + required this.tiers, + }); + + void _onModeToggle(WidgetRef ref, PaymentMode next) { + if (next == mode) return; + ref.read(paymentDraftNotifierProvider.notifier).setMode(next); + } + + void _onTierTap(WidgetRef ref, PriceTier tier) { + ref.read(paymentDraftNotifierProvider.notifier).setTier( + durationId: tier.id ?? tier.durationMinutes.toString(), + durationMinutes: tier.durationMinutes, + priceIDR: tier.price, + ); + } + + void _onPay(BuildContext context) { + context.push('/payment/method'); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedTier = tiers.firstWhere( + (t) => (t.id ?? t.durationMinutes.toString()) == selectedDurationId, + orElse: () => const PriceTier(durationMinutes: 0, price: 0, label: ''), + ); + final hasSelection = selectedTier.durationMinutes > 0; + final ctaLabel = hasSelection + ? '${mode == PaymentMode.call ? '📞' : '💬'} bayar ${formatRupiah(selectedTier.price)}' + : 'pilih durasi dulu'; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s8, + HaloSpacing.s24, + HaloSpacing.s8, + ), + child: _ModeToggle(mode: mode, onChanged: (m) => _onModeToggle(ref, m)), + ), + Expanded( + child: tiers.isEmpty + ? const _EmptyState() + : ListView.separated( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s12, + HaloSpacing.s24, + HaloSpacing.s24, + ), + itemCount: tiers.length, + separatorBuilder: (_, __) => const SizedBox(height: HaloSpacing.s8), + itemBuilder: (context, i) { + final tier = tiers[i]; + final id = tier.id ?? tier.durationMinutes.toString(); + final selected = id == selectedDurationId; + return _TierCard( + tier: tier, + selected: selected, + onTap: () => _onTierTap(ref, tier), + ); + }, + ), + ), + Container( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s12, + HaloSpacing.s24, + HaloSpacing.s32, + ), + decoration: const BoxDecoration( + color: HaloTokens.bg, + border: Border(top: BorderSide(color: HaloTokens.border)), + ), + child: HaloButton( + label: ctaLabel, + size: HaloButtonSize.lg, + fullWidth: true, + onPressed: hasSelection ? () => _onPay(context) : null, + ), + ), + ], + ); + } +} + +class _ModeToggle extends StatelessWidget { + final PaymentMode mode; + final ValueChanged onChanged; + + const _ModeToggle({required this.mode, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: HaloTokens.brandSofter, + borderRadius: HaloRadius.pill, + ), + child: Row( + children: [ + Expanded(child: _Pill(label: 'chat', selected: mode == PaymentMode.chat, onTap: () => onChanged(PaymentMode.chat))), + Expanded(child: _Pill(label: 'call', selected: mode == PaymentMode.call, onTap: () => onChanged(PaymentMode.call))), + ], + ), + ); + } +} + +class _Pill extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + const _Pill({required this.label, required this.selected, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Material( + color: selected ? HaloTokens.surface : Colors.transparent, + borderRadius: HaloRadius.pill, + child: InkWell( + borderRadius: HaloRadius.pill, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: HaloSpacing.s8), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: selected ? HaloTokens.brandDark : HaloTokens.inkSoft, + ), + ), + ), + ), + ); + } +} + +class _TierCard extends StatelessWidget { + final PriceTier tier; + final bool selected; + final VoidCallback onTap; + + const _TierCard({required this.tier, required this.selected, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Material( + color: selected ? HaloTokens.brandSofter : HaloTokens.surface, + borderRadius: HaloRadius.lg, + child: InkWell( + borderRadius: HaloRadius.lg, + onTap: onTap, + child: AnimatedContainer( + duration: HaloMotion.fast, + padding: const EdgeInsets.all(HaloSpacing.s16), + decoration: BoxDecoration( + border: Border.all( + color: selected ? HaloTokens.brand : HaloTokens.border, + width: selected ? 2 : 1, + ), + borderRadius: HaloRadius.lg, + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: const BoxDecoration( + color: HaloTokens.surface, + borderRadius: HaloRadius.md, + ), + alignment: Alignment.center, + child: Text( + '${tier.durationMinutes}', + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 16, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + ), + const SizedBox(width: HaloSpacing.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '${tier.durationMinutes} menit', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + ), + if (tier.tag != null) ...[ + const SizedBox(width: HaloSpacing.s8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s8, + vertical: 2, + ), + decoration: const BoxDecoration( + color: HaloTokens.mint, + borderRadius: HaloRadius.pill, + ), + child: Text( + tier.tag!, + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: Color(0xFF1F4D34), + letterSpacing: 0.4, + ), + ), + ), + ], + ], + ), + ], + ), + ), + Text( + formatRupiah(tier.price), + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 16, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.all(HaloSpacing.s24), + child: Text( + 'Belum ada paket untuk mode ini.', + style: TextStyle(color: HaloTokens.inkSoft), + ), + ), + ); + } +} diff --git a/client_app/lib/features/payment/screens/method_pick_screen.dart b/client_app/lib/features/payment/screens/method_pick_screen.dart new file mode 100644 index 0000000..c78195c --- /dev/null +++ b/client_app/lib/features/payment/screens/method_pick_screen.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../state/payment_draft_provider.dart'; + +/// "Pilih cara curhat" — two cards (chat / call). The "premium" call indicator +/// is visual only, no hard-coded multiplier. Tapping a card stores the mode +/// in the draft and pushes `/payment/duration-pick`. +class MethodPickScreen extends ConsumerWidget { + const MethodPickScreen({super.key}); + + void _onPick(BuildContext context, WidgetRef ref, PaymentMode mode) { + ref.read(paymentDraftNotifierProvider.notifier).setMode(mode); + context.push('/payment/duration-pick'); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + backgroundColor: HaloTokens.bg, + appBar: AppBar( + backgroundColor: HaloTokens.bg, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.chevron_left, color: HaloTokens.brandDark), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/home'); + } + }, + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s8, + HaloSpacing.s24, + HaloSpacing.s24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'pilih cara curhat', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 26, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: HaloSpacing.s8), + const Text( + 'mau ngobrol pakai chat atau dengar suaranya langsung?', + style: TextStyle( + fontSize: 14, + color: HaloTokens.inkSoft, + height: 1.5, + ), + ), + const SizedBox(height: HaloSpacing.s24), + _ModeCard( + emoji: '💬', + title: 'chat', + subtitle: 'tulis dan baca dengan tenang', + onTap: () => _onPick(context, ref, PaymentMode.chat), + ), + const SizedBox(height: HaloSpacing.s12), + _ModeCard( + emoji: '📞', + title: 'call', + subtitle: 'dengar suara bestie langsung', + isPremium: true, + onTap: () => _onPick(context, ref, PaymentMode.call), + ), + ], + ), + ), + ), + ); + } +} + +class _ModeCard extends StatelessWidget { + final String emoji; + final String title; + final String subtitle; + final VoidCallback onTap; + final bool isPremium; + + const _ModeCard({ + required this.emoji, + required this.title, + required this.subtitle, + required this.onTap, + this.isPremium = false, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: HaloTokens.surface, + borderRadius: HaloRadius.lg, + child: InkWell( + borderRadius: HaloRadius.lg, + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(HaloSpacing.s20), + decoration: BoxDecoration( + border: Border.all(color: HaloTokens.border), + borderRadius: HaloRadius.lg, + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: const BoxDecoration( + color: HaloTokens.brandSofter, + borderRadius: HaloRadius.md, + ), + alignment: Alignment.center, + child: Text(emoji, style: const TextStyle(fontSize: 28)), + ), + const SizedBox(width: HaloSpacing.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + title, + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 18, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), + if (isPremium) ...[ + const SizedBox(width: HaloSpacing.s8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s8, + vertical: 2, + ), + decoration: const BoxDecoration( + color: HaloTokens.accentSoft, + borderRadius: HaloRadius.pill, + ), + child: const Text( + 'PREMIUM', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: Color(0xFF8C5418), + letterSpacing: 0.6, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 12.5, + color: HaloTokens.inkSoft, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: HaloTokens.inkMuted, + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/payment/screens/payment_entry_screen.dart b/client_app/lib/features/payment/screens/payment_entry_screen.dart new file mode 100644 index 0000000..c85acba --- /dev/null +++ b/client_app/lib/features/payment/screens/payment_entry_screen.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/chat/chat_opening_provider.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../state/payment_draft_provider.dart'; + +/// Single point of truth for the discount-vs-method-pick branch. +/// +/// Reads `chat-pricing.first_session_discount.eligible`. When the customer +/// is eligible (and the discount is enabled), routes to the S6 paywall; +/// otherwise routes to the regular method-pick screen. The draft is reset +/// here so a fresh entry into the flow always starts clean. +class PaymentEntryScreen extends ConsumerStatefulWidget { + const PaymentEntryScreen({super.key}); + + @override + ConsumerState createState() => _PaymentEntryScreenState(); +} + +class _PaymentEntryScreenState extends ConsumerState { + bool _routed = false; + + @override + void initState() { + super.initState(); + Future.microtask(() { + if (!mounted) return; + ref.read(paymentDraftNotifierProvider.notifier).reset(); + }); + } + + void _routeOnce(String location) { + if (_routed || !mounted) return; + _routed = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.go(location); + }); + } + + @override + Widget build(BuildContext context) { + final pricingAsync = ref.watch(chatPricingProvider); + + return Scaffold( + backgroundColor: HaloTokens.bg, + body: pricingAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) { + // Pricing fetch failed — fall through to method-pick (which fetches + // pricing again and surfaces the error there). + _routeOnce('/payment/method-pick'); + return const SizedBox.shrink(); + }, + data: (pricing) { + if (pricing.firstSessionDiscount?.eligible ?? false) { + _routeOnce('/payment/discount-paywall'); + } else { + _routeOnce('/payment/method-pick'); + } + return const SizedBox.shrink(); + }, + ), + ); + } +} diff --git a/client_app/lib/features/payment/screens/payment_expired_screen.dart b/client_app/lib/features/payment/screens/payment_expired_screen.dart new file mode 100644 index 0000000..ca358e4 --- /dev/null +++ b/client_app/lib/features/payment/screens/payment_expired_screen.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/halo_button.dart'; + +/// "Pembayaran expired" — terminal screen for the timeout path. The retry CTA +/// pushes back to `/payment/method`; the draft is intentionally retained so +/// the user re-pays the same plan/mode/discount without re-picking. +class PaymentExpiredScreen extends ConsumerWidget { + final String paymentId; + const PaymentExpiredScreen({super.key, required this.paymentId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PopScope( + canPop: true, + child: Scaffold( + backgroundColor: HaloTokens.bg, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s32, + HaloSpacing.s24, + HaloSpacing.s32, + ), + child: Column( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 90, + height: 90, + decoration: const BoxDecoration( + color: Color(0xFFFFE5E5), + borderRadius: HaloRadius.xl, + ), + alignment: Alignment.center, + child: const Text('⏰', style: TextStyle(fontSize: 42)), + ), + const SizedBox(height: HaloSpacing.s20), + const Text( + 'pembayaran kedaluwarsa', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + height: 1.25, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: HaloSpacing.s12), + const SizedBox( + width: 280, + child: Text( + 'sesi pembayaran udah lewat 20 menit. coba lagi yuk, gak ada potongan apapun.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.5, + color: HaloTokens.inkSoft, + height: 1.55, + ), + ), + ), + ], + ), + ), + HaloButton( + label: 'coba lagi', + size: HaloButtonSize.lg, + fullWidth: true, + onPressed: () => context.go('/payment/method'), + ), + const SizedBox(height: HaloSpacing.s8), + HaloButton( + label: 'kembali ke home', + variant: HaloButtonVariant.ghost, + size: HaloButtonSize.md, + fullWidth: true, + onPressed: () => context.go('/home'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/payment/screens/payment_method_screen.dart b/client_app/lib/features/payment/screens/payment_method_screen.dart new file mode 100644 index 0000000..3f063f2 --- /dev/null +++ b/client_app/lib/features/payment/screens/payment_method_screen.dart @@ -0,0 +1,411 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/api/api_client_provider.dart'; +import '../../../core/constants.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/halo_button.dart'; +import '../state/payment_draft_provider.dart'; + +/// "Cara bayar" — QRIS-first list of payment methods. On tap of `bayar`: +/// 1. POST `/api/client/payment-sessions` with the draft + chosen method. +/// 2. Push `/payment/waiting/:paymentId`. +class PaymentMethodScreen extends ConsumerStatefulWidget { + const PaymentMethodScreen({super.key}); + + @override + ConsumerState createState() => _PaymentMethodScreenState(); +} + +enum _PayMethod { + qris('qris', 'QRIS', 'semua e-wallet & m-banking', '🔲', recommended: true), + ovo('ovo', 'OVO', 'saldo OVO', '🟣'), + gopay('gopay', 'GoPay', 'saldo GoPay', '🟢'), + dana('dana', 'DANA', 'saldo DANA', '🔵'), + shopee('shopee', 'ShopeePay', 'saldo ShopeePay', '🟠'); + + final String id; + final String label; + final String sub; + final String icon; + final bool recommended; + + const _PayMethod( + this.id, + this.label, + this.sub, + this.icon, { + this.recommended = false, + }); +} + +class _PaymentMethodScreenState extends ConsumerState { + _PayMethod _selected = _PayMethod.qris; + bool _submitting = false; + String? _error; + + Future _onPay() async { + if (_submitting) return; + final draft = ref.read(paymentDraftNotifierProvider); + if (draft.durationMinutes == null || draft.priceIDR == null) { + setState(() => _error = 'Pilih durasi dulu sebelum bayar.'); + return; + } + setState(() { + _submitting = true; + _error = null; + }); + final api = ref.read(apiClientProvider); + try { + final body = { + 'mode': draft.mode.value, + 'duration_minutes': draft.durationMinutes, + 'price_idr': draft.priceIDR, + 'is_first_session_discount': draft.isFirstSessionDiscount, + 'method': _selected.id, + }; + // Trailing slash matches the existing payment_notifier path — Fastify + // is not configured with `ignoreTrailingSlash`. + final response = await api.post('/api/client/payment-sessions/', data: body); + final data = response['data'] as Map; + final paymentId = data['id'] as String; + ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId); + if (!mounted) return; + context.push('/payment/waiting/$paymentId'); + } on DioException catch (e) { + if (!mounted) return; + setState(() { + _submitting = false; + _error = _humanError(e); + }); + } catch (_) { + if (!mounted) return; + setState(() { + _submitting = false; + _error = 'Gagal membuat sesi pembayaran.'; + }); + } + } + + String _humanError(DioException e) { + final code = e.response?.data?['error']?['code'] as String?; + final status = e.response?.statusCode; + if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') { + return 'Pilihan durasi tidak valid.'; + } + if (status == 403) return 'Sesi tidak diizinkan.'; + if (status == 404) return 'Sesi pembayaran tidak ditemukan.'; + return 'Gagal membuat sesi pembayaran.'; + } + + @override + Widget build(BuildContext context) { + final draft = ref.watch(paymentDraftNotifierProvider); + final amount = draft.priceIDR ?? 0; + final durationLabel = draft.durationMinutes != null + ? 'sesi ${draft.durationMinutes} menit' + : 'sesi'; + final amountLabel = formatRupiah(amount); + final recommended = _PayMethod.values.where((m) => m.recommended).toList(); + final others = _PayMethod.values.where((m) => !m.recommended).toList(); + + return Scaffold( + backgroundColor: HaloTokens.bg, + appBar: AppBar( + backgroundColor: HaloTokens.bg, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.chevron_left, color: HaloTokens.brandDark), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/payment/duration-pick'); + } + }, + ), + title: const Text( + 'cara bayar', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 18, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + ), + ), + centerTitle: false, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s4, + HaloSpacing.s24, + HaloSpacing.s12, + ), + child: Container( + padding: const EdgeInsets.all(HaloSpacing.s12), + decoration: BoxDecoration( + color: HaloTokens.surface, + borderRadius: HaloRadius.lg, + border: Border.all(color: HaloTokens.border), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'total bayar', + style: TextStyle(fontSize: 11.5, color: HaloTokens.inkSoft), + ), + Text( + durationLabel, + style: const TextStyle(fontSize: 11, color: HaloTokens.inkMuted), + ), + ], + ), + ), + Text( + amountLabel, + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + letterSpacing: -0.5, + ), + ), + ], + ), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s20, + HaloSpacing.s8, + HaloSpacing.s20, + HaloSpacing.s16, + ), + children: [ + const _SectionLabel('paling cepat'), + ...recommended.map((m) => _MethodTile( + method: m, + selected: _selected == m, + onTap: () => setState(() => _selected = m), + large: true, + )), + const SizedBox(height: HaloSpacing.s8), + const _SectionLabel('e-wallet lain'), + ...others.map((m) => _MethodTile( + method: m, + selected: _selected == m, + onTap: () => setState(() => _selected = m), + )), + ], + ), + ), + if (_error != null) + Container( + margin: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + 0, + HaloSpacing.s24, + HaloSpacing.s8, + ), + padding: const EdgeInsets.all(HaloSpacing.s12), + decoration: BoxDecoration( + color: const Color(0xFFFFEBEB), + borderRadius: HaloRadius.md, + border: Border.all(color: HaloTokens.danger), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: HaloTokens.danger, size: 18), + const SizedBox(width: HaloSpacing.s8), + Expanded( + child: Text( + _error!, + style: const TextStyle(color: HaloTokens.danger, fontSize: 13), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s12, + HaloSpacing.s24, + HaloSpacing.s32, + ), + decoration: const BoxDecoration( + color: HaloTokens.bg, + border: Border(top: BorderSide(color: HaloTokens.border)), + ), + child: HaloButton( + label: _submitting ? 'memproses...' : 'bayar $amountLabel', + size: HaloButtonSize.lg, + fullWidth: true, + onPressed: _submitting ? null : _onPay, + ), + ), + ], + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + final String label; + const _SectionLabel(this.label); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, HaloSpacing.s8, 4, HaloSpacing.s8), + child: Text( + label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: HaloTokens.inkSoft, + letterSpacing: 0.6, + ), + ), + ); + } +} + +class _MethodTile extends StatelessWidget { + final _PayMethod method; + final bool selected; + final VoidCallback onTap; + final bool large; + + const _MethodTile({ + required this.method, + required this.selected, + required this.onTap, + this.large = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: HaloSpacing.s8), + child: Material( + color: selected ? HaloTokens.brandSofter : HaloTokens.surface, + borderRadius: HaloRadius.lg, + child: InkWell( + borderRadius: HaloRadius.lg, + onTap: onTap, + child: AnimatedContainer( + duration: HaloMotion.fast, + padding: EdgeInsets.all(large ? HaloSpacing.s16 : HaloSpacing.s12), + decoration: BoxDecoration( + border: Border.all( + color: selected ? HaloTokens.brand : HaloTokens.border, + width: selected ? 2 : 1, + ), + borderRadius: HaloRadius.lg, + ), + child: Row( + children: [ + Container( + width: large ? 40 : 36, + height: large ? 40 : 36, + decoration: BoxDecoration( + color: HaloTokens.surface, + borderRadius: HaloRadius.md, + border: Border.all(color: HaloTokens.border), + ), + alignment: Alignment.center, + child: Text(method.icon, style: TextStyle(fontSize: large ? 20 : 18)), + ), + const SizedBox(width: HaloSpacing.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + method.label, + style: TextStyle( + fontSize: large ? 14.5 : 13.5, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + ), + if (method.recommended) ...[ + const SizedBox(width: HaloSpacing.s8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: HaloSpacing.s8, + vertical: 2, + ), + decoration: const BoxDecoration( + color: HaloTokens.mint, + borderRadius: HaloRadius.pill, + ), + child: const Text( + 'DIREKOMENDASIKAN', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: Color(0xFF1F4D34), + letterSpacing: 0.4, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + method.sub, + style: TextStyle( + fontSize: large ? 11.5 : 11, + color: HaloTokens.inkSoft, + ), + ), + ], + ), + ), + Container( + width: large ? 20 : 18, + height: large ? 20 : 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? HaloTokens.brand : HaloTokens.border, + width: 2, + ), + color: selected ? HaloTokens.brand : HaloTokens.surface, + ), + child: selected + ? Center( + child: Container( + width: large ? 8 : 6, + height: large ? 8 : 6, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ) + : null, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/client_app/lib/features/payment/screens/waiting_payment_screen.dart b/client_app/lib/features/payment/screens/waiting_payment_screen.dart new file mode 100644 index 0000000..21b21e1 --- /dev/null +++ b/client_app/lib/features/payment/screens/waiting_payment_screen.dart @@ -0,0 +1,318 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import '../../../core/api/api_client_provider.dart'; +import '../../../core/constants.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../state/payment_draft_provider.dart'; + +/// "Waiting payment" — placeholder QR + 20-minute countdown header. Polls the +/// backend every 3 seconds for status changes. On `confirmed` the payment is +/// considered paid; on `expired` we route to the expired screen. +/// +/// Polling is paused while the app is backgrounded and resumed on foreground +/// (per the `WidgetsBindingObserver` pattern used elsewhere in the app). +class WaitingPaymentScreen extends ConsumerStatefulWidget { + final String paymentId; + const WaitingPaymentScreen({super.key, required this.paymentId}); + + @override + ConsumerState createState() => _WaitingPaymentScreenState(); +} + +class _WaitingPaymentScreenState extends ConsumerState + with WidgetsBindingObserver { + static const Duration _pollInterval = Duration(seconds: 3); + static const Duration _tickInterval = Duration(seconds: 1); + + Timer? _ticker; + Timer? _poller; + DateTime? _expiresAt; + int _amount = 0; + String? _qrPayload; + bool _initialLoading = true; + bool _terminal = false; + String? _error; + + Duration get _remaining { + final exp = _expiresAt; + if (exp == null) return Duration.zero; + final left = exp.difference(DateTime.now()); + return left.isNegative ? Duration.zero : left; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + Future.microtask(_loadInitial); + } + + @override + void dispose() { + _ticker?.cancel(); + _poller?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _resumePolling(); + } else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { + _poller?.cancel(); + _poller = null; + } + } + + Future _loadInitial() async { + final session = await _fetchSession(); + if (!mounted || session == null) return; + final expiresAtRaw = session['expires_at'] as String?; + final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw) : null; + setState(() { + _amount = (session['amount'] as int?) ?? 0; + _expiresAt = expiresAt; + _qrPayload = (session['qr_string'] as String?) ?? widget.paymentId; + _initialLoading = false; + }); + _maybeHandleStatus(session); + _startTicker(); + _resumePolling(); + } + + void _startTicker() { + _ticker?.cancel(); + _ticker = Timer.periodic(_tickInterval, (_) { + if (!mounted) return; + // Trigger a rebuild to refresh the countdown label. Status routing + // happens off the polled response, not the local clock — backend is + // the source of truth for `expired`. + setState(() {}); + }); + } + + void _resumePolling() { + if (_terminal) return; + _poller?.cancel(); + _poller = Timer.periodic(_pollInterval, (_) => _pollOnce()); + } + + Future _pollOnce() async { + final session = await _fetchSession(); + if (!mounted || session == null) return; + _maybeHandleStatus(session); + } + + Future?> _fetchSession() async { + try { + final api = ref.read(apiClientProvider); + final response = await api.get('/api/client/payment-sessions/${widget.paymentId}'); + return response['data'] as Map?; + } catch (e) { + if (!mounted) return null; + setState(() => _error = 'Gagal memeriksa status pembayaran.'); + return null; + } + } + + void _maybeHandleStatus(Map session) { + final status = session['status'] as String?; + if (status == PaymentSessionStatus.confirmed || + status == PaymentSessionStatus.consumed) { + _markTerminal(); + // TODO(stage4): route to `/onboarding/notif-gate` once Stage 4 lands. + // For now, drop the user back home — Stage 5 will pick the pairing flow up. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.go('/'); + }); + } else if (status == PaymentSessionStatus.expired || + status == PaymentSessionStatus.abandoned) { + _markTerminal(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.go('/payment/expired/${widget.paymentId}'); + }); + } + } + + void _markTerminal() { + _terminal = true; + _ticker?.cancel(); + _poller?.cancel(); + } + + String _countdownLabel() { + final r = _remaining; + final mm = r.inMinutes; + final ss = r.inSeconds % 60; + return '${mm.toString().padLeft(2, '0')}:${ss.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: true, + child: Scaffold( + backgroundColor: HaloTokens.bg, + appBar: AppBar( + backgroundColor: HaloTokens.bg, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.chevron_left, color: HaloTokens.brandDark), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/home'); + } + }, + ), + centerTitle: true, + title: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'kedaluwarsa dalam', + style: TextStyle(fontSize: 11, color: HaloTokens.inkMuted), + ), + Text( + _initialLoading ? '--:--' : _countdownLabel(), + style: const TextStyle( + fontFamily: HaloTokens.fontMono, + fontSize: 16, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + letterSpacing: -0.5, + ), + ), + ], + ), + ), + body: _initialLoading + ? const Center(child: CircularProgressIndicator()) + : _buildContent(), + ), + ); + } + + Widget _buildContent() { + final draft = ref.watch(paymentDraftNotifierProvider); + final amount = _amount > 0 ? _amount : (draft.priceIDR ?? 0); + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s8, + HaloSpacing.s24, + HaloSpacing.s16, + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(HaloSpacing.s24), + decoration: BoxDecoration( + color: HaloTokens.surface, + borderRadius: HaloRadius.xl, + border: Border.all(color: HaloTokens.border), + ), + child: Column( + children: [ + const Text( + 'scan QRIS untuk bayar', + style: TextStyle(fontSize: 12, color: HaloTokens.inkSoft), + ), + const SizedBox(height: HaloSpacing.s12), + Container( + padding: const EdgeInsets.all(HaloSpacing.s12), + decoration: BoxDecoration( + color: HaloTokens.surface, + borderRadius: HaloRadius.lg, + border: Border.all(color: HaloTokens.border), + ), + child: QrImageView( + data: _qrPayload ?? widget.paymentId, + size: 200, + version: QrVersions.auto, + backgroundColor: HaloTokens.surface, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: HaloTokens.ink, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: HaloTokens.ink, + ), + ), + ), + const SizedBox(height: HaloSpacing.s12), + const Text( + 'jumlah', + style: TextStyle(fontSize: 11.5, color: HaloTokens.inkSoft), + ), + Text( + formatRupiah(amount), + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 24, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + letterSpacing: -0.5, + ), + ), + ], + ), + ), + const SizedBox(height: HaloSpacing.s12), + Container( + padding: const EdgeInsets.all(HaloSpacing.s12), + decoration: BoxDecoration( + color: HaloTokens.brandSofter, + borderRadius: HaloRadius.md, + border: Border.all(color: HaloTokens.brandSoft), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: HaloTokens.brand, + ), + ), + const SizedBox(width: HaloSpacing.s8), + const Expanded( + child: Text( + 'menunggu pembayaran kamu...', + style: TextStyle( + fontSize: 12, + color: HaloTokens.brandDark, + ), + ), + ), + ], + ), + ), + if (_error != null) ...[ + const SizedBox(height: HaloSpacing.s8), + Text( + _error!, + style: const TextStyle(fontSize: 12, color: HaloTokens.danger), + ), + ], + ], + ), + ), + ), + ], + ); + } +} diff --git a/client_app/lib/features/payment/state/payment_draft_provider.dart b/client_app/lib/features/payment/state/payment_draft_provider.dart new file mode 100644 index 0000000..66a991a --- /dev/null +++ b/client_app/lib/features/payment/state/payment_draft_provider.dart @@ -0,0 +1,111 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'payment_draft_provider.g.dart'; + +/// Session mode the customer is paying for. Mirrors backend `SessionMode` +/// (chat | call). Phase 4 introduces `call` as a future option — pricing config +/// supplies the call tier list, but no functional voice feature is built yet. +enum PaymentMode { + chat('chat'), + call('call'); + + final String value; + const PaymentMode(this.value); + + static PaymentMode fromString(String? v) => + values.firstWhere((e) => e.value == v, orElse: () => PaymentMode.chat); +} + +/// Draft state shared across the multi-screen payment flow: +/// discount-paywall / method-pick / duration-pick / method / waiting / expired. +/// +/// The state is deliberately minimal — the *source of truth* for amount and +/// duration is always the backend (server-validated tier or first-session +/// discount). The draft only carries the customer's in-flight intent across +/// the screen graph. +class PaymentDraft { + final PaymentMode mode; + final String? durationId; + final int? durationMinutes; + final int? priceIDR; + final String? paymentId; + final bool isFirstSessionDiscount; + + const PaymentDraft({ + this.mode = PaymentMode.chat, + this.durationId, + this.durationMinutes, + this.priceIDR, + this.paymentId, + this.isFirstSessionDiscount = false, + }); + + PaymentDraft copyWith({ + PaymentMode? mode, + String? durationId, + int? durationMinutes, + int? priceIDR, + String? paymentId, + bool? isFirstSessionDiscount, + }) { + return PaymentDraft( + mode: mode ?? this.mode, + durationId: durationId ?? this.durationId, + durationMinutes: durationMinutes ?? this.durationMinutes, + priceIDR: priceIDR ?? this.priceIDR, + paymentId: paymentId ?? this.paymentId, + isFirstSessionDiscount: isFirstSessionDiscount ?? this.isFirstSessionDiscount, + ); + } +} + +@Riverpod(keepAlive: true) +class PaymentDraftNotifier extends _$PaymentDraftNotifier { + @override + PaymentDraft build() => const PaymentDraft(); + + void setMode(PaymentMode mode) { + // Switching mode resets the previously-picked tier — chat and call + // tier lists are independent. + state = state.copyWith( + mode: mode, + durationId: null, + durationMinutes: null, + priceIDR: null, + ); + } + + void setTier({ + required String durationId, + required int durationMinutes, + required int priceIDR, + }) { + state = state.copyWith( + durationId: durationId, + durationMinutes: durationMinutes, + priceIDR: priceIDR, + ); + } + + void setDiscountPlan({ + required int durationMinutes, + required int priceIDR, + }) { + state = state.copyWith( + mode: PaymentMode.chat, + durationMinutes: durationMinutes, + priceIDR: priceIDR, + isFirstSessionDiscount: true, + ); + } + + void setPaymentId(String paymentId) { + state = state.copyWith(paymentId: paymentId); + } + + /// Wipe the draft when entering the flow fresh (e.g. tapping "Mulai Curhat" + /// from home). Keeping it across back-nav inside the flow is the default. + void reset() { + state = const PaymentDraft(); + } +} diff --git a/client_app/lib/features/payment/state/payment_draft_provider.g.dart b/client_app/lib/features/payment/state/payment_draft_provider.g.dart new file mode 100644 index 0000000..395afe3 --- /dev/null +++ b/client_app/lib/features/payment/state/payment_draft_provider.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'payment_draft_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$paymentDraftNotifierHash() => + r'1c81b22f25f525cd290f54618bee0b69de792998'; + +/// See also [PaymentDraftNotifier]. +@ProviderFor(PaymentDraftNotifier) +final paymentDraftNotifierProvider = + NotifierProvider.internal( + PaymentDraftNotifier.new, + name: r'paymentDraftNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$paymentDraftNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$PaymentDraftNotifier = Notifier; +// 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 diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 3f5cb20..c1f2b7d 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -21,6 +21,13 @@ import 'features/chat/screens/chat_screen.dart'; import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart'; import 'features/payment/screens/payment_screen.dart'; +import 'features/payment/screens/payment_entry_screen.dart'; +import 'features/payment/screens/discount_paywall_screen.dart'; +import 'features/payment/screens/method_pick_screen.dart'; +import 'features/payment/screens/duration_pick_screen.dart'; +import 'features/payment/screens/payment_method_screen.dart'; +import 'features/payment/screens/waiting_payment_screen.dart'; +import 'features/payment/screens/payment_expired_screen.dart'; import 'core/theme/_preview.dart'; class RouterNotifier extends ChangeNotifier { @@ -150,10 +157,13 @@ GoRouter buildRouter(Ref ref) { ), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute(path: '/payment', builder: (context, state) { - // Payment screen reachable from + // Legacy Phase 3.7 single-screen payment. Still reachable from // - Home "Mulai Curhat" CTA → no extras (general blast follows confirm) // - Chat history "Curhat lagi" CTA → extras carry targetedMitraId/mitraName // for the returning-chat flow, plus optional topicSensitivity. + // Phase 4 Stage 3 introduces sibling routes under `/payment/*`; the new + // entry point is `/payment/entry`. This route is preserved until Stage 5 + // migrates the chat-history "Curhat lagi" flow. final extra = state.extra; if (extra is Map) { final topic = extra['topicSensitivity']; @@ -165,6 +175,24 @@ GoRouter buildRouter(Ref ref) { } return const PaymentScreen(); }), + // Phase 4 Stage 3 — multi-screen payment shell. + GoRoute(path: '/payment/entry', builder: (_, __) => const PaymentEntryScreen()), + GoRoute(path: '/payment/discount-paywall', builder: (_, __) => const DiscountPaywallScreen()), + GoRoute(path: '/payment/method-pick', builder: (_, __) => const MethodPickScreen()), + GoRoute(path: '/payment/duration-pick', builder: (_, __) => const DurationPickScreen()), + GoRoute(path: '/payment/method', builder: (_, __) => const PaymentMethodScreen()), + GoRoute( + path: '/payment/waiting/:paymentId', + builder: (context, state) => WaitingPaymentScreen( + paymentId: state.pathParameters['paymentId']!, + ), + ), + GoRoute( + path: '/payment/expired/:paymentId', + builder: (context, state) => PaymentExpiredScreen( + paymentId: state.pathParameters['paymentId']!, + ), + ), GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()), GoRoute(path: '/chat/found', builder: (context, state) { final extra = state.extra as Map; diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index 4e00018..8759b61 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -840,6 +840,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" record_use: dependency: transitive description: diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index e80e5a2..3019321 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -15,8 +15,8 @@ dependencies: firebase_core: ^3.12.1 firebase_messaging: ^15.2.5 - # Social login (kept — activated when OAuth creds arrive; buttons hidden behind - # ENABLE_SOCIAL_AUTH dart-define flag until then) + # Social login (kept — buttons gated server-side via /api/shared/auth-providers + # until the corresponding OAuth env vars are set on the backend) google_sign_in: ^6.2.1 sign_in_with_apple: ^6.1.0 @@ -38,6 +38,10 @@ dependencies: go_router: ^13.2.1 flutter_local_notifications: ^21.0.0 + # QR code rendering — used by the waiting-payment screen as a placeholder + # (mock mode encodes payment_session_id; real QR will come from Xendit later). + qr_flutter: ^4.1.0 + dev_dependencies: flutter_test: sdk: flutter