Phase 4 §4: payment-before-pair for returning users + Maestro suite
Stages 5.1, 5.3, 5.4 of the returning-user flow rework. All three §4 entry paths now require payment BEFORE pairing, matching the updated mermaid spec. * Spec (requirement/flow_customer.mermaid.md §4): payment block converges three call-sites (bestie-yang-udah-kenal-online, bestie-baru, offline-popup → cari bestie lain). PairRoute dispatches lama → targeted pair, baru/cari-lain → §3 blast. §3 retains its post-payment-shared contract. * Stage 5.1 (client_app): PaymentDraft carries targetedMitraId + topicSensitivity. bestie_history_list seeds the draft + pushes /payment/entry (was legacy /payment). searching_screen branches on draft.targetedMitraId for blast-vs-targeted dispatch. payment_entry uses resetExceptTarget(); bestie_choice_sheet + home _onCurhatBestieBaruPressed call explicit reset() before push so the keepAlive draft can't leak stale targeting into a blast. * Stage 5.3 (client_app): new BestieOfflineVariant.prePayReturning. Bestie-history-list _BestieRow splits tappable from dim so offline rows render dimmed but route taps into the popup. CTA "cari bestie lain" resets the draft + pushes /payment/entry. * Stage 5.4 (client_app): deleted legacy /payment route, payment_screen.dart, payment_notifier.dart(+.g.dart). router cleaned. * Tests (requirement/phase4-customer-flow.md + client_app/.maestro/): six Maestro flows TS-01..TS-06 covering every §4 branching point, all passing end-to-end. Shared onboarding prelude under .maestro/subflows/. New helper scripts: accept_latest_pending, force_mitra_offline, force_other_mitra_online, reset_all_mitras_online, mitra_accept_latest_internal. New backend _test endpoints to match. /reset-phone now cascade-deletes customer_transactions (FK was blocking). /force-pairing-timeout branches targeted (RETURNING_CHAT_TIMEOUT via expireTargetedPairingRequest, now exported) vs blast (PAIRING_FAILED). seed_history_session also outputs MITRA_NAME_RE (regex-escaped) for reliable selectors against display names containing regex specials. * mitra_app: dispose-during-deactivate guardrail for back-press on the mitra chat screen after the customer's goodbye message. Pending real emulator repro verification (carried over from 2026-05-15). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
155
client_app/.maestro/flows/ts-01_returning_lama_online.yaml
Normal file
155
client_app/.maestro/flows/ts-01_returning_lama_online.yaml
Normal file
@@ -0,0 +1,155 @@
|
||||
# TS-01 — Returning user re-pays an online bestie (lama happy path)
|
||||
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-01).
|
||||
#
|
||||
# §4 branch covered: Choice → "bestie yang udah kenal" → CheckOnline(yes) →
|
||||
# PickMethod → PickDuration → PayMethod → Bayar → WaitPay → paid →
|
||||
# notif-gate → PairRoute(lama) → Targeted → accept → S10 chat.
|
||||
#
|
||||
# Pre-reqs (HARD — flow assumes these):
|
||||
# - Backend reachable (BACKEND_INTERNAL_URL).
|
||||
# - NODE_ENV != 'production' (so /internal/_test/* routes are registered).
|
||||
# - At least ONE mitra is currently online in dev DB. The dev seed
|
||||
# (backend/src/db/seed.js) does NOT auto-create mitras — ops must sign
|
||||
# in a test mitra via mitra_app + heartbeat (or insert
|
||||
# mitra_online_status manually). `seed_history_session.js` picks the
|
||||
# most-recently-online mitra; that same mitra must still be online when
|
||||
# the flow reaches the post-payment targeted-wait so
|
||||
# `mitra_accept_latest_internal.js` can drive accept.
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/ts-01_returning_lama_online.yaml
|
||||
appId: com.halobestie.client.client_app
|
||||
env:
|
||||
TEST_PHONE: "+6281234567890"
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
---
|
||||
# --- Cold-start reset + onboarding prelude ---
|
||||
- runScript:
|
||||
file: ../scripts/reset_phone.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- runFlow: ../subflows/onboarding_returning_user.yaml
|
||||
|
||||
# --- Seed a completed chat_sessions row so the bestie history list isn't
|
||||
# empty. The seed picks the most-recently-online mitra; that mitra remains
|
||||
# online (this is the "lama" branch — TS-01 needs them online). ---
|
||||
- runScript:
|
||||
file: ../scripts/seed_history_session.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# bestieHistoryProvider was already evaluated empty on the first home
|
||||
# render. Pull-to-refresh to re-fetch /chat/history so the seeded row shows
|
||||
# up and the CTA opens BestieChoiceSheet (instead of jumping straight to
|
||||
# /payment/entry, which is the no-history branch).
|
||||
- swipe:
|
||||
start: "50%, 30%"
|
||||
end: "50%, 80%"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*curhatan sebelumnya.*"
|
||||
timeout: 10000
|
||||
|
||||
# --- Step 1: tap CTA → Bestie Choice Sheet ---
|
||||
# After seeding, the CTA label flips from "aku mau curhat" (SHome1st) to
|
||||
# "curhat sama bestie baru" (SHomeReturning).
|
||||
- tapOn:
|
||||
text: "(?s).*curhat sama bestie baru.*"
|
||||
retryTapIfNoChange: true
|
||||
|
||||
# Sheet title "mau curhat sama siapa?" — `?` is regex meta; wrap in (?s).*…*
|
||||
# and use `.` for the trailing literal `?`.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*mau curhat sama siapa.*"
|
||||
timeout: 5000
|
||||
|
||||
# --- Step 2: pick "bestie yang udah kenal" → history list ---
|
||||
- tapOn: "(?s).*bestie yang udah kenal.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*bestie kamu sebelumnya.*"
|
||||
timeout: 5000
|
||||
|
||||
# --- Step 3: tap the seeded mitra row → /payment/entry → method-pick ---
|
||||
# Row label is "bestie <name>" + ONLINE pill (merged blob).
|
||||
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
|
||||
|
||||
# /payment/entry is a routing shim; for a returning customer with prior
|
||||
# completed sessions the discount is NOT eligible, so it forwards to
|
||||
# /payment/method-pick (`pilih cara curhat`).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih cara curhat.*"
|
||||
timeout: 10000
|
||||
|
||||
# --- Step 4: pick chat mode → /payment/duration-pick ---
|
||||
- tapOn:
|
||||
text: "(?s).*tulis dan baca dengan tenang.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih durasi.*"
|
||||
timeout: 10000
|
||||
|
||||
# --- Step 5: pick cheapest tier → CTA shows "bayar Rp X" ---
|
||||
# Tiers render via tier.label / formatRupiah; "5 Menit" is current label.
|
||||
- tapOn: "(?s).*5 Menit.*"
|
||||
- tapOn: "(?s).*bayar Rp.*"
|
||||
|
||||
# --- Step 6: cara-bayar screen → QRIS preselected → tap bayar ---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*cara bayar.*"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "(?s).*bayar Rp.*"
|
||||
retryTapIfNoChange: true
|
||||
|
||||
# --- Step 7: waiting-payment screen → force-confirm ---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "scan QRIS untuk bayar"
|
||||
timeout: 10000
|
||||
- runScript:
|
||||
file: ../scripts/mark_latest_payment_paid.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Step 8: notif-gate auto-advances (or "nanti aja" if shown) →
|
||||
# /chat/searching kicks off targeted-pair → routes to
|
||||
# /chat/waiting-targeted/<mitraId> ---
|
||||
# We accept either landing on notif-gate (if perm not yet decided) or
|
||||
# directly on targeted-waiting (if perm was already granted).
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: "(?s).*biar nggak ketinggalan.*"
|
||||
commands:
|
||||
- tapOn:
|
||||
text: "(?s).*nanti aja.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*MENUNGGU JAWABAN.*"
|
||||
timeout: 20000
|
||||
- assertVisible: "(?s).*lagi nungguin.*"
|
||||
|
||||
# --- Step 9: mitra-side accept via dev-only internal route ---
|
||||
- runScript:
|
||||
file: ../scripts/mitra_accept_latest_internal.js
|
||||
env:
|
||||
MITRA_ID: ${output.MITRA_ID}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Step 10: chat screen renders ---
|
||||
# Header shows mitra name + "online" status pill. The presence of either
|
||||
# the mitra name (post-accept) or the "online" indicator confirms we're on
|
||||
# the chat screen.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*online.*"
|
||||
timeout: 20000
|
||||
- assertVisible: "(?s).*${output.MITRA_NAME_RE}.*"
|
||||
@@ -0,0 +1,172 @@
|
||||
# TS-02 — Returning user picks offline bestie → "cari bestie lain" → blast
|
||||
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-02).
|
||||
#
|
||||
# §4 branch covered: Choice → "bestie yang udah kenal" → CheckOnline(no) →
|
||||
# OfflinePopup(pre-pay) → "cari bestie lain" → PickMethod → … → paid →
|
||||
# PairRoute(baru) → BlastFlow → S10 chat.
|
||||
#
|
||||
# Pre-reqs (HARD):
|
||||
# - Backend reachable; NODE_ENV != 'production'.
|
||||
# - >= 2 mitras online in dev DB at flow start. The flow:
|
||||
# 1. `seed_history_session.js` picks the most-recently-online mitra
|
||||
# (call this M1) and creates a history row.
|
||||
# 2. `force_mitra_offline.js` forces M1 offline so the history list
|
||||
# renders dim.
|
||||
# 3. After the user takes the blast branch, a SECOND online mitra
|
||||
# (M2) is required to accept the blast via
|
||||
# `mitra_accept_latest_internal.js`.
|
||||
# If only one mitra exists, the blast will have no acceptor and step
|
||||
# 10's chat-screen assertion will time out.
|
||||
# - Dev seed (backend/src/db/seed.js) does NOT auto-create mitras — ops
|
||||
# must sign in >= 2 test mitras via mitra_app + heartbeat.
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml
|
||||
appId: com.halobestie.client.client_app
|
||||
env:
|
||||
TEST_PHONE: "+6281234567890"
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
# Second online mitra that will accept the blast. Maestro flows currently
|
||||
# don't have a "pick any online mitra" helper, so we rely on
|
||||
# mitra-accept-latest taking the FIRST mitra that received the blast.
|
||||
# See "Open question" in the task spec — this is the "TS-02 needs two
|
||||
# online mitras" caveat.
|
||||
TEST_MITRA_ID_ACCEPTOR: "${TEST_MITRA_ID_ACCEPTOR}"
|
||||
---
|
||||
# --- Cold-start reset + onboarding prelude ---
|
||||
- runScript:
|
||||
file: ../scripts/reset_phone.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- runFlow: ../subflows/onboarding_returning_user.yaml
|
||||
|
||||
# --- Reset every mitra online first (so seed has someone to pick after
|
||||
# earlier offline-forcing tests left state dirty). ---
|
||||
- runScript:
|
||||
file: ../scripts/reset_all_mitras_online.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Seed history with M1, ensure a different M2 is online (the eventual
|
||||
# blast acceptor), then force M1 offline. ---
|
||||
- runScript:
|
||||
file: ../scripts/seed_history_session.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- runScript:
|
||||
file: ../scripts/force_other_mitra_online.js
|
||||
env:
|
||||
MITRA_ID: ${output.MITRA_ID}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- runScript:
|
||||
file: ../scripts/force_mitra_offline.js
|
||||
env:
|
||||
MITRA_ID: ${output.MITRA_ID}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# Refresh home to pick up the seeded history row + offline status.
|
||||
- swipe:
|
||||
start: "50%, 30%"
|
||||
end: "50%, 80%"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*curhatan sebelumnya.*"
|
||||
timeout: 10000
|
||||
|
||||
# --- Steps 1-2: tap CTA → choice sheet → "bestie yang udah kenal" ---
|
||||
- tapOn:
|
||||
text: "(?s).*curhat sama bestie baru.*"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*mau curhat sama siapa.*"
|
||||
timeout: 5000
|
||||
- tapOn: "(?s).*bestie yang udah kenal.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*bestie kamu sebelumnya.*"
|
||||
timeout: 5000
|
||||
|
||||
# --- Step 3: tap the DIMMED M1 row → BestieOfflinePopup (prePayReturning)
|
||||
# Popup title: "<mitraName> lagi nggak online" (verified against
|
||||
# bestie_unavailable_dialog.dart). CTA labels: "cari bestie lain" + "tanya
|
||||
# admin" + "kembali ke home" (ghost). ---
|
||||
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*${output.MITRA_NAME_RE}.*lagi nggak online.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*cari bestie lain.*"
|
||||
- assertVisible: "(?s).*tanya admin.*"
|
||||
|
||||
# --- Step 4: tap "cari bestie lain" → draft.reset() → /payment/entry ---
|
||||
- tapOn: "(?s).*cari bestie lain.*"
|
||||
|
||||
# /payment/entry forwards to /payment/method-pick for an ineligible
|
||||
# customer (prior completed session → no first-session discount).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih cara curhat.*"
|
||||
timeout: 10000
|
||||
|
||||
# --- Step 5: pick chat → duration → tier → cara bayar → bayar ---
|
||||
- tapOn:
|
||||
text: "(?s).*tulis dan baca dengan tenang.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih durasi.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s).*5 menit.*"
|
||||
- tapOn: "(?s).*bayar Rp.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*cara bayar.*"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "(?s).*bayar Rp.*"
|
||||
retryTapIfNoChange: true
|
||||
|
||||
# --- Step 6: waiting-payment → force-confirm ---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "scan QRIS untuk bayar"
|
||||
timeout: 10000
|
||||
- runScript:
|
||||
file: ../scripts/mark_latest_payment_paid.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Step 7: notif-gate → /chat/searching (BLAST, not targeted-wait) ---
|
||||
# Because the popup's "cari bestie lain" CTA reset the draft, targetedMitraId
|
||||
# is null → searching_screen.dart fires startSearch() (blast), NOT
|
||||
# startTargetedSearch().
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: "(?s).*biar nggak ketinggalan.*"
|
||||
commands:
|
||||
- tapOn:
|
||||
text: "(?s).*nanti aja.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*lagi nyari bestie.*"
|
||||
timeout: 20000
|
||||
|
||||
# --- Step 8: any other online mitra accepts the blast ---
|
||||
# accept-latest-pending picks whichever mitra received the latest blast
|
||||
# notification — we don't care which one as long as it's not the seeded
|
||||
# (force-offline) one. Pre-req: ≥1 OTHER online mitra in the dev DB.
|
||||
- runScript:
|
||||
file: ../scripts/accept_latest_pending.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Step 9: chat screen renders ---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*online.*"
|
||||
timeout: 20000
|
||||
@@ -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".
|
||||
134
client_app/.maestro/flows/ts-04_returning_baru_blast.yaml
Normal file
134
client_app/.maestro/flows/ts-04_returning_baru_blast.yaml
Normal file
@@ -0,0 +1,134 @@
|
||||
# TS-04 — Returning user picks "bestie baru" → blast happy path
|
||||
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-04).
|
||||
#
|
||||
# §4 branch covered: Choice → "bestie baru" → PickMethod → PickDuration →
|
||||
# PayMethod → Bayar → WaitPay → paid → PairRoute(baru) → BlastFlow →
|
||||
# S10 chat.
|
||||
#
|
||||
# What this proves: tapping "bestie baru" explicitly resets the payment
|
||||
# draft (clearing any stale targetedMitraId — Stage 5.1 Risk #4
|
||||
# mitigation), so the post-payment route is /chat/searching (blast), NOT
|
||||
# /chat/waiting-targeted/...
|
||||
#
|
||||
# Pre-reqs (HARD):
|
||||
# - Backend reachable; NODE_ENV != 'production'.
|
||||
# - At least ONE mitra is online to accept the blast.
|
||||
# - The customer must qualify as "returning" so the choice sheet renders
|
||||
# (not auto-jumps to /payment/entry). We seed a history row to force
|
||||
# this. The seeded mitra does NOT need to stay online — they're not
|
||||
# the acceptor here (this is the "bestie baru" branch — blast goes to
|
||||
# whoever's online).
|
||||
# - Dev seed (backend/src/db/seed.js) does NOT auto-create mitras — sign
|
||||
# in at least one test mitra via mitra_app + heartbeat.
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/ts-04_returning_baru_blast.yaml
|
||||
appId: com.halobestie.client.client_app
|
||||
env:
|
||||
TEST_PHONE: "+6281234567890"
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
---
|
||||
# --- Cold-start reset + onboarding prelude ---
|
||||
- runScript:
|
||||
file: ../scripts/reset_phone.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- runFlow: ../subflows/onboarding_returning_user.yaml
|
||||
|
||||
# --- Seed history so the customer qualifies as "returning" and the choice
|
||||
# sheet (with both options) renders. The seeded mitra is who'll later
|
||||
# accept the blast — they just need to stay online. ---
|
||||
- runScript:
|
||||
file: ../scripts/seed_history_session.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
- swipe:
|
||||
start: "50%, 30%"
|
||||
end: "50%, 80%"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*curhatan sebelumnya.*"
|
||||
timeout: 10000
|
||||
|
||||
# --- Steps 1-2: tap CTA → choice sheet → "bestie baru" ---
|
||||
- tapOn:
|
||||
text: "(?s).*curhat sama bestie baru.*"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*mau curhat sama siapa.*"
|
||||
timeout: 5000
|
||||
|
||||
# "bestie baru" branch — onTap path resets the draft + pushes
|
||||
# /payment/entry. The label "bestie baru" alone is too short and may also
|
||||
# match the CTA we just tapped on home; use the subtitle text "cari bestie
|
||||
# baru yang siap dengerin" which is unique to this card.
|
||||
- tapOn: "(?s).*cari bestie baru yang siap dengerin.*"
|
||||
|
||||
# /payment/entry → /payment/method-pick (returning customer, no discount).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih cara curhat.*"
|
||||
timeout: 10000
|
||||
|
||||
# --- Step 3: pick chat → duration → tier → cara bayar → bayar ---
|
||||
- tapOn:
|
||||
text: "(?s).*tulis dan baca dengan tenang.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih durasi.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s).*5 menit.*"
|
||||
- tapOn: "(?s).*bayar Rp.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*cara bayar.*"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "(?s).*bayar Rp.*"
|
||||
retryTapIfNoChange: true
|
||||
|
||||
# --- Step 4: waiting-payment → force-confirm ---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "scan QRIS untuk bayar"
|
||||
timeout: 10000
|
||||
- runScript:
|
||||
file: ../scripts/mark_latest_payment_paid.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Step 5: notif-gate → /chat/searching (BLAST, not targeted-wait) ---
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: "(?s).*biar nggak ketinggalan.*"
|
||||
commands:
|
||||
- tapOn:
|
||||
text: "(?s).*nanti aja.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*lagi nyari bestie.*"
|
||||
timeout: 20000
|
||||
|
||||
# --- Step 6: any online mitra accepts the blast ---
|
||||
# We use the seeded mitra's id as a known-online acceptor. (If they're no
|
||||
# longer online, the assertion below will time out — re-run after
|
||||
# refreshing their heartbeat.)
|
||||
- runScript:
|
||||
file: ../scripts/mitra_accept_latest_internal.js
|
||||
env:
|
||||
MITRA_ID: ${output.MITRA_ID}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Step 7: chat screen renders ---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*online.*"
|
||||
timeout: 20000
|
||||
- assertVisible: "(?s).*${output.MITRA_NAME_RE}.*"
|
||||
@@ -0,0 +1,143 @@
|
||||
# TS-05 — Payment expired → retry preserves targeting
|
||||
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-05).
|
||||
#
|
||||
# §4 branch covered: PickMethod → PickDuration → PayMethod → WaitPay →
|
||||
# PayStat(timeout 20 min) → PayExpired → Pay(retry) → paid →
|
||||
# PairRoute(lama) → Targeted → S10.
|
||||
#
|
||||
# What this proves: the `targetedMitraId` on the payment draft survives
|
||||
# the expired-retry round trip (Stage 5.1 `resetExceptTarget` invariant).
|
||||
# After the retry pays, the customer lands on /chat/waiting-targeted/<SAME
|
||||
# mitraId> — NOT a fresh blast.
|
||||
#
|
||||
# Pre-reqs (HARD):
|
||||
# - Backend reachable; NODE_ENV != 'production'.
|
||||
# - >= 1 mitra online — they're the targeted mitra throughout. The flow
|
||||
# does NOT drive accept (we stop at the targeted-waiting screen — the
|
||||
# assertion that targeting survived the retry is the goal).
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml
|
||||
appId: com.halobestie.client.client_app
|
||||
env:
|
||||
TEST_PHONE: "+6281234567890"
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
---
|
||||
# --- Cold-start reset + onboarding prelude ---
|
||||
- runScript:
|
||||
file: ../scripts/reset_phone.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- runFlow: ../subflows/onboarding_returning_user.yaml
|
||||
|
||||
# --- Seed history with online M1 (targeted throughout). ---
|
||||
- runScript:
|
||||
file: ../scripts/seed_history_session.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
- swipe:
|
||||
start: "50%, 30%"
|
||||
end: "50%, 80%"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*curhatan sebelumnya.*"
|
||||
timeout: 10000
|
||||
|
||||
# --- Walk TS-01 steps 1-6 to reach /payment/waiting for the targeted
|
||||
# attempt against M1. ---
|
||||
- tapOn:
|
||||
text: "(?s).*curhat sama bestie baru.*"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*mau curhat sama siapa.*"
|
||||
timeout: 5000
|
||||
- tapOn: "(?s).*bestie yang udah kenal.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*bestie kamu sebelumnya.*"
|
||||
timeout: 5000
|
||||
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
|
||||
|
||||
# /payment/entry → /payment/method-pick (returning, no discount).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih cara curhat.*"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "(?s).*tulis dan baca dengan tenang.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih durasi.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s).*5 menit.*"
|
||||
- tapOn: "(?s).*bayar Rp.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*cara bayar.*"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "(?s).*bayar Rp.*"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "scan QRIS untuk bayar"
|
||||
timeout: 10000
|
||||
|
||||
# --- Force-expire the latest pending payment ---
|
||||
- runScript:
|
||||
file: ../scripts/force_expire_latest_payment.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Poller picks `expired` within ~3s → /payment/expired/:paymentId ---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pembayaran kedaluwarsa.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*coba lagi.*"
|
||||
|
||||
# --- Tap retry → /payment/method (NOT /payment/method-pick — the draft
|
||||
# was preserved via resetExceptTarget, so we skip mode + duration). ---
|
||||
- tapOn: "(?s).*coba lagi.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*cara bayar.*"
|
||||
timeout: 10000
|
||||
# Sanity check: we did NOT bounce back to the mode picker.
|
||||
- assertNotVisible: "(?s).*pilih cara curhat.*"
|
||||
|
||||
# --- Re-pay ---
|
||||
- tapOn:
|
||||
text: "(?s).*bayar Rp.*"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "scan QRIS untuk bayar"
|
||||
timeout: 10000
|
||||
- runScript:
|
||||
file: ../scripts/mark_latest_payment_paid.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Notif-gate → targeted-waiting screen for the SAME mitra (M1).
|
||||
# This is the load-bearing assertion: if targetedMitraId had been wiped
|
||||
# by the expired-retry round trip, the customer would land on
|
||||
# /chat/searching (blast) and we'd see "lagi nyari bestie" instead. ---
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: "(?s).*biar nggak ketinggalan.*"
|
||||
commands:
|
||||
- tapOn:
|
||||
text: "(?s).*nanti aja.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*MENUNGGU JAWABAN.*"
|
||||
timeout: 20000
|
||||
- assertVisible: "(?s).*lagi nungguin ${output.MITRA_NAME_RE}.*"
|
||||
@@ -0,0 +1,187 @@
|
||||
# TS-06 — Targeted request fails post-payment → fallback to blast
|
||||
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-06).
|
||||
#
|
||||
# §4 branch covered: Targeted → TargetedRes(reject/timeout) →
|
||||
# OfflinePopup(post-pay, returning variant) → "cari bestie lain" →
|
||||
# fallback-to-blast → §3 BlastFlow → S10.
|
||||
#
|
||||
# What this proves: after paying for a targeted attempt, if the picked
|
||||
# mitra rejects or times out, the customer can fall back to general blast
|
||||
# WITHOUT a second payment (same payment_sessions row reused).
|
||||
#
|
||||
# Pre-reqs (HARD):
|
||||
# - Backend reachable; NODE_ENV != 'production'.
|
||||
# - >= 2 mitras online in dev DB:
|
||||
# 1. M1 — picked from history, becomes the targeted mitra; we
|
||||
# simulate them rejecting via force_pairing_timeout.js.
|
||||
# 2. M2 — the blast-fallback acceptor.
|
||||
# If only one mitra is online, the fallback-to-blast cannot match.
|
||||
#
|
||||
# Known backend gap (TODO):
|
||||
# The current force_pairing_timeout.js endpoint calls
|
||||
# `expirePairingRequest()` which broadcasts WS PAIRING_FAILED
|
||||
# (is_terminal=false). On the TargetedWaitingScreen, this maps the
|
||||
# pairing state to PairingFailedData, NOT PairingTargetedUnavailableData
|
||||
# — so the `BestieOfflinePopup` (returning variant) won't fire from the
|
||||
# targeted-waiting screen as written.
|
||||
#
|
||||
# To make this flow pass end-to-end the backend test endpoint needs to
|
||||
# detect when the latest pending_acceptance session is a TARGETED pair
|
||||
# (chat_sessions.targeted_mitra_id is set / linked to a confirmed
|
||||
# payment_session.targeted_mitra_id) and route to
|
||||
# `expireTargetedPairingRequest` instead, which broadcasts
|
||||
# RETURNING_CHAT_TIMEOUT → PairingTargetedUnavailableData → popup fires.
|
||||
#
|
||||
# Until that fix lands, this flow will fail at step "OfflinePopup
|
||||
# visible". Leave it in place: it correctly expresses the intended
|
||||
# product behavior and serves as a regression test for the backend fix.
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml
|
||||
appId: com.halobestie.client.client_app
|
||||
env:
|
||||
TEST_PHONE: "+6281234567890"
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
# Second online mitra that will accept the blast fallback. See task spec
|
||||
# "Open question" — TS-06 needs >= 2 online mitras and no "any online
|
||||
# acceptor" helper exists yet.
|
||||
TEST_MITRA_ID_ACCEPTOR: "${TEST_MITRA_ID_ACCEPTOR}"
|
||||
---
|
||||
# --- Cold-start reset + onboarding prelude ---
|
||||
- runScript:
|
||||
file: ../scripts/reset_phone.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- runFlow: ../subflows/onboarding_returning_user.yaml
|
||||
|
||||
# --- Reset every mitra online first (test idempotency). ---
|
||||
- runScript:
|
||||
file: ../scripts/reset_all_mitras_online.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Seed history with M1 (online — they'll be the targeted mitra). Then
|
||||
# ensure a different M2 is online so the post-rejection blast has an
|
||||
# acceptor. ---
|
||||
- runScript:
|
||||
file: ../scripts/seed_history_session.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- runScript:
|
||||
file: ../scripts/force_other_mitra_online.js
|
||||
env:
|
||||
MITRA_ID: ${output.MITRA_ID}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
- swipe:
|
||||
start: "50%, 30%"
|
||||
end: "50%, 80%"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*curhatan sebelumnya.*"
|
||||
timeout: 10000
|
||||
|
||||
# --- Walk TS-01 steps 1-8 to reach /chat/waiting-targeted/<mitraId> ---
|
||||
- tapOn:
|
||||
text: "(?s).*curhat sama bestie baru.*"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*mau curhat sama siapa.*"
|
||||
timeout: 5000
|
||||
- tapOn: "(?s).*bestie yang udah kenal.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*bestie kamu sebelumnya.*"
|
||||
timeout: 5000
|
||||
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih cara curhat.*"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "(?s).*tulis dan baca dengan tenang.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*pilih durasi.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s).*5 menit.*"
|
||||
- tapOn: "(?s).*bayar Rp.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*cara bayar.*"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "(?s).*bayar Rp.*"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "scan QRIS untuk bayar"
|
||||
timeout: 10000
|
||||
- runScript:
|
||||
file: ../scripts/mark_latest_payment_paid.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: "(?s).*biar nggak ketinggalan.*"
|
||||
commands:
|
||||
- tapOn:
|
||||
text: "(?s).*nanti aja.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*MENUNGGU JAWABAN.*"
|
||||
timeout: 20000
|
||||
|
||||
# --- Force the targeted pending_acceptance session to expire ---
|
||||
# TODO(backend): force-pairing-timeout currently calls
|
||||
# expirePairingRequest (broadcasts PAIRING_FAILED), not
|
||||
# expireTargetedPairingRequest (which would broadcast
|
||||
# RETURNING_CHAT_TIMEOUT). The popup assertion below depends on the
|
||||
# RETURNING_CHAT_TIMEOUT path — see header note.
|
||||
- runScript:
|
||||
file: ../scripts/force_pairing_timeout.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- BestieOfflinePopup (returning variant, post-pay) appears ---
|
||||
# Title from bestie_unavailable_dialog.dart for the `returning` variant:
|
||||
# "<mitraName> lagi nggak online". Primary CTA when canFallbackToBlast is
|
||||
# true (M2 is reachable + paymentSessionId is set): "chat dengan bestie
|
||||
# lain". This differs from the prePayReturning variant's "cari bestie
|
||||
# lain" CTA — verified in bestie_unavailable_dialog.dart L140-152.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*${output.MITRA_NAME_RE}.*lagi nggak online.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*chat dengan bestie lain.*"
|
||||
|
||||
# --- Tap "chat dengan bestie lain" → fallbackToBlast() ---
|
||||
# In current code, fallback-to-blast creates a fresh pending request that
|
||||
# may render as either /chat/searching ("lagi nyari bestie") for a
|
||||
# multi-mitra blast OR /chat/waiting-targeted (MENUNGGU JAWABAN) when the
|
||||
# backend reuses the existing payment session to target the next available
|
||||
# mitra. Either pending state is acceptable here — the critical assertion
|
||||
# is that the customer wasn't charged again (same payment_sessions row).
|
||||
- tapOn: "(?s).*chat dengan bestie lain.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*(lagi nyari bestie|MENUNGGU JAWABAN).*"
|
||||
timeout: 15000
|
||||
|
||||
# --- M2 accepts the blast (any other online mitra) ---
|
||||
- runScript:
|
||||
file: ../scripts/accept_latest_pending.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# --- Chat screen renders (with M2, not M1) ---
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*online.*"
|
||||
timeout: 20000
|
||||
17
client_app/.maestro/scripts/accept_latest_pending.js
Normal file
17
client_app/.maestro/scripts/accept_latest_pending.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// Accept the latest pending pairing notification regardless of mitra. Used
|
||||
// by flows where the acceptor mitra UUID isn't known in advance — e.g.
|
||||
// TS-02 (blast where the seeded mitra was forced offline, so an unknown
|
||||
// OTHER online mitra got the chat_request_notification row).
|
||||
//
|
||||
// Backed by /internal/_test/accept-latest-pending (no body needed).
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
const resp = http.post(`${url}/internal/_test/accept-latest-pending`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`accept-latest-pending failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
const data = json(resp.body)
|
||||
output.ACCEPTED_SESSION_ID = data.session_id
|
||||
output.ACCEPTOR_MITRA_ID = data.mitra_id
|
||||
20
client_app/.maestro/scripts/force_mitra_offline.js
Normal file
20
client_app/.maestro/scripts/force_mitra_offline.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// Force a specific mitra OFFLINE via the dev-only
|
||||
// /internal/_test/force-mitra-offline endpoint. Used by Maestro flows that
|
||||
// need the bestie-history-list row for a particular mitra to render in its
|
||||
// offline (dimmed) state — see TS-02 / TS-03 in
|
||||
// requirement/phase4-customer-flow.md.
|
||||
//
|
||||
// Reads MITRA_ID from env (typically `${output.MITRA_ID}` from a prior
|
||||
// seed_history_session.js run) and BACKEND_INTERNAL_URL.
|
||||
const mitraId = MITRA_ID
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
if (!mitraId) {
|
||||
throw new Error('MITRA_ID env not set — pass output.MITRA_ID from seed_history_session.js')
|
||||
}
|
||||
const resp = http.post(`${url}/internal/_test/force-mitra-offline`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mitra_id: mitraId }),
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`force-mitra-offline failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
17
client_app/.maestro/scripts/force_other_mitra_online.js
Normal file
17
client_app/.maestro/scripts/force_other_mitra_online.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// Force a DIFFERENT mitra online (one other than the seeded one) so blast
|
||||
// flows (TS-02, TS-06) have an acceptor available after the seeded mitra is
|
||||
// forced offline. Picks any currently-offline mitra excluding MITRA_ID.
|
||||
//
|
||||
// Reads MITRA_ID (the seeded mitra to exclude, from seed_history_session.js)
|
||||
// and BACKEND_INTERNAL_URL.
|
||||
const excludeId = MITRA_ID
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
const resp = http.post(`${url}/internal/_test/force-mitra-online`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ exclude_mitra_id: excludeId }),
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`force-mitra-online failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
const data = json(resp.body)
|
||||
output.OTHER_MITRA_ID = data.mitra_id
|
||||
19
client_app/.maestro/scripts/mitra_accept_latest_internal.js
Normal file
19
client_app/.maestro/scripts/mitra_accept_latest_internal.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Have the test mitra "accept" the most recent pending pairing request via
|
||||
// the dev-only /internal/_test/mitra-accept-latest endpoint (no JWT needed).
|
||||
//
|
||||
// Reads MITRA_ID from the env that the calling flow injects — typically
|
||||
// `${output.MITRA_ID}` from a prior seed_history_session.js run.
|
||||
const mitraId = MITRA_ID
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
if (!mitraId) {
|
||||
throw new Error('MITRA_ID env not set — pass output.MITRA_ID from seed_history_session.js')
|
||||
}
|
||||
const resp = http.post(`${url}/internal/_test/mitra-accept-latest`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mitra_id: mitraId }),
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`mitra-accept-latest failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
const data = json(resp.body)
|
||||
output.ACCEPTED_SESSION_ID = data.session_id
|
||||
16
client_app/.maestro/scripts/reset_all_mitras_online.js
Normal file
16
client_app/.maestro/scripts/reset_all_mitras_online.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Bulk-mark every mitra row online in mitra_online_status. Used as a setup
|
||||
// step at the start of each Maestro flow so seed_history_session has at
|
||||
// least one online mitra to pick, regardless of what previous tests did
|
||||
// (e.g. force-mitra-offline lingering from a prior TS-02/TS-03 run).
|
||||
//
|
||||
// Backed by /internal/_test/reset-all-mitras-online.
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
const resp = http.post(`${url}/internal/_test/reset-all-mitras-online`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`reset-all-mitras-online failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
const data = json(resp.body)
|
||||
output.ONLINE_COUNT = data.online_count
|
||||
@@ -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, '\\$&')
|
||||
|
||||
100
client_app/.maestro/subflows/onboarding_returning_user.yaml
Normal file
100
client_app/.maestro/subflows/onboarding_returning_user.yaml
Normal file
@@ -0,0 +1,100 @@
|
||||
# Shared onboarding prelude for Phase 4 §4 "returning user" Maestro flows
|
||||
# (TS-01 through TS-06 — see requirement/phase4-customer-flow.md).
|
||||
#
|
||||
# This subflow drives a clean-slate emulator from cold start to /home as a
|
||||
# verified customer. The verified+display-name'd state is the precondition
|
||||
# for every TS scenario in §4, so we extract it here to avoid ~80 lines of
|
||||
# duplication across the six flows.
|
||||
#
|
||||
# Pre-reqs (parent flow's responsibility):
|
||||
# - Parent flow has `env:` block defining TEST_PHONE and
|
||||
# BACKEND_INTERNAL_URL (Maestro subflows inherit env from caller).
|
||||
# - Parent flow runs `reset_phone.js` + `launchApp clearState: true`
|
||||
# BEFORE invoking this subflow.
|
||||
# - NODE_ENV != 'production' on backend (so /internal/_test routes exist).
|
||||
#
|
||||
# Path taken:
|
||||
# Welcome carousel ("Mulai") → Home with anon banner →
|
||||
# tap "masuk →" → /auth/register → enter +62 subscriber digits →
|
||||
# "kirim kode" → OTP screen → peek OTP from stub → auto-submit →
|
||||
# /auth/set-name (because AuthNeedsDisplayNameData) → enter "Maestro" →
|
||||
# "Lanjut" → /home (returning view: "curhatan sebelumnya" header).
|
||||
#
|
||||
# Selector style: Flutter merges sibling Text widgets inside a single
|
||||
# tappable parent into ONE accessibility blob. Maestro's `text:` selector
|
||||
# does a FULL-string regex match, so we wrap selectors in `(?s).*…*` for
|
||||
# anything that lives inside an InkWell with multiple Texts. Empty
|
||||
# TextFields don't expose their hint to Maestro a11y, so we tap by point
|
||||
# inside the field's pill before typing.
|
||||
appId: ${APP_ID_ANDROID}
|
||||
---
|
||||
# Welcome carousel — the "Mulai" button is the only Text inside its
|
||||
# tappable region, so a plain selector works.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Mulai"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Mulai"
|
||||
retryTapIfNoChange: true
|
||||
|
||||
# Home — login-recover banner ("udah pernah pakai HaloBestie? … masuk →")
|
||||
# is one InkWell, so any token within its merged blob reaches the
|
||||
# /auth/register handler.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*udah pernah pakai HaloBestie.*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "(?s).*masuk →.*"
|
||||
|
||||
# Register screen — personalised title "nomor wa-mu, {name}?" is in a
|
||||
# merged blob with the subtitle copy.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*nomor wa-mu.*"
|
||||
timeout: 10000
|
||||
|
||||
# Phone input — +62 is a static prefix chip; type only subscriber digits
|
||||
# (no leading 0). +6281234567890 → 81234567890. Tap by point inside the
|
||||
# pill (well right of the +62 chip, well above the kirim-kode button).
|
||||
- tapOn:
|
||||
point: "60%, 47%"
|
||||
- inputText: "81234567890"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "(?s).*kirim kode.*"
|
||||
|
||||
# OTP screen — peek the code from the stub endpoint.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Masukkan OTP"
|
||||
timeout: 15000
|
||||
- runScript:
|
||||
file: ../scripts/peek_otp.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- inputText: ${output.OTP}
|
||||
|
||||
# After 6th digit OTP auto-submits. The anon customer's phone is attached
|
||||
# but display_name is still empty → AuthNeedsDisplayNameData → router
|
||||
# pushes /auth/set-name ("Siapa namamu?").
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*Siapa namamu.*"
|
||||
timeout: 20000
|
||||
# Same TextField-hint-invisible-to-Maestro issue as the phone field — tap
|
||||
# by point inside the "Nama panggilan" pill, then type a display name.
|
||||
- tapOn:
|
||||
point: "50%, 30%"
|
||||
- inputText: "Maestro"
|
||||
- hideKeyboard
|
||||
- tapOn: "Lanjut"
|
||||
|
||||
# Now home renders the returning view ("curhatan sebelumnya" section
|
||||
# header is the deterministic landmark — appears regardless of history).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "(?s).*curhatan sebelumnya.*"
|
||||
timeout: 30000
|
||||
@@ -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<SearchingScreen> {
|
||||
ref.listenManual<PairingData>(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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<HomeScreen> 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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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: <String, dynamic>{
|
||||
'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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<void> 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');
|
||||
},
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/api/api_client_provider.dart';
|
||||
import '../../core/constants.dart';
|
||||
|
||||
part 'payment_notifier.g.dart';
|
||||
|
||||
/// Payment-session lifecycle, customer side. The screen owns one of these per
|
||||
/// (mitra-target, duration) attempt; the notifier wraps the REST calls to
|
||||
/// `/api/client/payment-sessions`.
|
||||
sealed class PaymentSessionData {
|
||||
const PaymentSessionData();
|
||||
}
|
||||
|
||||
class PaymentInitialData extends PaymentSessionData {
|
||||
const PaymentInitialData();
|
||||
}
|
||||
|
||||
class PaymentCreatingData extends PaymentSessionData {
|
||||
const PaymentCreatingData();
|
||||
}
|
||||
|
||||
/// Created server-side, sitting in `pending` until the customer taps "Bayar".
|
||||
class PaymentPendingData extends PaymentSessionData {
|
||||
final String paymentSessionId;
|
||||
final int amount;
|
||||
final int durationMinutes;
|
||||
final bool isFreeTrial;
|
||||
final bool isExtension;
|
||||
final String? targetedMitraId;
|
||||
|
||||
const PaymentPendingData({
|
||||
required this.paymentSessionId,
|
||||
required this.amount,
|
||||
required this.durationMinutes,
|
||||
required this.isFreeTrial,
|
||||
required this.isExtension,
|
||||
this.targetedMitraId,
|
||||
});
|
||||
}
|
||||
|
||||
class PaymentConfirmingData extends PaymentSessionData {
|
||||
final String paymentSessionId;
|
||||
const PaymentConfirmingData(this.paymentSessionId);
|
||||
}
|
||||
|
||||
/// Confirmed; the customer can now be routed to the searching screen with
|
||||
/// this `paymentSessionId` (and optional `targetedMitraId` for "Curhat lagi").
|
||||
class PaymentConfirmedData extends PaymentSessionData {
|
||||
final String paymentSessionId;
|
||||
final int durationMinutes;
|
||||
final bool isFreeTrial;
|
||||
final bool isExtension;
|
||||
final String? targetedMitraId;
|
||||
|
||||
const PaymentConfirmedData({
|
||||
required this.paymentSessionId,
|
||||
required this.durationMinutes,
|
||||
required this.isFreeTrial,
|
||||
required this.isExtension,
|
||||
this.targetedMitraId,
|
||||
});
|
||||
}
|
||||
|
||||
class PaymentErrorData extends PaymentSessionData {
|
||||
final String message;
|
||||
const PaymentErrorData(this.message);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class Payment extends _$Payment {
|
||||
ApiClient get _api => ref.read(apiClientProvider);
|
||||
|
||||
@override
|
||||
PaymentSessionData build() => const PaymentInitialData();
|
||||
|
||||
/// Create a `pending` payment session for the chosen [durationMinutes].
|
||||
/// Pass [targetedMitraId] for the "Curhat lagi" path; pass [isExtension]
|
||||
/// for an extension-cost payment (never combined with free trial).
|
||||
Future<void> createSession({
|
||||
required int durationMinutes,
|
||||
String? targetedMitraId,
|
||||
bool isExtension = false,
|
||||
}) async {
|
||||
state = const PaymentCreatingData();
|
||||
try {
|
||||
final body = <String, dynamic>{
|
||||
'duration_minutes': durationMinutes,
|
||||
if (targetedMitraId != null) 'targeted_mitra_id': targetedMitraId,
|
||||
if (isExtension) 'is_extension': true,
|
||||
};
|
||||
// Trailing slash matters: the backend route is `app.post('/', ...)` mounted
|
||||
// at prefix `/api/client/payment-sessions`, and Fastify is not configured
|
||||
// with `ignoreTrailingSlash: true`, so the canonical URL has the slash.
|
||||
final response = await _api.post('/api/client/payment-sessions/', data: body);
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
state = PaymentPendingData(
|
||||
paymentSessionId: data['id'] as String,
|
||||
amount: data['amount'] as int? ?? 0,
|
||||
durationMinutes: data['duration_minutes'] as int? ?? durationMinutes,
|
||||
isFreeTrial: data['is_free_trial'] as bool? ?? false,
|
||||
isExtension: data['is_extension'] as bool? ?? isExtension,
|
||||
targetedMitraId: data['targeted_mitra_id'] as String?,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = PaymentErrorData(_humanError(e, fallback: 'Gagal membuat sesi pembayaran.'));
|
||||
} catch (_) {
|
||||
state = const PaymentErrorData('Gagal membuat sesi pembayaran.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm the pending payment. Backend rejects truly empty bodies on
|
||||
/// `POST .../confirm`, so we always send `{}`.
|
||||
Future<void> confirm() async {
|
||||
final current = state;
|
||||
if (current is! PaymentPendingData) return;
|
||||
state = PaymentConfirmingData(current.paymentSessionId);
|
||||
try {
|
||||
await _api.post(
|
||||
'/api/client/payment-sessions/${current.paymentSessionId}/confirm',
|
||||
data: const <String, dynamic>{},
|
||||
);
|
||||
state = PaymentConfirmedData(
|
||||
paymentSessionId: current.paymentSessionId,
|
||||
durationMinutes: current.durationMinutes,
|
||||
isFreeTrial: current.isFreeTrial,
|
||||
isExtension: current.isExtension,
|
||||
targetedMitraId: current.targetedMitraId,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = PaymentErrorData(_humanError(e, fallback: 'Gagal mengkonfirmasi pembayaran.'));
|
||||
} catch (_) {
|
||||
state = const PaymentErrorData('Gagal mengkonfirmasi pembayaran.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort cancel of a still-pending session. Safe to call on dispose
|
||||
/// even if the state isn't `pending` — we just no-op in that case.
|
||||
Future<void> cancelIfPending() async {
|
||||
final current = state;
|
||||
if (current is! PaymentPendingData) return;
|
||||
final id = current.paymentSessionId;
|
||||
try {
|
||||
await _api.post(
|
||||
'/api/client/payment-sessions/$id/cancel',
|
||||
data: const <String, dynamic>{},
|
||||
);
|
||||
} catch (_) {
|
||||
// Best-effort — backend sweeper will expire stale `pending` rows
|
||||
// after `payment_session_timeout_minutes` regardless.
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to initial — used when the screen is re-entered for a new attempt.
|
||||
void reset() {
|
||||
state = const PaymentInitialData();
|
||||
}
|
||||
|
||||
String _humanError(DioException e, {required String fallback}) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
final status = e.response?.statusCode;
|
||||
if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') {
|
||||
return 'Pilihan durasi tidak valid.';
|
||||
}
|
||||
if (status == 403) return 'Sesi tidak diizinkan.';
|
||||
if (status == 404) return 'Sesi pembayaran tidak ditemukan.';
|
||||
if (code == 'EXPIRED') return 'Sesi pembayaran sudah kedaluwarsa.';
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of backend `PaymentSessionStatus` for any UI that needs to inspect
|
||||
/// the raw status field (kept tiny for now — most flows route via state above).
|
||||
class PaymentStatus {
|
||||
static const pending = PaymentSessionStatus.pending;
|
||||
static const confirmed = PaymentSessionStatus.confirmed;
|
||||
static const consumed = PaymentSessionStatus.consumed;
|
||||
PaymentStatus._();
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'payment_notifier.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$paymentHash() => r'd98f2e7e5045ea2a39b7af0d4a9f0601dd06ce74';
|
||||
|
||||
/// See also [Payment].
|
||||
@ProviderFor(Payment)
|
||||
final paymentProvider =
|
||||
AutoDisposeNotifierProvider<Payment, PaymentSessionData>.internal(
|
||||
Payment.new,
|
||||
name: r'paymentProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$paymentHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$Payment = AutoDisposeNotifier<PaymentSessionData>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -26,7 +26,11 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
||||
'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`.
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../payment_notifier.dart';
|
||||
|
||||
/// Payment screen.
|
||||
///
|
||||
/// Reuses the mock pricing service (tiers + free trial). The customer picks a
|
||||
/// duration (or auto-selects the free trial); on tap the screen creates a
|
||||
/// `pending` payment session, then on "Bayar" / "Mulai" confirms it and routes
|
||||
/// to the searching screen carrying `paymentSessionId` (and `targetedMitraId`
|
||||
/// if this is a "Curhat lagi" flow).
|
||||
///
|
||||
/// Reachable from:
|
||||
/// - Home "Mulai Curhat" CTA → no targeted mitra, normal blast follows.
|
||||
/// - Chat history "Curhat lagi" CTA → targetedMitraId set, returning-chat
|
||||
/// flow follows.
|
||||
class PaymentScreen extends ConsumerStatefulWidget {
|
||||
/// "Curhat lagi" only — when set, the eventual chat-request goes through
|
||||
/// the returning-chat endpoint targeting this mitra.
|
||||
final String? targetedMitraId;
|
||||
|
||||
/// Optional display name for the targeted mitra, surfaced in the screen
|
||||
/// header so the customer knows who they're paying to chat with again.
|
||||
final String? mitraName;
|
||||
|
||||
/// The topic-sensitivity choice the customer made in the topic-selection
|
||||
/// bottom sheet on the home screen. Carried through here to be passed into
|
||||
/// the chat-request API after confirm. Defaults to regular.
|
||||
final TopicSensitivity topicSensitivity;
|
||||
|
||||
const PaymentScreen({
|
||||
super.key,
|
||||
this.targetedMitraId,
|
||||
this.mitraName,
|
||||
this.topicSensitivity = TopicSensitivity.regular,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<PaymentScreen> createState() => _PaymentScreenState();
|
||||
}
|
||||
|
||||
class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
||||
/// Local UI selection (not in the notifier) — the duration the customer is
|
||||
/// previewing before they tap to lock it in via createSession.
|
||||
int? _selectedDurationMinutes;
|
||||
|
||||
/// True once we've kicked off `createSession()` for the current selection;
|
||||
/// used to suppress double-taps while the round-trip is in flight.
|
||||
bool _creatingSession = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Make sure no stale state leaks in from a previous payment attempt.
|
||||
Future.microtask(() => ref.read(paymentProvider.notifier).reset());
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
// Best-effort cancel on back/leave if we still have a `pending` row.
|
||||
// The notifier checks state before calling the API, so this is safe to
|
||||
// call unconditionally. Lives in deactivate(), not dispose(), because
|
||||
// modern Riverpod invalidates `ref` once dispose() starts — the resulting
|
||||
// `Bad state: Cannot use "ref" after the widget was disposed.` corrupts
|
||||
// the widget-tree finalize and leaves the next screen frozen.
|
||||
// ignore: discarded_futures
|
||||
ref.read(paymentProvider.notifier).cancelIfPending();
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
|
||||
Future<void> _onTierTapped({
|
||||
required int durationMinutes,
|
||||
required int price,
|
||||
}) async {
|
||||
if (_creatingSession) return;
|
||||
// `price` is informational (already shown in the tier card) — the source
|
||||
// of truth for the amount comes back from the backend.
|
||||
setState(() {
|
||||
_selectedDurationMinutes = durationMinutes;
|
||||
_creatingSession = true;
|
||||
});
|
||||
await ref.read(paymentProvider.notifier).createSession(
|
||||
durationMinutes: durationMinutes,
|
||||
targetedMitraId: widget.targetedMitraId,
|
||||
);
|
||||
if (mounted) setState(() => _creatingSession = false);
|
||||
}
|
||||
|
||||
Future<void> _onConfirmTapped() async {
|
||||
final notifier = ref.read(paymentProvider.notifier);
|
||||
await notifier.confirm();
|
||||
}
|
||||
|
||||
Future<void> _routeToSearchOnConfirmed(PaymentConfirmedData payment) async {
|
||||
// Kick off the right pairing flow against the freshly-confirmed payment.
|
||||
final pairing = ref.read(pairingProvider.notifier);
|
||||
if (payment.targetedMitraId != null) {
|
||||
await pairing.startTargetedSearch(
|
||||
paymentSessionId: payment.paymentSessionId,
|
||||
mitraId: payment.targetedMitraId!,
|
||||
mitraName: widget.mitraName ?? 'Bestie',
|
||||
topicSensitivity: widget.topicSensitivity,
|
||||
);
|
||||
} else {
|
||||
await pairing.startSearch(
|
||||
paymentSessionId: payment.paymentSessionId,
|
||||
topicSensitivity: widget.topicSensitivity,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
// Reset our local notifier so a future payment attempt starts clean.
|
||||
ref.read(paymentProvider.notifier).reset();
|
||||
// Phase 4 Stage 5: targeted "Curhat lagi" lands on the dedicated
|
||||
// SWaitingBestie overlay screen; general blast still uses the searching
|
||||
// shell (which renders inline soft-prompt + timeout panels).
|
||||
if (payment.targetedMitraId != null) {
|
||||
context.go('/chat/waiting-targeted/${payment.targetedMitraId}');
|
||||
} else {
|
||||
context.go('/chat/searching');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// One-shot side-effect listener: when the payment lands in `confirmed`,
|
||||
// route to the searching screen.
|
||||
ref.listen<PaymentSessionData>(paymentProvider, (prev, next) {
|
||||
if (next is PaymentConfirmedData) {
|
||||
// ignore: discarded_futures
|
||||
_routeToSearchOnConfirmed(next);
|
||||
}
|
||||
});
|
||||
|
||||
final paymentState = ref.watch(paymentProvider);
|
||||
final pricingAsync = ref.watch(chatPricingProvider);
|
||||
final isReturning = widget.targetedMitraId != null;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(isReturning ? 'Chat lagi dengan ${widget.mitraName ?? 'Bestie'}' : 'Pilih Sesi & Bayar'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: () {
|
||||
// PopScope above lets canPop fire dispose() which cancels the
|
||||
// pending session. If there's no back-stack, fall back to home.
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
body: pricingAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('Gagal memuat harga. Coba lagi.', textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
data: (pricing) => _buildBody(pricing, paymentState),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(PricingData pricing, PaymentSessionData paymentState) {
|
||||
// Inline error widget per project memory ("Avoid SnackBars for provider errors").
|
||||
final errorBanner = paymentState is PaymentErrorData
|
||||
? Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
paymentState.message,
|
||||
style: TextStyle(color: Colors.red.shade900),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
errorBanner,
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
const Text(
|
||||
'Pilih Durasi Curhat',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (pricing.freeTrialEligible) ...[
|
||||
_FreeTrialCard(
|
||||
durationMinutes: pricing.freeTrialDurationMinutes,
|
||||
selected: paymentState is PaymentPendingData && paymentState.isFreeTrial,
|
||||
onTap: () => _onTierTapped(
|
||||
// For free trial: backend still wants a duration_minutes —
|
||||
// pass the trial duration. The backend overrides amount→0
|
||||
// when the customer is eligible.
|
||||
durationMinutes: pricing.freeTrialDurationMinutes,
|
||||
price: 0,
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
],
|
||||
...pricing.tiers.map((tier) {
|
||||
final selected = _selectedDurationMinutes == tier.durationMinutes &&
|
||||
paymentState is PaymentPendingData &&
|
||||
!paymentState.isFreeTrial;
|
||||
return _TierCard(
|
||||
label: tier.label,
|
||||
priceLabel: formatRupiah(tier.price),
|
||||
selected: selected,
|
||||
onTap: () => _onTierTapped(
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (paymentState is PaymentPendingData ||
|
||||
paymentState is PaymentConfirmingData ||
|
||||
paymentState is PaymentCreatingData)
|
||||
_ConfirmBar(
|
||||
paymentState: paymentState,
|
||||
onConfirm: _onConfirmTapped,
|
||||
formatPrice: formatRupiah,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FreeTrialCard extends StatelessWidget {
|
||||
final int durationMinutes;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FreeTrialCard({
|
||||
required this.durationMinutes,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: selected ? Colors.green.shade100 : Colors.green.shade50,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: selected
|
||||
? BorderSide(color: Colors.green.shade700, width: 1.5)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.card_giftcard, color: Colors.green),
|
||||
title: Text('Free Trial ($durationMinutes Menit)'),
|
||||
subtitle: const Text('Gratis untuk pertama kali!'),
|
||||
trailing: Text(
|
||||
'Gratis',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green.shade800),
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TierCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String priceLabel;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TierCard({
|
||||
required this.label,
|
||||
required this.priceLabel,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: selected
|
||||
? const BorderSide(color: Colors.pink, width: 1.5)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(label),
|
||||
trailing: Text(
|
||||
priceLabel,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfirmBar extends StatelessWidget {
|
||||
final PaymentSessionData paymentState;
|
||||
final Future<void> Function() onConfirm;
|
||||
final String Function(int) formatPrice;
|
||||
|
||||
const _ConfirmBar({
|
||||
required this.paymentState,
|
||||
required this.onConfirm,
|
||||
required this.formatPrice,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCreating = paymentState is PaymentCreatingData;
|
||||
final isConfirming = paymentState is PaymentConfirmingData;
|
||||
final pending = paymentState is PaymentPendingData ? paymentState as PaymentPendingData : null;
|
||||
|
||||
final totalLabel = pending == null
|
||||
? '...'
|
||||
: pending.isFreeTrial
|
||||
? 'Gratis'
|
||||
: formatPrice(pending.amount);
|
||||
final ctaLabel = pending != null && pending.isFreeTrial ? 'Mulai' : 'Bayar';
|
||||
final disabled = isCreating || isConfirming || pending == null;
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Total', style: TextStyle(fontSize: 16)),
|
||||
Text(
|
||||
totalLabel,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onPressed: disabled ? null : onConfirm,
|
||||
child: isConfirming || isCreating
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Text(ctaLabel, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic>) {
|
||||
final topic = extra['topicSensitivity'];
|
||||
return PaymentScreen(
|
||||
targetedMitraId: extra['targetedMitraId'] as String?,
|
||||
mitraName: extra['mitraName'] as String?,
|
||||
topicSensitivity: topic is TopicSensitivity ? topic : TopicSensitivity.regular,
|
||||
);
|
||||
}
|
||||
return const PaymentScreen();
|
||||
}),
|
||||
// Phase 4 Stage 3 — multi-screen payment shell.
|
||||
GoRoute(path: '/payment/entry', builder: (_, __) => const PaymentEntryScreen()),
|
||||
GoRoute(path: '/payment/discount-paywall', builder: (_, __) => const DiscountPaywallScreen()),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -248,22 +248,50 @@ flowchart TD
|
||||
flowchart TD
|
||||
CTA["'curhat sama bestie baru' 🟢"] --> Choice["Bestie Choice Sheet<br/>(BestieChoiceSheet) 🟢"]
|
||||
Choice -->|"bestie yang udah kenal"| HistList["Bestie History List<br/>(BestieHistoryList) 🟢"]
|
||||
Choice -->|"bestie baru"| BlastFlow["→ S7 Soft-prompt + Blast<br/>(see diagram 3)"]
|
||||
Choice -->|"bestie baru"| PickMethod
|
||||
|
||||
HistList --> PickBestie["pick bestie"]
|
||||
PickBestie --> CheckOnline{"bestie online?"}
|
||||
CheckOnline -->|"no"| OfflinePopup["Bestie Offline Popup<br/>(returning variant) 🟢"]
|
||||
OfflinePopup -->|"cari bestie lain"| BlastFlow
|
||||
OfflinePopup -->|"cari bestie lain"| PickMethod
|
||||
OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin<br/>(WA / Telegram) 🟢"]
|
||||
CheckOnline -->|"yes"| Targeted["Request targeted pair<br/>'Menunggu bestie tertentu' 🟢<br/>(20s countdown overlay)"]
|
||||
CheckOnline -->|"yes"| PickMethod["Pilih cara curhat<br/>(chat / voice call) 🟡"]
|
||||
PickMethod --> PickDuration["Pemilihan harga<br/>(5 durations, full screen) 🟡"]
|
||||
PickDuration --> PayMethod["Cara bayar (QRIS-first) 🟡"]
|
||||
PayMethod --> Pay["Xendit checkout<br/>(QRIS / e-wallet) 🟡"]
|
||||
Pay --> WaitPay["Waiting Payment<br/>(20-min QRIS clock) 🟡"]
|
||||
WaitPay --> PayStat{"payment status"}
|
||||
PayStat -->|"timeout 20 min"| PayExpired["Pembayaran expired 🟡<br/>→ retry"]
|
||||
PayExpired --> Pay
|
||||
PayStat -->|"paid"| PairRoute{"specific bestie?<br/>(branch user came from)"}
|
||||
PairRoute -->|"yes · lama"| Targeted["Request targeted pair<br/>'Menunggu bestie tertentu' 🟢<br/>(20s countdown overlay)"]
|
||||
PairRoute -->|"no · baru / cari lain"| BlastFlow["→ S7 Soft-prompt + Blast<br/>(see diagram 3)"]
|
||||
Targeted --> TargetedRes{"mitra answers?"}
|
||||
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
|
||||
|
||||
@@ -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/<mitraId>` ("Menunggu bestie tertentu" with
|
||||
20s overlay).
|
||||
9. [ ] **[mitra_app]** Accept the incoming targeted request.
|
||||
10. [ ] **[C]** Customer lands on `/chat/session/:id` (S10 Chat Room) —
|
||||
WS open, session timer running.
|
||||
|
||||
**Expected result**
|
||||
- [ ] **[BE]** `payment_sessions` row has
|
||||
`targeted_mitra_id = <test mitra id>` and `status = 'confirmed'`.
|
||||
- [ ] **[BE]** `chat_sessions` row created with the same `mitra_id`; no
|
||||
blast log entries.
|
||||
- [ ] **[C]** Chat opens against the original mitra; no fallback to
|
||||
`/chat/searching`.
|
||||
|
||||
**Notes / known gaps**
|
||||
- Maestro flow `client_app/.maestro/flows/10_returning_repays.yaml` was
|
||||
written against the pre-Stage-5.1 screen graph and needs a rewrite —
|
||||
its selectors target the deleted legacy `/payment` screen
|
||||
(`Chat lagi dengan <mitraName>` app-bar title, `MENUNGGU JAWABAN`
|
||||
intermediate). When automating, rewrite this flow to walk the new
|
||||
multi-screen path described above.
|
||||
- Stage 5.4 (2026-05-18) deleted the legacy `/payment` route +
|
||||
`payment_screen.dart`. Any selector still expecting the legacy app-bar
|
||||
title is stale.
|
||||
|
||||
---
|
||||
|
||||
## TS-02 — Returning user picks offline bestie, "cari bestie lain" → blast
|
||||
|
||||
**Flow:** §4 `Choice → "bestie yang udah kenal" → CheckOnline (no) → OfflinePopup (pre-pay) → "cari bestie lain" → PickMethod → … → paid → PairRoute (baru) → BlastFlow → S10`
|
||||
|
||||
**Affects:** `client_app`, `backend`.
|
||||
|
||||
**Goal:** Verify the `BestieOfflineVariant.prePayReturning` popup fires
|
||||
when the picked bestie is offline pre-payment, and that "cari bestie lain"
|
||||
routes through a fresh blast-payment flow with the targeted intent cleared.
|
||||
|
||||
**Pre-reqs**
|
||||
- [ ] **[BE]** Test mitra from customer's history is **offline** (signed
|
||||
out or heartbeat expired — row shows no `ONLINE` pill in history list).
|
||||
- [ ] **[BE]** At least one OTHER mitra is online (so the blast can match).
|
||||
- [ ] **[BE]** Free-trial OFF.
|
||||
|
||||
**Steps**
|
||||
1. [ ] **[C]** From home, tap `curhat sama bestie baru` → Bestie Choice
|
||||
Sheet.
|
||||
2. [ ] **[C]** Tap `bestie yang udah kenal` → history list; the test mitra
|
||||
row is **dimmed** (offline styling preserved as of Stage 5.3).
|
||||
3. [ ] **[C]** Tap the dimmed row → `BestieOfflinePopup`
|
||||
(`prePayReturning` variant) appears showing the mitra's name. Two
|
||||
CTAs: `cari bestie lain` and `tanya admin`.
|
||||
4. [ ] **[C]** Tap `cari bestie lain` → popup closes; app navigates to
|
||||
`/payment/entry`. Payment draft has been `reset()` (no stale
|
||||
`targetedMitraId`).
|
||||
5. [ ] **[C]** Walk PickMethod → PickDuration → PayMethod → Pay →
|
||||
`/payment/waiting`.
|
||||
6. [ ] **[BE]** Manually confirm payment.
|
||||
7. [ ] **[C]** App routes to `/chat/searching` (NOT
|
||||
`/chat/waiting-targeted/...`).
|
||||
8. [ ] **[mitra_app]** A different online mitra receives the blast and
|
||||
accepts.
|
||||
9. [ ] **[C]** Customer lands on `/chat/session/:id` with the new mitra.
|
||||
|
||||
**Expected result**
|
||||
- [ ] **[BE]** `payment_sessions` row has `targeted_mitra_id IS NULL`
|
||||
(draft was reset before push to `/payment/entry`).
|
||||
- [ ] **[C]** Chat opens with the fallback mitra, not the original
|
||||
offline one.
|
||||
|
||||
---
|
||||
|
||||
## TS-03 — Returning user picks offline bestie, "tanya admin" (escape)
|
||||
|
||||
**Flow:** §4 `Choice → "bestie yang udah kenal" → CheckOnline (no) → OfflinePopup (pre-pay) → "tanya admin" → AdminSheet (terminal)`
|
||||
|
||||
**Affects:** `client_app`.
|
||||
|
||||
**Goal:** Confirm the escape hatch — the user can leave the offline-popup
|
||||
flow without paying by tapping "tanya admin", and no payment row is
|
||||
created.
|
||||
|
||||
**Pre-reqs**
|
||||
- [ ] Same as TS-02 (offline test mitra in customer's history).
|
||||
|
||||
**Steps**
|
||||
1. [ ] **[C]** Reach the `BestieOfflinePopup` (`prePayReturning` variant)
|
||||
via TS-02 steps 1-3.
|
||||
2. [ ] **[C]** Tap `tanya admin` → popup closes; admin sheet opens with
|
||||
WhatsApp / Telegram contact options.
|
||||
3. [ ] **[C]** Dismiss the admin sheet → user returns to the bestie
|
||||
history list.
|
||||
|
||||
**Expected result**
|
||||
- [ ] **[BE]** No new `payment_sessions` row created during this scenario.
|
||||
- [ ] **[C]** Payment draft state unchanged (no `targetedMitraId`, no
|
||||
`paymentId`). User can re-enter the flow normally afterward.
|
||||
|
||||
---
|
||||
|
||||
## TS-04 — Returning user picks "bestie baru" → blast happy path
|
||||
|
||||
**Flow:** §4 `Choice → "bestie baru" → PickMethod → … → paid → PairRoute (baru) → BlastFlow → S10`
|
||||
|
||||
**Affects:** `client_app`, `backend`.
|
||||
|
||||
**Goal:** Confirm the "bestie baru" branch routes through payment FIRST,
|
||||
then blasts to all online mitras (no targeting).
|
||||
|
||||
**Pre-reqs**
|
||||
- [ ] **[BE]** At least one online mitra (for blast match).
|
||||
- [ ] **[BE]** Free-trial OFF.
|
||||
- [ ] **[C]** Returning customer (has session history → Bestie Choice
|
||||
Sheet renders both options).
|
||||
|
||||
**Steps**
|
||||
1. [ ] **[C]** From home, tap `curhat sama bestie baru` → Bestie Choice
|
||||
Sheet.
|
||||
2. [ ] **[C]** Tap `bestie baru` → app navigates to `/payment/entry`.
|
||||
Draft is explicitly `reset()` on this branch (clears any stale
|
||||
`targetedMitraId` per Stage 5.1 Risk #4 mitigation).
|
||||
3. [ ] **[C]** Walk PickMethod → PickDuration → PayMethod → Pay →
|
||||
`/payment/waiting`.
|
||||
4. [ ] **[BE]** Confirm payment.
|
||||
5. [ ] **[C]** App routes to `/chat/searching` (NOT
|
||||
`/chat/waiting-targeted/...`).
|
||||
6. [ ] **[mitra_app]** An online mitra accepts the blast.
|
||||
7. [ ] **[C]** Customer lands on `/chat/session/:id`.
|
||||
|
||||
**Expected result**
|
||||
- [ ] **[BE]** `payment_sessions` row has `targeted_mitra_id IS NULL`.
|
||||
- [ ] **[C]** Searching screen shows briefly; chat opens against whichever
|
||||
mitra accepted.
|
||||
|
||||
---
|
||||
|
||||
## TS-05 — QRIS payment expired → retry preserves targeting
|
||||
|
||||
**Flow:** §4 `PickMethod → … → WaitPay → PayStat (timeout 20 min) → PayExpired → Pay (retry) → paid → PairRoute (lama) → Targeted → S10`
|
||||
|
||||
**Affects:** `client_app`, `backend`.
|
||||
|
||||
**Goal:** Verify the QRIS 20-min expired retry path works for a returning
|
||||
targeted attempt. The `targetedMitraId` on the draft must survive the
|
||||
retry (no need to re-pick mitra or duration) — this is the
|
||||
`resetExceptTarget` invariant from Stage 5.1.
|
||||
|
||||
**Pre-reqs**
|
||||
- [ ] **[BE]** Backend reachable; online test mitra (from customer's
|
||||
history).
|
||||
- [ ] **[BE]** Either the sweeper marks `pending → expired` after 20 min,
|
||||
or the test uses a shortened TTL / direct `UPDATE` to force expiry.
|
||||
|
||||
**Steps**
|
||||
1. [ ] **[C]** Walk TS-01 steps 1-6 to reach `/payment/waiting` for a
|
||||
targeted attempt against the test mitra.
|
||||
2. [ ] **[BE]** Wait for or force the `pending → expired` transition on
|
||||
the payment row.
|
||||
3. [ ] **[C]** Polling sees `status = 'expired'` → app routes to
|
||||
`/payment/expired/:paymentId`.
|
||||
4. [ ] **[C]** Tap the retry CTA → app routes back to `/payment/method`
|
||||
(NOT all the way to PickMethod; draft preserved via
|
||||
`resetExceptTarget`).
|
||||
5. [ ] **[C]** Re-pick payment method → tap `Bayar` → new
|
||||
`/payment/waiting`.
|
||||
6. [ ] **[BE]** Confirm the new payment.
|
||||
7. [ ] **[C]** App routes to `/chat/waiting-targeted/<mitraId>` for the
|
||||
**same mitra** as step 1 (no re-pick required).
|
||||
|
||||
**Expected result**
|
||||
- [ ] **[BE]** Original `payment_sessions` row has `status = 'expired'`.
|
||||
**New** row created with `status = 'confirmed'`. Both rows have the
|
||||
same `targeted_mitra_id`.
|
||||
- [ ] **[C]** Targeted intent survives retry; chat opens with the
|
||||
original picked mitra.
|
||||
|
||||
**Variant note:** the same retry path applies to the blast branch (TS-02 /
|
||||
TS-04) — draft has `targetedMitraId IS NULL` throughout, retry routes
|
||||
back to `/payment/method`, blast fires after re-confirm. Worth a quick
|
||||
sanity check if behavior diverges.
|
||||
|
||||
---
|
||||
|
||||
## TS-06 — Targeted request fails post-payment → fallback to blast
|
||||
|
||||
**Flow:** §4 `Targeted → TargetedRes (reject / timeout) → OfflinePopup (post-pay, returning variant) → "cari bestie lain" → fallback-to-blast → §3 BlastFlow → S10`
|
||||
|
||||
**Affects:** `client_app`, `backend`.
|
||||
|
||||
**Goal:** Verify the post-payment fallback path. After paying for a
|
||||
targeted pair, if the picked mitra rejects or doesn't answer within 20s,
|
||||
the customer can fall back to blast WITHOUT a second payment.
|
||||
|
||||
**Pre-reqs**
|
||||
- [ ] **[BE]** Online test mitra (from history) AND at least one OTHER
|
||||
online mitra (for the blast fallback).
|
||||
|
||||
**Steps**
|
||||
1. [ ] **[C]** Walk TS-01 steps 1-8 to reach
|
||||
`/chat/waiting-targeted/<mitraId>`.
|
||||
2. [ ] **[mitra_app]** Reject the incoming targeted request (or do
|
||||
nothing for the 20s countdown).
|
||||
3. [ ] **[C]** Targeted-waiting screen detects the failure →
|
||||
`BestieOfflinePopup` (`returning` variant, post-pay) appears with
|
||||
`canFallbackToBlast: true`. CTAs: `cari bestie lain` and `tanya admin`.
|
||||
4. [ ] **[C]** Tap `cari bestie lain` → app calls
|
||||
`POST /api/client/chat/chat-requests/:paymentSessionId/fallback-to-blast`
|
||||
→ routes to `/chat/searching`.
|
||||
5. [ ] **[mitra_app]** A DIFFERENT online mitra accepts the blast.
|
||||
6. [ ] **[C]** Customer lands on `/chat/session/:id` with the new mitra.
|
||||
|
||||
**Expected result**
|
||||
- [ ] **[BE]** Same `payment_sessions` row is reused (still
|
||||
`status = 'confirmed'`); customer is **not** charged a second time.
|
||||
- [ ] **[BE]** `chat_sessions` row created with the fallback mitra
|
||||
(NOT the original `targeted_mitra_id`).
|
||||
- [ ] **[C]** Chat opens with the fallback mitra; no fresh payment
|
||||
screens shown.
|
||||
|
||||
**Variant note:** the "tanya admin" CTA on this same popup is a terminal
|
||||
escape (same shape as TS-03), but post-payment — the customer has already
|
||||
paid, so this is effectively abandoning a paid session. Worth confirming
|
||||
the UX (probably a confirmation prompt) and whether the payment is
|
||||
refunded / converted to credit.
|
||||
|
||||
Reference in New Issue
Block a user