Files
halobestie-clone/requirement/phase4-customer-flow.md
Ramadhan Sjamsani e09f76ceb6 Phase 4 §4: payment-before-pair for returning users + Maestro suite
Stages 5.1, 5.3, 5.4 of the returning-user flow rework. All three §4
entry paths now require payment BEFORE pairing, matching the updated
mermaid spec.

* Spec (requirement/flow_customer.mermaid.md §4): payment block converges
  three call-sites (bestie-yang-udah-kenal-online, bestie-baru,
  offline-popup → cari bestie lain). PairRoute dispatches lama → targeted
  pair, baru/cari-lain → §3 blast. §3 retains its post-payment-shared
  contract.

* Stage 5.1 (client_app): PaymentDraft carries targetedMitraId +
  topicSensitivity. bestie_history_list seeds the draft + pushes
  /payment/entry (was legacy /payment). searching_screen branches on
  draft.targetedMitraId for blast-vs-targeted dispatch.
  payment_entry uses resetExceptTarget(); bestie_choice_sheet + home
  _onCurhatBestieBaruPressed call explicit reset() before push so
  the keepAlive draft can't leak stale targeting into a blast.

* Stage 5.3 (client_app): new BestieOfflineVariant.prePayReturning.
  Bestie-history-list _BestieRow splits tappable from dim so offline
  rows render dimmed but route taps into the popup. CTA "cari bestie
  lain" resets the draft + pushes /payment/entry.

* Stage 5.4 (client_app): deleted legacy /payment route,
  payment_screen.dart, payment_notifier.dart(+.g.dart). router cleaned.

* Tests (requirement/phase4-customer-flow.md + client_app/.maestro/):
  six Maestro flows TS-01..TS-06 covering every §4 branching point,
  all passing end-to-end. Shared onboarding prelude under
  .maestro/subflows/. New helper scripts: accept_latest_pending,
  force_mitra_offline, force_other_mitra_online,
  reset_all_mitras_online, mitra_accept_latest_internal. New backend
  _test endpoints to match. /reset-phone now cascade-deletes
  customer_transactions (FK was blocking). /force-pairing-timeout
  branches targeted (RETURNING_CHAT_TIMEOUT via
  expireTargetedPairingRequest, now exported) vs blast (PAIRING_FAILED).
  seed_history_session also outputs MITRA_NAME_RE (regex-escaped) for
  reliable selectors against display names containing regex specials.

* mitra_app: dispose-during-deactivate guardrail for back-press on the
  mitra chat screen after the customer's goodbye message. Pending real
  emulator repro verification (carried over from 2026-05-15).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:25:15 +08:00

40 KiB
Raw Blame History

PRD: Phase 4 — Customer Flow Redesign (Figma Alignment)

Source-of-truth flow: flow_customer.md (numbered list) + flow_customer.mermaid.md (cross-referenced diagrams). Visual design dump in requirement/Figma/ (git-ignored).

Overview

Goal: Bring the customer app in line with the new HaloBestie design package shipped in Figma.zip. Phase 13.7 already cover auth, pairing, chat, and the pay-before-blast loop, but the new design adds:

  • a verified-vs-anonymous decision sheet at onboarding,
  • ESP screening (multi-select emotional-state chips) and a dedicated USP screen before paywall,
  • a chat / voice-call mode toggle with separate pricing,
  • an explicit QRIS-first payment screen + 20-min waiting/expiry pages,
  • a notification gate screen + home banner variant,
  • the soft-prompt + 5-min searching timeout UX,
  • chat-room time warnings (3-min snackbar, last-2-min danger visuals, expired floating banner),
  • a 2-step "akhiri sesi" confirm + dedicated closing-message bottom sheet,
  • a proper S11 thank-you screen,
  • the bestie-choice / bestie-history / targeted-wait flow on returning users (the targeted-wait countdown overlay specifically),
  • secondary safety nets: OTP-blocked → fallback to anon, tanya admin (WA/Telegram) sheet.

Out of scope (defer):

  • Real Xendit integration — payment provider stays mocked through this phase (per memory Pricing Still Mocked Through Phase 3.7); only stub the UI for QRIS / payment-method / 20-min wait / expired so it's wired once payments go real. Pricing values themselves become configurable via CC in this phase (see §3 + §3.5).
  • Voice/video media session — voice-call mode in Phase 4 is just a chat session with a different price group + a "Voice Call" badge in the chat header. The mitra manually shares a Google Meet (or equivalent) link in the chat as a normal message; we do not validate, generate, or proxy the link. Real call media handling is a later phase.
  • Mitra-side composer helpers for Google Meet links (no "insert meet link" button or template) — left to ops convention; revisit if it becomes a friction point.
  • Mitra-side changes from Figma's BestieHome / BestieInvites / BestieChat mockups — those overlap with the existing mitra_app and don't change customer UX in this phase.

Affects: client_app (heavy), backend (additive endpoints), control_center (light: pricing-tier readonly view in case mock changes).


Background & Audit

The audit (see flow_customer.mermaid.md legend) classified every screen in the new flow as 🟢 EXISTS, 🟡 PARTIAL, or 🔴 MISSING. Numbers from the audit:

Group EXISTS PARTIAL MISSING
Onboarding & Auth 4 3 2
Payment 1 1 4
Pairing & Match 2 3 2
Chat session 1 2 3
Session-end 0 2 2
Returning user 1 1 3

This phase resolves all 🟡 PARTIAL and 🔴 MISSING items. EXISTS screens are only touched for visual alignment with the Figma palette + tokens.

Screen-by-screen detail (and the file path of the closest existing implementation, where applicable) lives in flow_customer.mermaid.md Section "Cross-reference: Figma → flow_customer.md" plus the audit at the bottom of this doc.


Functional Requirements

1. Verif vs. Anon decision (sheet)

Figma: screens/v4.jsx::VerifChoiceSheet · flow §5.1 (implied between Nama and Verif/Anon branches).

1.1

  • After S2 Nama finishes, show a bottom sheet with two CTAs:
    • "verif WA · Rp2.000" → routes to ESP → USP → S3a WA input → S3b OTP → S6 Paywall → QRIS payment.
    • "tanpa verif · mulai Rp5.000" → routes to ESP → USP → Pilih cara curhat → Pemilihan harga → Cara bayar.
  • Sheet is dismissable with no default — back-tap returns to S2 Nama (does not auto-pick).
  • This sheet is shown once per fresh install before first session; for users who already paid Rp2k once, the sheet is skipped and we go straight to "Pemilihan harga" (returning-user economics).

1.2 Backend

  • New: GET /api/client/onboarding-state → returns { has_paid_first_session: bool }. Used by client_app to pick whether to show this sheet.

2. ESP screening + USP screen

Figma: screens/onboarding.jsx::S5ESP and S5USP. Existing topic_selection_bottom_sheet.dart is binary-only and is replaced by ESP.

2.1 ESP (Emotional State Picker)

  • 12 chips (hubungan, keluarga, kerja/sekolah, diri sendiri, cemas, sedih, kesepian, bingung arah, kesel, capek banget, pengen ngomong, lainnya) — multi-select, ≥1 required to continue, skip CTA allowed.
  • Result is sent to backend as topics: string[] and stored on the chat session for informational purposes only — surfaced to the mitra at session start, surfaced in CC for analytics. Not used for matching. No mapping layer, no routing change. Topic-aware matching, if ever needed, is a later phase.

2.2 USP screen

  • Static, four-bullet trust hook: "manusia nyata", "hampir instan", "hampir 24 jam", "kayak sahabat". CTA "aku ngerti, lanjut →".
  • Route step-dot is current=2 of 4 (matches screens/onboarding.jsx::S5USP).

3. Pricing & duration picker (two independent groups)

Figma: screens/v3.jsx::SPickDuration + screens/v4.jsx::InitialDurationPicker.

3.1 Two price groups

Pricing has two independent tier lists, one per mode:

  • Chat — text-based session. Default tiers: 5/10/30/60/120 menit.
  • Voice call — same WebSocket chat, but mitra is expected to share a Google Meet link manually. Default tiers: 10/20/45/60 menit at premium prices.

Both lists are fully configurable via CC (no hard-coded multiplier). Each tier carries: id, minutes, price_idr, tag (optional, e.g. paling pas, hemat, best deal).

3.2 Mode toggle

  • Pill toggle at top of the duration picker: chat | call. Defaults to chat.
  • Toggling rebuilds the option list from the corresponding tier group.
  • For first-session-discount-eligible users (see §3.5), the call toggle is hidden unless ops enables call for first session via first_session_discount_modes config (default: ["chat"] only).

3.3 Picker UX

  • Each option renders price + tag. Selection persists across back-nav.
  • Bottom CTA reads bayar Rp{price} with the mode icon (💬 or 📞).

3.5 First-session discount (replaces "free trial")

Figma: screens/onboarding.jsx::S6Paywall (the "Rp2.000 untuk 12 menit" screen). There is no free trial in Phase 4 — the previous free-trial concept is replaced with a configurable first-session discount.

3.5.1 Eligibility

  • User is OTP-verified (came through the verified onboarding branch), and
  • User has no prior consumed session (no chat_sessions row with terminal success state for this customer_id).

Anonymous users never see the discount; verified users who already consulted once also do not. Eligibility is determined by the backend at the point of displaying the paywall — never trust client.

3.5.2 Configurable values (CC)

All four of these live in app_config, editable from CC:

Key Default Notes
first_session_discount_enabled true Master kill-switch
first_session_discount_actual_price_idr 2000 The user pays this
first_session_discount_gimmick_price_idr 12000 Struck-through "before" price
first_session_discount_duration_minutes 12 Session length
first_session_discount_modes ["chat"] Which modes the discount applies to (chat / call). For Phase 4 default chat-only.

3.5.3 UX

  • After Verif Choice → ESP → USP, eligible users land on the S6 paywall with the gimmick price struck through (Rp{gimmick_price}) and the actual price prominent (Rp{actual_price}). CTA: mulai · Rp{actual_price}.
  • Tapping CTA routes them to the existing payment flow (Cara bayar → Waiting → Paid) with mode='chat', duration_minutes=N, price_idr=actual, and is_first_session_discount=true flagged on the payment session.
  • Ineligible users (anon path or verified-but-already-consulted) skip S6 and go directly to the Pemilihan Harga picker.

3.5.4 Backend

  • payment_sessions schema gains a column is_first_session_discount BOOLEAN NOT NULL DEFAULT false (replaces is_free_trial). Migration drops is_free_trial (or aliases it during cutover — confirm in plan).
  • Pricing endpoint exposes the discount block (see §16 below).

4. Payment-method selection + waiting + expired

Figma: screens/extras.jsx::SPaymentMethod, SWaitingPayment, screens/v4.jsx::PaymentExpiredV4.

4.1 Method screen

  • QRIS marked "DIREKOMENDASIKAN" + 4 e-wallet options (gopay/ovo/dana/shopee). Total amount + duration label rendered above.
  • CTA: bayar Rp{amount} → calls existing payment-creation API (mock for now).

4.2 Waiting screen

  • Renders the QRIS code, amount, and a 20-minute countdown in JetBrains Mono styling (matches Figma SWaitingPayment).
  • Polls payment status every 3 seconds while foregrounded; FCM webhook can short-circuit. Stop polling on background; resume on foreground.

4.3 Expired screen

  • When countdown hits 0 with no paid event, replace screen with PaymentExpiredV4 content; CTA coba lagi returns to method-selection with the duration pre-selected.

4.4 Backend additions

  • POST /api/client/payments/{id}/status-poll (or reuse existing) returns one of pending | paid | expired. Mock provider flips to paid on a manual control-center button (already exists from phase 3.7) or after 20 min → expired.

5. Notification gate + home banner

Figma: screens/extras.jsx::SNotifGate (full screen) + screens/v3.jsx::HBNotifBanner (home banner).

5.1 Gate screen

  • Shown after payment succeeds and before searching (per flow_customer.md §5.1.5). If OS reports notifications already allowed, skip.
  • Two CTAs: izinkan notifikasi (triggers OS prompt; on grant → next; on deny → fall-through to next anyway) and nanti aja.
  • Implementation note: use existing FCM bootstrap; never re-prompt within the same install if user denied (OS rules).

5.2 Home banner

  • If notifications are denied and user is on Home, show a thin amber banner above the fold: "aktifkan notif biar kamu enggak ketinggalan" + button nyalain.
  • Banner is dismissable for the session but reappears on next cold start until the user enables notifications.

6. S7 Soft-prompt + searching + 5-min timeout

Figma: screens/session.jsx::S7Prompt (warmup) + screens/v3.jsx::SSearchPrompt (searching/timeout combined).

6.1 Warmup prompt

  • After Notif Gate, show a single screen with three sample reflective prompts
    • CTA aku ngerti, lanjut →. Tapping fires the actual blast.
  • Skip button is allowed.

6.2 Searching state

  • Reuse current searching_screen.dart flow but visually swap to the v3 pulsing-dots panel + brand colors.
  • Server-side timer is unchanged (5 min).

6.3 5-min timeout

  • When 5-min timer expires with no match:
    • Replace dots with 🌙 icon and copy "bestie lagi rame".
    • Add CTAs: primary coba cari lagi (re-fires blast); ghost kembali ke home (pops to Home).
  • Tracking: log pairing_timeout_v2 to existing analytics path.

7. Match-found visuals (S9)

  • Existing bestie_found_screen.dart is upgraded to render the v4 S9MatchV4 layout: orb + status dot + greeting copy halo, aku bestie {name} + CTA mulai sesi N menit → (where N is bound to the picked duration).
  • No backend change.

8. Chat-room countdown UX

Figma: screens/session.jsx::S10Chat low-time branch + screens/v3.jsx::HBSnackbar + HBChatExpiredBanner.

8.1 3-minutes-left snackbar

  • Backend already emits a session_warning event at 3 min remaining (Phase 3 — verify in backend/src/services/session-timer.service.js); if absent, add it.
  • Client renders a snackbar with copy "sisa 3 menit lagi ya 🤍". Auto-dismiss after 4 s. Only fired once per session.

8.2 Last-2-minutes visuals

  • When seconds_left ≤ 120, the timer pill in the chat header switches to danger color (#FFE8D9 background, #A8410E text) and the progress bar at the bottom of the header turns orange.
  • This is a pure UI flip on a value the client already has.

8.3 Expired floating banner

  • When seconds_left == 0 and the session is in a closing-grace window (existing concept from Phase 3 session-end overhaul, see memory "Phase 3 Session-End Overhaul"), inject a floating banner above the input bar: "habis nih... mau lanjutin curhat sama {name}?" + inline CTA perpanjang.
  • Tapping perpanjang opens the Time-up Bottom Sheet (next section).

9. Time-up bottom sheet (extension)

Figma: screens/extras.jsx::STimeUpSheet.

9.1

  • Replace the current pricing_bottom_sheet.dart with the 5-option time-up sheet that includes the chat / call toggle (with strike-through chat price next to call price for transparency).
  • CTA copy: perpanjang · Rp{price} (primary) and cukup, akhiri sesi (secondary, opens 2-step end-of-session flow).

9.2 Targeted re-pay

  • Tapping perpanjang reuses the current Phase 3.7 extension API (no blast, same mitra) — this part is already shipped. Only the sheet UI changes.

10. End-of-session 2-step confirm + closing message + S11

Figma: screens/v3.jsx::HBConfirmEndPopup (steps 1 + 2) + screens/extras.jsx::SClosingSheet + screens/session.jsx::S11Post.

10.1 Step-1 popup

  • "beneran udah cukup?" + body. Primary lanjut akhiri, secondary gak jadi, balik → returns to Time-up sheet.

10.2 Step-2 popup

  • Only fires after step 1's lanjut akhiri.
  • "mau tinggalin pesan penutup?" — primary tulis pesan penutup opens the closing-message bottom sheet; secondary lewati saja skips to S11.

10.3 Closing message sheet

  • Textarea + two CTAs: primary kirim & akhiri sesi (sends a final WS message flagged is_closing=true, then closes session), secondary lewat — langsung akhiri (closes without sending).

10.4 S11 thank-you

  • New screen rendered after the WS close ack: 🌷 emoji + "terima kasih udah cerita" + CTA balik ke home.
  • This replaces the current "jump straight to Home" behaviour.

10.5 Mitra rejects close (rare)

  • If the mitra-side returns a 409 on the close-with-message request (e.g. mitra already closed it), surface the existing Bestie Offline Popup (returning variant). No new asset.

11. Returning-user pairing (bestie lama / bestie baru)

Figma: screens/v4.jsx::BestieChoiceSheet, BestieHistoryList, BestieOfflinePopup + screens/extras.jsx::SWaitingBestie.

11.1 Choice sheet

  • The home CTA curhat sama bestie baru opens the Bestie Choice Sheet (not the searching screen directly).
  • Two CTAs: bestie yang udah kenal → Bestie History List; bestie baru → S7 soft-prompt + blast.

11.2 History list

  • Reuse existing chat_history_screen.dart data + visual upgrade to the Figma layout: orb + bestie name + last-session date + topic + sessions count
    • ONLINE pill (if currently online).

11.3 Targeted-wait overlay

  • Tapping a bestie in the list:
    • Online → calls existing targeted-pair API (Phase 3.7) and opens the SWaitingBestie overlay with the 20s mitra-approval countdown that Phase 3.7 already gives us via WS but doesn't render. Three sub-states rendered (waiting, accepted, declined).
    • Offline → opens BestieOfflinePopup (returning variant). Two CTAs: cari bestie lain → blast flow; tanya admin → Tanya Admin sheet.

12. Tanya admin sheet

Figma: screens/v3.jsx::HBContactAdminSheet.

  • WhatsApp + Telegram options.
  • Numbers/handles configured in control_center (new readonly tiles), pulled by GET /api/client/support-handles (or hard-coded constants for v1 — pick hard-coded for first ship to keep it light).

13. OTP-blocked popup

Figma: screens/v4.jsx::OTPBlockedPopup. Not in flow_customer.md but matches the existing OTP rate-limit followups (memory: OTP Rate Limit Followups).

  • When OTP attempts are exhausted (existing 429 from backend/src/services/otp.service.js), show the popup instead of an inline error.
  • Primary CTA lanjut tanpa verif jumps the user to the anonymous branch (Pilih cara → Pemilihan harga → Cara bayar) without re-asking for ESP/USP — the values entered earlier in the same OTP attempt are carried over.
  • Secondary CTA hubungi admin opens Tanya Admin sheet.

14. Auth Provider Auto-detection (server-driven)

Replaces the existing --dart-define=ENABLE_SOCIAL_AUTH build flag (per memory client_app/CLAUDE.md). Backend now decides; client_app reads.

14.1 Backend probes env at boot

  • backend/src/services/auth-providers.service.js (new) reads on cold start:
    • Google OAuth → enabled if both GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET are set and non-empty.
    • Apple OAuth → enabled if all required Apple keys (APPLE_OAUTH_CLIENT_ID, APPLE_OAUTH_TEAM_ID, APPLE_OAUTH_KEY_ID, APPLE_OAUTH_PRIVATE_KEY) are set and non-empty.
    • Phone OTP → always enabled (existing).
  • The result is cached in memory; no DB hit on the read endpoint.

14.2 New endpoint

  • GET /api/shared/auth-providers
    { "google":   { "enabled": false },
      "apple":    { "enabled": false },
      "phone":    { "enabled": true } }
    
  • Public, no auth header required.

14.3 client_app integration

  • A new authProvidersProvider (Riverpod) fetches once on app start + caches.
  • Welcome / login / register screens hide the Google/Apple buttons whose flag is false. If both social providers are off, only the phone-OTP CTA is shown.
  • The --dart-define=ENABLE_SOCIAL_AUTH flag is removed. Update social_auth_enabled.dart to read the provider flags instead, or delete it and inline the check.
  • Memory entry client_app/CLAUDE.md will be updated by the implementer to remove the build-flag reference.

14.4 mitra_app

  • mitra_app remains OTP-only (no change to its login screen). The endpoint exists in /api/shared/ but mitra_app doesn't need to call it.

15. Voice-call mode indicator on chat

Voice-call sessions use the same WebSocket chat infrastructure as regular chat. The only differences are price group, header badge, and the ops convention that the mitra shares a Google Meet link as a normal chat message. No call media handling. No link validation.

15.1 Schema

  • payment_sessions gains a mode TEXT NOT NULL DEFAULT 'chat' CHECK (mode IN ('chat', 'call')).
  • chat_sessions reads mode via the existing payment_session_id FK (added in Phase 3.7); no new column on chat_sessions.

15.2 Customer chat header

  • When chat_sessions.mode == 'call', render a 📞 Voice Call pill next to the bestie name in the header (replacing or appended to the existing status row).
  • When 'chat', render 💬 Chat (or render nothing — design choice).
  • All other chat UI is unchanged.

15.3 Mitra chat header

  • Same badge logic on the mitra-side chat screen. No composer helper for Meet links — mitra types/pastes the URL as a normal message.
  • Both apps already render URLs in chat messages as tap-to-open. Confirm Google Meet links (meet.google.com/...) launch the OS handler / app.
  • No special "join call" badge on the message bubble — it's just a URL.

15.5 Out of scope for Phase 4

  • Generating the Meet link server-side.
  • Validating/checking the link before send.
  • Rich preview cards for Meet links.
  • Audio/video session inside the app.

Backend Surface Changes (additive)

Endpoint Status Purpose
GET /api/client/onboarding-state new drives Verif-vs-Anon sheet visibility + first-session-discount eligibility
GET /api/client/chat-pricing rewrite returns chat + call tier groups + first-session-discount block (replaces mode_multiplier design)
GET /api/client/support-handles new WA/Telegram pulled from CC config
GET /api/shared/auth-providers new per-provider enabled flags; replaces ENABLE_SOCIAL_AUTH build flag
POST /api/client/payments/{id}/status-poll clarify already mock; document polling contract
WS event session_warning confirm 3-min remaining ping (verify already emitted)

Schema changes (one migration):

  • payment_sessions.is_free_trial BOOLEAN → drop and replace with is_first_session_discount BOOLEAN NOT NULL DEFAULT false.
  • payment_sessions.mode TEXT NOT NULL DEFAULT 'chat' CHECK (mode IN ('chat','call')) (new column).
  • chat_sessions.topics TEXT[] NULL (new column — stores ESP picks for info display).

New app_config rows (seeded):

  • first_session_discount_enabled = true
  • first_session_discount_actual_price_idr = 2000
  • first_session_discount_gimmick_price_idr = 12000
  • first_session_discount_duration_minutes = 12
  • first_session_discount_modes = ["chat"]
  • pricing_chat_tiers_json = JSON array of chat tiers
  • pricing_call_tiers_json = JSON array of call tiers
  • support_handles_json = WA + Telegram deeplinks
  • searching_timeout_minutes = 5
  • end_session_two_step_confirm = true
  • three_minute_warning_enabled = true

UX Guards (carry over from Figma README)

These are non-negotiable per Figma/handoff/README.md:

  • Two-step akhiri sesi confirmation. Single tap = accident.
  • Payment confirmation popup before redirect to the (mock) Xendit checkout.
  • 5-minute timeout on bestie search before suggesting alternatives.
  • OTP input never auto-advances on paste — user must verify before submit.
  • All taps ≥ 44×44.
  • Color is never the only signal (timer also bolds; snackbar also iconified).
  • Lowercase Bahasa Indonesia copy by design — apps must text-transform: none.
  • Out-of-scope: rating system on S11, mid-chat "akhiri sesi" button, "darurat / SOS" button, subscription tiers.

Acceptance Criteria

A real-device run on emulator + physical phone passes all of these:

  1. Fresh install: S1 → Home (no JWT) → CTA → S2 Nama → Verif Choice Sheet → both branches reach Notif Gate.
  2. Verified branch (first time): ESP (≥1 chip) → USP → S3a → S3b → S6 Discounted Paywall showing struck-through Rp{gimmick} and prominent Rp{actual} → Cara Bayar → Waiting (20-min clock visible) → Paid → Notif Gate.
  3. Verified branch (already consulted): same path but skips S6 and goes straight to Pemilihan Harga.
  4. Anonymous branch: ESP → USP → Pilih cara curhat → Pemilihan Harga (chat tier list) → toggle to call → tier list rebuilds with call prices → Cara Bayar.
  5. OTP exhausted: blocked popup appears; tapping "lanjut tanpa verif" reaches Pemilihan Harga with previously-picked ESP/USP carried over (no re-prompt).
  6. Payment expired: 20-min timer hits 0 → expired screen → retry returns to method.
  7. Notif denied: full-screen gate's "nanti aja" closes; Home shows the amber banner; reopening Home after grant clears the banner.
  8. Soft-prompt → blast → 5-min timeout: timeout state shows the two CTAs and their behaviors are correct.
  9. Match found: S9 renders bestie name + duration in CTA copy.
  10. Chat (mode=chat): header shows 💬 Chat (or no badge); at 3 min remaining, snackbar fires once; at 2 min remaining, timer pill turns danger; at 0, expired banner appears with perpanjang CTA.
  11. Chat (mode=call): header shows 📞 Voice Call badge. Mitra pastes a meet.google.com URL → renders as a tappable link → tap launches the OS handler.
  12. Perpanjang: time-up sheet renders with chat/call toggle. Toggling rebuilds prices from the corresponding group.
  13. Akhiri: 2-step popup → closing message sheet → S11 thank-you → Home.
  14. Returning user: Home CTA opens Bestie Choice Sheet. bestie yang udah kenal → list → online → SWaitingBestie 20s overlay → match.
  15. Returning, offline: bestie offline popup shown; tanya admin opens WA/Tele sheet.
  16. Returning, mitra rejects: surfaces same offline popup with "cari bestie lain" CTA.
  17. Auth providers: with no Google/Apple env vars set, Welcome screen shows only the phone-OTP CTA. Setting GOOGLE_OAUTH_CLIENT_ID/SECRET and restarting backend → Google button appears on next app launch (after authProvidersProvider refresh).
  18. CC config knobs: an ops user toggling first_session_discount_enabled=false in CC → next eligible user no longer sees S6, goes to Pemilihan Harga.

Test Plan

  • Backend: Vitest cases for any new endpoints (per memory Test Infrastructure).
  • client_app: Maestro flows added under client_app/.maestro/flows/:
    • 02_onboarding_verified.yaml
    • 03_onboarding_anon.yaml
    • 04_payment_expired.yaml
    • 05_searching_timeout.yaml
    • 06_chat_countdown.yaml
    • 07_end_session_2step.yaml
    • 08_returning_targeted.yaml
  • control_center: Playwright smoke that the existing pricing-tier admin view still works (no UI change but extended response shape).

OTP smoke (existing 01_smoke.yaml) must keep passing — uses the dev-only /internal/_test/peek-otp + /reset-phone endpoints (memory: OTP Test Infrastructure).


Audit Snapshot (frozen 2026-05-09)

Source: a one-shot audit at the time this PRD was written. File paths are relative to client_app/. Re-run before kickoff.

Onboarding & Auth

  • 🟢 S1 Splash · lib/features/splash/splash_screen.dart
  • 🟡 Home (1st time) — needs notif-banner variant · lib/features/home/home_screen.dart
  • 🟢 Home (returning) · lib/features/home/home_screen.dart
  • 🟢 S2 Nama · lib/features/auth/screens/display_name_screen.dart
  • 🟡 ESP — currently binary, needs multi-select · lib/features/chat/widgets/topic_selection_bottom_sheet.dart
  • 🔴 S5b USP screen
  • 🔴 Verif-vs-Anon Choice Sheet
  • 🟡 S3a/S3b — currently 6-digit, needs 4-digit per Figma · lib/features/auth/screens/{register,otp}_screen.dart
  • 🔴 OTP-blocked popup → fallback to anon

Payment

  • 🟡 S6 Rp2k Paywall — exists as generic pricing; copy/visuals diverge · lib/features/payment/screens/payment_screen.dart
  • 🟡 Pemilihan Harga — exists, missing call-mode toggle
  • 🔴 Pilih cara curhat (chat / call)
  • 🔴 Cara bayar (QRIS-first method picker)
  • 🔴 Waiting Payment (20-min QRIS)
  • 🔴 Pembayaran expired

Pairing & Match

  • 🔴 Notif Gate (full screen)
  • 🔴 Home notif banner
  • 🟡 S7 Soft-prompt + Searching · lib/features/chat/screens/searching_screen.dart
  • 🔴 S7 Timeout 5 menit (CTAs)
  • 🟡 S9 Match · lib/features/chat/screens/bestie_found_screen.dart

Chat Session

  • 🟡 S10 Chat · lib/features/chat/screens/chat_screen.dart (no countdown UX)
  • 🔴 3-min snackbar
  • 🔴 Last-2-min danger visuals
  • 🔴 Floating expired banner

Session-end

  • 🟡 Confirm-akhiri popup (only step 1 today)
  • 🔴 Confirm-akhiri popup step 2
  • 🟡 Closing message — exists in goodbye composer, not as bottom sheet
  • 🔴 S11 thank-you screen

Returning user

  • 🟢 Bestie history · lib/features/chat/screens/chat_history_screen.dart
  • 🔴 Bestie Choice Sheet
  • 🟡 Targeted-wait overlay (data exists in pairing_notifier.dart, no UI)
  • 🟢 Bestie Offline Popup (returning) · lib/features/chat/widgets/bestie_unavailable_dialog.dart
  • 🔴 Tanya Admin sheet (WA/Telegram)

Implementation Order (suggested)

  1. Backend stubs first: onboarding-state, pricing mode_multiplier, confirm session_warning 3-min ping, support-handles (or constants).
  2. Onboarding shell: Verif Choice Sheet + ESP (multi-select) + USP. Cuts over the existing topic-selection sheet without breaking pairing.
  3. Payment shell: Pilih cara → Pemilihan harga (with toggle) → Cara bayar → Waiting → Expired. Wire to existing mock payment API.
  4. Notif gate + home banner.
  5. Searching upgrades: soft-prompt + 5-min timeout state.
  6. Chat countdown UX: 3-min snackbar, last-2-min danger, expired banner.
  7. Session-end: 2-step popups + closing-message sheet + S11.
  8. Returning user: Choice Sheet + targeted-wait overlay + Tanya Admin.
  9. Edge cases: OTP-blocked popup; mitra rejects close.
  10. Maestro coverage + visual regression sweep.

Each block is shippable independently — they share no breaking schema change.


Test Scenarios

Manual reproduction checklists for Phase 4 customer flows. Tick boxes as verified. Cluster tag [C] = client_app, [BE] = backend setup.

Coverage map — these scenarios collectively exercise every branching point in §4 of flow_customer.mermaid.md:

Branching point Scenario(s)
Choice: bestie yang udah kenal vs bestie baru TS-01/02/03/05/06 vs TS-04
CheckOnline: yes vs no (pre-pay) TS-01/05/06 vs TS-02/03
OfflinePopup (pre-pay): cari bestie lain vs tanya admin TS-02 vs TS-03
PayStat: paid vs timeout 20 min TS-01/02/04/06 vs TS-05
PairRoute: lama (Targeted) vs baru / cari lain (BlastFlow) TS-01/05/06 vs TS-02/04
TargetedRes: accept vs reject/timeout TS-01/05 vs TS-06

TS-01 — Returning user re-pays an online bestie (lama happy path)

Flow: §4 Choice → "bestie yang udah kenal" → CheckOnline (yes) → PickMethod → … → paid → PairRoute (lama) → Targeted → accept → S10

Affects: client_app, backend.

Goal: Confirm the returning-user flow gates on payment and routes the same picked mitra through targeted pairing (not blast).

Pre-reqs

  • [BE] Backend reachable; test mitra signed in + online (renders ONLINE pill in history list).
  • [BE] Free-trial config OFF for the test customer (otherwise the paywall path replaces the QRIS flow).
  • [C] client_app pointed at local backend (--dart-define=API_BASE_URL=http://192.168.88.247:3000); test customer has at least one closed session with the test mitra so they appear in bestie history.

Steps

  1. [C] From home (returning state), tap curhat sama bestie baru → Bestie Choice Sheet appears.
  2. [C] Tap bestie yang udah kenal → bestie history list opens; the test mitra row shows ONLINE pill (not dimmed).
  3. [C] Tap the test mitra row → app navigates to /payment/entry (PickMethod). The legacy /payment route is no longer reachable as of Stage 5.4.
  4. [C] Pick chat (or voice call) → PickDuration.
  5. [C] Pick any tier (e.g. 5 Menit) → /payment/method (the "cara bayar" screen).
  6. [C] Pick a payment method (e.g. QRIS) → tap Bayar/payment/waiting (20-min QRIS countdown).
  7. [BE] Manually confirm the payment via POST /api/client/payment-sessions/:id/confirm (or use the mock helper script).
  8. [C] App auto-advances through notif-gate and lands on /chat/waiting-targeted/<mitraId> ("Menunggu bestie tertentu" with 20s overlay).
  9. [mitra_app] Accept the incoming targeted request.
  10. [C] Customer lands on /chat/session/:id (S10 Chat Room) — WS open, session timer running.

Expected result

  • [BE] payment_sessions row has targeted_mitra_id = <test mitra id> and status = 'confirmed'.
  • [BE] chat_sessions row created with the same mitra_id; no blast log entries.
  • [C] Chat opens against the original mitra; no fallback to /chat/searching.

Notes / known gaps

  • Maestro flow client_app/.maestro/flows/10_returning_repays.yaml was written against the pre-Stage-5.1 screen graph and needs a rewrite — its selectors target the deleted legacy /payment screen (Chat lagi dengan <mitraName> app-bar title, MENUNGGU JAWABAN intermediate). When automating, rewrite this flow to walk the new multi-screen path described above.
  • Stage 5.4 (2026-05-18) deleted the legacy /payment route + payment_screen.dart. Any selector still expecting the legacy app-bar title is stale.

TS-02 — Returning user picks offline bestie, "cari bestie lain" → blast

Flow: §4 Choice → "bestie yang udah kenal" → CheckOnline (no) → OfflinePopup (pre-pay) → "cari bestie lain" → PickMethod → … → paid → PairRoute (baru) → BlastFlow → S10

Affects: client_app, backend.

Goal: Verify the BestieOfflineVariant.prePayReturning popup fires when the picked bestie is offline pre-payment, and that "cari bestie lain" routes through a fresh blast-payment flow with the targeted intent cleared.

Pre-reqs

  • [BE] Test mitra from customer's history is offline (signed out or heartbeat expired — row shows no ONLINE pill in history list).
  • [BE] At least one OTHER mitra is online (so the blast can match).
  • [BE] Free-trial OFF.

Steps

  1. [C] From home, tap curhat sama bestie baru → Bestie Choice Sheet.
  2. [C] Tap bestie yang udah kenal → history list; the test mitra row is dimmed (offline styling preserved as of Stage 5.3).
  3. [C] Tap the dimmed row → BestieOfflinePopup (prePayReturning variant) appears showing the mitra's name. Two CTAs: cari bestie lain and tanya admin.
  4. [C] Tap cari bestie lain → popup closes; app navigates to /payment/entry. Payment draft has been reset() (no stale targetedMitraId).
  5. [C] Walk PickMethod → PickDuration → PayMethod → Pay → /payment/waiting.
  6. [BE] Manually confirm payment.
  7. [C] App routes to /chat/searching (NOT /chat/waiting-targeted/...).
  8. [mitra_app] A different online mitra receives the blast and accepts.
  9. [C] Customer lands on /chat/session/:id with the new mitra.

Expected result

  • [BE] payment_sessions row has targeted_mitra_id IS NULL (draft was reset before push to /payment/entry).
  • [C] Chat opens with the fallback mitra, not the original offline one.

TS-03 — Returning user picks offline bestie, "tanya admin" (escape)

Flow: §4 Choice → "bestie yang udah kenal" → CheckOnline (no) → OfflinePopup (pre-pay) → "tanya admin" → AdminSheet (terminal)

Affects: client_app.

Goal: Confirm the escape hatch — the user can leave the offline-popup flow without paying by tapping "tanya admin", and no payment row is created.

Pre-reqs

  • Same as TS-02 (offline test mitra in customer's history).

Steps

  1. [C] Reach the BestieOfflinePopup (prePayReturning variant) via TS-02 steps 1-3.
  2. [C] Tap tanya admin → popup closes; admin sheet opens with WhatsApp / Telegram contact options.
  3. [C] Dismiss the admin sheet → user returns to the bestie history list.

Expected result

  • [BE] No new payment_sessions row created during this scenario.
  • [C] Payment draft state unchanged (no targetedMitraId, no paymentId). User can re-enter the flow normally afterward.

TS-04 — Returning user picks "bestie baru" → blast happy path

Flow: §4 Choice → "bestie baru" → PickMethod → … → paid → PairRoute (baru) → BlastFlow → S10

Affects: client_app, backend.

Goal: Confirm the "bestie baru" branch routes through payment FIRST, then blasts to all online mitras (no targeting).

Pre-reqs

  • [BE] At least one online mitra (for blast match).
  • [BE] Free-trial OFF.
  • [C] Returning customer (has session history → Bestie Choice Sheet renders both options).

Steps

  1. [C] From home, tap curhat sama bestie baru → Bestie Choice Sheet.
  2. [C] Tap bestie baru → app navigates to /payment/entry. Draft is explicitly reset() on this branch (clears any stale targetedMitraId per Stage 5.1 Risk #4 mitigation).
  3. [C] Walk PickMethod → PickDuration → PayMethod → Pay → /payment/waiting.
  4. [BE] Confirm payment.
  5. [C] App routes to /chat/searching (NOT /chat/waiting-targeted/...).
  6. [mitra_app] An online mitra accepts the blast.
  7. [C] Customer lands on /chat/session/:id.

Expected result

  • [BE] payment_sessions row has targeted_mitra_id IS NULL.
  • [C] Searching screen shows briefly; chat opens against whichever mitra accepted.

TS-05 — QRIS payment expired → retry preserves targeting

Flow: §4 PickMethod → … → WaitPay → PayStat (timeout 20 min) → PayExpired → Pay (retry) → paid → PairRoute (lama) → Targeted → S10

Affects: client_app, backend.

Goal: Verify the QRIS 20-min expired retry path works for a returning targeted attempt. The targetedMitraId on the draft must survive the retry (no need to re-pick mitra or duration) — this is the resetExceptTarget invariant from Stage 5.1.

Pre-reqs

  • [BE] Backend reachable; online test mitra (from customer's history).
  • [BE] Either the sweeper marks pending → expired after 20 min, or the test uses a shortened TTL / direct UPDATE to force expiry.

Steps

  1. [C] Walk TS-01 steps 1-6 to reach /payment/waiting for a targeted attempt against the test mitra.
  2. [BE] Wait for or force the pending → expired transition on the payment row.
  3. [C] Polling sees status = 'expired' → app routes to /payment/expired/:paymentId.
  4. [C] Tap the retry CTA → app routes back to /payment/method (NOT all the way to PickMethod; draft preserved via resetExceptTarget).
  5. [C] Re-pick payment method → tap Bayar → new /payment/waiting.
  6. [BE] Confirm the new payment.
  7. [C] App routes to /chat/waiting-targeted/<mitraId> for the same mitra as step 1 (no re-pick required).

Expected result

  • [BE] Original payment_sessions row has status = 'expired'. New row created with status = 'confirmed'. Both rows have the same targeted_mitra_id.
  • [C] Targeted intent survives retry; chat opens with the original picked mitra.

Variant note: the same retry path applies to the blast branch (TS-02 / TS-04) — draft has targetedMitraId IS NULL throughout, retry routes back to /payment/method, blast fires after re-confirm. Worth a quick sanity check if behavior diverges.


TS-06 — Targeted request fails post-payment → fallback to blast

Flow: §4 Targeted → TargetedRes (reject / timeout) → OfflinePopup (post-pay, returning variant) → "cari bestie lain" → fallback-to-blast → §3 BlastFlow → S10

Affects: client_app, backend.

Goal: Verify the post-payment fallback path. After paying for a targeted pair, if the picked mitra rejects or doesn't answer within 20s, the customer can fall back to blast WITHOUT a second payment.

Pre-reqs

  • [BE] Online test mitra (from history) AND at least one OTHER online mitra (for the blast fallback).

Steps

  1. [C] Walk TS-01 steps 1-8 to reach /chat/waiting-targeted/<mitraId>.
  2. [mitra_app] Reject the incoming targeted request (or do nothing for the 20s countdown).
  3. [C] Targeted-waiting screen detects the failure → BestieOfflinePopup (returning variant, post-pay) appears with canFallbackToBlast: true. CTAs: cari bestie lain and tanya admin.
  4. [C] Tap cari bestie lain → app calls POST /api/client/chat/chat-requests/:paymentSessionId/fallback-to-blast → routes to /chat/searching.
  5. [mitra_app] A DIFFERENT online mitra accepts the blast.
  6. [C] Customer lands on /chat/session/:id with the new mitra.

Expected result

  • [BE] Same payment_sessions row is reused (still status = 'confirmed'); customer is not charged a second time.
  • [BE] chat_sessions row created with the fallback mitra (NOT the original targeted_mitra_id).
  • [C] Chat opens with the fallback mitra; no fresh payment screens shown.

Variant note: the "tanya admin" CTA on this same popup is a terminal escape (same shape as TS-03), but post-payment — the customer has already paid, so this is effectively abandoning a paid session. Worth confirming the UX (probably a confirmation prompt) and whether the payment is refunded / converted to credit.