Phase 4 §2 + §1/§4: OnboardingIntent post-OTP routing + test naming + register-screen overflow
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>
This commit is contained in:
89
client_app/.maestro/flows/README_section_02.md
Normal file
89
client_app/.maestro/flows/README_section_02.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# §2 — New-User Onboarding test plan
|
||||||
|
|
||||||
|
Spec: [requirement/flow_customer.mermaid.md §2 + §2.1](../../../requirement/flow_customer.mermaid.md).
|
||||||
|
|
||||||
|
Tests use the naming convention `ts-customer-<section>-<sub>-<description>.yaml`:
|
||||||
|
- `<section>` — flow_customer.mermaid section number (`02` for §2 + §2.1).
|
||||||
|
- `<sub>` — sub-flow index within the section.
|
||||||
|
- `<description>` — snake_case summary of the branch under test.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
| File | Branch (spec ref) | Expected destination |
|
||||||
|
|---|---|---|
|
||||||
|
| `ts-customer-02-01-verified_brand_new_to_s6_paywall.yaml` | §2 verified · UserLookup=no (brand-new) | `/payment/discount-paywall` (S6) |
|
||||||
|
| `ts-customer-02-02-verified_existing_no_tx_to_s6_paywall.yaml` | §2 verified · existing customer · has_transacted=false | `/payment/discount-paywall` (S6) |
|
||||||
|
| `ts-customer-02-03-verified_existing_transacted_to_method_pick.yaml` | §2 verified · existing customer · has_transacted=true | `/payment/method-pick` (PickMethod) |
|
||||||
|
| `ts-customer-02-04-otp_blocked_fallback_anonymous_to_method_pick.yaml` | §2 verified · OTPok="too many retries" → OTPBlock → fallback | `/payment/method-pick` (PickMethod) |
|
||||||
|
| `ts-customer-02-05-anonymous_first_timer_to_method_pick.yaml` | §2 anonymous · USPGateB=first-timer → USPb | `/payment/method-pick` (PickMethod) |
|
||||||
|
| `ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml` | SHome1st "masuk →" recover · existing identified user | `/home` (returning view) |
|
||||||
|
|
||||||
|
The shared verified-onboarding prelude lives at
|
||||||
|
[`../subflows/onboarding_new_user_verified.yaml`](../subflows/onboarding_new_user_verified.yaml).
|
||||||
|
|
||||||
|
## Deferred (not yet implemented — see reasons)
|
||||||
|
|
||||||
|
### `ts-customer-02-06` — anonymous · USP already seen → PickMethod directly
|
||||||
|
**Branch:** §2 anonymous · USPGateB=seen → skip USP → PickMethod.
|
||||||
|
|
||||||
|
**Why deferred:** verifying the seen=true skip path literally requires the
|
||||||
|
user to reach `VerifChoiceSheet` while local `flutter.usp_seen=true` AND
|
||||||
|
auth state is fresh (`AuthInitialData`, no anon customer). Maestro's
|
||||||
|
`launchApp clearState:true` wipes both `SharedPreferences` and
|
||||||
|
`FlutterSecureStorage` together — there's no built-in way to clear ONLY
|
||||||
|
secure storage between passes. A two-pass test that runs USP once then
|
||||||
|
relaunches preserves both prefs and the anonymous session, so Pass 2 lands
|
||||||
|
on `SHomeReturning` instead of `SHome1st`, bypassing VerifChoiceSheet.
|
||||||
|
|
||||||
|
**Possible future approach:** add a shell-level pre-test step that runs
|
||||||
|
`adb shell run-as <pkg> rm shared_prefs/FlutterSecureStorage*.xml` and
|
||||||
|
preserves `FlutterSharedPreferences.xml`, then invoke maestro. Requires a
|
||||||
|
wrapper script or a maestro extension that can call adb.
|
||||||
|
|
||||||
|
### `ts-customer-02-07` — §2.1 5.1 anon transact → OTP new phone → upgrade in place
|
||||||
|
### `ts-customer-02-08` — §2.1 5.2 anon transact → OTP existing phone → merge (existing has_transacted=true)
|
||||||
|
### `ts-customer-02-09` — §2.1 5.2 merge · existing has_transacted=false
|
||||||
|
|
||||||
|
**Branch:** §2.1 anonymous → existing-user merge sub-flow.
|
||||||
|
|
||||||
|
**Why deferred:** §2.1's prereq is "anonymous customer has completed at
|
||||||
|
least one transaction". The app derives `has_transacted` from
|
||||||
|
`chat_sessions` rows, and the auth flow ties customer identity to the
|
||||||
|
device's `FlutterSecureStorage` refresh token. Reproducing the prereq in
|
||||||
|
Maestro requires either:
|
||||||
|
|
||||||
|
1. Driving the app UI all the way through anonymous transaction → payment
|
||||||
|
confirm → pairing → chat → end-session (a full §3 + §5 + §6 traversal),
|
||||||
|
then triggering OTP from the SHomeReturning state. The SHomeReturning
|
||||||
|
"curhat sama bestie baru" CTA bypasses `VerifChoiceSheet` entirely, so
|
||||||
|
the only OTP entry point from this state is the SHome1st "masuk →"
|
||||||
|
banner — which is only shown on SHome1st (no identity), not on
|
||||||
|
SHomeReturning.
|
||||||
|
|
||||||
|
2. Pre-seeding a customer + chat_session in the DB *and* injecting the
|
||||||
|
corresponding refresh token into the device's FlutterSecureStorage so
|
||||||
|
the app boots as that anonymous-with-transactions customer. Maestro
|
||||||
|
doesn't expose adb shell exec, so this requires a wrapper script.
|
||||||
|
|
||||||
|
In practice, the §2.1 merge logic is more naturally covered by **backend
|
||||||
|
integration tests** against `resolveCustomerForIdentity` in
|
||||||
|
`backend/src/services/auth.service.js` — the app-side behavior after
|
||||||
|
re-login is the same `/home` landing (login intent) tested by 02-10. The
|
||||||
|
merge state is a backend invariant, not a UI invariant.
|
||||||
|
|
||||||
|
## Behavior notes
|
||||||
|
|
||||||
|
- §2 verified branches route post-OTP via `/payment/entry`, which then
|
||||||
|
dispatches based on the backend's `first_session_discount.eligible`
|
||||||
|
(see `backend/src/services/pricing.service.js::isCustomerEligibleForFirstSessionDiscount`).
|
||||||
|
This handles both "brand-new" and "existing-but-never-paid" with a
|
||||||
|
single check.
|
||||||
|
- The `OnboardingIntent` provider (added 2026-05-18 with the §2 fix)
|
||||||
|
distinguishes transaction-CTA entries (`onboarding`) from login-recover
|
||||||
|
entries (`recover`). Set at `verif_choice_sheet.dart::routeForVerifChoice`
|
||||||
|
(verified branch) and at `home_screen.dart::_LoginRecoverBanner`
|
||||||
|
(masuk →). Consumed by `router.dart`'s post-OTP redirect.
|
||||||
|
- `auth_notifier.dart::verifyOtp` uses `.copyWithPrevious(previous)` when
|
||||||
|
setting `AsyncError`, so `valueOrNull` retains the prior
|
||||||
|
`AuthOtpSentData` / `AuthAnonymousData` through the OTP-blocked popup
|
||||||
|
→ fallback path.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# ts-customer-02-01 — §2 verified path, BRAND-NEW user → S6 paywall.
|
||||||
|
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
|
||||||
|
# OTPok="verified" → UserLookup="no · brand-new" → S6 Paywall.
|
||||||
|
#
|
||||||
|
# Verifies the post-OTP intent fix: the user who arrived via the §2
|
||||||
|
# transaction CTA ("aku mau curhat") is routed by the router redirect to
|
||||||
|
# /payment/entry → /payment/discount-paywall, NOT /home.
|
||||||
|
#
|
||||||
|
# Pre-reqs:
|
||||||
|
# - Backend reachable; NODE_ENV != 'production'.
|
||||||
|
# - ≥1 mitra online (mitraAvailable gates the "aku mau curhat" CTA).
|
||||||
|
# - first_session_discount is enabled in the pricing config (default).
|
||||||
|
appId: com.halobestie.client.client_app
|
||||||
|
env:
|
||||||
|
TEST_PHONE: "+6281234567890"
|
||||||
|
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||||
|
---
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/reset_phone.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- runFlow: ../subflows/onboarding_new_user_verified.yaml
|
||||||
|
|
||||||
|
# Post-OTP: router consumes intent=onboarding → /payment/discount-paywall.
|
||||||
|
# Brand-new user (no chat_sessions) is eligible for first_session_discount.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*biar yakin yang mau cerita.*"
|
||||||
|
timeout: 20000
|
||||||
|
- assertVisible: "(?s).*mulai ·.*"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# ts-customer-02-02 — §2 verified path, EXISTING customer with no prior
|
||||||
|
# transactions → S6 paywall.
|
||||||
|
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
|
||||||
|
# OTPok="verified" → UserLookup="yes · existing" → LoadCallSign →
|
||||||
|
# TransactedCheck="no · never paid" → S6 Paywall.
|
||||||
|
#
|
||||||
|
# Pre-seeds an identified customer with display_name but no chat_sessions
|
||||||
|
# before the run, so:
|
||||||
|
# 1. The OTP-verify backend lookup finds the existing customer.
|
||||||
|
# 2. The stored display_name overwrites the typed S2 Nama (per spec L62).
|
||||||
|
# 3. has_transacted is implicitly false (no chat_sessions →
|
||||||
|
# isCustomerEligibleForFirstSessionDiscount returns true).
|
||||||
|
# 4. /payment/entry routes to /payment/discount-paywall (S6).
|
||||||
|
appId: com.halobestie.client.client_app
|
||||||
|
env:
|
||||||
|
TEST_PHONE: "+6281234567890"
|
||||||
|
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||||
|
---
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/reset_phone.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
|
||||||
|
# Seed the existing identified customer AFTER reset (which would otherwise
|
||||||
|
# drop them). No history session is seeded → has_transacted=false.
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/seed_customer.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
DISPLAY_NAME: "ExistingPriorName"
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- runFlow: ../subflows/onboarding_new_user_verified.yaml
|
||||||
|
|
||||||
|
# Post-OTP: existing customer with no prior chat_sessions →
|
||||||
|
# first_session_discount.eligible=true → S6 paywall.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*biar yakin yang mau cerita.*"
|
||||||
|
timeout: 20000
|
||||||
|
- assertVisible: "(?s).*mulai ·.*"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# ts-customer-02-03 — §2 verified path, EXISTING customer with prior
|
||||||
|
# transactions → PickMethod (skip S6).
|
||||||
|
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
|
||||||
|
# OTPok="verified" → UserLookup="yes · existing" → LoadCallSign →
|
||||||
|
# TransactedCheck="yes · returning verified" → PickMethod.
|
||||||
|
#
|
||||||
|
# Pre-seeds an identified customer with a completed chat_session so
|
||||||
|
# `isCustomerEligibleForFirstSessionDiscount` returns false → /payment/entry
|
||||||
|
# routes to /payment/method-pick instead of /payment/discount-paywall.
|
||||||
|
#
|
||||||
|
# Pre-reqs:
|
||||||
|
# - ≥1 mitra online (seed_history_session pairs with the most-recent
|
||||||
|
# online mitra to write the chat_sessions row).
|
||||||
|
appId: com.halobestie.client.client_app
|
||||||
|
env:
|
||||||
|
TEST_PHONE: "+6281234567890"
|
||||||
|
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||||
|
---
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/reset_phone.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
|
||||||
|
# Seed identified customer first — seed_history_session needs an existing
|
||||||
|
# customers row to attach the chat_session FK to.
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/seed_customer.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
DISPLAY_NAME: "ExistingTxName"
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/seed_history_session.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- runFlow: ../subflows/onboarding_new_user_verified.yaml
|
||||||
|
|
||||||
|
# Post-OTP: existing customer with prior chat_session →
|
||||||
|
# first_session_discount.eligible=false → /payment/method-pick (PickMethod).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*pilih cara curhat.*"
|
||||||
|
timeout: 20000
|
||||||
|
- assertVisible: "(?s).*tulis dan baca dengan tenang.*"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# ts-customer-02-04 — §2 verified path, OTP blocked → fallback to anonymous
|
||||||
|
# → PickMethod.
|
||||||
|
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
|
||||||
|
# S3b OTP → OTPok="too many retries" → OTPBlock → fallback to Anon → USPGateB.
|
||||||
|
#
|
||||||
|
# The OtpBlockedPopup is gated by OTP_ATTEMPTS_EXCEEDED (default config:
|
||||||
|
# 5 verification attempts per OTP request; the 6th submission triggers the
|
||||||
|
# 429). Popup CTA "lanjut tanpa verif" calls context.go('/onboarding/anon/method')
|
||||||
|
# which redirects to /payment/method-pick — USP is presumed already evaluated
|
||||||
|
# upstream (verif_choice_sheet shown → USPb skipped), so the anonymous flow
|
||||||
|
# jumps straight into PickMethod.
|
||||||
|
#
|
||||||
|
# This flow inlines the pre-OTP onboarding steps (instead of using the
|
||||||
|
# onboarding_new_user_verified subflow) because we want to enter wrong OTPs
|
||||||
|
# rather than the peeked valid one.
|
||||||
|
appId: com.halobestie.client.client_app
|
||||||
|
env:
|
||||||
|
TEST_PHONE: "+6281234567890"
|
||||||
|
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||||
|
---
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/reset_phone.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Mulai"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn:
|
||||||
|
text: "Mulai"
|
||||||
|
retryTapIfNoChange: true
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*aku mau curhat.*"
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn: "(?s).*aku mau curhat.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*Siapa namamu.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
point: "50%, 28%"
|
||||||
|
- inputText: "MaestroBlock"
|
||||||
|
- tapOn: "lanjut"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*Mau curhat sebagai siapa.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "(?s).*verifikasi nomor HP.*"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
text: "Sebelum mulai"
|
||||||
|
commands:
|
||||||
|
- tapOn: "(?s).*aku ngerti.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*nomor wa-mu.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
point: "60%, 47%"
|
||||||
|
- inputText: "81234567890"
|
||||||
|
- tapOn: "(?s).*kirim kode.*"
|
||||||
|
|
||||||
|
# OTP screen — type wrong "000000" six times. Default config is
|
||||||
|
# verify_max_attempts=5 (config.service.js L218); attempt 6 throws
|
||||||
|
# OTP_ATTEMPTS_EXCEEDED → OtpBlockedPopup fires.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Masukkan OTP"
|
||||||
|
timeout: 15000
|
||||||
|
- inputText: "000000"
|
||||||
|
- inputText: "000000"
|
||||||
|
- inputText: "000000"
|
||||||
|
- inputText: "000000"
|
||||||
|
- inputText: "000000"
|
||||||
|
- inputText: "000000"
|
||||||
|
|
||||||
|
# Popup: "Verifikasi nomor lagi penuh" with primary CTA "lanjut tanpa verif".
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*Verifikasi nomor lagi penuh.*"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "(?s).*lanjut tanpa verif.*"
|
||||||
|
|
||||||
|
# Assert: redirected to /payment/method-pick (PickMethod).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*pilih cara curhat.*"
|
||||||
|
timeout: 15000
|
||||||
|
- assertVisible: "(?s).*tulis dan baca dengan tenang.*"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# ts-customer-02-05 — §2 anonymous path, first-timer (USP not yet seen) →
|
||||||
|
# PickMethod (method-pick screen).
|
||||||
|
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
|
||||||
|
# VerifChoice="tanpa verif · Rp5k+" → USPGateB="no · first-timer" →
|
||||||
|
# USPb → PickMethod.
|
||||||
|
#
|
||||||
|
# Anonymous path skips OTP entirely — no router redirect needed (user
|
||||||
|
# stays AuthAnonymousData). USP screen pushes /payment/method-pick
|
||||||
|
# directly when verified=false. Verifies onboardingIntent is NOT set
|
||||||
|
# (it stays `recover` because we picked "curhat anonim", not "verifikasi").
|
||||||
|
appId: com.halobestie.client.client_app
|
||||||
|
env:
|
||||||
|
TEST_PHONE: "+6281234567890"
|
||||||
|
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||||
|
---
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/reset_phone.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
# Welcome → SHome1st.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Mulai"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn:
|
||||||
|
text: "Mulai"
|
||||||
|
retryTapIfNoChange: true
|
||||||
|
|
||||||
|
# "aku mau curhat" → S2 Nama.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*aku mau curhat.*"
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn: "(?s).*aku mau curhat.*"
|
||||||
|
|
||||||
|
# S2 Nama — submit display name.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*Siapa namamu.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
point: "50%, 28%"
|
||||||
|
- inputText: "MaestroAnon"
|
||||||
|
- tapOn: "lanjut"
|
||||||
|
|
||||||
|
# VerifChoiceSheet — pick "curhat anonim" (anonymous branch). Per
|
||||||
|
# routeForVerifChoice (verif_choice_sheet.dart L94), USP-not-seen takes
|
||||||
|
# /onboarding/anon/usp; USP-seen would go directly to /payment/method-pick.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*Mau curhat sebagai siapa.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "(?s).*curhat anonim.*"
|
||||||
|
|
||||||
|
# USPb (anonymous variant) — first-timer.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Sebelum mulai"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "(?s).*aku ngerti.*"
|
||||||
|
|
||||||
|
# Assert: /payment/method-pick visible (PickMethod in spec).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*pilih cara curhat.*"
|
||||||
|
timeout: 15000
|
||||||
|
- assertVisible: "(?s).*tulis dan baca dengan tenang.*"
|
||||||
@@ -1,19 +1,28 @@
|
|||||||
# TS-07 — Returning user with existing display_name skips set-name screen
|
# ts-customer-02-10 — SHome1st "masuk →" login-recover banner · existing
|
||||||
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-07).
|
# identified user → /home directly (no set-name detour).
|
||||||
|
# Spec refs:
|
||||||
|
# - requirement/flow_customer.mermaid.md §2 (post-OTP path for existing
|
||||||
|
# identified customer with display_name stored)
|
||||||
|
# - Project directive: login-intent entries (masuk → banner) land on
|
||||||
|
# /home; transaction-CTA entries (aku mau curhat / curhat sama bestie
|
||||||
|
# baru) land on /payment/entry.
|
||||||
#
|
#
|
||||||
# Inverse of TS-01..TS-06: those flows wipe the customer (drop_customer=true)
|
# Pre-seeds an existing identified customer with phone + display_name.
|
||||||
# so every OTP path hits the new-user set-name branch. TS-07 instead seeds
|
# After OTP succeeds, backend's resolveCustomerForIdentity returns the
|
||||||
# an EXISTING customer row with phone + display_name, then verifies the
|
# existing row unchanged. The router's onboardingIntentProvider stays at
|
||||||
# OTP sign-in returns the existing row unchanged (via
|
# `recover` (the masuk → handler resets it defensively), so the post-OTP
|
||||||
# resolveCustomerForIdentity branch 1) and the client routes directly to
|
# redirect lands on /home rather than /payment/entry.
|
||||||
# /home without showing /auth/set-name.
|
#
|
||||||
|
# This was previously named TS-07 (under the §4-driven naming scheme).
|
||||||
|
# Renamed 2026-05-18 to the ts-customer-NN-MM-* convention; the assertion
|
||||||
|
# semantics are unchanged.
|
||||||
#
|
#
|
||||||
# Pre-reqs:
|
# Pre-reqs:
|
||||||
# - Backend reachable, NODE_ENV != 'production'.
|
# - Backend reachable, NODE_ENV != 'production'.
|
||||||
# - (No mitra requirement — flow stops at /home.)
|
# - (No mitra requirement — flow stops at /home.)
|
||||||
#
|
#
|
||||||
# Run:
|
# Run:
|
||||||
# maestro test client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml
|
# maestro test client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml
|
||||||
appId: com.halobestie.client.client_app
|
appId: com.halobestie.client.client_app
|
||||||
env:
|
env:
|
||||||
TEST_PHONE: "+6281234567890"
|
TEST_PHONE: "+6281234567890"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Shared §2 verified onboarding prelude — covers cold-start → SHome1st →
|
||||||
|
# "aku mau curhat" → S2 Nama → VerifChoiceSheet "verifikasi nomor HP" →
|
||||||
|
# S5b USP (if first-timer) → S3a Phone → S3b OTP → verifyOtp.
|
||||||
|
#
|
||||||
|
# After this subflow returns, the post-OTP redirect has fired:
|
||||||
|
# - intent=onboarding (set in routeForVerifChoice verified branch) →
|
||||||
|
# /payment/entry → /payment/discount-paywall (S6) for eligible users,
|
||||||
|
# OR /payment/method-pick for has_transacted=true users.
|
||||||
|
# Callers assert which destination is visible.
|
||||||
|
#
|
||||||
|
# Pre-reqs (parent flow's responsibility):
|
||||||
|
# - `env:` with TEST_PHONE and BACKEND_INTERNAL_URL.
|
||||||
|
# - reset_phone.js + launchApp clearState: true BEFORE this subflow.
|
||||||
|
# - ≥1 mitra online so the "aku mau curhat" CTA is enabled.
|
||||||
|
#
|
||||||
|
# Selector style: same regex-merged-node rules as
|
||||||
|
# onboarding_returning_user.yaml apply (see feedback-maestro-wsl-setup).
|
||||||
|
appId: ${APP_ID_ANDROID}
|
||||||
|
---
|
||||||
|
# Welcome carousel auto-advances 0→1→2 (1s each). Wait for Mulai on slide 3.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Mulai"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn:
|
||||||
|
text: "Mulai"
|
||||||
|
retryTapIfNoChange: true
|
||||||
|
|
||||||
|
# SHome1st "aku mau curhat" CTA → /auth/display-name.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*aku mau curhat.*"
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn: "(?s).*aku mau curhat.*"
|
||||||
|
|
||||||
|
# S2 Nama — type name + Lanjut. No hideKeyboard (TextField.onSubmitted
|
||||||
|
# would auto-fire _submit and tear down the screen before Lanjut tap
|
||||||
|
# resolves). The "Nama panggilan" hint isn't visible in a11y; point-tap
|
||||||
|
# the field area (28% of viewport y on tested Pixel profile).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*Siapa namamu.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
point: "50%, 28%"
|
||||||
|
- inputText: "MaestroNew"
|
||||||
|
- tapOn: "lanjut"
|
||||||
|
|
||||||
|
# VerifChoiceSheet — verified branch sets onboardingIntentProvider =
|
||||||
|
# onboarding (verif_choice_sheet.dart L87) so the router can route to
|
||||||
|
# /payment/entry post-OTP.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*Mau curhat sebagai siapa.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "(?s).*verifikasi nomor HP.*"
|
||||||
|
|
||||||
|
# S5b USP — first-timer only (usp_seen=false after clearState). If
|
||||||
|
# parent flow pre-marked USP as seen, this step is skipped because the
|
||||||
|
# verified branch goes straight to /auth/register.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
text: "Sebelum mulai"
|
||||||
|
commands:
|
||||||
|
- tapOn: "(?s).*aku ngerti.*"
|
||||||
|
|
||||||
|
# S3a phone input → kirim kode.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "(?s).*nomor wa-mu.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
point: "60%, 47%"
|
||||||
|
- inputText: "81234567890"
|
||||||
|
- tapOn: "(?s).*kirim kode.*"
|
||||||
|
|
||||||
|
# S3b OTP — peek stub, auto-submits on 6th digit.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Masukkan OTP"
|
||||||
|
timeout: 15000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/peek_otp.js
|
||||||
|
env:
|
||||||
|
TEST_PHONE: ${TEST_PHONE}
|
||||||
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
|
- inputText: ${output.OTP}
|
||||||
@@ -241,6 +241,12 @@ class Auth extends _$Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> verifyOtp(String otpRequestId, String code) async {
|
Future<void> verifyOtp(String otpRequestId, String code) async {
|
||||||
|
// Preserve the prior auth data (typically AuthAnonymousData from the
|
||||||
|
// pre-OTP loginAnonymous) so AsyncError keeps `valueOrNull` non-null.
|
||||||
|
// The router uses valueOrNull to gate redirects — a wipe-to-null on
|
||||||
|
// OTP failure would bounce the OTP-blocked recovery path
|
||||||
|
// (/onboarding/anon/method → /payment/method-pick) to /home.
|
||||||
|
final previous = state;
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
try {
|
try {
|
||||||
// Bearer is attached automatically by ApiClient from AuthBridge — when
|
// Bearer is attached automatically by ApiClient from AuthBridge — when
|
||||||
@@ -259,12 +265,13 @@ class Auth extends _$Auth {
|
|||||||
final profile = await _applyTokens(response);
|
final profile = await _applyTokens(response);
|
||||||
state = AsyncData(await _stateForProfile(profile));
|
state = AsyncData(await _stateForProfile(profile));
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
state = AsyncError(_otpVerifyErrorInfo(e), StackTrace.current);
|
state = AsyncError<AuthData>(_otpVerifyErrorInfo(e), StackTrace.current)
|
||||||
|
.copyWithPrevious(previous);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
state = AsyncError(
|
state = AsyncError<AuthData>(
|
||||||
const AuthErrorInfo('Gagal verifikasi. Coba lagi.'),
|
const AuthErrorInfo('Gagal verifikasi. Coba lagi.'),
|
||||||
StackTrace.current,
|
StackTrace.current,
|
||||||
);
|
).copyWithPrevious(previous);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
client_app/lib/core/auth/onboarding_intent_provider.dart
Normal file
20
client_app/lib/core/auth/onboarding_intent_provider.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
/// Tracks where the user came from when they entered an auth flow.
|
||||||
|
///
|
||||||
|
/// `onboarding` is set when the user taps a transaction CTA ("aku mau
|
||||||
|
/// curhat" / "curhat sama bestie baru") that drives them into the §2
|
||||||
|
/// New-User Onboarding journey. Post-OTP, the router consumes this and
|
||||||
|
/// pushes /payment/entry (which dispatches S6 paywall vs PickMethod via
|
||||||
|
/// `first_session_discount.eligible`).
|
||||||
|
///
|
||||||
|
/// `recover` (default) is the SHome1st "masuk →" login-recover banner
|
||||||
|
/// path — the spec doesn't route this through /payment/entry; the user
|
||||||
|
/// expects to land on /home with their chat history.
|
||||||
|
///
|
||||||
|
/// Spec ref: requirement/flow_customer.mermaid.md §2 (`UserLookup → S6
|
||||||
|
/// or PickMethod`).
|
||||||
|
enum OnboardingIntent { recover, onboarding }
|
||||||
|
|
||||||
|
final onboardingIntentProvider =
|
||||||
|
StateProvider<OnboardingIntent>((ref) => OnboardingIntent.recover);
|
||||||
@@ -45,7 +45,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_phoneController.addListener(() => setState(() {}));
|
_phoneController.addListener(() => setState(() {}));
|
||||||
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
_authSub =
|
||||||
|
ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final data = next.valueOrNull;
|
final data = next.valueOrNull;
|
||||||
if (data is AuthOtpSentData) {
|
if (data is AuthOtpSentData) {
|
||||||
@@ -106,7 +107,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
String _greetingName(AuthData? data) => switch (data) {
|
String _greetingName(AuthData? data) => switch (data) {
|
||||||
AuthAnonymousData d => d.displayName,
|
AuthAnonymousData d => d.displayName,
|
||||||
AuthAuthenticatedData d => (d.profile['display_name'] as String?) ?? '',
|
AuthAuthenticatedData d => (d.profile['display_name'] as String?) ?? '',
|
||||||
AuthNeedsDisplayNameData d => (d.profile['display_name'] as String?) ?? '',
|
AuthNeedsDisplayNameData d =>
|
||||||
|
(d.profile['display_name'] as String?) ?? '',
|
||||||
_ => '',
|
_ => '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,53 +136,63 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
child: HaloStepDots(total: 4, current: 3),
|
child: HaloStepDots(total: 4, current: 3),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: LayoutBuilder(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
builder: (context, constraints) => SingleChildScrollView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: ConstrainedBox(
|
||||||
children: [
|
constraints:
|
||||||
Text(
|
BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
'nomor wa-mu, $shownName?',
|
child: IntrinsicHeight(
|
||||||
style: const TextStyle(
|
child: Column(
|
||||||
fontFamily: HaloTokens.fontDisplay,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
fontSize: 28,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
fontWeight: FontWeight.w700,
|
children: [
|
||||||
color: HaloTokens.brandDark,
|
Text(
|
||||||
height: 1.15,
|
'nomor wa-mu, $shownName?',
|
||||||
letterSpacing: -0.56,
|
style: const TextStyle(
|
||||||
),
|
fontFamily: HaloTokens.fontDisplay,
|
||||||
),
|
fontSize: 28,
|
||||||
const SizedBox(height: 10),
|
fontWeight: FontWeight.w700,
|
||||||
const Text(
|
color: HaloTokens.brandDark,
|
||||||
'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.',
|
height: 1.15,
|
||||||
style: TextStyle(
|
letterSpacing: -0.56,
|
||||||
fontFamily: HaloTokens.fontBody,
|
),
|
||||||
fontSize: 14.5,
|
),
|
||||||
color: HaloTokens.inkSoft,
|
const SizedBox(height: 10),
|
||||||
height: 1.5,
|
const Text(
|
||||||
),
|
'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.',
|
||||||
),
|
style: TextStyle(
|
||||||
const SizedBox(height: 24),
|
fontFamily: HaloTokens.fontBody,
|
||||||
_PhoneRow(
|
fontSize: 14.5,
|
||||||
controller: _phoneController,
|
color: HaloTokens.inkSoft,
|
||||||
borderColor: hasMinDigits
|
height: 1.5,
|
||||||
? HaloTokens.brand
|
),
|
||||||
: HaloTokens.border,
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
const SizedBox(height: 16),
|
_PhoneRow(
|
||||||
const _PrivacyCard(),
|
controller: _phoneController,
|
||||||
if (_errorMessage != null) ...[
|
borderColor: hasMinDigits
|
||||||
const SizedBox(height: 12),
|
? HaloTokens.brand
|
||||||
Text(
|
: HaloTokens.border,
|
||||||
_errorMessage!,
|
),
|
||||||
textAlign: TextAlign.center,
|
const SizedBox(height: 16),
|
||||||
style: const TextStyle(
|
const _PrivacyCard(),
|
||||||
fontFamily: HaloTokens.fontBody,
|
if (_errorMessage != null) ...[
|
||||||
color: HaloTokens.danger,
|
const SizedBox(height: 12),
|
||||||
fontSize: 13,
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: HaloTokens.fontBody,
|
||||||
|
color: HaloTokens.danger,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
HaloButton(
|
HaloButton(
|
||||||
@@ -191,9 +203,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
: 'kirim kode',
|
: 'kirim kode',
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
onPressed: canSubmit
|
onPressed: canSubmit
|
||||||
? () => ref
|
? () =>
|
||||||
.read(authProvider.notifier)
|
ref.read(authProvider.notifier).requestOtp(_e164Phone())
|
||||||
.requestOtp(_e164Phone())
|
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/auth/onboarding_intent_provider.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
import '../../onboarding/usp_seen_provider.dart';
|
import '../../onboarding/usp_seen_provider.dart';
|
||||||
@@ -81,6 +82,10 @@ Future<void> routeForVerifChoice(
|
|||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
switch (choice) {
|
switch (choice) {
|
||||||
case VerifChoice.verified:
|
case VerifChoice.verified:
|
||||||
|
// §2 transaction CTA path — router consumes this post-OTP and routes
|
||||||
|
// to /payment/entry (S6 paywall vs PickMethod via first_session_discount).
|
||||||
|
ref.read(onboardingIntentProvider.notifier).state =
|
||||||
|
OnboardingIntent.onboarding;
|
||||||
context.push(seen ? '/auth/register' : '/onboarding/verif/usp');
|
context.push(seen ? '/auth/register' : '/onboarding/verif/usp');
|
||||||
break;
|
break;
|
||||||
case VerifChoice.anonymous:
|
case VerifChoice.anonymous:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/auth/auth_notifier.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
|
import '../../core/auth/onboarding_intent_provider.dart';
|
||||||
import '../../core/availability/mitra_availability_notifier.dart';
|
import '../../core/availability/mitra_availability_notifier.dart';
|
||||||
import '../../core/chat/active_session_notifier.dart';
|
import '../../core/chat/active_session_notifier.dart';
|
||||||
import '../../core/notifications/notif_permission.dart';
|
import '../../core/notifications/notif_permission.dart';
|
||||||
@@ -198,11 +199,11 @@ class _SHome1stView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginRecoverBanner extends StatelessWidget {
|
class _LoginRecoverBanner extends ConsumerWidget {
|
||||||
const _LoginRecoverBanner();
|
const _LoginRecoverBanner();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -210,7 +211,14 @@ class _LoginRecoverBanner extends StatelessWidget {
|
|||||||
borderRadius: HaloRadius.md,
|
borderRadius: HaloRadius.md,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: HaloRadius.md,
|
borderRadius: HaloRadius.md,
|
||||||
onTap: () => context.push('/auth/register'),
|
onTap: () {
|
||||||
|
// Recovery flow — post-OTP should land on /home (the user wants
|
||||||
|
// their history), NOT /payment/entry. Defensive reset in case a
|
||||||
|
// prior onboarding run left the intent dirty.
|
||||||
|
ref.read(onboardingIntentProvider.notifier).state =
|
||||||
|
OnboardingIntent.recover;
|
||||||
|
context.push('/auth/register');
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -786,11 +794,13 @@ class _NotifDeniedBanner extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
OutlinedButton(
|
||||||
style: TextButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: HaloTokens.brandDark,
|
foregroundColor: HaloTokens.brandDark,
|
||||||
|
side: const BorderSide(color: HaloTokens.brandDark, width: 1),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: HaloSpacing.s8,
|
horizontal: HaloSpacing.s12,
|
||||||
),
|
),
|
||||||
minimumSize: const Size(0, 32),
|
minimumSize: const Size(0, 32),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/auth/onboarding_intent_provider.dart';
|
||||||
import '../../../core/chat/chat_opening_provider.dart';
|
import '../../../core/chat/chat_opening_provider.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../state/payment_draft_provider.dart';
|
import '../state/payment_draft_provider.dart';
|
||||||
@@ -31,6 +32,12 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
|
|||||||
// reset() would wipe targetedMitraId and silently downgrade the
|
// reset() would wipe targetedMitraId and silently downgrade the
|
||||||
// returning-targeted flow to a blast.
|
// returning-targeted flow to a blast.
|
||||||
ref.read(paymentDraftNotifierProvider.notifier).resetExceptTarget();
|
ref.read(paymentDraftNotifierProvider.notifier).resetExceptTarget();
|
||||||
|
// Consume the onboarding intent — landing here means the router-level
|
||||||
|
// post-OTP redirect has fired (or the user navigated in via another
|
||||||
|
// CTA). Reset to default so a later masuk → recovery flow doesn't
|
||||||
|
// inherit a stale onboarding intent.
|
||||||
|
ref.read(onboardingIntentProvider.notifier).state =
|
||||||
|
OnboardingIntent.recover;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'core/auth/auth_notifier.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
|
import 'core/auth/onboarding_intent_provider.dart';
|
||||||
import 'features/auth/screens/display_name_screen.dart';
|
import 'features/auth/screens/display_name_screen.dart';
|
||||||
import 'features/auth/screens/register_screen.dart';
|
import 'features/auth/screens/register_screen.dart';
|
||||||
import 'features/auth/screens/otp_screen.dart';
|
import 'features/auth/screens/otp_screen.dart';
|
||||||
@@ -96,23 +97,47 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
if (data == null) {
|
if (data == null) {
|
||||||
// Error state — drop onto Home; SHome1st variant handles the
|
// Error state — drop onto Home; SHome1st variant handles the
|
||||||
// unauthenticated render (login banner overlay).
|
// unauthenticated render (login banner overlay).
|
||||||
|
// EXCEPTION: /onboarding/* routes are the post-OTP-blocked popup
|
||||||
|
// fallback path (`/onboarding/anon/method` alias → method-pick).
|
||||||
|
// They must transit freely even when authProvider is in AsyncError
|
||||||
|
// (which is how OTP_ATTEMPTS_EXCEEDED leaves the state), otherwise
|
||||||
|
// the redirect to /home wins over the route-level alias.
|
||||||
|
if (isOnboardingFlow) return null;
|
||||||
if (!isAuthRoute && !isSplash) return '/home';
|
if (!isAuthRoute && !isSplash) return '/home';
|
||||||
if (isSplash) return '/home';
|
if (isSplash) return '/home';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
if (data is AuthAuthenticatedData ||
|
||||||
|
data is AuthAnonymousData ||
|
||||||
|
data is AuthOtpSentData) {
|
||||||
// Allow the Phase 4 onboarding flow (ESP/USP) to stay put even when
|
// Allow the Phase 4 onboarding flow (ESP/USP) to stay put even when
|
||||||
// the user is already anonymous-authenticated — display_name_screen
|
// the user is already anonymous-authenticated — display_name_screen
|
||||||
// intentionally pushes into /onboarding/* after loginAnonymous.
|
// intentionally pushes into /onboarding/* after loginAnonymous.
|
||||||
if (isOnboardingFlow) return null;
|
if (isOnboardingFlow) return null;
|
||||||
// While AuthAnonymousData, the user may legitimately be mid-flow on
|
// While AuthAnonymousData OR AuthOtpSentData, the user may
|
||||||
// /home → /auth/display-name (push) → about to open the Verif Choice
|
// legitimately be mid-flow on /home → /auth/display-name (push) →
|
||||||
// Sheet. When refreshListenable fires after loginAnonymous resolves,
|
// VerifChoice → /auth/register → /auth/otp. When refreshListenable
|
||||||
// GoRouter re-evaluates the bottom of the navigation stack — without
|
// fires after loginAnonymous resolves OR after requestOtp returns
|
||||||
// this carve-out an /auth/* push would be torn down before the sheet
|
// AuthOtpSentData, GoRouter re-evaluates the bottom of the
|
||||||
// can open. Allow any auth route to stay put under AuthAnonymousData.
|
// navigation stack — without this carve-out an /auth/* push would
|
||||||
if (data is AuthAnonymousData && isAuthRoute) return null;
|
// be torn down before the next screen can open.
|
||||||
|
if ((data is AuthAnonymousData || data is AuthOtpSentData) &&
|
||||||
|
isAuthRoute) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// §2 spec north star: when the user reached an auth route from a
|
||||||
|
// transaction CTA ("aku mau curhat" / "curhat sama bestie baru"),
|
||||||
|
// post-OTP must land at /payment/entry — which dispatches to S6
|
||||||
|
// paywall vs PickMethod via `first_session_discount.eligible`. The
|
||||||
|
// login-recover banner path keeps the default `recover` intent and
|
||||||
|
// lands on /home (preserves user expectation of seeing history).
|
||||||
|
if (data is AuthAuthenticatedData && isAuthRoute) {
|
||||||
|
final intent = ref.read(onboardingIntentProvider);
|
||||||
|
if (intent == OnboardingIntent.onboarding) {
|
||||||
|
return '/payment/entry';
|
||||||
|
}
|
||||||
|
}
|
||||||
return (isSplash || isAuthRoute) ? '/home' : null;
|
return (isSplash || isAuthRoute) ? '/home' : null;
|
||||||
}
|
}
|
||||||
if (data is AuthNeedsDisplayNameData) return '/auth/set-name';
|
if (data is AuthNeedsDisplayNameData) return '/auth/set-name';
|
||||||
|
|||||||
@@ -130,6 +130,33 @@ flowchart TD
|
|||||||
> first-time pricing off forever. Backend phone-lookup behaviour already
|
> first-time pricing off forever. Backend phone-lookup behaviour already
|
||||||
> exists (see Phase 1 auto-link via phone); the app-side reconciliation +
|
> exists (see Phase 1 auto-link via phone); the app-side reconciliation +
|
||||||
> `has_transacted` plumbing is the new work.
|
> `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) 🔴
|
### 2.1 Anonymous → existing-user merge (post-transaction OTP) 🔴
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user