diff --git a/.gitignore b/.gitignore
index 994194c..1b5e7f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,8 @@ build/
.flutter-plugins
.flutter-plugins-dependencies
bugreport-*.zip
+
+# Figma design dump (do not check in)
+requirement/Figma.zip
+requirement/Figma/
+requirement/figma/
diff --git a/requirement/flow_customer.md b/requirement/flow_customer.md
new file mode 100644
index 0000000..8d90b48
--- /dev/null
+++ b/requirement/flow_customer.md
@@ -0,0 +1,92 @@
+# Application Flow for Customer App
+We are defining customer flow along with the app referenced on the claude design
+
+Customer App Flow:
+1. Splash screen -> S1 - Splash
+2. Show home screen -> Home
+3. Check for session existence (through JWT):
+ 1. when No session found, show login panel on top -> Home (1st time)
+ 2. when session found, show profil panel on top -> Home (returning)
+4. Check for notification allowance
+ 1. If not allowed -> shows Home + notif banner
+ 2. If allowed, go to next step
+5. CTA Of, Aku mau Curhat -> New User (1) | Curhat Sama Bestie Baru -> Existing user (2)
+ 1. New User
+ 1. Check for user call sign existence
+ 1. Not Exist -> Shows S2 - Nama
+ 2. Verification request (OTP)
+ 1. Verify phone number
+ 1. ESP Screen -> Shows S5 (selection)
+ 2. USP Screen -> Shows S5b. USP Screen
+ 3. Phone number input -> show S3a · Input WhatsApp
+ 4. Phone number OTP => show S3b · OTP verification
+ 5. One time Paywal -> Shows s6 - paywall Rp.2000
+ 6. Payment Screen -> Only QRIS
+
+ 2. Stay anonymous
+ 1. ESP Screen -> Shows S5 (selection)
+ 2. USP Screen -> Shows S5b. USP Screen
+ 3. Curhat method -> shows Pilih cara curhat
+ 4. Duration selection -> shows Pemilihan harga (full screen)
+ 5. Payment Screen -> Shows Cara bayar (QRIS-first)
+
+ 3. Xendit payment screen (webview or other view)
+
+ 4. Check for payment status status:
+ 1. 20 minutes -> Payment set to expired. Shows Pembayaran expired
+ 2. Pay -> Go to next step
+
+ 5. Check for notification allowance
+ 1. If not allowed -> shows Aktifkan notifikasi
+ 1. if Izinkan Notifikasi CTA -> Navigate to enable notification in system
+ 2. else (Nanti Saja) -> Go to next step
+ 2. If allowed, go to next step
+
+ 6. Soft Prompt Searching -> Show S7 · Searching state . CTA Aku ngerti, Lanjut
+ 7. Blast chat session pair request -> Show S7 · Timeout 5 menit without CTA
+ 8. Blast chat session timeout status (5 minutes timeout):
+ 1. Before timeout -> Show S7 · Timeout 5 menit without CTA
+ 2. Timeout -> Show S7 · Timeout 5 menit with CTA Coba Cari Lagi and ghost CTA button Coba cari lagi nanti that navigate to home
+ 3. No Timeout -> Go to next step
+ 9. Match Found -> Shows S9 · Match found
+
+
+ 2. Returning User
+ 1. CTA Curhat sama Bestie Baru -> Shows Bestie Choice Sheet · returning user
+ 1. Bestie yang udah kenal CTA -> Shows Bestie History List · pilih bestie lama
+ 1. Bestie offline -> Shows Bestie Offline Popup · returning user
+ 1. Cari Bestie Lain CTA -> Go to Bestie Baru CTA of returning user
+ 2. Tanya admin CTA -> Shows Sheet · tanya admin
+ 2. Bestie online
+ 1. Request chat session pair -> Shows Menunggu bestie tertentu
+ 2. Bestie Baru CTA
+ 1. Soft Prompt Searching -> Show S7 · Searching state . CTA Aku ngerti, Lanjut
+ 2. Blast chat session pair request -> Show S7 · Timeout 5 menit without CTA
+ 3. Blast chat session timeout status (5 minutes timeout):
+ 1. Before timeout -> Show S7 · Timeout 5 menit without CTA
+ 2. Timeout -> Show S7 · Timeout 5 menit with CTA Coba Cari Lagi and ghost CTA button Coba cari lagi nanti that navigate to home
+ 3. No Timeout -> Go to next step
+ 4. Match Found -> Shows S9 · Match found
+
+ 3. Chat Room -> S10 · Last 2 Minutes Chat without snackbar reminder
+ 4. 3 minutes left -> Shows S10 · Snackbar reminder
+ 5. 2 minutes left -> Shows S10 · Last 2 Minutes Chat
+ 6. Times up -> Shows S10 · Floating banner expired
+ 7. CTA Perpanjang -> Shows Time-up sheet (5 opsi)
+ 8. Shows Time Topup bottom sheet -> Shows Time-up sheet (5 opsi)
+ 1. Perpanjang CTA -> Go to Payment Screen, but now without blast but instead asking same Mitra for approval.
+ 2. Cukup, Akhiri Sesi CTA -> Popup · konfirmasi akhiri (1)
+ 1. Lanjut Akhiri CTA -> Shows Popup · konfirmasi akhiri (2)
+ 1. Tulis Pesan Penutup CTA -> Shows Pesan penutup sheet
+ 1. Kirim & Akhiri sesi CTA -> Shows S11 · Terima kasih udah cerita
+ 1. Mitra reject -> Shows Bestie Offline Popup · returning user
+ 2. Lewat - Langusng akhiri CTA -> Shows S11 · Terima kasih udah cerita
+ 2. Lewati Saja CTA -> Shows S11 · Terima kasih udah cerita
+ 2. Gak Jadi, Balik CTA -> Go to Shows Time Topup Bottom Sheet
+
+
+
+
+
+
+
diff --git a/requirement/flow_customer.mermaid.md b/requirement/flow_customer.mermaid.md
new file mode 100644
index 0000000..cf4100b
--- /dev/null
+++ b/requirement/flow_customer.mermaid.md
@@ -0,0 +1,239 @@
+# Customer App Flow — Mermaid Diagrams
+
+> Generated from `requirement/flow_customer.md` and cross-checked against the Figma
+> handoff package (`requirement/Figma/` — git-ignored).
+>
+> **Legend (status pulled from a client_app audit, see `phase4-customer-flow.md`):**
+> - 🟢 EXISTS — already shipped in client_app
+> - 🟡 PARTIAL — close, but a key piece (UX, copy, or sub-state) is missing
+> - 🔴 MISSING — no implementation in client_app yet
+
+The flow is split into 6 sub-diagrams so each one stays readable. Screen IDs use
+the Figma handoff naming (`S1`, `S6`, `S10`, …); see `Figma/handoff/png/` for the
+909×540 renders and `Figma/screens/*.jsx` for the live source.
+
+---
+
+## 1. Boot + Home gating
+
+```mermaid
+flowchart TD
+ S1["S1 · Splash 🟢"] --> Home{"JWT session?"}
+ Home -->|"no"| Home1st["Home (1st time)
+ login panel 🟡"]
+ Home -->|"yes"| HomeRet["Home (returning)
+ profile panel 🟢"]
+ Home1st --> NotifCheck{"OS notif allowed?"}
+ HomeRet --> NotifCheck
+ NotifCheck -->|"no"| HomeBanner["Home + notif banner 🔴"]
+ NotifCheck -->|"yes"| HomeReady["Home (ready) 🟢"]
+ HomeBanner --> HomeReady
+
+ HomeReady --> CTA{"CTA tapped?"}
+ CTA -->|"'aku mau curhat'
(1st time)"| NewUser["→ New User flow"]
+ CTA -->|"'curhat sama bestie baru'
(returning)"| ReturningUser["→ Returning flow"]
+
+ classDef missing fill:#ffe5e5,stroke:#c44979
+ classDef partial fill:#fff4d6,stroke:#c69b3f
+ class HomeBanner missing
+ class Home1st partial
+```
+
+---
+
+## 2. New-User onboarding (verified vs. anonymous)
+
+```mermaid
+flowchart TD
+ Start["from Home — 'aku mau curhat' 🟢"] --> NameCheck{"call_sign exists?"}
+ NameCheck -->|"no"| S2["S2 · Pengisian Nama 🟢"]
+ NameCheck -->|"yes"| VerifChoice
+ S2 --> VerifChoice["Verif vs Anon Choice Sheet
(VerifChoiceSheet) 🔴"]
+
+ VerifChoice -->|"verif WA · Rp2k"| ESPa["S5 · ESP screening
(multi-select chips) 🟡"]
+ VerifChoice -->|"tanpa verif · Rp5k+"| ESPb["S5 · ESP screening 🟡"]
+
+ %% Verified path
+ ESPa --> USPa["S5b · USP screen 🔴"]
+ USPa --> S3a["S3a · WhatsApp input 🟡 (6→4 digit)"]
+ S3a --> S3b["S3b · OTP 4-digit 🟡"]
+ S3b --> OTPok{"OTP ok?"}
+ OTPok -->|"too many retries"| OTPBlock["OTP Blocked Popup 🔴
→ fallback to Anon"]
+ OTPBlock --> ESPb
+ OTPok -->|"verified"| S6["S6 · Paywall Rp2.000
(12 menit, sekali seumur hidup) 🟡"]
+ S6 --> Pay
+
+ %% Anonymous path
+ ESPb --> USPb["S5b · USP screen 🔴"]
+ USPb --> PickMethod["Pilih cara curhat
(chat / voice call) 🔴"]
+ PickMethod --> PickDuration["Pemilihan harga
(5 durations, full screen) 🟡"]
+ PickDuration --> PayMethod["Cara bayar (QRIS-first) 🔴"]
+ PayMethod --> Pay
+
+ %% Shared payment exit
+ Pay["Xendit checkout
(QRIS / e-wallet) 🔴"] --> WaitPay["Waiting Payment
(20-min QRIS clock) 🔴"]
+ WaitPay --> PayStat{"payment status"}
+ PayStat -->|"timeout 20 min"| PayExpired["Pembayaran expired 🔴
→ retry"]
+ PayExpired --> Pay
+ PayStat -->|"paid"| NotifGate
+
+ classDef missing fill:#ffe5e5,stroke:#c44979
+ classDef partial fill:#fff4d6,stroke:#c69b3f
+ class VerifChoice,USPa,USPb,PickMethod,PayMethod,Pay,WaitPay,PayExpired,OTPBlock missing
+ class ESPa,ESPb,S3a,S3b,S6,PickDuration partial
+```
+
+> **Anchor mismatch:** flow_customer.md numbers ESP/USP under
+> `5.1.2 Verification request (OTP)` for both branches, but Figma puts
+> `VerifChoiceSheet` *before* ESP. The mermaid above follows Figma; reconcile in
+> phase4 spec.
+
+---
+
+## 3. Pre-pairing → Searching → Match (shared)
+
+```mermaid
+flowchart TD
+ NotifGate["Notif Gate Screen 🔴
(Aktifkan / Nanti Saja)"] --> NotifBranch{"OS allowed?"}
+ NotifBranch -->|"no + ask"| EnableNotif["OS settings deeplink"] --> SoftPrompt
+ NotifBranch -->|"yes / skipped"| SoftPrompt
+ SoftPrompt["S7 · Soft-prompt
(consent + warmup, CTA 'Aku ngerti, Lanjut') 🟡"] --> Blast
+ Blast["Blast pair request
S7 · Searching state 🟡"] --> BlastTimer{"5-min timer"}
+ BlastTimer -->|"matched"| S9["S9 · Match Found
(bestie name, age, hobi) 🟡"]
+ BlastTimer -->|"timeout"| S7Timeout["S7 · Timeout 5 menit 🔴
CTA 'Coba Cari Lagi'
ghost CTA 'Coba cari lagi nanti' → Home"]
+ S7Timeout -->|"retry"| Blast
+ S7Timeout -->|"home"| HomeRet
+ S9 --> S10
+ S10["S10 · Chat Room"]
+ HomeRet["→ Home (returning)"]
+
+ classDef missing fill:#ffe5e5,stroke:#c44979
+ classDef partial fill:#fff4d6,stroke:#c69b3f
+ class NotifGate,S7Timeout missing
+ class SoftPrompt,Blast,S9 partial
+```
+
+---
+
+## 4. Returning-User pairing (lama / baru)
+
+```mermaid
+flowchart TD
+ CTA["'curhat sama bestie baru' 🟢"] --> Choice["Bestie Choice Sheet
(BestieChoiceSheet) 🔴"]
+ Choice -->|"bestie yang udah kenal"| HistList["Bestie History List
(BestieHistoryList) 🟢"]
+ Choice -->|"bestie baru"| BlastFlow["→ S7 Soft-prompt + Blast
(see diagram 3)"]
+
+ HistList --> PickBestie["pick bestie"]
+ PickBestie --> CheckOnline{"bestie online?"}
+ CheckOnline -->|"no"| OfflinePopup["Bestie Offline Popup
(returning variant) 🟢"]
+ OfflinePopup -->|"cari bestie lain"| BlastFlow
+ OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin
(WA / Telegram) 🔴"]
+ CheckOnline -->|"yes"| Targeted["Request targeted pair
'Menunggu bestie tertentu' 🟡
(20s countdown overlay)"]
+ Targeted --> TargetedRes{"mitra answers?"}
+ TargetedRes -->|"accept"| S10["→ S10 Chat Room"]
+ TargetedRes -->|"reject / timeout"| OfflinePopup
+
+ classDef missing fill:#ffe5e5,stroke:#c44979
+ classDef partial fill:#fff4d6,stroke:#c69b3f
+ class Choice,AdminSheet missing
+ class Targeted partial
+```
+
+---
+
+## 5. Chat Room (S10) — countdown UX
+
+```mermaid
+flowchart TD
+ Enter["enter S10 · Chat Room
(WebSocket open, timer running) 🟡"] --> T3{"3-minutes-left tick"}
+ T3 -->|"fired"| Snackbar["S10 · Snackbar reminder
'sisa 3 menit lagi ya' 🔴"]
+ Snackbar --> T2
+ T3 -->|"not yet"| T2{"2-minutes-left tick"}
+ T2 -->|"fired"| LowTime["S10 · Last 2 Minutes
(timer turns danger color) 🔴"]
+ LowTime --> Expire
+ T2 -->|"not yet"| Expire{"timer hits 0"}
+ Expire -->|"fired"| ExpiredBanner["S10 · Floating Expired Banner
'habis nih... mau lanjutin?' 🔴"]
+ ExpiredBanner --> CTAExt{"perpanjang CTA?"}
+ CTAExt -->|"yes"| TimeUp
+ CTAExt -->|"close / ignore"| EndFlow["→ end-session flow"]
+
+ Expire -->|"not yet · user taps perpanjang"| TimeUp
+ TimeUp["Time-up Bottom Sheet
(5 durations · chat/call toggle) 🟡"]
+ TimeUp -->|"perpanjang"| AskMitra["Targeted re-pay request
(same mitra, no blast) 🔴"]
+ TimeUp -->|"cukup, akhiri sesi"| EndFlow
+
+ AskMitra --> MitraRes{"mitra approves?"}
+ MitraRes -->|"yes + paid"| Enter
+ MitraRes -->|"reject"| OfflinePopup["Bestie Offline Popup
(returning variant) 🟢"]
+
+ classDef missing fill:#ffe5e5,stroke:#c44979
+ classDef partial fill:#fff4d6,stroke:#c69b3f
+ class Snackbar,LowTime,ExpiredBanner,AskMitra missing
+ class Enter,TimeUp partial
+```
+
+---
+
+## 6. End-of-session sequence (2-step confirm + closing message)
+
+```mermaid
+flowchart TD
+ EndStart["End-session entry
(from S10 or Time-up sheet 'Cukup, Akhiri')"] --> Confirm1["Popup · Konfirmasi Akhiri (1)
'beneran udah cukup?' 🟡"]
+ Confirm1 -->|"Gak Jadi, Balik"| TimeUp["Time-up Bottom Sheet 🟡"]
+ Confirm1 -->|"Lanjut Akhiri"| Confirm2["Popup · Konfirmasi Akhiri (2)
'mau tinggalin pesan penutup?' 🔴"]
+
+ Confirm2 -->|"Tulis Pesan Penutup"| ClosingSheet["Pesan Penutup Bottom Sheet
(textarea) 🟡"]
+ Confirm2 -->|"Lewati Saja"| ThankYou
+
+ ClosingSheet -->|"Kirim & Akhiri"| MitraReceipt{"mitra rejects close?"}
+ ClosingSheet -->|"Lewat — Langsung Akhiri"| ThankYou
+ MitraReceipt -->|"no"| ThankYou
+ MitraReceipt -->|"yes (rare)"| OfflinePopup["Bestie Offline Popup 🟢"]
+
+ ThankYou["S11 · Terima Kasih Udah Cerita 🔴"] --> Home["→ Home (returning) 🟢"]
+
+ classDef missing fill:#ffe5e5,stroke:#c44979
+ classDef partial fill:#fff4d6,stroke:#c69b3f
+ class Confirm2,ThankYou missing
+ class Confirm1,ClosingSheet,TimeUp partial
+```
+
+---
+
+## Cross-reference: Figma → flow_customer.md
+
+| Figma artifact (file) | Source | flow_customer.md ref |
+|---|---|---|
+| S1 Splash | `screens/onboarding.jsx::S1Splash` | §1 |
+| Home 1st / Returning | `screens/v3.jsx::SHome1st` / `SHomeReturning` + `screens/session.jsx::S12Home` | §2–3 |
+| Notif banner on home | `screens/v3.jsx::HBNotifBanner` | §4.1 |
+| S2 Nama | `screens/onboarding.jsx::S2Name` (and v4 variant) | §5.1.1 |
+| `VerifChoiceSheet` | `screens/v4.jsx::VerifChoiceSheet` | implied between §5.1.1 ↔ §5.1.2 |
+| S5 ESP screening | `screens/onboarding.jsx::S5ESP` | §5.1.2.1.1 / §5.1.2.2.1 |
+| S5b USP | `screens/onboarding.jsx::S5USP` | §5.1.2.1.2 / §5.1.2.2.2 |
+| S3a WA / S3b OTP | `screens/onboarding.jsx::S3Phone` (+ `screens/v4.jsx::S3OTPV4`) | §5.1.2.1.3-4 |
+| `OTPBlockedPopup` | `screens/v4.jsx::OTPBlockedPopup` | (gap — not in flow doc) |
+| S6 Paywall Rp2k | `screens/onboarding.jsx::S6Paywall` | §5.1.2.1.5 |
+| Pilih cara curhat | `screens/v3.jsx::SPickMethod` | §5.1.2.2.3 |
+| Pemilihan harga | `screens/v3.jsx::SPickDuration` (+ `v4::InitialDurationPicker`) | §5.1.2.2.4 |
+| Cara bayar | `screens/extras.jsx::SPaymentMethod` | §5.1.2.2.5 |
+| Waiting Payment | `screens/extras.jsx::SWaitingPayment` | §5.1.4 |
+| Pembayaran expired | `screens/v4.jsx::PaymentExpiredV4` (+ extras `SWaitingPayment expired`) | §5.1.4.1 |
+| Notif Gate (full screen) | `screens/extras.jsx::SNotifGate` (+ `v4::NotifGateV4`) | §5.1.5.1 |
+| S7 Soft-prompt + Searching | `screens/session.jsx::S7Prompt` + `S8Searching` + `screens/v3.jsx::SSearchPrompt` | §5.1.6-8 |
+| S7 Timeout 5 menit | `screens/v3.jsx::SSearchPrompt(state='timeout')` | §5.1.8.2 |
+| S9 Match | `screens/session.jsx::S9Match` (+ `v4::S9MatchV4`) | §5.1.9 |
+| Bestie Choice Sheet | `screens/v4.jsx::BestieChoiceSheet` | §5.2.1 |
+| Bestie History List | `screens/v4.jsx::BestieHistoryList` | §5.2.1.1 |
+| Bestie Offline Popup | `screens/v4.jsx::BestieOfflinePopup` | §5.2.1.1.1 / §5.7.8.1.1.1 |
+| Tanya Admin Sheet | `screens/v3.jsx::HBContactAdminSheet` | §5.2.1.1.1.2 |
+| Menunggu Bestie | `screens/extras.jsx::SWaitingBestie` | §5.2.1.1.2.1 |
+| S10 Chat | `screens/session.jsx::S10Chat` | §5.3 |
+| 3-min Snackbar reminder | `screens/v3.jsx::HBSnackbar` (configured for "sisa 3 menit") | §5.4 |
+| Last-2-min visuals | `screens/session.jsx::S10Chat` (lowTime branch) | §5.5 |
+| Floating expired banner | `screens/v3.jsx::HBChatExpiredBanner` | §5.6 |
+| Time-up Bottom Sheet | `screens/extras.jsx::STimeUpSheet` | §5.7-8 |
+| Confirm akhiri (2 popups) | `screens/v3.jsx::HBConfirmEndPopup` (step 1 + 2) | §5.8.2.1 / §5.8.2.1.1 |
+| Closing Message Sheet | `screens/extras.jsx::SClosingSheet` | §5.8.2.1.1.1 |
+| S11 Thank-you | `screens/session.jsx::S11Post` | §5.8.2.1.1.1.1 |
+
+Anything Figma describes that flow_customer.md doesn't mention is captured as a
+gap in `phase4-customer-flow.md` (next-phase doc).
diff --git a/requirement/phase4-customer-flow-plan.md b/requirement/phase4-customer-flow-plan.md
new file mode 100644
index 0000000..6fb55c2
--- /dev/null
+++ b/requirement/phase4-customer-flow-plan.md
@@ -0,0 +1,866 @@
+# Phase 4 — Implementation Plan
+
+> See [phase4-customer-flow.md](phase4-customer-flow.md) for the PRD,
+> [flow_customer.md](flow_customer.md) for the source-of-truth flow,
+> [flow_customer.mermaid.md](flow_customer.mermaid.md) for cross-referenced
+> diagrams. Visual reference is in `requirement/Figma/` (git-ignored).
+
+This document is the build sequence: **what** files change, **in what order**,
+with **API contracts** for new endpoints and **widget contracts** for the
+reusable UI primitives. The "why" is in the PRD — don't restate it here.
+
+---
+
+## Build Order (10 stages)
+
+The dependency graph forces this order. **Stages 0–1 are pure groundwork that
+unblocks everything else; stages 2–8 are feature-shaped and shippable
+independently; stage 9 is verification.**
+
+0. **Design system foundation** — tokens, fonts, ThemeData, reusable widgets (no new screens yet)
+1. **Backend foundation** — additive endpoints + config rows
+2. **Onboarding redesign** — Verif Choice Sheet, ESP multi-select, USP, OTP-blocked popup
+3. **Payment shell** — Pilih cara, Pemilihan harga, Cara bayar (QRIS-first), Waiting Payment, Pembayaran expired
+4. **Notif gate + home banner**
+5. **Pairing UX upgrades** — Soft-prompt, Searching state, S7 timeout, S9 Match, targeted-wait overlay
+6. **Chat-room countdown UX** — 3-min snackbar, last-2-min danger, expired floating banner
+7. **End-of-session sequence** — 2-step confirm, closing-message sheet, S11 thank-you
+8. **Returning-user shell** — Bestie Choice Sheet, Bestie history visual upgrade, Tanya Admin sheet
+9. **Test sweep** — Maestro flows, manual real-device run, visual regression
+
+Within each stage, items are listed in dependency order. Each stage is
+independently mergeable; nothing in stage N+1 hard-depends on stage N's UI
+(only on stages 0 and 1).
+
+---
+
+# Stage 0 — Design System Foundation
+
+The new flow leans hard on a tokenized look (Bricolage Grotesque + Poppins,
+warm rose palette, pill buttons, soft shadows). client_app currently uses the
+default `MaterialApp` theme — no `ThemeData`, no token file. Stage 0 sets that
+up so every subsequent stage can compose screens from primitives.
+
+## 0.1 Token file
+
+> **New:** `client_app/lib/core/theme/halo_tokens.dart`
+
+Mirrors `requirement/Figma/handoff/tokens.json` 1-for-1. Exports a single
+`HaloTokens` class with `static const Color brand = Color(0xFFE17A9D);` etc.
+
+Three palettes (`warm`, `calm`, `playful`) — ship with **warm only**; expose
+the structure for the others but leave commented `TODO: phase5`.
+
+Spacing scale is `HaloSpacing.s4`, `s8`, `s12`, … (matches Figma `1=4, 2=8`).
+
+Radius: `HaloRadius.sm/md/lg/xl/pill` as `BorderRadius` constants.
+
+Motion: `HaloMotion.fast = Duration(milliseconds: 180)` etc. Use the cubic
+curve `Cubic(0.2, 0.8, 0.2, 1)` as `HaloMotion.ease`.
+
+## 0.2 Fonts
+
+Add to `client_app/pubspec.yaml`:
+
+```yaml
+fonts:
+ - family: BricolageGrotesque
+ fonts:
+ - asset: assets/fonts/BricolageGrotesque-Regular.ttf
+ - asset: assets/fonts/BricolageGrotesque-Bold.ttf
+ weight: 700
+ - family: Poppins
+ fonts:
+ - asset: assets/fonts/Poppins-Regular.ttf
+ - asset: assets/fonts/Poppins-Medium.ttf
+ weight: 500
+ - asset: assets/fonts/Poppins-SemiBold.ttf
+ weight: 600
+ - asset: assets/fonts/Poppins-Bold.ttf
+ weight: 700
+ - family: JetBrainsMono
+ fonts:
+ - asset: assets/fonts/JetBrainsMono-Regular.ttf
+ - asset: assets/fonts/JetBrainsMono-Bold.ttf
+ weight: 700
+```
+
+Download .ttf files from the Google Fonts CDN snapshot
+(`fonts.google.com/specimen/...`). Place under `client_app/assets/fonts/`. Add
+the `assets/fonts/` directory to the existing `flutter:` block.
+
+## 0.3 ThemeData
+
+> **New:** `client_app/lib/core/theme/halo_theme.dart`
+
+Single `haloThemeData()` builder returning a `ThemeData` with:
+- `colorScheme.fromSeed(seedColor: HaloTokens.brand)`, then override `primary/onPrimary/surface/onSurface/error` to match tokens
+- `textTheme` mapped to the Figma scale (`displayLarge` → Bricolage 36/700, `titleLarge` → Bricolage 22/700, `bodyMedium` → Poppins 15/400, etc.)
+- `elevatedButtonTheme` with pill radius + `HaloShadows.button`
+- `inputDecorationTheme` matching the 64px-tall S2 Nama input
+- `bottomSheetTheme` with 24px top corners + soft shadow
+- `snackBarTheme` matching `HBSnackbar` (pill, dark backdrop)
+
+Wire into `client_app/lib/main.dart::84` (the `MaterialApp.router`):
+
+```dart
+return MaterialApp.router(
+ title: 'Halo Bestie',
+ theme: haloThemeData(),
+ routerConfig: router,
+);
+```
+
+## 0.4 Reusable widgets
+
+> **New folder:** `client_app/lib/core/theme/widgets/`
+
+Each widget is a thin Flutter port of a Figma primitive:
+
+| Widget | Port of | Notes |
+|---|---|---|
+| `HaloButton` | `HBButton` | variants: `primary` / `secondary` / `ghost`; sizes: `sm` / `md` / `lg`; `onPressed: null` → disabled visuals |
+| `HaloOrb` | `HBOrb` | gradient circle with `seed` int → deterministic color blend |
+| `HaloStepDots` | `HBStepDots` | progress dots, e.g. 4 dots for onboarding |
+| `HaloBottomSheet` | `HBBottomSheet` | helper that wraps `showModalBottomSheet` with the correct shape, drag handle, padding |
+| `HaloPopup` | `HBPopup` | helper that wraps `showDialog` with title/body/icon/primary/secondary |
+| `HaloSnackbar` | `HBSnackbar` | dark pill snackbar, 4s default; static `show(context, message, {icon})` |
+| `HaloChip` | ESP chip | toggleable pill chip with icon + label |
+
+**Naming:** `Halo*` prefix (avoids collision with Material's `Card`, `Chip`, etc.)
+and matches the brand. Internal-only; no need for a published package.
+
+## 0.5 Acceptance for Stage 0
+
+- `flutter analyze` clean.
+- `flutter run` launches with the warm palette visible on the existing
+ Splash and Home screens (they pick up the new ThemeData automatically).
+- A simple harness screen (`lib/core/theme/_preview.dart`, dev-only,
+ routed at `/_theme_preview` with a build flag) renders all `Halo*`
+ widgets — used as a visual reference during stages 2–8.
+
+---
+
+# Stage 1 — Backend Foundation
+
+Mostly additive endpoints + one migration that touches `payment_sessions` and
+`chat_sessions`. Pricing stays mocked but moves from hardcoded to `app_config`.
+
+## 1.1 Schema migration
+
+> File: `backend/src/db/migrate.js` (single-file migration script — append).
+
+```sql
+-- 1. Replace is_free_trial with is_first_session_discount
+ALTER TABLE payment_sessions
+ ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false;
+
+UPDATE payment_sessions
+ SET is_first_session_discount = is_free_trial
+ WHERE is_free_trial = true
+ AND is_first_session_discount = false;
+
+ALTER TABLE payment_sessions DROP COLUMN IF EXISTS is_free_trial;
+
+-- 2. Add mode column for chat vs voice call
+ALTER TABLE payment_sessions
+ ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
+ CHECK (mode IN ('chat', 'call'));
+
+-- 3. Store ESP picks on chat session for info display
+ALTER TABLE chat_sessions
+ ADD COLUMN IF NOT EXISTS topics TEXT[];
+```
+
+> **Backwards compat note:** any service code still reading `is_free_trial`
+> will break — grep + cut over in §1.2. There is no production data to
+> protect (Phase 3.7 was the first ship of `is_free_trial` and it never went
+> live in real users per `project_pricing_still_mocked_3_7`).
+
+## 1.2 Free-trial → first-session-discount cutover
+
+> Files: `backend/src/services/pricing.service.js`,
+> `backend/src/services/payment.service.js`, any other reader.
+
+```bash
+grep -rn "is_free_trial\|free_trial\|freeTrial" backend/src
+```
+
+For each hit:
+- Replace the read with `is_first_session_discount`.
+- Replace any "free trial" copy in API response strings with neutral
+ language (the pricing block carries `actual_price_idr` etc. now).
+- Eligibility logic now reads:
+ - `users.phone_verified_at IS NOT NULL` (verified user), AND
+ - no `chat_sessions` row with `status IN ('completed','closed_by_user','closed_by_mitra')` for this customer.
+
+## 1.3 New + rewritten endpoints
+
+### `GET /api/client/onboarding-state` (new)
+> File: `backend/src/routes/client/onboarding.routes.js` (new) or extend `auth.routes.js`.
+
+```http
+200 OK
+{ "has_consulted_before": boolean,
+ "is_phone_verified": boolean,
+ "is_first_session_discount_eligible": boolean,
+ "is_anonymous": boolean
+}
+```
+- `is_first_session_discount_eligible` is the AND of:
+ `is_phone_verified` && `!has_consulted_before` &&
+ `app_config.first_session_discount_enabled == 'true'`.
+- Drives both `VerifChoiceSheet` visibility and S6 paywall display.
+
+### `GET /api/client/chat-pricing` (rewrite)
+> File: `backend/src/services/pricing.service.js`
+
+```http
+200 OK
+{ "chat": {
+ "tiers": [
+ { "id": "5", "minutes": 5, "price_idr": 5000, "tag": null },
+ { "id": "12", "minutes": 12, "price_idr": 12000, "tag": "paling pas" },
+ { "id": "30", "minutes": 30, "price_idr": 25000, "tag": "hemat" },
+ { "id": "60", "minutes": 60, "price_idr": 45000, "tag": null },
+ { "id": "120", "minutes": 120, "price_idr": 80000, "tag": "best deal" }
+ ]
+ },
+ "call": {
+ "tiers": [
+ { "id": "10", "minutes": 10, "price_idr": 9000, "tag": null },
+ { "id": "20", "minutes": 20, "price_idr": 17000, "tag": "paling pas" },
+ { "id": "45", "minutes": 45, "price_idr": 35000, "tag": null },
+ { "id": "60", "minutes": 60, "price_idr": 45000, "tag": "hemat" }
+ ]
+ },
+ "first_session_discount": {
+ "eligible": true,
+ "actual_price_idr": 2000,
+ "gimmick_price_idr": 12000,
+ "duration_minutes": 12,
+ "modes": ["chat"]
+ }
+}
+```
+- Tiers come from `app_config.pricing_chat_tiers_json` and
+ `pricing_call_tiers_json` (JSON arrays). The discount block reads its four
+ config values and the per-customer eligibility check.
+- `first_session_discount.eligible` is **per-customer** — uses the same
+ predicate as `onboarding-state.is_first_session_discount_eligible`.
+
+### `GET /api/shared/auth-providers` (new)
+> File: `backend/src/services/auth-providers.service.js` (new) +
+> `backend/src/routes/shared/auth-providers.routes.js` (new).
+
+```http
+200 OK
+{ "google": { "enabled": false },
+ "apple": { "enabled": false },
+ "phone": { "enabled": true } }
+```
+Probes env at module load:
+```js
+const enabled = (...keys) => keys.every(k => process.env[k] && process.env[k].trim());
+const google = enabled('GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET');
+const apple = enabled('APPLE_OAUTH_CLIENT_ID', 'APPLE_OAUTH_TEAM_ID',
+ 'APPLE_OAUTH_KEY_ID', 'APPLE_OAUTH_PRIVATE_KEY');
+```
+
+### `GET /api/client/support-handles` (new)
+```http
+200 OK
+{ "wa": { "label": "WhatsApp", "deeplink": "https://wa.me/62..." },
+ "telegram": { "label": "Telegram", "deeplink": "https://t.me/..." } }
+```
+Reads `app_config.support_handles_json`. CC will get a writable form for
+this in stage 8 (or earlier if convenient).
+
+## 1.4 Confirm `session_warning` event exists
+
+> File: `backend/src/services/session-timer.service.js`
+
+```bash
+grep -n "session_warning\|three_minutes\|180" backend/src/services/session-timer.service.js
+```
+
+If absent, add: when `secondsLeft == 180`, emit once over the customer WS:
+
+```json
+{ "type": "session_warning", "kind": "three_minutes_left", "session_id": "..." }
+```
+
+## 1.5 Seed `app_config` rows
+
+Append to seed step in `backend/src/db/migrate.js`:
+
+```sql
+INSERT INTO app_config (key, value) VALUES
+ ('payment_method_qris_first', 'true'),
+ ('payment_session_timeout_minutes', '20'),
+ ('searching_timeout_minutes', '5'),
+ ('end_session_two_step_confirm', 'true'),
+ ('three_minute_warning_enabled', 'true'),
+ ('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', '[
+ {"id":"5","minutes":5,"price_idr":5000,"tag":null},
+ {"id":"12","minutes":12,"price_idr":12000,"tag":"paling pas"},
+ {"id":"30","minutes":30,"price_idr":25000,"tag":"hemat"},
+ {"id":"60","minutes":60,"price_idr":45000,"tag":null},
+ {"id":"120","minutes":120,"price_idr":80000,"tag":"best deal"}
+ ]'),
+ ('pricing_call_tiers_json', '[
+ {"id":"10","minutes":10,"price_idr":9000,"tag":null},
+ {"id":"20","minutes":20,"price_idr":17000,"tag":"paling pas"},
+ {"id":"45","minutes":45,"price_idr":35000,"tag":null},
+ {"id":"60","minutes":60,"price_idr":45000,"tag":"hemat"}
+ ]'),
+ ('support_handles_json',
+ '{"wa":{"label":"WhatsApp","deeplink":"https://wa.me/6285173310010"},
+ "telegram":{"label":"Telegram","deeplink":"https://t.me/halobestie"}}'
+ )
+ON CONFLICT (key) DO NOTHING;
+```
+
+## 1.6 CC editor for new config rows
+
+> File: `control_center/src/pages/AppConfigPage.tsx` (existing) — add fields.
+
+A simple form section labeled "First-session discount" with the 5 keys. A
+second section "Pricing tiers (mock)" with two textareas (JSON-validated)
+for chat + call. A third section "Support handles" with WA + Telegram
+inputs. **No backend route changes needed** — CC already has a generic
+`PUT /internal/_config/:key` (verify name).
+
+## 1.7 Acceptance for Stage 1
+
+- Curl smoke against all four endpoints returns the documented shape.
+- `backend/test/` Vitest covers:
+ - `chat-pricing` returns chat + call groups; eligibility flips when the
+ customer has a completed session.
+ - `auth-providers` returns `{enabled:false}` when env vars unset, `true`
+ when set.
+ - `session_warning` 3-min ping fires once.
+- Migration is idempotent (re-run `migrate.js` on a populated DB does not
+ error or duplicate config rows).
+- No frontend change required to merge stage 1.
+
+---
+
+# Stage 2 — Onboarding Redesign
+
+> Resolves PRD §1, §2, §13.
+
+## 2.1 Verif Choice Sheet
+> **New:** `client_app/lib/features/auth/widgets/verif_choice_sheet.dart`
+
+`HaloBottomSheet` with two buttons. Built on top of Stage 0 primitives.
+
+Trigger location: `display_name_screen.dart` — after the user submits a name,
+read `onboarding-state`. If `has_paid_first_session == true`, jump straight to
+the duration picker; else show the sheet.
+
+Routes the user to one of two GoRouter paths:
+- `/onboarding/verif/esp`
+- `/onboarding/anon/esp`
+
+These are sibling shell routes that share the rest of the onboarding sequence.
+
+## 2.2 ESP screen (multi-select, info-only)
+> **New:** `client_app/lib/features/onboarding/screens/esp_screen.dart`
+> **Replaces existing usage of:** `lib/features/chat/widgets/topic_selection_bottom_sheet.dart`
+
+12 chips, multi-select. State held in a `StateProvider>` named
+`espSelectionProvider`. Skip CTA writes an empty set + a `skipped: true` flag.
+
+ESP is **purely informational** — the picks are persisted on
+`chat_sessions.topics` (column added in stage 1.1) and surfaced to the mitra
+on session start as a chip row above the first message bubble. **They do not
+affect matching, pricing, or routing.** Existing `pairing.service.js`
+topic-classification code stays untouched.
+
+The mitra-side display (chip row above first bubble) is a small `mitra_app`
+edit but considered in scope for stage 2 since it's read-only.
+
+## 2.3 USP screen
+> **New:** `client_app/lib/features/onboarding/screens/usp_screen.dart`
+
+Static — four feature cards + CTA `aku ngerti, lanjut →`.
+
+`HaloStepDots(total: 4, current: 2)` in the header.
+
+## 2.4 OTP screens — visual re-skin only
+> **Edit:** `client_app/lib/features/auth/screens/{register,otp}_screen.dart`
+
+- Re-style with Stage 0 widgets. Keep **6-digit** (resolved decision).
+- The Figma 4-digit boxes become 6 boxes laid out across the same horizontal
+ width — slightly tighter spacing.
+
+## 2.5 OTP-blocked popup
+> **New:** `client_app/lib/features/auth/widgets/otp_blocked_popup.dart`
+
+`HaloPopup` shown when `otp_screen.dart` receives a 429 with
+`error: "otp_retry_exhausted"`.
+- Primary: **`lanjut tanpa verif`** → routes to `/onboarding/anon/method`
+ with the `espSelectionProvider` and any USP-acknowledged flag preserved
+ (no re-prompt).
+- Secondary: `hubungi admin` → opens Tanya Admin sheet (built in stage 8;
+ stub a TODO `SnackBar` for now).
+
+Backend-side: confirm `otp.service.js` returns the 429 shape
+`{ error: "otp_retry_exhausted", retry_after_seconds: 1800 }`. If not, adjust.
+
+## 2.6 Auth-providers gating (replace `ENABLE_SOCIAL_AUTH` build flag)
+> **New:** `client_app/lib/core/auth/auth_providers_provider.dart`
+> **Edit:** `client_app/lib/features/auth/screens/welcome_screen.dart`,
+> `register_screen.dart`, any other screen with Google/Apple buttons.
+> **Edit:** `client_app/lib/core/auth/social_auth_enabled.dart` — read from
+> Riverpod provider instead of `bool.fromEnvironment`. Or delete this file
+> and inline the check at button render sites.
+
+Logic:
+1. On app cold start (in `main.dart` post-bootstrap), `ref.read(authProvidersProvider.future)` once and cache.
+2. Provider exposes `{google: bool, apple: bool, phone: bool}`.
+3. Each social button site reads the corresponding flag and renders nothing if `false`.
+4. If both are false, the welcome screen falls back to the phone-OTP-only layout.
+
+**Memory update:** the implementer must update
+`client_app/CLAUDE.md` to remove the `ENABLE_SOCIAL_AUTH` build-flag note.
+
+## 2.7 Acceptance for Stage 2
+
+- Maestro flow `02_onboarding_verified.yaml` covers Splash → Name → Verif Sheet
+ → ESP (pick chip) → USP → S3a → S3b (6-digit) → arrival at S6 paywall (when
+ eligible) or duration picker (when not).
+- Maestro flow `03_onboarding_anon.yaml` covers Splash → Name → Verif Sheet
+ ("anon") → ESP → USP → arrival at the (Stage 3) Pilih cara route.
+- Manual: trigger 5x failed OTP → blocked popup → "lanjut tanpa verif" lands
+ on the anonymous path with chips preserved.
+- Auth providers: backend started without OAuth env vars → social buttons
+ hidden on welcome/register screens; setting envs and restarting backend +
+ app → buttons appear.
+
+---
+
+# Stage 3 — Payment Shell
+
+> Resolves PRD §3, §4. Backend payment is **still mocked** — only the UI is
+> being built out.
+
+GoRouter additions (sibling routes under `/payment/`):
+- `/payment/discount-paywall` — S6 first-session discount (verified eligibles only)
+- `/payment/method-pick` — Pilih cara curhat (chat/call) — anonymous + non-eligible verified
+- `/payment/duration-pick` — Pemilihan harga (rebuilds from selected mode group)
+- `/payment/method` — Cara bayar (QRIS-first list)
+- `/payment/waiting/:paymentId` — Waiting Payment with QR + 20-min countdown
+- `/payment/expired/:paymentId` — Pembayaran expired
+
+State bag: `paymentDraftProvider` — a Riverpod `Notifier` that
+holds `mode (chat|call)`, `durationId`, `priceIDR`, `paymentId?`,
+`isFirstSessionDiscount` (bool). Cleared on arrival at the first stage when
+entered fresh; persisted across back-nav.
+
+## 3.1 S6 first-session discount paywall
+> **New:** `client_app/lib/features/payment/screens/discount_paywall_screen.dart`
+
+Renders only when `chat-pricing.first_session_discount.eligible == true`.
+Layout matches `screens/onboarding.jsx::S6Paywall`:
+- Struck-through `Rp{gimmick_price_idr}` next to prominent `Rp{actual_price_idr}`.
+- Subtitle: "untuk {duration} menit ngobrol".
+- CTA: `mulai · Rp{actual_price_idr}` → routes to `/payment/method` with
+ `paymentDraft = { mode: 'chat', duration_minutes: discount.duration_minutes,
+ price_idr: discount.actual_price_idr, isFirstSessionDiscount: true }`.
+
+If `modes` config is `["chat","call"]` (ops enabled call for first session),
+render a tiny mode toggle at the top — but in v1 default config, the screen
+is chat-only and the toggle is hidden.
+
+Routing decision (single point of truth):
+```dart
+// after S5b USP → next:
+if (eligible && discount.enabled) → /payment/discount-paywall
+else → /payment/method-pick
+```
+
+## 3.2 Pilih cara curhat
+> **New:** `client_app/lib/features/payment/screens/method_pick_screen.dart`
+
+Two cards (chat / call). The "premium" call indicator on the card is a
+visual cue, not a hard-coded multiplier. Tapping a card stores the mode in
+`paymentDraft.mode` and routes to `/payment/duration-pick`.
+
+## 3.3 Pemilihan harga
+> **New:** `client_app/lib/features/payment/screens/duration_pick_screen.dart`
+
+Reads `paymentDraft.mode`. Renders the corresponding tier list
+(`pricing.chat.tiers` or `pricing.call.tiers`). Top of the screen shows a
+**chat | call mode toggle** — toggling rebuilds the list from the other
+group and resets the selection.
+
+Selecting a tier sets `paymentDraft.priceIDR` and
+`paymentDraft.durationMinutes`; bottom CTA `{mode_icon} bayar Rp{price}`
+routes to `/payment/method`.
+
+## 3.4 Cara bayar
+> **New:** `client_app/lib/features/payment/screens/payment_method_screen.dart`
+
+Mirrors `screens/extras.jsx::SPaymentMethod`. QRIS at top with
+"DIREKOMENDASIKAN" pill; 4 e-wallet options below. Tapping `bayar`:
+1. Calls existing `POST /api/client/payment-sessions` (Phase 3.7) with
+ `{ mode, duration_minutes, price_idr, is_first_session_discount, method }`.
+2. Routes to `/payment/waiting/:paymentId`.
+
+## 3.5 Waiting Payment
+> **New:** `client_app/lib/features/payment/screens/waiting_payment_screen.dart`
+
+Renders a placeholder QR (use a `qr_flutter` package; add to pubspec) —
+in mock mode the QR encodes the `paymentId` only. Real QR string comes from
+the response.
+
+State: `Timer.periodic(const Duration(seconds: 1))` ticks the 20-min
+countdown for the header. Polling: `Timer.periodic(const Duration(seconds: 3))`
+hits `GET /api/client/payment-sessions/:id`. On `paid` → route to
+`/onboarding/notif-gate` (Stage 4). On `expired` → `/payment/expired/:paymentId`.
+
+Polling pauses when app is backgrounded (`WidgetsBindingObserver`).
+
+## 3.6 Pembayaran expired
+> **New:** `client_app/lib/features/payment/screens/payment_expired_screen.dart`
+
+Static screen + retry CTA → routes back to `/payment/method` with the
+`paymentDraft` retained (so the user re-pays the same plan, same mode,
+same discount flag if applicable).
+
+## 3.7 Acceptance for Stage 3
+
+- Curl: a fresh `payment_sessions` row with `is_first_session_discount=true`
+ goes to `paid` via the existing CC "mark as paid" tool → app advances.
+- Maestro: `04_payment_expired.yaml` exercises the timeout path.
+- Visual sanity: run `flutter run` for both eligible (S6 paywall first) and
+ ineligible (Pilih cara → Pemilihan harga first) users.
+- Mode toggle on duration picker: switching chat → call rebuilds the option
+ list from `pricing.call.tiers`; selection state resets.
+
+---
+
+# Stage 4 — Notif Gate + Home Banner
+
+> Resolves PRD §5.
+
+## 4.1 OS-permission helper
+> **New:** `client_app/lib/core/notifications/notif_permission.dart`
+
+Wraps `firebase_messaging` and `permission_handler` into:
+
+```dart
+Future readStatus(); // notDetermined | granted | denied
+Future request(); // shows OS prompt (only if notDetermined)
+Future openAppSettings(); // for "denied" path
+```
+
+Status cached in a Riverpod `notifPermissionProvider` that auto-refreshes when
+the app foregrounds (existing `appLifecycleProvider` pattern).
+
+## 4.2 Notif Gate full screen
+> **New:** `client_app/lib/features/onboarding/screens/notif_gate_screen.dart`
+
+Route: `/onboarding/notif-gate`. Shown post-payment (Stage 3 routes here).
+
+If status is already `granted`, redirect immediately to the searching shell
+(Stage 5). Otherwise render the screen with two CTAs:
+- `izinkan notifikasi` → calls `request()`. After resolution (any), advance.
+- `nanti aja` → advance.
+
+## 4.3 Home banner
+> **Edit:** `client_app/lib/features/home/home_screen.dart`
+
+Above-the-fold thin amber banner if `notifPermissionProvider == denied`.
+Dismissable for the session via a `homeNotifBannerDismissedProvider`
+(`StateProvider`). Persists nothing across cold-start.
+
+Tap `nyalain` → calls `openAppSettings()`.
+
+## 4.4 Acceptance for Stage 4
+
+- Cold-start with notif denied → banner visible. Dismiss → gone for session.
+- Cold-restart → banner reappears.
+- Notif Gate full screen: "nanti aja" advances; "izinkan" + grant advances; "izinkan" + deny advances and home banner shows.
+
+---
+
+# Stage 5 — Pairing UX Upgrades
+
+> Resolves PRD §6, §7, §11.3 (targeted-wait overlay only — choice sheet & list
+> visual upgrade live in stage 8).
+
+## 5.1 Soft-prompt screen
+> **Edit:** `client_app/lib/features/chat/screens/searching_screen.dart`
+
+The existing screen already has a reflective-prompt-card phase before the
+blast fires. Re-skin with Stage 0 widgets and confirm the CTA copy
+`aku ngerti, lanjut →`.
+
+## 5.2 Searching state visuals
+- Replace the current spinner with the v3 pulsing-dots panel
+ (`screens/v3.jsx::SSearchPrompt`).
+- No state-machine change.
+
+## 5.3 5-min timeout state
+- The pairing notifier (`lib/core/pairing/pairing_notifier.dart`) already
+ exposes a timeout state. Render the new copy + two CTAs:
+ - primary `coba cari lagi` → re-fires the blast (calls existing pairing
+ `retry` action).
+ - ghost `kembali ke home` → `context.go('/')`.
+
+## 5.4 S9 Match-found re-skin
+> **Edit:** `client_app/lib/features/chat/screens/bestie_found_screen.dart`
+
+Render the `S9MatchV4` layout: orb + status dot + `halo, aku bestie {name}` +
+CTA `mulai sesi {N} menit →`. `N` comes from the chat session's pricing tier.
+
+## 5.5 Targeted-wait overlay
+> **New:** `client_app/lib/features/chat/screens/targeted_waiting_screen.dart`
+
+Route: `/chat/waiting-targeted/:mitraId`. Renders the `SWaitingBestie`
+component with three sub-states (`waiting | accepted | declined`). The
+20-second countdown pulls from the pairing notifier's existing
+`PairingTargetedWaitingData`. On `accepted` → route to the chat screen; on
+`declined` → show the BestieOfflinePopup (built in stage 8) overlaid.
+
+Chat history's "Curhat lagi" button is updated to push this route instead of
+the current intermediate.
+
+## 5.6 Acceptance for Stage 5
+
+- Maestro: `05_searching_timeout.yaml` — make the backend return no mitras for
+ 6 minutes, confirm timeout state and both CTAs work.
+- Manual: open chat history → tap "Curhat lagi" on an online bestie →
+ 20s overlay → mitra accepts → chat opens.
+
+---
+
+# Stage 6 — Chat-room Countdown UX
+
+> Resolves PRD §8.
+
+## 6.0 Voice-call mode badge (header)
+> **Edit:** `client_app/lib/features/chat/screens/chat_screen.dart` and the
+> mitra_app equivalent (`mitra_app/lib/features/chat/screens/chat_screen.dart`).
+
+The session payload (loaded from `GET /api/{client|mitra}/chat-sessions/:id`)
+exposes `mode: 'chat' | 'call'` (sourced from `payment_sessions.mode`,
+column added in stage 1.1). The chat header renders a small pill next to
+the bestie/customer name:
+
+- `mode == 'call'` → `📞 Voice Call` pill in `HaloTokens.accent` color.
+- `mode == 'chat'` → either no pill, or a subtle `💬 Chat` pill (design
+ choice — default to no pill to reduce noise).
+
+URL rendering inside chat bubbles already handles plain links; confirm
+`meet.google.com/...` URLs launch the OS handler via `url_launcher`. No
+special "join meet" badge — links are plain.
+
+**No mitra composer helpers** for Meet links — mitra types/pastes the URL
+manually as a normal message. (Resolved decision; see bottom.)
+
+## 6.1 3-min snackbar
+> **Edit:** `client_app/lib/features/chat/screens/chat_screen.dart`
+
+Listen on the WS event stream for `session_warning.kind == 'three_minutes_left'`.
+On fire, call `HaloSnackbar.show(context, 'sisa 3 menit lagi ya 🤍', icon: '⏳')`.
+A `bool _threeMinShown` per-session flag prevents double-fire.
+
+## 6.2 Last-2-min danger visuals
+- Compute `remaining = secondsLeftProvider.watch(...)` (existing).
+- When `remaining <= 120`, swap the timer pill style (`HaloTokens.danger`
+ background + bold `JetBrainsMono` text) and the progress bar color.
+
+## 6.3 Floating expired banner
+- When `remaining == 0` and the session is in closing-grace
+ (existing flag — see memory `Phase 3 Session-End Overhaul`), inject
+ `ChatExpiredBanner` widget above the input bar.
+- Tap `perpanjang` → opens the time-up bottom sheet.
+
+## 6.4 Time-up sheet upgrade
+> **Edit (or replace):** `client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart`
+
+Cut over to the 5-option layout with chat/call toggle. Behavior of the
+`perpanjang` CTA is unchanged from Phase 3.7 — only UI changes.
+
+## 6.5 Acceptance for Stage 6
+
+- Maestro: `06_chat_countdown.yaml` — manipulate the session's `expires_at`
+ to drive 3-min snackbar, last-2-min visuals, and expired banner in one run.
+- Manual: chat session, observe the visual transitions.
+
+---
+
+# Stage 7 — End-of-session Sequence
+
+> Resolves PRD §10.
+
+## 7.1 Step-1 confirm popup
+> **New widget; trigger from chat screen.**
+- File: `client_app/lib/features/chat/widgets/confirm_end_step1.dart`
+- Primary `lanjut akhiri` → opens step-2 popup.
+- Secondary `gak jadi, balik` → close, stay in chat.
+
+## 7.2 Step-2 confirm popup
+> **New:** `client_app/lib/features/chat/widgets/confirm_end_step2.dart`
+- Primary `tulis pesan penutup` → opens closing-message sheet.
+- Secondary `lewati saja` → calls existing close-session API and routes
+ to S11.
+
+## 7.3 Closing-message bottom sheet
+> **Replace existing goodbye composer** (currently a screen) with a
+> bottom sheet at `client_app/lib/features/chat/widgets/closing_message_sheet.dart`.
+
+Textarea + two CTAs:
+- `kirim & akhiri sesi` → POSTs goodbye message + closes session.
+- `lewat — langsung akhiri` → closes without sending.
+
+## 7.4 S11 thank-you screen
+> **New:** `client_app/lib/features/chat/screens/thank_you_screen.dart`
+
+Route: `/chat/thank-you`. Replaces the current "navigate straight home"
+behavior. CTA `balik ke home` → `context.go('/')`.
+
+## 7.5 Mitra-rejects-close fallback
+- If close API returns 409, show BestieOfflinePopup (built in stage 8) with
+ the "returning" variant. No new asset for this stage.
+
+## 7.6 Acceptance for Stage 7
+
+- Maestro: `07_end_session_2step.yaml` covers chat → akhiri → step1 → step2 →
+ closing message → thank-you → home.
+- Manual: confirm the "gak jadi, balik" path returns cleanly to chat.
+
+---
+
+# Stage 8 — Returning-User Shell
+
+> Resolves PRD §11 (choice + list visual upgrade) and §12.
+
+## 8.1 Bestie Choice Sheet
+> **New:** `client_app/lib/features/home/widgets/bestie_choice_sheet.dart`
+
+`HaloBottomSheet` with two cards. Triggered from the home CTA when the user
+has at least one prior session (`bestieHistoryHasItems` provider).
+
+- `bestie yang udah kenal` → routes to chat history list.
+- `bestie baru` → routes to soft-prompt + blast (existing).
+
+## 8.2 Bestie history list — visual upgrade
+> **Edit:** `client_app/lib/features/chat/screens/chat_history_screen.dart`
+
+Render the v4 `BestieHistoryList` layout: orb + name + last-session date +
+topic + sessions count + ONLINE pill (live from the existing presence
+provider).
+
+## 8.3 Bestie Offline Popup variants
+> **Edit:** `client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart`
+
+Add a `variant: 'returning' | 'new'` param. The existing dialog covers the
+returning case; add the new-user copy ("semua bestie lagi istirahat") and
+wire `tanya admin` ghost link.
+
+## 8.4 Tanya Admin sheet
+> **New:** `client_app/lib/features/support/widgets/tanya_admin_sheet.dart`
+
+`HaloBottomSheet` with WA + Telegram buttons. Reads handles from Stage 1's
+endpoint via a `supportHandlesProvider`.
+
+Tapping launches `url_launcher` with the deeplink. No webview.
+
+## 8.5 Acceptance for Stage 8
+
+- Maestro: `08_returning_targeted.yaml` covers home → bestie choice sheet →
+ history list → online pick → 20s overlay → match.
+- Manual: pick offline bestie → BestieOfflinePopup → tanya admin → WA opens.
+
+---
+
+# Stage 9 — Test Sweep
+
+## 9.1 Maestro flows
+All under `client_app/.maestro/flows/`:
+- `01_smoke.yaml` — keep passing (existing).
+- `02_onboarding_verified.yaml` (stage 2)
+- `03_onboarding_anon.yaml` (stage 2)
+- `04_payment_expired.yaml` (stage 3)
+- `05_searching_timeout.yaml` (stage 5)
+- `06_chat_countdown.yaml` (stage 6)
+- `07_end_session_2step.yaml` (stage 7)
+- `08_returning_targeted.yaml` (stage 8)
+
+Helper scripts (`peek_otp.js`, `reset_phone.js`) reused; add
+`force_payment_state.js` and `force_session_warning.js` (call internal CC
+endpoints).
+
+## 9.2 Real-device run
+- Single AVD + physical Android per memory `Test Infrastructure`.
+- Capture screen recordings of each Maestro flow; save under
+ `requirement/phase4-testing/.mp4` (git-ignored — add to .gitignore).
+
+## 9.3 Visual regression
+- Manual sweep with the Figma `handoff/png/` images side-by-side. Goal: 95%
+ visual parity (copy must be exact; visual fidelity within 5%).
+
+---
+
+# Resolved Decisions (2026-05-09 — recorded from product review)
+
+| # | Decision |
+|---|---|
+| 1 | **OTP stays 6-digit.** Figma 4-digit is stylistic only; we keep backend security parity. The visual row of 6 boxes uses tighter spacing to fit the same width. |
+| 2 | **First-session call-mode lock.** First sessions are chat-only by default but **configurable** via `app_config.first_session_discount_modes` (default `["chat"]`; ops can flip to `["chat","call"]` to enable). |
+| 3 | **Tanya Admin handles are CC-config-driven.** No hard-coded constants — sourced from `app_config.support_handles_json`, edited via CC. |
+| 4 | **First-session-chat-only behavior** is the configurable knob in #2; when pricing goes real, the same knob is reused. |
+| 5 | **ESP is information-only.** Tags are persisted on `chat_sessions.topics` and shown to the mitra at session start. **No matching, no routing, no pricing impact.** Topic-aware matching is not in any current phase. |
+| 6 | **OTP-blocked popup carries over ESP/USP** within the same OTP attempt — user is never re-prompted after falling back to anon. (Cross-session: the values clear on app close.) |
+| 7 | **Voice-call mode is just chat with a different price group + a `📞 Voice Call` badge in the chat header.** Mitra shares Google Meet (or any) link as a normal chat message. **No validation, no in-app call media, no composer helpers** — purely ops-handled. |
+| + | **No free trial.** The previous Phase 3 free-trial concept is removed. Replaced by a configurable first-session discount (PRD §3.5). |
+| + | **Social login is server-driven.** Backend probes env at boot; client_app reads `GET /api/shared/auth-providers` and hides Google/Apple buttons when the corresponding keys are not configured. The `--dart-define=ENABLE_SOCIAL_AUTH` build flag is removed. |
+
+---
+
+# Risk Register
+
+| Risk | Likelihood | Mitigation |
+|---|---|---|
+| Font assets bloat APK by ~3 MB | high | Subset to `latin-ext` only — drops to ~600 KB; use `flutter pub run flutter_font_subset` |
+| ThemeData rollout breaks pre-Phase-3.7 screens | medium | Stage 0 includes a visual diff sweep on existing screens before merging |
+| 20-min QRIS polling kills battery | low | Polling pauses on background; 3s interval is mild |
+| `is_free_trial` → `is_first_session_discount` migration leaves a service hot reading the dropped column | medium | Stage 1.2 grep-and-replace pass before migration runs; CI integration test that boots the backend post-migration |
+| First-session-discount eligibility check races with payment commit | low | Check is server-authoritative on `payment-sessions` create; client never decides eligibility |
+| `S11` thank-you delay feels slow on flaky network | low | Optimistic close: navigate to S11 immediately, retry close API in background |
+| Two-step end-session feels naggy | medium | A/B switch via `app_config.end_session_two_step_confirm` (already in stage 1.5) |
+| Auth-providers cache stale after env change | low | Module-load probe is fine for prod (restart on env change); in dev, document that backend restart is needed for the flag to flip |
+| Mitra pastes a non-Meet URL in a `mode='call'` session | low | Out of scope — ops handles. Phase 4 does no validation. Optional follow-up: detect link missing in last N seconds → mitra-side reminder snackbar (not in plan). |
+
+---
+
+# Memory Touchpoints
+
+This phase will likely add or update these memory entries when work begins:
+- `project_phase4_status.md` — kick-off + progress
+- `project_design_system_setup.md` — that we now have a tokenized theme
+- `project_otp_followups_resolved.md` — closes the OTP rate-limit followup item
+- `feedback_*` — only if a new convention is established (e.g., a Halo* widget naming rule)
+
+---
+
+# Definition of Done (Phase 4)
+
+1. All PRD acceptance criteria pass on the real-device run.
+2. All 8 Maestro flows green on CI (or local single-emulator run).
+3. `flutter analyze` clean across client_app.
+4. `npm test` clean in backend; new endpoints covered.
+5. Figma `png/` reference vs. live app: spot-check sweep ≥ 95% visual parity, copy 100% exact.
+6. PRD's "Out of scope" list confirmed not introduced (no rating, no SOS, no subscription).
+7. The 7 open questions above are answered + recorded in this plan (replace
+ the section with "Resolved decisions").
diff --git a/requirement/phase4-customer-flow.md b/requirement/phase4-customer-flow.md
new file mode 100644
index 0000000..ee0bac5
--- /dev/null
+++ b/requirement/phase4-customer-flow.md
@@ -0,0 +1,601 @@
+# 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.
+
+## 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.