# 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 ``` --- ## 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"| 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 🟢
→ fallback to Anon"] OTPBlock --> USPGateB OTPok -->|"verified"| UserLookup{"user found in DB?
(phone match) 🔴"} UserLookup -->|"no · brand-new"| S6["S6 · Paywall Rp2.000
(12 menit, sekali seumur hidup) 🟢"] UserLookup -->|"yes · existing account"| LoadCallSign["Load stored call_sign
→ 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
(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 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. ### 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
(set phone, preserve display_name
+ 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
(keep Anon row + its chat_sessions/transactions intact) 🔴"] StampBelongsTo --> ReloginAs["5.2.2 · issue tokens for Existing.id
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 ` 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 🟢
(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 SoftPrompt,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"| PickMethod HistList --> PickBestie["pick bestie"] PickBestie --> CheckOnline{"bestie online?"} CheckOnline -->|"no"| OfflinePopup["Bestie Offline Popup
(returning variant) 🟢"] OfflinePopup -->|"cari bestie lain"| PickMethod OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin
(WA / Telegram) 🟢"] CheckOnline -->|"yes"| PickMethod["Pilih cara curhat
(chat / voice call) 🟡"] PickMethod --> PickDuration["Pemilihan harga
(5 durations, full screen) 🟡"] PickDuration --> PayMethod["Cara bayar (QRIS-first) 🟡"] PayMethod --> Pay["Xendit checkout
(QRIS / e-wallet) 🟡"] Pay --> WaitPay["Waiting Payment
(20-min QRIS clock) 🟡"] WaitPay --> PayStat{"payment status"} PayStat -->|"timeout 20 min"| PayExpired["Pembayaran expired 🟡
→ retry"] PayExpired --> Pay PayStat -->|"paid"| PairRoute{"specific bestie?
(branch user came from)"} PairRoute -->|"yes · lama"| Targeted["Request targeted pair
'Menunggu bestie tertentu' 🟢
(20s countdown overlay)"] PairRoute -->|"no · baru / cari lain"| BlastFlow["→ S7 Soft-prompt + Blast
(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
(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 ``` --- ## 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 ``` --- ## 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).