diff --git a/backend/src/routes/public/client.chat.routes.js b/backend/src/routes/public/client.chat.routes.js index e213e6d..59dba66 100644 --- a/backend/src/routes/public/client.chat.routes.js +++ b/backend/src/routes/public/client.chat.routes.js @@ -201,12 +201,13 @@ export const clientChatRoutes = async (app) => { return reply.send({ success: true, data: extension }) }) - // Chat history + // Phase 4 Stage 10 — Chat Tab Selesai feed. Cursor-paginated; old `page` + // param removed. Response shape: { items, next_cursor, has_more }. app.get('/history', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { - const { page, limit } = request.query + const { cursor, limit } = request.query const history = await getCustomerHistory(request.customer.id, { - page: page ? parseInt(page) : 1, - limit: limit ? parseInt(limit) : 20, + cursor: cursor ?? null, + limit: limit ? parseInt(limit, 10) : 20, }) return reply.send({ success: true, data: history }) }) diff --git a/backend/src/routes/public/client.payment.routes.js b/backend/src/routes/public/client.payment.routes.js index 0e317f7..8b52040 100644 --- a/backend/src/routes/public/client.payment.routes.js +++ b/backend/src/routes/public/client.payment.routes.js @@ -5,6 +5,7 @@ import { confirmPaymentSession, abandonPaymentSession, getPaymentSession, + getCustomerPendingPayments, } from '../../services/payment.service.js' import { isCustomerEligibleForFirstSessionDiscount, @@ -172,6 +173,13 @@ export const clientPaymentRoutes = async (app) => { }) }) + // Phase 4 Stage 10 — Chat Tab Pembayaran feed. Static path; registered + // before `/:id` so find-my-way matches this and not the wildcard. + app.get('/pending', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + const data = await getCustomerPendingPayments(request.customer.id) + return reply.send({ success: true, data }) + }) + app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const session = await getPaymentSession(request.params.id) if (!session) { diff --git a/backend/src/services/payment.service.js b/backend/src/services/payment.service.js index 91b94e6..e003252 100644 --- a/backend/src/services/payment.service.js +++ b/backend/src/services/payment.service.js @@ -304,3 +304,41 @@ export const getPaymentSession = async (id) => { ` return row || null } + +/** + * Phase 4 Stage 10 — Chat Tab Pembayaran feed. + * + * Returns the customer's pending payment sessions (initial + extension) that + * haven't paid AND haven't expired. The `expires_at > NOW()` filter is + * defensive: the background sweeper flips stale pending rows to `expired`, + * but rows can be stale between sweeps, so we filter inline too. + * + * Extension rows resolve mitra info via session_extensions → chat_sessions → + * mitras. Initial rows fall back to `payment_sessions.targeted_mitra_id` + * (set for targeted "Curhat lagi" flows); for general-blast initial rows + * the mitra is unknown until pairing succeeds, so mitra fields are null. + */ +export const getCustomerPendingPayments = async (customerId) => { + const items = await sql` + SELECT + ps.id, + ps.is_extension, + ps.amount, + ps.duration_minutes, + ps.mode, + ps.created_at, + ps.expires_at, + COALESCE(ext_m.id, tgt_m.id) AS mitra_id, + COALESCE(ext_m.display_name, tgt_m.display_name) AS mitra_display_name + FROM payment_sessions ps + LEFT JOIN session_extensions se ON se.payment_session_id = ps.id + LEFT JOIN chat_sessions cs ON cs.id = se.session_id + LEFT JOIN mitras ext_m ON ext_m.id = cs.mitra_id + LEFT JOIN mitras tgt_m ON tgt_m.id = ps.targeted_mitra_id + WHERE ps.customer_id = ${customerId} + AND ps.status = ${PaymentSessionStatus.PENDING} + AND ps.expires_at > NOW() + ORDER BY ps.created_at DESC + ` + return { items, total: items.length } +} diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js index 86d89f8..588ef10 100644 --- a/backend/src/services/session.service.js +++ b/backend/src/services/session.service.js @@ -204,31 +204,87 @@ export const getActiveSessionsByMitraWithUnread = async (mitraId) => { return sessions } -export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => { - const offset = (page - 1) * limit - const items = await sql` - SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at, - cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes, - m.display_name AS mitra_display_name, - COALESCE(mos.is_online, false) AS mitra_is_online, - (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message, - (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message, - (SELECT COUNT(*) FROM chat_sessions x - WHERE x.customer_id = ${customerId} AND x.mitra_id = cs.mitra_id - AND x.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})) AS sessions_count - FROM chat_sessions cs - LEFT JOIN mitras m ON m.id = cs.mitra_id - LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id - WHERE cs.customer_id = ${customerId} - AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) - ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC - LIMIT ${limit} OFFSET ${offset} - ` - const [{ count }] = await sql` - SELECT COUNT(*) FROM chat_sessions WHERE customer_id = ${customerId} - AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) - ` - return { items, total: Number(count), page, limit } +/** + * Phase 4 Stage 10 — Selesai sub-tab uses cursor pagination on this endpoint. + * + * Cursor is a base64-encoded `|` of the last row's + * `COALESCE(ended_at, created_at)` and `id`. The next page reads strictly + * older rows, breaking ties on `id` so adjacent rows with the same timestamp + * don't duplicate or skip across pages. + */ +const encodeHistoryCursor = (row) => { + const ts = (row.ended_at ?? row.created_at).toISOString + ? (row.ended_at ?? row.created_at).toISOString() + : new Date(row.ended_at ?? row.created_at).toISOString() + return Buffer.from(`${ts}|${row.id}`, 'utf8').toString('base64url') +} + +const decodeHistoryCursor = (cursor) => { + if (!cursor) return null + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8') + const [ts, id] = decoded.split('|') + if (!ts || !id) return null + return { ts, id } + } catch { + return null + } +} + +export const getCustomerHistory = async (customerId, { cursor = null, limit = 20 } = {}) => { + const cap = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 50) + const decoded = decodeHistoryCursor(cursor) + // Fetch one extra to determine has_more without a second query + const fetch = cap + 1 + const items = decoded + ? await sql` + SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at, + cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes, + ps.mode AS mode, + m.display_name AS mitra_display_name, + COALESCE(mos.is_online, false) AS mitra_is_online, + (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message, + (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message, + (SELECT COUNT(*)::int FROM chat_sessions x + WHERE x.customer_id = ${customerId} AND x.mitra_id = cs.mitra_id + AND x.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})) AS sessions_count + FROM chat_sessions cs + LEFT JOIN mitras m ON m.id = cs.mitra_id + LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id + LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id + WHERE cs.customer_id = ${customerId} + AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) + AND ( + COALESCE(cs.ended_at, cs.created_at) < ${decoded.ts}::timestamptz + OR (COALESCE(cs.ended_at, cs.created_at) = ${decoded.ts}::timestamptz AND cs.id < ${decoded.id}) + ) + ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC + LIMIT ${fetch} + ` + : await sql` + SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at, + cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes, + ps.mode AS mode, + m.display_name AS mitra_display_name, + COALESCE(mos.is_online, false) AS mitra_is_online, + (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message, + (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message, + (SELECT COUNT(*)::int FROM chat_sessions x + WHERE x.customer_id = ${customerId} AND x.mitra_id = cs.mitra_id + AND x.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})) AS sessions_count + FROM chat_sessions cs + LEFT JOIN mitras m ON m.id = cs.mitra_id + LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id + LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id + WHERE cs.customer_id = ${customerId} + AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) + ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC + LIMIT ${fetch} + ` + const hasMore = items.length > cap + const page = hasMore ? items.slice(0, cap) : items + const nextCursor = hasMore ? encodeHistoryCursor(page[page.length - 1]) : null + return { items: page, next_cursor: nextCursor, has_more: hasMore } } export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) => { diff --git a/backend/test/services/payment.service.test.js b/backend/test/services/payment.service.test.js index e514315..77ed9c0 100644 --- a/backend/test/services/payment.service.test.js +++ b/backend/test/services/payment.service.test.js @@ -3,10 +3,11 @@ import { createPaymentSession, confirmPaymentSession, getPaymentSession, + getCustomerPendingPayments, } from '../../src/services/payment.service.js' -import { PaymentSessionStatus } from '../../src/constants.js' -import { resetDb, resetAppConfig } from '../helpers/db.js' -import { createCustomer } from '../helpers/fixtures.js' +import { PaymentSessionStatus, SessionStatus } from '../../src/constants.js' +import { resetDb, resetAppConfig, db } from '../helpers/db.js' +import { createCustomer, createMitra } from '../helpers/fixtures.js' describe('payment.service', () => { let customer @@ -83,4 +84,167 @@ describe('payment.service', () => { expect(reloaded.status).toBe(PaymentSessionStatus.PENDING) expect(reloaded.confirmed_at).toBeNull() }) + + // Phase 4 Stage 10 — Chat Tab Pembayaran feed. + describe('getCustomerPendingPayments', () => { + it('returns empty when customer has no payments', async () => { + const result = await getCustomerPendingPayments(customer.id) + expect(result.items).toEqual([]) + expect(result.total).toBe(0) + }) + + it('returns pending initial-session payment with null mitra info', async () => { + const pay = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 15, + amount: 5000, + }) + + const result = await getCustomerPendingPayments(customer.id) + expect(result.total).toBe(1) + expect(result.items[0]).toMatchObject({ + id: pay.id, + is_extension: false, + amount: 5000, + duration_minutes: 15, + mode: 'chat', + mitra_id: null, + mitra_display_name: null, + }) + }) + + it('fills mitra info from targeted_mitra_id for targeted initial payments', async () => { + const mitra = await createMitra({ callName: 'kak Dimas' }) + await createPaymentSession({ + customerId: customer.id, + durationMinutes: 30, + amount: 10000, + targetedMitraId: mitra.id, + }) + + const result = await getCustomerPendingPayments(customer.id) + expect(result.total).toBe(1) + expect(result.items[0].mitra_id).toBe(mitra.id) + expect(result.items[0].mitra_display_name).toBe('kak Dimas') + }) + + it('fills mitra info via session_extensions → chat_sessions for extension payments', async () => { + const sql = db() + const mitra = await createMitra({ callName: 'kak Sari' }) + + // The initial chat session this extension belongs to. + const [chatSession] = await sql` + INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes) + VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.ACTIVE}, 12) + RETURNING id + ` + + const extPay = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 10, + amount: 2500, + isExtension: true, + }) + + await sql` + INSERT INTO session_extensions ( + session_id, requested_duration_minutes, requested_price, status, payment_session_id + ) + VALUES (${chatSession.id}, 10, 2500, 'pending', ${extPay.id}) + ` + + const result = await getCustomerPendingPayments(customer.id) + expect(result.total).toBe(1) + expect(result.items[0]).toMatchObject({ + id: extPay.id, + is_extension: true, + amount: 2500, + mitra_id: mitra.id, + mitra_display_name: 'kak Sari', + }) + }) + + it('orders newest first and returns mixed initial + extension rows', async () => { + const sql = db() + const mitra = await createMitra({ callName: 'kak Sari' }) + + // Initial first + const initial = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 15, + amount: 5000, + }) + + // Then extension (newer) + const [chatSession] = await sql` + INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes) + VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.ACTIVE}, 12) + RETURNING id + ` + const extension = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 10, + amount: 2500, + isExtension: true, + }) + await sql` + INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_session_id) + VALUES (${chatSession.id}, 10, 2500, 'pending', ${extension.id}) + ` + + const result = await getCustomerPendingPayments(customer.id) + expect(result.total).toBe(2) + // Newest first + expect(result.items[0].id).toBe(extension.id) + expect(result.items[1].id).toBe(initial.id) + }) + + it('excludes expired pending rows (defensive filter on expires_at)', async () => { + const sql = db() + const pay = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 15, + amount: 5000, + }) + // Manually move expires_at into the past — leaves status pending so this + // simulates the gap between TTL expiry and the next sweep tick. + await sql` + UPDATE payment_sessions + SET expires_at = NOW() - INTERVAL '1 second' + WHERE id = ${pay.id} + ` + + const result = await getCustomerPendingPayments(customer.id) + expect(result.total).toBe(0) + }) + + it('excludes non-pending statuses', async () => { + const pay = await createPaymentSession({ + customerId: customer.id, + durationMinutes: 15, + amount: 5000, + }) + await confirmPaymentSession(pay.id, customer.id) // → confirmed + + const result = await getCustomerPendingPayments(customer.id) + expect(result.total).toBe(0) + }) + + it('scopes by customer — does not leak other customers payments', async () => { + await createPaymentSession({ + customerId: customer.id, + durationMinutes: 15, + amount: 5000, + }) + await createPaymentSession({ + customerId: otherCustomer.id, + durationMinutes: 30, + amount: 10000, + }) + + const result = await getCustomerPendingPayments(customer.id) + expect(result.total).toBe(1) + expect(result.items[0].amount).toBe(5000) + }) + }) }) diff --git a/backend/test/services/session.service.history.test.js b/backend/test/services/session.service.history.test.js new file mode 100644 index 0000000..1bdf592 --- /dev/null +++ b/backend/test/services/session.service.history.test.js @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' +import { getCustomerHistory } from '../../src/services/session.service.js' +import { SessionStatus } from '../../src/constants.js' +import { resetDb, resetAppConfig, db } from '../helpers/db.js' +import { createCustomer, createMitra } from '../helpers/fixtures.js' + +// Phase 4 Stage 10 — Chat Tab Selesai feed uses cursor pagination. +// Cursor is base64url(`|`); response is { items, next_cursor, has_more }. + +describe('session.service.getCustomerHistory (cursor paginated)', () => { + let customer + let mitra + + beforeAll(async () => { + await resetAppConfig() + }) + + beforeEach(async () => { + await resetDb() + customer = await createCustomer({ callName: 'Alice' }) + mitra = await createMitra({ callName: 'kak Sari' }) + }) + + // Seed N completed sessions ended at deterministically spaced times. + // i=0 is the OLDEST, i=N-1 is the NEWEST. + const seedCompleted = async (count) => { + const sql = db() + const now = Date.now() + const ids = [] + for (let i = 0; i < count; i++) { + const endedAt = new Date(now - (count - i) * 60_000) // 1 min apart + const [row] = await sql` + INSERT INTO chat_sessions ( + customer_id, mitra_id, status, duration_minutes, price, ended_at, created_at + ) + VALUES ( + ${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, + ${endedAt}, ${endedAt} + ) + RETURNING id + ` + ids.push(row.id) + } + return ids + } + + it('returns empty + has_more=false when there is no history', async () => { + const result = await getCustomerHistory(customer.id, { limit: 10 }) + expect(result.items).toEqual([]) + expect(result.has_more).toBe(false) + expect(result.next_cursor).toBeNull() + }) + + it('returns all items + has_more=false when count <= limit', async () => { + await seedCompleted(5) + const result = await getCustomerHistory(customer.id, { limit: 10 }) + expect(result.items).toHaveLength(5) + expect(result.has_more).toBe(false) + expect(result.next_cursor).toBeNull() + }) + + it('first page returns `limit` items, has_more=true, and a usable cursor', async () => { + await seedCompleted(7) + const page1 = await getCustomerHistory(customer.id, { limit: 3 }) + expect(page1.items).toHaveLength(3) + expect(page1.has_more).toBe(true) + expect(page1.next_cursor).toBeTruthy() + }) + + it('walks across pages without duplicates or skips', async () => { + const seeded = await seedCompleted(7) // ids[0]=oldest .. ids[6]=newest + const collected = [] + + let cursor = null + do { + const page = await getCustomerHistory(customer.id, { cursor, limit: 3 }) + collected.push(...page.items.map((r) => r.id)) + cursor = page.next_cursor + if (!page.has_more) break + } while (cursor) + + // Newest → oldest = reverse of seeded + expect(collected).toEqual([...seeded].reverse()) + // No duplicates + expect(new Set(collected).size).toBe(collected.length) + }) + + it('orders by ended_at DESC with id DESC tiebreak (no gaps for same-timestamp rows)', async () => { + const sql = db() + const sameTime = new Date() + // Insert 3 rows with the EXACT same ended_at — only id distinguishes them. + const ids = [] + for (let i = 0; i < 3; i++) { + const [row] = await sql` + INSERT INTO chat_sessions ( + customer_id, mitra_id, status, duration_minutes, price, ended_at, created_at + ) + VALUES ( + ${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, + ${sameTime}, ${sameTime} + ) + RETURNING id + ` + ids.push(row.id) + } + + // Page through them 1 at a time. + const collected = [] + let cursor = null + for (let i = 0; i < 5; i++) { + const page = await getCustomerHistory(customer.id, { cursor, limit: 1 }) + collected.push(...page.items.map((r) => r.id)) + cursor = page.next_cursor + if (!page.has_more) break + } + + expect(collected).toHaveLength(3) + expect(new Set(collected).size).toBe(3) + }) + + it('clamps limit to 50 max', async () => { + await seedCompleted(60) + const result = await getCustomerHistory(customer.id, { limit: 999 }) + expect(result.items.length).toBeLessThanOrEqual(50) + }) + + it('scopes by customer — does not leak other customers history', async () => { + const other = await createCustomer({ callName: 'Bob' }) + const sql = db() + await sql` + INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at) + VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, NOW()) + ` + await sql` + INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at) + VALUES (${other.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, NOW()) + ` + const result = await getCustomerHistory(customer.id, { limit: 20 }) + expect(result.items).toHaveLength(1) + }) + + it('includes CLOSING status (grace period) alongside COMPLETED', async () => { + const sql = db() + await sql` + INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at) + VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.CLOSING}, 12, 5000, NOW()) + ` + await sql` + INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price, ended_at) + VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 12, 5000, NOW()) + ` + const result = await getCustomerHistory(customer.id, { limit: 20 }) + expect(result.items).toHaveLength(2) + }) + + it('excludes ACTIVE / PENDING_PAYMENT / EXTENDING', async () => { + const sql = db() + for (const status of [SessionStatus.ACTIVE, SessionStatus.PENDING_PAYMENT, SessionStatus.EXTENDING]) { + await sql` + INSERT INTO chat_sessions (customer_id, mitra_id, status, duration_minutes, price) + VALUES (${customer.id}, ${mitra.id}, ${status}, 12, 5000) + ` + } + const result = await getCustomerHistory(customer.id, { limit: 20 }) + expect(result.items).toHaveLength(0) + }) +}) diff --git a/requirement/flow_customer.mermaid.md b/requirement/flow_customer.mermaid.md index cf4100b..e29511f 100644 --- a/requirement/flow_customer.mermaid.md +++ b/requirement/flow_customer.mermaid.md @@ -19,11 +19,11 @@ the Figma handoff naming (`S1`, `S6`, `S10`, …); see `Figma/handoff/png/` for ```mermaid flowchart TD S1["S1 · Splash 🟢"] --> Home{"JWT session?"} - Home -->|"no"| Home1st["Home (1st time)
+ login panel 🟡"] + Home -->|"no"| Home1st["Home (1st time)
+ login panel 🟢"] Home -->|"yes"| HomeRet["Home (returning)
+ profile panel 🟢"] Home1st --> NotifCheck{"OS notif allowed?"} HomeRet --> NotifCheck - NotifCheck -->|"no"| HomeBanner["Home + notif banner 🔴"] + NotifCheck -->|"no"| HomeBanner["Home + notif banner 🟢"] NotifCheck -->|"yes"| HomeReady["Home (ready) 🟢"] HomeBanner --> HomeReady @@ -33,8 +33,6 @@ flowchart TD classDef missing fill:#ffe5e5,stroke:#c44979 classDef partial fill:#fff4d6,stroke:#c69b3f - class HomeBanner missing - class Home1st partial ``` --- @@ -46,45 +44,92 @@ flowchart TD Start["from Home — 'aku mau curhat' 🟢"] --> NameCheck{"call_sign exists?"} NameCheck -->|"no"| S2["S2 · Pengisian Nama 🟢"] NameCheck -->|"yes"| VerifChoice - S2 --> VerifChoice["Verif vs Anon Choice Sheet
(VerifChoiceSheet) 🔴"] + S2 --> VerifChoice["Verif vs Anon Choice Sheet
(VerifChoiceSheet) 🟢"] - VerifChoice -->|"verif WA · Rp2k"| ESPa["S5 · ESP screening
(multi-select chips) 🟡"] - VerifChoice -->|"tanpa verif · Rp5k+"| ESPb["S5 · ESP screening 🟡"] + VerifChoice -->|"verif WA · Rp2k"| USPGateA{"usp_seen flag? 🔴"} + VerifChoice -->|"tanpa verif · Rp5k+"| USPGateB{"usp_seen flag? 🔴"} %% Verified path - ESPa --> USPa["S5b · USP screen 🔴"] - USPa --> S3a["S3a · WhatsApp input 🟡 (6→4 digit)"] + USPGateA -->|"no · first-timer"| USPa["S5b · USP screen 🟢"] + USPGateA -->|"yes · skip"| S3a + USPa --> S3a["S3a · WhatsApp input 🟢"] S3a --> S3b["S3b · OTP 4-digit 🟡"] S3b --> OTPok{"OTP ok?"} - OTPok -->|"too many retries"| OTPBlock["OTP Blocked Popup 🔴
→ fallback to Anon"] - OTPBlock --> ESPb - OTPok -->|"verified"| S6["S6 · Paywall Rp2.000
(12 menit, sekali seumur hidup) 🟡"] + OTPok -->|"too many retries"| OTPBlock["OTP Blocked Popup 🟢
→ fallback to Anon"] + OTPBlock --> USPGateB + OTPok -->|"verified"| UserLookup{"user found in DB?
(phone match) 🔴"} + UserLookup -->|"no · brand-new"| S6["S6 · Paywall Rp2.000
(12 menit, sekali seumur hidup) 🟢"] + UserLookup -->|"yes · existing account"| LoadCallSign["Load stored call_sign
→ overwrite local call_sign 🔴"] + LoadCallSign --> TransactedCheck{"has_transacted flag? 🔴"} + TransactedCheck -->|"no · never paid"| S6 + TransactedCheck -->|"yes · returning verified"| PickMethod S6 --> Pay %% Anonymous path - ESPb --> USPb["S5b · USP screen 🔴"] - USPb --> PickMethod["Pilih cara curhat
(chat / voice call) 🔴"] - PickMethod --> PickDuration["Pemilihan harga
(5 durations, full screen) 🟡"] - PickDuration --> PayMethod["Cara bayar (QRIS-first) 🔴"] + USPGateB -->|"no · first-timer"| USPb["S5b · USP screen 🟢"] + USPGateB -->|"yes · skip"| PickMethod + USPb --> PickMethod["Pilih cara curhat
(chat / voice call) 🟢"] + PickMethod --> PickDuration["Pemilihan harga
(5 durations, full screen) 🟢"] + PickDuration --> PayMethod["Cara bayar (QRIS-first) 🟢"] PayMethod --> Pay %% Shared payment exit - Pay["Xendit checkout
(QRIS / e-wallet) 🔴"] --> WaitPay["Waiting Payment
(20-min QRIS clock) 🔴"] + Pay["Xendit checkout
(QRIS / e-wallet) 🟡"] --> WaitPay["Waiting Payment
(20-min QRIS clock) 🟢"] WaitPay --> PayStat{"payment status"} - PayStat -->|"timeout 20 min"| PayExpired["Pembayaran expired 🔴
→ retry"] + PayStat -->|"timeout 20 min"| PayExpired["Pembayaran expired 🟢
→ retry"] PayExpired --> Pay PayStat -->|"paid"| NotifGate classDef missing fill:#ffe5e5,stroke:#c44979 classDef partial fill:#fff4d6,stroke:#c69b3f - class VerifChoice,USPa,USPb,PickMethod,PayMethod,Pay,WaitPay,PayExpired,OTPBlock missing - class ESPa,ESPb,S3a,S3b,S6,PickDuration partial + class UserLookup,LoadCallSign,TransactedCheck,USPGateA,USPGateB missing + class S3b,Pay partial ``` +> **ESP screening removed (2026-05-12):** the S5 ESP multi-select chip +> screen is retired from the spec. Both verified and anonymous branches +> now go from `VerifChoiceSheet` straight to the `usp_seen?` gate. Any +> ESP code still in `client_app` is now tech debt to retire — see +> `TECH_DEBT.md`. +> +> **S5b USP is one-time-only (2026-05-12):** the USP screen now shows at +> most once per user. Gating is driven by a `usp_seen` flag that lives in +> two places: +> - **Local (SharedPreferences):** the runtime gate. Set to `true` when the +> user dismisses the USP screen for the first time. Survives across +> sessions on the same device. +> - **DB (`customers.usp_seen` column 🔴):** the cross-device source of truth. +> Written when the user account is created (post-OTP, after JWT issuance +> and `users` row insert) if local flag is already true, OR on any +> subsequent USP dismissal once an account exists. Read on login/relogin +> and hydrated back into local. "True wins" — if either side says seen, +> the gate is closed. +> +> Anonymous users with no account only have the local flag; if they later +> upgrade to verified, account creation propagates the local flag to DB. +> Returning verified users on a fresh device will see USP at most once on +> that device (DB hydrate happens on login, after USP gate fires pre-OTP). +> Business has accepted this edge case. +> > **Anchor mismatch:** flow_customer.md numbers ESP/USP under > `5.1.2 Verification request (OTP)` for both branches, but Figma puts > `VerifChoiceSheet` *before* ESP. The mermaid above follows Figma; reconcile in > phase4 spec. +> +> **Post-OTP account lookup (added 2026-05-11):** the verified path is not +> always "new user". After a successful OTP, the backend looks up the phone +> number; if a row exists, the app overwrites the freshly-typed call_sign with +> the stored one. Then `has_transacted` decides routing: +> - `false` → S6 Paywall (Rp2.000 first-session) — user has an account but +> never converted. +> - `true` → jump straight into `PickMethod` (the regular chat/voice + +> duration + payment flow). USPb is already skipped because USPGateA +> already evaluated pre-OTP on the verified branch. +> +> `has_transacted` is the persistent flag on the users table that flips the +> first-time pricing off forever. Backend phone-lookup behaviour already +> exists (see Phase 1 auto-link via phone); the app-side reconciliation + +> `has_transacted` plumbing is the new work. --- @@ -92,13 +137,13 @@ flowchart TD ```mermaid flowchart TD - NotifGate["Notif Gate Screen 🔴
(Aktifkan / Nanti Saja)"] --> NotifBranch{"OS allowed?"} + NotifGate["Notif Gate Screen 🟢
(Aktifkan / Nanti Saja)"] --> NotifBranch{"OS allowed?"} NotifBranch -->|"no + ask"| EnableNotif["OS settings deeplink"] --> SoftPrompt NotifBranch -->|"yes / skipped"| SoftPrompt SoftPrompt["S7 · Soft-prompt
(consent + warmup, CTA 'Aku ngerti, Lanjut') 🟡"] --> Blast - Blast["Blast pair request
S7 · Searching state 🟡"] --> BlastTimer{"5-min timer"} + Blast["Blast pair request
S7 · Searching state 🟢"] --> BlastTimer{"5-min timer"} BlastTimer -->|"matched"| S9["S9 · Match Found
(bestie name, age, hobi) 🟡"] - BlastTimer -->|"timeout"| S7Timeout["S7 · Timeout 5 menit 🔴
CTA 'Coba Cari Lagi'
ghost CTA 'Coba cari lagi nanti' → Home"] + BlastTimer -->|"timeout"| S7Timeout["S7 · Timeout 5 menit 🟢
CTA 'Coba Cari Lagi'
ghost CTA 'Coba cari lagi nanti' → Home"] S7Timeout -->|"retry"| Blast S7Timeout -->|"home"| HomeRet S9 --> S10 @@ -107,8 +152,7 @@ flowchart TD classDef missing fill:#ffe5e5,stroke:#c44979 classDef partial fill:#fff4d6,stroke:#c69b3f - class NotifGate,S7Timeout missing - class SoftPrompt,Blast,S9 partial + class SoftPrompt,S9 partial ``` --- @@ -117,7 +161,7 @@ flowchart TD ```mermaid flowchart TD - CTA["'curhat sama bestie baru' 🟢"] --> Choice["Bestie Choice Sheet
(BestieChoiceSheet) 🔴"] + CTA["'curhat sama bestie baru' 🟢"] --> Choice["Bestie Choice Sheet
(BestieChoiceSheet) 🟢"] Choice -->|"bestie yang udah kenal"| HistList["Bestie History List
(BestieHistoryList) 🟢"] Choice -->|"bestie baru"| BlastFlow["→ S7 Soft-prompt + Blast
(see diagram 3)"] @@ -125,16 +169,14 @@ flowchart TD PickBestie --> CheckOnline{"bestie online?"} CheckOnline -->|"no"| OfflinePopup["Bestie Offline Popup
(returning variant) 🟢"] OfflinePopup -->|"cari bestie lain"| BlastFlow - OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin
(WA / Telegram) 🔴"] - CheckOnline -->|"yes"| Targeted["Request targeted pair
'Menunggu bestie tertentu' 🟡
(20s countdown overlay)"] + OfflinePopup -->|"tanya admin"| AdminSheet["Sheet · tanya admin
(WA / Telegram) 🟢"] + CheckOnline -->|"yes"| Targeted["Request targeted pair
'Menunggu bestie tertentu' 🟢
(20s countdown overlay)"] 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 Choice,AdminSheet missing - class Targeted partial ``` --- @@ -143,21 +185,21 @@ flowchart TD ```mermaid flowchart TD - Enter["enter S10 · Chat Room
(WebSocket open, timer running) 🟡"] --> T3{"3-minutes-left tick"} - T3 -->|"fired"| Snackbar["S10 · Snackbar reminder
'sisa 3 menit lagi ya' 🔴"] + Enter["enter S10 · Chat Room
(WebSocket open, timer running) 🟢"] --> T3{"3-minutes-left tick"} + T3 -->|"fired"| Snackbar["S10 · Snackbar reminder
'sisa 3 menit lagi ya' 🟢"] Snackbar --> T2 T3 -->|"not yet"| T2{"2-minutes-left tick"} - T2 -->|"fired"| LowTime["S10 · Last 2 Minutes
(timer turns danger color) 🔴"] + T2 -->|"fired"| LowTime["S10 · Last 2 Minutes
(timer turns danger color) 🟢"] LowTime --> Expire T2 -->|"not yet"| Expire{"timer hits 0"} - Expire -->|"fired"| ExpiredBanner["S10 · Floating Expired Banner
'habis nih... mau lanjutin?' 🔴"] + Expire -->|"fired"| ExpiredBanner["S10 · Floating Expired Banner
'habis nih... mau lanjutin?' 🟢"] ExpiredBanner --> CTAExt{"perpanjang CTA?"} CTAExt -->|"yes"| TimeUp CTAExt -->|"close / ignore"| EndFlow["→ end-session flow"] Expire -->|"not yet · user taps perpanjang"| TimeUp - TimeUp["Time-up Bottom Sheet
(5 durations · chat/call toggle) 🟡"] - TimeUp -->|"perpanjang"| AskMitra["Targeted re-pay request
(same mitra, no blast) 🔴"] + TimeUp["Time-up Bottom Sheet
(5 durations · chat/call toggle) 🟢"] + TimeUp -->|"perpanjang"| AskMitra["Targeted re-pay request
(same mitra, no blast) 🟢"] TimeUp -->|"cukup, akhiri sesi"| EndFlow AskMitra --> MitraRes{"mitra approves?"} @@ -166,8 +208,6 @@ flowchart TD classDef missing fill:#ffe5e5,stroke:#c44979 classDef partial fill:#fff4d6,stroke:#c69b3f - class Snackbar,LowTime,ExpiredBanner,AskMitra missing - class Enter,TimeUp partial ``` --- @@ -176,11 +216,11 @@ flowchart TD ```mermaid flowchart TD - EndStart["End-session entry
(from S10 or Time-up sheet 'Cukup, Akhiri')"] --> Confirm1["Popup · Konfirmasi Akhiri (1)
'beneran udah cukup?' 🟡"] - Confirm1 -->|"Gak Jadi, Balik"| TimeUp["Time-up Bottom Sheet 🟡"] - Confirm1 -->|"Lanjut Akhiri"| Confirm2["Popup · Konfirmasi Akhiri (2)
'mau tinggalin pesan penutup?' 🔴"] + EndStart["End-session entry
(from S10 or Time-up sheet 'Cukup, Akhiri')"] --> Confirm1["Popup · Konfirmasi Akhiri (1)
'beneran udah cukup?' 🟢"] + Confirm1 -->|"Gak Jadi, Balik"| TimeUp["Time-up Bottom Sheet 🟢"] + Confirm1 -->|"Lanjut Akhiri"| Confirm2["Popup · Konfirmasi Akhiri (2)
'mau tinggalin pesan penutup?' 🟢"] - Confirm2 -->|"Tulis Pesan Penutup"| ClosingSheet["Pesan Penutup Bottom Sheet
(textarea) 🟡"] + Confirm2 -->|"Tulis Pesan Penutup"| ClosingSheet["Pesan Penutup Bottom Sheet
(textarea) 🟢"] Confirm2 -->|"Lewati Saja"| ThankYou ClosingSheet -->|"Kirim & Akhiri"| MitraReceipt{"mitra rejects close?"} @@ -188,16 +228,54 @@ flowchart TD MitraReceipt -->|"no"| ThankYou MitraReceipt -->|"yes (rare)"| OfflinePopup["Bestie Offline Popup 🟢"] - ThankYou["S11 · Terima Kasih Udah Cerita 🔴"] --> Home["→ Home (returning) 🟢"] + ThankYou["S11 · Terima Kasih Udah Cerita 🟢"] --> Home["→ Home (returning) 🟢"] classDef missing fill:#ffe5e5,stroke:#c44979 classDef partial fill:#fff4d6,stroke:#c69b3f - class Confirm2,ThankYou missing - class Confirm1,ClosingSheet,TimeUp partial ``` --- +## 7. Chat Tab (3 sub-tabs) — Phase 4 Stage 10 + +```mermaid +flowchart TD + HomeTabBar["HaloTabBar · tap '💬 chat'"] --> ChatRedirect["/chat redirect 🟡"] + ChatRedirect --> Aktif["/chat/aktif · sub-tab 🟡"] + + Aktif -->|"tap pill 'pembayaran'"| Pembayaran["/chat/pembayaran · sub-tab 🟡"] + Aktif -->|"tap pill 'selesai'"| Selesai["/chat/selesai · sub-tab 🟡"] + Pembayaran -->|"tap pill 'aktif'"| Aktif + Pembayaran -->|"tap pill 'selesai'"| Selesai + Selesai -->|"tap pill 'aktif'"| Aktif + Selesai -->|"tap pill 'pembayaran'"| Pembayaran + + Aktif -->|"empty"| AktifEmpty["empty state · 'belum ada chat di sini' 🟡"] + Pembayaran -->|"empty"| PembayaranEmpty["empty state · 'belum ada pembayaran tertunda' 🟡"] + Selesai -->|"empty"| SelesaiEmpty["empty state · 'belum ada riwayat curhat' 🟡"] + + Aktif -->|"tap active session row"| ChatRoom["S10 · Chat Room 🟢"] + Pembayaran -->|"tap pending payment row"| WaitingPayment["S7 · Waiting Payment 🟢"] + Selesai -->|"tap past session row"| Transcript["Read-only transcript /chat/transcript/:id 🟢"] + + classDef missing fill:#ffe5e5,stroke:#c44979 + classDef partial fill:#fff4d6,stroke:#c69b3f + class ChatRedirect,Aktif,Pembayaran,Selesai,AktifEmpty,PembayaranEmpty,SelesaiEmpty partial +``` + +**Data sources** +- Aktif → existing `GET /api/client/chat/session/active-with-unread` (0 or 1 row) +- Pembayaran → new `GET /api/client/payment-sessions/pending` (filters `status='pending' AND expires_at > NOW()`) +- Selesai → existing `GET /api/client/history`, migrated to cursor pagination (`{ items, next_cursor, has_more }`) + +**Badges** +- Bottom-nav `chat` tab → red dot when Pembayaran `total > 0` +- `aktif` sub-tab pill → numeric badge from `unread_count` +- `pembayaran` sub-tab pill → numeric badge from `total` +- `selesai` sub-tab pill → no badge + +--- + ## Cross-reference: Figma → flow_customer.md | Figma artifact (file) | Source | flow_customer.md ref | @@ -207,8 +285,8 @@ flowchart TD | Notif banner on home | `screens/v3.jsx::HBNotifBanner` | §4.1 | | S2 Nama | `screens/onboarding.jsx::S2Name` (and v4 variant) | §5.1.1 | | `VerifChoiceSheet` | `screens/v4.jsx::VerifChoiceSheet` | implied between §5.1.1 ↔ §5.1.2 | -| S5 ESP screening | `screens/onboarding.jsx::S5ESP` | §5.1.2.1.1 / §5.1.2.2.1 | -| S5b USP | `screens/onboarding.jsx::S5USP` | §5.1.2.1.2 / §5.1.2.2.2 | +| ~~S5 ESP screening~~ | ~~`screens/onboarding.jsx::S5ESP`~~ | **retired 2026-05-12** — code is tech debt | +| S5b USP (one-time) | `screens/onboarding.jsx::S5USP` | §5.1.2.1.2 / §5.1.2.2.2 — gated by `usp_seen` | | S3a WA / S3b OTP | `screens/onboarding.jsx::S3Phone` (+ `screens/v4.jsx::S3OTPV4`) | §5.1.2.1.3-4 | | `OTPBlockedPopup` | `screens/v4.jsx::OTPBlockedPopup` | (gap — not in flow doc) | | S6 Paywall Rp2k | `screens/onboarding.jsx::S6Paywall` | §5.1.2.1.5 | @@ -234,6 +312,7 @@ flowchart TD | Confirm akhiri (2 popups) | `screens/v3.jsx::HBConfirmEndPopup` (step 1 + 2) | §5.8.2.1 / §5.8.2.1.1 | | Closing Message Sheet | `screens/extras.jsx::SClosingSheet` | §5.8.2.1.1.1 | | S11 Thank-you | `screens/session.jsx::S11Post` | §5.8.2.1.1.1.1 | +| Chat Tab (3 sub-tabs) | `screens/extras.jsx::SChatList` | §7 | Anything Figma describes that flow_customer.md doesn't mention is captured as a gap in `phase4-customer-flow.md` (next-phase doc). diff --git a/requirement/phase4-customer-flow-plan.md b/requirement/phase4-customer-flow-plan.md index d9170d2..ad325ca 100644 --- a/requirement/phase4-customer-flow-plan.md +++ b/requirement/phase4-customer-flow-plan.md @@ -4,8 +4,8 @@ > on master (commits `4ada7c9` through `862fc35`, plus the pre-Phase-4 > `4680c36` OTP test infrastructure). `flutter analyze` clean across > both apps; backend Vitest 15/15. **Stage 9 (test sweep) is -> operator-driven and pending** — see `project_resume_next.md` in -> agent memory for the run-list and known TODOs. +> operator-driven and in progress** — see "Post-Stage-8 corrections" +> below for fixes applied during the visual sweep. > See [phase4-customer-flow.md](phase4-customer-flow.md) for the PRD, > [flow_customer.md](flow_customer.md) for the source-of-truth flow, @@ -791,6 +791,74 @@ Tapping launches `url_launcher` with the deeplink. No webview. --- +# Post-Stage-8 corrections (2026-05-10, uncommitted) + +The first visual sweep of the live app caught that the boot path was still +on Phase 1 plumbing — Splash → `/welcome` (Phase 1 social/phone picker) → +forms — instead of the mermaid §1 contract: **Splash → Home (1st time / returning)**. +The new home variants (`SHome1st`, `SHomeReturning`) had not been built; +`home_screen.dart` was the Phase 1 placeholder with a Material AppBar + +"Mulai Curhat" button. + +Fixes applied in the working tree (not yet committed): + +## C.1 `/welcome` retired + +- Route + `WelcomeScreen` import + `welcome_screen.dart` file all removed. +- Router redirects (formerly pointing at `/welcome` for `AuthInitialData`, + `AsyncError`, and post-onboarding-carousel cases) now point at `/home`. +- The router carve-out comment that referenced `/welcome` as the bottom of + the navigation stack updated to reference `/home`. +- **Stage 2.6 of this plan is stale**: it described editing + `welcome_screen.dart` to read `authProvidersProvider`; that screen no + longer exists. The `authProvidersProvider` itself is preserved and is + now consumed only at the phone-OTP / future login-recovery surfaces. + +## C.2 `home_screen.dart` rewritten to Figma §1 spec + +- Renders `SHome1st` (`screens/v3.jsx::SHome1st`) for unauthenticated users + (any state that isn't `AuthAuthenticatedData` / `AuthAnonymousData`). +- Renders `SHomeReturning` (`SHomeReturning`) for authenticated / + anonymous users. +- Components: login-recover banner, "halo," / "halo, {name}" greeting + (brand-colored name on returning), `aku mau curhat` / `curhat sama + bestie baru` primary CTA, "curhatan sebelumnya" history section (live + data via `bestieHistoryProvider`), bottom 4-tab `HBTabBar` footer + (home / chat / kamu / premium SOON — only home + chat wired). +- `_NotifDeniedBanner` (Stage 4) preserved at the top. +- `_ActiveSessionCard` preserved on SHomeReturning so a user mid-session + can rejoin (not in Figma §1 but a hard UX requirement). +- Material `AppBar` removed — the Figma layout has none. Logout will land + on the `kamu` tab when that's built. + +## C.3 Onboarding carousel destination fixed + +- `OnboardingScreen._finish()` now navigates to `/home` instead of + `/welcome`. The 3-page intro carousel (`Langsung Curhat / 100% Anonim / + Bestie yang Relevan`) itself is kept for now — it is **not in the + mermaid §1**, but the operator chose minimum-touch correction. Full + retirement (delete `OnboardingScreen` + `onboardingDoneProvider` + the + `/onboarding` route + the gate at the top of `router.dart`) is a + follow-up. + +## C.4 Defensive variant gate + +- `HomeScreen` now treats anything that is *not* `AuthAuthenticatedData` + or `AuthAnonymousData` as "fresh" → renders `SHome1st`. This avoids the + unauthenticated-but-erroring user seeing `halo, kamu` (the returning + view) for a brief moment. + +## C.5 Open gap — Login flow not in mermaid + +`SHome1st`'s `masuk →` banner button currently routes to `/auth/register` +(phone-OTP entry). This is an interpretation, not a spec: the mermaid (and +Figma `SHome1st`'s `onLogin` callback) doesn't define the login destination. +**The mermaid needs a Login flow diagram** added — destinations from the +`masuk →` banner, OTP success → `AuthAuthenticatedData` → SHomeReturning. +Tracked in agent memory as `project_phase4_login_flow_gap.md`. + +--- + # Stage 9 — Test Sweep ## 9.1 Maestro flows @@ -819,6 +887,268 @@ endpoints). --- +# Stage 10 — Chat Tab (3 sub-tabs) + +> Added 2026-05-12 after design review. Figma source: `SChatList` in +> [requirement/Figma/screens/extras.jsx](Figma/screens/extras.jsx) (line 22+). +> Not yet in `flow_customer.mermaid.md` — §10.8 adds it. + +## 10.1 Scope & goal +Replace the existing `/chat/history` destination (a flat list of closed sessions +backed by `bestie_history_provider`) with a new **Chat tab** screen that +contains three sub-tabs: + +| Sub-tab | Contents | Tap behavior | +|---|---|---| +| `aktif` | The user's single ongoing session (0 or 1 item) | Resume the live chat room | +| `pembayaran` | Pending initial-session + extension payments | Resume the Xendit payment flow | +| `selesai` | Past sessions (status `COMPLETED` + `CLOSING`) — cursor-paginated 20/page | Open read-only transcript | + +The chat icon in `HaloTabBar` already exists and points to `/chat/history` — +only its **destination** changes. Bottom-nav structure is unchanged. + +`bestie_history` (screen + provider) is **retired** in this stage. + +## 10.2 Figma source +- `SChatList` — list layout, sub-tab pill counters, per-item visuals +- `S_pembayaran_kedaluwarsa` (same file, ~line 600) — expired-payment full + screen. **Copy says 20 menit**, see §10.6. +- Item visuals: `HBOrb` (avatar) + optional green `success`-color live dot; + name (`who`) bold; preview text muted; right-aligned timestamp + (`● live` when active); below-preview chips: + - `bayar Rp X.XXX` chip (amber) on `pembayaran` items + - `X menit` duration suffix on `selesai` items + +## 10.3 Routes & navigation (client_app) +Each sub-tab gets its **own path** so deep links, back stack, and Maestro +tests all agree on the active tab (URL is the source of truth): + +| Path | Sub-tab | +|---|---| +| `/chat` | Redirect → `/chat/aktif` | +| `/chat/aktif` | Aktif (default landing) | +| `/chat/pembayaran` | Pembayaran | +| `/chat/selesai` | Selesai | + +Implementation: a single `ShellRoute` (or a shared scaffold widget passed +the active tab id) so the three paths render the same chrome (heading, +sub-tab pills, bottom `HaloTabBar`) with only the list body swapping. +Tapping a sub-tab pill calls `context.go('/chat/')`. + +Renames + cleanup: +- `HaloTabBar` `chat` tab `onTap`: `/chat/history` → `/chat` (which then + redirects to `/chat/aktif`). +- Old `/chat/history` route + `bestie_history_screen.dart` + + `bestie_history_provider.dart` deleted. +- `/chat/history/:sessionId` (read-only transcript) renamed to + **`/chat/transcript/:sessionId`** so no route lives under the retired + `/chat/history` parent. All inbound `context.push('/chat/history/...')` + updated. + +Bottom-nav red-dot tap behavior: the chat tab still calls +`context.go('/chat')` (no special-case for the red dot). The user lands on +the default `aktif` tab. FCM payment-pending pushes (if/when wired) target +`/chat/pembayaran` directly. + +## 10.4 Sub-tab content & item model + +### aktif +- Backed by existing `/api/client/chat/session/active-with-unread` (already + wired via `activeSessionProvider`). No new endpoint. +- Always renders the active session even when the user is currently inside + the chat room (per decision §10.6 below). +- Voice-call sessions (`mode='call'`) render with a small **📞 Call** pill in + the same row (consistent with Stage 6.0 header-badge convention). +- Empty state copy: `belum ada chat di sini`. + +### pembayaran +- Backed by **new** `GET /api/client/payment-sessions/pending` (§10.7). +- Two row kinds (preview copy differentiates): + - Initial-session: `menunggu pembayaran sesi` + - Extension: `menunggu pembayaran perpanjangan` +- Amber `bayar Rp X.XXX` chip per Figma. +- Empty state: `belum ada pembayaran tertunda`. + +### selesai +- Backed by existing `GET /api/client/history` — switch from offset (`page`) + to cursor pagination (§10.7) and rename param. +- Per-item: `mins` (duration), preview = closing message (mitra's if present, + else customer's), relative timestamp. +- Empty state: `belum ada riwayat curhat`. + +## 10.5 Badges + +| Surface | Trigger | Visual | +|---|---|---| +| Bottom-nav `chat` tab | `pembayaran` count > 0 | Red dot (no number) | +| `aktif` sub-tab pill | Unread message count > 0 | Numeric badge (uses existing `unread_count` from `active-with-unread`) | +| `pembayaran` sub-tab pill | Pending payment count > 0 | Numeric badge (count from `/payments/pending`) | +| `selesai` sub-tab pill | — | **No badge** (overrides the Figma count pill) | + +Bottom-nav red-dot data source: piggy-back on the same +`/api/client/payment-sessions/pending` call (its `total` field). Polled when the +`HaloTabBar` host screen mounts; refreshed by riverpod invalidation when a +payment is created or completed. + +## 10.6 Decisions baked in + +1. **Aktif always shows the live session.** Even when the user is on the chat + screen, the row stays in `aktif` — it represents state, not navigation. +2. **Voice-call sessions live in the same list with a Call pill.** Per memory + `project_phase4_chat_ux_improvements` and Stage 6.0. +3. **Pembayaran TTL reuses existing `payment_session_timeout_minutes`.** + Payment is still mocked (per memory `project_pricing_still_mocked_3_7`); + real Xendit is not wired yet. The `app_config.payment_session_timeout_minutes` + row (default `20`) already drives `expires_at` on `payment_sessions` rows + via `createPaymentSession`. The Figma "pembayaran kedaluwarsa" 20-min copy + already matches the default — no new app_config row needed for Stage 10. + When real Xendit lands, the same value is reused for the invoice TTL. +4. **Max 1 active session.** Aligns with existing pairing constraint; no + backend change. + +## 10.7 Backend changes + +### 10.7.1 New: `GET /api/client/payment-sessions/pending` +Returns pending initial-session + extension payment sessions for the +authenticated customer (not yet paid, not yet expired). + +Query: `payment_sessions WHERE customer_id = $1 AND status = 'pending' AND +expires_at > NOW() ORDER BY created_at DESC`. `is_extension` drives the row +kind. For extension rows, the originating `chat_sessions` row is joined for +mitra info; for initial rows, mitra info is null until pairing happens. + +``` +GET /api/client/payment-sessions/pending +→ 200 { + success: true, + data: { + items: [ + { + id: "pay_…", // payment_sessions.id + kind: "initial" | "extension", + mitra_id: "…" | null, // populated only for extension rows + mitra_display_name: "kak Dimas" | null, + amount: 2500, + duration_minutes: 30, + mode: "chat" | "call", + created_at: "…", + expires_at: "…" // already = created_at + payment_session_timeout_minutes + } + ], + total: 1 // drives the bottom-nav red dot + } + } +``` + +Service: `getCustomerPendingPayments(customerId)` (new fn) in +`payment.service.js`. The existing `expireStalePaymentSessions` sweeper + +inline expiry check in `confirmPaymentSession` already covers the TTL flip; +the endpoint just filters `expires_at > NOW()` defensively in case the +sweeper hasn't run yet for a stale row. + +### 10.7.2 Modify: `GET /api/client/history` → cursor pagination +- Add `cursor` (opaque, base64 of `ended_at + id`) and `limit` (default 20, + max 50) query params. +- Response shape changes from `{ items, total, page, limit }` to + `{ items, next_cursor, has_more }`. +- `total` removed; if a future UI needs it, expose a separate `/count` + endpoint. +- `bestie_history_provider` is deleted along with the screen — the new + `selesai_history_provider` uses cursor pagination on this endpoint. + +### 10.7.3 Reuse: `GET /api/client/chat/session/active-with-unread` +No changes. The `aktif` tab calls this directly. + +### 10.7.4 No new sweeper needed +The existing `expireStalePaymentSessions` already flips +`pending → expired` past `expires_at`. The Pembayaran query filters on +`expires_at > NOW()` to handle the gap between TTL expiry and the next +sweep tick, so no additional sweeper is needed for Stage 10. + +## 10.8 Mermaid flow update (`flow_customer.mermaid.md`) +Add a `subgraph chat_tab` after the home subgraph: + +``` +chat_tab + chat_tab.entry — tap "💬 chat" in HaloTabBar + chat_tab.aktif — active session row → resume chat + chat_tab.pembayaran — pending payment row → resume Xendit + chat_tab.selesai — past session row → transcript + chat_tab.empty.{aktif,pembayaran,selesai} — empty states +``` + +Edges: +- `home_* → chat_tab.entry` (from any home variant) +- `chat_tab.aktif → S10_chat_room` (existing) +- `chat_tab.pembayaran → S7_waiting_payment` (Stage 3.5) +- `chat_tab.selesai → S_transcript` (existing read-only transcript) + +(Wording above is a description — final mermaid syntax added during the +implementation commit.) + +## 10.9 Flutter file changes (preview) +- New: `client_app/lib/features/chat_tab/screens/chat_tab_shell.dart` — the + shared scaffold (heading + sub-tab pills + body slot) rendered by all + three sub-tab paths via `ShellRoute`. +- New: `client_app/lib/features/chat_tab/screens/{aktif_view.dart,pembayaran_view.dart,selesai_view.dart}` — the body of each sub-tab. +- New: `client_app/lib/features/chat_tab/widgets/{chat_row.dart,sub_tab_pill.dart}` +- New: `client_app/lib/features/chat_tab/providers/{pending_payments_provider.dart,selesai_history_provider.dart}` +- Delete: `client_app/lib/features/home/providers/bestie_history_provider.dart` +- Delete: `client_app/lib/features/chat/screens/bestie_history_screen.dart` + (or wherever it lives — confirm during code stage) +- Modify: `client_app/lib/features/home/widgets/halo_tab_bar.dart` — change + `/chat/history` → `/chat`; add red-dot rendering driven by + `pendingPaymentsProvider.total`. +- Modify: `client_app/lib/router.dart`: + - Add `/chat` (redirect → `/chat/aktif`), `/chat/aktif`, `/chat/pembayaran`, + `/chat/selesai` (wrapped in a single `ShellRoute`). + - Rename `/chat/history/:sessionId` → `/chat/transcript/:sessionId`. + - Remove `/chat/history`. +- Modify: any caller that does `context.push('/chat/history/$id')` for the + transcript — grep and update to `/chat/transcript/$id`. + +## 10.10 Out of scope (this stage) +- **Failed-payment retry from the list.** Pembayaran only shows + not-yet-paid + not-yet-expired. Failed/expired surface via the existing + S6/S7 "pembayaran kedaluwarsa" screen on direct payment-flow re-entry, not + the list. +- **Refund / dispute states.** No row kind for these. +- **Search / filter** in `selesai`. +- **Concurrent active sessions.** Aktif is 0-or-1 by backend constraint. +- **Voice-call as separate sub-tab.** Lives in the same list with a Call pill. + +## 10.11 Acceptance for Stage 10 +1. Tapping `💬 chat` navigates to `/chat`, which redirects to `/chat/aktif`. + Direct navigation to `/chat/pembayaran` or `/chat/selesai` lands on the + correct tab. Tapping a sub-tab pill updates the URL accordingly. +2. With an active session: row appears in `aktif`, tap → live chat room with + composer focused. Returning to `/chat` keeps the row visible. +3. With a pending initial-session payment: row appears in `pembayaran` with + `bayar Rp X.XXX` chip; tap → Stage 3.5 waiting-payment screen. +4. With a pending extension payment: row appears in `pembayaran` with the + extension preview copy; tap → extension payment screen. +5. After 20 minutes without payment: row disappears from `pembayaran`; the + "pembayaran kedaluwarsa" screen shows on re-entry (Stage 3.6 behavior + unchanged). +6. Bottom-nav `💬 chat` shows a red dot iff `pembayaran` total > 0. +7. `aktif` sub-tab pill shows unread count when > 0. +8. `pembayaran` sub-tab pill shows pending count when > 0. +9. `selesai` sub-tab pill shows no badge regardless. +10. `selesai` scrolls past 20 items via cursor pagination without duplicates + or gaps. +11. Voice-call sessions render with the 📞 Call pill in both `aktif` and + `selesai`. +12. `/chat/history` is gone from `router.dart`; `/chat/history/:sessionId` is + renamed to `/chat/transcript/:sessionId`; no dead inbound `context.push` + references remain. +13. Maestro: new flow `09_chat_tab.yaml` covering aktif → resume, + pembayaran → payment, selesai → transcript. +14. Backend tests cover `getCustomerPendingPayments` (initial only, + extension only, mixed, expired excluded) and the new cursor-paginated + `getCustomerHistory`. + +--- + # Resolved Decisions (2026-05-09 — recorded from product review) | # | Decision |