diff --git a/client_app/.maestro/flows/README_section_02.md b/client_app/.maestro/flows/README_section_02.md new file mode 100644 index 0000000..c23bac0 --- /dev/null +++ b/client_app/.maestro/flows/README_section_02.md @@ -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-
--.yaml`: +- `
` — flow_customer.mermaid section number (`02` for §2 + §2.1). +- `` — sub-flow index within the section. +- `` — 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 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. diff --git a/client_app/.maestro/flows/ts-customer-02-01-verified_brand_new_to_s6_paywall.yaml b/client_app/.maestro/flows/ts-customer-02-01-verified_brand_new_to_s6_paywall.yaml new file mode 100644 index 0000000..84509db --- /dev/null +++ b/client_app/.maestro/flows/ts-customer-02-01-verified_brand_new_to_s6_paywall.yaml @@ -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 ·.*" diff --git a/client_app/.maestro/flows/ts-customer-02-02-verified_existing_no_tx_to_s6_paywall.yaml b/client_app/.maestro/flows/ts-customer-02-02-verified_existing_no_tx_to_s6_paywall.yaml new file mode 100644 index 0000000..caef08a --- /dev/null +++ b/client_app/.maestro/flows/ts-customer-02-02-verified_existing_no_tx_to_s6_paywall.yaml @@ -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 ·.*" diff --git a/client_app/.maestro/flows/ts-customer-02-03-verified_existing_transacted_to_method_pick.yaml b/client_app/.maestro/flows/ts-customer-02-03-verified_existing_transacted_to_method_pick.yaml new file mode 100644 index 0000000..423aae0 --- /dev/null +++ b/client_app/.maestro/flows/ts-customer-02-03-verified_existing_transacted_to_method_pick.yaml @@ -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.*" diff --git a/client_app/.maestro/flows/ts-customer-02-04-otp_blocked_fallback_anonymous_to_method_pick.yaml b/client_app/.maestro/flows/ts-customer-02-04-otp_blocked_fallback_anonymous_to_method_pick.yaml new file mode 100644 index 0000000..d869fc7 --- /dev/null +++ b/client_app/.maestro/flows/ts-customer-02-04-otp_blocked_fallback_anonymous_to_method_pick.yaml @@ -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.*" diff --git a/client_app/.maestro/flows/ts-customer-02-05-anonymous_first_timer_to_method_pick.yaml b/client_app/.maestro/flows/ts-customer-02-05-anonymous_first_timer_to_method_pick.yaml new file mode 100644 index 0000000..1d4f7ab --- /dev/null +++ b/client_app/.maestro/flows/ts-customer-02-05-anonymous_first_timer_to_method_pick.yaml @@ -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.*" diff --git a/client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml b/client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml similarity index 69% rename from client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml rename to client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml index 16b07bf..65be1a6 100644 --- a/client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml +++ b/client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml @@ -1,19 +1,28 @@ -# TS-07 — Returning user with existing display_name skips set-name screen -# (requirement/phase4-customer-flow.md → Test Scenarios → TS-07). +# ts-customer-02-10 — SHome1st "masuk →" login-recover banner · existing +# 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) -# so every OTP path hits the new-user set-name branch. TS-07 instead seeds -# an EXISTING customer row with phone + display_name, then verifies the -# OTP sign-in returns the existing row unchanged (via -# resolveCustomerForIdentity branch 1) and the client routes directly to -# /home without showing /auth/set-name. +# Pre-seeds an existing identified customer with phone + display_name. +# After OTP succeeds, backend's resolveCustomerForIdentity returns the +# existing row unchanged. The router's onboardingIntentProvider stays at +# `recover` (the masuk → handler resets it defensively), so the post-OTP +# redirect lands on /home rather than /payment/entry. +# +# 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: # - Backend reachable, NODE_ENV != 'production'. # - (No mitra requirement — flow stops at /home.) # # 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 env: TEST_PHONE: "+6281234567890" diff --git a/client_app/.maestro/flows/ts-01_returning_lama_online.yaml b/client_app/.maestro/flows/ts-customer-04-01-returning_lama_online.yaml similarity index 100% rename from client_app/.maestro/flows/ts-01_returning_lama_online.yaml rename to client_app/.maestro/flows/ts-customer-04-01-returning_lama_online.yaml diff --git a/client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml b/client_app/.maestro/flows/ts-customer-04-02-returning_lama_offline_blast.yaml similarity index 100% rename from client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml rename to client_app/.maestro/flows/ts-customer-04-02-returning_lama_offline_blast.yaml diff --git a/client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml b/client_app/.maestro/flows/ts-customer-04-03-returning_lama_offline_tanya_admin.yaml similarity index 100% rename from client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml rename to client_app/.maestro/flows/ts-customer-04-03-returning_lama_offline_tanya_admin.yaml diff --git a/client_app/.maestro/flows/ts-04_returning_baru_blast.yaml b/client_app/.maestro/flows/ts-customer-04-04-returning_baru_blast.yaml similarity index 100% rename from client_app/.maestro/flows/ts-04_returning_baru_blast.yaml rename to client_app/.maestro/flows/ts-customer-04-04-returning_baru_blast.yaml diff --git a/client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml b/client_app/.maestro/flows/ts-customer-04-05-payment_expired_retry_preserves_targeting.yaml similarity index 100% rename from client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml rename to client_app/.maestro/flows/ts-customer-04-05-payment_expired_retry_preserves_targeting.yaml diff --git a/client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml b/client_app/.maestro/flows/ts-customer-04-06-targeted_reject_fallback_to_blast.yaml similarity index 100% rename from client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml rename to client_app/.maestro/flows/ts-customer-04-06-targeted_reject_fallback_to_blast.yaml diff --git a/client_app/.maestro/subflows/onboarding_new_user_verified.yaml b/client_app/.maestro/subflows/onboarding_new_user_verified.yaml new file mode 100644 index 0000000..c576e57 --- /dev/null +++ b/client_app/.maestro/subflows/onboarding_new_user_verified.yaml @@ -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} diff --git a/client_app/lib/core/auth/auth_notifier.dart b/client_app/lib/core/auth/auth_notifier.dart index 53be380..03af198 100644 --- a/client_app/lib/core/auth/auth_notifier.dart +++ b/client_app/lib/core/auth/auth_notifier.dart @@ -241,6 +241,12 @@ class Auth extends _$Auth { } Future 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(); try { // Bearer is attached automatically by ApiClient from AuthBridge — when @@ -259,12 +265,13 @@ class Auth extends _$Auth { final profile = await _applyTokens(response); state = AsyncData(await _stateForProfile(profile)); } on DioException catch (e) { - state = AsyncError(_otpVerifyErrorInfo(e), StackTrace.current); + state = AsyncError(_otpVerifyErrorInfo(e), StackTrace.current) + .copyWithPrevious(previous); } catch (_) { - state = AsyncError( + state = AsyncError( const AuthErrorInfo('Gagal verifikasi. Coba lagi.'), StackTrace.current, - ); + ).copyWithPrevious(previous); } } diff --git a/client_app/lib/core/auth/onboarding_intent_provider.dart b/client_app/lib/core/auth/onboarding_intent_provider.dart new file mode 100644 index 0000000..b5fdfbd --- /dev/null +++ b/client_app/lib/core/auth/onboarding_intent_provider.dart @@ -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((ref) => OnboardingIntent.recover); diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart index 660e980..e0259da 100644 --- a/client_app/lib/features/auth/screens/register_screen.dart +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -45,7 +45,8 @@ class _RegisterScreenState extends ConsumerState { void initState() { super.initState(); _phoneController.addListener(() => setState(() {})); - _authSub = ref.listenManual>(authProvider, (prev, next) { + _authSub = + ref.listenManual>(authProvider, (prev, next) { if (!mounted) return; final data = next.valueOrNull; if (data is AuthOtpSentData) { @@ -106,7 +107,8 @@ class _RegisterScreenState extends ConsumerState { String _greetingName(AuthData? data) => switch (data) { AuthAnonymousData d => d.displayName, 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 { child: HaloStepDots(total: 4, current: 3), ), Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'nomor wa-mu, $shownName?', - style: const TextStyle( - fontFamily: HaloTokens.fontDisplay, - fontSize: 28, - fontWeight: FontWeight.w700, - color: HaloTokens.brandDark, - height: 1.15, - letterSpacing: -0.56, - ), - ), - const SizedBox(height: 10), - const Text( - 'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.', - style: TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 14.5, - color: HaloTokens.inkSoft, - height: 1.5, - ), - ), - const SizedBox(height: 24), - _PhoneRow( - controller: _phoneController, - borderColor: hasMinDigits - ? HaloTokens.brand - : HaloTokens.border, - ), - const SizedBox(height: 16), - const _PrivacyCard(), - if (_errorMessage != null) ...[ - const SizedBox(height: 12), - Text( - _errorMessage!, - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: HaloTokens.fontBody, - color: HaloTokens.danger, - fontSize: 13, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'nomor wa-mu, $shownName?', + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 28, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + height: 1.15, + letterSpacing: -0.56, + ), + ), + const SizedBox(height: 10), + const Text( + 'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14.5, + color: HaloTokens.inkSoft, + height: 1.5, + ), + ), + const SizedBox(height: 24), + _PhoneRow( + controller: _phoneController, + borderColor: hasMinDigits + ? HaloTokens.brand + : HaloTokens.border, + ), + const SizedBox(height: 16), + const _PrivacyCard(), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + color: HaloTokens.danger, + fontSize: 13, + ), + ), + ], + ], ), ), - ], - ], + ), + ), ), ), HaloButton( @@ -191,9 +203,8 @@ class _RegisterScreenState extends ConsumerState { : 'kirim kode', fullWidth: true, onPressed: canSubmit - ? () => ref - .read(authProvider.notifier) - .requestOtp(_e164Phone()) + ? () => + ref.read(authProvider.notifier).requestOtp(_e164Phone()) : null, ), const SizedBox(height: 4), diff --git a/client_app/lib/features/auth/widgets/verif_choice_sheet.dart b/client_app/lib/features/auth/widgets/verif_choice_sheet.dart index 2c085de..983a96f 100644 --- a/client_app/lib/features/auth/widgets/verif_choice_sheet.dart +++ b/client_app/lib/features/auth/widgets/verif_choice_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/auth/onboarding_intent_provider.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; import '../../onboarding/usp_seen_provider.dart'; @@ -81,6 +82,10 @@ Future routeForVerifChoice( if (!context.mounted) return; switch (choice) { 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'); break; case VerifChoice.anonymous: diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index 5fc9ed7..aa14e35 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/auth/auth_notifier.dart'; +import '../../core/auth/onboarding_intent_provider.dart'; import '../../core/availability/mitra_availability_notifier.dart'; import '../../core/chat/active_session_notifier.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(); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Material( @@ -210,7 +211,14 @@ class _LoginRecoverBanner extends StatelessWidget { borderRadius: HaloRadius.md, child: InkWell( 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( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( @@ -786,11 +794,13 @@ class _NotifDeniedBanner extends ConsumerWidget { ), ), ), - TextButton( - style: TextButton.styleFrom( + OutlinedButton( + style: OutlinedButton.styleFrom( foregroundColor: HaloTokens.brandDark, + side: const BorderSide(color: HaloTokens.brandDark, width: 1), + shape: const StadiumBorder(), padding: const EdgeInsets.symmetric( - horizontal: HaloSpacing.s8, + horizontal: HaloSpacing.s12, ), minimumSize: const Size(0, 32), tapTargetSize: MaterialTapTargetSize.shrinkWrap, diff --git a/client_app/lib/features/payment/screens/payment_entry_screen.dart b/client_app/lib/features/payment/screens/payment_entry_screen.dart index 3b72a58..209af48 100644 --- a/client_app/lib/features/payment/screens/payment_entry_screen.dart +++ b/client_app/lib/features/payment/screens/payment_entry_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/auth/onboarding_intent_provider.dart'; import '../../../core/chat/chat_opening_provider.dart'; import '../../../core/theme/halo_tokens.dart'; import '../state/payment_draft_provider.dart'; @@ -31,6 +32,12 @@ class _PaymentEntryScreenState extends ConsumerState { // reset() would wipe targetedMitraId and silently downgrade the // returning-targeted flow to a blast. 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; }); } diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 49c2347..6af2b2c 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.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/register_screen.dart'; import 'features/auth/screens/otp_screen.dart'; @@ -96,23 +97,47 @@ GoRouter buildRouter(Ref ref) { if (data == null) { // Error state — drop onto Home; SHome1st variant handles the // 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 (isSplash) return '/home'; 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 // the user is already anonymous-authenticated — display_name_screen // intentionally pushes into /onboarding/* after loginAnonymous. if (isOnboardingFlow) return null; - // While AuthAnonymousData, the user may legitimately be mid-flow on - // /home → /auth/display-name (push) → about to open the Verif Choice - // Sheet. When refreshListenable fires after loginAnonymous resolves, - // GoRouter re-evaluates the bottom of the navigation stack — without - // this carve-out an /auth/* push would be torn down before the sheet - // can open. Allow any auth route to stay put under AuthAnonymousData. - if (data is AuthAnonymousData && isAuthRoute) return null; + // While AuthAnonymousData OR AuthOtpSentData, the user may + // legitimately be mid-flow on /home → /auth/display-name (push) → + // VerifChoice → /auth/register → /auth/otp. When refreshListenable + // fires after loginAnonymous resolves OR after requestOtp returns + // AuthOtpSentData, GoRouter re-evaluates the bottom of the + // navigation stack — without this carve-out an /auth/* push would + // 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; } if (data is AuthNeedsDisplayNameData) return '/auth/set-name'; diff --git a/requirement/flow_customer.mermaid.md b/requirement/flow_customer.mermaid.md index 5689530..e30bb84 100644 --- a/requirement/flow_customer.mermaid.md +++ b/requirement/flow_customer.mermaid.md @@ -130,6 +130,33 @@ flowchart TD > 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. +> +> **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) 🔴