Adds `customers.account_belongs_to UUID NULL` and refactors customer sign-in (phone/Google/Apple) so an anon row that re-verifies into an existing customer no longer 409s. Instead the anon row stays intact with a breadcrumb pointing at the real customer; tokens are issued for the existing user. Actual data reconciliation onto the existing row (chat_sessions, customer_transactions, payment_sessions, pairing_failures) is deferred. Backend - migrate.js: ADD COLUMN account_belongs_to UUID REFERENCES customers(id) ON DELETE SET NULL. - customer.service.js: stampAccountBelongsTo helper; account_belongs_to exposed in CUSTOMER_SELECT. - auth.service.js: new shared resolveCustomerForIdentity (4-case logic); normalizeIdentityConflict + IDENTITY_ALREADY_LINKED 409 deleted; completeCustomerPhoneSignIn / signInWithGoogle / signInWithApple all route through the shared helper. - client.auth.routes.js: new resolveAnonymousCustomerId picks the anon prefix ONLY from a verified Bearer JWT — closes the UUID-leak attack where a tamper-able body field could mis-route someone else's transactions. /otp/verify, /google, /apple all use it; the body field `anonymous_customer_id` is no longer accepted on any of them. - test/services/auth.service.test.js: 9 Vitest cases covering phone + Google + Apple, all 4 logic cases + multi-merge accumulation. Customer app - auth_notifier.dart::verifyOtp: drop `skipAuth: true` and the dead body field so ApiClient auto-attaches the anon's Bearer from AuthBridge. Survives the AuthOtpSentData state transition (the earlier `_currentAnonymousCustomerId()` state-drop bug is bypassed by sourcing the id from the bridge instead of state). - Google + Apple client paths remain unchanged (gated on provider creds; mirror this fix when wiring lands). Docs - flow_customer.mermaid.md: new §2.1 sub-section with the merge diagram, schema note, replaces-current-behaviour paragraph, and Bearer-only security callout. - phase3.4-testing.md: §1.5 line 76 simplified (no more per-path split); new §1.5.1 with the 5-step operator scenario + DB invariants + curl recipe + Vitest pointer; new §1.5.2 covering Google/Apple parity (deferred client work flagged). Verification (against live dev backend, before this commit): - Vitest: 9/9 in auth.service.test.js; 49/51 overall (2 unrelated pre-existing failures in session-timer.service.test.js). - Operator Node smoke: 14/14 in the §1.5.1 scenario; 11/11 in the Bearer-precedence cases. - Real-device UI walkthrough on SM-A530F still pending — see resume memory `project_phase4_2_1_resume_test`. Sister WIP bundled in migrate.js + customer.service.js: `usp_seen` column + `markCustomerUspSeen` helper (Phase 4 USP one-time gate, was already uncommitted in the working tree). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 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.
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"| 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).