Anonymous customers now see a brand-gradient "Simpan Nomor HP" panel above the user card on the kamu tab, ported from the Figma SProfile save-phone banner. Tapping it pushes /auth/register?from=profile, which hides the "lanjut tanpa verifikasi (harga normal)" link — a user who re-entered the verif funnel from Profile shouldn't be re-offered the anon exit. Spec §1.3 added documenting the ?from= entry-point convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
44 KiB
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 inrequirement/Figma/(git-ignored).
Overview
Goal: Bring the customer app in line with the new HaloBestie design package
shipped in Figma.zip. Phase 1–3.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 / BestieChatmockups — 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.
1.3 Profile re-prompt for anon users (shipped 2026-05-22)
Figma:
screens/extras.jsx::SProfilesave-phone banner (lines 170-204). Code:client_app/lib/features/profile/profile_screen.dart::_SavePhoneBanner.
For users who chose the anon path in §1 and later open the kamu (Profile) tab, a brand-gradient banner appears between the page title and the user card. Body copy: "Biar riwayat curhat kamu tersimpan, yuk simpan Nomor Handphone kamu…". CTA Simpan Nomor HP pushes /auth/register?from=profile.
- Banner gates on
authData is AuthAnonymousData— disappears the moment the auth state flips toAuthAuthenticatedDataafter a successful OTP verify. - The
?from=profilequery param causesRegisterScreento omit the "lanjut tanpa verifikasi (harga normal)" escape-hatch link. A user who tapped the banner deliberately re-entered the verif funnel, so we don't re-offer the anon exit. - Convention for future entry points: when pushing into
/auth/registerfrom somewhere that should branch the screen (copy, escape hatch, post-OTP destination), pass?from=<callsite-slug>and readGoRouterState.of(context).uri.queryParameters['from']inRegisterScreen.build(). The router's redirect preserves query params for anonymous users on/auth/*routes.
2. ESP screening + USP screen
Figma:
screens/onboarding.jsx::S5ESPandS5USP. Existingtopic_selection_bottom_sheet.dartis 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=2of 4 (matchesscreens/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 menitat 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 tochat. - 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_modesconfig (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_sessionsrow with terminal success state for thiscustomer_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, andis_first_session_discount=trueflagged 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_sessionsschema gains a columnis_first_session_discount BOOLEAN NOT NULL DEFAULT false(replacesis_free_trial). Migration dropsis_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 Monostyling (matches FigmaSWaitingPayment). - 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
paidevent, replace screen withPaymentExpiredV4content; CTAcoba lagireturns to method-selection with the duration pre-selected.
4.4 Backend additions
POST /api/client/payments/{id}/status-poll(or reuse existing) returns one ofpending | paid | expired. Mock provider flips topaidon 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) andnanti 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.
- CTA
- Skip button is allowed.
6.2 Searching state
- Reuse current
searching_screen.dartflow 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); ghostkembali ke home(pops to Home).
- Replace dots with
- Tracking: log
pairing_timeout_v2to existing analytics path.
7. Match-found visuals (S9)
- Existing
bestie_found_screen.dartis upgraded to render the v4S9MatchV4layout: orb + status dot + greeting copyhalo, aku bestie {name}+ CTAmulai sesi N menit →(whereNis bound to the picked duration). - No backend change.
8. Chat-room countdown UX
Figma:
screens/session.jsx::S10Chatlow-time branch +screens/v3.jsx::HBSnackbar+HBChatExpiredBanner.
8.1 3-minutes-left snackbar
- Backend already emits a
session_warningevent at 3 min remaining (Phase 3 — verify inbackend/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 (#FFE8D9background,#A8410Etext) 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 == 0and 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 CTAperpanjang. - Tapping
perpanjangopens 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.dartwith 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) andcukup, akhiri sesi(secondary, opens 2-step end-of-session flow).
9.2 Targeted re-pay
- Tapping
perpanjangreuses 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, secondarygak 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 penutupopens the closing-message bottom sheet; secondarylewati sajaskips to S11.
10.3 Closing message sheet
- Textarea + two CTAs: primary
kirim & akhiri sesi(sends a final WS message flaggedis_closing=true, then closes session), secondarylewat — 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 baruopens 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.dartdata + 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
SWaitingBestieoverlay 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.
- Online → calls existing targeted-pair API (Phase 3.7) and opens the
12. Tanya admin sheet
Figma:
screens/v3.jsx::HBContactAdminSheet.
- WhatsApp + Telegram options.
- Numbers/handles configured in
control_center(new readonly tiles), pulled byGET /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 verifjumps 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 adminopens Tanya Admin sheet.
14. Auth Provider Auto-detection (server-driven)
Replaces the existing
--dart-define=ENABLE_SOCIAL_AUTHbuild flag (per memoryclient_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_IDandGOOGLE_OAUTH_CLIENT_SECRETare 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).
- Google OAuth → enabled if both
- 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_AUTHflag is removed. Updatesocial_auth_enabled.dartto read the provider flags instead, or delete it and inline the check. - Memory entry
client_app/CLAUDE.mdwill 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_sessionsgains amode TEXT NOT NULL DEFAULT 'chat' CHECK (mode IN ('chat', 'call')).chat_sessionsreadsmodevia the existingpayment_session_idFK (added in Phase 3.7); no new column onchat_sessions.
15.2 Customer chat header
- When
chat_sessions.mode == 'call', render a📞 Voice Callpill 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.
15.4 Link rendering
- 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 withis_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=truefirst_session_discount_actual_price_idr=2000first_session_discount_gimmick_price_idr=12000first_session_discount_duration_minutes=12first_session_discount_modes=["chat"]pricing_chat_tiers_json= JSON array of chat tierspricing_call_tiers_json= JSON array of call tierssupport_handles_json= WA + Telegram deeplinkssearching_timeout_minutes=5end_session_two_step_confirm=truethree_minute_warning_enabled=true
UX Guards (carry over from Figma README)
These are non-negotiable per Figma/handoff/README.md:
- Two-step
akhiri sesiconfirmation. 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:
- Fresh install: S1 → Home (no JWT) → CTA → S2 Nama → Verif Choice Sheet → both branches reach Notif Gate.
- Verified branch (first time): ESP (≥1 chip) → USP → S3a → S3b → S6 Discounted Paywall showing struck-through
Rp{gimmick}and prominentRp{actual}→ Cara Bayar → Waiting (20-min clock visible) → Paid → Notif Gate. - Verified branch (already consulted): same path but skips S6 and goes straight to Pemilihan Harga.
- Anonymous branch: ESP → USP → Pilih cara curhat → Pemilihan Harga (chat tier list) → toggle to call → tier list rebuilds with call prices → Cara Bayar.
- OTP exhausted: blocked popup appears; tapping "lanjut tanpa verif" reaches Pemilihan Harga with previously-picked ESP/USP carried over (no re-prompt).
- Payment expired: 20-min timer hits 0 → expired screen → retry returns to method.
- Notif denied: full-screen gate's "nanti aja" closes; Home shows the amber banner; reopening Home after grant clears the banner.
- Soft-prompt → blast → 5-min timeout: timeout state shows the two CTAs and their behaviors are correct.
- Match found: S9 renders bestie name + duration in CTA copy.
- 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 withperpanjangCTA. - Chat (mode=call): header shows
📞 Voice Callbadge. Mitra pastes ameet.google.comURL → renders as a tappable link → tap launches the OS handler. - Perpanjang: time-up sheet renders with chat/call toggle. Toggling rebuilds prices from the corresponding group.
- Akhiri: 2-step popup → closing message sheet → S11 thank-you → Home.
- Returning user: Home CTA opens Bestie Choice Sheet.
bestie yang udah kenal→ list → online → SWaitingBestie 20s overlay → match. - Returning, offline:
bestie offline popupshown;tanya adminopens WA/Tele sheet. - Returning, mitra rejects: surfaces same offline popup with "cari bestie lain" CTA.
- Auth providers: with no Google/Apple env vars set, Welcome screen shows only the phone-OTP CTA. Setting
GOOGLE_OAUTH_CLIENT_ID/SECRETand restarting backend → Google button appears on next app launch (afterauthProvidersProviderrefresh). - CC config knobs: an ops user toggling
first_session_discount_enabled=falsein 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.yaml03_onboarding_anon.yaml04_payment_expired.yaml05_searching_timeout.yaml06_chat_countdown.yaml07_end_session_2step.yaml08_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)
- Backend stubs first:
onboarding-state, pricingmode_multiplier, confirmsession_warning3-min ping, support-handles (or constants). - Onboarding shell: Verif Choice Sheet + ESP (multi-select) + USP. Cuts over the existing topic-selection sheet without breaking pairing.
- Payment shell: Pilih cara → Pemilihan harga (with toggle) → Cara bayar → Waiting → Expired. Wire to existing mock payment API.
- Notif gate + home banner.
- Searching upgrades: soft-prompt + 5-min timeout state.
- Chat countdown UX: 3-min snackbar, last-2-min danger, expired banner.
- Session-end: 2-step popups + closing-message sheet + S11.
- Returning user: Choice Sheet + targeted-wait overlay + Tanya Admin.
- Edge cases: OTP-blocked popup; mitra rejects close.
- 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, plus one §2 (auth) edge:
Branching point Scenario(s) Choice: bestie yang udah kenalvsbestie baruTS-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 lainvstanya adminTS-02 vs TS-03 PayStat: paidvstimeout 20 minTS-01/02/04/06 vs TS-05 PairRoute: lama (Targeted)vsbaru / cari lain (BlastFlow)TS-01/05/06 vs TS-02/04 TargetedRes: acceptvsreject/timeoutTS-01/05 vs TS-06 §2 post-OTP: new user (set-name) vs existing user with name (skip) TS-01..06 vs TS-07
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
ONLINEpill in history list). - [BE] Free-trial config OFF for the test customer (otherwise the paywall path replaces the QRIS flow).
- [C]
client_apppointed 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
- [C] From home (returning state), tap
curhat sama bestie baru→ Bestie Choice Sheet appears. - [C] Tap
bestie yang udah kenal→ bestie history list opens; the test mitra row showsONLINEpill (not dimmed). - [C] Tap the test mitra row → app navigates to
/payment/entry(PickMethod). The legacy/paymentroute is no longer reachable as of Stage 5.4. - [C] Pick
chat(orvoice call) → PickDuration. - [C] Pick any tier (e.g.
5 Menit) →/payment/method(the "cara bayar" screen). - [C] Pick a payment method (e.g. QRIS) → tap
Bayar→/payment/waiting(20-min QRIS countdown). - [BE] Manually confirm the payment via
POST /api/client/payment-sessions/:id/confirm(or use the mock helper script). - [C] App auto-advances through notif-gate and lands on
/chat/waiting-targeted/<mitraId>("Menunggu bestie tertentu" with 20s overlay). - [mitra_app] Accept the incoming targeted request.
- [C] Customer lands on
/chat/session/:id(S10 Chat Room) — WS open, session timer running.
Expected result
- [BE]
payment_sessionsrow hastargeted_mitra_id = <test mitra id>andstatus = 'confirmed'. - [BE]
chat_sessionsrow created with the samemitra_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.yamlwas written against the pre-Stage-5.1 screen graph and needs a rewrite — its selectors target the deleted legacy/paymentscreen (Chat lagi dengan <mitraName>app-bar title,MENUNGGU JAWABANintermediate). When automating, rewrite this flow to walk the new multi-screen path described above. - Stage 5.4 (2026-05-18) deleted the legacy
/paymentroute +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
ONLINEpill in history list). - [BE] At least one OTHER mitra is online (so the blast can match).
- [BE] Free-trial OFF.
Steps
- [C] From home, tap
curhat sama bestie baru→ Bestie Choice Sheet. - [C] Tap
bestie yang udah kenal→ history list; the test mitra row is dimmed (offline styling preserved as of Stage 5.3). - [C] Tap the dimmed row →
BestieOfflinePopup(prePayReturningvariant) appears showing the mitra's name. Two CTAs:cari bestie lainandtanya admin. - [C] Tap
cari bestie lain→ popup closes; app navigates to/payment/entry. Payment draft has beenreset()(no staletargetedMitraId). - [C] Walk PickMethod → PickDuration → PayMethod → Pay →
/payment/waiting. - [BE] Manually confirm payment.
- [C] App routes to
/chat/searching(NOT/chat/waiting-targeted/...). - [mitra_app] A different online mitra receives the blast and accepts.
- [C] Customer lands on
/chat/session/:idwith the new mitra.
Expected result
- [BE]
payment_sessionsrow hastargeted_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
- [C] Reach the
BestieOfflinePopup(prePayReturningvariant) via TS-02 steps 1-3. - [C] Tap
tanya admin→ popup closes; admin sheet opens with WhatsApp / Telegram contact options. - [C] Dismiss the admin sheet → user returns to the bestie history list.
Expected result
- [BE] No new
payment_sessionsrow created during this scenario. - [C] Payment draft state unchanged (no
targetedMitraId, nopaymentId). 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
- [C] From home, tap
curhat sama bestie baru→ Bestie Choice Sheet. - [C] Tap
bestie baru→ app navigates to/payment/entry. Draft is explicitlyreset()on this branch (clears any staletargetedMitraIdper Stage 5.1 Risk #4 mitigation). - [C] Walk PickMethod → PickDuration → PayMethod → Pay →
/payment/waiting. - [BE] Confirm payment.
- [C] App routes to
/chat/searching(NOT/chat/waiting-targeted/...). - [mitra_app] An online mitra accepts the blast.
- [C] Customer lands on
/chat/session/:id.
Expected result
- [BE]
payment_sessionsrow hastargeted_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 → expiredafter 20 min, or the test uses a shortened TTL / directUPDATEto force expiry.
Steps
- [C] Walk TS-01 steps 1-6 to reach
/payment/waitingfor a targeted attempt against the test mitra. - [BE] Wait for or force the
pending → expiredtransition on the payment row. - [C] Polling sees
status = 'expired'→ app routes to/payment/expired/:paymentId. - [C] Tap the retry CTA → app routes back to
/payment/method(NOT all the way to PickMethod; draft preserved viaresetExceptTarget). - [C] Re-pick payment method → tap
Bayar→ new/payment/waiting. - [BE] Confirm the new payment.
- [C] App routes to
/chat/waiting-targeted/<mitraId>for the same mitra as step 1 (no re-pick required).
Expected result
- [BE] Original
payment_sessionsrow hasstatus = 'expired'. New row created withstatus = 'confirmed'. Both rows have the sametargeted_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
- [C] Walk TS-01 steps 1-8 to reach
/chat/waiting-targeted/<mitraId>. - [mitra_app] Reject the incoming targeted request (or do nothing for the 20s countdown).
- [C] Targeted-waiting screen detects the failure →
BestieOfflinePopup(returningvariant, post-pay) appears withcanFallbackToBlast: true. CTAs:cari bestie lainandtanya admin. - [C] Tap
cari bestie lain→ app callsPOST /api/client/chat/chat-requests/:paymentSessionId/fallback-to-blast→ routes to/chat/searching. - [mitra_app] A DIFFERENT online mitra accepts the blast.
- [C] Customer lands on
/chat/session/:idwith the new mitra.
Expected result
- [BE] Same
payment_sessionsrow is reused (stillstatus = 'confirmed'); customer is not charged a second time. - [BE]
chat_sessionsrow created with the fallback mitra (NOT the originaltargeted_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.
TS-07 — Returning user with existing display_name skips set-name screen
Flow: §2 (verified path) Choice → "verif WA" → OTP → user lookup → existing account (display_name set, has_transacted=false) → /home. Verifies the existing-user-with-name branch of resolveCustomerForIdentity.
Affects: client_app, backend.
Goal: Confirm a phone-OTP sign-in for a customer who already has a
non-empty display_name in customers does NOT re-show the
"Siapa namamu?" set-name screen. Routes directly from OTP success
to /home with the stored display_name. This is the inverse of TS-01..TS-06,
all of which use drop_customer:true (wiping the row) and therefore always
land on the new-user set-name branch.
Pre-reqs
- [BE] Backend reachable; NODE_ENV != 'production'.
Steps
- [BE] Wipe phone state via
/internal/_test/reset-phone{ phone, drop_customer: true }— clears any prior customer row. - [BE] Seed an identified customer via
/internal/_test/seed-customer{ phone, display_name }— inserts a row withis_anonymous=falseand the chosen display_name. - [C] Cold-launch
client_appwith clearState → welcome carousel → tapMulai→ home (anonymous view, showsmasuk →banner). - [C] Tap
masuk →→/auth/register→ input phone digits (after the+62chip) → tapkirim kode→ OTP screen. - [C] Peek OTP from the stub, input it — auto-submits on the 6th digit.
Expected result
- [C] App routes directly to
/home, CTAaku mau curhatvisible (the_SHome1stViewno-history variant). The customer's stored display_name is loaded into the profile state. - [C] The
Siapa namamu?set-name screen is never shown. AnassertNotVisiblefor the set-name title at the home-arrival point acts as a belt-and-braces check against a brief flash-then-redirect. - [BE] No new
customersrow created — the seeded row is the same one returned bygetCustomerByPhone→resolveCustomerForIdentitybranch 1 (existing identity, no anon prefix).customers.idafter the flow equals the seededCUSTOMER_ID.
Why this needs its own test: TS-01..TS-06 all begin with
reset_phone drop_customer:true, which makes every OTP path land in
resolveCustomerForIdentity branch 4 (no existing + no anon → create
new with display_name=null → client routes to set-name). That covers
the new-user surface but never exercises the "existing user with name"
path. TS-07 is the symmetric coverage for the same auth code, ensuring
the set-name screen isn't accidentally re-shown for known users (which
would be a real UX regression — name re-entry every login).