Files
halobestie-clone/requirement/flow_customer.mermaid.md
ramadhan sjamsani 350b92f1f3 Phase 4 Stage 10 backend: Chat-tab feeds (pending payments + cursor history)
Backend half of Stage 10 — the new Chat tab in the customer app that
replaces /chat/history with a 3-sub-tab list (Aktif / Pembayaran /
Selesai).

- New GET /api/client/payment-sessions/pending — returns the customer's
  pending initial + extension payment sessions. Filter is status='pending'
  AND expires_at > NOW(). Mitra info comes from session_extensions →
  chat_sessions for extension rows, payment_sessions.targeted_mitra_id
  for targeted-curhat-lagi initial rows. TTL reuses the existing
  payment_session_timeout_minutes app_config row (default 20m) — no new
  config row needed since payment is still mocked.

- getCustomerHistory migrated from offset (page/limit) to cursor
  pagination. Cursor is base64url(`<endedAtIso>|<id>`) with id-tiebreak
  in ORDER BY so rows with identical timestamps don't duplicate or skip
  across pages. SELECT now JOINs payment_sessions to surface `mode`
  (chat/call) for the Selesai-row voice-call pill.

- requirement/flow_customer.mermaid.md: new §7 Chat Tab subgraph + Figma
  cross-ref entry for SChatList.

- requirement/phase4-customer-flow-plan.md: Stage 10 plan section. Also
  carries forward earlier uncommitted "Post-Stage-8 corrections" notes
  from the Stage 9 sweep (boot path / SHome1st / onboarding fixes).

Tests: +7 for getCustomerPendingPayments (initial null mitra,
targeted-mitra fill, extension-via-session JOIN, mixed-newest-first,
expired excluded, non-pending excluded, customer scoping). +10 for
cursor history (empty, exact-fit, multi-page walk, same-timestamp
tiebreak, limit clamp, customer scoping, CLOSING+COMPLETED only).

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

319 lines
15 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.
# 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)<br/>+ login panel 🟢"]
Home -->|"yes"| HomeRet["Home (returning)<br/>+ 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'<br/>(1st time)"| NewUser["→ New User flow"]
CTA -->|"'curhat sama bestie baru'<br/>(returning)"| ReturningUser["→ Returning flow"]
classDef missing fill:#ffe5e5,stroke:#c44979
classDef partial fill:#fff4d6,stroke:#c69b3f
```
---
## 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<br/>(VerifChoiceSheet) 🟢"]
VerifChoice -->|"verif WA · Rp2k"| USPGateA{"usp_seen flag? 🔴"}
VerifChoice -->|"tanpa verif · Rp5k+"| USPGateB{"usp_seen flag? 🔴"}
%% Verified path
USPGateA -->|"no · first-timer"| USPa["S5b · USP screen 🟢"]
USPGateA -->|"yes · skip"| S3a
USPa --> S3a["S3a · WhatsApp input 🟢"]
S3a --> S3b["S3b · OTP 4-digit 🟡"]
S3b --> OTPok{"OTP ok?"}
OTPok -->|"too many retries"| OTPBlock["OTP Blocked Popup 🟢<br/>→ fallback to Anon"]
OTPBlock --> USPGateB
OTPok -->|"verified"| UserLookup{"user found in DB?<br/>(phone match) 🔴"}
UserLookup -->|"no · brand-new"| S6["S6 · Paywall Rp2.000<br/>(12 menit, sekali seumur hidup) 🟢"]
UserLookup -->|"yes · existing account"| LoadCallSign["Load stored call_sign<br/>→ overwrite local call_sign 🔴"]
LoadCallSign --> TransactedCheck{"has_transacted flag? 🔴"}
TransactedCheck -->|"no · never paid"| S6
TransactedCheck -->|"yes · returning verified"| PickMethod
S6 --> Pay
%% Anonymous path
USPGateB -->|"no · first-timer"| USPb["S5b · USP screen 🟢"]
USPGateB -->|"yes · skip"| PickMethod
USPb --> PickMethod["Pilih cara curhat<br/>(chat / voice call) 🟢"]
PickMethod --> PickDuration["Pemilihan harga<br/>(5 durations, full screen) 🟢"]
PickDuration --> PayMethod["Cara bayar (QRIS-first) 🟢"]
PayMethod --> Pay
%% Shared payment exit
Pay["Xendit checkout<br/>(QRIS / e-wallet) 🟡"] --> WaitPay["Waiting Payment<br/>(20-min QRIS clock) 🟢"]
WaitPay --> PayStat{"payment status"}
PayStat -->|"timeout 20 min"| PayExpired["Pembayaran expired 🟢<br/>→ retry"]
PayExpired --> Pay
PayStat -->|"paid"| NotifGate
classDef missing fill:#ffe5e5,stroke:#c44979
classDef partial fill:#fff4d6,stroke:#c69b3f
class UserLookup,LoadCallSign,TransactedCheck,USPGateA,USPGateB missing
class S3b,Pay partial
```
> **ESP screening removed (2026-05-12):** the S5 ESP multi-select chip
> screen is retired from the spec. Both verified and anonymous branches
> now go from `VerifChoiceSheet` straight to the `usp_seen?` gate. Any
> ESP code still in `client_app` is now tech debt to retire — see
> `TECH_DEBT.md`.
>
> **S5b USP is one-time-only (2026-05-12):** the USP screen now shows at
> most once per user. Gating is driven by a `usp_seen` flag that lives in
> two places:
> - **Local (SharedPreferences):** the runtime gate. Set to `true` when the
> user dismisses the USP screen for the first time. Survives across
> sessions on the same device.
> - **DB (`customers.usp_seen` column 🔴):** the cross-device source of truth.
> Written when the user account is created (post-OTP, after JWT issuance
> and `users` row insert) if local flag is already true, OR on any
> subsequent USP dismissal once an account exists. Read on login/relogin
> and hydrated back into local. "True wins" — if either side says seen,
> the gate is closed.
>
> Anonymous users with no account only have the local flag; if they later
> upgrade to verified, account creation propagates the local flag to DB.
> Returning verified users on a fresh device will see USP at most once on
> that device (DB hydrate happens on login, after USP gate fires pre-OTP).
> Business has accepted this edge case.
>
> **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.
>
> **Post-OTP account lookup (added 2026-05-11):** the verified path is not
> always "new user". After a successful OTP, the backend looks up the phone
> number; if a row exists, the app overwrites the freshly-typed call_sign with
> the stored one. Then `has_transacted` decides routing:
> - `false` → S6 Paywall (Rp2.000 first-session) — user has an account but
> never converted.
> - `true` → jump straight into `PickMethod` (the regular chat/voice +
> duration + payment flow). USPb is already skipped because USPGateA
> already evaluated pre-OTP on the verified branch.
>
> `has_transacted` is the persistent flag on the users table that flips the
> first-time pricing off forever. Backend phone-lookup behaviour already
> exists (see Phase 1 auto-link via phone); the app-side reconciliation +
> `has_transacted` plumbing is the new work.
---
## 3. Pre-pairing → Searching → Match (shared)
```mermaid
flowchart TD
NotifGate["Notif Gate Screen 🟢<br/>(Aktifkan / Nanti Saja)"] --> NotifBranch{"OS allowed?"}
NotifBranch -->|"no + ask"| EnableNotif["OS settings deeplink"] --> SoftPrompt
NotifBranch -->|"yes / skipped"| SoftPrompt
SoftPrompt["S7 · Soft-prompt<br/>(consent + warmup, CTA 'Aku ngerti, Lanjut') 🟡"] --> Blast
Blast["Blast pair request<br/>S7 · Searching state 🟢"] --> BlastTimer{"5-min timer"}
BlastTimer -->|"matched"| S9["S9 · Match Found<br/>(bestie name, age, hobi) 🟡"]
BlastTimer -->|"timeout"| S7Timeout["S7 · Timeout 5 menit 🟢<br/>CTA 'Coba Cari Lagi'<br/>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 SoftPrompt,S9 partial
```
---
## 4. Returning-User pairing (lama / baru)
```mermaid
flowchart TD
CTA["'curhat sama bestie baru' 🟢"] --> Choice["Bestie Choice Sheet<br/>(BestieChoiceSheet) 🟢"]
Choice -->|"bestie yang udah kenal"| HistList["Bestie History List<br/>(BestieHistoryList) 🟢"]
Choice -->|"bestie baru"| BlastFlow["→ S7 Soft-prompt + Blast<br/>(see diagram 3)"]
HistList --> PickBestie["pick bestie"]
PickBestie --> CheckOnline{"bestie online?"}
CheckOnline -->|"no"| OfflinePopup["Bestie Offline Popup<br/>(returning variant) 🟢"]
OfflinePopup -->|"cari bestie lain"| BlastFlow
OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin<br/>(WA / Telegram) 🟢"]
CheckOnline -->|"yes"| Targeted["Request targeted pair<br/>'Menunggu bestie tertentu' 🟢<br/>(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
```
---
## 5. Chat Room (S10) — countdown UX
```mermaid
flowchart TD
Enter["enter S10 · Chat Room<br/>(WebSocket open, timer running) 🟢"] --> T3{"3-minutes-left tick"}
T3 -->|"fired"| Snackbar["S10 · Snackbar reminder<br/>'sisa 3 menit lagi ya' 🟢"]
Snackbar --> T2
T3 -->|"not yet"| T2{"2-minutes-left tick"}
T2 -->|"fired"| LowTime["S10 · Last 2 Minutes<br/>(timer turns danger color) 🟢"]
LowTime --> Expire
T2 -->|"not yet"| Expire{"timer hits 0"}
Expire -->|"fired"| ExpiredBanner["S10 · Floating Expired Banner<br/>'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<br/>(5 durations · chat/call toggle) 🟢"]
TimeUp -->|"perpanjang"| AskMitra["Targeted re-pay request<br/>(same mitra, no blast) 🟢"]
TimeUp -->|"cukup, akhiri sesi"| EndFlow
AskMitra --> MitraRes{"mitra approves?"}
MitraRes -->|"yes + paid"| Enter
MitraRes -->|"reject"| OfflinePopup["Bestie Offline Popup<br/>(returning variant) 🟢"]
classDef missing fill:#ffe5e5,stroke:#c44979
classDef partial fill:#fff4d6,stroke:#c69b3f
```
---
## 6. End-of-session sequence (2-step confirm + closing message)
```mermaid
flowchart TD
EndStart["End-session entry<br/>(from S10 or Time-up sheet 'Cukup, Akhiri')"] --> Confirm1["Popup · Konfirmasi Akhiri (1)<br/>'beneran udah cukup?' 🟢"]
Confirm1 -->|"Gak Jadi, Balik"| TimeUp["Time-up Bottom Sheet 🟢"]
Confirm1 -->|"Lanjut Akhiri"| Confirm2["Popup · Konfirmasi Akhiri (2)<br/>'mau tinggalin pesan penutup?' 🟢"]
Confirm2 -->|"Tulis Pesan Penutup"| ClosingSheet["Pesan Penutup Bottom Sheet<br/>(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
```
---
## 7. Chat Tab (3 sub-tabs) — Phase 4 Stage 10
```mermaid
flowchart TD
HomeTabBar["HaloTabBar · tap '💬 chat'"] --> ChatRedirect["/chat redirect 🟡"]
ChatRedirect --> Aktif["/chat/aktif · sub-tab 🟡"]
Aktif -->|"tap pill 'pembayaran'"| Pembayaran["/chat/pembayaran · sub-tab 🟡"]
Aktif -->|"tap pill 'selesai'"| Selesai["/chat/selesai · sub-tab 🟡"]
Pembayaran -->|"tap pill 'aktif'"| Aktif
Pembayaran -->|"tap pill 'selesai'"| Selesai
Selesai -->|"tap pill 'aktif'"| Aktif
Selesai -->|"tap pill 'pembayaran'"| Pembayaran
Aktif -->|"empty"| AktifEmpty["empty state · 'belum ada chat di sini' 🟡"]
Pembayaran -->|"empty"| PembayaranEmpty["empty state · 'belum ada pembayaran tertunda' 🟡"]
Selesai -->|"empty"| SelesaiEmpty["empty state · 'belum ada riwayat curhat' 🟡"]
Aktif -->|"tap active session row"| ChatRoom["S10 · Chat Room 🟢"]
Pembayaran -->|"tap pending payment row"| WaitingPayment["S7 · Waiting Payment 🟢"]
Selesai -->|"tap past session row"| Transcript["Read-only transcript /chat/transcript/:id 🟢"]
classDef missing fill:#ffe5e5,stroke:#c44979
classDef partial fill:#fff4d6,stroke:#c69b3f
class ChatRedirect,Aktif,Pembayaran,Selesai,AktifEmpty,PembayaranEmpty,SelesaiEmpty partial
```
**Data sources**
- Aktif → existing `GET /api/client/chat/session/active-with-unread` (0 or 1 row)
- Pembayaran → new `GET /api/client/payment-sessions/pending` (filters `status='pending' AND expires_at > NOW()`)
- Selesai → existing `GET /api/client/history`, migrated to cursor pagination (`{ items, next_cursor, has_more }`)
**Badges**
- Bottom-nav `chat` tab → red dot when Pembayaran `total > 0`
- `aktif` sub-tab pill → numeric badge from `unread_count`
- `pembayaran` sub-tab pill → numeric badge from `total`
- `selesai` sub-tab pill → no badge
---
## 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` | §23 |
| 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`~~ | **retired 2026-05-12** — code is tech debt |
| S5b USP (one-time) | `screens/onboarding.jsx::S5USP` | §5.1.2.1.2 / §5.1.2.2.2 — gated by `usp_seen` |
| 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 |
| Chat Tab (3 sub-tabs) | `screens/extras.jsx::SChatList` | §7 |
Anything Figma describes that flow_customer.md doesn't mention is captured as a
gap in `phase4-customer-flow.md` (next-phase doc).