diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js index d15b41d..5a37131 100644 --- a/backend/src/routes/internal/_test.routes.js +++ b/backend/src/routes/internal/_test.routes.js @@ -6,7 +6,7 @@ // test phone numbers or fixed codes into production code paths. 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 { getDb } from '../../db/client.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}` 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_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 payment_sessions WHERE customer_id = ${id}` 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) { 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) - 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 @@ -286,4 +305,136 @@ export const internalTestRoutes = async (fastify) => { ` return { ok: true, payment_id: row.id, ...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 } + }) } diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index bdd80ec..0f5d853 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -742,7 +742,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC * - cause_tag is targeted_mitra_timeout (audit row only) * - 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` UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} diff --git a/client_app/.maestro/flows/ts-01_returning_lama_online.yaml b/client_app/.maestro/flows/ts-01_returning_lama_online.yaml new file mode 100644 index 0000000..696fde3 --- /dev/null +++ b/client_app/.maestro/flows/ts-01_returning_lama_online.yaml @@ -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 " + 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/ --- +# 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}.*" diff --git a/client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml b/client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml new file mode 100644 index 0000000..fb6cc2f --- /dev/null +++ b/client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml @@ -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: " 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 diff --git a/client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml b/client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml new file mode 100644 index 0000000..eb3b4cf --- /dev/null +++ b/client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml @@ -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". diff --git a/client_app/.maestro/flows/ts-04_returning_baru_blast.yaml b/client_app/.maestro/flows/ts-04_returning_baru_blast.yaml new file mode 100644 index 0000000..88b5485 --- /dev/null +++ b/client_app/.maestro/flows/ts-04_returning_baru_blast.yaml @@ -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}.*" diff --git a/client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml b/client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml new file mode 100644 index 0000000..7ba1a46 --- /dev/null +++ b/client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml @@ -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/ — 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}.*" diff --git a/client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml b/client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml new file mode 100644 index 0000000..f849d0a --- /dev/null +++ b/client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml @@ -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/ --- +- 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: +# " 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 diff --git a/client_app/.maestro/scripts/accept_latest_pending.js b/client_app/.maestro/scripts/accept_latest_pending.js new file mode 100644 index 0000000..c6a4c02 --- /dev/null +++ b/client_app/.maestro/scripts/accept_latest_pending.js @@ -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 diff --git a/client_app/.maestro/scripts/force_mitra_offline.js b/client_app/.maestro/scripts/force_mitra_offline.js new file mode 100644 index 0000000..b879592 --- /dev/null +++ b/client_app/.maestro/scripts/force_mitra_offline.js @@ -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}`) +} diff --git a/client_app/.maestro/scripts/force_other_mitra_online.js b/client_app/.maestro/scripts/force_other_mitra_online.js new file mode 100644 index 0000000..39b92a6 --- /dev/null +++ b/client_app/.maestro/scripts/force_other_mitra_online.js @@ -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 diff --git a/client_app/.maestro/scripts/mitra_accept_latest_internal.js b/client_app/.maestro/scripts/mitra_accept_latest_internal.js new file mode 100644 index 0000000..820df84 --- /dev/null +++ b/client_app/.maestro/scripts/mitra_accept_latest_internal.js @@ -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 diff --git a/client_app/.maestro/scripts/reset_all_mitras_online.js b/client_app/.maestro/scripts/reset_all_mitras_online.js new file mode 100644 index 0000000..9820558 --- /dev/null +++ b/client_app/.maestro/scripts/reset_all_mitras_online.js @@ -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 diff --git a/client_app/.maestro/scripts/seed_history_session.js b/client_app/.maestro/scripts/seed_history_session.js index f87e293..bc7bf00 100644 --- a/client_app/.maestro/scripts/seed_history_session.js +++ b/client_app/.maestro/scripts/seed_history_session.js @@ -16,3 +16,7 @@ const data = json(resp.body) output.SESSION_ID = data.session_id output.MITRA_ID = data.mitra_id 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, '\\$&') diff --git a/client_app/.maestro/subflows/onboarding_returning_user.yaml b/client_app/.maestro/subflows/onboarding_returning_user.yaml new file mode 100644 index 0000000..69b863f --- /dev/null +++ b/client_app/.maestro/subflows/onboarding_returning_user.yaml @@ -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 diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart index eede89f..0a15456 100644 --- a/client_app/lib/features/chat/screens/searching_screen.dart +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/constants.dart'; import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; @@ -44,18 +43,32 @@ class _SearchingScreenState extends ConsumerState { ref.listenManual(pairingProvider, _onPairingState); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - // Kick off the blast if pairing hasn't started yet — Phase 4's - // multi-screen payment flow lands here without a startSearch call + // Kick off pairing if it hasn't started yet — Phase 4's multi-screen + // payment flow lands here without an upstream startSearch call // (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); if (state is PairingInitialData) { final draft = ref.read(paymentDraftNotifierProvider); 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 ref.read(pairingProvider.notifier).startSearch( paymentSessionId: draft.paymentId!, - topicSensitivity: TopicSensitivity.regular, + topicSensitivity: draft.topicSensitivity, ); } } diff --git a/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart b/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart index 1a61763..b3f3a6c 100644 --- a/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart +++ b/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart @@ -6,24 +6,31 @@ import '../../../core/constants.dart'; import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; +import '../../payment/state/payment_draft_provider.dart'; import '../../support/widgets/tanya_admin_sheet.dart'; /// Phase 4 Stage 8 — `BestieOfflinePopup`. /// -/// Two variants: +/// Three variants: /// - [BestieOfflineVariant.returning] — the customer tried to chat with a /// specific mitra (history "Curhat lagi"); the targeted attempt failed /// (409 `targeted_mitra_offline`, or WS `returning_chat_timeout` / /// `returning_chat_rejected`). Payment session is still `confirmed`, so we /// surface a `Chat dengan bestie lain` primary CTA when other besties are /// 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 /// that bottomed out (no online besties). No fallback button; just a /// 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]. -enum BestieOfflineVariant { returning, new_ } +enum BestieOfflineVariant { returning, prePayReturning, new_ } class BestieOfflinePopup extends ConsumerWidget { final BestieOfflineVariant variant; @@ -65,8 +72,12 @@ class BestieOfflinePopup extends ConsumerWidget { final hasOtherAvailable = availabilityAsync.valueOrNull ?? false; final isReturning = variant == BestieOfflineVariant.returning; - final title = isReturning ? '$mitraName lagi nggak online' : 'semua bestie lagi istirahat'; - final body = isReturning + final isPrePayReturning = variant == BestieOfflineVariant.prePayReturning; + 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.' : '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 HaloButton( label: 'kembali ke home', @@ -161,7 +185,7 @@ class BestieOfflinePopup extends ConsumerWidget { TanyaAdminSheet.show(context); }, ), - if (canFallbackToBlast) ...[ + if (canFallbackToBlast || isPrePayReturning) ...[ const SizedBox(height: HaloSpacing.s4), HaloButton( label: 'kembali ke home', diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index b5bfb70..5fc9ed7 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -6,6 +6,7 @@ import '../../core/availability/mitra_availability_notifier.dart'; import '../../core/chat/active_session_notifier.dart'; import '../../core/notifications/notif_permission.dart'; import '../../core/theme/halo_tokens.dart'; +import '../payment/state/payment_draft_provider.dart'; import 'providers/bestie_history_provider.dart'; import 'widgets/bestie_choice_sheet.dart'; import 'widgets/halo_tab_bar.dart'; @@ -90,6 +91,8 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse await BestieChoiceSheet.show(context); return; } + // explicit reset — this branch is blast-only, clear any stale targeted mitra + ref.read(paymentDraftNotifierProvider.notifier).reset(); context.push('/payment/entry'); } diff --git a/client_app/lib/features/home/screens/bestie_history_list_screen.dart b/client_app/lib/features/home/screens/bestie_history_list_screen.dart index 5837643..1befba6 100644 --- a/client_app/lib/features/home/screens/bestie_history_list_screen.dart +++ b/client_app/lib/features/home/screens/bestie_history_list_screen.dart @@ -5,6 +5,8 @@ import '../../../core/api/api_client_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.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'; /// `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. /// /// Row interaction rules: -/// - mitra_is_online + status != closing → tap targets `/payment` with -/// `targetedMitraId`, which jumps into the Stage-3.x payment flow and, -/// once confirmed, the Stage-5 targeted-wait overlay. +/// - mitra_is_online + status != closing → tap stamps the picked mitra +/// onto `paymentDraftNotifierProvider` (via `setTargetedMitra`) and +/// 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 /// user can finish the goodbye composer (one-time grace path). -/// - mitra_is_online == false → row is dimmed and tap is disabled. Mermaid -/// §4 calls for a Bestie Offline Popup variant here, deferred until -/// OfflinePopup gets its returning-user copy. +/// - mitra_is_online == false → row stays dimmed for the visual cue, but +/// tap is enabled and opens [BestieOfflinePopup] with the +/// [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 { const BestieHistoryListScreen({super.key}); @@ -116,12 +123,29 @@ class BestieHistoryListScreen extends ConsumerWidget { ); 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; - context.push('/payment', extra: { - 'targetedMitraId': item.mitraId, - 'mitraName': item.mitraName, - 'topicSensitivity': TopicSensitivity.regular, - }); + // Stamp the targeted mitra onto the payment draft; the + // multi-screen payment flow (entry → method → waiting → + // notif-gate → searching) reads it back to fire the + // 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 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( color: HaloTokens.surface, borderRadius: HaloRadius.lg, child: InkWell( - onTap: canPick ? onPick : null, + onTap: tappable ? onPick : null, borderRadius: HaloRadius.lg, child: Opacity( - opacity: canPick ? 1.0 : 0.55, + opacity: dim ? 0.55 : 1.0, child: Padding( padding: const EdgeInsets.all(HaloSpacing.s16), child: Row( @@ -239,7 +270,7 @@ class _BestieRow extends StatelessWidget { '→', style: TextStyle( fontSize: 16, - color: canPick ? HaloTokens.brand : HaloTokens.inkMuted, + color: dim ? HaloTokens.inkMuted : HaloTokens.brand, ), ), ], diff --git a/client_app/lib/features/home/widgets/bestie_choice_sheet.dart b/client_app/lib/features/home/widgets/bestie_choice_sheet.dart index 2c01f4d..dd0be99 100644 --- a/client_app/lib/features/home/widgets/bestie_choice_sheet.dart +++ b/client_app/lib/features/home/widgets/bestie_choice_sheet.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; +import '../../payment/state/payment_draft_provider.dart'; /// Phase 4 Stage 8 — Bestie Choice Sheet. /// /// 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) /// vs. find a new bestie (→ soft-prompt + blast). -class BestieChoiceSheet extends StatelessWidget { +class BestieChoiceSheet extends ConsumerWidget { const BestieChoiceSheet({super.key}); static Future show(BuildContext context) { @@ -19,7 +21,7 @@ class BestieChoiceSheet extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -60,6 +62,8 @@ class BestieChoiceSheet extends StatelessWidget { subtitle: 'cari bestie baru yang siap dengerin sekarang.', icon: Icons.auto_awesome_outlined, onTap: () { + // explicit reset — this branch is blast-only, clear any stale targeted mitra + ref.read(paymentDraftNotifierProvider.notifier).reset(); Navigator.of(context).pop(); context.push('/payment/entry'); }, diff --git a/client_app/lib/features/payment/payment_notifier.dart b/client_app/lib/features/payment/payment_notifier.dart deleted file mode 100644 index c42381a..0000000 --- a/client_app/lib/features/payment/payment_notifier.dart +++ /dev/null @@ -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 createSession({ - required int durationMinutes, - String? targetedMitraId, - bool isExtension = false, - }) async { - state = const PaymentCreatingData(); - try { - final body = { - '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; - 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 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 {}, - ); - 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 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 {}, - ); - } 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._(); -} diff --git a/client_app/lib/features/payment/payment_notifier.g.dart b/client_app/lib/features/payment/payment_notifier.g.dart deleted file mode 100644 index 6955372..0000000 --- a/client_app/lib/features/payment/payment_notifier.g.dart +++ /dev/null @@ -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.internal( - Payment.new, - name: r'paymentProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$paymentHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$Payment = AutoDisposeNotifier; -// 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 diff --git a/client_app/lib/features/payment/screens/payment_entry_screen.dart b/client_app/lib/features/payment/screens/payment_entry_screen.dart index c85acba..3b72a58 100644 --- a/client_app/lib/features/payment/screens/payment_entry_screen.dart +++ b/client_app/lib/features/payment/screens/payment_entry_screen.dart @@ -26,7 +26,11 @@ class _PaymentEntryScreenState extends ConsumerState { super.initState(); Future.microtask(() { 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(); }); } diff --git a/client_app/lib/features/payment/screens/payment_method_screen.dart b/client_app/lib/features/payment/screens/payment_method_screen.dart index 3f063f2..9f947d7 100644 --- a/client_app/lib/features/payment/screens/payment_method_screen.dart +++ b/client_app/lib/features/payment/screens/payment_method_screen.dart @@ -64,6 +64,11 @@ class _PaymentMethodScreenState extends ConsumerState { 'price_idr': draft.priceIDR, 'is_first_session_discount': draft.isFirstSessionDiscount, '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 // is not configured with `ignoreTrailingSlash`. diff --git a/client_app/lib/features/payment/screens/payment_screen.dart b/client_app/lib/features/payment/screens/payment_screen.dart deleted file mode 100644 index 4d0c00f..0000000 --- a/client_app/lib/features/payment/screens/payment_screen.dart +++ /dev/null @@ -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 createState() => _PaymentScreenState(); -} - -class _PaymentScreenState extends ConsumerState { - /// 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 _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 _onConfirmTapped() async { - final notifier = ref.read(paymentProvider.notifier); - await notifier.confirm(); - } - - Future _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(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 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)), - ), - ), - ], - ), - ), - ); - } -} diff --git a/client_app/lib/features/payment/state/payment_draft_provider.dart b/client_app/lib/features/payment/state/payment_draft_provider.dart index 66a991a..ab1b1a8 100644 --- a/client_app/lib/features/payment/state/payment_draft_provider.dart +++ b/client_app/lib/features/payment/state/payment_draft_provider.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../core/constants.dart'; part 'payment_draft_provider.g.dart'; @@ -31,6 +33,21 @@ class PaymentDraft { final String? paymentId; 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({ this.mode = PaymentMode.chat, this.durationId, @@ -38,6 +55,9 @@ class PaymentDraft { this.priceIDR, this.paymentId, this.isFirstSessionDiscount = false, + this.targetedMitraId, + this.targetedMitraName, + this.topicSensitivity = TopicSensitivity.regular, }); PaymentDraft copyWith({ @@ -47,6 +67,9 @@ class PaymentDraft { int? priceIDR, String? paymentId, bool? isFirstSessionDiscount, + String? targetedMitraId, + String? targetedMitraName, + TopicSensitivity? topicSensitivity, }) { return PaymentDraft( mode: mode ?? this.mode, @@ -55,6 +78,9 @@ class PaymentDraft { priceIDR: priceIDR ?? this.priceIDR, paymentId: paymentId ?? this.paymentId, 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); } - /// Wipe the draft when entering the flow fresh (e.g. tapping "Mulai Curhat" - /// from home). Keeping it across back-nav inside the flow is the default. + /// Mark this draft as a targeted "Curhat lagi" flow against a specific + /// 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() { + debugPrint('[PaymentDraft] reset() — clearing entire draft including targeted-mitra intent'); 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, + ); + } } diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 24bf25d..49c2347 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -14,7 +14,6 @@ import 'features/splash/splash_screen.dart'; import 'features/home/home_screen.dart'; import 'features/home/screens/bestie_history_list_screen.dart'; import 'features/profile/profile_screen.dart'; -import 'core/constants.dart'; import 'features/chat/screens/searching_screen.dart'; import 'features/chat/screens/bestie_found_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/screens/targeted_waiting_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/discount_paywall_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: '/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) { - 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. GoRoute(path: '/payment/entry', builder: (_, __) => const PaymentEntryScreen()), GoRoute(path: '/payment/discount-paywall', builder: (_, __) => const DiscountPaywallScreen()), diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index 2053ccd..0562b10 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -691,18 +691,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1136,10 +1136,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: transitive description: diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart index c3c5ca4..c6afc42 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; +import 'package:flutter/scheduler.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../api/api_client.dart'; @@ -221,7 +222,15 @@ class MitraChat extends _$MitraChat { void disconnect() { _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) { diff --git a/mitra_app/pubspec.lock b/mitra_app/pubspec.lock index 8cf2041..a0a93ea 100644 --- a/mitra_app/pubspec.lock +++ b/mitra_app/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -643,18 +643,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -944,10 +944,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: transitive description: diff --git a/requirement/flow_customer.mermaid.md b/requirement/flow_customer.mermaid.md index dbf8968..5689530 100644 --- a/requirement/flow_customer.mermaid.md +++ b/requirement/flow_customer.mermaid.md @@ -248,22 +248,50 @@ flowchart TD flowchart TD CTA["'curhat sama bestie baru' 🟢"] --> Choice["Bestie Choice Sheet
(BestieChoiceSheet) 🟢"] Choice -->|"bestie yang udah kenal"| HistList["Bestie History List
(BestieHistoryList) 🟢"] - Choice -->|"bestie baru"| BlastFlow["→ S7 Soft-prompt + Blast
(see diagram 3)"] + Choice -->|"bestie baru"| PickMethod HistList --> PickBestie["pick bestie"] PickBestie --> CheckOnline{"bestie online?"} CheckOnline -->|"no"| OfflinePopup["Bestie Offline Popup
(returning variant) 🟢"] - OfflinePopup -->|"cari bestie lain"| BlastFlow + OfflinePopup -->|"cari bestie lain"| PickMethod OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin
(WA / Telegram) 🟢"] - CheckOnline -->|"yes"| Targeted["Request targeted pair
'Menunggu bestie tertentu' 🟢
(20s countdown overlay)"] + CheckOnline -->|"yes"| PickMethod["Pilih cara curhat
(chat / voice call) 🟡"] + PickMethod --> PickDuration["Pemilihan harga
(5 durations, full screen) 🟡"] + PickDuration --> PayMethod["Cara bayar (QRIS-first) 🟡"] + PayMethod --> Pay["Xendit checkout
(QRIS / e-wallet) 🟡"] + Pay --> WaitPay["Waiting Payment
(20-min QRIS clock) 🟡"] + WaitPay --> PayStat{"payment status"} + PayStat -->|"timeout 20 min"| PayExpired["Pembayaran expired 🟡
→ retry"] + PayExpired --> Pay + PayStat -->|"paid"| PairRoute{"specific bestie?
(branch user came from)"} + PairRoute -->|"yes · lama"| Targeted["Request targeted pair
'Menunggu bestie tertentu' 🟢
(20s countdown overlay)"] + PairRoute -->|"no · baru / cari lain"| BlastFlow["→ S7 Soft-prompt + Blast
(see diagram 3)"] Targeted --> TargetedRes{"mitra answers?"} TargetedRes -->|"accept"| S10["→ S10 Chat Room"] TargetedRes -->|"reject / timeout"| OfflinePopup classDef missing fill:#ffe5e5,stroke:#c44979 classDef partial fill:#fff4d6,stroke:#c69b3f + 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 diff --git a/requirement/phase4-customer-flow.md b/requirement/phase4-customer-flow.md index ee0bac5..c2fd1ea 100644 --- a/requirement/phase4-customer-flow.md +++ b/requirement/phase4-customer-flow.md @@ -599,3 +599,282 @@ OTP smoke (existing `01_smoke.yaml`) must keep passing — uses the dev-only 10. **Maestro coverage** + visual regression sweep. 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`: +> +> | 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 | + +## 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/` ("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 = ` 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 ` 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/` 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/`. +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.