Compare commits

..

3 Commits

Author SHA1 Message Date
938954bbe8 Phase 4 plan: refresh status header (stages 0-10 + §4 migration shipped)
Header was dated 2026-05-10 and described stages 0-8 with Stage 9 in
progress. As of 2026-05-18 Stage 10 (chat tab), the §4 payment-before-pair
migration (Stages 5.1/5.3/5.4), the legacy /payment retirement, and the
TS-01..TS-07 Maestro suite are all on master. Older notes preserved
under "Post-Stage-8 corrections".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:57:19 +08:00
93fa5f113a Test: TS-07 returning user with existing display_name skips set-name
Inverse coverage for the auth path: TS-01..TS-06 all wipe the customer
row (drop_customer=true) so every OTP path lands on the new-user
set-name branch. TS-07 instead seeds an existing identified customer
(phone + display_name + is_anonymous=false) and verifies the OTP
sign-in returns the existing row unchanged via
resolveCustomerForIdentity branch 1, so /auth/set-name is never shown.

Adds:
* /internal/_test/seed-customer endpoint — upserts a customer with
  phone + display_name + is_anonymous=false.
* client_app/.maestro/scripts/seed_customer.js helper.
* client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml.
* TS-07 scenario doc + coverage-map row in
  requirement/phase4-customer-flow.md.

The flow asserts the "halo, <name>" greeting on the returning-user home
variant (identified users always land on _SHomeReturningView regardless
of chat history) plus an explicit notVisible on "Siapa namamu" as a
belt-and-braces check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:40 +08:00
e09f76ceb6 Phase 4 §4: payment-before-pair for returning users + Maestro suite
Stages 5.1, 5.3, 5.4 of the returning-user flow rework. All three §4
entry paths now require payment BEFORE pairing, matching the updated
mermaid spec.

* Spec (requirement/flow_customer.mermaid.md §4): payment block converges
  three call-sites (bestie-yang-udah-kenal-online, bestie-baru,
  offline-popup → cari bestie lain). PairRoute dispatches lama → targeted
  pair, baru/cari-lain → §3 blast. §3 retains its post-payment-shared
  contract.

* Stage 5.1 (client_app): PaymentDraft carries targetedMitraId +
  topicSensitivity. bestie_history_list seeds the draft + pushes
  /payment/entry (was legacy /payment). searching_screen branches on
  draft.targetedMitraId for blast-vs-targeted dispatch.
  payment_entry uses resetExceptTarget(); bestie_choice_sheet + home
  _onCurhatBestieBaruPressed call explicit reset() before push so
  the keepAlive draft can't leak stale targeting into a blast.

* Stage 5.3 (client_app): new BestieOfflineVariant.prePayReturning.
  Bestie-history-list _BestieRow splits tappable from dim so offline
  rows render dimmed but route taps into the popup. CTA "cari bestie
  lain" resets the draft + pushes /payment/entry.

* Stage 5.4 (client_app): deleted legacy /payment route,
  payment_screen.dart, payment_notifier.dart(+.g.dart). router cleaned.

* Tests (requirement/phase4-customer-flow.md + client_app/.maestro/):
  six Maestro flows TS-01..TS-06 covering every §4 branching point,
  all passing end-to-end. Shared onboarding prelude under
  .maestro/subflows/. New helper scripts: accept_latest_pending,
  force_mitra_offline, force_other_mitra_online,
  reset_all_mitras_online, mitra_accept_latest_internal. New backend
  _test endpoints to match. /reset-phone now cascade-deletes
  customer_transactions (FK was blocking). /force-pairing-timeout
  branches targeted (RETURNING_CHAT_TIMEOUT via
  expireTargetedPairingRequest, now exported) vs blast (PAIRING_FAILED).
  seed_history_session also outputs MITRA_NAME_RE (regex-escaped) for
  reliable selectors against display names containing regex specials.

* mitra_app: dispose-during-deactivate guardrail for back-press on the
  mitra chat screen after the customer's goodbye message. Pending real
  emulator repro verification (carried over from 2026-05-15).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:25:15 +08:00
35 changed files with 1973 additions and 686 deletions

View File

@@ -6,7 +6,7 @@
// test phone numbers or fixed codes into production code paths. // test phone numbers or fixed codes into production code paths.
import { peekStubOtp } from '../../services/otp.service.js' import { peekStubOtp } from '../../services/otp.service.js'
import { expirePairingRequest } from '../../services/pairing.service.js' import { acceptPairingRequest, expirePairingRequest, expireTargetedPairingRequest, getPendingRequestsForMitra } from '../../services/pairing.service.js'
import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js' import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js'
import { getDb } from '../../db/client.js' import { getDb } from '../../db/client.js'
import { PairingFailureCause, SessionStatus } from '../../constants.js' import { PairingFailureCause, SessionStatus } from '../../constants.js'
@@ -42,6 +42,8 @@ export const internalTestRoutes = async (fastify) => {
const ids = await sql`SELECT id FROM customers WHERE phone = ${phone}` const ids = await sql`SELECT id FROM customers WHERE phone = ${phone}`
for (const { id } of ids) { for (const { id } of ids) {
await sql`DELETE FROM chat_messages WHERE session_id IN (SELECT id FROM chat_sessions WHERE customer_id = ${id})` await sql`DELETE FROM chat_messages WHERE session_id IN (SELECT id FROM chat_sessions WHERE customer_id = ${id})`
await sql`DELETE FROM chat_request_notifications WHERE session_id IN (SELECT id FROM chat_sessions WHERE customer_id = ${id})`
await sql`DELETE FROM customer_transactions WHERE customer_id = ${id}`
await sql`DELETE FROM chat_sessions WHERE customer_id = ${id}` await sql`DELETE FROM chat_sessions WHERE customer_id = ${id}`
await sql`DELETE FROM payment_sessions WHERE customer_id = ${id}` await sql`DELETE FROM payment_sessions WHERE customer_id = ${id}`
await sql`DELETE FROM auth_sessions WHERE user_id = ${id} AND user_type = 'customer'` await sql`DELETE FROM auth_sessions WHERE user_id = ${id} AND user_type = 'customer'`
@@ -154,8 +156,25 @@ export const internalTestRoutes = async (fastify) => {
if (!target) { if (!target) {
return reply.code(400).send({ error: 'session_id or latest:true required in body' }) return reply.code(400).send({ error: 'session_id or latest:true required in body' })
} }
// Branch targeted vs blast: a chat_session linked to a payment with
// `targeted_mitra_id` is a TARGETED pair waiting for that specific mitra
// to accept (20s countdown). Its expiry must fire RETURNING_CHAT_TIMEOUT
// — which the customer-side TargetedWaitingScreen listens for to surface
// the post-pay BestieOfflinePopup (returning variant). Blast pairs go
// through the regular PAIRING_FAILED → S7 timeout screen path.
const [linked] = await sql`
SELECT ps.targeted_mitra_id
FROM chat_sessions cs
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
WHERE cs.id = ${target}
LIMIT 1
`
if (linked?.targeted_mitra_id) {
await expireTargetedPairingRequest(target)
return { ok: true, session_id: target, kind: 'targeted' }
}
await expirePairingRequest(target, PairingFailureCause.NO_MITRA_AVAILABLE) await expirePairingRequest(target, PairingFailureCause.NO_MITRA_AVAILABLE)
return { ok: true, session_id: target } return { ok: true, session_id: target, kind: 'blast' }
}) })
// Force-set the expires_at of an active chat_session to drive Phase 4 // Force-set the expires_at of an active chat_session to drive Phase 4
@@ -286,4 +305,159 @@ export const internalTestRoutes = async (fastify) => {
` `
return { ok: true, payment_id: row.id, ...row } return { ok: true, payment_id: row.id, ...row }
}) })
// Upsert a customer row with phone + display_name (is_anonymous=false).
// Used by Maestro TS-07 to set up the "returning user already has a name"
// precondition: a real returning OTP sign-in must skip the set-name screen
// because resolveCustomerForIdentity returns the existing row unchanged.
//
// Body: { phone, display_name }
fastify.post('/seed-customer', async (request, reply) => {
const phone = request.body?.phone
const display_name = request.body?.display_name
if (!phone || !display_name) {
return reply.code(400).send({ error: 'phone and display_name required in body' })
}
const [row] = await sql`
INSERT INTO customers (phone, display_name, is_anonymous)
VALUES (${phone}, ${display_name}, false)
ON CONFLICT (phone) DO UPDATE
SET display_name = EXCLUDED.display_name,
is_anonymous = false
RETURNING id, phone, display_name, is_anonymous
`
return { ok: true, ...row }
})
// Mark EVERY mitra row online. Used by Maestro flows as a setup step to
// ensure a clean known-good state regardless of what previous tests did
// (e.g. force-mitra-offline leaving the dev DB with no online mitras).
// Inserts a status row for every mitra that doesn't have one yet.
fastify.post('/reset-all-mitras-online', async (_request, reply) => {
await sql`
INSERT INTO mitra_online_status
(mitra_id, is_online, last_online_at, last_heartbeat_at)
SELECT id, true, NOW(), NOW() FROM mitras
ON CONFLICT (mitra_id) DO UPDATE SET
is_online = true,
last_online_at = NOW(),
last_heartbeat_at = NOW()
`
const [count] = await sql`
SELECT COUNT(*)::int AS n FROM mitra_online_status WHERE is_online = true
`
return { ok: true, online_count: count.n }
})
// Force a mitra ONLINE in mitra_online_status — used by Maestro flows that
// need a SECOND online mitra (TS-02, TS-06) when the dev DB only has one
// signed-in mitra. With `exclude_mitra_id`, picks any other mitra (offline
// or never-online) and upserts their status row as online. Without it,
// either targets the explicit `mitra_id` or the first offline candidate.
//
// Body: { mitra_id?: uuid, exclude_mitra_id?: uuid }
fastify.post('/force-mitra-online', async (request, reply) => {
const { mitra_id, exclude_mitra_id } = request.body ?? {}
let target = mitra_id
if (!target) {
// Pick any mitra other than exclude — already-online is fine (the
// UPSERT below is idempotent). The intent is "ensure a different
// mitra IS online", not "force a state change".
const [row] = await sql`
SELECT m.id, m.display_name FROM mitras m
WHERE (${exclude_mitra_id ?? null}::uuid IS NULL
OR m.id != ${exclude_mitra_id ?? null}::uuid)
ORDER BY m.id
LIMIT 1
`
if (!row) {
return reply.code(404).send({
error: 'no_other_mitra_available',
exclude_mitra_id: exclude_mitra_id ?? null,
})
}
target = row.id
}
const [updated] = await sql`
INSERT INTO mitra_online_status
(mitra_id, is_online, last_online_at, last_heartbeat_at)
VALUES (${target}, true, NOW(), NOW())
ON CONFLICT (mitra_id) DO UPDATE SET
is_online = true,
last_online_at = NOW(),
last_heartbeat_at = NOW()
RETURNING mitra_id, is_online, last_heartbeat_at
`
return { ok: true, ...updated }
})
// Force a specific mitra OFFLINE in mitra_online_status — used by Maestro
// flows (TS-02 / TS-03 in requirement/phase4-customer-flow.md) that need the
// customer's history-list bestie row to render in its offline (dimmed) state.
// Distinct from the CC `mitra-online-status/:id/offline` endpoint which
// requires a CC_JWT; this one is unauthenticated (NODE_ENV-gated) so flows
// don't need CC credentials.
//
// Body: { mitra_id }
fastify.post('/force-mitra-offline', async (request, reply) => {
const mitraId = request.body?.mitra_id
if (!mitraId) {
return reply.code(400).send({ error: 'mitra_id required in body' })
}
const [updated] = await sql`
UPDATE mitra_online_status
SET is_online = false,
last_heartbeat_at = NOW() - INTERVAL '10 minutes'
WHERE mitra_id = ${mitraId}
RETURNING mitra_id, is_online, last_heartbeat_at
`
if (!updated) {
return reply.code(404).send({ error: 'no_online_status_row', mitra_id: mitraId })
}
return { ok: true, ...updated }
})
// Accept the most recent pending pairing notification, regardless of which
// mitra it was sent to. Used by Maestro flows where the test doesn't know
// (or care) which specific mitra should accept — e.g. TS-02 (blast where
// the seeded mitra was forced offline, so an unknown OTHER online mitra
// got the notification). No body required.
fastify.post('/accept-latest-pending', async (_request, reply) => {
const [notif] = await sql`
SELECT n.session_id, n.mitra_id
FROM chat_request_notifications n
JOIN chat_sessions s ON s.id = n.session_id
WHERE s.status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
AND n.response IS NULL
ORDER BY n.notified_at DESC
LIMIT 1
`
if (!notif) {
return reply.code(404).send({ error: 'no_pending_notification' })
}
const session = await acceptPairingRequest(notif.session_id, notif.mitra_id)
return { ok: true, session_id: notif.session_id, mitra_id: notif.mitra_id, session }
})
// Accept the most recent pending pairing request for a given mitra without
// needing a mitra JWT. Used by Maestro flows that drive the customer side
// through to the post-payment waiting screen and need the mitra side to
// "accept" so the customer transitions onward (see TS-01 in
// requirement/phase4-customer-flow.md).
//
// Body: { mitra_id }
fastify.post('/mitra-accept-latest', async (request, reply) => {
const mitraId = request.body?.mitra_id
if (!mitraId) {
return reply.code(400).send({ error: 'mitra_id required in body' })
}
const pending = await getPendingRequestsForMitra(mitraId)
if (!pending || pending.length === 0) {
return reply.code(404).send({ error: 'no_pending_request', mitra_id: mitraId })
}
// Newest first — flows always want the request that was just created.
const latest = pending[pending.length - 1]
const session = await acceptPairingRequest(latest.session_id, mitraId)
return { ok: true, session_id: latest.session_id, session }
})
} }

View File

@@ -742,7 +742,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
* - cause_tag is targeted_mitra_timeout (audit row only) * - cause_tag is targeted_mitra_timeout (audit row only)
* - WS event sent to customer is RETURNING_CHAT_TIMEOUT (not PAIRING_FAILED) * - WS event sent to customer is RETURNING_CHAT_TIMEOUT (not PAIRING_FAILED)
*/ */
const expireTargetedPairingRequest = async (sessionId) => { export const expireTargetedPairingRequest = async (sessionId) => {
const [session] = await sql` const [session] = await sql`
UPDATE chat_sessions UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED} SET status = ${SessionStatus.EXPIRED}

View File

@@ -0,0 +1,155 @@
# TS-01 — Returning user re-pays an online bestie (lama happy path)
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-01).
#
# §4 branch covered: Choice → "bestie yang udah kenal" → CheckOnline(yes) →
# PickMethod → PickDuration → PayMethod → Bayar → WaitPay → paid →
# notif-gate → PairRoute(lama) → Targeted → accept → S10 chat.
#
# Pre-reqs (HARD — flow assumes these):
# - Backend reachable (BACKEND_INTERNAL_URL).
# - NODE_ENV != 'production' (so /internal/_test/* routes are registered).
# - At least ONE mitra is currently online in dev DB. The dev seed
# (backend/src/db/seed.js) does NOT auto-create mitras — ops must sign
# in a test mitra via mitra_app + heartbeat (or insert
# mitra_online_status manually). `seed_history_session.js` picks the
# most-recently-online mitra; that same mitra must still be online when
# the flow reaches the post-payment targeted-wait so
# `mitra_accept_latest_internal.js` can drive accept.
#
# Run:
# maestro test client_app/.maestro/flows/ts-01_returning_lama_online.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Seed a completed chat_sessions row so the bestie history list isn't
# empty. The seed picks the most-recently-online mitra; that mitra remains
# online (this is the "lama" branch — TS-01 needs them online). ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# bestieHistoryProvider was already evaluated empty on the first home
# render. Pull-to-refresh to re-fetch /chat/history so the seeded row shows
# up and the CTA opens BestieChoiceSheet (instead of jumping straight to
# /payment/entry, which is the no-history branch).
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Step 1: tap CTA → Bestie Choice Sheet ---
# After seeding, the CTA label flips from "aku mau curhat" (SHome1st) to
# "curhat sama bestie baru" (SHomeReturning).
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
# Sheet title "mau curhat sama siapa?" — `?` is regex meta; wrap in (?s).*…*
# and use `.` for the trailing literal `?`.
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
# --- Step 2: pick "bestie yang udah kenal" → history list ---
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
# --- Step 3: tap the seeded mitra row → /payment/entry → method-pick ---
# Row label is "bestie <name>" + ONLINE pill (merged blob).
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
# /payment/entry is a routing shim; for a returning customer with prior
# completed sessions the discount is NOT eligible, so it forwards to
# /payment/method-pick (`pilih cara curhat`).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
# --- Step 4: pick chat mode → /payment/duration-pick ---
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
# --- Step 5: pick cheapest tier → CTA shows "bayar Rp X" ---
# Tiers render via tier.label / formatRupiah; "5 Menit" is current label.
- tapOn: "(?s).*5 Menit.*"
- tapOn: "(?s).*bayar Rp.*"
# --- Step 6: cara-bayar screen → QRIS preselected → tap bayar ---
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
# --- Step 7: waiting-payment screen → force-confirm ---
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 8: notif-gate auto-advances (or "nanti aja" if shown) →
# /chat/searching kicks off targeted-pair → routes to
# /chat/waiting-targeted/<mitraId> ---
# We accept either landing on notif-gate (if perm not yet decided) or
# directly on targeted-waiting (if perm was already granted).
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*MENUNGGU JAWABAN.*"
timeout: 20000
- assertVisible: "(?s).*lagi nungguin.*"
# --- Step 9: mitra-side accept via dev-only internal route ---
- runScript:
file: ../scripts/mitra_accept_latest_internal.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 10: chat screen renders ---
# Header shows mitra name + "online" status pill. The presence of either
# the mitra name (post-accept) or the "online" indicator confirms we're on
# the chat screen.
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000
- assertVisible: "(?s).*${output.MITRA_NAME_RE}.*"

View File

@@ -0,0 +1,172 @@
# TS-02 — Returning user picks offline bestie → "cari bestie lain" → blast
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-02).
#
# §4 branch covered: Choice → "bestie yang udah kenal" → CheckOnline(no) →
# OfflinePopup(pre-pay) → "cari bestie lain" → PickMethod → … → paid →
# PairRoute(baru) → BlastFlow → S10 chat.
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - >= 2 mitras online in dev DB at flow start. The flow:
# 1. `seed_history_session.js` picks the most-recently-online mitra
# (call this M1) and creates a history row.
# 2. `force_mitra_offline.js` forces M1 offline so the history list
# renders dim.
# 3. After the user takes the blast branch, a SECOND online mitra
# (M2) is required to accept the blast via
# `mitra_accept_latest_internal.js`.
# If only one mitra exists, the blast will have no acceptor and step
# 10's chat-screen assertion will time out.
# - Dev seed (backend/src/db/seed.js) does NOT auto-create mitras — ops
# must sign in >= 2 test mitras via mitra_app + heartbeat.
#
# Run:
# maestro test client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
# Second online mitra that will accept the blast. Maestro flows currently
# don't have a "pick any online mitra" helper, so we rely on
# mitra-accept-latest taking the FIRST mitra that received the blast.
# See "Open question" in the task spec — this is the "TS-02 needs two
# online mitras" caveat.
TEST_MITRA_ID_ACCEPTOR: "${TEST_MITRA_ID_ACCEPTOR}"
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Reset every mitra online first (so seed has someone to pick after
# earlier offline-forcing tests left state dirty). ---
- runScript:
file: ../scripts/reset_all_mitras_online.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Seed history with M1, ensure a different M2 is online (the eventual
# blast acceptor), then force M1 offline. ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/force_other_mitra_online.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/force_mitra_offline.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Refresh home to pick up the seeded history row + offline status.
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Steps 1-2: tap CTA → choice sheet → "bestie yang udah kenal" ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
# --- Step 3: tap the DIMMED M1 row → BestieOfflinePopup (prePayReturning)
# Popup title: "<mitraName> lagi nggak online" (verified against
# bestie_unavailable_dialog.dart). CTA labels: "cari bestie lain" + "tanya
# admin" + "kembali ke home" (ghost). ---
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
- extendedWaitUntil:
visible:
text: "(?s).*${output.MITRA_NAME_RE}.*lagi nggak online.*"
timeout: 10000
- assertVisible: "(?s).*cari bestie lain.*"
- assertVisible: "(?s).*tanya admin.*"
# --- Step 4: tap "cari bestie lain" → draft.reset() → /payment/entry ---
- tapOn: "(?s).*cari bestie lain.*"
# /payment/entry forwards to /payment/method-pick for an ineligible
# customer (prior completed session → no first-session discount).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
# --- Step 5: pick chat → duration → tier → cara bayar → bayar ---
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
# --- Step 6: waiting-payment → force-confirm ---
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 7: notif-gate → /chat/searching (BLAST, not targeted-wait) ---
# Because the popup's "cari bestie lain" CTA reset the draft, targetedMitraId
# is null → searching_screen.dart fires startSearch() (blast), NOT
# startTargetedSearch().
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*lagi nyari bestie.*"
timeout: 20000
# --- Step 8: any other online mitra accepts the blast ---
# accept-latest-pending picks whichever mitra received the latest blast
# notification — we don't care which one as long as it's not the seeded
# (force-offline) one. Pre-req: ≥1 OTHER online mitra in the dev DB.
- runScript:
file: ../scripts/accept_latest_pending.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 9: chat screen renders ---
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000

View File

@@ -0,0 +1,104 @@
# TS-03 — Returning user picks offline bestie → "tanya admin" (escape)
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-03).
#
# §4 branch covered: Choice → "bestie yang udah kenal" → CheckOnline(no) →
# OfflinePopup(pre-pay) → "tanya admin" → AdminSheet (terminal).
#
# This is a terminal-escape scenario: no payment row, no chat. The flow
# ends after asserting the admin sheet renders with at least one contact
# option (WhatsApp / Telegram).
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - >= 1 mitra online in dev DB so `seed_history_session.js` can pick
# someone to be the (about-to-be-forced-offline) M1. No second mitra
# needed (no blast or accept in this scenario).
# - `support_handles_json` in app_config has at least WA or Telegram
# populated (default seed includes both). If empty, the admin sheet
# renders "kontak admin belum tersedia" — the assertion will still pass
# on the "tanya admin" title but the deeplink CTA assertion below would
# fail; in that case the WA/Telegram assertion is the verification of
# interest.
#
# Run:
# maestro test client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Seed history with M1 then force M1 offline ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/force_mitra_offline.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Refresh home so history list shows the dimmed row.
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Steps 1-2: choice sheet → "bestie yang udah kenal" → history list ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
# --- Step 3: tap dimmed M1 row → BestieOfflinePopup (prePayReturning) ---
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
- extendedWaitUntil:
visible:
text: "(?s).*${output.MITRA_NAME_RE}.*lagi nggak online.*"
timeout: 10000
- assertVisible: "(?s).*cari bestie lain.*"
- assertVisible: "(?s).*tanya admin.*"
# --- Step 4: tap "tanya admin" → admin sheet opens ON TOP of the popup
# (per bestie_unavailable_dialog.dart L181-187 the popup stays alive under
# the sheet). The sheet title is "tanya admin". ---
- tapOn: "(?s).*tanya admin.*"
# The admin sheet's title is "tanya admin" — but so was the CTA we just
# tapped, which remains rendered behind the sheet. Disambiguate by also
# asserting on the subtitle copy "pilih cara yang paling enak buat kamu",
# which only exists on the sheet (verified against tanya_admin_sheet.dart).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara yang paling enak buat kamu.*"
timeout: 10000
- assertVisible: "(?s).*tanya admin.*"
# Confirm at least one contact option renders. If support_handles_json is
# empty in app_config the sheet renders "kontak admin belum tersedia" — in
# that case the assertVisible above (title + subtitle) is the meaningful
# check; the WhatsApp/Telegram presence depends on CC seed.
# TODO(test-data): if dev seeds reliably populate WA + Telegram, tighten
# this to assertVisible "WhatsApp|Telegram".

View File

@@ -0,0 +1,134 @@
# TS-04 — Returning user picks "bestie baru" → blast happy path
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-04).
#
# §4 branch covered: Choice → "bestie baru" → PickMethod → PickDuration →
# PayMethod → Bayar → WaitPay → paid → PairRoute(baru) → BlastFlow →
# S10 chat.
#
# What this proves: tapping "bestie baru" explicitly resets the payment
# draft (clearing any stale targetedMitraId — Stage 5.1 Risk #4
# mitigation), so the post-payment route is /chat/searching (blast), NOT
# /chat/waiting-targeted/...
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - At least ONE mitra is online to accept the blast.
# - The customer must qualify as "returning" so the choice sheet renders
# (not auto-jumps to /payment/entry). We seed a history row to force
# this. The seeded mitra does NOT need to stay online — they're not
# the acceptor here (this is the "bestie baru" branch — blast goes to
# whoever's online).
# - Dev seed (backend/src/db/seed.js) does NOT auto-create mitras — sign
# in at least one test mitra via mitra_app + heartbeat.
#
# Run:
# maestro test client_app/.maestro/flows/ts-04_returning_baru_blast.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Seed history so the customer qualifies as "returning" and the choice
# sheet (with both options) renders. The seeded mitra is who'll later
# accept the blast — they just need to stay online. ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Steps 1-2: tap CTA → choice sheet → "bestie baru" ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
# "bestie baru" branch — onTap path resets the draft + pushes
# /payment/entry. The label "bestie baru" alone is too short and may also
# match the CTA we just tapped on home; use the subtitle text "cari bestie
# baru yang siap dengerin" which is unique to this card.
- tapOn: "(?s).*cari bestie baru yang siap dengerin.*"
# /payment/entry → /payment/method-pick (returning customer, no discount).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
# --- Step 3: pick chat → duration → tier → cara bayar → bayar ---
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
# --- Step 4: waiting-payment → force-confirm ---
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 5: notif-gate → /chat/searching (BLAST, not targeted-wait) ---
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*lagi nyari bestie.*"
timeout: 20000
# --- Step 6: any online mitra accepts the blast ---
# We use the seeded mitra's id as a known-online acceptor. (If they're no
# longer online, the assertion below will time out — re-run after
# refreshing their heartbeat.)
- runScript:
file: ../scripts/mitra_accept_latest_internal.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 7: chat screen renders ---
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000
- assertVisible: "(?s).*${output.MITRA_NAME_RE}.*"

View File

@@ -0,0 +1,143 @@
# TS-05 — Payment expired → retry preserves targeting
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-05).
#
# §4 branch covered: PickMethod → PickDuration → PayMethod → WaitPay →
# PayStat(timeout 20 min) → PayExpired → Pay(retry) → paid →
# PairRoute(lama) → Targeted → S10.
#
# What this proves: the `targetedMitraId` on the payment draft survives
# the expired-retry round trip (Stage 5.1 `resetExceptTarget` invariant).
# After the retry pays, the customer lands on /chat/waiting-targeted/<SAME
# mitraId> — NOT a fresh blast.
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - >= 1 mitra online — they're the targeted mitra throughout. The flow
# does NOT drive accept (we stop at the targeted-waiting screen — the
# assertion that targeting survived the retry is the goal).
#
# Run:
# maestro test client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Seed history with online M1 (targeted throughout). ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Walk TS-01 steps 1-6 to reach /payment/waiting for the targeted
# attempt against M1. ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
# /payment/entry → /payment/method-pick (returning, no discount).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
# --- Force-expire the latest pending payment ---
- runScript:
file: ../scripts/force_expire_latest_payment.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Poller picks `expired` within ~3s → /payment/expired/:paymentId ---
- extendedWaitUntil:
visible:
text: "(?s).*pembayaran kedaluwarsa.*"
timeout: 10000
- assertVisible: "(?s).*coba lagi.*"
# --- Tap retry → /payment/method (NOT /payment/method-pick — the draft
# was preserved via resetExceptTarget, so we skip mode + duration). ---
- tapOn: "(?s).*coba lagi.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
# Sanity check: we did NOT bounce back to the mode picker.
- assertNotVisible: "(?s).*pilih cara curhat.*"
# --- Re-pay ---
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Notif-gate → targeted-waiting screen for the SAME mitra (M1).
# This is the load-bearing assertion: if targetedMitraId had been wiped
# by the expired-retry round trip, the customer would land on
# /chat/searching (blast) and we'd see "lagi nyari bestie" instead. ---
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*MENUNGGU JAWABAN.*"
timeout: 20000
- assertVisible: "(?s).*lagi nungguin ${output.MITRA_NAME_RE}.*"

View File

@@ -0,0 +1,187 @@
# TS-06 — Targeted request fails post-payment → fallback to blast
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-06).
#
# §4 branch covered: Targeted → TargetedRes(reject/timeout) →
# OfflinePopup(post-pay, returning variant) → "cari bestie lain" →
# fallback-to-blast → §3 BlastFlow → S10.
#
# What this proves: after paying for a targeted attempt, if the picked
# mitra rejects or times out, the customer can fall back to general blast
# WITHOUT a second payment (same payment_sessions row reused).
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - >= 2 mitras online in dev DB:
# 1. M1 — picked from history, becomes the targeted mitra; we
# simulate them rejecting via force_pairing_timeout.js.
# 2. M2 — the blast-fallback acceptor.
# If only one mitra is online, the fallback-to-blast cannot match.
#
# Known backend gap (TODO):
# The current force_pairing_timeout.js endpoint calls
# `expirePairingRequest()` which broadcasts WS PAIRING_FAILED
# (is_terminal=false). On the TargetedWaitingScreen, this maps the
# pairing state to PairingFailedData, NOT PairingTargetedUnavailableData
# — so the `BestieOfflinePopup` (returning variant) won't fire from the
# targeted-waiting screen as written.
#
# To make this flow pass end-to-end the backend test endpoint needs to
# detect when the latest pending_acceptance session is a TARGETED pair
# (chat_sessions.targeted_mitra_id is set / linked to a confirmed
# payment_session.targeted_mitra_id) and route to
# `expireTargetedPairingRequest` instead, which broadcasts
# RETURNING_CHAT_TIMEOUT → PairingTargetedUnavailableData → popup fires.
#
# Until that fix lands, this flow will fail at step "OfflinePopup
# visible". Leave it in place: it correctly expresses the intended
# product behavior and serves as a regression test for the backend fix.
#
# Run:
# maestro test client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
# Second online mitra that will accept the blast fallback. See task spec
# "Open question" — TS-06 needs >= 2 online mitras and no "any online
# acceptor" helper exists yet.
TEST_MITRA_ID_ACCEPTOR: "${TEST_MITRA_ID_ACCEPTOR}"
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Reset every mitra online first (test idempotency). ---
- runScript:
file: ../scripts/reset_all_mitras_online.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Seed history with M1 (online — they'll be the targeted mitra). Then
# ensure a different M2 is online so the post-rejection blast has an
# acceptor. ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/force_other_mitra_online.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Walk TS-01 steps 1-8 to reach /chat/waiting-targeted/<mitraId> ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*MENUNGGU JAWABAN.*"
timeout: 20000
# --- Force the targeted pending_acceptance session to expire ---
# TODO(backend): force-pairing-timeout currently calls
# expirePairingRequest (broadcasts PAIRING_FAILED), not
# expireTargetedPairingRequest (which would broadcast
# RETURNING_CHAT_TIMEOUT). The popup assertion below depends on the
# RETURNING_CHAT_TIMEOUT path — see header note.
- runScript:
file: ../scripts/force_pairing_timeout.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- BestieOfflinePopup (returning variant, post-pay) appears ---
# Title from bestie_unavailable_dialog.dart for the `returning` variant:
# "<mitraName> lagi nggak online". Primary CTA when canFallbackToBlast is
# true (M2 is reachable + paymentSessionId is set): "chat dengan bestie
# lain". This differs from the prePayReturning variant's "cari bestie
# lain" CTA — verified in bestie_unavailable_dialog.dart L140-152.
- extendedWaitUntil:
visible:
text: "(?s).*${output.MITRA_NAME_RE}.*lagi nggak online.*"
timeout: 15000
- assertVisible: "(?s).*chat dengan bestie lain.*"
# --- Tap "chat dengan bestie lain" → fallbackToBlast() ---
# In current code, fallback-to-blast creates a fresh pending request that
# may render as either /chat/searching ("lagi nyari bestie") for a
# multi-mitra blast OR /chat/waiting-targeted (MENUNGGU JAWABAN) when the
# backend reuses the existing payment session to target the next available
# mitra. Either pending state is acceptable here — the critical assertion
# is that the customer wasn't charged again (same payment_sessions row).
- tapOn: "(?s).*chat dengan bestie lain.*"
- extendedWaitUntil:
visible:
text: "(?s).*(lagi nyari bestie|MENUNGGU JAWABAN).*"
timeout: 15000
# --- M2 accepts the blast (any other online mitra) ---
- runScript:
file: ../scripts/accept_latest_pending.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Chat screen renders (with M2, not M1) ---
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000

View File

@@ -0,0 +1,101 @@
# TS-07 — Returning user with existing display_name skips set-name screen
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-07).
#
# 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-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
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
EXISTING_NAME: "Returning User"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# --- Setup: wipe the phone, then re-seed an identified customer with name ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/seed_customer.js
env:
TEST_PHONE: ${TEST_PHONE}
DISPLAY_NAME: ${EXISTING_NAME}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
# --- Welcome carousel → home (anon) ---
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# Home anon view shows the `masuk →` banner.
- extendedWaitUntil:
visible:
text: "(?s).*udah pernah pakai HaloBestie.*"
timeout: 30000
# --- Tap masuk → register → phone → OTP ---
- tapOn:
text: "(?s).*masuk →.*"
- extendedWaitUntil:
visible:
text: "(?s).*nomor wa-mu.*"
timeout: 10000
- tapOn:
point: "60%, 47%"
- inputText: "81234567890"
- hideKeyboard
- tapOn:
text: "(?s).*kirim kode.*"
- 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}
# --- KEY ASSERTIONS ---
# 1. OTP entry should disappear (auto-submit on 6th digit).
- extendedWaitUntil:
notVisible:
text: "Masukkan OTP"
timeout: 15000
# 2. Home renders directly. Identified (verified) users land on the
# `_SHomeReturningView` regardless of chat history: greeting becomes
# "halo, <name>" and CTA flips to "curhat sama bestie baru". The
# 1st-time view ("aku mau curhat") is the anon-user variant only.
- extendedWaitUntil:
visible:
text: "(?s).*halo, ${EXISTING_NAME}.*"
timeout: 20000
- assertVisible: "(?s).*curhat sama bestie baru.*"
# 3. The "Siapa namamu?" set-name screen must NOT have been shown —
# if it had, the assertion above would have failed at the set-name
# intermediate. This belt-and-braces assert catches the case where
# the set-name screen briefly flashes then auto-redirects.
- assertNotVisible:
text: "(?s).*Siapa namamu.*"

View File

@@ -0,0 +1,17 @@
// Accept the latest pending pairing notification regardless of mitra. Used
// by flows where the acceptor mitra UUID isn't known in advance — e.g.
// TS-02 (blast where the seeded mitra was forced offline, so an unknown
// OTHER online mitra got the chat_request_notification row).
//
// Backed by /internal/_test/accept-latest-pending (no body needed).
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/accept-latest-pending`, {
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
if (resp.status !== 200) {
throw new Error(`accept-latest-pending failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.ACCEPTED_SESSION_ID = data.session_id
output.ACCEPTOR_MITRA_ID = data.mitra_id

View File

@@ -0,0 +1,20 @@
// Force a specific mitra OFFLINE via the dev-only
// /internal/_test/force-mitra-offline endpoint. Used by Maestro flows that
// need the bestie-history-list row for a particular mitra to render in its
// offline (dimmed) state — see TS-02 / TS-03 in
// requirement/phase4-customer-flow.md.
//
// Reads MITRA_ID from env (typically `${output.MITRA_ID}` from a prior
// seed_history_session.js run) and BACKEND_INTERNAL_URL.
const mitraId = MITRA_ID
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!mitraId) {
throw new Error('MITRA_ID env not set — pass output.MITRA_ID from seed_history_session.js')
}
const resp = http.post(`${url}/internal/_test/force-mitra-offline`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mitra_id: mitraId }),
})
if (resp.status !== 200) {
throw new Error(`force-mitra-offline failed (${resp.status}): ${resp.body}`)
}

View File

@@ -0,0 +1,17 @@
// Force a DIFFERENT mitra online (one other than the seeded one) so blast
// flows (TS-02, TS-06) have an acceptor available after the seeded mitra is
// forced offline. Picks any currently-offline mitra excluding MITRA_ID.
//
// Reads MITRA_ID (the seeded mitra to exclude, from seed_history_session.js)
// and BACKEND_INTERNAL_URL.
const excludeId = MITRA_ID
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/force-mitra-online`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exclude_mitra_id: excludeId }),
})
if (resp.status !== 200) {
throw new Error(`force-mitra-online failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.OTHER_MITRA_ID = data.mitra_id

View File

@@ -0,0 +1,19 @@
// Have the test mitra "accept" the most recent pending pairing request via
// the dev-only /internal/_test/mitra-accept-latest endpoint (no JWT needed).
//
// Reads MITRA_ID from the env that the calling flow injects — typically
// `${output.MITRA_ID}` from a prior seed_history_session.js run.
const mitraId = MITRA_ID
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!mitraId) {
throw new Error('MITRA_ID env not set — pass output.MITRA_ID from seed_history_session.js')
}
const resp = http.post(`${url}/internal/_test/mitra-accept-latest`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mitra_id: mitraId }),
})
if (resp.status !== 200) {
throw new Error(`mitra-accept-latest failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.ACCEPTED_SESSION_ID = data.session_id

View File

@@ -0,0 +1,16 @@
// Bulk-mark every mitra row online in mitra_online_status. Used as a setup
// step at the start of each Maestro flow so seed_history_session has at
// least one online mitra to pick, regardless of what previous tests did
// (e.g. force-mitra-offline lingering from a prior TS-02/TS-03 run).
//
// Backed by /internal/_test/reset-all-mitras-online.
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/reset-all-mitras-online`, {
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
if (resp.status !== 200) {
throw new Error(`reset-all-mitras-online failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.ONLINE_COUNT = data.online_count

View File

@@ -0,0 +1,20 @@
// Upsert a customer row with TEST_PHONE + DISPLAY_NAME via the dev-only
// /internal/_test/seed-customer endpoint. Used by TS-07 to set up the
// "returning user already has a name" precondition, so the OTP sign-in
// path can verify the set-name screen is skipped for existing identified
// customers.
const phone = TEST_PHONE
const displayName = DISPLAY_NAME
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!phone) throw new Error('TEST_PHONE env not set')
if (!displayName) throw new Error('DISPLAY_NAME env not set')
const resp = http.post(`${url}/internal/_test/seed-customer`, {
body: JSON.stringify({ phone, display_name: displayName }),
headers: { 'Content-Type': 'application/json' },
})
if (resp.status !== 200) {
throw new Error(`seed-customer failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.CUSTOMER_ID = data.id
output.CUSTOMER_DISPLAY_NAME = data.display_name

View File

@@ -16,3 +16,7 @@ const data = json(resp.body)
output.SESSION_ID = data.session_id output.SESSION_ID = data.session_id
output.MITRA_ID = data.mitra_id output.MITRA_ID = data.mitra_id
output.MITRA_NAME = data.mitra_name output.MITRA_NAME = data.mitra_name
// Regex-escaped variant for Maestro `text:` selectors (which do FULL-string
// regex match). Display names can contain `+` (phone-as-name), `.`, etc.
// which break selectors otherwise.
output.MITRA_NAME_RE = data.mitra_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

View File

@@ -0,0 +1,100 @@
# Shared onboarding prelude for Phase 4 §4 "returning user" Maestro flows
# (TS-01 through TS-06 — see requirement/phase4-customer-flow.md).
#
# This subflow drives a clean-slate emulator from cold start to /home as a
# verified customer. The verified+display-name'd state is the precondition
# for every TS scenario in §4, so we extract it here to avoid ~80 lines of
# duplication across the six flows.
#
# Pre-reqs (parent flow's responsibility):
# - Parent flow has `env:` block defining TEST_PHONE and
# BACKEND_INTERNAL_URL (Maestro subflows inherit env from caller).
# - Parent flow runs `reset_phone.js` + `launchApp clearState: true`
# BEFORE invoking this subflow.
# - NODE_ENV != 'production' on backend (so /internal/_test routes exist).
#
# Path taken:
# Welcome carousel ("Mulai") → Home with anon banner →
# tap "masuk →" → /auth/register → enter +62 subscriber digits →
# "kirim kode" → OTP screen → peek OTP from stub → auto-submit →
# /auth/set-name (because AuthNeedsDisplayNameData) → enter "Maestro" →
# "Lanjut" → /home (returning view: "curhatan sebelumnya" header).
#
# Selector style: Flutter merges sibling Text widgets inside a single
# tappable parent into ONE accessibility blob. Maestro's `text:` selector
# does a FULL-string regex match, so we wrap selectors in `(?s).*…*` for
# anything that lives inside an InkWell with multiple Texts. Empty
# TextFields don't expose their hint to Maestro a11y, so we tap by point
# inside the field's pill before typing.
appId: ${APP_ID_ANDROID}
---
# Welcome carousel — the "Mulai" button is the only Text inside its
# tappable region, so a plain selector works.
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# Home — login-recover banner ("udah pernah pakai HaloBestie? … masuk →")
# is one InkWell, so any token within its merged blob reaches the
# /auth/register handler.
- extendedWaitUntil:
visible:
text: "(?s).*udah pernah pakai HaloBestie.*"
timeout: 30000
- tapOn:
text: "(?s).*masuk →.*"
# Register screen — personalised title "nomor wa-mu, {name}?" is in a
# merged blob with the subtitle copy.
- extendedWaitUntil:
visible:
text: "(?s).*nomor wa-mu.*"
timeout: 10000
# Phone input — +62 is a static prefix chip; type only subscriber digits
# (no leading 0). +6281234567890 → 81234567890. Tap by point inside the
# pill (well right of the +62 chip, well above the kirim-kode button).
- tapOn:
point: "60%, 47%"
- inputText: "81234567890"
- hideKeyboard
- tapOn:
text: "(?s).*kirim kode.*"
# OTP screen — peek the code from the stub endpoint.
- 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}
# After 6th digit OTP auto-submits. The anon customer's phone is attached
# but display_name is still empty → AuthNeedsDisplayNameData → router
# pushes /auth/set-name ("Siapa namamu?").
- extendedWaitUntil:
visible:
text: "(?s).*Siapa namamu.*"
timeout: 20000
# Same TextField-hint-invisible-to-Maestro issue as the phone field — tap
# by point inside the "Nama panggilan" pill, then type a display name.
- tapOn:
point: "50%, 30%"
- inputText: "Maestro"
- hideKeyboard
- tapOn: "Lanjut"
# Now home renders the returning view ("curhatan sebelumnya" section
# header is the deterministic landmark — appears regardless of history).
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 30000

View File

@@ -1,7 +1,6 @@
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/constants.dart';
import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/pairing/pairing_notifier.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';
@@ -44,18 +43,32 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
ref.listenManual<PairingData>(pairingProvider, _onPairingState); ref.listenManual<PairingData>(pairingProvider, _onPairingState);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
// Kick off the blast if pairing hasn't started yet — Phase 4's // Kick off pairing if it hasn't started yet — Phase 4's multi-screen
// multi-screen payment flow lands here without a startSearch call // payment flow lands here without an upstream startSearch call
// (waiting → notif-gate → /chat/searching, no intermediate that // (waiting → notif-gate → /chat/searching, no intermediate that
// owned the call). // owned the call). Branch on draft.targetedMitraId: a returning-user
// "Curhat lagi" flow stamped the targeted mitra onto the draft before
// payment, so we fire the targeted request and bounce to the dedicated
// wait overlay; everything else is a general blast.
final state = ref.read(pairingProvider); final state = ref.read(pairingProvider);
if (state is PairingInitialData) { if (state is PairingInitialData) {
final draft = ref.read(paymentDraftNotifierProvider); final draft = ref.read(paymentDraftNotifierProvider);
if (draft.paymentId != null) { if (draft.paymentId != null) {
if (draft.targetedMitraId != null) {
// ignore: discarded_futures
ref.read(pairingProvider.notifier).startTargetedSearch(
paymentSessionId: draft.paymentId!,
mitraId: draft.targetedMitraId!,
mitraName: draft.targetedMitraName ?? 'Bestie',
topicSensitivity: draft.topicSensitivity,
);
context.go('/chat/waiting-targeted/${draft.targetedMitraId}');
return;
}
// ignore: discarded_futures // ignore: discarded_futures
ref.read(pairingProvider.notifier).startSearch( ref.read(pairingProvider.notifier).startSearch(
paymentSessionId: draft.paymentId!, paymentSessionId: draft.paymentId!,
topicSensitivity: TopicSensitivity.regular, topicSensitivity: draft.topicSensitivity,
); );
} }
} }

View File

@@ -6,24 +6,31 @@ import '../../../core/constants.dart';
import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/pairing/pairing_notifier.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 '../../payment/state/payment_draft_provider.dart';
import '../../support/widgets/tanya_admin_sheet.dart'; import '../../support/widgets/tanya_admin_sheet.dart';
/// Phase 4 Stage 8 — `BestieOfflinePopup`. /// Phase 4 Stage 8 — `BestieOfflinePopup`.
/// ///
/// Two variants: /// Three variants:
/// - [BestieOfflineVariant.returning] — the customer tried to chat with a /// - [BestieOfflineVariant.returning] — the customer tried to chat with a
/// specific mitra (history "Curhat lagi"); the targeted attempt failed /// specific mitra (history "Curhat lagi"); the targeted attempt failed
/// (409 `targeted_mitra_offline`, or WS `returning_chat_timeout` / /// (409 `targeted_mitra_offline`, or WS `returning_chat_timeout` /
/// `returning_chat_rejected`). Payment session is still `confirmed`, so we /// `returning_chat_rejected`). Payment session is still `confirmed`, so we
/// surface a `Chat dengan bestie lain` primary CTA when other besties are /// surface a `Chat dengan bestie lain` primary CTA when other besties are
/// reachable (calls [Pairing.fallbackToBlast]). /// reachable (calls [Pairing.fallbackToBlast]).
/// - [BestieOfflineVariant.prePayReturning] — Stage 5.3: the customer tapped
/// a dimmed (offline) row in `BestieHistoryList` BEFORE any payment. No
/// payment session exists yet, so the "cari bestie lain" CTA resets the
/// payment draft and pushes `/payment/entry` for a fresh blast-payment
/// flow. This branch never calls [Pairing.fallbackToBlast] because there's
/// no `paymentSessionId` to attach to.
/// - [BestieOfflineVariant.new_] — the customer triggered a general blast /// - [BestieOfflineVariant.new_] — the customer triggered a general blast
/// that bottomed out (no online besties). No fallback button; just a /// that bottomed out (no online besties). No fallback button; just a
/// ghost `tanya admin` and a `kembali ke home` exit. /// ghost `tanya admin` and a `kembali ke home` exit.
/// ///
/// Both variants expose `tanya admin` via a ghost CTA that opens the /// All variants expose `tanya admin` via a ghost CTA that opens the
/// [TanyaAdminSheet]. /// [TanyaAdminSheet].
enum BestieOfflineVariant { returning, new_ } enum BestieOfflineVariant { returning, prePayReturning, new_ }
class BestieOfflinePopup extends ConsumerWidget { class BestieOfflinePopup extends ConsumerWidget {
final BestieOfflineVariant variant; final BestieOfflineVariant variant;
@@ -65,8 +72,12 @@ class BestieOfflinePopup extends ConsumerWidget {
final hasOtherAvailable = availabilityAsync.valueOrNull ?? false; final hasOtherAvailable = availabilityAsync.valueOrNull ?? false;
final isReturning = variant == BestieOfflineVariant.returning; final isReturning = variant == BestieOfflineVariant.returning;
final title = isReturning ? '$mitraName lagi nggak online' : 'semua bestie lagi istirahat'; final isPrePayReturning = variant == BestieOfflineVariant.prePayReturning;
final body = isReturning final mentionsBestie = isReturning || isPrePayReturning;
final title = mentionsBestie
? '$mitraName lagi nggak online'
: 'semua bestie lagi istirahat';
final body = mentionsBestie
? 'bestie kamu belum bisa nerima chat sekarang. coba bestie lain atau balik ke beranda dulu ya.' ? 'bestie kamu belum bisa nerima chat sekarang. coba bestie lain atau balik ke beranda dulu ya.'
: 'lagi nggak ada bestie yang siap dengerin. coba lagi bentar, atau hubungin admin biar dibantu.'; : 'lagi nggak ada bestie yang siap dengerin. coba lagi bentar, atau hubungin admin biar dibantu.';
@@ -139,6 +150,19 @@ class BestieOfflinePopup extends ConsumerWidget {
); );
}, },
) )
else if (isPrePayReturning)
HaloButton(
label: 'cari bestie lain',
fullWidth: true,
onPressed: () {
// No payment session yet — clear any targeted-mitra intent
// on the draft so the fresh `/payment/entry` flow falls
// through to the blast branch.
ref.read(paymentDraftNotifierProvider.notifier).reset();
Navigator.of(context).pop();
context.push('/payment/entry');
},
)
else else
HaloButton( HaloButton(
label: 'kembali ke home', label: 'kembali ke home',
@@ -161,7 +185,7 @@ class BestieOfflinePopup extends ConsumerWidget {
TanyaAdminSheet.show(context); TanyaAdminSheet.show(context);
}, },
), ),
if (canFallbackToBlast) ...[ if (canFallbackToBlast || isPrePayReturning) ...[
const SizedBox(height: HaloSpacing.s4), const SizedBox(height: HaloSpacing.s4),
HaloButton( HaloButton(
label: 'kembali ke home', label: 'kembali ke home',

View File

@@ -6,6 +6,7 @@ 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';
import '../../core/theme/halo_tokens.dart'; import '../../core/theme/halo_tokens.dart';
import '../payment/state/payment_draft_provider.dart';
import 'providers/bestie_history_provider.dart'; import 'providers/bestie_history_provider.dart';
import 'widgets/bestie_choice_sheet.dart'; import 'widgets/bestie_choice_sheet.dart';
import 'widgets/halo_tab_bar.dart'; import 'widgets/halo_tab_bar.dart';
@@ -90,6 +91,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
await BestieChoiceSheet.show(context); await BestieChoiceSheet.show(context);
return; return;
} }
// explicit reset — this branch is blast-only, clear any stale targeted mitra
ref.read(paymentDraftNotifierProvider.notifier).reset();
context.push('/payment/entry'); context.push('/payment/entry');
} }

View File

@@ -5,6 +5,8 @@ import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.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 '../../chat/widgets/bestie_unavailable_dialog.dart';
import '../../payment/state/payment_draft_provider.dart';
import '../providers/bestie_history_provider.dart'; import '../providers/bestie_history_provider.dart';
/// `BestieHistoryList` — the picker for the returning-user "curhat lagi" /// `BestieHistoryList` — the picker for the returning-user "curhat lagi"
@@ -15,14 +17,19 @@ import '../providers/bestie_history_provider.dart';
/// lives in the Chat-tab Selesai sub-tab, not here. /// lives in the Chat-tab Selesai sub-tab, not here.
/// ///
/// Row interaction rules: /// Row interaction rules:
/// - mitra_is_online + status != closing → tap targets `/payment` with /// - mitra_is_online + status != closing → tap stamps the picked mitra
/// `targetedMitraId`, which jumps into the Stage-3.x payment flow and, /// onto `paymentDraftNotifierProvider` (via `setTargetedMitra`) and
/// once confirmed, the Stage-5 targeted-wait overlay. /// pushes `/payment/entry`. The Phase-4 multi-screen payment flow
/// (entry → method-pick → duration-pick → method → waiting → notif-gate
/// → searching) reads the targeting back off the draft to fire the
/// returning-chat request after the customer pays.
/// - status == closing → tap drops into the chat session screen so the /// - status == closing → tap drops into the chat session screen so the
/// user can finish the goodbye composer (one-time grace path). /// user can finish the goodbye composer (one-time grace path).
/// - mitra_is_online == false → row is dimmed and tap is disabled. Mermaid /// - mitra_is_online == false → row stays dimmed for the visual cue, but
/// §4 calls for a Bestie Offline Popup variant here, deferred until /// tap is enabled and opens [BestieOfflinePopup] with the
/// OfflinePopup gets its returning-user copy. /// [BestieOfflineVariant.prePayReturning] variant (Stage 5.3). The popup
/// offers "cari bestie lain" (resets the draft + pushes `/payment/entry`
/// for a fresh blast-payment flow) and `tanya admin`.
class BestieHistoryListScreen extends ConsumerWidget { class BestieHistoryListScreen extends ConsumerWidget {
const BestieHistoryListScreen({super.key}); const BestieHistoryListScreen({super.key});
@@ -116,12 +123,29 @@ class BestieHistoryListScreen extends ConsumerWidget {
); );
return; return;
} }
// Offline mitra — surface the Stage 5.3 pre-payment
// popup. The popup's "cari bestie lain" CTA resets the
// draft and pushes `/payment/entry` for a blast flow.
if (!item.mitraIsOnline) {
// ignore: discarded_futures
BestieOfflinePopup.show(
context,
variant: BestieOfflineVariant.prePayReturning,
mitraName: item.mitraName,
);
return;
}
if (item.mitraId == null) return; if (item.mitraId == null) return;
context.push('/payment', extra: <String, dynamic>{ // Stamp the targeted mitra onto the payment draft; the
'targetedMitraId': item.mitraId, // multi-screen payment flow (entry → method → waiting →
'mitraName': item.mitraName, // notif-gate → searching) reads it back to fire the
'topicSensitivity': TopicSensitivity.regular, // returning-chat request after payment confirms.
}); ref.read(paymentDraftNotifierProvider.notifier)
.setTargetedMitra(
mitraId: item.mitraId!,
mitraName: item.mitraName,
);
context.push('/payment/entry');
}, },
); );
}, },
@@ -156,15 +180,22 @@ class _BestieRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final canPick = item.mitraIsOnline && !isClosing && item.mitraId != null; // Online + has mitraId → full-colour, taps into targeted-pair payment.
// Closing → full-colour, taps into the goodbye composer (mitraId may be
// absent but `sessionId` is what the chat route needs).
// Offline → dim, but still tappable so [BestieOfflinePopup] can fire the
// Stage 5.3 pre-payment branch.
final isLive = item.mitraIsOnline && item.mitraId != null;
final tappable = isLive || isClosing || !item.mitraIsOnline;
final dim = !isLive && !isClosing;
return Material( return Material(
color: HaloTokens.surface, color: HaloTokens.surface,
borderRadius: HaloRadius.lg, borderRadius: HaloRadius.lg,
child: InkWell( child: InkWell(
onTap: canPick ? onPick : null, onTap: tappable ? onPick : null,
borderRadius: HaloRadius.lg, borderRadius: HaloRadius.lg,
child: Opacity( child: Opacity(
opacity: canPick ? 1.0 : 0.55, opacity: dim ? 0.55 : 1.0,
child: Padding( child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s16), padding: const EdgeInsets.all(HaloSpacing.s16),
child: Row( child: Row(
@@ -239,7 +270,7 @@ class _BestieRow extends StatelessWidget {
'', '',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: canPick ? HaloTokens.brand : HaloTokens.inkMuted, color: dim ? HaloTokens.inkMuted : HaloTokens.brand,
), ),
), ),
], ],

View File

@@ -1,14 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.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 '../../payment/state/payment_draft_provider.dart';
/// Phase 4 Stage 8 — Bestie Choice Sheet. /// Phase 4 Stage 8 — Bestie Choice Sheet.
/// ///
/// Triggered from the home `Mulai Curhat` CTA when the user has at least one /// Triggered from the home `Mulai Curhat` CTA when the user has at least one
/// prior session. Two cards: continue with a known bestie (→ history list) /// prior session. Two cards: continue with a known bestie (→ history list)
/// vs. find a new bestie (→ soft-prompt + blast). /// vs. find a new bestie (→ soft-prompt + blast).
class BestieChoiceSheet extends StatelessWidget { class BestieChoiceSheet extends ConsumerWidget {
const BestieChoiceSheet({super.key}); const BestieChoiceSheet({super.key});
static Future<void> show(BuildContext context) { static Future<void> show(BuildContext context) {
@@ -19,7 +21,7 @@ class BestieChoiceSheet extends StatelessWidget {
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -60,6 +62,8 @@ class BestieChoiceSheet extends StatelessWidget {
subtitle: 'cari bestie baru yang siap dengerin sekarang.', subtitle: 'cari bestie baru yang siap dengerin sekarang.',
icon: Icons.auto_awesome_outlined, icon: Icons.auto_awesome_outlined,
onTap: () { onTap: () {
// explicit reset — this branch is blast-only, clear any stale targeted mitra
ref.read(paymentDraftNotifierProvider.notifier).reset();
Navigator.of(context).pop(); Navigator.of(context).pop();
context.push('/payment/entry'); context.push('/payment/entry');
}, },

View File

@@ -1,180 +0,0 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../core/api/api_client.dart';
import '../../core/api/api_client_provider.dart';
import '../../core/constants.dart';
part 'payment_notifier.g.dart';
/// Payment-session lifecycle, customer side. The screen owns one of these per
/// (mitra-target, duration) attempt; the notifier wraps the REST calls to
/// `/api/client/payment-sessions`.
sealed class PaymentSessionData {
const PaymentSessionData();
}
class PaymentInitialData extends PaymentSessionData {
const PaymentInitialData();
}
class PaymentCreatingData extends PaymentSessionData {
const PaymentCreatingData();
}
/// Created server-side, sitting in `pending` until the customer taps "Bayar".
class PaymentPendingData extends PaymentSessionData {
final String paymentSessionId;
final int amount;
final int durationMinutes;
final bool isFreeTrial;
final bool isExtension;
final String? targetedMitraId;
const PaymentPendingData({
required this.paymentSessionId,
required this.amount,
required this.durationMinutes,
required this.isFreeTrial,
required this.isExtension,
this.targetedMitraId,
});
}
class PaymentConfirmingData extends PaymentSessionData {
final String paymentSessionId;
const PaymentConfirmingData(this.paymentSessionId);
}
/// Confirmed; the customer can now be routed to the searching screen with
/// this `paymentSessionId` (and optional `targetedMitraId` for "Curhat lagi").
class PaymentConfirmedData extends PaymentSessionData {
final String paymentSessionId;
final int durationMinutes;
final bool isFreeTrial;
final bool isExtension;
final String? targetedMitraId;
const PaymentConfirmedData({
required this.paymentSessionId,
required this.durationMinutes,
required this.isFreeTrial,
required this.isExtension,
this.targetedMitraId,
});
}
class PaymentErrorData extends PaymentSessionData {
final String message;
const PaymentErrorData(this.message);
}
@riverpod
class Payment extends _$Payment {
ApiClient get _api => ref.read(apiClientProvider);
@override
PaymentSessionData build() => const PaymentInitialData();
/// Create a `pending` payment session for the chosen [durationMinutes].
/// Pass [targetedMitraId] for the "Curhat lagi" path; pass [isExtension]
/// for an extension-cost payment (never combined with free trial).
Future<void> createSession({
required int durationMinutes,
String? targetedMitraId,
bool isExtension = false,
}) async {
state = const PaymentCreatingData();
try {
final body = <String, dynamic>{
'duration_minutes': durationMinutes,
if (targetedMitraId != null) 'targeted_mitra_id': targetedMitraId,
if (isExtension) 'is_extension': true,
};
// Trailing slash matters: the backend route is `app.post('/', ...)` mounted
// at prefix `/api/client/payment-sessions`, and Fastify is not configured
// with `ignoreTrailingSlash: true`, so the canonical URL has the slash.
final response = await _api.post('/api/client/payment-sessions/', data: body);
final data = response['data'] as Map<String, dynamic>;
state = PaymentPendingData(
paymentSessionId: data['id'] as String,
amount: data['amount'] as int? ?? 0,
durationMinutes: data['duration_minutes'] as int? ?? durationMinutes,
isFreeTrial: data['is_free_trial'] as bool? ?? false,
isExtension: data['is_extension'] as bool? ?? isExtension,
targetedMitraId: data['targeted_mitra_id'] as String?,
);
} on DioException catch (e) {
state = PaymentErrorData(_humanError(e, fallback: 'Gagal membuat sesi pembayaran.'));
} catch (_) {
state = const PaymentErrorData('Gagal membuat sesi pembayaran.');
}
}
/// Confirm the pending payment. Backend rejects truly empty bodies on
/// `POST .../confirm`, so we always send `{}`.
Future<void> confirm() async {
final current = state;
if (current is! PaymentPendingData) return;
state = PaymentConfirmingData(current.paymentSessionId);
try {
await _api.post(
'/api/client/payment-sessions/${current.paymentSessionId}/confirm',
data: const <String, dynamic>{},
);
state = PaymentConfirmedData(
paymentSessionId: current.paymentSessionId,
durationMinutes: current.durationMinutes,
isFreeTrial: current.isFreeTrial,
isExtension: current.isExtension,
targetedMitraId: current.targetedMitraId,
);
} on DioException catch (e) {
state = PaymentErrorData(_humanError(e, fallback: 'Gagal mengkonfirmasi pembayaran.'));
} catch (_) {
state = const PaymentErrorData('Gagal mengkonfirmasi pembayaran.');
}
}
/// Best-effort cancel of a still-pending session. Safe to call on dispose
/// even if the state isn't `pending` — we just no-op in that case.
Future<void> cancelIfPending() async {
final current = state;
if (current is! PaymentPendingData) return;
final id = current.paymentSessionId;
try {
await _api.post(
'/api/client/payment-sessions/$id/cancel',
data: const <String, dynamic>{},
);
} catch (_) {
// Best-effort — backend sweeper will expire stale `pending` rows
// after `payment_session_timeout_minutes` regardless.
}
}
/// Reset to initial — used when the screen is re-entered for a new attempt.
void reset() {
state = const PaymentInitialData();
}
String _humanError(DioException e, {required String fallback}) {
final code = e.response?.data?['error']?['code'] as String?;
final status = e.response?.statusCode;
if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') {
return 'Pilihan durasi tidak valid.';
}
if (status == 403) return 'Sesi tidak diizinkan.';
if (status == 404) return 'Sesi pembayaran tidak ditemukan.';
if (code == 'EXPIRED') return 'Sesi pembayaran sudah kedaluwarsa.';
return fallback;
}
}
/// Mirror of backend `PaymentSessionStatus` for any UI that needs to inspect
/// the raw status field (kept tiny for now — most flows route via state above).
class PaymentStatus {
static const pending = PaymentSessionStatus.pending;
static const confirmed = PaymentSessionStatus.confirmed;
static const consumed = PaymentSessionStatus.consumed;
PaymentStatus._();
}

View File

@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'payment_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$paymentHash() => r'd98f2e7e5045ea2a39b7af0d4a9f0601dd06ce74';
/// See also [Payment].
@ProviderFor(Payment)
final paymentProvider =
AutoDisposeNotifierProvider<Payment, PaymentSessionData>.internal(
Payment.new,
name: r'paymentProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$paymentHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Payment = AutoDisposeNotifier<PaymentSessionData>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -26,7 +26,11 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
super.initState(); super.initState();
Future.microtask(() { Future.microtask(() {
if (!mounted) return; if (!mounted) return;
ref.read(paymentDraftNotifierProvider.notifier).reset(); // Targeting is set BEFORE this screen (by bestie-history-list) and must
// survive the entry-screen reset, so use resetExceptTarget() — full
// reset() would wipe targetedMitraId and silently downgrade the
// returning-targeted flow to a blast.
ref.read(paymentDraftNotifierProvider.notifier).resetExceptTarget();
}); });
} }

View File

@@ -64,6 +64,11 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
'price_idr': draft.priceIDR, 'price_idr': draft.priceIDR,
'is_first_session_discount': draft.isFirstSessionDiscount, 'is_first_session_discount': draft.isFirstSessionDiscount,
'method': _selected.id, 'method': _selected.id,
// Returning-targeted "Curhat lagi" flow: backend ties the payment
// session to the picked mitra so the eventual chat request can fire
// against the same bestie. Absent on the general-blast path.
if (draft.targetedMitraId != null)
'targeted_mitra_id': draft.targetedMitraId,
}; };
// Trailing slash matches the existing payment_notifier path — Fastify // Trailing slash matches the existing payment_notifier path — Fastify
// is not configured with `ignoreTrailingSlash`. // is not configured with `ignoreTrailingSlash`.

View File

@@ -1,400 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/constants.dart';
import '../../../core/pairing/pairing_notifier.dart';
import '../payment_notifier.dart';
/// Payment screen.
///
/// Reuses the mock pricing service (tiers + free trial). The customer picks a
/// duration (or auto-selects the free trial); on tap the screen creates a
/// `pending` payment session, then on "Bayar" / "Mulai" confirms it and routes
/// to the searching screen carrying `paymentSessionId` (and `targetedMitraId`
/// if this is a "Curhat lagi" flow).
///
/// Reachable from:
/// - Home "Mulai Curhat" CTA → no targeted mitra, normal blast follows.
/// - Chat history "Curhat lagi" CTA → targetedMitraId set, returning-chat
/// flow follows.
class PaymentScreen extends ConsumerStatefulWidget {
/// "Curhat lagi" only — when set, the eventual chat-request goes through
/// the returning-chat endpoint targeting this mitra.
final String? targetedMitraId;
/// Optional display name for the targeted mitra, surfaced in the screen
/// header so the customer knows who they're paying to chat with again.
final String? mitraName;
/// The topic-sensitivity choice the customer made in the topic-selection
/// bottom sheet on the home screen. Carried through here to be passed into
/// the chat-request API after confirm. Defaults to regular.
final TopicSensitivity topicSensitivity;
const PaymentScreen({
super.key,
this.targetedMitraId,
this.mitraName,
this.topicSensitivity = TopicSensitivity.regular,
});
@override
ConsumerState<PaymentScreen> createState() => _PaymentScreenState();
}
class _PaymentScreenState extends ConsumerState<PaymentScreen> {
/// Local UI selection (not in the notifier) — the duration the customer is
/// previewing before they tap to lock it in via createSession.
int? _selectedDurationMinutes;
/// True once we've kicked off `createSession()` for the current selection;
/// used to suppress double-taps while the round-trip is in flight.
bool _creatingSession = false;
@override
void initState() {
super.initState();
// Make sure no stale state leaks in from a previous payment attempt.
Future.microtask(() => ref.read(paymentProvider.notifier).reset());
}
@override
void deactivate() {
// Best-effort cancel on back/leave if we still have a `pending` row.
// The notifier checks state before calling the API, so this is safe to
// call unconditionally. Lives in deactivate(), not dispose(), because
// modern Riverpod invalidates `ref` once dispose() starts — the resulting
// `Bad state: Cannot use "ref" after the widget was disposed.` corrupts
// the widget-tree finalize and leaves the next screen frozen.
// ignore: discarded_futures
ref.read(paymentProvider.notifier).cancelIfPending();
super.deactivate();
}
Future<void> _onTierTapped({
required int durationMinutes,
required int price,
}) async {
if (_creatingSession) return;
// `price` is informational (already shown in the tier card) — the source
// of truth for the amount comes back from the backend.
setState(() {
_selectedDurationMinutes = durationMinutes;
_creatingSession = true;
});
await ref.read(paymentProvider.notifier).createSession(
durationMinutes: durationMinutes,
targetedMitraId: widget.targetedMitraId,
);
if (mounted) setState(() => _creatingSession = false);
}
Future<void> _onConfirmTapped() async {
final notifier = ref.read(paymentProvider.notifier);
await notifier.confirm();
}
Future<void> _routeToSearchOnConfirmed(PaymentConfirmedData payment) async {
// Kick off the right pairing flow against the freshly-confirmed payment.
final pairing = ref.read(pairingProvider.notifier);
if (payment.targetedMitraId != null) {
await pairing.startTargetedSearch(
paymentSessionId: payment.paymentSessionId,
mitraId: payment.targetedMitraId!,
mitraName: widget.mitraName ?? 'Bestie',
topicSensitivity: widget.topicSensitivity,
);
} else {
await pairing.startSearch(
paymentSessionId: payment.paymentSessionId,
topicSensitivity: widget.topicSensitivity,
);
}
if (!mounted) return;
// Reset our local notifier so a future payment attempt starts clean.
ref.read(paymentProvider.notifier).reset();
// Phase 4 Stage 5: targeted "Curhat lagi" lands on the dedicated
// SWaitingBestie overlay screen; general blast still uses the searching
// shell (which renders inline soft-prompt + timeout panels).
if (payment.targetedMitraId != null) {
context.go('/chat/waiting-targeted/${payment.targetedMitraId}');
} else {
context.go('/chat/searching');
}
}
@override
Widget build(BuildContext context) {
// One-shot side-effect listener: when the payment lands in `confirmed`,
// route to the searching screen.
ref.listen<PaymentSessionData>(paymentProvider, (prev, next) {
if (next is PaymentConfirmedData) {
// ignore: discarded_futures
_routeToSearchOnConfirmed(next);
}
});
final paymentState = ref.watch(paymentProvider);
final pricingAsync = ref.watch(chatPricingProvider);
final isReturning = widget.targetedMitraId != null;
return PopScope(
canPop: true,
child: Scaffold(
appBar: AppBar(
title: Text(isReturning ? 'Chat lagi dengan ${widget.mitraName ?? 'Bestie'}' : 'Pilih Sesi & Bayar'),
leading: IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
// PopScope above lets canPop fire dispose() which cancels the
// pending session. If there's no back-stack, fall back to home.
if (context.canPop()) {
context.pop();
} else {
context.go('/home');
}
},
),
),
body: pricingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('Gagal memuat harga. Coba lagi.', textAlign: TextAlign.center),
),
),
data: (pricing) => _buildBody(pricing, paymentState),
),
),
);
}
Widget _buildBody(PricingData pricing, PaymentSessionData paymentState) {
// Inline error widget per project memory ("Avoid SnackBars for provider errors").
final errorBanner = paymentState is PaymentErrorData
? Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
paymentState.message,
style: TextStyle(color: Colors.red.shade900),
),
),
],
),
)
: const SizedBox.shrink();
return Column(
children: [
errorBanner,
Expanded(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
const Text(
'Pilih Durasi Curhat',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
if (pricing.freeTrialEligible) ...[
_FreeTrialCard(
durationMinutes: pricing.freeTrialDurationMinutes,
selected: paymentState is PaymentPendingData && paymentState.isFreeTrial,
onTap: () => _onTierTapped(
// For free trial: backend still wants a duration_minutes —
// pass the trial duration. The backend overrides amount→0
// when the customer is eligible.
durationMinutes: pricing.freeTrialDurationMinutes,
price: 0,
),
),
const Divider(height: 24),
],
...pricing.tiers.map((tier) {
final selected = _selectedDurationMinutes == tier.durationMinutes &&
paymentState is PaymentPendingData &&
!paymentState.isFreeTrial;
return _TierCard(
label: tier.label,
priceLabel: formatRupiah(tier.price),
selected: selected,
onTap: () => _onTierTapped(
durationMinutes: tier.durationMinutes,
price: tier.price,
),
);
}),
],
),
),
if (paymentState is PaymentPendingData ||
paymentState is PaymentConfirmingData ||
paymentState is PaymentCreatingData)
_ConfirmBar(
paymentState: paymentState,
onConfirm: _onConfirmTapped,
formatPrice: formatRupiah,
),
],
);
}
}
class _FreeTrialCard extends StatelessWidget {
final int durationMinutes;
final bool selected;
final VoidCallback onTap;
const _FreeTrialCard({
required this.durationMinutes,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
color: selected ? Colors.green.shade100 : Colors.green.shade50,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: selected
? BorderSide(color: Colors.green.shade700, width: 1.5)
: BorderSide.none,
),
child: ListTile(
leading: const Icon(Icons.card_giftcard, color: Colors.green),
title: Text('Free Trial ($durationMinutes Menit)'),
subtitle: const Text('Gratis untuk pertama kali!'),
trailing: Text(
'Gratis',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green.shade800),
),
onTap: onTap,
),
);
}
}
class _TierCard extends StatelessWidget {
final String label;
final String priceLabel;
final bool selected;
final VoidCallback onTap;
const _TierCard({
required this.label,
required this.priceLabel,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: selected
? const BorderSide(color: Colors.pink, width: 1.5)
: BorderSide.none,
),
child: ListTile(
title: Text(label),
trailing: Text(
priceLabel,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
onTap: onTap,
),
);
}
}
class _ConfirmBar extends StatelessWidget {
final PaymentSessionData paymentState;
final Future<void> Function() onConfirm;
final String Function(int) formatPrice;
const _ConfirmBar({
required this.paymentState,
required this.onConfirm,
required this.formatPrice,
});
@override
Widget build(BuildContext context) {
final isCreating = paymentState is PaymentCreatingData;
final isConfirming = paymentState is PaymentConfirmingData;
final pending = paymentState is PaymentPendingData ? paymentState as PaymentPendingData : null;
final totalLabel = pending == null
? '...'
: pending.isFreeTrial
? 'Gratis'
: formatPrice(pending.amount);
final ctaLabel = pending != null && pending.isFreeTrial ? 'Mulai' : 'Bayar';
final disabled = isCreating || isConfirming || pending == null;
return SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total', style: TextStyle(fontSize: 16)),
Text(
totalLabel,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: disabled ? null : onConfirm,
child: isConfirming || isCreating
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Text(ctaLabel, style: const TextStyle(fontSize: 16)),
),
),
],
),
),
);
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/constants.dart';
part 'payment_draft_provider.g.dart'; part 'payment_draft_provider.g.dart';
@@ -31,6 +33,21 @@ class PaymentDraft {
final String? paymentId; final String? paymentId;
final bool isFirstSessionDiscount; final bool isFirstSessionDiscount;
/// When set, this payment is for a "Curhat lagi" (returning-targeted) flow:
/// downstream of payment confirm, `searching_screen` will fire the targeted
/// chat request against this specific mitra rather than the general blast.
/// Set by `BestieHistoryListScreen` BEFORE pushing `/payment/entry`.
final String? targetedMitraId;
/// Optional display name for the targeted mitra — surfaced on the targeted
/// waiting overlay ("lagi nungguin {name}") and any future returning-flow UI
/// that wants to greet the customer with the right bestie.
final String? targetedMitraName;
/// Topic-sensitivity choice made before entering the payment flow. Carried
/// through to the eventual chat-request API call. Defaults to `regular`.
final TopicSensitivity topicSensitivity;
const PaymentDraft({ const PaymentDraft({
this.mode = PaymentMode.chat, this.mode = PaymentMode.chat,
this.durationId, this.durationId,
@@ -38,6 +55,9 @@ class PaymentDraft {
this.priceIDR, this.priceIDR,
this.paymentId, this.paymentId,
this.isFirstSessionDiscount = false, this.isFirstSessionDiscount = false,
this.targetedMitraId,
this.targetedMitraName,
this.topicSensitivity = TopicSensitivity.regular,
}); });
PaymentDraft copyWith({ PaymentDraft copyWith({
@@ -47,6 +67,9 @@ class PaymentDraft {
int? priceIDR, int? priceIDR,
String? paymentId, String? paymentId,
bool? isFirstSessionDiscount, bool? isFirstSessionDiscount,
String? targetedMitraId,
String? targetedMitraName,
TopicSensitivity? topicSensitivity,
}) { }) {
return PaymentDraft( return PaymentDraft(
mode: mode ?? this.mode, mode: mode ?? this.mode,
@@ -55,6 +78,9 @@ class PaymentDraft {
priceIDR: priceIDR ?? this.priceIDR, priceIDR: priceIDR ?? this.priceIDR,
paymentId: paymentId ?? this.paymentId, paymentId: paymentId ?? this.paymentId,
isFirstSessionDiscount: isFirstSessionDiscount ?? this.isFirstSessionDiscount, isFirstSessionDiscount: isFirstSessionDiscount ?? this.isFirstSessionDiscount,
targetedMitraId: targetedMitraId ?? this.targetedMitraId,
targetedMitraName: targetedMitraName ?? this.targetedMitraName,
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
); );
} }
} }
@@ -103,9 +129,45 @@ class PaymentDraftNotifier extends _$PaymentDraftNotifier {
state = state.copyWith(paymentId: paymentId); state = state.copyWith(paymentId: paymentId);
} }
/// Wipe the draft when entering the flow fresh (e.g. tapping "Mulai Curhat" /// Mark this draft as a targeted "Curhat lagi" flow against a specific
/// from home). Keeping it across back-nav inside the flow is the default. /// mitra. Must be called BEFORE pushing `/payment/entry` from the
/// bestie-history list — the entry screen calls [resetExceptTarget] to
/// clear stale tier/payment state while preserving the targeting set here.
void setTargetedMitra({required String mitraId, String? mitraName}) {
state = state.copyWith(
targetedMitraId: mitraId,
targetedMitraName: mitraName,
);
}
/// Set the topic-sensitivity choice for the upcoming chat request.
void setTopicSensitivity(TopicSensitivity topicSensitivity) {
state = state.copyWith(topicSensitivity: topicSensitivity);
}
/// Full reset — clears EVERYTHING including targeted-mitra intent.
/// Use this when starting a fresh BLAST flow (e.g. "bestie baru" branch or
/// the no-history Home CTA). If you want to preserve a targeted-mitra
/// selection made just before entering the payment flow (set via
/// [setTargetedMitra]), use [resetExceptTarget] instead.
void reset() { void reset() {
debugPrint('[PaymentDraft] reset() — clearing entire draft including targeted-mitra intent');
state = const PaymentDraft(); state = const PaymentDraft();
} }
/// Reset everything EXCEPT the targeted-mitra fields. Used by the payment
/// entry screen so a fresh dive into the multi-screen flow clears any stale
/// tier/payment state while preserving the just-picked targeted mitra. If
/// the draft has no targeted mitra set, this behaves identically to
/// [reset].
void resetExceptTarget() {
debugPrint(
'[PaymentDraft] resetExceptTarget() — preserving targetedMitraId=${state.targetedMitraId}',
);
state = PaymentDraft(
targetedMitraId: state.targetedMitraId,
targetedMitraName: state.targetedMitraName,
topicSensitivity: state.topicSensitivity,
);
}
} }

View File

@@ -14,7 +14,6 @@ import 'features/splash/splash_screen.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'features/home/screens/bestie_history_list_screen.dart'; import 'features/home/screens/bestie_history_list_screen.dart';
import 'features/profile/profile_screen.dart'; import 'features/profile/profile_screen.dart';
import 'core/constants.dart';
import 'features/chat/screens/searching_screen.dart'; import 'features/chat/screens/searching_screen.dart';
import 'features/chat/screens/bestie_found_screen.dart'; import 'features/chat/screens/bestie_found_screen.dart';
import 'features/chat/screens/no_bestie_screen.dart'; import 'features/chat/screens/no_bestie_screen.dart';
@@ -26,7 +25,6 @@ import 'features/chat_tab/screens/pembayaran_view.dart';
import 'features/chat_tab/screens/selesai_view.dart'; import 'features/chat_tab/screens/selesai_view.dart';
import 'features/chat/screens/targeted_waiting_screen.dart'; import 'features/chat/screens/targeted_waiting_screen.dart';
import 'features/chat/screens/thank_you_screen.dart'; import 'features/chat/screens/thank_you_screen.dart';
import 'features/payment/screens/payment_screen.dart';
import 'features/payment/screens/payment_entry_screen.dart'; import 'features/payment/screens/payment_entry_screen.dart';
import 'features/payment/screens/discount_paywall_screen.dart'; import 'features/payment/screens/discount_paywall_screen.dart';
import 'features/payment/screens/method_pick_screen.dart'; import 'features/payment/screens/method_pick_screen.dart';
@@ -166,25 +164,6 @@ GoRouter buildRouter(Ref ref) {
), ),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()), GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
GoRoute(path: '/payment', builder: (context, state) {
// Legacy Phase 3.7 single-screen payment. Still reachable from
// - Home "Mulai Curhat" CTA → no extras (general blast follows confirm)
// - Chat history "Curhat lagi" CTA → extras carry targetedMitraId/mitraName
// for the returning-chat flow, plus optional topicSensitivity.
// Phase 4 Stage 3 introduces sibling routes under `/payment/*`; the new
// entry point is `/payment/entry`. This route is preserved until Stage 5
// migrates the chat-history "Curhat lagi" flow.
final extra = state.extra;
if (extra is Map<String, dynamic>) {
final topic = extra['topicSensitivity'];
return PaymentScreen(
targetedMitraId: extra['targetedMitraId'] as String?,
mitraName: extra['mitraName'] as String?,
topicSensitivity: topic is TopicSensitivity ? topic : TopicSensitivity.regular,
);
}
return const PaymentScreen();
}),
// Phase 4 Stage 3 — multi-screen payment shell. // Phase 4 Stage 3 — multi-screen payment shell.
GoRoute(path: '/payment/entry', builder: (_, __) => const PaymentEntryScreen()), GoRoute(path: '/payment/entry', builder: (_, __) => const PaymentEntryScreen()),
GoRoute(path: '/payment/discount-paywall', builder: (_, __) => const DiscountPaywallScreen()), GoRoute(path: '/payment/discount-paywall', builder: (_, __) => const DiscountPaywallScreen()),

View File

@@ -125,10 +125,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -691,18 +691,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -1136,10 +1136,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.10"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/scheduler.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart'; import '../api/api_client.dart';
@@ -221,7 +222,15 @@ class MitraChat extends _$MitraChat {
void disconnect() { void disconnect() {
_cleanup(); _cleanup();
state = const MitraChatInitialData(); // State reset is deferred to post-frame: disconnect() is called from
// mitra_chat_screen's deactivate() during back-nav, and a synchronous
// `state =` here notifies watchers while the chat screen's widget tree is
// still being torn down — Riverpod throws "Tried to modify a provider
// while the widget tree was building". WS is already closed by _cleanup(),
// so deferring the state reset is a no-op for users.
SchedulerBinding.instance.addPostFrameCallback((_) {
state = const MitraChatInitialData();
});
} }
void sendMessage(String content) { void sendMessage(String content) {

View File

@@ -125,10 +125,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -643,18 +643,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -944,10 +944,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.10"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:

View File

@@ -248,22 +248,50 @@ flowchart TD
flowchart TD flowchart TD
CTA["'curhat sama bestie baru' 🟢"] --> Choice["Bestie Choice Sheet<br/>(BestieChoiceSheet) 🟢"] CTA["'curhat sama bestie baru' 🟢"] --> Choice["Bestie Choice Sheet<br/>(BestieChoiceSheet) 🟢"]
Choice -->|"bestie yang udah kenal"| HistList["Bestie History List<br/>(BestieHistoryList) 🟢"] Choice -->|"bestie yang udah kenal"| HistList["Bestie History List<br/>(BestieHistoryList) 🟢"]
Choice -->|"bestie baru"| BlastFlow["→ S7 Soft-prompt + Blast<br/>(see diagram 3)"] Choice -->|"bestie baru"| PickMethod
HistList --> PickBestie["pick bestie"] HistList --> PickBestie["pick bestie"]
PickBestie --> CheckOnline{"bestie online?"} PickBestie --> CheckOnline{"bestie online?"}
CheckOnline -->|"no"| OfflinePopup["Bestie Offline Popup<br/>(returning variant) 🟢"] CheckOnline -->|"no"| OfflinePopup["Bestie Offline Popup<br/>(returning variant) 🟢"]
OfflinePopup -->|"cari bestie lain"| BlastFlow OfflinePopup -->|"cari bestie lain"| PickMethod
OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin<br/>(WA / Telegram) 🟢"] OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin<br/>(WA / Telegram) 🟢"]
CheckOnline -->|"yes"| Targeted["Request targeted pair<br/>'Menunggu bestie tertentu' 🟢<br/>(20s countdown overlay)"] CheckOnline -->|"yes"| PickMethod["Pilih cara curhat<br/>(chat / voice call) 🟡"]
PickMethod --> PickDuration["Pemilihan harga<br/>(5 durations, full screen) 🟡"]
PickDuration --> PayMethod["Cara bayar (QRIS-first) 🟡"]
PayMethod --> Pay["Xendit checkout<br/>(QRIS / e-wallet) 🟡"]
Pay --> WaitPay["Waiting Payment<br/>(20-min QRIS clock) 🟡"]
WaitPay --> PayStat{"payment status"}
PayStat -->|"timeout 20 min"| PayExpired["Pembayaran expired 🟡<br/>→ retry"]
PayExpired --> Pay
PayStat -->|"paid"| PairRoute{"specific bestie?<br/>(branch user came from)"}
PairRoute -->|"yes · lama"| Targeted["Request targeted pair<br/>'Menunggu bestie tertentu' 🟢<br/>(20s countdown overlay)"]
PairRoute -->|"no · baru / cari lain"| BlastFlow["→ S7 Soft-prompt + Blast<br/>(see diagram 3)"]
Targeted --> TargetedRes{"mitra answers?"} Targeted --> TargetedRes{"mitra answers?"}
TargetedRes -->|"accept"| S10["→ S10 Chat Room"] TargetedRes -->|"accept"| S10["→ S10 Chat Room"]
TargetedRes -->|"reject / timeout"| OfflinePopup TargetedRes -->|"reject / timeout"| OfflinePopup
classDef missing fill:#ffe5e5,stroke:#c44979 classDef missing fill:#ffe5e5,stroke:#c44979
classDef partial fill:#fff4d6,stroke:#c69b3f classDef partial fill:#fff4d6,stroke:#c69b3f
class PickMethod,PickDuration,PayMethod,Pay,WaitPay,PayExpired partial
``` ```
> **Payment block added 2026-05-18:** the `PickMethod → … → WaitPay` chain
> mirrors §2's payment block — the **screens already exist** (reused from
> §2), but the *routing for the returning branches* through them is not yet
> wired in `client_app`. Three call-sites converge at `PickMethod`:
> 1. `Choice → "bestie yang udah kenal" → PickBestie → CheckOnline (yes)` — pay, then targeted pair
> 2. `Choice → "bestie baru"` — pay, then blast (handoff to §3)
> 3. `OfflinePopup → "cari bestie lain"` — pay, then blast (handoff to §3)
>
> After `PayStat → "paid"`, the `PairRoute` decision dispatches by the
> branch the user came from: targeted pair (case 1) or blast/§3 (cases
> 2 & 3). Today the lama branch (case 1) goes from `PickBestie` straight
> into a tier-pick + auto-confirm shortcut that skips QRIS; the baru
> branch (case 2) hops straight into §3 without paying — both tracked as
> the Stage-5 returning-user payment migration. The 🟡 marks reflect
> "screen exists, branch wiring missing", not new screens to build.
> Mitra-side targeted accept/reject UX is unchanged.
--- ---
## 5. Chat Room (S10) — countdown UX ## 5. Chat Room (S10) — countdown UX

View File

@@ -1,11 +1,26 @@
# Phase 4 — Implementation Plan # Phase 4 — Implementation Plan
> **Status (2026-05-10):** Stages 08 are code-complete and committed > **Status (2026-05-18):** Stages 010 + the §4 returning-user payment
> on master (commits `4ada7c9` through `862fc35`, plus the pre-Phase-4 > migration (Stages 5.1 / 5.3 / 5.4) are code-complete on master. Latest
> `4680c36` OTP test infrastructure). `flutter analyze` clean across > milestones:
> both apps; backend Vitest 15/15. **Stage 9 (test sweep) is >
> operator-driven and in progress** — see "Post-Stage-8 corrections" > - Pricing relational migration (commit `1c9d81d`).
> below for fixes applied during the visual sweep. > - §4 payment-before-pair for returning users (commit `e09f76c`):
> bestie-history row tap now routes through the full QRIS multi-screen
> flow before pairing, for both targeted (lama) and blast (baru /
> cari-bestie-lain) branches. Legacy `/payment` route + screen retired.
> - Maestro suite (commits `e09f76c` + `93fa5f1`): seven flows
> `ts-01..ts-07` covering every §4 branching point plus the §2
> "existing user with name skips set-name" edge. All green end-to-end
> against a debug APK rebuilt with the new code; shared onboarding
> prelude under `.maestro/subflows/`. New backend `_test` endpoints
> for offline/online/seed/accept fixtures (`/internal/_test/{force-mitra-{offline,online},reset-all-mitras-online,accept-latest-pending,seed-customer}`).
> - `/internal/_test/force-pairing-timeout` now branches targeted-vs-blast
> so the targeted-fail-post-payment path fires `RETURNING_CHAT_TIMEOUT`
> (drives the BestieOfflinePopup → fallback-to-blast UX).
>
> Older status notes for stages 08 preserved in
> "Post-Stage-8 corrections" below.
> See [phase4-customer-flow.md](phase4-customer-flow.md) for the PRD, > See [phase4-customer-flow.md](phase4-customer-flow.md) for the PRD,
> [flow_customer.md](flow_customer.md) for the source-of-truth flow, > [flow_customer.md](flow_customer.md) for the source-of-truth flow,

View File

@@ -599,3 +599,335 @@ OTP smoke (existing `01_smoke.yaml`) must keep passing — uses the dev-only
10. **Maestro coverage** + visual regression sweep. 10. **Maestro coverage** + visual regression sweep.
Each block is shippable independently — they share no breaking schema change. Each block is shippable independently — they share no breaking schema change.
---
# Test Scenarios
Manual reproduction checklists for Phase 4 customer flows. Tick boxes as
verified. Cluster tag `[C]` = client_app, `[BE]` = backend setup.
> **Coverage map** — these scenarios collectively exercise every branching
> point in §4 of `flow_customer.mermaid.md`, plus one §2 (auth) edge:
>
> | Branching point | Scenario(s) |
> |---|---|
> | Choice: `bestie yang udah kenal` vs `bestie baru` | TS-01/02/03/05/06 vs TS-04 |
> | CheckOnline: yes vs no (pre-pay) | TS-01/05/06 vs TS-02/03 |
> | OfflinePopup (pre-pay): `cari bestie lain` vs `tanya admin` | TS-02 vs TS-03 |
> | PayStat: `paid` vs `timeout 20 min` | TS-01/02/04/06 vs TS-05 |
> | PairRoute: `lama (Targeted)` vs `baru / cari lain (BlastFlow)` | TS-01/05/06 vs TS-02/04 |
> | TargetedRes: `accept` vs `reject/timeout` | TS-01/05 vs TS-06 |
> | §2 post-OTP: new user (set-name) vs existing user with name (skip) | TS-01..06 vs TS-07 |
## TS-01 — Returning user re-pays an online bestie (lama happy path)
**Flow:** §4 `Choice → "bestie yang udah kenal" → CheckOnline (yes) → PickMethod → … → paid → PairRoute (lama) → Targeted → accept → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Confirm the returning-user flow gates on payment and routes the
same picked mitra through targeted pairing (not blast).
**Pre-reqs**
- [ ] **[BE]** Backend reachable; test mitra signed in + online (renders
`ONLINE` pill in history list).
- [ ] **[BE]** Free-trial config OFF for the test customer (otherwise the
paywall path replaces the QRIS flow).
- [ ] **[C]** `client_app` pointed at local backend
(`--dart-define=API_BASE_URL=http://192.168.88.247:3000`); test customer
has at least one closed session with the test mitra so they appear in
bestie history.
**Steps**
1. [ ] **[C]** From home (returning state), tap `curhat sama bestie baru`
→ Bestie Choice Sheet appears.
2. [ ] **[C]** Tap `bestie yang udah kenal` → bestie history list opens;
the test mitra row shows `ONLINE` pill (not dimmed).
3. [ ] **[C]** Tap the test mitra row → app navigates to `/payment/entry`
(PickMethod). **The legacy `/payment` route is no longer reachable as
of Stage 5.4.**
4. [ ] **[C]** Pick `chat` (or `voice call`) → PickDuration.
5. [ ] **[C]** Pick any tier (e.g. `5 Menit`) → `/payment/method` (the
"cara bayar" screen).
6. [ ] **[C]** Pick a payment method (e.g. QRIS) → tap `Bayar` →
`/payment/waiting` (20-min QRIS countdown).
7. [ ] **[BE]** Manually confirm the payment via
`POST /api/client/payment-sessions/:id/confirm` (or use the mock
helper script).
8. [ ] **[C]** App auto-advances through notif-gate and lands on
`/chat/waiting-targeted/<mitraId>` ("Menunggu bestie tertentu" with
20s overlay).
9. [ ] **[mitra_app]** Accept the incoming targeted request.
10. [ ] **[C]** Customer lands on `/chat/session/:id` (S10 Chat Room) —
WS open, session timer running.
**Expected result**
- [ ] **[BE]** `payment_sessions` row has
`targeted_mitra_id = <test mitra id>` and `status = 'confirmed'`.
- [ ] **[BE]** `chat_sessions` row created with the same `mitra_id`; no
blast log entries.
- [ ] **[C]** Chat opens against the original mitra; no fallback to
`/chat/searching`.
**Notes / known gaps**
- Maestro flow `client_app/.maestro/flows/10_returning_repays.yaml` was
written against the pre-Stage-5.1 screen graph and needs a rewrite —
its selectors target the deleted legacy `/payment` screen
(`Chat lagi dengan <mitraName>` app-bar title, `MENUNGGU JAWABAN`
intermediate). When automating, rewrite this flow to walk the new
multi-screen path described above.
- Stage 5.4 (2026-05-18) deleted the legacy `/payment` route +
`payment_screen.dart`. Any selector still expecting the legacy app-bar
title is stale.
---
## TS-02 — Returning user picks offline bestie, "cari bestie lain" → blast
**Flow:** §4 `Choice → "bestie yang udah kenal" → CheckOnline (no) → OfflinePopup (pre-pay) → "cari bestie lain" → PickMethod → … → paid → PairRoute (baru) → BlastFlow → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Verify the `BestieOfflineVariant.prePayReturning` popup fires
when the picked bestie is offline pre-payment, and that "cari bestie lain"
routes through a fresh blast-payment flow with the targeted intent cleared.
**Pre-reqs**
- [ ] **[BE]** Test mitra from customer's history is **offline** (signed
out or heartbeat expired — row shows no `ONLINE` pill in history list).
- [ ] **[BE]** At least one OTHER mitra is online (so the blast can match).
- [ ] **[BE]** Free-trial OFF.
**Steps**
1. [ ] **[C]** From home, tap `curhat sama bestie baru` → Bestie Choice
Sheet.
2. [ ] **[C]** Tap `bestie yang udah kenal` → history list; the test mitra
row is **dimmed** (offline styling preserved as of Stage 5.3).
3. [ ] **[C]** Tap the dimmed row → `BestieOfflinePopup`
(`prePayReturning` variant) appears showing the mitra's name. Two
CTAs: `cari bestie lain` and `tanya admin`.
4. [ ] **[C]** Tap `cari bestie lain` → popup closes; app navigates to
`/payment/entry`. Payment draft has been `reset()` (no stale
`targetedMitraId`).
5. [ ] **[C]** Walk PickMethod → PickDuration → PayMethod → Pay →
`/payment/waiting`.
6. [ ] **[BE]** Manually confirm payment.
7. [ ] **[C]** App routes to `/chat/searching` (NOT
`/chat/waiting-targeted/...`).
8. [ ] **[mitra_app]** A different online mitra receives the blast and
accepts.
9. [ ] **[C]** Customer lands on `/chat/session/:id` with the new mitra.
**Expected result**
- [ ] **[BE]** `payment_sessions` row has `targeted_mitra_id IS NULL`
(draft was reset before push to `/payment/entry`).
- [ ] **[C]** Chat opens with the fallback mitra, not the original
offline one.
---
## TS-03 — Returning user picks offline bestie, "tanya admin" (escape)
**Flow:** §4 `Choice → "bestie yang udah kenal" → CheckOnline (no) → OfflinePopup (pre-pay) → "tanya admin" → AdminSheet (terminal)`
**Affects:** `client_app`.
**Goal:** Confirm the escape hatch — the user can leave the offline-popup
flow without paying by tapping "tanya admin", and no payment row is
created.
**Pre-reqs**
- [ ] Same as TS-02 (offline test mitra in customer's history).
**Steps**
1. [ ] **[C]** Reach the `BestieOfflinePopup` (`prePayReturning` variant)
via TS-02 steps 1-3.
2. [ ] **[C]** Tap `tanya admin` → popup closes; admin sheet opens with
WhatsApp / Telegram contact options.
3. [ ] **[C]** Dismiss the admin sheet → user returns to the bestie
history list.
**Expected result**
- [ ] **[BE]** No new `payment_sessions` row created during this scenario.
- [ ] **[C]** Payment draft state unchanged (no `targetedMitraId`, no
`paymentId`). User can re-enter the flow normally afterward.
---
## TS-04 — Returning user picks "bestie baru" → blast happy path
**Flow:** §4 `Choice → "bestie baru" → PickMethod → … → paid → PairRoute (baru) → BlastFlow → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Confirm the "bestie baru" branch routes through payment FIRST,
then blasts to all online mitras (no targeting).
**Pre-reqs**
- [ ] **[BE]** At least one online mitra (for blast match).
- [ ] **[BE]** Free-trial OFF.
- [ ] **[C]** Returning customer (has session history → Bestie Choice
Sheet renders both options).
**Steps**
1. [ ] **[C]** From home, tap `curhat sama bestie baru` → Bestie Choice
Sheet.
2. [ ] **[C]** Tap `bestie baru` → app navigates to `/payment/entry`.
Draft is explicitly `reset()` on this branch (clears any stale
`targetedMitraId` per Stage 5.1 Risk #4 mitigation).
3. [ ] **[C]** Walk PickMethod → PickDuration → PayMethod → Pay →
`/payment/waiting`.
4. [ ] **[BE]** Confirm payment.
5. [ ] **[C]** App routes to `/chat/searching` (NOT
`/chat/waiting-targeted/...`).
6. [ ] **[mitra_app]** An online mitra accepts the blast.
7. [ ] **[C]** Customer lands on `/chat/session/:id`.
**Expected result**
- [ ] **[BE]** `payment_sessions` row has `targeted_mitra_id IS NULL`.
- [ ] **[C]** Searching screen shows briefly; chat opens against whichever
mitra accepted.
---
## TS-05 — QRIS payment expired → retry preserves targeting
**Flow:** §4 `PickMethod → … → WaitPay → PayStat (timeout 20 min) → PayExpired → Pay (retry) → paid → PairRoute (lama) → Targeted → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Verify the QRIS 20-min expired retry path works for a returning
targeted attempt. The `targetedMitraId` on the draft must survive the
retry (no need to re-pick mitra or duration) — this is the
`resetExceptTarget` invariant from Stage 5.1.
**Pre-reqs**
- [ ] **[BE]** Backend reachable; online test mitra (from customer's
history).
- [ ] **[BE]** Either the sweeper marks `pending → expired` after 20 min,
or the test uses a shortened TTL / direct `UPDATE` to force expiry.
**Steps**
1. [ ] **[C]** Walk TS-01 steps 1-6 to reach `/payment/waiting` for a
targeted attempt against the test mitra.
2. [ ] **[BE]** Wait for or force the `pending → expired` transition on
the payment row.
3. [ ] **[C]** Polling sees `status = 'expired'` → app routes to
`/payment/expired/:paymentId`.
4. [ ] **[C]** Tap the retry CTA → app routes back to `/payment/method`
(NOT all the way to PickMethod; draft preserved via
`resetExceptTarget`).
5. [ ] **[C]** Re-pick payment method → tap `Bayar` → new
`/payment/waiting`.
6. [ ] **[BE]** Confirm the new payment.
7. [ ] **[C]** App routes to `/chat/waiting-targeted/<mitraId>` for the
**same mitra** as step 1 (no re-pick required).
**Expected result**
- [ ] **[BE]** Original `payment_sessions` row has `status = 'expired'`.
**New** row created with `status = 'confirmed'`. Both rows have the
same `targeted_mitra_id`.
- [ ] **[C]** Targeted intent survives retry; chat opens with the
original picked mitra.
**Variant note:** the same retry path applies to the blast branch (TS-02 /
TS-04) — draft has `targetedMitraId IS NULL` throughout, retry routes
back to `/payment/method`, blast fires after re-confirm. Worth a quick
sanity check if behavior diverges.
---
## TS-06 — Targeted request fails post-payment → fallback to blast
**Flow:** §4 `Targeted → TargetedRes (reject / timeout) → OfflinePopup (post-pay, returning variant) → "cari bestie lain" → fallback-to-blast → §3 BlastFlow → S10`
**Affects:** `client_app`, `backend`.
**Goal:** Verify the post-payment fallback path. After paying for a
targeted pair, if the picked mitra rejects or doesn't answer within 20s,
the customer can fall back to blast WITHOUT a second payment.
**Pre-reqs**
- [ ] **[BE]** Online test mitra (from history) AND at least one OTHER
online mitra (for the blast fallback).
**Steps**
1. [ ] **[C]** Walk TS-01 steps 1-8 to reach
`/chat/waiting-targeted/<mitraId>`.
2. [ ] **[mitra_app]** Reject the incoming targeted request (or do
nothing for the 20s countdown).
3. [ ] **[C]** Targeted-waiting screen detects the failure →
`BestieOfflinePopup` (`returning` variant, post-pay) appears with
`canFallbackToBlast: true`. CTAs: `cari bestie lain` and `tanya admin`.
4. [ ] **[C]** Tap `cari bestie lain` → app calls
`POST /api/client/chat/chat-requests/:paymentSessionId/fallback-to-blast`
→ routes to `/chat/searching`.
5. [ ] **[mitra_app]** A DIFFERENT online mitra accepts the blast.
6. [ ] **[C]** Customer lands on `/chat/session/:id` with the new mitra.
**Expected result**
- [ ] **[BE]** Same `payment_sessions` row is reused (still
`status = 'confirmed'`); customer is **not** charged a second time.
- [ ] **[BE]** `chat_sessions` row created with the fallback mitra
(NOT the original `targeted_mitra_id`).
- [ ] **[C]** Chat opens with the fallback mitra; no fresh payment
screens shown.
**Variant note:** the "tanya admin" CTA on this same popup is a terminal
escape (same shape as TS-03), but post-payment — the customer has already
paid, so this is effectively abandoning a paid session. Worth confirming
the UX (probably a confirmation prompt) and whether the payment is
refunded / converted to credit.
---
## TS-07 — Returning user with existing display_name skips set-name screen
**Flow:** §2 (verified path) `Choice → "verif WA" → OTP → user lookup → existing account (display_name set, has_transacted=false) → /home`. Verifies the existing-user-with-name branch of `resolveCustomerForIdentity`.
**Affects:** `client_app`, `backend`.
**Goal:** Confirm a phone-OTP sign-in for a customer who already has a
non-empty `display_name` in `customers` does NOT re-show the
"Siapa namamu?" set-name screen. Routes directly from OTP success
to /home with the stored display_name. This is the inverse of TS-01..TS-06,
all of which use `drop_customer:true` (wiping the row) and therefore always
land on the new-user set-name branch.
**Pre-reqs**
- [ ] **[BE]** Backend reachable; NODE_ENV != 'production'.
**Steps**
1. [ ] **[BE]** Wipe phone state via `/internal/_test/reset-phone`
`{ phone, drop_customer: true }` — clears any prior customer row.
2. [ ] **[BE]** Seed an identified customer via
`/internal/_test/seed-customer` `{ phone, display_name }` —
inserts a row with `is_anonymous=false` and the chosen display_name.
3. [ ] **[C]** Cold-launch `client_app` with clearState → welcome
carousel → tap `Mulai` → home (anonymous view, shows `masuk →` banner).
4. [ ] **[C]** Tap `masuk →` → `/auth/register` → input phone digits
(after the `+62` chip) → tap `kirim kode` → OTP screen.
5. [ ] **[C]** Peek OTP from the stub, input it — auto-submits on the
6th digit.
**Expected result**
- [ ] **[C]** App routes directly to `/home`, CTA `aku mau curhat`
visible (the `_SHome1stView` no-history variant). The customer's
stored display_name is loaded into the profile state.
- [ ] **[C]** The `Siapa namamu?` set-name screen is **never shown**.
An `assertNotVisible` for the set-name title at the home-arrival point
acts as a belt-and-braces check against a brief flash-then-redirect.
- [ ] **[BE]** No new `customers` row created — the seeded row is the
same one returned by `getCustomerByPhone` → `resolveCustomerForIdentity`
branch 1 (existing identity, no anon prefix). `customers.id` after the
flow equals the seeded `CUSTOMER_ID`.
**Why this needs its own test:** TS-01..TS-06 all begin with
`reset_phone` `drop_customer:true`, which makes every OTP path land in
`resolveCustomerForIdentity` branch 4 (no existing + no anon → create
new with display_name=null → client routes to set-name). That covers
the new-user surface but never exercises the "existing user with name"
path. TS-07 is the symmetric coverage for the same auth code, ensuring
the set-name screen isn't accidentally re-shown for known users (which
would be a real UX regression — name re-entry every login).