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>
459 lines
23 KiB
Markdown
459 lines
23 KiB
Markdown
# 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.
|
||
>
|
||
> **Implementation (2026-05-18):** post-OTP routing is driven by an
|
||
> `onboardingIntentProvider` (`client_app/lib/core/auth/onboarding_intent_provider.dart`)
|
||
> that's set to `OnboardingIntent.onboarding` by `routeForVerifChoice`
|
||
> (verified branch in `verif_choice_sheet.dart`) and consumed by the
|
||
> router redirect for `AuthAuthenticatedData` on any auth route. When the
|
||
> intent is `onboarding`, the redirect returns `/payment/entry`; otherwise
|
||
> (default `recover`, set by the masuk → handler) it returns `/home`.
|
||
> `/payment/entry` then dispatches S6 vs PickMethod via the backend's
|
||
> `first_session_discount.eligible` flag — which is computed as
|
||
> "phone-verified AND no prior completed chat_sessions" in
|
||
> `pricing.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/register` with intent left at `recover`, 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](../client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml).
|
||
> Tests for the §2 transaction-CTA branches live under
|
||
> `ts-customer-02-01..05`; see
|
||
> [client_app/.maestro/flows/README_section_02.md](../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.
|
||
|
||
```mermaid
|
||
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 moving `chat_sessions`,
|
||
> `customer_transactions`, `payment_sessions`, `pairing_failures` onto 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:**
|
||
> 1. User opens the app (fresh install or after logout).
|
||
> 2. User taps "aku mau curhat" and proceeds through S2 Nama → VerifChoice.
|
||
> 3. User picks "tanpa verifikasi" (anonymous path).
|
||
> 4. One or more anonymous transactions complete and close.
|
||
> 5. 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`, keep `display_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.id` on the active
|
||
> anon row. The anon row is **not** deleted; its
|
||
> `chat_sessions`/`customer_transactions` FKs stay valid.
|
||
> - **5.2.2** issue tokens for `Existing.id` and 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; honors `has_transacted` to skip S6 paywall).
|
||
|
||
> **Replaces current behaviour:**
|
||
> The legacy `normalizeIdentityConflict` helper threw `IDENTITY_ALREADY_LINKED`
|
||
> (409) for case 5.2 across all three identity paths (phone/Google/Apple).
|
||
> It has been deleted; the new shared `resolveCustomerForIdentity`
|
||
> ([auth.service.js](../backend/src/services/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 field
|
||
> `anonymous_customer_id` is no longer read — accepting it would let anyone
|
||
> who learns a victim's anon UUID mis-route their transactions. Bearer
|
||
> tokens are HS256-signed with `AUTH_JWT_SECRET` and unforgeable. See
|
||
> `resolveAnonymousCustomerId` in
|
||
> [client.auth.routes.js](../backend/src/routes/public/client.auth.routes.js).
|
||
> Client implication: on the verified-OTP path the app must carry the
|
||
> anonymous session's access token through `requestOtp` → `verifyOtp` (today
|
||
> the client uses `skipAuth: true` and drops the bridge token — needs to be
|
||
> fixed alongside the `AuthOtpSentData` carry-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; treat `account_belongs_to` as many-to-one.
|
||
|
||
---
|
||
|
||
## 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"| 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 → … → WaitPay` chain
|
||
> 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 in `client_app`. Three call-sites converge at `PickMethod`:
|
||
> 1. `Choice → "bestie yang udah kenal" → PickBestie → CheckOnline (yes)` — pay, then targeted pair
|
||
> 2. `Choice → "bestie baru"` — pay, then blast (handoff to §3)
|
||
> 3. `OfflinePopup → "cari bestie lain"` — pay, then blast (handoff to §3)
|
||
>
|
||
> After `PayStat → "paid"`, the `PairRoute` decision 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 from `PickBestie` straight
|
||
> 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
|
||
|
||
```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` | §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`~~ | **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).
|