Files
halobestie-clone/requirement/phase4-customer-flow.md
ramadhan sjamsani 8c212cb464 Phase 4 PRD + plan: customer-flow redesign (Figma alignment)
Adds the Phase 4 requirement docs that align the customer app with the new
HaloBestie Figma design dump.

- requirement/flow_customer.md: source-of-truth numbered flow (input)
- requirement/flow_customer.mermaid.md: 6 mermaid diagrams + Figma cross-ref
- requirement/phase4-customer-flow.md: PRD (15 functional sections)
- requirement/phase4-customer-flow-plan.md: 10-stage implementation plan
- .gitignore: exclude requirement/Figma.zip + extracted Figma/ folder

Resolved product decisions: no free trial (replaced by configurable
first-session discount), pricing has independent chat/call groups,
voice-call mode is chat-with-badge (mitra shares Meet link manually),
social login is server-driven via /api/shared/auth-providers, ESP tags
are info-only (not used for matching).

No code changes; implementation starts at plan stage 0 (design system).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:21:26 +08:00

602 lines
28 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.