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>
15 KiB
Customer App Flow — Mermaid Diagrams
Generated from
requirement/flow_customer.mdand 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
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)
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
VerifChoiceSheetstraight to theusp_seen?gate. Any ESP code still inclient_appis now tech debt to retire — seeTECH_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_seenflag that lives in two places:
- Local (SharedPreferences): the runtime gate. Set to
truewhen the user dismisses the USP screen for the first time. Survives across sessions on the same device.- DB (
customers.usp_seencolumn 🔴): the cross-device source of truth. Written when the user account is created (post-OTP, after JWT issuance andusersrow 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 putsVerifChoiceSheetbefore 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_transacteddecides routing:
false→ S6 Paywall (Rp2.000 first-session) — user has an account but never converted.true→ jump straight intoPickMethod(the regular chat/voice + duration + payment flow). USPb is already skipped because USPGateA already evaluated pre-OTP on the verified branch.
has_transactedis 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_transactedplumbing is the new work.
3. Pre-pairing → Searching → Match (shared)
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)
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
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)
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
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(filtersstatus='pending' AND expires_at > NOW()) - Selesai → existing
GET /api/client/history, migrated to cursor pagination ({ items, next_cursor, has_more })
Badges
- Bottom-nav
chattab → red dot when Pembayarantotal > 0 aktifsub-tab pill → numeric badge fromunread_countpembayaransub-tab pill → numeric badge fromtotalselesaisub-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 |
§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 |
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).