Spec §2 (flow_customer.mermaid) routes post-OTP based on user-lookup + has_transacted, but the implementation previously dumped every OTP success on /home. Introduce `OnboardingIntent` provider: set to `onboarding` by routeForVerifChoice's verified branch (the "aku mau curhat" transaction journey), set to `recover` by SHome1st's masuk → banner. Router redirect on AuthAuthenticatedData+isAuthRoute consumes it: `onboarding` → /payment/entry (dispatches S6 paywall vs PickMethod via first_session_discount.eligible); `recover` → /home. Intent is reset in /payment/entry's initState so subsequent masuk → flows don't inherit it. auth_notifier.verifyOtp uses .copyWithPrevious on AsyncError so valueOrNull retains AuthOtpSentData/AuthAnonymousData through OTP failures — required for the OTP-blocked recovery path (/onboarding/anon/method → /payment/method-pick) to clear the global redirect without bouncing to /home. Router also extends the isAuthRoute/isOnboardingFlow carve-out to AuthOtpSentData. Maestro tests adopt `ts-<app>-<NN>-<MM>-<descriptor>.yaml` convention: NN = mermaid section, MM = sub-flow index. New ts-customer-02-01..05 cover the §2 branches (verified brand-new → S6, existing-no-tx → S6, existing-tx → method-pick, OTP-blocked → method-pick, anonymous first- timer → method-pick); deferred 02-06/07/08/09 documented in README_section_02.md. TS-07 → ts-customer-02-10 (masuk → recovery); TS-01..06 → ts-customer-04-01..06 (§4 returning-user). Shared onboarding_new_user_verified.yaml subflow extracted. Register screen's body Column now uses LayoutBuilder + SingleChildScrollView + ConstrainedBox + IntrinsicHeight so the keyboard-open layout no longer overflows by 1.3 px (verified visually). Spec prose updated at flow_customer.mermaid §2 to describe the intent-driven routing + login-vs-transaction divergence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 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.Implementation (2026-05-18): post-OTP routing is driven by an
onboardingIntentProvider(client_app/lib/core/auth/onboarding_intent_provider.dart) that's set toOnboardingIntent.onboardingbyrouteForVerifChoice(verified branch inverif_choice_sheet.dart) and consumed by the router redirect forAuthAuthenticatedDataon any auth route. When the intent isonboarding, the redirect returns/payment/entry; otherwise (defaultrecover, set by the masuk → handler) it returns/home./payment/entrythen dispatches S6 vs PickMethod via the backend'sfirst_session_discount.eligibleflag — which is computed as "phone-verified AND no prior completed chat_sessions" inpricing.service.js::isCustomerEligibleForFirstSessionDiscount. That single check covers both "brand-new" and "existing-but-never-paid" (UserLookup=no and UserLookup=yes+has_transacted=false in the mermaid above).Login-vs-transaction divergence: the SHome1st "masuk →" login-recover banner pushes
/auth/registerwith intent left atrecover, so its post-OTP path lands on/home(the user expects to see their chat history, not be thrown into payment). This is a deliberate departure from a strict reading of the mermaid arrow, motivated by the user directive that login-intent and transaction-intent entries should not share the same landing zone. Maestro coverage: client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml. Tests for the §2 transaction-CTA branches live underts-customer-02-01..05; see client_app/.maestro/flows/README_section_02.md.
2.1 Anonymous → existing-user merge (post-transaction OTP) 🔴
Concern: a real user can accidentally transact as anonymous (skipped login, picked "tanpa verifikasi", or reopened the app after logout) and only later verify their phone. If that phone already belongs to another customer row in our DB, the anonymous customer's transactions live under a separate, unreachable identity — a data gap where the same human shows up as two distinct customers. The flow must capture this transition so the orphan can be reconciled with the real account later.
flowchart TD
Open["Open app · fresh install or post-logout"] --> Transact["Anonymous transact(s) under Anon-row · S2→S6→…→S10→close"]
Transact --> More{"more sessions?"}
More -->|"yes"| Transact
More -->|"no · verify phone"| OTP["S3a/S3b OTP"]
OTP -->|"OTP ok"| PhoneLookup{"phone exists in customers?"}
PhoneLookup -->|"5.1 · new phone"| AnonUpgrade["Upgrade Anon-row in place<br/>(set phone, preserve display_name<br/>+ has_transacted + history) 🔴"]
AnonUpgrade --> Continue["Resume verified flow as same id"]
PhoneLookup -->|"5.2 · existing user"| StampBelongsTo["5.2.1 · stamp Anon.account_belongs_to = Existing.id<br/>(keep Anon row + its chat_sessions/transactions intact) 🔴"]
StampBelongsTo --> ReloginAs["5.2.2 · issue tokens for Existing.id<br/>app re-logs in as Existing 🔴"]
ReloginAs --> Continue
classDef partial fill:#fff7e6,stroke:#d4a017
class StampBelongsTo,ReloginAs,AnonUpgrade partial
Schema (new): add a nullable self-FK on the customers table:
account_belongs_to UUID NULL REFERENCES customers(id). This points from an orphaned anon row → the real account it should be merged into. The column is just the breadcrumb — actually movingchat_sessions,customer_transactions,payment_sessions,pairing_failuresonto the real account is a separate reconciliation step deferred to a later phase. Keeping the anon row intact (rather than deleting it) preserves the historical record and lets reconciliation be replayed/audited.
Flow:
- User opens the app (fresh install or after logout).
- User taps "aku mau curhat" and proceeds through S2 Nama → VerifChoice.
- User picks "tanpa verifikasi" (anonymous path).
- One or more anonymous transactions complete and close.
- User later opts to verify phone (S3a/S3b OTP). Backend looks up phone:
- 5.1 · phone not found → upgrade the active anon row in place: set
phone, keepdisplay_name,has_transacted,usp_seen. The same customer id continues; all prior anonymous transactions are now attached to the verified identity automatically.- 5.2 · phone exists (Existing-row) →
- 5.2.1 stamp
account_belongs_to = Existing.idon the active anon row. The anon row is not deleted; itschat_sessions/customer_transactionsFKs stay valid.- 5.2.2 issue tokens for
Existing.idand re-login the app as that user — the app should treat this like a normal returning verified session (overwrites local call_sign per mermaid §2 line 62; honorshas_transactedto skip S6 paywall).
Replaces current behaviour: The legacy
normalizeIdentityConflicthelper threwIDENTITY_ALREADY_LINKED(409) for case 5.2 across all three identity paths (phone/Google/Apple). It has been deleted; the new sharedresolveCustomerForIdentity(auth.service.js) implements the 4-case merge logic uniformly. The customer app currently only exercises the phone-OTP path, but Google and Apple behave identically once their credentials land.
Auth source for the anon id (security): the backend identifies the anon row to stamp only from a verified Bearer JWT presented as
Authorization: Bearer <token>on/otp/verify. The earlier body fieldanonymous_customer_idis no longer read — accepting it would let anyone who learns a victim's anon UUID mis-route their transactions. Bearer tokens are HS256-signed withAUTH_JWT_SECRETand unforgeable. SeeresolveAnonymousCustomerIdin client.auth.routes.js. Client implication: on the verified-OTP path the app must carry the anonymous session's access token throughrequestOtp→verifyOtp(today the client usesskipAuth: trueand drops the bridge token — needs to be fixed alongside theAuthOtpSentDatacarry-through bug, which is the "real account verification implementation" tracked separately).
Note on multi-merge: if a user does this dance repeatedly across installs/logouts, multiple anon rows can accumulate, each pointing at the same
account_belongs_to. That's fine — reconciliation later walks the set; treataccount_belongs_toas many-to-one.
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"| PickMethod
HistList --> PickBestie["pick bestie"]
PickBestie --> CheckOnline{"bestie online?"}
CheckOnline -->|"no"| OfflinePopup["Bestie Offline Popup<br/>(returning variant) 🟢"]
OfflinePopup -->|"cari bestie lain"| PickMethod
OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin<br/>(WA / Telegram) 🟢"]
CheckOnline -->|"yes"| 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["Xendit checkout<br/>(QRIS / e-wallet) 🟡"]
Pay --> 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"| PairRoute{"specific bestie?<br/>(branch user came from)"}
PairRoute -->|"yes · lama"| Targeted["Request targeted pair<br/>'Menunggu bestie tertentu' 🟢<br/>(20s countdown overlay)"]
PairRoute -->|"no · baru / cari lain"| BlastFlow["→ S7 Soft-prompt + Blast<br/>(see diagram 3)"]
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 PickMethod,PickDuration,PayMethod,Pay,WaitPay,PayExpired partial
Payment block added 2026-05-18: the
PickMethod → … → WaitPaychain mirrors §2's payment block — the screens already exist (reused from §2), but the routing for the returning branches through them is not yet wired inclient_app. Three call-sites converge atPickMethod:
Choice → "bestie yang udah kenal" → PickBestie → CheckOnline (yes)— pay, then targeted pairChoice → "bestie baru"— pay, then blast (handoff to §3)OfflinePopup → "cari bestie lain"— pay, then blast (handoff to §3)After
PayStat → "paid", thePairRoutedecision dispatches by the branch the user came from: targeted pair (case 1) or blast/§3 (cases 2 & 3). Today the lama branch (case 1) goes fromPickBestiestraight into a tier-pick + auto-confirm shortcut that skips QRIS; the baru branch (case 2) hops straight into §3 without paying — both tracked as the Stage-5 returning-user payment migration. The 🟡 marks reflect "screen exists, branch wiring missing", not new screens to build. Mitra-side targeted accept/reject UX is unchanged.
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).