# PRD: Phase 4 — Customer Flow Redesign (Figma Alignment) > Source-of-truth flow: [`flow_customer.md`](flow_customer.md) (numbered list) + > [`flow_customer.mermaid.md`](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 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 / 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. ### 1.3 Profile re-prompt for anon users (shipped 2026-05-22) > Figma: `screens/extras.jsx::SProfile` save-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 to `AuthAuthenticatedData` after a successful OTP verify. - The `?from=profile` query param causes `RegisterScreen` to **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/register` from somewhere that should branch the screen (copy, escape hatch, post-OTP destination), pass `?from=` and read `GoRouterState.of(context).uri.queryParameters['from']` in `RegisterScreen.build()`. The router's redirect preserves query params for anonymous users on `/auth/*` routes. ## 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` → ```json { "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. ### 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 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`, plus one §2 (auth) edge: > > | 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 | > | §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 `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/` ("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 = ` 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 ` 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/` 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/`. 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. --- ## 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** 1. [ ] **[BE]** Wipe phone state via `/internal/_test/reset-phone` `{ phone, drop_customer: true }` — clears any prior customer row. 2. [ ] **[BE]** Seed an identified customer via `/internal/_test/seed-customer` `{ phone, display_name }` — inserts a row with `is_anonymous=false` and the chosen display_name. 3. [ ] **[C]** Cold-launch `client_app` with clearState → welcome carousel → tap `Mulai` → home (anonymous view, shows `masuk →` banner). 4. [ ] **[C]** Tap `masuk →` → `/auth/register` → input phone digits (after the `+62` chip) → tap `kirim kode` → OTP screen. 5. [ ] **[C]** Peek OTP from the stub, input it — auto-submits on the 6th digit. **Expected result** - [ ] **[C]** App routes directly to `/home`, CTA `aku mau curhat` visible (the `_SHome1stView` no-history variant). The customer's stored display_name is loaded into the profile state. - [ ] **[C]** The `Siapa namamu?` set-name screen is **never shown**. An `assertNotVisible` for 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 `customers` row created — the seeded row is the same one returned by `getCustomerByPhone` → `resolveCustomerForIdentity` branch 1 (existing identity, no anon prefix). `customers.id` after the flow equals the seeded `CUSTOMER_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).