Stage 9 sweep on Client_Phone AVD + physical mitra phone:
- 01_smoke ✅
- 02_onboarding_verified ✅
- 03_onboarding_anon ✅
- 04_payment_expired ✅
- 05_searching_timeout: in progress when wrap-up began
- 06–08: not yet attempted
## Real shipping bugs fixed (would have hit prod)
1. **Router carve-out too narrow** (router.dart). The AuthAnonymousData
carve-out only protected /auth/display-name. On refreshListenable
notify after loginAnonymous resolves, GoRouter re-evaluates the
*bottom* of the navigation stack (/welcome — also an auth route),
and the AuthAnonymousData fallback redirected to /home, tearing down
the verif sheet before it could open. Loosened to allow any auth
route under AuthAnonymousData.
2. **Phase 4 multi-screen payment never called startSearch**
(searching_screen.dart). The legacy single-screen /payment did
`pairing.startSearch()` on confirm. The Phase 4 flow is
waiting → notif-gate → /chat/searching with no intermediate that
owned the call — customers would land on the searching screen with
no pairing in flight and never get matched. Added the kickoff to
searching_screen::initState when state is PairingInitialData and
paymentDraft.paymentId is set.
## Test infrastructure
- Self-contained Maestro flows 04 + 05 with inline verified-onboarding
prelude, distinct test phones per flow, robust waits.
- 02 + 03 fixed: malformed `extendedWaitUntil` (visible: + notVisible:
true → Maestro parsed as compound predicate); now use proper
notVisible: block.
- New dev-only POST /internal/_test/force-confirm-payment so flows can
advance past the waiting-payment screen without going through Xendit.
- /internal/_test/reset-phone now cascades through chat_messages →
chat_sessions → payment_sessions → auth_sessions before deleting the
customer row (FK 23503 was blocking re-runs).
- /internal/_test/force-pairing-timeout now accepts both
`searching` and `pending_acceptance` states (mitra-online dev means
the chat_session transitions through searching very quickly).
- mark_latest_payment_paid.js helper script for Stage 5+ flows.
## Maestro YAML quirks documented in flows
- text: matches anchored regex against the FULL content-desc — need .*
wildcards for substring, e.g. "mulai.*Rp.*" not "mulai".
- The middot `·` and other special unicode break naive matching;
always use .* anchors when the source string contains them.
- runFlow `when:` evaluates immediately; pair with waitForAnimationToEnd
or a preceding extendedWaitUntil before branching.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bestie Choice Sheet on home Mulai Curhat CTA. When the user has at
least one prior session (bestieHistoryHasItemsProvider hits the chat-
sessions history endpoint), the CTA opens a HaloBottomSheet with two
cards: 'bestie yang udah kenal' -> /chat/history, 'bestie baru' ->
/payment/entry. Empty history -> direct to /payment/entry.
Bestie history list visual upgrade: HaloOrb (mitraId seed) + name +
last-session date + topic pills + sessions count + ONLINE pill.
Backend getCustomerHistory now returns topics, mitra_is_online,
sessions_count in a single payload (no per-row presence round-trip).
BestieOfflinePopup with two variants (returning | new_) replacing the
legacy BestieUnavailableDialog. tanya admin ghost CTA on both variants
opens the new TanyaAdminSheet. Stage 5's targeted-wait declined stub
+ Stage 7's chat-screen 409 stub + searching-screen call site all
migrated to the real component.
TanyaAdminSheet: HaloBottomSheet with WA + Telegram buttons, deeplinks
fetched via supportHandlesProvider (CC-config-driven). url_launcher
added to client_app; ios LSApplicationQueriesSchemes covers
https/http/whatsapp/tg.
Stage 2's OTP-blocked popup hubungi admin SnackBar stub also migrated
to TanyaAdminSheet.
Dev-only POST /internal/_test/seed-history-session lets Maestro 08
flow seed a history row before exercising the choice sheet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Customer-driven session end flow:
- AppBar 'akhiri' action on chat_screen (visible when connected and
not already closing).
- Tap fires confirm_end_step1 HaloPopup. lanjut akhiri -> step2;
gak jadi balik -> dismiss, stay in chat.
- confirm_end_step2 HaloPopup. tulis pesan penutup -> closing_message_sheet
HaloBottomSheet (textarea + kirim & akhiri / lewat — langsung akhiri).
lewati saja closes immediately.
- Both close paths POST /api/client/session/:sessionId/end via
session_closure_notifier.closeSession() and route to /chat/thank-you.
- 409 from the close endpoint surfaces a ClosureRejectedByMitraData
state and a stub HaloPopup with TODO(stage8) for the BestieOfflinePopup
returning variant.
Removed the legacy _showSessionExpiredDialog modal — Stage 6's
ChatExpiredBanner is the replacement notification.
Inline _buildGoodbyeView retained with a TODO for the mitra-side early
end flow (still reaches it).
endSessionTwoStepConfirmProvider hardcoded to true with a TODO — the
Stage 1.5 app_config row exists but no client-readable config endpoint
exists yet. Flip the provider to a FutureProvider once the read endpoint
ships.
Maestro 07_end_session_2step.yaml chains after the chat-happy flow
and asserts the Indonesian copy at each step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Searching screen: soft-prompt card reskin, pulsing-dots panel replaces
the spinner, inline 5-min timeout panel with `coba cari lagi` (resets
pairing notifier + routes to /payment/entry for a fresh funnel — the
server-side payment is failed_pairing at that point so a stale retry
isn't valid) and `kembali ke home` ghost CTA.
Bestie-found screen: S9 Match-V4 reskin — HaloOrb + status dot +
'halo, aku bestie {name}' + `mulai sesi {N} menit →` with N pulled from
the active session's duration_minutes.
Targeted-wait overlay (new) at /chat/waiting-targeted/:mitraId. Three
sub-states from pairingProvider's PairingTargetedWaitingData:
waiting (20s countdown) / accepted (routes to chat) / declined (stubbed
BestieOfflinePopup with a TODO pointing to Stage 8). Reached via
payment_screen._routeToSearchOnConfirmed when the confirm carried a
targetedMitraId — keeps the mandatory payment-before-pairing invariant.
Dev-only POST /internal/_test/force-pairing-timeout drives the 5-min
timeout shortcut for the Maestro flow without waiting live.
Maestro 05_searching_timeout.yaml + force_pairing_timeout.js helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six new screens under /payment/* + a paymentDraftProvider holding
mode/durationId/durationMinutes/priceIDR/paymentId/isFirstSessionDiscount
across the flow. PaymentEntryScreen handles the routing decision
(eligible+enabled -> /payment/discount-paywall, else /payment/method-pick)
and clears the draft on fresh entry.
Screens:
- discount_paywall_screen: S6 first-session discount with struck-through
gimmick price + actual price + 'mulai · Rp{actual}' CTA -> /payment/method
- method_pick_screen: chat vs call cards
- duration_pick_screen: tier list with chat|call mode toggle that resets
the selection on swap
- payment_method_screen: QRIS-first list, posts to existing
/api/client/payment-sessions with mode/duration/price/discount/method
- waiting_payment_screen: qr_flutter QR (encodes paymentId in mock mode),
20-min countdown header, 3s polling for status, pauses on background
via WidgetsBindingObserver
- payment_expired_screen: retry CTA -> /payment/method with draft retained
Status mapping: real payment_sessions.status uses 'confirmed'/'consumed'
for paid (not 'paid' as in plan) and 'expired'/'abandoned' as terminal.
home_screen 'Mulai Curhat' CTA now pushes /payment/entry.
Dev-only /internal/_test/force-expire-payment endpoint to drive Maestro
flow 04_payment_expired.yaml without waiting 20 minutes. Gated behind
NODE_ENV !== 'production'.
chat_opening_provider PricingData extended to carry Phase 4 chat/call
groups + firstSessionDiscount, back-compat with the Phase 3 shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verif Choice Sheet on display_name_screen drives the user into either
the verified or anonymous onboarding sub-flow. ESP screen (12 chips,
multi-select, info-only) + USP screen are shared between both branches;
selections persist through to chat_sessions.topics on session start.
OTP-blocked popup (HaloPopup) listens for the four real OTP-rate-limit
error codes (OTP_RATE_LIMIT_PHONE, OTP_RATE_LIMIT_IP, OTP_COOLDOWN,
OTP_ATTEMPTS_EXCEEDED) and drops the user onto the anonymous path with
ESP/USP state preserved.
Auth-providers gating replaces the --dart-define=ENABLE_SOCIAL_AUTH
build flag with server-driven discovery. authProvidersProvider preloads
GET /api/shared/auth-providers at cold start; welcome/register/
force-register screens render Google/Apple buttons only when the
backend reports enabled:true. Falls back to phone-OTP-only when both
providers are off. social_auth_enabled.dart deleted; client_app/CLAUDE.md
updated to reflect the new gating contract.
Mitra app: chat screen renders an ESP chip strip above the first message
bubble when chat_sessions.topics is non-empty.
Backend session.service.js getSessionById SELECTs cs.topics so the mitra
side can read the customer's selected topics.
Maestro flows 02_onboarding_verified.yaml + 03_onboarding_anon.yaml.
Deviation from plan: plan referenced OTP error code 'otp_retry_exhausted';
real codes are OTP_RATE_LIMIT_*/OTP_COOLDOWN/OTP_ATTEMPTS_EXCEEDED -
popup listens for all four. Plan said 'has_paid_first_session'; live
endpoint returns 'has_consulted_before' - used the live field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dev-only /internal/_test/peek-otp + /internal/_test/reset-phone endpoints
gated by NODE_ENV !== 'production'. peek-otp reads the latest stub OTP
out of an in-memory map populated by otp.service.js fazpassSendStub;
reset-phone wipes otp_requests rows (and optionally the customers row)
so flows can re-run without tripping cooldowns.
JS + shell helpers under .maestro/scripts/ wrap the endpoints for use
inside Maestro runScript steps. 01_smoke.yaml expanded from a launch-only
sanity check to a full cold-start onboarding -> force-register -> OTP ->
home walk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>