Files
halobestie-clone/requirement/phase4-customer-flow.md
Ramadhan Sjamsani 93fa5f113a Test: TS-07 returning user with existing display_name skips set-name
Inverse coverage for the auth path: TS-01..TS-06 all wipe the customer
row (drop_customer=true) so every OTP path lands on the new-user
set-name branch. TS-07 instead seeds an existing identified customer
(phone + display_name + is_anonymous=false) and verifies the OTP
sign-in returns the existing row unchanged via
resolveCustomerForIdentity branch 1, so /auth/set-name is never shown.

Adds:
* /internal/_test/seed-customer endpoint — upserts a customer with
  phone + display_name + is_anonymous=false.
* client_app/.maestro/scripts/seed_customer.js helper.
* client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml.
* TS-07 scenario doc + coverage-map row in
  requirement/phase4-customer-flow.md.

The flow asserts the "halo, <name>" greeting on the returning-user home
variant (identified users always land on _SHomeReturningView regardless
of chat history) plus an explicit notVisible on "Siapa namamu" as a
belt-and-braces check.

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

934 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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`
```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/<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.
---
## 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).