Files
halobestie-clone/requirement/flow_customer.mermaid.md
ramadhan sjamsani a48f108fc0 Phase 4 §2.1: anonymous → existing-user merge breadcrumb
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>
2026-05-13 23:57:53 +08:00

20 KiB
Raw Blame History

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

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 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.

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) 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. Client implication: on the verified-OTP path the app must carry the anonymous session's access token through requestOtpverifyOtp (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)

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 (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 §23
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).