Compare commits
11 Commits
8c212cb464
...
ccc52a5c3c
| Author | SHA1 | Date | |
|---|---|---|---|
| ccc52a5c3c | |||
| 862fc35a40 | |||
| d454fd39db | |||
| 14b5cc966b | |||
| f170d54535 | |||
| 7ae8f33b2c | |||
| 706149c75e | |||
| 2645bcd0e5 | |||
| 4680c36e34 | |||
| d33d4419ea | |||
| 4ada7c991a |
@@ -10,6 +10,7 @@ import { internalConfigRoutes } from './routes/internal/config.routes.js'
|
||||
import { sessionManagementRoutes } from './routes/internal/session.routes.js'
|
||||
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
|
||||
import { failedPairingsRoutes } from './routes/internal/failed-pairings.routes.js'
|
||||
import { internalTestRoutes } from './routes/internal/_test.routes.js'
|
||||
import { errorHandler } from './plugins/error-handler.js'
|
||||
|
||||
export const buildInternalApp = async () => {
|
||||
@@ -38,5 +39,10 @@ export const buildInternalApp = async () => {
|
||||
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
|
||||
app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' })
|
||||
|
||||
// Dev/test-only — never registered in production builds.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.register(internalTestRoutes, { prefix: '/internal/_test' })
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ import { sharedAuthRoutes } from './routes/public/shared.auth.routes.js'
|
||||
import { clientAuthRoutes } from './routes/public/client.auth.routes.js'
|
||||
import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js'
|
||||
import { sharedConfigRoutes } from './routes/public/shared.config.routes.js'
|
||||
import { sharedAuthProvidersRoutes } from './routes/public/shared.auth-providers.routes.js'
|
||||
import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js'
|
||||
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
||||
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
||||
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
|
||||
import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js'
|
||||
import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
|
||||
import { clientSupportRoutes } from './routes/public/client.support.routes.js'
|
||||
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
||||
import { errorHandler } from './plugins/error-handler.js'
|
||||
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
|
||||
@@ -24,6 +27,7 @@ export const buildPublicApp = async () => {
|
||||
|
||||
app.register(sharedAuthRoutes, { prefix: '/api/shared/auth' })
|
||||
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
|
||||
app.register(sharedAuthProvidersRoutes, { prefix: '/api/shared/auth-providers' })
|
||||
app.register(sharedChatRoutes, { prefix: '/api/shared' })
|
||||
app.register(clientAuthRoutes, { prefix: '/api/client/auth' })
|
||||
app.register(mitraAuthRoutes, { prefix: '/api/mitra/auth' })
|
||||
@@ -32,6 +36,10 @@ export const buildPublicApp = async () => {
|
||||
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
|
||||
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
|
||||
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
|
||||
// Phase 4: onboarding-state + support handles. Both are tiny so they live in their
|
||||
// own files rather than bloating client.auth.routes / shared.config.routes.
|
||||
app.register(clientOnboardingRoutes, { prefix: '/api/client' })
|
||||
app.register(clientSupportRoutes, { prefix: '/api/client' })
|
||||
|
||||
// WebSocket route (registered at app level, not prefixed)
|
||||
registerWebSocketRoute(app)
|
||||
|
||||
@@ -48,11 +48,20 @@ export const ExtensionStatus = Object.freeze({
|
||||
|
||||
// Customer transaction types
|
||||
export const TransactionType = Object.freeze({
|
||||
FREE_TRIAL: 'free_trial',
|
||||
// Phase 4: replaces FREE_TRIAL. Eligibility = phone-verified + no completed sessions
|
||||
// + first_session_discount_enabled. Discounted price comes from app_config, not 0.
|
||||
FIRST_SESSION_DISCOUNT: 'first_session_discount',
|
||||
PAID: 'paid',
|
||||
EXTENSION: 'extension',
|
||||
})
|
||||
|
||||
// Mode of a chat/payment session — chat (default) or voice call. Voice call is just
|
||||
// chat with a different price group + a header badge; no extra media handling.
|
||||
export const SessionMode = Object.freeze({
|
||||
CHAT: 'chat',
|
||||
CALL: 'call',
|
||||
})
|
||||
|
||||
// Payment session lifecycle
|
||||
export const PaymentSessionStatus = Object.freeze({
|
||||
PENDING: 'pending',
|
||||
@@ -144,6 +153,9 @@ export const WsMessage = Object.freeze({
|
||||
|
||||
// Session lifecycle
|
||||
SESSION_TIMER: 'session_timer',
|
||||
// Phase 4: in-session early warning. Currently fires once at 3 minutes left ("kind:
|
||||
// three_minutes_left"). Customer-only — mitra has no countdown UI.
|
||||
SESSION_WARNING: 'session_warning',
|
||||
SESSION_EXPIRED: 'session_expired',
|
||||
SESSION_CLOSING: 'session_closing',
|
||||
SESSION_COMPLETED: 'session_completed',
|
||||
|
||||
@@ -549,6 +549,111 @@ const migrate = async () => {
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
// --- Phase 4 — Customer Flow Redesign ---
|
||||
|
||||
// 1. payment_sessions + chat_sessions: replace is_free_trial with is_first_session_discount.
|
||||
// Phase 3.7 was the first ship of is_free_trial and never went live with real users
|
||||
// (per project memory), so we copy whatever values exist and drop the old column.
|
||||
// Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the
|
||||
// old column still existing.
|
||||
await sql`
|
||||
ALTER TABLE payment_sessions
|
||||
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
|
||||
`
|
||||
await sql`
|
||||
ALTER TABLE chat_sessions
|
||||
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
|
||||
`
|
||||
|
||||
// Copy values from the legacy column to the new one. We must use dynamic SQL
|
||||
// (EXECUTE) inside the DO block — a static reference to is_free_trial would fail
|
||||
// to parse when the column has already been dropped on a previous re-run.
|
||||
//
|
||||
// The IF EXISTS check resolves the column against the *current* search_path so
|
||||
// test schemas don't false-positive on the dev `public` schema's leftover columns.
|
||||
// We use to_regclass + pg_attribute (which is search_path-aware) instead of
|
||||
// information_schema.columns (which lists every schema).
|
||||
await sql`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = to_regclass('payment_sessions')
|
||||
AND attname = 'is_free_trial'
|
||||
AND NOT attisdropped
|
||||
) THEN
|
||||
EXECUTE 'UPDATE payment_sessions
|
||||
SET is_first_session_discount = is_free_trial
|
||||
WHERE is_free_trial = true
|
||||
AND is_first_session_discount = false';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = to_regclass('chat_sessions')
|
||||
AND attname = 'is_free_trial'
|
||||
AND NOT attisdropped
|
||||
) THEN
|
||||
EXECUTE 'UPDATE chat_sessions
|
||||
SET is_first_session_discount = is_free_trial
|
||||
WHERE is_free_trial = true
|
||||
AND is_first_session_discount = false';
|
||||
END IF;
|
||||
END
|
||||
$$
|
||||
`
|
||||
|
||||
await sql`ALTER TABLE payment_sessions DROP COLUMN IF EXISTS is_free_trial`
|
||||
await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial`
|
||||
|
||||
// 2. payment_sessions.mode — chat (default) vs voice call. Voice call is just chat
|
||||
// with a different price group + a header badge; no extra media handling.
|
||||
await sql`
|
||||
ALTER TABLE payment_sessions
|
||||
ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
|
||||
CHECK (mode IN ('chat', 'call'))
|
||||
`
|
||||
|
||||
// 3. chat_sessions.topics — ESP picks persisted for info-only display to mitra.
|
||||
// Does NOT affect matching, pricing, or routing.
|
||||
await sql`
|
||||
ALTER TABLE chat_sessions
|
||||
ADD COLUMN IF NOT EXISTS topics TEXT[]
|
||||
`
|
||||
|
||||
// 4. Phase 4 app_config rows. Use ON CONFLICT (key) DO NOTHING so re-runs don't
|
||||
// clobber operator edits, and the migration is idempotent against partially
|
||||
// populated DBs.
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value) VALUES
|
||||
('payment_method_qris_first', ${sql.json({ value: true })}),
|
||||
('searching_timeout_minutes', ${sql.json({ value: 5 })}),
|
||||
('end_session_two_step_confirm', ${sql.json({ value: true })}),
|
||||
('three_minute_warning_enabled', ${sql.json({ value: true })}),
|
||||
('first_session_discount_enabled', ${sql.json({ value: true })}),
|
||||
('first_session_discount_actual_price_idr', ${sql.json({ value: 2000 })}),
|
||||
('first_session_discount_gimmick_price_idr', ${sql.json({ value: 12000 })}),
|
||||
('first_session_discount_duration_minutes', ${sql.json({ value: 12 })}),
|
||||
('first_session_discount_modes', ${sql.json({ value: ['chat'] })}),
|
||||
('pricing_chat_tiers_json', ${sql.json({ tiers: [
|
||||
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
|
||||
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
{ id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||
{ id: '60', minutes: 60, price_idr: 45000, tag: null },
|
||||
{ id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||
]})}),
|
||||
('pricing_call_tiers_json', ${sql.json({ tiers: [
|
||||
{ id: '10', minutes: 10, price_idr: 9000, tag: null },
|
||||
{ id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||
{ id: '45', minutes: 45, price_idr: 35000, tag: null },
|
||||
{ id: '60', minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||||
]})}),
|
||||
('support_handles_json', ${sql.json({
|
||||
wa: { label: 'WhatsApp', deeplink: 'https://wa.me/6285173310010' },
|
||||
telegram: { label: 'Telegram', deeplink: 'https://t.me/halobestie' },
|
||||
})})
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`
|
||||
|
||||
console.log('Migration complete.')
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
200
backend/src/routes/internal/_test.routes.js
Normal file
200
backend/src/routes/internal/_test.routes.js
Normal file
@@ -0,0 +1,200 @@
|
||||
// Dev/test-only routes. Registration in app.internal.js is gated on
|
||||
// NODE_ENV !== 'production' so these endpoints never exist in prod builds.
|
||||
//
|
||||
// Used by Maestro flows + curl harnesses to read state that the OTP stub
|
||||
// keeps in memory (the stub-generated code per phone), without baking
|
||||
// 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 { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
import { PairingFailureCause, SessionStatus } from '../../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
export const internalTestRoutes = async (fastify) => {
|
||||
fastify.get('/peek-otp', async (request, reply) => {
|
||||
const phone = request.query?.phone
|
||||
if (!phone) {
|
||||
return reply.code(400).send({ error: 'phone query param required' })
|
||||
}
|
||||
const entry = peekStubOtp(phone)
|
||||
if (!entry) {
|
||||
return reply.code(404).send({ error: 'no_otp_for_phone', phone })
|
||||
}
|
||||
return entry
|
||||
})
|
||||
|
||||
// Wipe rate-limit + cooldown state for a phone so flows can re-run quickly.
|
||||
// Deletes otp_requests rows for the phone and (optionally) the customer row
|
||||
// so identity-upgrade flows start fresh.
|
||||
fastify.post('/reset-phone', async (request, reply) => {
|
||||
const phone = request.body?.phone
|
||||
if (!phone) {
|
||||
return reply.code(400).send({ error: 'phone required in body' })
|
||||
}
|
||||
const dropCustomer = request.body?.drop_customer === true
|
||||
await sql`DELETE FROM otp_requests WHERE phone = ${phone}`
|
||||
if (dropCustomer) {
|
||||
await sql`DELETE FROM customers WHERE phone = ${phone}`
|
||||
}
|
||||
return { ok: true, phone, dropped_customer: dropCustomer }
|
||||
})
|
||||
|
||||
// Force-expire a `pending` payment session (used by Maestro Stage 3 flow to
|
||||
// drive the waiting-payment screen into the expired state without waiting
|
||||
// 20 minutes). Sets `expires_at` to the past and status to `expired` so the
|
||||
// next poll from the client sees the terminal state.
|
||||
//
|
||||
// Body shape:
|
||||
// { payment_id: '<uuid>' } → expire this specific session
|
||||
// { latest: true } → expire the most-recently-created pending
|
||||
fastify.post('/force-expire-payment', async (request, reply) => {
|
||||
const { payment_id, latest } = request.body ?? {}
|
||||
let target
|
||||
if (latest === true) {
|
||||
const [row] = await sql`
|
||||
SELECT id FROM payment_sessions
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
if (!row) {
|
||||
return reply.code(404).send({ error: 'no_pending_payment' })
|
||||
}
|
||||
target = row.id
|
||||
} else if (payment_id) {
|
||||
target = payment_id
|
||||
} else {
|
||||
return reply.code(400).send({ error: 'payment_id or latest:true required in body' })
|
||||
}
|
||||
const [updated] = await sql`
|
||||
UPDATE payment_sessions
|
||||
SET status = 'expired', expires_at = NOW() - INTERVAL '1 minute'
|
||||
WHERE id = ${target} AND status = 'pending'
|
||||
RETURNING id, status
|
||||
`
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: 'no_pending_payment_for_id', payment_id: target })
|
||||
}
|
||||
return { ok: true, ...updated }
|
||||
})
|
||||
|
||||
// Force-expire a pairing blast (used by Maestro Stage 5 flow to drive the
|
||||
// searching screen into the timeout state without waiting 5 minutes). Marks
|
||||
// the most-recently-created blast chat_session as no_mitra_available.
|
||||
//
|
||||
// Body shape:
|
||||
// { session_id: '<uuid>' } → expire this specific session
|
||||
// { latest: true } → expire the most-recent SEARCHING session
|
||||
fastify.post('/force-pairing-timeout', async (request, reply) => {
|
||||
const { session_id, latest } = request.body ?? {}
|
||||
let target = session_id
|
||||
if (latest === true) {
|
||||
const [row] = await sql`
|
||||
SELECT id FROM chat_sessions
|
||||
WHERE status = ${SessionStatus.SEARCHING}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
if (!row) {
|
||||
return reply.code(404).send({ error: 'no_searching_session' })
|
||||
}
|
||||
target = row.id
|
||||
}
|
||||
if (!target) {
|
||||
return reply.code(400).send({ error: 'session_id or latest:true required in body' })
|
||||
}
|
||||
await expirePairingRequest(target, PairingFailureCause.NO_MITRA_AVAILABLE)
|
||||
return { ok: true, session_id: target }
|
||||
})
|
||||
|
||||
// Force-set the expires_at of an active chat_session to drive Phase 4
|
||||
// Stage 6 countdown UX (3-min snackbar, last-2-min danger, expired banner)
|
||||
// without waiting in real time. Reschedules the in-memory session timer so
|
||||
// `session_warning` / `session_timer` / `session_expired` WS events fire on
|
||||
// the new schedule.
|
||||
//
|
||||
// Body shape:
|
||||
// { seconds_from_now: 175 } → expire latest active session in N seconds
|
||||
// { session_id: '<uuid>', seconds_from_now } → expire specific session
|
||||
fastify.post('/force-session-expires-at', async (request, reply) => {
|
||||
const { session_id, seconds_from_now } = request.body ?? {}
|
||||
if (typeof seconds_from_now !== 'number') {
|
||||
return reply.code(400).send({ error: 'seconds_from_now (number) required' })
|
||||
}
|
||||
let target = session_id
|
||||
if (!target) {
|
||||
const [row] = await sql`
|
||||
SELECT id FROM chat_sessions
|
||||
WHERE status = ${SessionStatus.ACTIVE}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
if (!row) {
|
||||
return reply.code(404).send({ error: 'no_active_session' })
|
||||
}
|
||||
target = row.id
|
||||
}
|
||||
const [updated] = await sql`
|
||||
UPDATE chat_sessions
|
||||
SET expires_at = NOW() + (${seconds_from_now} || ' seconds')::interval
|
||||
WHERE id = ${target} AND status = ${SessionStatus.ACTIVE}
|
||||
RETURNING id, expires_at
|
||||
`
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: 'no_active_session_for_id', session_id: target })
|
||||
}
|
||||
// Allow the 3-min warning to fire again on the new schedule.
|
||||
_resetThreeMinFiredForTest(updated.id)
|
||||
startSessionTimer(updated.id, updated.expires_at)
|
||||
// Push an immediate WS resync so the customer UI's local ticker tracks
|
||||
// the new schedule without waiting for the next scheduled event.
|
||||
_broadcastTimerResyncForTest(updated.id, updated.expires_at)
|
||||
return { ok: true, session_id: updated.id, expires_at: updated.expires_at }
|
||||
})
|
||||
|
||||
// Seed a completed chat_sessions row for the customer linked to `phone`,
|
||||
// pairing them with the most-recent online mitra. Used by Maestro Stage 8
|
||||
// flow (08_returning_targeted.yaml) so the bestie history list isn't empty.
|
||||
//
|
||||
// Body shape:
|
||||
// { phone: '+62...' } — the customer; mitra is auto-picked.
|
||||
fastify.post('/seed-history-session', async (request, reply) => {
|
||||
const phone = request.body?.phone
|
||||
if (!phone) {
|
||||
return reply.code(400).send({ error: 'phone required in body' })
|
||||
}
|
||||
const [customer] = await sql`
|
||||
SELECT id FROM customers WHERE phone = ${phone} LIMIT 1
|
||||
`
|
||||
if (!customer) {
|
||||
return reply.code(404).send({ error: 'no_customer_for_phone', phone })
|
||||
}
|
||||
const [mitra] = await sql`
|
||||
SELECT m.id, m.display_name FROM mitras m
|
||||
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
|
||||
WHERE s.is_online = true
|
||||
ORDER BY s.last_heartbeat_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
`
|
||||
if (!mitra) {
|
||||
return reply.code(404).send({ error: 'no_online_mitra' })
|
||||
}
|
||||
const [session] = await sql`
|
||||
INSERT INTO chat_sessions (
|
||||
customer_id, mitra_id, status, topic_sensitivity, topics,
|
||||
created_at, paired_at, ended_at, duration_minutes, price
|
||||
) VALUES (
|
||||
${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 'regular',
|
||||
${sql.array(['hubungan'])},
|
||||
NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day',
|
||||
NOW() - INTERVAL '1 day' + INTERVAL '15 minutes',
|
||||
15, 30000
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name }
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
|
||||
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
||||
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
||||
getFirstSessionDiscountConfig, setFirstSessionDiscountConfig,
|
||||
getSupportHandles, setSupportHandles,
|
||||
getPricingTierGroups, setPricingTierGroup,
|
||||
} from '../../services/config.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
@@ -284,4 +287,104 @@ export const internalConfigRoutes = async (app) => {
|
||||
await publishConfigInvalidate('pairing_blast_timeout_seconds')
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 4: First-session discount ---
|
||||
app.get('/first-session-discount', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
return reply.send({ success: true, data: await getFirstSessionDiscountConfig() })
|
||||
})
|
||||
|
||||
app.patch('/first-session-discount', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes } = request.body ?? {}
|
||||
const patch = {}
|
||||
if (enabled !== undefined) {
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'enabled must be a boolean' } })
|
||||
}
|
||||
patch.enabled = enabled
|
||||
}
|
||||
for (const [field, value] of [
|
||||
['actual_price_idr', actual_price_idr],
|
||||
['gimmick_price_idr', gimmick_price_idr],
|
||||
['duration_minutes', duration_minutes],
|
||||
]) {
|
||||
if (value !== undefined) {
|
||||
if (typeof value !== 'number' || value < 0 || !Number.isFinite(value)) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: `${field} must be a non-negative number` } })
|
||||
}
|
||||
patch[field] = Math.round(value)
|
||||
}
|
||||
}
|
||||
if (modes !== undefined) {
|
||||
if (!Array.isArray(modes) || modes.some((m) => m !== 'chat' && m !== 'call')) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'modes must be an array of "chat" | "call"' } })
|
||||
}
|
||||
patch.modes = modes
|
||||
}
|
||||
const config = await setFirstSessionDiscountConfig(patch)
|
||||
await publishConfigInvalidate('first_session_discount')
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 4: Pricing tier groups (chat / call) ---
|
||||
app.get('/pricing-tiers', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
return reply.send({ success: true, data: await getPricingTierGroups() })
|
||||
})
|
||||
|
||||
app.patch('/pricing-tiers/:mode', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const mode = request.params.mode
|
||||
if (mode !== 'chat' && mode !== 'call') {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' } })
|
||||
}
|
||||
const { tiers } = request.body ?? {}
|
||||
if (!Array.isArray(tiers) || tiers.length === 0) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'tiers must be a non-empty array' } })
|
||||
}
|
||||
for (const t of tiers) {
|
||||
if (
|
||||
typeof t.id !== 'string'
|
||||
|| typeof t.minutes !== 'number' || t.minutes <= 0
|
||||
|| typeof t.price_idr !== 'number' || t.price_idr < 0
|
||||
) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'each tier needs id (string), minutes (number > 0), price_idr (number >= 0)' } })
|
||||
}
|
||||
}
|
||||
const config = await setPricingTierGroup(mode, tiers)
|
||||
await publishConfigInvalidate(`pricing_${mode}_tiers_json`)
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
app.get('/support-handles', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
return reply.send({ success: true, data: await getSupportHandles() })
|
||||
})
|
||||
|
||||
app.patch('/support-handles', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { wa, telegram } = request.body ?? {}
|
||||
const validateHandle = (h, name) => {
|
||||
if (h === undefined) return null
|
||||
if (typeof h !== 'object' || h === null) return `${name} must be an object`
|
||||
if (h.label !== undefined && typeof h.label !== 'string') return `${name}.label must be a string`
|
||||
if (h.deeplink !== undefined && typeof h.deeplink !== 'string') return `${name}.deeplink must be a string`
|
||||
return null
|
||||
}
|
||||
for (const [name, value] of [['wa', wa], ['telegram', telegram]]) {
|
||||
const err = validateHandle(value, name)
|
||||
if (err) return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: err } })
|
||||
}
|
||||
const config = await setSupportHandles({ wa, telegram })
|
||||
await publishConfigInvalidate('support_handles_json')
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ const resolveCustomer = async (request, reply) => {
|
||||
}
|
||||
|
||||
export const clientChatRoutes = async (app) => {
|
||||
// Get pricing tiers + free trial eligibility
|
||||
// Get chat + call pricing tiers + first-session-discount eligibility (per-customer).
|
||||
// Phase 4 reshape — tiers come from `app_config.pricing_{chat,call}_tiers_json` and
|
||||
// discount eligibility is the AND of: phone-verified + no completed sessions +
|
||||
// first_session_discount_enabled.
|
||||
app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const pricing = await getPricingForCustomer(request.customer.id)
|
||||
return reply.send({ success: true, data: pricing })
|
||||
@@ -171,7 +174,7 @@ export const clientChatRoutes = async (app) => {
|
||||
|
||||
/**
|
||||
* Extension request REQUIRES `extension_payment_session_id`.
|
||||
* The payment session must be is_extension=true and is_free_trial=false.
|
||||
* The payment session must be is_extension=true and is_first_session_discount=false.
|
||||
* Pricing/duration come from the payment session via the extension service.
|
||||
*/
|
||||
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
|
||||
60
backend/src/routes/public/client.onboarding.routes.js
Normal file
60
backend/src/routes/public/client.onboarding.routes.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getCustomerById } from '../../services/customer.service.js'
|
||||
import { isCustomerEligibleForFirstSessionDiscount } from '../../services/pricing.service.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
import { UserType, SessionStatus } from '../../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
/**
|
||||
* Phase 4 onboarding-state endpoint. Drives:
|
||||
* - Verif Choice Sheet visibility on the post-name screen.
|
||||
* - S6 paywall vs Pilih cara routing decision.
|
||||
*
|
||||
* Eligibility predicate (server-authoritative — client never decides):
|
||||
* first_session_discount_enabled AND phone-verified AND no completed sessions.
|
||||
*
|
||||
* NOTE: deviates from the plan's `users.phone_verified_at` reference — there is no
|
||||
* such column. `customers.phone IS NOT NULL` is equivalent in this schema (phone is
|
||||
* only ever set by the OTP-verify path).
|
||||
*/
|
||||
export const clientOnboardingRoutes = async (app) => {
|
||||
app.get('/onboarding-state', { preHandler: authenticate }, async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CUSTOMER) {
|
||||
return reply.code(403).send({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Customer account required' },
|
||||
})
|
||||
}
|
||||
const customer = await getCustomerById(request.auth.userId)
|
||||
if (!customer) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' },
|
||||
})
|
||||
}
|
||||
|
||||
const isPhoneVerified = !!customer.phone
|
||||
|
||||
const [prior] = await sql`
|
||||
SELECT id FROM chat_sessions
|
||||
WHERE customer_id = ${customer.id}
|
||||
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||
LIMIT 1
|
||||
`
|
||||
const hasConsultedBefore = !!prior
|
||||
|
||||
// Use the same predicate the pricing endpoint uses, so the two stay in lock-step.
|
||||
const isFirstSessionDiscountEligible = await isCustomerEligibleForFirstSessionDiscount(customer.id)
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
has_consulted_before: hasConsultedBefore,
|
||||
is_phone_verified: isPhoneVerified,
|
||||
is_first_session_discount_eligible: isFirstSessionDiscountEligible,
|
||||
is_anonymous: !!customer.is_anonymous,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
getPaymentSession,
|
||||
} from '../../services/payment.service.js'
|
||||
import {
|
||||
isCustomerEligibleForFreeTrial,
|
||||
isCustomerEligibleForFirstSessionDiscount,
|
||||
isValidTier,
|
||||
getPriceTiers,
|
||||
findTier,
|
||||
} from '../../services/pricing.service.js'
|
||||
import { UserType } from '../../constants.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
import { UserType, SessionMode } from '../../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const resolveCustomer = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CUSTOMER) {
|
||||
@@ -30,6 +33,25 @@ const resolveCustomer = async (request, reply) => {
|
||||
request.customer = customer
|
||||
}
|
||||
|
||||
const readDiscountConfig = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN (
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes'
|
||||
)
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
return {
|
||||
enabled: byKey.first_session_discount_enabled ?? true,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
|
||||
modes: byKey.first_session_discount_modes ?? ['chat'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment session lifecycle (mocked — no Xendit yet).
|
||||
*
|
||||
@@ -39,12 +61,13 @@ const resolveCustomer = async (request, reply) => {
|
||||
* GET /api/client/payment-sessions/:id
|
||||
*/
|
||||
export const clientPaymentRoutes = async (app) => {
|
||||
// Create a payment session (status = pending). Free-trial logic is server-side: if the
|
||||
// customer is eligible AND this is NOT an extension, amount is forced to 0 and
|
||||
// is_free_trial = true regardless of what the client passes.
|
||||
// Create a payment session (status = pending). First-session-discount is server-authoritative:
|
||||
// if the customer is eligible AND this is NOT an extension AND mode is in the configured
|
||||
// modes list, amount is forced to the configured discount price.
|
||||
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const {
|
||||
duration_minutes,
|
||||
mode = SessionMode.CHAT,
|
||||
targeted_mitra_id = null,
|
||||
is_extension = false,
|
||||
} = request.body ?? {}
|
||||
@@ -55,33 +78,44 @@ export const clientPaymentRoutes = async (app) => {
|
||||
error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' },
|
||||
})
|
||||
}
|
||||
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
|
||||
return reply.code(422).send({
|
||||
success: false,
|
||||
error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' },
|
||||
})
|
||||
}
|
||||
|
||||
// Free trial: never for extensions.
|
||||
let isFreeTrial = false
|
||||
let isFirstSessionDiscount = false
|
||||
let amount
|
||||
|
||||
if (!is_extension) {
|
||||
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
|
||||
const eligible = await isCustomerEligibleForFirstSessionDiscount(request.customer.id)
|
||||
if (eligible) {
|
||||
isFreeTrial = true
|
||||
amount = 0
|
||||
const discount = await readDiscountConfig()
|
||||
// Discount is mode-gated. With default config (modes: ['chat']) call-mode never
|
||||
// gets the discount even if the user is eligible.
|
||||
if (
|
||||
discount.enabled
|
||||
&& discount.modes.includes(mode)
|
||||
&& duration_minutes === discount.duration_minutes
|
||||
) {
|
||||
isFirstSessionDiscount = true
|
||||
amount = discount.actual_price_idr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFreeTrial) {
|
||||
// Resolve amount from the price tiers (duration-keyed). The client passes
|
||||
// duration_minutes; we look up the matching tier to get the canonical price.
|
||||
const tiers = await getPriceTiers()
|
||||
const tier = tiers.find((t) => t.duration_minutes === duration_minutes)
|
||||
if (!isFirstSessionDiscount) {
|
||||
// Resolve amount from the configured tier list for the requested mode.
|
||||
const tier = await findTier({ mode, durationMinutes: duration_minutes })
|
||||
if (!tier) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested duration' },
|
||||
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested mode/duration' },
|
||||
})
|
||||
}
|
||||
amount = tier.price
|
||||
// Sanity check (defense-in-depth) — duration+price should match a known tier.
|
||||
if (!(await isValidTier(duration_minutes, amount))) {
|
||||
amount = tier.price_idr
|
||||
if (!(await isValidTier({ mode, durationMinutes: duration_minutes, priceIdr: amount }))) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
||||
@@ -93,9 +127,10 @@ export const clientPaymentRoutes = async (app) => {
|
||||
customerId: request.customer.id,
|
||||
durationMinutes: duration_minutes,
|
||||
amount,
|
||||
isFreeTrial,
|
||||
isFirstSessionDiscount,
|
||||
isExtension: Boolean(is_extension),
|
||||
targetedMitraId: targeted_mitra_id || null,
|
||||
mode,
|
||||
})
|
||||
|
||||
return reply.code(201).send({
|
||||
@@ -104,8 +139,9 @@ export const clientPaymentRoutes = async (app) => {
|
||||
id: session.id,
|
||||
amount: session.amount,
|
||||
duration_minutes: session.duration_minutes,
|
||||
is_free_trial: session.is_free_trial,
|
||||
is_first_session_discount: session.is_first_session_discount,
|
||||
is_extension: session.is_extension,
|
||||
mode: session.mode,
|
||||
targeted_mitra_id: session.targeted_mitra_id,
|
||||
expires_at: session.expires_at,
|
||||
status: session.status,
|
||||
|
||||
14
backend/src/routes/public/client.support.routes.js
Normal file
14
backend/src/routes/public/client.support.routes.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getSupportHandles } from '../../services/config.service.js'
|
||||
|
||||
/**
|
||||
* Phase 4 — Tanya Admin sheet handles. Sourced from `app_config.support_handles_json`,
|
||||
* editable by CC. Authenticated so unauthenticated callers can't enumerate the
|
||||
* support channels (rate-limit hardening, not a secret).
|
||||
*/
|
||||
export const clientSupportRoutes = async (app) => {
|
||||
app.get('/support-handles', { preHandler: authenticate }, async (_request, reply) => {
|
||||
const handles = await getSupportHandles()
|
||||
return reply.send({ success: true, data: handles })
|
||||
})
|
||||
}
|
||||
14
backend/src/routes/public/shared.auth-providers.routes.js
Normal file
14
backend/src/routes/public/shared.auth-providers.routes.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getAuthProviders } from '../../services/auth-providers.service.js'
|
||||
|
||||
/**
|
||||
* GET /api/shared/auth-providers — public, no auth required.
|
||||
*
|
||||
* Tells the client which auth entry points are wired up server-side. The client uses
|
||||
* this to hide Google/Apple buttons when the corresponding OAuth env vars aren't
|
||||
* configured (avoids a "press button → mysterious 500" UX).
|
||||
*/
|
||||
export const sharedAuthProvidersRoutes = async (app) => {
|
||||
app.get('/', async (_request, reply) => {
|
||||
return reply.send({ success: true, data: getAuthProviders() })
|
||||
})
|
||||
}
|
||||
51
backend/src/services/auth-providers.service.js
Normal file
51
backend/src/services/auth-providers.service.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Phase 4 — server-driven auth-provider gating.
|
||||
*
|
||||
* Probes env at module load. The result is captured at boot, NOT on every request:
|
||||
* - matches the ops contract (operators set the env, restart the backend, the flag
|
||||
* flips). In dev this means a backend restart is required after editing .env.
|
||||
* - keeps the endpoint dirt cheap (no DB / env reads on the hot path).
|
||||
*
|
||||
* Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag in client_app:
|
||||
* the client now reads `GET /api/shared/auth-providers` once on cold start and
|
||||
* hides Google/Apple buttons when the corresponding flag is `false`.
|
||||
*/
|
||||
|
||||
const isPresent = (key) => {
|
||||
const v = process.env[key]
|
||||
return typeof v === 'string' && v.trim().length > 0
|
||||
}
|
||||
|
||||
const allPresent = (...keys) => keys.every(isPresent)
|
||||
|
||||
// Snapshot taken at module load. If callers need a live value (rare — only env
|
||||
// hot-reload tooling does), they can call `probeAuthProviders()` directly.
|
||||
export const probeAuthProviders = () => ({
|
||||
google: {
|
||||
enabled: allPresent('GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET'),
|
||||
},
|
||||
apple: {
|
||||
enabled: allPresent(
|
||||
'APPLE_OAUTH_CLIENT_ID',
|
||||
'APPLE_OAUTH_TEAM_ID',
|
||||
'APPLE_OAUTH_KEY_ID',
|
||||
'APPLE_OAUTH_PRIVATE_KEY',
|
||||
),
|
||||
},
|
||||
// Phone OTP is always available — we don't gate it on env. (The OTP stub or the
|
||||
// Fazpass integration is the only thing that varies, but neither prevents the
|
||||
// phone-OTP entry point from being available.)
|
||||
phone: { enabled: true },
|
||||
})
|
||||
|
||||
let cached = null
|
||||
|
||||
export const getAuthProviders = () => {
|
||||
if (!cached) cached = probeAuthProviders()
|
||||
return cached
|
||||
}
|
||||
|
||||
// Test-only: drop the cache so tests that mutate env between cases see the change.
|
||||
export const _resetAuthProvidersCache = () => {
|
||||
cached = null
|
||||
}
|
||||
@@ -35,33 +35,113 @@ export const setMaxCustomersPerMitra = async (value) => {
|
||||
return { max_customers_per_mitra: value }
|
||||
}
|
||||
|
||||
// --- Phase 3 config ---
|
||||
// --- Phase 4: First-session discount (replaces Phase 3 free-trial config) ---
|
||||
|
||||
export const getFreeTrialConfig = async () => {
|
||||
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
||||
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
||||
export const getFirstSessionDiscountConfig = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN (
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_gimmick_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes'
|
||||
)
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
return {
|
||||
enabled: enabledRow?.value?.value ?? false,
|
||||
duration_minutes: durationRow?.value?.value ?? 5,
|
||||
enabled: byKey.first_session_discount_enabled ?? true,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
|
||||
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? 12000,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
|
||||
modes: byKey.first_session_discount_modes ?? ['chat'],
|
||||
}
|
||||
}
|
||||
|
||||
export const setFirstSessionDiscountConfig = async (patch) => {
|
||||
const map = {
|
||||
enabled: 'first_session_discount_enabled',
|
||||
actual_price_idr: 'first_session_discount_actual_price_idr',
|
||||
gimmick_price_idr: 'first_session_discount_gimmick_price_idr',
|
||||
duration_minutes: 'first_session_discount_duration_minutes',
|
||||
modes: 'first_session_discount_modes',
|
||||
}
|
||||
for (const [field, key] of Object.entries(map)) {
|
||||
if (patch[field] === undefined) continue
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES (${key}, ${sql.json({ value: patch[field] })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
return getFirstSessionDiscountConfig()
|
||||
}
|
||||
|
||||
// Back-compat shim — CC settings page still calls /internal/config/free-trial.
|
||||
// Phase 4 routes will be added; until the CC UI is migrated this maps to the new keys.
|
||||
export const getFreeTrialConfig = async () => {
|
||||
const cfg = await getFirstSessionDiscountConfig()
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
duration_minutes: cfg.duration_minutes,
|
||||
}
|
||||
}
|
||||
|
||||
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||
if (enabled !== undefined) {
|
||||
return setFirstSessionDiscountConfig({
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(duration_minutes !== undefined ? { duration_minutes } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
|
||||
export const getSupportHandles = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'support_handles_json'`
|
||||
// Stored shape: { wa: {...}, telegram: {...} }. Fall back to a safe empty payload
|
||||
// so the client renders an empty Tanya Admin sheet rather than crashing.
|
||||
return row?.value ?? {
|
||||
wa: { label: 'WhatsApp', deeplink: '' },
|
||||
telegram: { label: 'Telegram', deeplink: '' },
|
||||
}
|
||||
}
|
||||
|
||||
export const setSupportHandles = async ({ wa, telegram }) => {
|
||||
const current = await getSupportHandles()
|
||||
const next = {
|
||||
wa: { ...current.wa, ...(wa || {}) },
|
||||
telegram: { ...current.telegram, ...(telegram || {}) },
|
||||
}
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('free_trial_enabled', ${sql.json({ value: enabled })}, NOW())
|
||||
VALUES ('support_handles_json', ${sql.json(next)}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
return next
|
||||
}
|
||||
if (duration_minutes !== undefined) {
|
||||
|
||||
// --- Phase 4: Pricing tier groups ---
|
||||
|
||||
export const getPricingTierGroups = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN ('pricing_chat_tiers_json', 'pricing_call_tiers_json')
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]))
|
||||
return {
|
||||
chat: byKey.pricing_chat_tiers_json?.tiers ?? [],
|
||||
call: byKey.pricing_call_tiers_json?.tiers ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export const setPricingTierGroup = async (mode, tiers) => {
|
||||
const key = mode === 'call' ? 'pricing_call_tiers_json' : 'pricing_chat_tiers_json'
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('free_trial_duration_minutes', ${sql.json({ value: duration_minutes })}, NOW())
|
||||
VALUES (${key}, ${sql.json({ tiers })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
return getFreeTrialConfig()
|
||||
return getPricingTierGroups()
|
||||
}
|
||||
|
||||
export const getExtensionTimeoutConfig = async () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ const getExtensionTimeoutAction = async () => {
|
||||
* - belong to this customer
|
||||
* - be in `confirmed` status (not yet consumed)
|
||||
* - have `is_extension = true`
|
||||
* - have `is_free_trial = false` (extensions never use free trial)
|
||||
* - have `is_first_session_discount = false` (extensions never use the first-session discount)
|
||||
*
|
||||
* The payment session is NOT consumed at request time. It is consumed at approval moment
|
||||
* (mitra explicit accept OR auto-approve fires).
|
||||
@@ -83,9 +83,9 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
|
||||
code: 'INVALID_STATE', statusCode: 409,
|
||||
})
|
||||
}
|
||||
if (paySession.is_free_trial) {
|
||||
throw Object.assign(new Error('Free trial is not available for extensions'), {
|
||||
code: 'FREE_TRIAL_NOT_ALLOWED', statusCode: 400,
|
||||
if (paySession.is_first_session_discount) {
|
||||
throw Object.assign(new Error('First-session discount is not available for extensions'), {
|
||||
code: 'FIRST_SESSION_DISCOUNT_NOT_ALLOWED', statusCode: 400,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,17 @@ const generate6DigitCode = () => {
|
||||
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
|
||||
}
|
||||
|
||||
// Dev-only in-memory cache of latest stub OTP per phone, read by the
|
||||
// /internal/_test/peek-otp endpoint to make Maestro flows deterministic
|
||||
// without baking test phone numbers into production code paths.
|
||||
const stubOtpByPhone = new Map()
|
||||
|
||||
export const peekStubOtp = (phone) => stubOtpByPhone.get(phone) ?? null
|
||||
|
||||
const fazpassSendStub = async ({ phone, channel }) => {
|
||||
const reference = `stub_${crypto.randomUUID()}`
|
||||
const code = generate6DigitCode()
|
||||
stubOtpByPhone.set(phone, { code, reference, channel, generated_at: new Date().toISOString() })
|
||||
// Log the code so developers can read it during dev testing.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
|
||||
|
||||
@@ -136,7 +136,7 @@ const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { al
|
||||
/**
|
||||
* General-blast pairing request. Requires a confirmed payment_session_id.
|
||||
*
|
||||
* The duration_minutes / price / is_free_trial values for the chat_session row are
|
||||
* The duration_minutes / price / is_first_session_discount values for the chat_session row are
|
||||
* sourced from the payment session — the client does not dictate pricing here.
|
||||
*
|
||||
* `allowTargetedPayment` is set true by the fallback-to-blast path: the original payment
|
||||
@@ -183,14 +183,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
|
||||
// Create session sourced from the payment session.
|
||||
const [session] = await sql`
|
||||
INSERT INTO chat_sessions (
|
||||
customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id
|
||||
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
|
||||
)
|
||||
VALUES (
|
||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial},
|
||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
|
||||
${resolvedTopic}, ${paymentSessionId}
|
||||
)
|
||||
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at
|
||||
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
|
||||
`
|
||||
|
||||
// Fan out to all available mitras in parallel — DB inserts and notifications are
|
||||
@@ -206,7 +206,7 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
|
||||
request_type: PairingRequestType.GENERAL,
|
||||
created_at: session.created_at,
|
||||
duration_minutes: session.duration_minutes,
|
||||
is_free_trial: session.is_free_trial,
|
||||
is_first_session_discount: session.is_first_session_discount,
|
||||
topic_sensitivity: session.topic_sensitivity,
|
||||
})
|
||||
}))
|
||||
@@ -305,14 +305,14 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
|
||||
// Create session sourced from the payment session, status = pending_acceptance.
|
||||
const [session] = await sql`
|
||||
INSERT INTO chat_sessions (
|
||||
customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id
|
||||
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
|
||||
)
|
||||
VALUES (
|
||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial},
|
||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
|
||||
${resolvedTopic}, ${paymentSessionId}
|
||||
)
|
||||
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at
|
||||
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
|
||||
`
|
||||
|
||||
// Single notification to the targeted mitra
|
||||
@@ -330,7 +330,7 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
|
||||
request_type: PairingRequestType.RETURNING,
|
||||
created_at: session.created_at,
|
||||
duration_minutes: session.duration_minutes,
|
||||
is_free_trial: session.is_free_trial,
|
||||
is_first_session_discount: session.is_first_session_discount,
|
||||
topic_sensitivity: session.topic_sensitivity,
|
||||
confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds,
|
||||
})
|
||||
@@ -399,12 +399,12 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE id = ${sessionId}
|
||||
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at, payment_session_id
|
||||
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_session_id
|
||||
`
|
||||
|
||||
// Record transaction
|
||||
if (activeSession.duration_minutes) {
|
||||
const txType = activeSession.is_free_trial ? TransactionType.FREE_TRIAL : TransactionType.PAID
|
||||
const txType = activeSession.is_first_session_discount ? TransactionType.FIRST_SESSION_DISCOUNT : TransactionType.PAID
|
||||
await sql`
|
||||
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
||||
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
|
||||
@@ -787,7 +787,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
|
||||
SELECT
|
||||
cs.id AS session_id,
|
||||
cs.duration_minutes,
|
||||
cs.is_free_trial,
|
||||
cs.is_first_session_discount,
|
||||
cs.topic_sensitivity,
|
||||
cs.created_at,
|
||||
CASE
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage } from '../constants.js'
|
||||
import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js'
|
||||
import { recordFailure } from './pairing-failure.service.js'
|
||||
import { sendToUser } from '../plugins/websocket.js'
|
||||
import { sendPushNotification } from './notification.service.js'
|
||||
@@ -15,14 +15,19 @@ const getPaymentSessionTimeoutMinutes = async () => {
|
||||
/**
|
||||
* Create a new payment session in `pending` status.
|
||||
* Reads `payment_session_timeout_minutes` from config to compute expires_at.
|
||||
*
|
||||
* Phase 4: `isFirstSessionDiscount` replaces the old `isFreeTrial` flag. Voice-call
|
||||
* mode is a routing/badge thing — the price comes from the call tier group, not from
|
||||
* the mode itself.
|
||||
*/
|
||||
export const createPaymentSession = async ({
|
||||
customerId,
|
||||
durationMinutes,
|
||||
amount,
|
||||
isFreeTrial = false,
|
||||
isFirstSessionDiscount = false,
|
||||
isExtension = false,
|
||||
targetedMitraId = null,
|
||||
mode = SessionMode.CHAT,
|
||||
}) => {
|
||||
if (!customerId) {
|
||||
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||
@@ -33,21 +38,24 @@ export const createPaymentSession = async ({
|
||||
if (typeof amount !== 'number' || amount < 0) {
|
||||
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||
}
|
||||
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
|
||||
throw Object.assign(new Error('mode must be chat or call'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||
}
|
||||
|
||||
const ttlMinutes = await getPaymentSessionTimeoutMinutes()
|
||||
|
||||
const [row] = await sql`
|
||||
INSERT INTO payment_sessions (
|
||||
customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
||||
status, targeted_mitra_id, expires_at
|
||||
customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||
status, targeted_mitra_id, mode, expires_at
|
||||
)
|
||||
VALUES (
|
||||
${customerId}, ${amount}, ${durationMinutes}, ${isFreeTrial}, ${isExtension},
|
||||
${PaymentSessionStatus.PENDING}, ${targetedMitraId},
|
||||
${customerId}, ${amount}, ${durationMinutes}, ${isFirstSessionDiscount}, ${isExtension},
|
||||
${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${mode},
|
||||
NOW() + (${ttlMinutes} || ' minutes')::interval
|
||||
)
|
||||
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||
`
|
||||
|
||||
return row
|
||||
@@ -87,8 +95,8 @@ export const confirmPaymentSession = async (paymentSessionId, customerId) => {
|
||||
UPDATE payment_sessions
|
||||
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW()
|
||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
||||
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||
`
|
||||
if (!updated) {
|
||||
throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
|
||||
@@ -289,8 +297,8 @@ export const expireStalePaymentSessions = async () => {
|
||||
|
||||
export const getPaymentSession = async (id) => {
|
||||
const [row] = await sql`
|
||||
SELECT id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||
SELECT id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||
FROM payment_sessions
|
||||
WHERE id = ${id}
|
||||
`
|
||||
|
||||
@@ -1,75 +1,175 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { SessionStatus } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// Default tiers as fallback
|
||||
const DEFAULT_TIERS = [
|
||||
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
|
||||
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
|
||||
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
|
||||
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
|
||||
{ duration_minutes: 60, price: 150000, label: '60 Menit' },
|
||||
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
|
||||
// Default tiers as fallback (used if app_config row is missing). Match the seed
|
||||
// values in migrate.js so a missing row never breaks pricing in the wild.
|
||||
const DEFAULT_CHAT_TIERS = [
|
||||
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
|
||||
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
{ id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||
{ id: '60', minutes: 60, price_idr: 45000, tag: null },
|
||||
{ id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||
]
|
||||
|
||||
export const getPriceTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'price_tiers'`
|
||||
return row?.value?.tiers ?? DEFAULT_TIERS
|
||||
const DEFAULT_CALL_TIERS = [
|
||||
{ id: '10', minutes: 10, price_idr: 9000, tag: null },
|
||||
{ id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||
{ id: '45', minutes: 45, price_idr: 35000, tag: null },
|
||||
{ id: '60', minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||||
]
|
||||
const DEFAULT_DISCOUNT = {
|
||||
enabled: true,
|
||||
actual_price_idr: 2000,
|
||||
gimmick_price_idr: 12000,
|
||||
duration_minutes: 12,
|
||||
modes: ['chat'],
|
||||
}
|
||||
|
||||
export const isValidTier = async (durationMinutes, price) => {
|
||||
const tiers = await getPriceTiers()
|
||||
return tiers.some(
|
||||
(t) => t.duration_minutes === durationMinutes && t.price === price
|
||||
)
|
||||
const readChatTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
|
||||
return row?.value?.tiers ?? DEFAULT_CHAT_TIERS
|
||||
}
|
||||
|
||||
export const getFreeTrial = async () => {
|
||||
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
||||
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
||||
return {
|
||||
enabled: enabledRow?.value?.value ?? false,
|
||||
duration_minutes: durationRow?.value?.value ?? 5,
|
||||
}
|
||||
const readCallTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
|
||||
return row?.value?.tiers ?? DEFAULT_CALL_TIERS
|
||||
}
|
||||
|
||||
export const isCustomerEligibleForFreeTrial = async (customerId) => {
|
||||
const freeTrial = await getFreeTrial()
|
||||
if (!freeTrial.enabled) return false
|
||||
|
||||
const [tx] = await sql`
|
||||
SELECT id FROM customer_transactions
|
||||
WHERE customer_id = ${customerId}
|
||||
LIMIT 1
|
||||
const readDiscountConfig = async () => {
|
||||
const keys = [
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_gimmick_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes',
|
||||
]
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config WHERE key IN ${sql(keys)}
|
||||
`
|
||||
return !tx // Eligible only if no transactions at all
|
||||
}
|
||||
|
||||
export const getPricingForCustomer = async (customerId) => {
|
||||
const tiers = await getPriceTiers()
|
||||
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
|
||||
const freeTrial = await getFreeTrial()
|
||||
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
return {
|
||||
tiers,
|
||||
free_trial: freeTrialEligible
|
||||
? { eligible: true, duration_minutes: freeTrial.duration_minutes }
|
||||
: { eligible: false },
|
||||
enabled: byKey.first_session_discount_enabled ?? DEFAULT_DISCOUNT.enabled,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? DEFAULT_DISCOUNT.actual_price_idr,
|
||||
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? DEFAULT_DISCOUNT.gimmick_price_idr,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? DEFAULT_DISCOUNT.duration_minutes,
|
||||
modes: byKey.first_session_discount_modes ?? DEFAULT_DISCOUNT.modes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension pricing tiers.
|
||||
* Per-customer first-session-discount eligibility.
|
||||
*
|
||||
* Same shape as `getPricingForCustomer`, but free trial is NEVER eligible for extensions.
|
||||
* The customerId is accepted for API symmetry/future tier personalization.
|
||||
* Predicate (Phase 4):
|
||||
* - app_config.first_session_discount_enabled == true, AND
|
||||
* - customer is phone-verified (customers.phone IS NOT NULL — phone only gets set
|
||||
* via the OTP-verify path, so non-null is proof of verification), AND
|
||||
* - customer has no completed/closing chat_sessions row (returning users pay full price).
|
||||
*
|
||||
* Note: deviates from the plan's `users.phone_verified_at` reference — there is no such
|
||||
* column. `phone IS NOT NULL` is the equivalent invariant in this schema.
|
||||
*/
|
||||
export const isCustomerEligibleForFirstSessionDiscount = async (customerId) => {
|
||||
const discount = await readDiscountConfig()
|
||||
if (!discount.enabled) return false
|
||||
|
||||
const [customer] = await sql`
|
||||
SELECT phone FROM customers WHERE id = ${customerId}
|
||||
`
|
||||
if (!customer || !customer.phone) return false
|
||||
|
||||
const [prior] = await sql`
|
||||
SELECT id FROM chat_sessions
|
||||
WHERE customer_id = ${customerId}
|
||||
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||
LIMIT 1
|
||||
`
|
||||
return !prior
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing payload for the client. Returns chat + call tier groups plus a per-customer
|
||||
* first-session-discount block.
|
||||
*
|
||||
* Shape:
|
||||
* { chat: { tiers: [...] },
|
||||
* call: { tiers: [...] },
|
||||
* first_session_discount: {
|
||||
* eligible: boolean,
|
||||
* actual_price_idr, gimmick_price_idr, duration_minutes, modes: string[]
|
||||
* } }
|
||||
*/
|
||||
export const getPricingForCustomer = async (customerId) => {
|
||||
const [chatTiers, callTiers, discount, eligible] = await Promise.all([
|
||||
readChatTiers(),
|
||||
readCallTiers(),
|
||||
readDiscountConfig(),
|
||||
isCustomerEligibleForFirstSessionDiscount(customerId),
|
||||
])
|
||||
return {
|
||||
chat: { tiers: chatTiers },
|
||||
call: { tiers: callTiers },
|
||||
first_session_discount: {
|
||||
eligible,
|
||||
actual_price_idr: discount.actual_price_idr,
|
||||
gimmick_price_idr: discount.gimmick_price_idr,
|
||||
duration_minutes: discount.duration_minutes,
|
||||
modes: discount.modes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a (mode, duration_minutes, price_idr) selection against the configured tiers.
|
||||
* Used by payment-session creation as a defense-in-depth check.
|
||||
*/
|
||||
export const isValidTier = async ({ mode, durationMinutes, priceIdr }) => {
|
||||
const tiers = mode === 'call' ? await readCallTiers() : await readChatTiers()
|
||||
return tiers.some((t) => t.minutes === durationMinutes && t.price_idr === priceIdr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the canonical tier for (mode, duration_minutes). Returns null if no match.
|
||||
*/
|
||||
export const findTier = async ({ mode, durationMinutes }) => {
|
||||
const tiers = mode === 'call' ? await readCallTiers() : await readChatTiers()
|
||||
return tiers.find((t) => t.minutes === durationMinutes) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension pricing — same chat tiers, but first-session discount NEVER applies.
|
||||
* (Kept for parity with the old pricing.service shape; voice-call extensions are not
|
||||
* a current feature, so we return chat tiers only.)
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export const getExtensionPriceTiers = async (customerId) => {
|
||||
const tiers = await getPriceTiers()
|
||||
const tiers = await readChatTiers()
|
||||
return {
|
||||
tiers,
|
||||
free_trial: { eligible: false },
|
||||
is_free_trial: false,
|
||||
first_session_discount: { eligible: false },
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Back-compat aliases (will be removed after Phase 4 frontend cutover) ----
|
||||
|
||||
/**
|
||||
* @deprecated Use isCustomerEligibleForFirstSessionDiscount.
|
||||
* Kept so route handlers and migrated services still resolve while we cut over.
|
||||
*/
|
||||
export const isCustomerEligibleForFreeTrial = isCustomerEligibleForFirstSessionDiscount
|
||||
|
||||
/**
|
||||
* @deprecated Tiers now live in `chat`/`call` groups; callers should pick one.
|
||||
* Returns chat tiers in the legacy shape (single array, no group wrapper).
|
||||
*/
|
||||
export const getPriceTiers = async () => {
|
||||
const tiers = await readChatTiers()
|
||||
// Legacy callers expected `{duration_minutes, price, label}` keys. Map.
|
||||
return tiers.map((t) => ({
|
||||
duration_minutes: t.minutes,
|
||||
price: t.price_idr,
|
||||
label: `${t.minutes} Menit`,
|
||||
id: t.id,
|
||||
tag: t.tag,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -6,18 +6,61 @@ import { UserType, SessionStatus, WsMessage, EndedBy } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// Active session timers: sessionId → { warningTimeout, expiryTimeout }
|
||||
// Active session timers: sessionId → { threeMinTimeout, warningTimeout, expiryTimeout, threeMinFired }
|
||||
// `threeMinFired` is a per-session idempotency flag — once the 3-min warning has been
|
||||
// emitted for a session it never fires again, even if startSessionTimer is called twice
|
||||
// (e.g. extension reschedule). This matches the Phase 4 spec: "fire once".
|
||||
const sessionTimers = new Map()
|
||||
|
||||
/**
|
||||
* Dev/test-only — clear the per-session "3-min warning already fired" flag so
|
||||
* the warning can fire again after `force-session-expires-at` reschedules a
|
||||
* session backwards. Production code never needs this.
|
||||
*/
|
||||
export const _resetThreeMinFiredForTest = (sessionId) => {
|
||||
const timers = sessionTimers.get(sessionId)
|
||||
if (timers) timers.threeMinFired = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev/test-only — push an immediate WS resync of the timer state so a Maestro
|
||||
* flow can drive the customer UI through the danger pill / expired banner
|
||||
* states without waiting for the next scheduled tick. Production code drives
|
||||
* UX off the scheduled `session_timer` / `session_warning` / `session_expired`
|
||||
* events instead.
|
||||
*/
|
||||
export const _broadcastTimerResyncForTest = (sessionId, expiresAt) => {
|
||||
const remaining = Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
|
||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||
type: WsMessage.SESSION_TIMER,
|
||||
remaining_seconds: remaining,
|
||||
expires_at: expiresAt,
|
||||
session_id: sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
export const startSessionTimer = (sessionId, expiresAt) => {
|
||||
const now = Date.now()
|
||||
const expiresMs = new Date(expiresAt).getTime()
|
||||
const threeMinMs = expiresMs - 180_000 // 3 minutes before expiry (Phase 4)
|
||||
const warningMs = expiresMs - 60_000 // 1 minute before expiry
|
||||
|
||||
// Clear any existing timers
|
||||
// Preserve idempotency flag across reschedules (e.g. extension extends expires_at).
|
||||
const previous = sessionTimers.get(sessionId)
|
||||
const threeMinFired = previous?.threeMinFired ?? false
|
||||
|
||||
// Clear any existing timers (but keep the threeMinFired flag captured above).
|
||||
clearSessionTimer(sessionId)
|
||||
|
||||
const timers = {}
|
||||
const timers = { threeMinFired }
|
||||
|
||||
// 3-min warning timer — Phase 4. Skip if already fired this session, or if the
|
||||
// remaining window is already ≤ 3 min (don't fire belatedly mid-session).
|
||||
if (!threeMinFired && threeMinMs > now) {
|
||||
timers.threeMinTimeout = setTimeout(() => {
|
||||
onThreeMinuteWarning(sessionId)
|
||||
}, threeMinMs - now)
|
||||
}
|
||||
|
||||
// Warning timer (1 min before expiry)
|
||||
if (warningMs > now) {
|
||||
@@ -43,6 +86,7 @@ export const startSessionTimer = (sessionId, expiresAt) => {
|
||||
export const clearSessionTimer = (sessionId) => {
|
||||
const timers = sessionTimers.get(sessionId)
|
||||
if (timers) {
|
||||
if (timers.threeMinTimeout) clearTimeout(timers.threeMinTimeout)
|
||||
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
|
||||
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
|
||||
sessionTimers.delete(sessionId)
|
||||
@@ -69,6 +113,29 @@ const onSessionWarning = (sessionId) => {
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4 — 3-min warning. Customer-only event (mitra has no countdown UI).
|
||||
* Idempotent per session via the `threeMinFired` flag captured by startSessionTimer.
|
||||
*
|
||||
* Includes `remaining_seconds` and `expires_at` so the client can resync its
|
||||
* local ticker against the server's view of when the session ends. The
|
||||
* customer-side ticker drives the last-2-min danger pill + expired banner,
|
||||
* neither of which the server emits a discrete event for.
|
||||
*/
|
||||
const onThreeMinuteWarning = async (sessionId) => {
|
||||
const timers = sessionTimers.get(sessionId)
|
||||
if (timers?.threeMinFired) return // belt-and-braces — should not happen
|
||||
if (timers) timers.threeMinFired = true
|
||||
const [row] = await sql`SELECT expires_at FROM chat_sessions WHERE id = ${sessionId}`
|
||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||
type: WsMessage.SESSION_WARNING,
|
||||
kind: 'three_minutes_left',
|
||||
session_id: sessionId,
|
||||
remaining_seconds: 180,
|
||||
expires_at: row?.expires_at ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
// Grace period timers for auto-completing abandoned sessions
|
||||
const closureGraceTimers = new Map()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const sql = getDb()
|
||||
export const getActiveSessionByCustomer = async (customerId) => {
|
||||
const [session] = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
|
||||
m.display_name AS mitra_display_name
|
||||
FROM chat_sessions cs
|
||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||
@@ -149,15 +149,20 @@ export const listSessions = async ({ page = 1, limit = 20, status, topic_sensiti
|
||||
}
|
||||
|
||||
export const getSessionById = async (sessionId) => {
|
||||
// `mode` lives on payment_sessions (chat | call), introduced in Phase 4.1.
|
||||
// The chat header pill needs it, so surface it on every session.info read.
|
||||
// Falls back to 'chat' for pre-3.7 rows where payment_session_id is null.
|
||||
const [session] = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
|
||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics,
|
||||
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
|
||||
COALESCE(ps.mode, 'chat') AS mode,
|
||||
c.display_name AS customer_display_name,
|
||||
m.display_name AS mitra_display_name
|
||||
FROM chat_sessions cs
|
||||
INNER JOIN customers c ON c.id = cs.customer_id
|
||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
|
||||
WHERE cs.id = ${sessionId}
|
||||
`
|
||||
return session
|
||||
@@ -168,7 +173,7 @@ export const getSessionById = async (sessionId) => {
|
||||
export const getActiveSessionByCustomerWithUnread = async (customerId) => {
|
||||
const [session] = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
|
||||
m.display_name AS mitra_display_name,
|
||||
(SELECT COUNT(*) FROM chat_messages cm
|
||||
WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.MITRA}
|
||||
@@ -202,13 +207,18 @@ export const getActiveSessionsByMitraWithUnread = async (mitraId) => {
|
||||
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.created_at, cs.paired_at, cs.ended_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||
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 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
|
||||
@@ -225,7 +235,7 @@ export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) =>
|
||||
const offset = (page - 1) * limit
|
||||
const items = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
|
||||
c.display_name AS customer_display_name,
|
||||
(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
|
||||
|
||||
@@ -52,7 +52,8 @@ export const resetDbHard = async () => {
|
||||
|
||||
/**
|
||||
* Drop and re-seed the configurable app_config rows back to their canonical defaults.
|
||||
* Tests that mutate config (e.g. flipping free_trial_enabled) call this in afterEach.
|
||||
* Tests that mutate config (e.g. flipping first_session_discount_enabled) call this
|
||||
* in afterEach.
|
||||
*/
|
||||
export const resetAppConfig = async () => {
|
||||
const sql = db()
|
||||
@@ -61,8 +62,6 @@ export const resetAppConfig = async () => {
|
||||
const defaults = [
|
||||
['anonymity', { enabled: false }],
|
||||
['max_customers_per_mitra', { value: 3 }],
|
||||
['free_trial_enabled', { value: true }],
|
||||
['free_trial_duration_minutes', { value: 5 }],
|
||||
['extension_timeout_seconds', { value: 60 }],
|
||||
['early_end_mitra_enabled', { value: false }],
|
||||
['early_end_customer_enabled', { value: false }],
|
||||
@@ -70,6 +69,13 @@ export const resetAppConfig = async () => {
|
||||
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
||||
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
||||
['pairing_blast_timeout_seconds', { value: 60 }],
|
||||
// Phase 4
|
||||
['first_session_discount_enabled', { value: true }],
|
||||
['first_session_discount_actual_price_idr', { value: 2000 }],
|
||||
['first_session_discount_gimmick_price_idr', { value: 12000 }],
|
||||
['first_session_discount_duration_minutes', { value: 12 }],
|
||||
['first_session_discount_modes', { value: ['chat'] }],
|
||||
['three_minute_warning_enabled', { value: true }],
|
||||
]
|
||||
for (const [key, value] of defaults) {
|
||||
await sql`
|
||||
|
||||
98
backend/test/routes/client.chat-pricing.routes.test.js
Normal file
98
backend/test/routes/client.chat-pricing.routes.test.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => false),
|
||||
sendToSessionParticipant: vi.fn(() => false),
|
||||
registerWebSocketPlugin: vi.fn(async () => {}),
|
||||
registerWebSocketRoute: vi.fn(),
|
||||
isUserOnlineWs: vi.fn(() => false),
|
||||
getSessionConnections: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/services/notification.service.js', () => ({
|
||||
sendPushNotification: vi.fn(async () => true),
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const { buildPublic } = await import('../helpers/server.js')
|
||||
const { resetDb, resetAppConfig, db } = await import('../helpers/db.js')
|
||||
const { createCustomer } = await import('../helpers/fixtures.js')
|
||||
const { customerJwt, authHeader } = await import('../helpers/jwt.js')
|
||||
|
||||
describe('GET /api/client/chat/pricing (Phase 4)', () => {
|
||||
let app
|
||||
let customer
|
||||
let token
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetAppConfig()
|
||||
app = await buildPublic()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb()
|
||||
const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}`
|
||||
customer = await createCustomer({ callName: 'PricingTester', phone })
|
||||
token = customerJwt(customer.id)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
it('returns chat + call tier groups and a discount block; eligibility flips when the customer has a completed session', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/client/chat/pricing',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.success).toBe(true)
|
||||
const data = body.data
|
||||
|
||||
// Two tier groups, both non-empty
|
||||
expect(Array.isArray(data.chat?.tiers)).toBe(true)
|
||||
expect(Array.isArray(data.call?.tiers)).toBe(true)
|
||||
expect(data.chat.tiers.length).toBeGreaterThan(0)
|
||||
expect(data.call.tiers.length).toBeGreaterThan(0)
|
||||
|
||||
// Tier shape (chat 12-min should match the seed config)
|
||||
const chat12 = data.chat.tiers.find((t) => t.minutes === 12)
|
||||
expect(chat12).toBeDefined()
|
||||
expect(chat12.price_idr).toBe(12000)
|
||||
|
||||
// Discount block — eligible (phone-verified + no completed sessions)
|
||||
expect(data.first_session_discount.eligible).toBe(true)
|
||||
expect(data.first_session_discount.actual_price_idr).toBe(2000)
|
||||
expect(data.first_session_discount.gimmick_price_idr).toBe(12000)
|
||||
expect(data.first_session_discount.duration_minutes).toBe(12)
|
||||
expect(data.first_session_discount.modes).toEqual(['chat'])
|
||||
|
||||
// Insert a completed session — eligibility must flip.
|
||||
const sql = db()
|
||||
await sql`
|
||||
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
|
||||
VALUES (${customer.id}, 'completed', 12, 12000)
|
||||
`
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/client/chat/pricing',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
expect(after.statusCode).toBe(200)
|
||||
expect(after.json().data.first_session_discount.eligible).toBe(false)
|
||||
})
|
||||
|
||||
it('eligibility is false when phone is not set (anonymous customer)', async () => {
|
||||
const anon = await createCustomer({ callName: 'AnonCust', phone: null })
|
||||
const anonToken = customerJwt(anon.id)
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/client/chat/pricing',
|
||||
headers: authHeader(anonToken),
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json().data.first_session_discount.eligible).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -34,7 +34,11 @@ describe('POST /api/client/payment-sessions', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb()
|
||||
customer = await createCustomer({ callName: 'PaymentTester' })
|
||||
// Phone-verified customer (phone non-null) is required for first-session-discount
|
||||
// eligibility under the Phase 4 predicate.
|
||||
// Random suffix avoids the unique-phone constraint clashing with parallel test files.
|
||||
const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}`
|
||||
customer = await createCustomer({ callName: 'PaymentTester', phone })
|
||||
token = customerJwt(customer.id)
|
||||
})
|
||||
|
||||
@@ -42,25 +46,25 @@ describe('POST /api/client/payment-sessions', () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
it('happy path returns 201 + a pending payment-session row', async () => {
|
||||
it('happy path returns 201 + a pending payment-session row at the discounted price for an eligible customer', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/client/payment-sessions',
|
||||
headers: authHeader(token),
|
||||
payload: { duration_minutes: 15 },
|
||||
// Discount duration default is 12 minutes (config seed). Eligible customer →
|
||||
// amount forced to actual_price_idr (2000), is_first_session_discount=true.
|
||||
payload: { duration_minutes: 12 },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data.status).toBe(PaymentSessionStatus.PENDING)
|
||||
expect(body.data.duration_minutes).toBe(15)
|
||||
// Default tier for 15min from migrate.js is 30000 — but the eligibility logic
|
||||
// also needs `free_trial_enabled` to be true (default) AND no prior tx. Customer is
|
||||
// brand-new so they get the trial → amount=0, is_free_trial=true. Verify accordingly.
|
||||
expect(body.data.is_free_trial).toBe(true)
|
||||
expect(body.data.amount).toBe(0)
|
||||
expect(body.data.duration_minutes).toBe(12)
|
||||
expect(body.data.is_first_session_discount).toBe(true)
|
||||
expect(body.data.amount).toBe(2000)
|
||||
expect(body.data.is_extension).toBe(false)
|
||||
expect(body.data.mode).toBe('chat')
|
||||
|
||||
// Verify persistence
|
||||
const sql = db()
|
||||
@@ -69,35 +73,41 @@ describe('POST /api/client/payment-sessions', () => {
|
||||
expect(row.customer_id).toBe(customer.id)
|
||||
})
|
||||
|
||||
it('POST /:id/confirm transitions the row and returns 200', async () => {
|
||||
// Create a paid (non-trial) tier so the row has a non-zero amount and we exercise the
|
||||
// confirm path with a "real" payment. Insert a transaction first so the customer is
|
||||
// ineligible for the free trial.
|
||||
it('non-eligible customer pays the standard tier price', async () => {
|
||||
// Drop first-session-discount eligibility by inserting a completed session.
|
||||
const sql = db()
|
||||
// Bootstrap: create a fake prior chat session + transaction so the customer is no
|
||||
// longer eligible for the free trial. (The simpler alternative — flipping
|
||||
// free_trial_enabled in app_config — would impact other tests.)
|
||||
const [prior] = await sql`
|
||||
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
|
||||
VALUES (${customer.id}, 'completed', 15, 30000)
|
||||
RETURNING id
|
||||
`
|
||||
await sql`
|
||||
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
||||
VALUES (${customer.id}, ${prior.id}, 'paid', 30000)
|
||||
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
|
||||
VALUES (${customer.id}, 'completed', 12, 12000)
|
||||
`
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/client/payment-sessions',
|
||||
headers: authHeader(token),
|
||||
payload: { duration_minutes: 12 },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json()
|
||||
expect(body.data.is_first_session_discount).toBe(false)
|
||||
// 12-minute tier in Phase 4 chat tiers = 12000 IDR.
|
||||
expect(body.data.amount).toBe(12000)
|
||||
})
|
||||
|
||||
it('POST /:id/confirm transitions the row and returns 200', async () => {
|
||||
// Use a non-discount tier (5 min @ 5000 IDR) so we exercise the regular confirm path.
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/client/payment-sessions',
|
||||
headers: authHeader(token),
|
||||
payload: { duration_minutes: 15 },
|
||||
payload: { duration_minutes: 5 },
|
||||
})
|
||||
expect(createRes.statusCode).toBe(201)
|
||||
const created = createRes.json().data
|
||||
expect(created.status).toBe(PaymentSessionStatus.PENDING)
|
||||
expect(created.is_free_trial).toBe(false)
|
||||
expect(created.amount).toBe(30000)
|
||||
expect(created.is_first_session_discount).toBe(false)
|
||||
expect(created.amount).toBe(5000)
|
||||
|
||||
const confirmRes = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -112,4 +122,21 @@ describe('POST /api/client/payment-sessions', () => {
|
||||
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
|
||||
expect(confirmed.confirmed_at).toBeTruthy()
|
||||
})
|
||||
|
||||
it('call-mode payment session uses the call tier price group', async () => {
|
||||
// 20-minute call tier in Phase 4 = 17000 IDR.
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/client/payment-sessions',
|
||||
headers: authHeader(token),
|
||||
payload: { duration_minutes: 20, mode: 'call' },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json()
|
||||
expect(body.data.mode).toBe('call')
|
||||
// Eligible customer but discount modes default = ['chat'], so call is full price.
|
||||
expect(body.data.is_first_session_discount).toBe(false)
|
||||
expect(body.data.amount).toBe(17000)
|
||||
})
|
||||
})
|
||||
|
||||
87
backend/test/routes/shared.auth-providers.routes.test.js
Normal file
87
backend/test/routes/shared.auth-providers.routes.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
// Same pattern as the other route tests — keep the websocket plugin no-op so
|
||||
// buildPublic doesn't try to open real WS upgrades.
|
||||
vi.mock('../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => false),
|
||||
sendToSessionParticipant: vi.fn(() => false),
|
||||
registerWebSocketPlugin: vi.fn(async () => {}),
|
||||
registerWebSocketRoute: vi.fn(),
|
||||
isUserOnlineWs: vi.fn(() => false),
|
||||
getSessionConnections: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/services/notification.service.js', () => ({
|
||||
sendPushNotification: vi.fn(async () => true),
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
describe('GET /api/shared/auth-providers (Phase 4)', () => {
|
||||
// Snapshot env so we can mutate freely and restore.
|
||||
const ENV_KEYS = [
|
||||
'GOOGLE_OAUTH_CLIENT_ID',
|
||||
'GOOGLE_OAUTH_CLIENT_SECRET',
|
||||
'APPLE_OAUTH_CLIENT_ID',
|
||||
'APPLE_OAUTH_TEAM_ID',
|
||||
'APPLE_OAUTH_KEY_ID',
|
||||
'APPLE_OAUTH_PRIVATE_KEY',
|
||||
]
|
||||
let original
|
||||
|
||||
beforeEach(() => {
|
||||
original = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]))
|
||||
for (const k of ENV_KEYS) delete process.env[k]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (original[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = original[k]
|
||||
}
|
||||
})
|
||||
|
||||
it('returns enabled:false for google + apple when env vars are unset; phone always true', async () => {
|
||||
// Re-import service to drop the module-load cache, then reset its in-memory cache.
|
||||
const svc = await import('../../src/services/auth-providers.service.js')
|
||||
svc._resetAuthProvidersCache()
|
||||
|
||||
const { buildPublic } = await import('../helpers/server.js')
|
||||
const app = await buildPublic()
|
||||
try {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/shared/auth-providers' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data.google.enabled).toBe(false)
|
||||
expect(body.data.apple.enabled).toBe(false)
|
||||
expect(body.data.phone.enabled).toBe(true)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns enabled:true for google + apple when all env vars are set', async () => {
|
||||
process.env.GOOGLE_OAUTH_CLIENT_ID = 'id'
|
||||
process.env.GOOGLE_OAUTH_CLIENT_SECRET = 'secret'
|
||||
process.env.APPLE_OAUTH_CLIENT_ID = 'apple-id'
|
||||
process.env.APPLE_OAUTH_TEAM_ID = 'team'
|
||||
process.env.APPLE_OAUTH_KEY_ID = 'key'
|
||||
process.env.APPLE_OAUTH_PRIVATE_KEY = 'priv'
|
||||
|
||||
const svc = await import('../../src/services/auth-providers.service.js')
|
||||
svc._resetAuthProvidersCache()
|
||||
|
||||
const { buildPublic } = await import('../helpers/server.js')
|
||||
const app = await buildPublic()
|
||||
try {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/shared/auth-providers' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.data.google.enabled).toBe(true)
|
||||
expect(body.data.apple.enabled).toBe(true)
|
||||
expect(body.data.phone.enabled).toBe(true)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -38,8 +38,9 @@ describe('payment.service', () => {
|
||||
expect(session.customer_id).toBe(customer.id)
|
||||
expect(session.duration_minutes).toBe(15)
|
||||
expect(session.amount).toBe(30000)
|
||||
expect(session.is_free_trial).toBe(false)
|
||||
expect(session.is_first_session_discount).toBe(false)
|
||||
expect(session.is_extension).toBe(false)
|
||||
expect(session.mode).toBe('chat')
|
||||
expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before)
|
||||
|
||||
// Verify it's actually persisted (not just returned from the INSERT)
|
||||
|
||||
91
backend/test/services/session-timer.service.test.js
Normal file
91
backend/test/services/session-timer.service.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
// Capture calls to sendToSessionParticipant so we can assert the 3-min warning event.
|
||||
vi.mock('../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => true),
|
||||
sendToSessionParticipant: vi.fn(() => true),
|
||||
registerWebSocketPlugin: vi.fn(),
|
||||
registerWebSocketRoute: vi.fn(),
|
||||
isUserOnlineWs: vi.fn(() => true),
|
||||
getSessionConnections: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/services/notification.service.js', () => ({
|
||||
sendPushNotification: vi.fn(async () => true),
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/plugins/valkey.js', () => ({
|
||||
publish: vi.fn(async () => {}),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
}))
|
||||
|
||||
const { sendToSessionParticipant } = await import('../../src/plugins/websocket.js')
|
||||
const { startSessionTimer, clearSessionTimer } = await import('../../src/services/session-timer.service.js')
|
||||
const { WsMessage, UserType } = await import('../../src/constants.js')
|
||||
|
||||
describe('session-timer 3-minute warning (Phase 4)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendToSessionParticipant.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('emits session_warning kind:three_minutes_left exactly once at the 3-min mark', async () => {
|
||||
const sessionId = 'sess-3min-test'
|
||||
const expiresAt = new Date(Date.now() + 5 * 60_000) // 5 minutes from now
|
||||
|
||||
startSessionTimer(sessionId, expiresAt)
|
||||
|
||||
// Advance 1 min 59 s — well before the 2-min mark when the 3-min warning fires.
|
||||
await vi.advanceTimersByTimeAsync(60_000 + 59_000)
|
||||
const warnCallsEarly = sendToSessionParticipant.mock.calls.filter(
|
||||
([, , data]) => data?.type === WsMessage.SESSION_WARNING,
|
||||
)
|
||||
expect(warnCallsEarly).toHaveLength(0)
|
||||
|
||||
// Cross the 3-min-left threshold. 5 min total - 3 min = warning fires at t=2:00.
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
// sendToSessionParticipant signature: (sessionId, userType, data)
|
||||
const warnCalls = sendToSessionParticipant.mock.calls.filter(
|
||||
([, , data]) => data?.type === WsMessage.SESSION_WARNING,
|
||||
)
|
||||
expect(warnCalls).toHaveLength(1)
|
||||
const [calledSessionId, userType, data] = warnCalls[0]
|
||||
expect(calledSessionId).toBe(sessionId)
|
||||
expect(userType).toBe(UserType.CUSTOMER)
|
||||
expect(data.kind).toBe('three_minutes_left')
|
||||
expect(data.session_id).toBe(sessionId)
|
||||
|
||||
// Cleanup before expiry hits.
|
||||
clearSessionTimer(sessionId)
|
||||
})
|
||||
|
||||
it('does NOT re-fire the 3-min warning when the timer is rescheduled (e.g. extension)', async () => {
|
||||
const sessionId = 'sess-rescheduled'
|
||||
const initial = new Date(Date.now() + 5 * 60_000)
|
||||
startSessionTimer(sessionId, initial)
|
||||
|
||||
// Cross the 3-min mark on the original schedule.
|
||||
await vi.advanceTimersByTimeAsync(2 * 60_000 + 1_000)
|
||||
let warnCalls = sendToSessionParticipant.mock.calls.filter(
|
||||
([, , data]) => data?.type === WsMessage.SESSION_WARNING,
|
||||
)
|
||||
expect(warnCalls).toHaveLength(1)
|
||||
|
||||
// Extension reschedules — give a new 5-min window. The 3-min warning must NOT fire again.
|
||||
const extended = new Date(Date.now() + 5 * 60_000)
|
||||
startSessionTimer(sessionId, extended)
|
||||
await vi.advanceTimersByTimeAsync(2 * 60_000 + 1_000)
|
||||
|
||||
warnCalls = sendToSessionParticipant.mock.calls.filter(
|
||||
([, , data]) => data?.type === WsMessage.SESSION_WARNING,
|
||||
)
|
||||
expect(warnCalls).toHaveLength(1) // still 1, no double-fire
|
||||
|
||||
clearSessionTimer(sessionId)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,99 @@
|
||||
# Smoke test: launch the app and assert the home screen renders.
|
||||
# Use this flow first to verify Maestro can talk to your device/emulator at all.
|
||||
# Smoke test: cold-start onboarding, registers a new customer via the
|
||||
# anonymity-disabled force-register path, lands on home screen.
|
||||
#
|
||||
# Exercises (in order): onboarding carousel -> welcome -> display name ->
|
||||
# force-register (because anonymity_enabled=false in dev) -> OTP via peek
|
||||
# endpoint -> home.
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/01_smoke.yaml
|
||||
#
|
||||
# Pre-req: client_app debug APK installed on the connected device, signed in as a customer.
|
||||
appId: ${APP_ID_ANDROID} # Maestro uses APP_ID_IOS automatically when running against an iOS device
|
||||
# Pre-req: client_app debug APK installed, backend reachable at
|
||||
# BACKEND_URL/BACKEND_INTERNAL_URL, NODE_ENV != 'production' (so the
|
||||
# /internal/_test/peek-otp + /internal/_test/reset-phone routes register).
|
||||
appId: com.halobestie.client.client_app
|
||||
env:
|
||||
TEST_PHONE: "+628155556677"
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
---
|
||||
# Wipe any prior state for TEST_PHONE so repeated runs don't trip cooldowns
|
||||
# or hit IDENTITY_CONFLICT on a previously-claimed customer row.
|
||||
- runScript:
|
||||
file: ../scripts/reset_phone.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- launchApp:
|
||||
clearState: false # keep existing auth — set to true to test cold-start onboarding
|
||||
- assertVisible:
|
||||
clearState: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Mulai"
|
||||
timeout: 15000 # onboarding carousel auto-advances; "Mulai" appears on slide 3
|
||||
- tapOn:
|
||||
text: "Mulai"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Lanjut sebagai Tamu"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Lanjut sebagai Tamu"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Nama panggilan"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Nama panggilan"
|
||||
- inputText: "Maestro"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "Lanjut"
|
||||
retryTapIfNoChange: true
|
||||
# Force-register kicks in (anonymity_enabled=false in dev DB)
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Verifikasi Akun"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Nomor HP"
|
||||
- inputText: ${TEST_PHONE}
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "Kirim OTP"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Masukkan OTP"
|
||||
timeout: 15000
|
||||
# Pull the stub-generated OTP code from the in-memory map on the backend
|
||||
- runScript:
|
||||
file: ../scripts/peek_otp.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
# inputText fills the autofocused first box; Flutter's onChanged advances
|
||||
# focus per char, so all 6 digits land in the right boxes and auto-submit.
|
||||
- inputText: ${output.OTP}
|
||||
# Post-OTP, force-register flow lands on /auth/set-name (anonymous display
|
||||
# name doesn't carry to the upgraded row). Wait for OTP screen to fade,
|
||||
# then re-fill display name and continue to home.
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
text: "Masukkan OTP"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Nama panggilan"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Nama panggilan"
|
||||
- inputText: "Maestro"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "Lanjut"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Mulai Curhat"
|
||||
timeout: 10000 # 10s — give Riverpod time to hydrate the home screen
|
||||
timeout: 20000
|
||||
|
||||
115
client_app/.maestro/flows/02_onboarding_verified.yaml
Normal file
115
client_app/.maestro/flows/02_onboarding_verified.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
# Phase 4 Stage 2 — verified onboarding path:
|
||||
# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet
|
||||
# (verifikasi nomor HP) → ESP (pick a chip) → USP → Register → OTP (6-digit)
|
||||
# → S6 paywall (when first-session-discount eligible) or duration picker.
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/02_onboarding_verified.yaml
|
||||
#
|
||||
# Pre-reqs: client_app debug APK installed, backend reachable at
|
||||
# BACKEND_URL/BACKEND_INTERNAL_URL, NODE_ENV != 'production' (so the
|
||||
# /internal/_test/peek-otp + /reset-phone routes register), and
|
||||
# `anonymity_enabled = true` in the dev DB so the verif choice sheet shows.
|
||||
#
|
||||
# NOTE: numeric prefix conflicts with the existing
|
||||
# 02_cta_disabled_when_no_mitra.yaml — Stage 9 will reorganize the flow
|
||||
# directory once the full Phase 4 suite lands.
|
||||
appId: com.halobestie.client.client_app
|
||||
env:
|
||||
TEST_PHONE: "+628155557701"
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
---
|
||||
- runScript:
|
||||
file: ../scripts/reset_phone.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Mulai"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Mulai"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Lanjut sebagai Tamu"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Lanjut sebagai Tamu"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Nama panggilan"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Nama panggilan"
|
||||
- inputText: "Maestro"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "lanjut"
|
||||
retryTapIfNoChange: true
|
||||
# Verif Choice Sheet
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "verifikasi nomor HP"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "verifikasi nomor HP"
|
||||
retryTapIfNoChange: true
|
||||
# ESP screen — pick at least one chip then tap "lanjut"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Lagi mikirin apa?"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Hubungan"
|
||||
- tapOn:
|
||||
text: "lanjut"
|
||||
retryTapIfNoChange: true
|
||||
# USP screen
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Sebelum mulai"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "aku ngerti, lanjut"
|
||||
retryTapIfNoChange: true
|
||||
# Register (S3a) — phone entry
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Nomor HP"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Nomor HP"
|
||||
- inputText: ${TEST_PHONE}
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "kirim OTP"
|
||||
retryTapIfNoChange: true
|
||||
# OTP screen (S3b)
|
||||
- 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}
|
||||
# Verified path: first-session-discount eligible customers land on the S6
|
||||
# paywall; non-eligibles land on the duration picker. Either is acceptable
|
||||
# arrival for this flow.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Masukkan OTP"
|
||||
timeout: 15000
|
||||
notVisible: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "harga sesi pertama"
|
||||
timeout: 15000
|
||||
optional: true
|
||||
71
client_app/.maestro/flows/03_onboarding_anon.yaml
Normal file
71
client_app/.maestro/flows/03_onboarding_anon.yaml
Normal file
@@ -0,0 +1,71 @@
|
||||
# Phase 4 Stage 2 — anonymous onboarding path:
|
||||
# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet
|
||||
# (curhat anonim) → ESP → USP → arrival at /payment/method-pick (Stage 3
|
||||
# owns the screen body; this flow stops at route arrival).
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/03_onboarding_anon.yaml
|
||||
#
|
||||
# Pre-reqs: same as 02_onboarding_verified.yaml.
|
||||
#
|
||||
# NOTE: numeric prefix conflicts with the existing 03_payment_to_chat_happy.yaml
|
||||
# — Stage 9 will reorganize the flow directory once the full Phase 4 suite lands.
|
||||
appId: com.halobestie.client.client_app
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Mulai"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Mulai"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Lanjut sebagai Tamu"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Lanjut sebagai Tamu"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Nama panggilan"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Nama panggilan"
|
||||
- inputText: "Maestro"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "lanjut"
|
||||
retryTapIfNoChange: true
|
||||
# Verif Choice Sheet — pick anonymous branch
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "curhat anonim"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "curhat anonim"
|
||||
retryTapIfNoChange: true
|
||||
# ESP screen — leave empty + tap lewati to exercise the skip path
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Lagi mikirin apa?"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "lewati"
|
||||
retryTapIfNoChange: true
|
||||
# USP screen
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Sebelum mulai"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "aku ngerti, lanjut"
|
||||
retryTapIfNoChange: true
|
||||
# Stage 3 owns /payment/method-pick — arrival is the success signal.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Sebelum mulai"
|
||||
timeout: 10000
|
||||
notVisible: true
|
||||
94
client_app/.maestro/flows/04_payment_expired.yaml
Normal file
94
client_app/.maestro/flows/04_payment_expired.yaml
Normal file
@@ -0,0 +1,94 @@
|
||||
# Stage 3 acceptance: drive a payment session into the expired state and
|
||||
# verify the expired screen renders.
|
||||
#
|
||||
# Flow:
|
||||
# home → tap CTA → /payment/entry → /payment/method-pick (or
|
||||
# discount-paywall — both arrive at /payment/method) → /payment/method →
|
||||
# tap bayar → /payment/waiting/:id → force-expire via dev endpoint →
|
||||
# poller transitions to /payment/expired/:id.
|
||||
#
|
||||
# Pre-req:
|
||||
# 1. The customer is already onboarded + on /home (run flow 01 first, or
|
||||
# launchApp with clearState=false on a state past onboarding).
|
||||
# 2. At least one mitra is ONLINE on the target backend (so the CTA is
|
||||
# enabled). Use mitra_app or the manual seed.
|
||||
# 3. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production'
|
||||
# (so the _test routes register).
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/04_payment_expired.yaml
|
||||
appId: ${APP_ID_ANDROID}
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- assertVisible: "Mulai Curhat"
|
||||
|
||||
# Step 1: tap CTA — home routes to /payment/entry which decides the next leg
|
||||
# based on first-session-discount eligibility.
|
||||
- tapOn: "Mulai Curhat"
|
||||
|
||||
# Step 2: regardless of which entry path was chosen, the customer ends up at
|
||||
# /payment/method-pick (non-eligible) or /payment/discount-paywall (eligible).
|
||||
# Both have a way forward to /payment/method. Wait for either landmark.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "pilih cara curhat|sesi pertama|pilih durasi"
|
||||
timeout: 10000
|
||||
|
||||
# Step 3: pick chat (if on method-pick) and a tier (if on duration-pick),
|
||||
# or tap mulai (if on discount paywall). Each branch funnels into
|
||||
# /payment/method.
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: "pilih cara curhat"
|
||||
commands:
|
||||
- tapOn: "chat"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "pilih durasi"
|
||||
timeout: 5000
|
||||
- tapOn:
|
||||
text: "5 menit"
|
||||
retryTapIfNoChange: true
|
||||
- tapOn:
|
||||
text: "bayar"
|
||||
retryTapIfNoChange: true
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: "sesi pertama"
|
||||
commands:
|
||||
- tapOn:
|
||||
text: "mulai"
|
||||
retryTapIfNoChange: true
|
||||
|
||||
# Step 4: on the cara-bayar screen, QRIS is preselected. Tap pay.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "cara bayar"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "bayar"
|
||||
retryTapIfNoChange: true
|
||||
|
||||
# Step 5: we should now be on the QR/waiting screen. The header shows the
|
||||
# countdown ("kedaluwarsa dalam"). Force-expire via the dev endpoint.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "kedaluwarsa dalam"
|
||||
timeout: 10000
|
||||
- runScript:
|
||||
file: ../scripts/force_expire_latest_payment.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# Step 6: poller picks up `expired` within ~3s and routes to expired screen.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "pembayaran kedaluwarsa"
|
||||
timeout: 10000
|
||||
- assertVisible: "coba lagi"
|
||||
- assertVisible: "kembali ke home"
|
||||
89
client_app/.maestro/flows/05_searching_timeout.yaml
Normal file
89
client_app/.maestro/flows/05_searching_timeout.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
# Stage 5 acceptance: drive the searching screen into the 5-min timeout
|
||||
# state without waiting 5 minutes, verify the new copy + both CTAs render.
|
||||
#
|
||||
# Flow:
|
||||
# home → tap CTA → payment funnel → confirm → /chat/searching →
|
||||
# force-timeout via dev endpoint → verify timeout panel + CTAs.
|
||||
#
|
||||
# Pre-req:
|
||||
# 1. Customer is already onboarded + on /home (run flow 01 first).
|
||||
# 2. At least one mitra is ONLINE on the target backend (so the home
|
||||
# "Mulai Curhat" CTA is enabled — we then force-timeout server-side
|
||||
# regardless of mitra availability).
|
||||
# 3. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production'
|
||||
# (so the _test routes register).
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/05_searching_timeout.yaml
|
||||
appId: ${APP_ID_ANDROID}
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
- assertVisible: "Mulai Curhat"
|
||||
|
||||
# Step 1: enter payment funnel.
|
||||
- tapOn: "Mulai Curhat"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "pilih cara curhat|sesi pertama|pilih durasi"
|
||||
timeout: 10000
|
||||
|
||||
# Step 2: regardless of branch, end up on /payment/method.
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: "pilih cara curhat"
|
||||
commands:
|
||||
- tapOn: "chat"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "pilih durasi"
|
||||
timeout: 5000
|
||||
- tapOn:
|
||||
text: "5 menit"
|
||||
retryTapIfNoChange: true
|
||||
- tapOn:
|
||||
text: "bayar"
|
||||
retryTapIfNoChange: true
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
text: "sesi pertama"
|
||||
commands:
|
||||
- tapOn:
|
||||
text: "mulai"
|
||||
retryTapIfNoChange: true
|
||||
|
||||
# Step 3: cara-bayar → tap bayar → waiting screen.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "cara bayar"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "bayar"
|
||||
retryTapIfNoChange: true
|
||||
|
||||
# Step 4: payment confirms via mock; the searching screen opens. The
|
||||
# soft-prompt copy ships in Stage 5 — we wait for that landmark.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "sambil nunggu"
|
||||
timeout: 15000
|
||||
- assertVisible: "lagi nyari bestie..."
|
||||
|
||||
# Step 5: force the 5-min timeout server-side; the WS event lands within
|
||||
# ~1s and the screen flips to the timeout panel.
|
||||
- runScript:
|
||||
file: ../scripts/force_pairing_timeout.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# Step 6: verify timeout panel + both CTAs render.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "masih nyari nih"
|
||||
timeout: 10000
|
||||
- assertVisible: "coba cari lagi"
|
||||
- assertVisible: "kembali ke home"
|
||||
74
client_app/.maestro/flows/06_chat_countdown.yaml
Normal file
74
client_app/.maestro/flows/06_chat_countdown.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
# Stage 6 acceptance: drive a live chat session through the countdown UX
|
||||
# in one run.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Customer is on the chat screen of an ACTIVE session (run flow 03 first).
|
||||
# 2. Force expires_at = now + 175s → backend fires `session_warning` at 175s
|
||||
# (180s threshold, fudge 5s for clock drift) within ~1s.
|
||||
# 3. Verify the 3-min snackbar copy renders.
|
||||
# 4. Force expires_at = now + 90s → timer pill flips to danger styling at
|
||||
# remaining <= 120s (well within the 90s window).
|
||||
# 5. Force expires_at = now + 0s → expired banner appears above input bar.
|
||||
#
|
||||
# Pre-req:
|
||||
# 1. A live chat session is on screen (paired + active). The simplest way is
|
||||
# to chain this after flow 03_payment_to_chat_happy.yaml.
|
||||
# 2. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production'.
|
||||
#
|
||||
# Run (chained):
|
||||
# maestro test client_app/.maestro/flows/03_payment_to_chat_happy.yaml \
|
||||
# client_app/.maestro/flows/06_chat_countdown.yaml
|
||||
appId: ${APP_ID_ANDROID}
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
# Step 0: assert we're already on the chat screen (input hint is the landmark).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Ketik Pesan"
|
||||
timeout: 10000
|
||||
|
||||
# Step 1: force expires_at to 175s — fires the 3-min warning within ~1s.
|
||||
- runScript:
|
||||
file: ../scripts/force_session_expires_at.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
SECONDS_FROM_NOW: "175"
|
||||
|
||||
# Step 2: verify the 3-min snackbar.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "sisa 3 menit lagi"
|
||||
timeout: 5000
|
||||
|
||||
# Step 3: force expires_at to 90s — last-2-min danger pill territory.
|
||||
- runScript:
|
||||
file: ../scripts/force_session_expires_at.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
SECONDS_FROM_NOW: "90"
|
||||
|
||||
# Step 4: assert the danger-styled timer pill renders. The pill content is a
|
||||
# minutes-and-seconds string ("1m Xd"); we only assert the unit suffix here
|
||||
# because the exact seconds drift between assertion and render.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "1m"
|
||||
timeout: 5000
|
||||
|
||||
# Step 5: force expires_at to 0s — expired banner appears.
|
||||
- runScript:
|
||||
file: ../scripts/force_session_expires_at.js
|
||||
env:
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
SECONDS_FROM_NOW: "0"
|
||||
|
||||
# Step 6: verify the floating expired banner + perpanjang CTA.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "waktu curhat habis"
|
||||
timeout: 8000
|
||||
- assertVisible: "perpanjang"
|
||||
76
client_app/.maestro/flows/07_end_session_2step.yaml
Normal file
76
client_app/.maestro/flows/07_end_session_2step.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
# Stage 7 acceptance: customer-initiated end-of-session 2-step flow.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Customer is on the chat screen of an ACTIVE session (run flow 03 first).
|
||||
# 2. Tap "akhiri" in the AppBar → step-1 confirm popup ("yakin mau akhiri sesi?").
|
||||
# 3. Tap "lanjut akhiri" → step-2 confirm popup ("mau tinggalin pesan penutup?").
|
||||
# 4. Tap "tulis pesan penutup" → closing-message bottom sheet.
|
||||
# 5. Type a message → "kirim & akhiri sesi" → POSTs goodbye + closes session.
|
||||
# 6. Verify navigation to S11 thank-you screen ("makasih udah curhat").
|
||||
# 7. Tap "balik ke home" → home screen ("Mulai Curhat").
|
||||
#
|
||||
# Pre-req:
|
||||
# 1. A live chat session is on screen (paired + active). Chain after flow
|
||||
# 03_payment_to_chat_happy.yaml.
|
||||
#
|
||||
# Run (chained):
|
||||
# maestro test client_app/.maestro/flows/03_payment_to_chat_happy.yaml \
|
||||
# client_app/.maestro/flows/07_end_session_2step.yaml
|
||||
appId: ${APP_ID_ANDROID}
|
||||
---
|
||||
- launchApp:
|
||||
clearState: false
|
||||
|
||||
# Step 0: assert we're on the chat screen.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Ketik Pesan"
|
||||
timeout: 10000
|
||||
|
||||
# Step 1: tap "akhiri" in the AppBar → step-1 popup.
|
||||
- tapOn: "akhiri"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "yakin mau akhiri sesi?"
|
||||
timeout: 5000
|
||||
- assertVisible: "lanjut akhiri"
|
||||
- assertVisible: "gak jadi, balik"
|
||||
|
||||
# Step 2: tap "lanjut akhiri" → step-2 popup.
|
||||
- tapOn: "lanjut akhiri"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "mau tinggalin pesan penutup?"
|
||||
timeout: 5000
|
||||
- assertVisible: "tulis pesan penutup"
|
||||
- assertVisible: "lewati saja"
|
||||
|
||||
# Step 3: tap "tulis pesan penutup" → closing-message bottom sheet.
|
||||
- tapOn: "tulis pesan penutup"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "pesan penutup"
|
||||
timeout: 5000
|
||||
- assertVisible: "kirim & akhiri sesi"
|
||||
- assertVisible: "lewat — langsung akhiri"
|
||||
|
||||
# Step 4: type a message + send.
|
||||
- tapOn:
|
||||
text: "makasih ya bestie..."
|
||||
- inputText: "makasih bestie, sesi ini ngebantu banget"
|
||||
- hideKeyboard
|
||||
- tapOn: "kirim & akhiri sesi"
|
||||
|
||||
# Step 5: verify S11 thank-you screen.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "makasih udah curhat"
|
||||
timeout: 10000
|
||||
- assertVisible: "balik ke home"
|
||||
|
||||
# Step 6: tap "balik ke home" → home.
|
||||
- tapOn: "balik ke home"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Mulai Curhat"
|
||||
timeout: 5000
|
||||
132
client_app/.maestro/flows/08_returning_targeted.yaml
Normal file
132
client_app/.maestro/flows/08_returning_targeted.yaml
Normal file
@@ -0,0 +1,132 @@
|
||||
# Stage 8 acceptance: returning-user shell.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Cold-start onboarding flow (mirrors 01_smoke) lands customer on home.
|
||||
# 2. Seed a completed chat_sessions row so the bestie history list isn't empty.
|
||||
# 3. Tap "Mulai Curhat" → Bestie Choice Sheet appears.
|
||||
# 4. Tap "bestie yang udah kenal" → bestie history list appears.
|
||||
# 5. Verify ONLINE pill renders for the seeded (online) mitra.
|
||||
# 6. Tap "curhat lagi" on the row → targeted-wait screen appears with 20s
|
||||
# countdown overlay, then matches via the running mitra.
|
||||
#
|
||||
# Pre-req: client_app debug APK installed, backend reachable, NODE_ENV != 'production'
|
||||
# so the dev-only /internal/_test routes are registered, AND a mitra is currently
|
||||
# online in the dev DB (see backend/src/db/seed.js or run mitra_app to sign in).
|
||||
#
|
||||
# Run:
|
||||
# maestro test client_app/.maestro/flows/08_returning_targeted.yaml
|
||||
appId: com.halobestie.client.client_app
|
||||
env:
|
||||
TEST_PHONE: "+628155556677"
|
||||
BACKEND_INTERNAL_URL: http://localhost:3001
|
||||
---
|
||||
# Wipe prior state for TEST_PHONE so the run is hermetic.
|
||||
- runScript:
|
||||
file: ../scripts/reset_phone.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
- launchApp:
|
||||
clearState: true
|
||||
|
||||
# Onboarding → welcome → display name → force-register → OTP → home (matches 01_smoke).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Mulai"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Mulai"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Lanjut sebagai Tamu"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Lanjut sebagai Tamu"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Nama panggilan"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Nama panggilan"
|
||||
- inputText: "Maestro"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "Lanjut"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Verifikasi Akun"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Nomor HP"
|
||||
- inputText: ${TEST_PHONE}
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "Kirim OTP"
|
||||
retryTapIfNoChange: true
|
||||
- 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}
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
text: "Masukkan OTP"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Nama panggilan"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Nama panggilan"
|
||||
- inputText: "Maestro"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
text: "Lanjut"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Mulai Curhat"
|
||||
timeout: 20000
|
||||
|
||||
# Seed a prior session against an online mitra.
|
||||
- runScript:
|
||||
file: ../scripts/seed_history_session.js
|
||||
env:
|
||||
TEST_PHONE: ${TEST_PHONE}
|
||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||
|
||||
# Tap "Mulai Curhat" → Bestie Choice Sheet (returning-user variant).
|
||||
- tapOn:
|
||||
text: "Mulai Curhat"
|
||||
retryTapIfNoChange: true
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "mau curhat sama siapa?"
|
||||
timeout: 5000
|
||||
- assertVisible: "bestie yang udah kenal"
|
||||
- assertVisible: "bestie baru"
|
||||
|
||||
# Choose the known bestie path → history list with v4 layout.
|
||||
- tapOn: "bestie yang udah kenal"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Riwayat Chat"
|
||||
timeout: 5000
|
||||
- assertVisible: "ONLINE"
|
||||
- assertVisible: "curhat lagi"
|
||||
|
||||
# Tap "curhat lagi" → /payment (legacy targeted-payment route). Verify the
|
||||
# screen title; the targeted-payment flow itself is covered by Stage 5.
|
||||
- tapOn: "curhat lagi"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Chat lagi dengan"
|
||||
timeout: 10000
|
||||
21
client_app/.maestro/scripts/force_expire_latest_payment.js
Normal file
21
client_app/.maestro/scripts/force_expire_latest_payment.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Force-expire the latest pending payment_session by hitting the dev-only
|
||||
// /internal/_test/force-expire-payment endpoint. Used by the Stage 3 maestro
|
||||
// flow (04_payment_expired.yaml) to drive the waiting screen into expired
|
||||
// without waiting 20 minutes.
|
||||
//
|
||||
// Strategy: query the latest pending payment_session via raw SQL through the
|
||||
// reset-phone endpoint? — actually no, we don't have an SQL surface. Instead,
|
||||
// we expose a tiny "expire-latest-pending" variant: pass `latest=true` and
|
||||
// the backend looks up the most-recent pending row.
|
||||
//
|
||||
// Reads BACKEND_INTERNAL_URL from env (Maestro injects it from the flow).
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
const resp = http.post(`${url}/internal/_test/force-expire-payment`, {
|
||||
body: JSON.stringify({ latest: true }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`force-expire-payment failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
const data = json(resp.body)
|
||||
output.PAYMENT_ID = data.id
|
||||
16
client_app/.maestro/scripts/force_pairing_timeout.js
Normal file
16
client_app/.maestro/scripts/force_pairing_timeout.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Force-expire the most-recent searching chat_session by hitting the dev-only
|
||||
// /internal/_test/force-pairing-timeout endpoint. Used by the Stage 5 maestro
|
||||
// flow (05_searching_timeout.yaml) to drive the searching screen into the
|
||||
// timeout state without waiting 5 minutes.
|
||||
//
|
||||
// Reads BACKEND_INTERNAL_URL from env (Maestro injects it from the flow).
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
const resp = http.post(`${url}/internal/_test/force-pairing-timeout`, {
|
||||
body: JSON.stringify({ latest: true }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`force-pairing-timeout failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
const data = json(resp.body)
|
||||
output.SESSION_ID = data.session_id
|
||||
21
client_app/.maestro/scripts/force_session_expires_at.js
Normal file
21
client_app/.maestro/scripts/force_session_expires_at.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Force-set the expires_at of the most-recent ACTIVE chat_session by hitting
|
||||
// the dev-only /internal/_test/force-session-expires-at endpoint. Used by the
|
||||
// Stage 6 maestro flow (06_chat_countdown.yaml) to drive the 3-min snackbar,
|
||||
// last-2-min danger pill, and expired banner without waiting in real time.
|
||||
//
|
||||
// Reads BACKEND_INTERNAL_URL + SECONDS_FROM_NOW from env (Maestro injects them
|
||||
// from the flow). The backend re-runs startSessionTimer with the new schedule
|
||||
// AND clears the per-session "3-min warning fired" flag so the warning fires
|
||||
// again on the new schedule.
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
const seconds = parseInt(SECONDS_FROM_NOW || '175', 10)
|
||||
const resp = http.post(`${url}/internal/_test/force-session-expires-at`, {
|
||||
body: JSON.stringify({ latest: true, seconds_from_now: seconds }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`force-session-expires-at failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
const data = json(resp.body)
|
||||
output.SESSION_ID = data.session_id
|
||||
output.EXPIRES_AT = data.expires_at
|
||||
13
client_app/.maestro/scripts/peek_otp.js
Normal file
13
client_app/.maestro/scripts/peek_otp.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Read the latest stub-generated OTP code for TEST_PHONE from the
|
||||
// backend's dev-only /internal/_test/peek-otp endpoint.
|
||||
//
|
||||
// Writes the 6-digit code to output.OTP so the calling flow can use ${output.OTP}.
|
||||
const phone = TEST_PHONE
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
const encoded = encodeURIComponent(phone)
|
||||
const resp = http.get(`${url}/internal/_test/peek-otp?phone=${encoded}`)
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`peek-otp failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
const data = json(resp.body)
|
||||
output.OTP = data.code
|
||||
20
client_app/.maestro/scripts/peek_otp.sh
Executable file
20
client_app/.maestro/scripts/peek_otp.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Read the latest stub-generated OTP code for ${TEST_PHONE} from the
|
||||
# backend's dev-only /internal/_test/peek-otp endpoint.
|
||||
#
|
||||
# Echoes the 6-digit code to stdout. Maestro captures the last line of
|
||||
# stdout into the variable named by the calling runScript step.
|
||||
set -euo pipefail
|
||||
|
||||
phone="${TEST_PHONE:-}"
|
||||
url="${BACKEND_INTERNAL_URL:-http://localhost:3001}"
|
||||
|
||||
if [[ -z "$phone" ]]; then
|
||||
echo "TEST_PHONE env var required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# url-encode the leading +
|
||||
encoded_phone="$(printf %s "$phone" | sed 's/+/%2B/')"
|
||||
resp="$(curl -fsS "${url}/internal/_test/peek-otp?phone=${encoded_phone}")"
|
||||
echo "$resp" | jq -r .code
|
||||
11
client_app/.maestro/scripts/reset_phone.js
Normal file
11
client_app/.maestro/scripts/reset_phone.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// Wipe otp_requests rows + customer row for TEST_PHONE so repeated runs
|
||||
// don't trip the 60s cooldown or hit IDENTITY_CONFLICT.
|
||||
const phone = TEST_PHONE
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
const resp = http.post(`${url}/internal/_test/reset-phone`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone, drop_customer: true }),
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`reset-phone failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
20
client_app/.maestro/scripts/reset_phone.sh
Executable file
20
client_app/.maestro/scripts/reset_phone.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wipe otp_requests rows + (optionally) customer row for ${TEST_PHONE} so
|
||||
# repeated test runs don't trip the 60s cooldown or hit IDENTITY_CONFLICT.
|
||||
#
|
||||
# Runs against backend's dev-only /internal/_test/reset-phone endpoint.
|
||||
set -euo pipefail
|
||||
|
||||
phone="${TEST_PHONE:-}"
|
||||
url="${BACKEND_INTERNAL_URL:-http://localhost:3001}"
|
||||
drop_customer="${DROP_CUSTOMER:-true}"
|
||||
|
||||
if [[ -z "$phone" ]]; then
|
||||
echo "TEST_PHONE env var required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -fsS -X POST "${url}/internal/_test/reset-phone" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"phone\":\"${phone}\",\"drop_customer\":${drop_customer}}" >/dev/null
|
||||
echo "reset complete: ${phone}"
|
||||
18
client_app/.maestro/scripts/seed_history_session.js
Normal file
18
client_app/.maestro/scripts/seed_history_session.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Seed a completed chat_sessions row for TEST_PHONE so the bestie history
|
||||
// list isn't empty when the Stage 8 flow opens it. Pairs the customer with
|
||||
// the most-recently-online mitra in the dev DB.
|
||||
//
|
||||
// Hits the dev-only /internal/_test/seed-history-session endpoint.
|
||||
const phone = TEST_PHONE
|
||||
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
|
||||
const resp = http.post(`${url}/internal/_test/seed-history-session`, {
|
||||
body: JSON.stringify({ phone }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`seed-history-session failed (${resp.status}): ${resp.body}`)
|
||||
}
|
||||
const data = json(resp.body)
|
||||
output.SESSION_ID = data.session_id
|
||||
output.MITRA_ID = data.mitra_id
|
||||
output.MITRA_NAME = data.mitra_name
|
||||
@@ -9,7 +9,7 @@ Flutter mobile application for end users (clients) seeking mental health support
|
||||
- **Framework:** Flutter (iOS + Android)
|
||||
- **Auth:** Self-managed (Phase 3.4). Anonymous-first + phone OTP + (Google / Apple when creds arrive).
|
||||
- Access token in memory on `AuthBridge`; refresh token persisted via `flutter_secure_storage`.
|
||||
- Google + Apple SDKs installed but buttons are hidden behind `--dart-define=ENABLE_SOCIAL_AUTH=true` until backend OAuth credentials exist.
|
||||
- Google + Apple SDKs installed; buttons are gated server-side via `GET /api/shared/auth-providers` (cached on cold start in `authProvidersProvider`). Buttons render only when the corresponding env-driven flag returns `enabled: true`.
|
||||
- `firebase_auth` removed; `firebase_messaging` kept for FCM push.
|
||||
- **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes). Refresh + logout live on `shared.auth`.
|
||||
- **Payment:** Xendit (paid sessions, optional trial)
|
||||
@@ -25,4 +25,4 @@ Flutter mobile application for end users (clients) seeking mental health support
|
||||
- Never call `/api/mitra/` or `/internal/` routes from this app
|
||||
- API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401
|
||||
- WebSocket handshake (`/api/shared/ws`) reads the access token from `AuthBridge` in the first frame's `{type:"auth", token, session_id?}` message
|
||||
- Use `const bool.fromEnvironment('ENABLE_SOCIAL_AUTH')` (via `social_auth_enabled.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable without that flag
|
||||
- Read `authProvidersProvider` (`core/auth/auth_providers_provider.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable when `providers.google` / `providers.apple` is `false`
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Android 13+ runtime notification permission. Requested by the
|
||||
Phase 4 Stage 4 notif-gate via permission_handler. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:label="client_app"
|
||||
android:name="${applicationName}"
|
||||
|
||||
BIN
client_app/assets/fonts/BricolageGrotesque-Variable.ttf
Normal file
BIN
client_app/assets/fonts/BricolageGrotesque-Variable.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
BIN
client_app/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/Poppins-Bold.ttf
Normal file
BIN
client_app/assets/fonts/Poppins-Bold.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/Poppins-Medium.ttf
Normal file
BIN
client_app/assets/fonts/Poppins-Medium.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/Poppins-Regular.ttf
Normal file
BIN
client_app/assets/fonts/Poppins-Regular.ttf
Normal file
Binary file not shown.
BIN
client_app/assets/fonts/Poppins-SemiBold.ttf
Normal file
BIN
client_app/assets/fonts/Poppins-SemiBold.ttf
Normal file
Binary file not shown.
15
client_app/assets/fonts/README.md
Normal file
15
client_app/assets/fonts/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# HaloBestie font assets
|
||||
|
||||
Stage 0 design-system fonts. All licensed under the SIL Open Font License.
|
||||
|
||||
| File | Source |
|
||||
|-----------------------------------|--------|
|
||||
| `BricolageGrotesque-Variable.ttf` | https://github.com/google/fonts/tree/main/ofl/bricolagegrotesque |
|
||||
| `Poppins-Regular.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
|
||||
| `Poppins-Medium.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
|
||||
| `Poppins-SemiBold.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
|
||||
| `Poppins-Bold.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
|
||||
| `JetBrainsMono-Variable.ttf` | https://github.com/google/fonts/tree/main/ofl/jetbrainsmono |
|
||||
|
||||
Wired into `client_app/pubspec.yaml` and consumed via `HaloTokens.fontDisplay`,
|
||||
`fontBody`, `fontMono` in `client_app/lib/core/theme/halo_tokens.dart`.
|
||||
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>NSUserNotificationsUsageDescription</key>
|
||||
<string>Halo Bestie kirim notifikasi pas bestie udah siap dengerin dan pas ada chat baru.</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -47,6 +49,13 @@
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
<string>http</string>
|
||||
<string>whatsapp</string>
|
||||
<string>tg</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
51
client_app/lib/core/auth/auth_providers_provider.dart
Normal file
51
client_app/lib/core/auth/auth_providers_provider.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../api/api_client_provider.dart';
|
||||
|
||||
part 'auth_providers_provider.g.dart';
|
||||
|
||||
class AuthProvidersConfig {
|
||||
final bool google;
|
||||
final bool apple;
|
||||
final bool phone;
|
||||
|
||||
const AuthProvidersConfig({
|
||||
required this.google,
|
||||
required this.apple,
|
||||
required this.phone,
|
||||
});
|
||||
|
||||
/// Conservative fallback used when the network probe fails. Phone OTP is
|
||||
/// always available; social sign-in is hidden until the backend confirms.
|
||||
static const fallback = AuthProvidersConfig(
|
||||
google: false,
|
||||
apple: false,
|
||||
phone: true,
|
||||
);
|
||||
|
||||
bool get hasAnySocial => google || apple;
|
||||
}
|
||||
|
||||
/// Cached server-driven flag set for which auth entry points are wired up.
|
||||
///
|
||||
/// Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag: the client
|
||||
/// now reads `GET /api/shared/auth-providers` once on cold start and hides
|
||||
/// Google/Apple buttons when the corresponding flag is `false`.
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<AuthProvidersConfig> authProviders(Ref ref) async {
|
||||
try {
|
||||
final response = await ref.read(apiClientProvider).get('/api/shared/auth-providers');
|
||||
final data = response['data'] as Map<String, dynamic>?;
|
||||
if (data == null) return AuthProvidersConfig.fallback;
|
||||
final google = data['google'] as Map<String, dynamic>?;
|
||||
final apple = data['apple'] as Map<String, dynamic>?;
|
||||
final phone = data['phone'] as Map<String, dynamic>?;
|
||||
return AuthProvidersConfig(
|
||||
google: (google?['enabled'] as bool?) ?? false,
|
||||
apple: (apple?['enabled'] as bool?) ?? false,
|
||||
phone: (phone?['enabled'] as bool?) ?? true,
|
||||
);
|
||||
} catch (_) {
|
||||
return AuthProvidersConfig.fallback;
|
||||
}
|
||||
}
|
||||
33
client_app/lib/core/auth/auth_providers_provider.g.dart
Normal file
33
client_app/lib/core/auth/auth_providers_provider.g.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_providers_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authProvidersHash() => r'cadec65217f3280bbd1b36568eefb93a7fcdd6f9';
|
||||
|
||||
/// Cached server-driven flag set for which auth entry points are wired up.
|
||||
///
|
||||
/// Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag: the client
|
||||
/// now reads `GET /api/shared/auth-providers` once on cold start and hides
|
||||
/// Google/Apple buttons when the corresponding flag is `false`.
|
||||
///
|
||||
/// Copied from [authProviders].
|
||||
@ProviderFor(authProviders)
|
||||
final authProvidersProvider = FutureProvider<AuthProvidersConfig>.internal(
|
||||
authProviders,
|
||||
name: r'authProvidersProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$authProvidersHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AuthProvidersRef = FutureProviderRef<AuthProvidersConfig>;
|
||||
// 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
|
||||
@@ -1,7 +0,0 @@
|
||||
/// Build-time flag controlling whether Google / Apple sign-in buttons
|
||||
/// are shown. Default: false until backend OAuth credentials are
|
||||
/// provisioned. Enable with `--dart-define=ENABLE_SOCIAL_AUTH=true`.
|
||||
const bool kSocialAuthEnabled = bool.fromEnvironment(
|
||||
'ENABLE_SOCIAL_AUTH',
|
||||
defaultValue: false,
|
||||
);
|
||||
@@ -6,9 +6,9 @@ part of 'mitra_availability_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9';
|
||||
String _$mitraAvailabilityHash() => r'036de8fea66c6a0114abd4a422af12186c1447d7';
|
||||
|
||||
/// Phase 3.7 §1: customer-home availability poll.
|
||||
/// Customer-home availability poll.
|
||||
///
|
||||
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
|
||||
/// screen is in the foreground. Polling is gated by the home screen calling
|
||||
@@ -16,10 +16,10 @@ String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9';
|
||||
/// - resumed → setActive(true)
|
||||
/// - paused/inactive → setActive(false)
|
||||
///
|
||||
/// On any HTTP error we emit `false` (PRD §1.3: never display stale state).
|
||||
/// On any HTTP error we emit `false` (never display stale state).
|
||||
///
|
||||
/// The endpoint also returns a `count`, but per PRD §1.3 the customer UI must
|
||||
/// only read the binary `available` field — the count is for CC/debug only.
|
||||
/// The endpoint also returns a `count`, but the customer UI must only read the
|
||||
/// binary `available` field — the count is for CC/debug only.
|
||||
///
|
||||
/// Copied from [MitraAvailability].
|
||||
@ProviderFor(MitraAvailability)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../api/api_client.dart';
|
||||
@@ -32,6 +33,12 @@ class ChatConnectedData extends ChatData {
|
||||
final bool sessionClosing;
|
||||
final bool goodbyeSubmitted;
|
||||
final Map<String, dynamic>? extensionResponse;
|
||||
// Phase 4 — voice-call mode badge in header. `chat` is the default (no pill).
|
||||
final SessionMode mode;
|
||||
// Phase 4 — drives the client-side seconds-left ticker. Backend only emits
|
||||
// discrete `session_timer` (60s) + `session_warning` (180s) events, so we
|
||||
// tick locally off this absolute timestamp for the danger pill / banner.
|
||||
final DateTime? expiresAt;
|
||||
|
||||
const ChatConnectedData({
|
||||
required this.messages,
|
||||
@@ -42,6 +49,8 @@ class ChatConnectedData extends ChatData {
|
||||
this.sessionClosing = false,
|
||||
this.goodbyeSubmitted = false,
|
||||
this.extensionResponse,
|
||||
this.mode = SessionMode.chat,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
ChatConnectedData copyWith({
|
||||
@@ -53,6 +62,8 @@ class ChatConnectedData extends ChatData {
|
||||
bool? sessionClosing,
|
||||
bool? goodbyeSubmitted,
|
||||
Map<String, dynamic>? extensionResponse,
|
||||
SessionMode? mode,
|
||||
DateTime? expiresAt,
|
||||
}) {
|
||||
return ChatConnectedData(
|
||||
messages: messages ?? this.messages,
|
||||
@@ -63,6 +74,8 @@ class ChatConnectedData extends ChatData {
|
||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
|
||||
extensionResponse: extensionResponse ?? this.extensionResponse,
|
||||
mode: mode ?? this.mode,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +115,25 @@ class ChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4 — derived seconds-left ticker for the chat screen countdown UX.
|
||||
/// Backend only emits discrete `session_timer` (60s remaining) and
|
||||
/// `session_warning` (180s remaining) events; the danger pill / expired banner
|
||||
/// transitions need a smooth tick. Computes remaining off `expiresAt` from the
|
||||
/// chat state and re-emits every second while a session is connected.
|
||||
@riverpod
|
||||
Stream<int> chatRemainingSeconds(Ref ref) async* {
|
||||
final chatState = ref.watch(chatProvider);
|
||||
if (chatState is! ChatConnectedData) return;
|
||||
final expiresAt = chatState.expiresAt;
|
||||
if (expiresAt == null) return;
|
||||
while (true) {
|
||||
final remaining = expiresAt.difference(DateTime.now()).inSeconds;
|
||||
yield remaining < 0 ? 0 : remaining;
|
||||
if (remaining <= 0) return;
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class Chat extends _$Chat {
|
||||
WebSocketChannel? _channel;
|
||||
@@ -109,10 +141,22 @@ class Chat extends _$Chat {
|
||||
Timer? _typingTimer;
|
||||
String? _connectedSessionId;
|
||||
|
||||
// Phase 4 — broadcast stream of `session_warning.kind` strings (e.g.
|
||||
// `three_minutes_left`). Screens listen via [warningStream] to fire one-shot
|
||||
// UI like the 3-min snackbar. Kept separate from state so the warning
|
||||
// doesn't accidentally re-fire on rebuild.
|
||||
final _warningController = StreamController<String>.broadcast();
|
||||
Stream<String> get warningStream => _warningController.stream;
|
||||
|
||||
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||
|
||||
@override
|
||||
ChatData build() => const ChatInitialData();
|
||||
ChatData build() {
|
||||
ref.onDispose(() {
|
||||
_warningController.close();
|
||||
});
|
||||
return const ChatInitialData();
|
||||
}
|
||||
|
||||
/// Idempotent connect: if we're already connected to [sessionId], refresh
|
||||
/// the session status from the server (in case it transitioned to closing /
|
||||
@@ -155,11 +199,16 @@ class Chat extends _$Chat {
|
||||
return;
|
||||
}
|
||||
final goodbyeSubmittedByMe = data?['goodbye_submitted_by_me'] as bool? ?? false;
|
||||
final mode = SessionMode.fromString(data?['mode'] as String?);
|
||||
final expiresAtRaw = data?['expires_at'] as String?;
|
||||
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
|
||||
state = current.copyWith(
|
||||
sessionClosing: status == SessionStatus.closing,
|
||||
sessionPaused: status == SessionStatus.extending,
|
||||
sessionExpired: false,
|
||||
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||
mode: mode,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
@@ -184,6 +233,9 @@ class Chat extends _$Chat {
|
||||
|
||||
final isClosing = sessionStatus == SessionStatus.closing;
|
||||
final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false;
|
||||
final mode = SessionMode.fromString(sessionData?['mode'] as String?);
|
||||
final expiresAtRaw = sessionData?['expires_at'] as String?;
|
||||
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
|
||||
|
||||
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
|
||||
final messagesData = response['data'] as List<dynamic>;
|
||||
@@ -225,6 +277,8 @@ class Chat extends _$Chat {
|
||||
messages: messages,
|
||||
sessionClosing: isClosing,
|
||||
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||
mode: mode,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
} catch (e) {
|
||||
state = const ChatErrorData('Gagal terhubung ke chat.');
|
||||
@@ -351,11 +405,41 @@ class Chat extends _$Chat {
|
||||
|
||||
case WsMessage.sessionTimer:
|
||||
final remaining = data['remaining_seconds'] as int?;
|
||||
state = current.copyWith(remainingSeconds: remaining);
|
||||
// When the server includes expires_at (Phase 4 dev resync + future
|
||||
// periodic ticks), update the local ticker reference. Backwards-
|
||||
// compatible: pre-Phase-4 events without `expires_at` are no-ops here.
|
||||
final expiresAtRaw = data['expires_at'] as String?;
|
||||
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
|
||||
state = current.copyWith(
|
||||
remainingSeconds: remaining,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionWarning:
|
||||
// Forward to listeners (chat screen drives a one-shot snackbar). Stream
|
||||
// is broadcast — subscribers may or may not be present; cheap if not.
|
||||
final kind = data['kind'] as String?;
|
||||
// Resync the local ticker — server may have shifted expires_at since
|
||||
// we last connected (e.g. extension, dev shortcut). Without this, the
|
||||
// last-2-min danger pill / expired banner can't track real time.
|
||||
final expiresAtRaw = data['expires_at'] as String?;
|
||||
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
|
||||
if (expiresAt != null) {
|
||||
state = current.copyWith(expiresAt: expiresAt);
|
||||
}
|
||||
if (kind != null) _warningController.add(kind);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionExpired:
|
||||
state = current.copyWith(sessionExpired: true);
|
||||
// Snap the local ticker to 0 so the floating expired banner appears
|
||||
// immediately. The server-side expires_at may have shifted (e.g.
|
||||
// dev /force-session-expires-at) ahead of our last refresh, so we
|
||||
// can't rely on the existing expiresAt value to reach 0 on its own.
|
||||
state = current.copyWith(
|
||||
sessionExpired: true,
|
||||
expiresAt: DateTime.now(),
|
||||
);
|
||||
break;
|
||||
|
||||
case WsMessage.sessionPaused:
|
||||
|
||||
@@ -6,7 +6,31 @@ part of 'chat_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatHash() => r'35388d2977db9cee4307697524620b22691c54f5';
|
||||
String _$chatRemainingSecondsHash() =>
|
||||
r'd7bce1bffe7d3034b6f4905194ead4dfaf473c92';
|
||||
|
||||
/// Phase 4 — derived seconds-left ticker for the chat screen countdown UX.
|
||||
/// Backend only emits discrete `session_timer` (60s remaining) and
|
||||
/// `session_warning` (180s remaining) events; the danger pill / expired banner
|
||||
/// transitions need a smooth tick. Computes remaining off `expiresAt` from the
|
||||
/// chat state and re-emits every second while a session is connected.
|
||||
///
|
||||
/// Copied from [chatRemainingSeconds].
|
||||
@ProviderFor(chatRemainingSeconds)
|
||||
final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
|
||||
chatRemainingSeconds,
|
||||
name: r'chatRemainingSecondsProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatRemainingSecondsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
|
||||
String _$chatHash() => r'f9d77e176acb682dc6477635e0e80a09e846988b';
|
||||
|
||||
/// See also [Chat].
|
||||
@ProviderFor(Chat)
|
||||
|
||||
@@ -8,27 +8,97 @@ class PriceTier {
|
||||
final int durationMinutes;
|
||||
final int price;
|
||||
final String label;
|
||||
final String? id;
|
||||
final String? tag;
|
||||
|
||||
PriceTier({required this.durationMinutes, required this.price, required this.label});
|
||||
const PriceTier({
|
||||
required this.durationMinutes,
|
||||
required this.price,
|
||||
required this.label,
|
||||
this.id,
|
||||
this.tag,
|
||||
});
|
||||
|
||||
/// Phase 4 shape: `{ id, minutes, price_idr, tag }` — used by the new
|
||||
/// chat/call tier groups. Falls back to the legacy free-trial-pricing shape
|
||||
/// (`{ duration_minutes, price, label }`) for back-compat with the Phase 3
|
||||
/// `/api/client/chat/pricing` payload still consumed by the legacy payment
|
||||
/// screen + bottom sheet.
|
||||
factory PriceTier.fromJson(Map<String, dynamic> json) {
|
||||
final minutes = (json['minutes'] ?? json['duration_minutes']) as int;
|
||||
final price = (json['price_idr'] ?? json['price']) as int;
|
||||
final label = (json['label'] as String?) ?? '$minutes Menit';
|
||||
return PriceTier(
|
||||
durationMinutes: json['duration_minutes'] as int,
|
||||
price: json['price'] as int,
|
||||
label: json['label'] as String,
|
||||
durationMinutes: minutes,
|
||||
price: price,
|
||||
label: label,
|
||||
id: (json['id'] as String?) ?? minutes.toString(),
|
||||
tag: json['tag'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// First-session discount block. Mirrors backend
|
||||
/// `pricing.first_session_discount`. Server-authoritative — the client only
|
||||
/// reads this; the actual discount price is re-validated on the backend when
|
||||
/// the payment session is created.
|
||||
class FirstSessionDiscount {
|
||||
final bool eligible;
|
||||
final int actualPriceIDR;
|
||||
final int gimmickPriceIDR;
|
||||
final int durationMinutes;
|
||||
final List<String> modes;
|
||||
|
||||
const FirstSessionDiscount({
|
||||
required this.eligible,
|
||||
required this.actualPriceIDR,
|
||||
required this.gimmickPriceIDR,
|
||||
required this.durationMinutes,
|
||||
required this.modes,
|
||||
});
|
||||
|
||||
factory FirstSessionDiscount.fromJson(Map<String, dynamic> json) {
|
||||
final modesRaw = json['modes'];
|
||||
final modes = modesRaw is List
|
||||
? modesRaw.map((e) => e.toString()).toList()
|
||||
: const <String>['chat'];
|
||||
return FirstSessionDiscount(
|
||||
eligible: json['eligible'] as bool? ?? false,
|
||||
actualPriceIDR: json['actual_price_idr'] as int? ?? 0,
|
||||
gimmickPriceIDR: json['gimmick_price_idr'] as int? ?? 0,
|
||||
durationMinutes: json['duration_minutes'] as int? ?? 0,
|
||||
modes: modes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PricingData {
|
||||
/// Legacy single-list tiers. Populated from `data.tiers` when the response
|
||||
/// uses the Phase 3 shape; populated from `data.chat.tiers` when the
|
||||
/// Phase 4 shape is returned (so existing callers keep working).
|
||||
final List<PriceTier> tiers;
|
||||
final bool freeTrialEligible;
|
||||
final int freeTrialDurationMinutes;
|
||||
|
||||
/// Phase 4 chat-mode tiers (`pricing.chat.tiers`). Empty when the backend
|
||||
/// still returns the Phase 3 shape.
|
||||
final List<PriceTier> chatTiers;
|
||||
|
||||
/// Phase 4 call-mode tiers (`pricing.call.tiers`). Empty when the backend
|
||||
/// still returns the Phase 3 shape.
|
||||
final List<PriceTier> callTiers;
|
||||
|
||||
/// Phase 4 first-session discount block. Null when the backend still
|
||||
/// returns the Phase 3 shape.
|
||||
final FirstSessionDiscount? firstSessionDiscount;
|
||||
|
||||
const PricingData({
|
||||
required this.tiers,
|
||||
required this.freeTrialEligible,
|
||||
this.freeTrialDurationMinutes = 5,
|
||||
this.chatTiers = const [],
|
||||
this.callTiers = const [],
|
||||
this.firstSessionDiscount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,9 +107,35 @@ Future<PricingData> chatPricing(Ref ref) async {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final response = await apiClient.get('/api/client/chat/pricing');
|
||||
final data = response['data'] as Map<String, dynamic>;
|
||||
|
||||
// Phase 4 shape — `data.chat.tiers` + `data.call.tiers` + `first_session_discount`.
|
||||
// Phase 3 shape — `data.tiers` + `data.free_trial`. Detect which we got and
|
||||
// populate the model accordingly.
|
||||
final hasPhase4Groups = data['chat'] is Map<String, dynamic>;
|
||||
|
||||
if (hasPhase4Groups) {
|
||||
final chat = data['chat'] as Map<String, dynamic>;
|
||||
final call = (data['call'] as Map<String, dynamic>?) ?? const {};
|
||||
final chatTiers = (chat['tiers'] as List<dynamic>? ?? const [])
|
||||
.map((t) => PriceTier.fromJson(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
final callTiers = (call['tiers'] as List<dynamic>? ?? const [])
|
||||
.map((t) => PriceTier.fromJson(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
final discountJson = data['first_session_discount'] as Map<String, dynamic>?;
|
||||
final discount = discountJson != null ? FirstSessionDiscount.fromJson(discountJson) : null;
|
||||
return PricingData(
|
||||
tiers: chatTiers,
|
||||
freeTrialEligible: false,
|
||||
chatTiers: chatTiers,
|
||||
callTiers: callTiers,
|
||||
firstSessionDiscount: discount,
|
||||
);
|
||||
}
|
||||
|
||||
final tiersJson = data['tiers'] as List<dynamic>;
|
||||
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
|
||||
final freeTrial = data['free_trial'] as Map<String, dynamic>;
|
||||
final freeTrial = (data['free_trial'] as Map<String, dynamic>?) ?? const {};
|
||||
|
||||
return PricingData(
|
||||
tiers: tiers,
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'chat_opening_provider.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatPricingHash() => r'53ca829d46f7ba3b481e7a6b54482c62f9fe3ad0';
|
||||
String _$chatPricingHash() => r'6dfbdf77942a67d3da689849eda89fc1fa3e6e39';
|
||||
|
||||
/// See also [chatPricing].
|
||||
@ProviderFor(chatPricing)
|
||||
|
||||
@@ -30,6 +30,13 @@ class ClosureCompleteData extends SessionClosureData {
|
||||
const ClosureCompleteData();
|
||||
}
|
||||
|
||||
/// Stage 7 — emitted when the close-session API returns 409 (mitra-rejects-
|
||||
/// close path). The chat screen surfaces a "bestie offline / returning"
|
||||
/// fallback popup; Stage 8 will own the proper variant.
|
||||
class ClosureRejectedByMitraData extends SessionClosureData {
|
||||
const ClosureRejectedByMitraData();
|
||||
}
|
||||
|
||||
class ClosureErrorData extends SessionClosureData {
|
||||
final String message;
|
||||
const ClosureErrorData(this.message);
|
||||
@@ -111,4 +118,37 @@ class SessionClosure extends _$SessionClosure {
|
||||
state = const ClosureErrorData('Gagal mengirim pesan penutup.');
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage 7 — customer-initiated close. Calls
|
||||
/// `POST /api/client/session/:sessionId/end`. On success, emits
|
||||
/// `ClosureCompleteData` and refreshes the active-session snapshot so the
|
||||
/// home CTA flips back to "Mulai Curhat" without waiting for the next poll.
|
||||
/// On 409, emits `ClosureRejectedByMitraData` so the chat screen can show
|
||||
/// the bestie-returning fallback popup. Other errors fall back to
|
||||
/// `ClosureErrorData`.
|
||||
Future<void> closeSession(String sessionId) async {
|
||||
try {
|
||||
await ref.read(apiClientProvider).post(
|
||||
'/api/client/session/$sessionId/end',
|
||||
data: const <String, dynamic>{},
|
||||
);
|
||||
state = const ClosureCompleteData();
|
||||
ref.invalidate(activeSessionProvider);
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 409) {
|
||||
state = const ClosureRejectedByMitraData();
|
||||
} else {
|
||||
final code = e.response?.data?['error']?['code'];
|
||||
if (code == 'SESSION_NOT_ACTIVE') {
|
||||
// Server treats it as already closed — equivalent to success.
|
||||
state = const ClosureCompleteData();
|
||||
ref.invalidate(activeSessionProvider);
|
||||
} else {
|
||||
state = const ClosureErrorData('Gagal mengakhiri sesi.');
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
state = const ClosureErrorData('Gagal mengakhiri sesi.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sessionClosureHash() => r'f238e4098aefdd942314a13eb3fb77ed19cd2b77';
|
||||
String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b';
|
||||
|
||||
/// See also [SessionClosure].
|
||||
@ProviderFor(SessionClosure)
|
||||
|
||||
13
client_app/lib/core/config/app_config_provider.dart
Normal file
13
client_app/lib/core/config/app_config_provider.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Phase 4 Stage 7 — UX A/B toggle for the two-step end-session confirm.
|
||||
///
|
||||
/// Backed by `app_config.end_session_two_step_confirm` (seeded `true` in
|
||||
/// Phase 4 Stage 1.5). The plan mentions an A/B switch but no client-facing
|
||||
/// endpoint is exposed yet — Stage 1.5 only seeded the row. Until a public
|
||||
/// `/api/shared/config/app-flags` (or similar) is added, this provider keeps
|
||||
/// the seed default on-device. When the endpoint lands, swap the override
|
||||
/// for a `FutureProvider` that fetches it.
|
||||
///
|
||||
/// TODO(phase4-followup): wire to backend once the read-side endpoint is added.
|
||||
final endSessionTwoStepConfirmProvider = Provider<bool>((ref) => true);
|
||||
@@ -64,6 +64,21 @@ class ExtensionStatus {
|
||||
ExtensionStatus._();
|
||||
}
|
||||
|
||||
/// Session mode — chat or voice call. Mirrors backend `payment_sessions.mode`
|
||||
/// (added in Phase 4 stage 1). A `call` session is functionally a chat with a
|
||||
/// "voice call" badge and (eventually) a Meet link the mitra pastes manually;
|
||||
/// no real audio transport is built yet.
|
||||
enum SessionMode {
|
||||
chat('chat'),
|
||||
call('call');
|
||||
|
||||
final String value;
|
||||
const SessionMode(this.value);
|
||||
|
||||
static SessionMode fromString(String? v) =>
|
||||
values.firstWhere((e) => e.value == v, orElse: () => SessionMode.chat);
|
||||
}
|
||||
|
||||
/// Session topic sensitivity
|
||||
enum TopicSensitivity {
|
||||
regular('regular'),
|
||||
@@ -101,6 +116,9 @@ class WsMessage {
|
||||
static const sessionCompleted = 'session_completed';
|
||||
static const sessionPaused = 'session_paused';
|
||||
static const sessionResumed = 'session_resumed';
|
||||
// Phase 4 — soft countdown warning (`kind: 'three_minutes_left'`).
|
||||
// Customer-only: mitra never sees a countdown.
|
||||
static const sessionWarning = 'session_warning';
|
||||
|
||||
// Extension
|
||||
static const extensionRequest = 'extension_request';
|
||||
|
||||
115
client_app/lib/core/notifications/notif_permission.dart
Normal file
115
client_app/lib/core/notifications/notif_permission.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as ph;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'notif_permission.g.dart';
|
||||
|
||||
enum NotifPermStatus { notDetermined, granted, denied }
|
||||
|
||||
/// Wraps `firebase_messaging` + `permission_handler` for the Phase 4 Stage 4
|
||||
/// notif gate. Reads/requests are platform-routed:
|
||||
/// - iOS uses Firebase Messaging (which surfaces the system UNNotification
|
||||
/// authorization status).
|
||||
/// - Android 13+ uses `permission_handler` for `Permission.notification`
|
||||
/// (POST_NOTIFICATIONS runtime). Older Android always reports granted.
|
||||
class NotifPermission {
|
||||
const NotifPermission();
|
||||
|
||||
Future<NotifPermStatus> readStatus() async {
|
||||
final phStatus = await ph.Permission.notification.status;
|
||||
return _mapPh(phStatus);
|
||||
}
|
||||
|
||||
/// Shows the OS prompt only when status is [NotifPermStatus.notDetermined].
|
||||
/// Otherwise returns the current status without re-prompting (the OS would
|
||||
/// no-op anyway on a previously-resolved permission).
|
||||
Future<NotifPermStatus> request() async {
|
||||
final current = await readStatus();
|
||||
if (current != NotifPermStatus.notDetermined) return current;
|
||||
|
||||
// Firebase Messaging requestPermission triggers the iOS prompt; on Android
|
||||
// it is a no-op for permission UI but registers the FCM iOS APNS token.
|
||||
// We still call permission_handler.request() so Android 13+ shows the
|
||||
// POST_NOTIFICATIONS dialog.
|
||||
await FirebaseMessaging.instance.requestPermission();
|
||||
final phResult = await ph.Permission.notification.request();
|
||||
return _mapPh(phResult);
|
||||
}
|
||||
|
||||
Future<void> openAppSettings() => ph.openAppSettings();
|
||||
|
||||
NotifPermStatus _mapPh(ph.PermissionStatus s) {
|
||||
if (s.isGranted || s.isLimited || s.isProvisional) {
|
||||
return NotifPermStatus.granted;
|
||||
}
|
||||
if (s.isDenied) return NotifPermStatus.notDetermined;
|
||||
// permanentlyDenied + restricted both behave like "denied — open settings".
|
||||
return NotifPermStatus.denied;
|
||||
}
|
||||
}
|
||||
|
||||
final _helperProvider = Provider<NotifPermission>((_) => const NotifPermission());
|
||||
|
||||
/// Cached notif permission status. Auto-refreshes on app foreground via an
|
||||
/// internal `WidgetsBindingObserver` — there is no shared `appLifecycleProvider`
|
||||
/// in this codebase yet, so the observer is owned here.
|
||||
@Riverpod(keepAlive: true)
|
||||
class NotifPermissionStatus extends _$NotifPermissionStatus {
|
||||
_LifecycleHook? _hook;
|
||||
|
||||
@override
|
||||
Future<NotifPermStatus> build() async {
|
||||
_hook ??= _LifecycleHook(_onResumed);
|
||||
ref.onDispose(() {
|
||||
_hook?.detach();
|
||||
_hook = null;
|
||||
});
|
||||
return ref.read(_helperProvider).readStatus();
|
||||
}
|
||||
|
||||
/// Triggers the OS prompt (only if [NotifPermStatus.notDetermined]) and
|
||||
/// re-publishes the resolved status.
|
||||
Future<NotifPermStatus> request() async {
|
||||
final result = await ref.read(_helperProvider).request();
|
||||
state = AsyncData(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> openAppSettings() =>
|
||||
ref.read(_helperProvider).openAppSettings();
|
||||
|
||||
/// Force a re-read — used after returning from app settings.
|
||||
Future<void> refresh() async {
|
||||
final s = await ref.read(_helperProvider).readStatus();
|
||||
if (state.valueOrNull == s) return;
|
||||
state = AsyncData(s);
|
||||
}
|
||||
|
||||
void _onResumed() {
|
||||
// Fire-and-forget; refresh is idempotent.
|
||||
// ignore: unawaited_futures
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
class _LifecycleHook with WidgetsBindingObserver {
|
||||
_LifecycleHook(this._onResumed) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
final VoidCallback _onResumed;
|
||||
|
||||
void detach() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_onResumed();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
client_app/lib/core/notifications/notif_permission.g.dart
Normal file
31
client_app/lib/core/notifications/notif_permission.g.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'notif_permission.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$notifPermissionStatusHash() =>
|
||||
r'16c81af5e48dab2c7d0cf33c985e0ca7c3d01006';
|
||||
|
||||
/// Cached notif permission status. Auto-refreshes on app foreground via an
|
||||
/// internal `WidgetsBindingObserver` — there is no shared `appLifecycleProvider`
|
||||
/// in this codebase yet, so the observer is owned here.
|
||||
///
|
||||
/// Copied from [NotifPermissionStatus].
|
||||
@ProviderFor(NotifPermissionStatus)
|
||||
final notifPermissionStatusProvider =
|
||||
AsyncNotifierProvider<NotifPermissionStatus, NotifPermStatus>.internal(
|
||||
NotifPermissionStatus.new,
|
||||
name: r'notifPermissionStatusProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$notifPermissionStatusHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$NotifPermissionStatus = AsyncNotifier<NotifPermStatus>;
|
||||
// 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
|
||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad';
|
||||
String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb';
|
||||
|
||||
/// See also [Pairing].
|
||||
@ProviderFor(Pairing)
|
||||
|
||||
289
client_app/lib/core/theme/_preview.dart
Normal file
289
client_app/lib/core/theme/_preview.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'halo_tokens.dart';
|
||||
import 'widgets/widgets.dart';
|
||||
|
||||
const bool kThemePreviewEnabled = bool.fromEnvironment(
|
||||
'THEME_PREVIEW',
|
||||
defaultValue: false,
|
||||
);
|
||||
|
||||
class ThemePreviewScreen extends StatefulWidget {
|
||||
const ThemePreviewScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ThemePreviewScreen> createState() => _ThemePreviewScreenState();
|
||||
}
|
||||
|
||||
class _ThemePreviewScreenState extends State<ThemePreviewScreen> {
|
||||
final Set<String> _selectedChips = {'gak nyenyak'};
|
||||
bool _disablePrimary = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Halo theme preview')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s20),
|
||||
children: [
|
||||
_section('Typography'),
|
||||
Text('display large 36/700', style: text.displayLarge),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Text('title large 22/700', style: text.titleLarge),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Text(
|
||||
'body medium 15/400 — Poppins',
|
||||
style: text.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Text(
|
||||
'label small 10/600 — caption tracking',
|
||||
style: text.labelSmall,
|
||||
),
|
||||
_divider(),
|
||||
|
||||
_section('Color tokens'),
|
||||
const Wrap(
|
||||
spacing: HaloSpacing.s8,
|
||||
runSpacing: HaloSpacing.s8,
|
||||
children: [
|
||||
_Swatch('brand', HaloTokens.brand),
|
||||
_Swatch('brandDark', HaloTokens.brandDark),
|
||||
_Swatch('brandSoft', HaloTokens.brandSoft),
|
||||
_Swatch('accent', HaloTokens.accent),
|
||||
_Swatch('mint', HaloTokens.mint),
|
||||
_Swatch('lilac', HaloTokens.lilac),
|
||||
_Swatch('success', HaloTokens.success),
|
||||
_Swatch('danger', HaloTokens.danger),
|
||||
_Swatch('ink', HaloTokens.ink, label: Colors.white),
|
||||
_Swatch('inkSoft', HaloTokens.inkSoft, label: Colors.white),
|
||||
],
|
||||
),
|
||||
_divider(),
|
||||
|
||||
_section('HaloButton'),
|
||||
Wrap(
|
||||
spacing: HaloSpacing.s8,
|
||||
runSpacing: HaloSpacing.s8,
|
||||
children: [
|
||||
HaloButton(
|
||||
label: 'primary md',
|
||||
onPressed: _disablePrimary ? null : () {},
|
||||
),
|
||||
HaloButton(
|
||||
label: 'secondary',
|
||||
variant: HaloButtonVariant.secondary,
|
||||
onPressed: () {},
|
||||
),
|
||||
HaloButton(
|
||||
label: 'ghost',
|
||||
variant: HaloButtonVariant.ghost,
|
||||
onPressed: () {},
|
||||
),
|
||||
HaloButton(
|
||||
label: 'small',
|
||||
size: HaloButtonSize.sm,
|
||||
onPressed: () {},
|
||||
),
|
||||
HaloButton(
|
||||
label: 'large with icon',
|
||||
size: HaloButtonSize.lg,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
onPressed: () {},
|
||||
),
|
||||
const HaloButton(
|
||||
label: 'disabled',
|
||||
onPressed: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Row(
|
||||
children: [
|
||||
Switch(
|
||||
value: _disablePrimary,
|
||||
onChanged: (v) => setState(() => _disablePrimary = v),
|
||||
),
|
||||
const Text('disable primary'),
|
||||
],
|
||||
),
|
||||
_divider(),
|
||||
|
||||
_section('HaloOrb'),
|
||||
Wrap(
|
||||
spacing: HaloSpacing.s12,
|
||||
runSpacing: HaloSpacing.s12,
|
||||
children: List.generate(
|
||||
6,
|
||||
(i) => HaloOrb(seed: i, label: 'ABCDEF'[i]),
|
||||
),
|
||||
),
|
||||
_divider(),
|
||||
|
||||
_section('HaloStepDots'),
|
||||
for (int c = 1; c <= 4; c++) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: HaloSpacing.s8),
|
||||
child: HaloStepDots(total: 4, current: c),
|
||||
),
|
||||
],
|
||||
_divider(),
|
||||
|
||||
_section('HaloChip (ESP-style multi-select)'),
|
||||
Wrap(
|
||||
spacing: HaloSpacing.s8,
|
||||
runSpacing: HaloSpacing.s8,
|
||||
children: [
|
||||
for (final t in const [
|
||||
'gak nyenyak',
|
||||
'overthinking',
|
||||
'putus',
|
||||
'kerjaan',
|
||||
'keluarga',
|
||||
'sendiri',
|
||||
])
|
||||
HaloChip(
|
||||
label: t,
|
||||
selected: _selectedChips.contains(t),
|
||||
onTap: () => setState(() {
|
||||
if (!_selectedChips.add(t)) _selectedChips.remove(t);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
_divider(),
|
||||
|
||||
_section('HaloBottomSheet / HaloPopup / HaloSnackbar'),
|
||||
Wrap(
|
||||
spacing: HaloSpacing.s8,
|
||||
runSpacing: HaloSpacing.s8,
|
||||
children: [
|
||||
HaloButton(
|
||||
label: 'show bottom sheet',
|
||||
variant: HaloButtonVariant.secondary,
|
||||
onPressed: () => HaloBottomSheet.show<void>(
|
||||
context,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('halo bestie', style: text.titleLarge),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Text(
|
||||
'mau verif nomor dulu, atau ngobrol anonim?',
|
||||
style: text.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
HaloButton(
|
||||
label: 'verif nomor',
|
||||
fullWidth: true,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
HaloButton(
|
||||
label: 'lanjut anonim',
|
||||
variant: HaloButtonVariant.ghost,
|
||||
fullWidth: true,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
HaloButton(
|
||||
label: 'show popup',
|
||||
variant: HaloButtonVariant.secondary,
|
||||
onPressed: () => HaloPopup.show<void>(
|
||||
context,
|
||||
title: 'verif lagi penuh',
|
||||
body: 'coba lagi nanti, atau lanjut tanpa verif aja.',
|
||||
icon: const Icon(
|
||||
Icons.lock_clock_rounded,
|
||||
size: 40,
|
||||
color: HaloTokens.brand,
|
||||
),
|
||||
primary: HaloPopupAction(
|
||||
label: 'lanjut tanpa verif',
|
||||
onPressed: () {},
|
||||
),
|
||||
secondary: HaloPopupAction(
|
||||
label: 'hubungi admin',
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
HaloButton(
|
||||
label: 'show snackbar',
|
||||
variant: HaloButtonVariant.secondary,
|
||||
onPressed: () => HaloSnackbar.show(
|
||||
context,
|
||||
'sisa 3 menit lagi ya',
|
||||
icon: '⏳',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_divider(),
|
||||
|
||||
_section('Input'),
|
||||
const TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'mau dipanggil apa?',
|
||||
labelText: 'nama panggilan',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(String title) => Padding(
|
||||
padding: const EdgeInsets.only(top: HaloSpacing.s16, bottom: HaloSpacing.s8),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _divider() => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16),
|
||||
child: Divider(),
|
||||
);
|
||||
}
|
||||
|
||||
class _Swatch extends StatelessWidget {
|
||||
const _Swatch(this.name, this.color, {this.label = HaloTokens.ink});
|
||||
final String name;
|
||||
final Color color;
|
||||
final Color label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 110,
|
||||
height: 64,
|
||||
padding: const EdgeInsets.all(HaloSpacing.s8),
|
||||
alignment: Alignment.bottomLeft,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: HaloRadius.md,
|
||||
border: Border.all(color: HaloTokens.border),
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: label,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
316
client_app/lib/core/theme/halo_theme.dart
Normal file
316
client_app/lib/core/theme/halo_theme.dart
Normal file
@@ -0,0 +1,316 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'halo_tokens.dart';
|
||||
|
||||
ThemeData haloThemeData() {
|
||||
final base = ColorScheme.fromSeed(
|
||||
seedColor: HaloTokens.brand,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
|
||||
final colorScheme = base.copyWith(
|
||||
primary: HaloTokens.brand,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: HaloTokens.brandSoft,
|
||||
onPrimaryContainer: HaloTokens.brandDark,
|
||||
secondary: HaloTokens.accent,
|
||||
onSecondary: HaloTokens.ink,
|
||||
secondaryContainer: HaloTokens.accentSoft,
|
||||
onSecondaryContainer: HaloTokens.brandDark,
|
||||
surface: HaloTokens.surface,
|
||||
onSurface: HaloTokens.ink,
|
||||
surfaceContainerHighest: HaloTokens.bg,
|
||||
error: HaloTokens.danger,
|
||||
onError: Colors.white,
|
||||
outline: HaloTokens.border,
|
||||
);
|
||||
|
||||
const textTheme = TextTheme(
|
||||
displayLarge: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 36,
|
||||
height: 40 / 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
displayMedium: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 30,
|
||||
height: 34 / 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
displaySmall: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 26,
|
||||
height: 30 / 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
height: 28 / 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
height: 28 / 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 18,
|
||||
height: 24 / 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
height: 22 / 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 16,
|
||||
height: 24 / 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
height: 22 / 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
height: 18 / 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
labelLarge: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
height: 22 / 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12,
|
||||
height: 16 / 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 10,
|
||||
height: 14 / 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: HaloTokens.bg,
|
||||
textTheme: textTheme,
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: HaloTokens.bg,
|
||||
foregroundColor: HaloTokens.ink,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
height: 28 / 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: HaloTokens.brand,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: HaloTokens.brandSoft,
|
||||
disabledForegroundColor: HaloTokens.inkMuted,
|
||||
elevation: 0,
|
||||
shadowColor: const Color(0x59E17A9D),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s24,
|
||||
vertical: HaloSpacing.s16,
|
||||
),
|
||||
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: HaloTokens.brandDark,
|
||||
side: const BorderSide(color: HaloTokens.border),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s24,
|
||||
vertical: HaloSpacing.s16,
|
||||
),
|
||||
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: HaloTokens.brandDark,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s16,
|
||||
vertical: HaloSpacing.s12,
|
||||
),
|
||||
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: HaloTokens.surface,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s20,
|
||||
vertical: HaloSpacing.s20,
|
||||
),
|
||||
constraints: BoxConstraints(minHeight: 64),
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: HaloRadius.lg,
|
||||
borderSide: BorderSide(color: HaloTokens.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: HaloRadius.lg,
|
||||
borderSide: BorderSide(color: HaloTokens.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: HaloRadius.lg,
|
||||
borderSide: BorderSide(color: HaloTokens.brand, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: HaloRadius.lg,
|
||||
borderSide: BorderSide(color: HaloTokens.danger),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: HaloRadius.lg,
|
||||
borderSide: BorderSide(color: HaloTokens.danger, width: 2),
|
||||
),
|
||||
),
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
surfaceTintColor: HaloTokens.surface,
|
||||
modalBackgroundColor: HaloTokens.surface,
|
||||
modalBarrierColor: Color(0x66000000),
|
||||
elevation: 0,
|
||||
modalElevation: 0,
|
||||
showDragHandle: true,
|
||||
dragHandleColor: HaloTokens.brandSoft,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(24),
|
||||
topRight: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
),
|
||||
dialogTheme: const DialogThemeData(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
surfaceTintColor: HaloTokens.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: HaloRadius.xl),
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
contentTextStyle: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
height: 22 / 15,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: HaloTokens.ink,
|
||||
contentTextStyle: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: HaloRadius.pill),
|
||||
elevation: 4,
|
||||
insetPadding: EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s16,
|
||||
vertical: HaloSpacing.s12,
|
||||
),
|
||||
actionTextColor: HaloTokens.brandSoft,
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
selectedColor: HaloTokens.brand,
|
||||
disabledColor: HaloTokens.brandSoft.withValues(alpha: 0.5),
|
||||
labelStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
secondaryLabelStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s16,
|
||||
vertical: HaloSpacing.s8,
|
||||
),
|
||||
side: const BorderSide(color: HaloTokens.border),
|
||||
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: HaloTokens.border,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
129
client_app/lib/core/theme/halo_tokens.dart
Normal file
129
client_app/lib/core/theme/halo_tokens.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Design tokens for the HaloBestie warm palette.
|
||||
///
|
||||
/// Mirrors `requirement/Figma/handoff/tokens.json`. Three palettes
|
||||
/// (warm/calm/playful) exist in the source-of-truth JSON; only `warm`
|
||||
/// ships in code today — the others are stubbed for phase 5.
|
||||
///
|
||||
/// Naming convention: every token prefixed with `Halo*` and grouped into
|
||||
/// purpose classes (`HaloTokens` for colors, `HaloSpacing`, `HaloRadius`,
|
||||
/// `HaloMotion`, `HaloShadows`).
|
||||
class HaloTokens {
|
||||
const HaloTokens._();
|
||||
|
||||
// Warm palette — default.
|
||||
static const Color bg = Color(0xFFFDF7F4);
|
||||
static const Color surface = Color(0xFFFFFFFF);
|
||||
static const Color ink = Color(0xFF2A1820);
|
||||
static const Color inkSoft = Color(0xFF6B5560);
|
||||
static const Color inkMuted = Color(0xFF9C8590);
|
||||
static const Color brand = Color(0xFFE17A9D);
|
||||
static const Color brandDark = Color(0xFF8C3255);
|
||||
static const Color brandSoft = Color(0xFFF7E4E9);
|
||||
static const Color brandSofter = Color(0xFFFBEFF3);
|
||||
static const Color accent = Color(0xFFF7B26A);
|
||||
static const Color accentSoft = Color(0xFFFCEAD3);
|
||||
static const Color mint = Color(0xFFB8DBC8);
|
||||
static const Color lilac = Color(0xFFD4C5E8);
|
||||
static const Color success = Color(0xFF5BA67F);
|
||||
static const Color danger = Color(0xFFD86B6B);
|
||||
static const Color border = Color(0xFFF0E4E8);
|
||||
|
||||
// Font family names — must match the `family:` entries in pubspec.yaml.
|
||||
// Falls back to system fonts when the .ttf assets are not bundled.
|
||||
static const String fontDisplay = 'BricolageGrotesque';
|
||||
static const String fontBody = 'Poppins';
|
||||
static const String fontMono = 'JetBrainsMono';
|
||||
|
||||
// TODO: phase5 — calm palette
|
||||
// static const Color calmBg = Color(0xFFF6F4F8);
|
||||
// static const Color calmBrand = Color(0xFF9B8BC4);
|
||||
// ...
|
||||
|
||||
// TODO: phase5 — playful palette
|
||||
// static const Color playfulBg = Color(0xFFFFF5F8);
|
||||
// static const Color playfulBrand = Color(0xFFFF69A0);
|
||||
// ...
|
||||
}
|
||||
|
||||
class HaloSpacing {
|
||||
const HaloSpacing._();
|
||||
|
||||
static const double s0 = 0;
|
||||
static const double s4 = 4;
|
||||
static const double s8 = 8;
|
||||
static const double s12 = 12;
|
||||
static const double s16 = 16;
|
||||
static const double s20 = 20;
|
||||
static const double s24 = 24;
|
||||
static const double s32 = 32;
|
||||
static const double s40 = 40;
|
||||
static const double s48 = 48;
|
||||
static const double s64 = 64;
|
||||
static const double s80 = 80;
|
||||
}
|
||||
|
||||
class HaloRadius {
|
||||
const HaloRadius._();
|
||||
|
||||
static const Radius _sm = Radius.circular(8);
|
||||
static const Radius _md = Radius.circular(12);
|
||||
static const Radius _lg = Radius.circular(16);
|
||||
static const Radius _xl = Radius.circular(22);
|
||||
static const Radius _pill = Radius.circular(9999);
|
||||
|
||||
static const BorderRadius sm = BorderRadius.all(_sm);
|
||||
static const BorderRadius md = BorderRadius.all(_md);
|
||||
static const BorderRadius lg = BorderRadius.all(_lg);
|
||||
static const BorderRadius xl = BorderRadius.all(_xl);
|
||||
static const BorderRadius pill = BorderRadius.all(_pill);
|
||||
}
|
||||
|
||||
class HaloMotion {
|
||||
const HaloMotion._();
|
||||
|
||||
static const Duration fast = Duration(milliseconds: 180);
|
||||
static const Duration normal = Duration(milliseconds: 280);
|
||||
static const Duration slow = Duration(milliseconds: 420);
|
||||
|
||||
static const Cubic ease = Cubic(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
class HaloShadows {
|
||||
const HaloShadows._();
|
||||
|
||||
static const List<BoxShadow> soft = [
|
||||
BoxShadow(
|
||||
color: Color(0x0A8C3255),
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x0F8C3255),
|
||||
offset: Offset(0, 8),
|
||||
blurRadius: 24,
|
||||
),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> card = [
|
||||
BoxShadow(
|
||||
color: Color(0x0D8C3255),
|
||||
offset: Offset(0, 2),
|
||||
blurRadius: 6,
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x1A8C3255),
|
||||
offset: Offset(0, 18),
|
||||
blurRadius: 40,
|
||||
),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> button = [
|
||||
BoxShadow(
|
||||
color: Color(0x59E17A9D),
|
||||
offset: Offset(0, 4),
|
||||
blurRadius: 14,
|
||||
),
|
||||
];
|
||||
}
|
||||
42
client_app/lib/core/theme/widgets/halo_bottom_sheet.dart
Normal file
42
client_app/lib/core/theme/widgets/halo_bottom_sheet.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../halo_tokens.dart';
|
||||
|
||||
class HaloBottomSheet {
|
||||
const HaloBottomSheet._();
|
||||
|
||||
static Future<T?> show<T>(
|
||||
BuildContext context, {
|
||||
required Widget child,
|
||||
bool isDismissible = true,
|
||||
bool enableDrag = true,
|
||||
bool isScrollControlled = false,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
isScrollControlled: isScrollControlled,
|
||||
backgroundColor: HaloTokens.surface,
|
||||
barrierColor: const Color(0x66000000),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(24),
|
||||
topRight: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
showDragHandle: true,
|
||||
builder: (ctx) => SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s8,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
152
client_app/lib/core/theme/widgets/halo_button.dart
Normal file
152
client_app/lib/core/theme/widgets/halo_button.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../halo_tokens.dart';
|
||||
|
||||
enum HaloButtonVariant { primary, secondary, ghost }
|
||||
|
||||
enum HaloButtonSize { sm, md, lg }
|
||||
|
||||
class HaloButton extends StatelessWidget {
|
||||
const HaloButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.variant = HaloButtonVariant.primary,
|
||||
this.size = HaloButtonSize.md,
|
||||
this.icon,
|
||||
this.fullWidth = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final HaloButtonVariant variant;
|
||||
final HaloButtonSize size;
|
||||
final Widget? icon;
|
||||
final bool fullWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = onPressed == null;
|
||||
final padding = _padding();
|
||||
final fontSize = _fontSize();
|
||||
const shape = RoundedRectangleBorder(borderRadius: HaloRadius.pill);
|
||||
final textStyle = TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
|
||||
Widget child = _content(textStyle);
|
||||
|
||||
Widget button;
|
||||
switch (variant) {
|
||||
case HaloButtonVariant.primary:
|
||||
button = Container(
|
||||
decoration: disabled
|
||||
? null
|
||||
: const BoxDecoration(
|
||||
borderRadius: HaloRadius.pill,
|
||||
boxShadow: HaloShadows.button,
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: HaloTokens.brand,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: HaloTokens.brandSoft,
|
||||
disabledForegroundColor: HaloTokens.inkMuted,
|
||||
elevation: 0,
|
||||
padding: padding,
|
||||
shape: shape,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case HaloButtonVariant.secondary:
|
||||
button = OutlinedButton(
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: HaloTokens.brandDark,
|
||||
disabledForegroundColor: HaloTokens.inkMuted,
|
||||
backgroundColor: HaloTokens.surface,
|
||||
side: BorderSide(
|
||||
color: disabled ? HaloTokens.border : HaloTokens.brandSoft,
|
||||
),
|
||||
padding: padding,
|
||||
shape: shape,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
break;
|
||||
case HaloButtonVariant.ghost:
|
||||
button = TextButton(
|
||||
onPressed: onPressed,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: HaloTokens.brandDark,
|
||||
disabledForegroundColor: HaloTokens.inkMuted,
|
||||
padding: padding,
|
||||
shape: shape,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (fullWidth) {
|
||||
return SizedBox(width: double.infinity, child: button);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
Widget _content(TextStyle textStyle) {
|
||||
if (icon == null) {
|
||||
return Text(label, style: textStyle);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconTheme(
|
||||
data: IconThemeData(size: textStyle.fontSize! + 2),
|
||||
child: icon!,
|
||||
),
|
||||
const SizedBox(width: HaloSpacing.s8),
|
||||
Text(label, style: textStyle),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
EdgeInsets _padding() {
|
||||
switch (size) {
|
||||
case HaloButtonSize.sm:
|
||||
return const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s16,
|
||||
vertical: HaloSpacing.s8,
|
||||
);
|
||||
case HaloButtonSize.md:
|
||||
return const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s24,
|
||||
vertical: HaloSpacing.s12,
|
||||
);
|
||||
case HaloButtonSize.lg:
|
||||
return const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s32,
|
||||
vertical: HaloSpacing.s16,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double _fontSize() {
|
||||
switch (size) {
|
||||
case HaloButtonSize.sm:
|
||||
return 13;
|
||||
case HaloButtonSize.md:
|
||||
return 15;
|
||||
case HaloButtonSize.lg:
|
||||
return 16;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
client_app/lib/core/theme/widgets/halo_chip.dart
Normal file
75
client_app/lib/core/theme/widgets/halo_chip.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../halo_tokens.dart';
|
||||
|
||||
class HaloChip extends StatelessWidget {
|
||||
const HaloChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
final Widget? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = onTap == null;
|
||||
final bgColor = selected
|
||||
? HaloTokens.brand
|
||||
: disabled
|
||||
? HaloTokens.brandSofter
|
||||
: HaloTokens.surface;
|
||||
final fgColor = selected
|
||||
? Colors.white
|
||||
: disabled
|
||||
? HaloTokens.inkMuted
|
||||
: HaloTokens.ink;
|
||||
final borderColor = selected ? HaloTokens.brand : HaloTokens.border;
|
||||
|
||||
return Material(
|
||||
color: bgColor,
|
||||
borderRadius: HaloRadius.pill,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: HaloRadius.pill,
|
||||
child: AnimatedContainer(
|
||||
duration: HaloMotion.fast,
|
||||
curve: HaloMotion.ease,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s16,
|
||||
vertical: HaloSpacing.s8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: HaloRadius.pill,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
IconTheme(
|
||||
data: IconThemeData(size: 16, color: fgColor),
|
||||
child: icon!,
|
||||
),
|
||||
const SizedBox(width: HaloSpacing.s8),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: fgColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
client_app/lib/core/theme/widgets/halo_orb.dart
Normal file
61
client_app/lib/core/theme/widgets/halo_orb.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../halo_tokens.dart';
|
||||
|
||||
/// A soft gradient circle used as an avatar/identity glyph.
|
||||
///
|
||||
/// `seed` deterministically picks a hue blend from the warm palette.
|
||||
class HaloOrb extends StatelessWidget {
|
||||
const HaloOrb({
|
||||
super.key,
|
||||
required this.seed,
|
||||
this.size = 64,
|
||||
this.label,
|
||||
});
|
||||
|
||||
final int seed;
|
||||
final double size;
|
||||
final String? label;
|
||||
|
||||
static const List<List<Color>> _gradients = [
|
||||
[HaloTokens.brand, HaloTokens.brandDark],
|
||||
[HaloTokens.accent, HaloTokens.brand],
|
||||
[HaloTokens.lilac, HaloTokens.brand],
|
||||
[HaloTokens.mint, HaloTokens.accent],
|
||||
[HaloTokens.brandSoft, HaloTokens.brand],
|
||||
[HaloTokens.accentSoft, HaloTokens.accent],
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = _gradients[seed.abs() % _gradients.length];
|
||||
final initial = (label ?? '').isNotEmpty
|
||||
? label!.substring(0, 1).toUpperCase()
|
||||
: null;
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: colors,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: HaloShadows.soft,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: initial == null
|
||||
? null
|
||||
: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
client_app/lib/core/theme/widgets/halo_popup.dart
Normal file
94
client_app/lib/core/theme/widgets/halo_popup.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../halo_tokens.dart';
|
||||
import 'halo_button.dart';
|
||||
|
||||
class HaloPopupAction {
|
||||
const HaloPopupAction({required this.label, required this.onPressed});
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
}
|
||||
|
||||
class HaloPopup {
|
||||
const HaloPopup._();
|
||||
|
||||
static Future<T?> show<T>(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String? body,
|
||||
Widget? icon,
|
||||
HaloPopupAction? primary,
|
||||
HaloPopupAction? secondary,
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierColor: const Color(0x66000000),
|
||||
builder: (ctx) => Dialog(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.xl),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Center(child: icon),
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
height: 28 / 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (body != null) ...[
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Text(
|
||||
body,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
height: 22 / 15,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
if (primary != null)
|
||||
HaloButton(
|
||||
label: primary.label,
|
||||
fullWidth: true,
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
primary.onPressed();
|
||||
},
|
||||
),
|
||||
if (secondary != null) ...[
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
HaloButton(
|
||||
label: secondary.label,
|
||||
variant: HaloButtonVariant.ghost,
|
||||
fullWidth: true,
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
secondary.onPressed();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
client_app/lib/core/theme/widgets/halo_snackbar.dart
Normal file
58
client_app/lib/core/theme/widgets/halo_snackbar.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../halo_tokens.dart';
|
||||
|
||||
class HaloSnackbar {
|
||||
const HaloSnackbar._();
|
||||
|
||||
static void show(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
String? icon,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
}) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (messenger == null) return;
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
duration: duration,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: HaloTokens.ink,
|
||||
elevation: 4,
|
||||
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s16,
|
||||
vertical: HaloSpacing.s12,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s20,
|
||||
vertical: HaloSpacing.s12,
|
||||
),
|
||||
content: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Text(
|
||||
icon,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(width: HaloSpacing.s8),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
client_app/lib/core/theme/widgets/halo_step_dots.dart
Normal file
43
client_app/lib/core/theme/widgets/halo_step_dots.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../halo_tokens.dart';
|
||||
|
||||
class HaloStepDots extends StatelessWidget {
|
||||
const HaloStepDots({
|
||||
super.key,
|
||||
required this.total,
|
||||
required this.current,
|
||||
}) : assert(total > 0),
|
||||
assert(current >= 1);
|
||||
|
||||
final int total;
|
||||
final int current;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(total, (index) {
|
||||
final step = index + 1;
|
||||
final active = step == current;
|
||||
final past = step < current;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: index == total - 1 ? 0 : HaloSpacing.s8),
|
||||
child: AnimatedContainer(
|
||||
duration: HaloMotion.fast,
|
||||
curve: HaloMotion.ease,
|
||||
width: active ? 24 : 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? HaloTokens.brand
|
||||
: past
|
||||
? HaloTokens.brandSoft
|
||||
: HaloTokens.border,
|
||||
borderRadius: HaloRadius.pill,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
7
client_app/lib/core/theme/widgets/widgets.dart
Normal file
7
client_app/lib/core/theme/widgets/widgets.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
export 'halo_bottom_sheet.dart';
|
||||
export 'halo_button.dart';
|
||||
export 'halo_chip.dart';
|
||||
export 'halo_orb.dart';
|
||||
export 'halo_popup.dart';
|
||||
export 'halo_snackbar.dart';
|
||||
export 'halo_step_dots.dart';
|
||||
@@ -1,6 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../widgets/verif_choice_sheet.dart';
|
||||
|
||||
class DisplayNameScreen extends ConsumerStatefulWidget {
|
||||
const DisplayNameScreen({super.key});
|
||||
@@ -11,9 +16,33 @@ class DisplayNameScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||
final _controller = TextEditingController();
|
||||
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
|
||||
String? _errorMessage;
|
||||
bool _routedAfterLogin = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Listener registered once in initState (see feedback_riverpod_listen_in_build).
|
||||
// We need to react to auth state changes once the anonymous login resolves
|
||||
// to drive the post-name onboarding fork.
|
||||
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||
if (!mounted) return;
|
||||
if (next is AsyncError) {
|
||||
setState(() => _errorMessage = next.error.toString());
|
||||
return;
|
||||
}
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthAnonymousData && !_routedAfterLogin) {
|
||||
_routedAfterLogin = true;
|
||||
_proceedAfterLogin();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authSub?.close();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -21,46 +50,99 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||
void _submit() {
|
||||
final name = _controller.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
setState(() => _errorMessage = null);
|
||||
ref.read(authProvider.notifier).loginAnonymous(name);
|
||||
}
|
||||
|
||||
/// After an anonymous login succeeds, decide where to send the user.
|
||||
///
|
||||
/// 1. Read `/api/client/onboarding-state`. If `has_consulted_before`, the
|
||||
/// user is a returning customer — skip the onboarding sequence and
|
||||
/// jump straight to the duration picker (Stage 3 owns that route).
|
||||
/// 2. Otherwise show the Verif Choice Sheet and route based on the picked
|
||||
/// branch.
|
||||
Future<void> _proceedAfterLogin() async {
|
||||
bool hasConsultedBefore = false;
|
||||
try {
|
||||
final response =
|
||||
await ref.read(apiClientProvider).get('/api/client/onboarding-state');
|
||||
final data = response['data'] as Map<String, dynamic>?;
|
||||
hasConsultedBefore =
|
||||
(data?['has_consulted_before'] as bool?) ?? false;
|
||||
} catch (_) {
|
||||
// Treat as first-time on failure — safer to over-collect onboarding
|
||||
// info than to silently strand a returning user.
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
if (hasConsultedBefore) {
|
||||
// TODO(stage3): Stage 3 will own /payment/duration-pick — for now
|
||||
// route there as a placeholder so returning users can continue.
|
||||
context.go('/payment/duration-pick');
|
||||
return;
|
||||
}
|
||||
|
||||
final choice = await VerifChoiceSheet.show(context);
|
||||
if (!mounted || choice == null) {
|
||||
// User dismissed the sheet — let them tap Lanjut again to retry.
|
||||
_routedAfterLogin = false;
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
routeForVerifChoice(context, choice);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Siapa namamu?')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Pilih nama yang ingin kamu gunakan. Nama ini akan terlihat oleh bestie kamu.',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
height: 22 / 15,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nama panggilan',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : _submit,
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Lanjut'),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.danger,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
HaloButton(
|
||||
label: isLoading ? 'memproses...' : 'lanjut',
|
||||
fullWidth: true,
|
||||
onPressed: isLoading ? null : _submit,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/auth/social_auth_enabled.dart';
|
||||
import '../../../core/auth/auth_providers_provider.dart';
|
||||
|
||||
/// Shown when anonymity is disabled by admin.
|
||||
/// User must identify themselves (phone OTP / Google / Apple).
|
||||
@@ -28,6 +28,9 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
final providersAsync = ref.watch(authProvidersProvider);
|
||||
final providers =
|
||||
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
@@ -51,7 +54,8 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (kSocialAuthEnabled) ...[
|
||||
if (providers.hasAnySocial) ...[
|
||||
if (providers.google) ...[
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: isLoading ? null
|
||||
@@ -59,12 +63,15 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
||||
label: const Text('Lanjut dengan Google'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (providers.apple) ...[
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.apple),
|
||||
onPressed: isLoading ? null
|
||||
: () => ref.read(authProvider.notifier).loginApple(),
|
||||
label: const Text('Lanjut dengan Apple'),
|
||||
),
|
||||
],
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Row(children: [
|
||||
|
||||
@@ -5,12 +5,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../widgets/otp_blocked_popup.dart';
|
||||
|
||||
const int _kOtpLength = 6;
|
||||
const int _kFallbackResendCooldownSeconds = 60;
|
||||
|
||||
const Color _kAccentPink = Color(0xFFBE7C8A);
|
||||
const Color _kBoxBorder = Color(0xFFE0E0E0);
|
||||
// Codes that mean "the user cannot make progress without waiting" — these
|
||||
// trip the OTP-blocked popup. Mirrors backend `otp.service.js`.
|
||||
const _kOtpBlockedCodes = {
|
||||
'OTP_RATE_LIMIT_PHONE',
|
||||
'OTP_RATE_LIMIT_IP',
|
||||
'OTP_COOLDOWN',
|
||||
'OTP_ATTEMPTS_EXCEEDED',
|
||||
};
|
||||
|
||||
class OtpScreen extends ConsumerStatefulWidget {
|
||||
final String phone;
|
||||
@@ -29,6 +37,7 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
String? _otpRequestId;
|
||||
bool _autoSubmitted = false;
|
||||
String? _errorMessage;
|
||||
bool _blockedPopupShown = false;
|
||||
|
||||
int _resendSeconds = _kFallbackResendCooldownSeconds;
|
||||
int _resendCooldown = _kFallbackResendCooldownSeconds;
|
||||
@@ -41,25 +50,27 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final data = ref.read(authProvider).valueOrNull;
|
||||
if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId;
|
||||
|
||||
// Register the auth listener ONCE — must NOT live in build(), or the
|
||||
// resend countdown's setState will pile up duplicate listeners every
|
||||
// second and the error toast will fire many times per state change.
|
||||
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||
if (next is AsyncError) {
|
||||
if (!mounted) return;
|
||||
final err = next.error;
|
||||
setState(() => _errorMessage = err.toString());
|
||||
_clearBoxes();
|
||||
// If the server says we're rate-limited, extend the resend countdown
|
||||
// to match — disables "Kirim ulang kode" until the lockout clears.
|
||||
if (err is AuthErrorInfo &&
|
||||
err.retryAfterSeconds != null &&
|
||||
if (err is AuthErrorInfo) {
|
||||
if (_kOtpBlockedCodes.contains(err.code) && !_blockedPopupShown) {
|
||||
_blockedPopupShown = true;
|
||||
OtpBlockedPopup.show(context).then((_) {
|
||||
if (mounted) _blockedPopupShown = false;
|
||||
});
|
||||
}
|
||||
if (err.retryAfterSeconds != null &&
|
||||
(err.code == 'OTP_COOLDOWN' ||
|
||||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
|
||||
err.code == 'OTP_RATE_LIMIT_IP')) {
|
||||
_resendCooldown = err.retryAfterSeconds!;
|
||||
_startResendCountdown();
|
||||
}
|
||||
}
|
||||
} else if (next is AsyncLoading || next is AsyncData) {
|
||||
if (_errorMessage != null && mounted) {
|
||||
setState(() => _errorMessage = null);
|
||||
@@ -131,7 +142,6 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
}
|
||||
|
||||
void _onDigitChanged(int index, String value) {
|
||||
// Move forward when a digit is entered, back when cleared.
|
||||
if (value.isNotEmpty && index < _kOtpLength - 1) {
|
||||
_focusNodes[index + 1].requestFocus();
|
||||
}
|
||||
@@ -142,9 +152,6 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final code = _readCode();
|
||||
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
||||
_autoSubmitted = true;
|
||||
// Keep keyboard open during verify — dismissing it caused a Scaffold
|
||||
// layout shift mid-snackbar-animation, which made the error toast
|
||||
// visually duplicate.
|
||||
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
|
||||
}
|
||||
}
|
||||
@@ -169,47 +176,76 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Masukkan OTP')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Kode OTP telah dikirim ke ${widget.phone}'),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(_kOtpLength, _buildBox),
|
||||
Text(
|
||||
'Kode OTP telah dikirim ke ${widget.phone}',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s32),
|
||||
LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
// 6 boxes laid out across the row. Tighter spacing than the
|
||||
// legacy 4-box layout (Figma reference) so the form still
|
||||
// fits a 320pt-wide screen.
|
||||
const gap = HaloSpacing.s8;
|
||||
final boxWidth =
|
||||
(constraints.maxWidth - gap * (_kOtpLength - 1)) /
|
||||
_kOtpLength;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(_kOtpLength, (i) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i == _kOtpLength - 1 ? 0 : gap,
|
||||
),
|
||||
child: _buildBox(i, boxWidth),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
if (_errorMessage != null)
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.danger,
|
||||
fontSize: 13,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
if (isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
padding:
|
||||
EdgeInsets.symmetric(vertical: HaloSpacing.s8),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
_buildResendRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBox(int index) {
|
||||
Widget _buildBox(int index, double width) {
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
width: width,
|
||||
height: 56,
|
||||
// Wrap with Focus to intercept hardware backspace BEFORE the TextField:
|
||||
// when the current box is empty, TextField.onChanged doesn't fire on
|
||||
// backspace, so we'd be stuck. We catch it here and rewind one box.
|
||||
child: Focus(
|
||||
canRequestFocus: false,
|
||||
onKeyEvent: (node, event) {
|
||||
@@ -230,18 +266,25 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
maxLength: 1,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: true,
|
||||
fillColor: HaloTokens.surface,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: _kBoxBorder, width: 1.5),
|
||||
borderSide: const BorderSide(color: HaloTokens.border, width: 1.5),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: _kAccentPink, width: 2),
|
||||
borderSide: const BorderSide(color: HaloTokens.brand, width: 2),
|
||||
),
|
||||
),
|
||||
onChanged: (v) => _onDigitChanged(index, v),
|
||||
@@ -259,7 +302,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
child: const Text(
|
||||
'Kirim ulang kode',
|
||||
style: TextStyle(
|
||||
color: _kAccentPink,
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.brandDark,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
@@ -267,7 +311,10 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
)
|
||||
: Text(
|
||||
'Kirim ulang dalam ${formatCountdown(_resendSeconds)}',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/auth/social_auth_enabled.dart';
|
||||
import '../../../core/auth/auth_providers_provider.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
|
||||
class RegisterScreen extends ConsumerStatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
@@ -26,8 +28,6 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Listener registered once in initState — keeps it independent of the
|
||||
// build cycle so it doesn't accumulate (see feedback_riverpod_listen_in_build).
|
||||
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||
if (!mounted) return;
|
||||
final data = next.valueOrNull;
|
||||
@@ -82,35 +82,63 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
final isLoading = authState is AsyncLoading;
|
||||
final isLockedOut = _lockoutSeconds > 0;
|
||||
final canSubmit = !isLoading && !isLockedOut;
|
||||
final providersAsync = ref.watch(authProvidersProvider);
|
||||
final providers =
|
||||
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (kSocialAuthEnabled) ...[
|
||||
ElevatedButton.icon(
|
||||
if (providers.hasAnySocial) ...[
|
||||
if (providers.google) ...[
|
||||
HaloButton(
|
||||
label: 'lanjut dengan Google',
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: isLoading ? null
|
||||
variant: HaloButtonVariant.secondary,
|
||||
fullWidth: true,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||
label: const Text('Lanjut dengan Google'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
],
|
||||
if (providers.apple) ...[
|
||||
HaloButton(
|
||||
label: 'lanjut dengan Apple',
|
||||
icon: const Icon(Icons.apple),
|
||||
onPressed: isLoading ? null
|
||||
variant: HaloButtonVariant.secondary,
|
||||
fullWidth: true,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => ref.read(authProvider.notifier).loginApple(),
|
||||
label: const Text('Lanjut dengan Apple'),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
],
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Row(children: [
|
||||
Expanded(child: Divider()),
|
||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
||||
Expanded(child: Divider()),
|
||||
]),
|
||||
padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: HaloTokens.border)),
|
||||
Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: HaloSpacing.s12),
|
||||
child: Text(
|
||||
'atau',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.inkMuted,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: HaloTokens.border)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
TextField(
|
||||
@@ -118,34 +146,41 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nomor HP',
|
||||
hintText: '+628xxxxxxxxxx',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: canSubmit ? () {
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
HaloButton(
|
||||
label: isLoading
|
||||
? 'memproses...'
|
||||
: isLockedOut
|
||||
? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
|
||||
: 'kirim OTP',
|
||||
fullWidth: true,
|
||||
onPressed: canSubmit
|
||||
? () {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
ref.read(authProvider.notifier).requestOtp(phone);
|
||||
} : null,
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: Text(isLockedOut
|
||||
? 'Coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
|
||||
: 'Kirim OTP'),
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.danger,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/auth/auth_providers_provider.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
class WelcomeScreen extends ConsumerWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final providersAsync = ref.watch(authProvidersProvider);
|
||||
final providers =
|
||||
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Center(child: HaloOrb(seed: 0, size: 96, label: 'H')),
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
const Text(
|
||||
'Halo Bestie',
|
||||
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
const Text(
|
||||
'Tempat curhat kamu',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
ElevatedButton(
|
||||
const SizedBox(height: HaloSpacing.s48),
|
||||
HaloButton(
|
||||
label: 'Lanjut sebagai Tamu',
|
||||
fullWidth: true,
|
||||
onPressed: () => context.push('/auth/display-name'),
|
||||
child: const Text('Lanjut sebagai Tamu'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
if (providers.google) ...[
|
||||
HaloButton(
|
||||
label: 'lanjut dengan Google',
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
variant: HaloButtonVariant.secondary,
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
ref.read(authProvider.notifier).loginGoogle(),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
],
|
||||
if (providers.apple) ...[
|
||||
HaloButton(
|
||||
label: 'lanjut dengan Apple',
|
||||
icon: const Icon(Icons.apple),
|
||||
variant: HaloButtonVariant.secondary,
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
ref.read(authProvider.notifier).loginApple(),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
],
|
||||
HaloButton(
|
||||
label: 'Daftar / Masuk',
|
||||
variant: HaloButtonVariant.ghost,
|
||||
fullWidth: true,
|
||||
onPressed: () => context.push('/auth/register'),
|
||||
child: const Text('Daftar / Masuk'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
54
client_app/lib/features/auth/widgets/otp_blocked_popup.dart
Normal file
54
client_app/lib/features/auth/widgets/otp_blocked_popup.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../../support/widgets/tanya_admin_sheet.dart';
|
||||
|
||||
/// Modal shown when OTP delivery / verification is exhausted (rate-limited
|
||||
/// 429 from `OTP_RATE_LIMIT_PHONE`, `OTP_RATE_LIMIT_IP`, `OTP_COOLDOWN`, or
|
||||
/// `OTP_ATTEMPTS_EXCEEDED`). Offers a "lanjut tanpa verif" exit into the
|
||||
/// anonymous flow (preserving any ESP/USP state) and a "hubungi admin" CTA
|
||||
/// that opens the Tanya Admin sheet.
|
||||
class OtpBlockedPopup {
|
||||
const OtpBlockedPopup._();
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return HaloPopup.show<void>(
|
||||
context,
|
||||
title: 'Verifikasi nomor lagi penuh',
|
||||
body:
|
||||
'Sistem lagi nahan permintaan OTP buat keamanan. Kamu bisa lanjut '
|
||||
'tanpa verifikasi, atau hubungi admin biar dibantu manual.',
|
||||
icon: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.lock_clock_outlined,
|
||||
color: HaloTokens.brandDark,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
primary: HaloPopupAction(
|
||||
label: 'lanjut tanpa verif',
|
||||
onPressed: () {
|
||||
// ESP/USP picks live in Riverpod providers (espSelectionProvider,
|
||||
// espSkippedProvider) and survive this navigation — no need to pass
|
||||
// them as `extra`.
|
||||
context.go('/onboarding/anon/method');
|
||||
},
|
||||
),
|
||||
secondary: HaloPopupAction(
|
||||
label: 'hubungi admin',
|
||||
onPressed: () {
|
||||
// ignore: discarded_futures
|
||||
TanyaAdminSheet.show(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
77
client_app/lib/features/auth/widgets/verif_choice_sheet.dart
Normal file
77
client_app/lib/features/auth/widgets/verif_choice_sheet.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
|
||||
/// Result of the post-name Verif Choice Sheet. Caller routes to the matching
|
||||
/// onboarding sub-flow.
|
||||
enum VerifChoice { verified, anonymous }
|
||||
|
||||
class VerifChoiceSheet extends StatelessWidget {
|
||||
const VerifChoiceSheet({super.key});
|
||||
|
||||
/// Show the sheet and return the user's choice (`null` if dismissed).
|
||||
static Future<VerifChoice?> show(BuildContext context) {
|
||||
return HaloBottomSheet.show<VerifChoice>(
|
||||
context,
|
||||
child: const VerifChoiceSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Mau curhat sebagai siapa?',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
height: 28 / 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
const Text(
|
||||
'Verifikasi nomor HP biar bisa dapet diskon sesi pertama dan riwayat curhatmu kesimpan. Atau langsung curhat anonim, nggak perlu daftar.',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
HaloButton(
|
||||
label: 'verifikasi nomor HP',
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(VerifChoice.verified),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
HaloButton(
|
||||
label: 'curhat anonim',
|
||||
variant: HaloButtonVariant.secondary,
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(VerifChoice.anonymous),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: route to the right onboarding sub-flow for a verif choice.
|
||||
void routeForVerifChoice(BuildContext context, VerifChoice choice) {
|
||||
switch (choice) {
|
||||
case VerifChoice.verified:
|
||||
context.push('/onboarding/verif/esp');
|
||||
break;
|
||||
case VerifChoice.anonymous:
|
||||
context.push('/onboarding/anon/esp');
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/chat/active_session_notifier.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
|
||||
class BestieFoundScreen extends ConsumerWidget {
|
||||
/// Phase 4 Stage 5 — S9 Match-found screen.
|
||||
///
|
||||
/// Reskinned from the v4 mock (`v4.jsx::S9MatchV4`). Shows the matched
|
||||
/// bestie's orb + a small online status dot, the matched-line copy, and a
|
||||
/// primary CTA `mulai sesi {N} menit →`. The duration is read from the active
|
||||
/// session payload (which the pairing notifier kicks via
|
||||
/// `activeSessionProvider.refresh()` on the WS `paired` event).
|
||||
///
|
||||
/// `PairingActiveData` is the auto-advance signal — fired by the notifier
|
||||
/// ~2s after WS `paired` lands. The same advance is also reachable manually
|
||||
/// via the CTA in case the user is faster than the auto-advance timer.
|
||||
class BestieFoundScreen extends ConsumerStatefulWidget {
|
||||
final String sessionId;
|
||||
final String mitraName;
|
||||
|
||||
@@ -14,34 +28,127 @@ class BestieFoundScreen extends ConsumerWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.listen(pairingProvider, (prev, next) {
|
||||
ConsumerState<BestieFoundScreen> createState() => _BestieFoundScreenState();
|
||||
}
|
||||
|
||||
class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
|
||||
if (!mounted) return;
|
||||
if (next is PairingActiveData) {
|
||||
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _enterChat() {
|
||||
context.go('/chat/session/${widget.sessionId}', extra: widget.mitraName);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeSession = ref.watch(activeSessionProvider).valueOrNull;
|
||||
final durationMinutes =
|
||||
activeSession?.session?['duration_minutes'] as int?;
|
||||
final ctaLabel = durationMinutes != null
|
||||
? 'mulai sesi $durationMinutes menit →'
|
||||
: 'mulai sesi →';
|
||||
final subtitle = durationMinutes != null
|
||||
? 'siap nemenin kamu $durationMinutes menit ke depan. cerita aja pelan-pelan ya 🤍'
|
||||
: 'siap nemenin kamu. cerita aja pelan-pelan ya 🤍';
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
backgroundColor: HaloTokens.bg,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.check_circle, size: 80, color: Colors.green),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
HaloOrb(
|
||||
size: 140,
|
||||
seed: widget.mitraName.hashCode,
|
||||
label: widget.mitraName,
|
||||
),
|
||||
Positioned(
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: HaloTokens.success,
|
||||
border: Border.all(
|
||||
color: HaloTokens.bg,
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s20),
|
||||
const Text(
|
||||
'Bestie ditemukan!',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
'◦ MATCHED ◦',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.6,
|
||||
color: HaloTokens.brand,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Text(
|
||||
'Menghubungkan kamu ke $mitraName',
|
||||
'halo, aku bestie ${widget.mitraName}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.grey),
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 26,
|
||||
height: 32 / 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
height: 22 / 14,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
HaloButton(
|
||||
label: ctaLabel,
|
||||
fullWidth: true,
|
||||
size: HaloButtonSize.lg,
|
||||
onPressed: _enterChat,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const CircularProgressIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,115 +3,102 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../../home/providers/bestie_history_provider.dart';
|
||||
|
||||
/// Chat history with per-row "Curhat lagi" CTA.
|
||||
/// Phase 4 Stage 8 — `BestieHistoryList`.
|
||||
///
|
||||
/// Tapping "Curhat lagi" routes to the payment screen with the targeted
|
||||
/// mitra id + display name as extras. The payment screen then:
|
||||
/// 1. POSTs `/api/client/payment-sessions` with `targeted_mitra_id`
|
||||
/// 2. On confirm, fires `pairingNotifier.startTargetedSearch(...)` instead
|
||||
/// of the general `startSearch(...)`.
|
||||
/// Renders past sessions with the v4 visual: orb + name + last-session date
|
||||
/// + topic chips + sessions count + ONLINE pill (per-row, sourced from the
|
||||
/// `mitra_is_online` field on the history payload).
|
||||
///
|
||||
/// The CTA is per-row (not per-unique-mitra).
|
||||
class ChatHistoryScreen extends ConsumerStatefulWidget {
|
||||
/// Tapping a row routes to the targeted "Curhat lagi" payment flow when the
|
||||
/// row references a known mitra; closing-state rows still drop into the
|
||||
/// session screen so the user can finish the goodbye composer. Otherwise we
|
||||
/// fall back to the transcript view.
|
||||
class ChatHistoryScreen extends ConsumerWidget {
|
||||
const ChatHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChatHistoryScreen> createState() => _ChatHistoryScreenState();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final historyAsync = ref.watch(bestieHistoryProvider);
|
||||
final fullSessionsAsync = ref.watch(_rawHistoryProvider);
|
||||
|
||||
class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
|
||||
List<Map<String, dynamic>> _sessions = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHistory();
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/client/chat/history');
|
||||
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||
setState(() {
|
||||
_sessions = items;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onCurhatLagiPressed(Map<String, dynamic> session) {
|
||||
// The mitra id field on the history payload is `mitra_id` per existing
|
||||
// backend convention. If absent (older rows), don't render the CTA.
|
||||
final mitraId = session['mitra_id'] as String?;
|
||||
if (mitraId == null) return;
|
||||
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
|
||||
context.push('/payment', extra: <String, dynamic>{
|
||||
'targetedMitraId': mitraId,
|
||||
'mitraName': mitraName,
|
||||
'topicSensitivity': TopicSensitivity.regular,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Riwayat Chat')),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _sessions.isEmpty
|
||||
? const Center(child: Text('Belum ada riwayat chat'))
|
||||
: ListView.separated(
|
||||
itemCount: _sessions.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
backgroundColor: HaloTokens.bg,
|
||||
appBar: AppBar(
|
||||
backgroundColor: HaloTokens.bg,
|
||||
foregroundColor: HaloTokens.ink,
|
||||
elevation: 0,
|
||||
title: const Text(
|
||||
'Riwayat Chat',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: historyAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(
|
||||
child: Text(
|
||||
'gagal memuat riwayat. tarik untuk muat ulang.',
|
||||
style: TextStyle(fontFamily: HaloTokens.fontBody),
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Belum ada riwayat chat',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(bestieHistoryProvider);
|
||||
ref.invalidate(_rawHistoryProvider);
|
||||
await ref.read(bestieHistoryProvider.future);
|
||||
},
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s16,
|
||||
vertical: HaloSpacing.s12,
|
||||
),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: HaloSpacing.s12),
|
||||
itemBuilder: (context, index) {
|
||||
final s = _sessions[index];
|
||||
final sessionId = s['id'] as String;
|
||||
final mitraId = s['mitra_id'] as String?;
|
||||
final mitraName = s['mitra_display_name'] as String? ?? 'Bestie';
|
||||
final status = s['status'] as String?;
|
||||
final isClosing = status == 'closing';
|
||||
final endedAt = s['ended_at'] != null
|
||||
? DateTime.parse(s['ended_at'] as String).toLocal()
|
||||
: null;
|
||||
final duration = s['duration_minutes'] as int?;
|
||||
final closureMsg = s['customer_closure_message'] as String?;
|
||||
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.person)),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(mitraName, overflow: TextOverflow.ellipsis)),
|
||||
if (isClosing) ...[
|
||||
const SizedBox(width: 8),
|
||||
const _OutstandingClosureBadge(),
|
||||
],
|
||||
],
|
||||
final item = items[index];
|
||||
final raw = fullSessionsAsync.valueOrNull?[index];
|
||||
final isClosing = raw?['status'] == SessionStatus.closing;
|
||||
return _BestieRow(
|
||||
item: item,
|
||||
isClosing: isClosing,
|
||||
onTap: () {
|
||||
if (isClosing && raw != null) {
|
||||
context.push(
|
||||
'/chat/session/${item.sessionId}',
|
||||
extra: item.mitraName,
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.push('/chat/history/${item.sessionId}');
|
||||
},
|
||||
onCurhatLagi: item.mitraId == null || isClosing
|
||||
? null
|
||||
: () => context.push('/payment', extra: <String, dynamic>{
|
||||
'targetedMitraId': item.mitraId,
|
||||
'mitraName': item.mitraName,
|
||||
'topicSensitivity': TopicSensitivity.regular,
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
subtitle: Text([
|
||||
if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}',
|
||||
if (duration != null) '$duration menit',
|
||||
if (closureMsg != null) '"$closureMsg"',
|
||||
].join(' - ')),
|
||||
// Curhat-lagi CTA renders inline; transcript view is
|
||||
// still reachable by tapping the row body (or, for
|
||||
// closing sessions, the active chat — same as before).
|
||||
trailing: !isClosing && mitraId != null
|
||||
? OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
),
|
||||
onPressed: () => _onCurhatLagiPressed(s),
|
||||
child: const Text('Curhat lagi'),
|
||||
)
|
||||
: const Icon(Icons.chevron_right),
|
||||
onTap: () => isClosing
|
||||
? context.push('/chat/session/$sessionId', extra: mitraName)
|
||||
: context.push('/chat/history/$sessionId'),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -119,24 +106,220 @@ class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _OutstandingClosureBadge extends StatelessWidget {
|
||||
const _OutstandingClosureBadge();
|
||||
/// Raw history payload — used to read fields the v4 `BestieHistoryItem`
|
||||
/// model doesn't surface (currently `status`, for the closing-row branch).
|
||||
final _rawHistoryProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/client/chat/history');
|
||||
return ((response['data']['items'] as List?) ?? const []).cast<Map<String, dynamic>>();
|
||||
});
|
||||
|
||||
class _BestieRow extends StatelessWidget {
|
||||
final BestieHistoryItem item;
|
||||
final bool isClosing;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onCurhatLagi;
|
||||
|
||||
const _BestieRow({
|
||||
required this.item,
|
||||
required this.isClosing,
|
||||
required this.onTap,
|
||||
required this.onCurhatLagi,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: HaloRadius.lg,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
HaloOrb(
|
||||
size: 56,
|
||||
seed: (item.mitraId ?? item.mitraName).hashCode,
|
||||
label: item.mitraName,
|
||||
),
|
||||
const SizedBox(width: HaloSpacing.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.mitraName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.mitraIsOnline) ...[
|
||||
const SizedBox(width: HaloSpacing.s8),
|
||||
const _OnlinePill(),
|
||||
],
|
||||
if (isClosing) ...[
|
||||
const SizedBox(width: HaloSpacing.s8),
|
||||
const _ClosingBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
[
|
||||
if (item.endedAt != null) _formatDate(item.endedAt!),
|
||||
'${item.sessionsCount} sesi',
|
||||
].join(' · '),
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12.5,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (item.topics.isNotEmpty) ...[
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Wrap(
|
||||
spacing: HaloSpacing.s8,
|
||||
runSpacing: HaloSpacing.s8,
|
||||
children: item.topics
|
||||
.take(3)
|
||||
.map((t) => _TopicPill(label: t))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
if (onCurhatLagi != null) ...[
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: HaloButton(
|
||||
label: 'curhat lagi',
|
||||
size: HaloButtonSize.sm,
|
||||
variant: HaloButtonVariant.secondary,
|
||||
onPressed: onCurhatLagi,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime d) =>
|
||||
'${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
|
||||
}
|
||||
|
||||
class _OnlinePill extends StatelessWidget {
|
||||
const _OnlinePill();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.amber.shade700, width: 0.5),
|
||||
color: HaloTokens.success.withAlpha(36),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Dot(color: HaloTokens.success),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'ONLINE',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: HaloTokens.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Dot extends StatelessWidget {
|
||||
final Color color;
|
||||
const _Dot({required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopicPill extends StatelessWidget {
|
||||
final String label;
|
||||
const _TopicPill({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'Belum ditutup',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.amber.shade900,
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClosingBadge extends StatelessWidget {
|
||||
const _ClosingBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.accentSoft,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Text(
|
||||
'Belum ditutup',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,15 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../core/chat/active_session_notifier.dart';
|
||||
import '../../../core/chat/chat_notifier.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
import '../../../core/config/app_config_provider.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/halo_snackbar.dart';
|
||||
import '../widgets/bestie_unavailable_dialog.dart';
|
||||
import '../widgets/chat_expired_banner.dart';
|
||||
import '../widgets/closing_message_sheet.dart';
|
||||
import '../widgets/confirm_end_step1.dart';
|
||||
import '../widgets/confirm_end_step2.dart';
|
||||
import '../widgets/pricing_bottom_sheet.dart';
|
||||
|
||||
// Chat theme colors
|
||||
@@ -31,9 +39,15 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
final _goodbyeController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
Timer? _typingThrottle;
|
||||
StreamSubscription<String>? _warningSub;
|
||||
bool _showBestieBanner = true;
|
||||
bool _showUserBanner = true;
|
||||
bool _expiredDialogShown = false;
|
||||
bool _rejectPopupShown = false;
|
||||
// Per-session-mount idempotency flag for the 3-min snackbar. The backend
|
||||
// also guards once-per-session (timers.threeMinFired), but a fresh mount
|
||||
// could still receive the event on a refreshed status pull, so we belt-
|
||||
// and-braces here.
|
||||
bool _threeMinShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -48,6 +62,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
ref.read(sessionClosureProvider.notifier).reset();
|
||||
ref.read(chatProvider.notifier).connectIfNotConnected(widget.sessionId);
|
||||
});
|
||||
// Subscribe to the chat notifier's session-warning stream. Using stream
|
||||
// subscription rather than a `ref.listen` on state because the warning is
|
||||
// a one-shot signal, not a persistent state field.
|
||||
_warningSub = ref.read(chatProvider.notifier).warningStream.listen((kind) {
|
||||
if (kind == 'three_minutes_left' && !_threeMinShown && mounted) {
|
||||
_threeMinShown = true;
|
||||
HaloSnackbar.show(
|
||||
context,
|
||||
'sisa 3 menit lagi ya 🤍',
|
||||
icon: '⏳',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -56,6 +83,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
_goodbyeController.dispose();
|
||||
_scrollController.dispose();
|
||||
_typingThrottle?.cancel();
|
||||
_warningSub?.cancel();
|
||||
super.dispose();
|
||||
// Intentionally do NOT disconnect the WS here. The global lifecycle in
|
||||
// `App` decides when to disconnect (logout / no active session).
|
||||
@@ -95,53 +123,83 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showSessionExpiredDialog() async {
|
||||
if (_expiredDialogShown) return;
|
||||
_expiredDialogShown = true;
|
||||
/// Stage 7 entry point — wired to both the AppBar "akhiri sesi" button and
|
||||
/// the menu equivalent. Reads `endSessionTwoStepConfirmProvider`: when the
|
||||
/// flag is `true` the user sees step-1 first; when `false` (A/B variant) we
|
||||
/// jump straight to step-2 (write-message vs skip).
|
||||
Future<void> _onAkhiriSesiTapped() async {
|
||||
final twoStep = ref.read(endSessionTwoStepConfirmProvider);
|
||||
if (!twoStep) {
|
||||
_showStep2();
|
||||
return;
|
||||
}
|
||||
await ConfirmEndStep1.show(context, onConfirm: _showStep2);
|
||||
}
|
||||
|
||||
void _showStep2() {
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Waktu Curhat Berakhir'),
|
||||
content: const Text(
|
||||
'Sesi curhatmu sudah habis waktunya. Kamu bisa menutup obrolan atau memperpanjang waktu untuk lanjut bicara.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
_exitChat();
|
||||
},
|
||||
child: const Text('Tutup'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId);
|
||||
},
|
||||
child: const Text('Perpanjang'),
|
||||
),
|
||||
],
|
||||
),
|
||||
ConfirmEndStep2.show(
|
||||
context,
|
||||
onWriteMessage: _showClosingSheet,
|
||||
onSkip: _closeWithoutMessage,
|
||||
);
|
||||
}
|
||||
|
||||
void _showClosingSheet() {
|
||||
if (!mounted) return;
|
||||
ClosingMessageSheet.show(
|
||||
context,
|
||||
sessionId: widget.sessionId,
|
||||
onCompleted: _goToThankYou,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _closeWithoutMessage() async {
|
||||
await ref.read(sessionClosureProvider.notifier).closeSession(widget.sessionId);
|
||||
// Navigation is driven by the closure listener (success path) or the
|
||||
// ClosureRejectedByMitraData branch (409 fallback popup).
|
||||
}
|
||||
|
||||
void _goToThankYou() {
|
||||
if (!mounted) return;
|
||||
context.go('/chat/thank-you');
|
||||
}
|
||||
|
||||
Future<void> _showBestieReturningPopup() async {
|
||||
if (_rejectPopupShown) return;
|
||||
_rejectPopupShown = true;
|
||||
if (!mounted) return;
|
||||
await BestieOfflinePopup.show(
|
||||
context,
|
||||
variant: BestieOfflineVariant.returning,
|
||||
mitraName: widget.mitraName,
|
||||
);
|
||||
_rejectPopupShown = false;
|
||||
// Reset closure state so the user can retry without a stale-error block.
|
||||
ref.read(sessionClosureProvider.notifier).reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatState = ref.watch(chatProvider);
|
||||
final closureState = ref.watch(sessionClosureProvider);
|
||||
|
||||
// Listen for closure complete to navigate home
|
||||
// Stage 7 — closure outcomes drive routing. Success ends in S11 thank-you;
|
||||
// 409 surfaces the bestie-returning fallback popup (Stage 8 owns the
|
||||
// dedicated component).
|
||||
ref.listen(sessionClosureProvider, (prev, next) {
|
||||
if (next is ClosureCompleteData) {
|
||||
// Make doubly sure home picks up the cleared session.
|
||||
ref.invalidate(activeSessionProvider);
|
||||
context.go('/home');
|
||||
_goToThankYou();
|
||||
} else if (next is ClosureRejectedByMitraData) {
|
||||
_showBestieReturningPopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for chat state changes to manage closure state and timer-expired modal
|
||||
// Listen for chat state changes to manage closure state. Stage 7 removed
|
||||
// the legacy `_showSessionExpiredDialog` modal — the Stage 6 ChatExpiredBanner
|
||||
// is the in-place replacement, and the user reaches the closing flow via
|
||||
// the AppBar "akhiri" button.
|
||||
ref.listen(chatProvider, (prev, next) {
|
||||
if (next is ChatConnectedData) {
|
||||
// Early-end (mitra/customer ended before timer): show goodbye composer.
|
||||
@@ -151,19 +209,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
ref.read(sessionClosureProvider.notifier).declineExtension();
|
||||
}
|
||||
}
|
||||
// Timer-expired: show non-dismissible modal once on false→true flip.
|
||||
final wasExpired = prev is ChatConnectedData && prev.sessionExpired;
|
||||
if (next.sessionExpired && !wasExpired) {
|
||||
_showSessionExpiredDialog();
|
||||
}
|
||||
if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
|
||||
final closure = ref.read(sessionClosureProvider);
|
||||
if (closure is! ClosureInitialData) {
|
||||
ref.read(sessionClosureProvider.notifier).reset();
|
||||
}
|
||||
// If we're back to a healthy active state, allow the modal to fire
|
||||
// again on a later expiry (e.g. after extension then re-expiry).
|
||||
_expiredDialogShown = false;
|
||||
}
|
||||
_scrollToBottom();
|
||||
final unread = next.messages
|
||||
@@ -178,6 +228,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 4 — derived ticker drives the danger pill / expired banner.
|
||||
// Only watched when there's a connected session with a known expires_at.
|
||||
final remainingAsync = ref.watch(chatRemainingSecondsProvider);
|
||||
final remainingTick = remainingAsync.value;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
@@ -193,29 +248,89 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
icon: const Icon(Icons.chevron_left, size: 28),
|
||||
onPressed: _exitChat,
|
||||
),
|
||||
title: Text(widget.mitraName),
|
||||
actions: [
|
||||
if (chatState is ChatConnectedData && chatState.remainingSeconds != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${chatState.remainingSeconds}s',
|
||||
style: TextStyle(
|
||||
color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
widget.mitraName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (chatState is ChatConnectedData && chatState.mode == SessionMode.call) ...[
|
||||
const SizedBox(width: 8),
|
||||
_buildVoiceCallPill(),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (chatState is ChatConnectedData && remainingTick != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Center(child: _buildTimerPill(remainingTick)),
|
||||
),
|
||||
if (chatState is ChatConnectedData &&
|
||||
!chatState.sessionClosing)
|
||||
TextButton(
|
||||
onPressed: _onAkhiriSesiTapped,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: HaloTokens.brandDark,
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: const Text('akhiri'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(chatState, closureState),
|
||||
body: _buildBody(chatState, closureState, remainingTick),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ChatData chatState, SessionClosureData closureState) {
|
||||
Widget _buildVoiceCallPill() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.accent,
|
||||
borderRadius: HaloRadius.pill,
|
||||
),
|
||||
child: const Text(
|
||||
'📞 Voice Call',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimerPill(int remaining) {
|
||||
final danger = remaining <= 120;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: danger ? HaloTokens.danger : Colors.transparent,
|
||||
borderRadius: HaloRadius.pill,
|
||||
),
|
||||
child: Text(
|
||||
formatCountdown(remaining),
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontMono,
|
||||
fontSize: 13,
|
||||
fontWeight: danger ? FontWeight.w700 : FontWeight.w600,
|
||||
color: danger ? Colors.white : HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ChatData chatState, SessionClosureData closureState, int? remainingTick) {
|
||||
if (chatState is ChatConnectingData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -223,12 +338,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
return Center(child: Text(chatState.message));
|
||||
}
|
||||
if (chatState is ChatConnectedData) {
|
||||
return _buildChatBody(chatState, closureState);
|
||||
return _buildChatBody(chatState, closureState, remainingTick);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState) {
|
||||
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState, int? remainingTick) {
|
||||
// Show goodbye composer when closure flow is in goodbye/submitting OR when
|
||||
// we mounted directly into a `closing` session (e.g. opened from history).
|
||||
// The chatProvider listener can't catch this case because it only fires on
|
||||
@@ -303,6 +418,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
),
|
||||
),
|
||||
// Floating expired banner — visible while the timer has hit zero
|
||||
// and the session hasn't been finalized yet (still in closing
|
||||
// grace). Tapping `perpanjang` opens the time-up sheet, same as
|
||||
// the modal route.
|
||||
if (remainingTick != null && remainingTick <= 0)
|
||||
ChatExpiredBanner(
|
||||
onExtend: () => PricingBottomSheet.showForExtension(
|
||||
context,
|
||||
sessionId: widget.sessionId,
|
||||
),
|
||||
),
|
||||
// Input bar — disabled when timer expired (modal handles next step)
|
||||
if (!state.sessionExpired) _buildInputBar(),
|
||||
],
|
||||
@@ -424,6 +550,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(phase4-followup): Stage 7 moved the customer-initiated goodbye flow
|
||||
// to ClosingMessageSheet. This inline composer is still reachable when the
|
||||
// mitra ends a session early (sessionClosing fired by the server). Migrate
|
||||
// that path to the new sheet too once the early-end UX is finalised.
|
||||
Widget _buildGoodbyeView(SessionClosureData closureState) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
|
||||
@@ -2,23 +2,30 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../widgets/bestie_unavailable_dialog.dart';
|
||||
import '../widgets/targeted_waiting_overlay.dart';
|
||||
|
||||
/// Searching screen, also responsible for routing all downstream pairing
|
||||
/// transitions:
|
||||
/// Searching screen — Phase 4 Stage 5 reskin of the SSearchPrompt soft-prompt
|
||||
/// + searching panel. Renders three pairing-driven phases inline:
|
||||
///
|
||||
/// - PairingTargetedWaitingData → render the targeted waiting overlay above
|
||||
/// the searching shell (the customer sees the 20s countdown + cancel CTA).
|
||||
/// - PairingTargetedUnavailableData → show the bestie-unavailable dialog
|
||||
/// - `PairingSearchingData` → reflective-prompt cards + pulsing-dots panel.
|
||||
/// - `PairingFailedData` (cause = noMitraAvailable / allMitrasRejected /
|
||||
/// targetedMitraTimeout / targetedMitraRejected — i.e. the 5-minute blast
|
||||
/// timeout) → moon panel + `coba cari lagi` / `kembali ke home` CTAs.
|
||||
/// - `PairingTargetedWaitingData` → 20s targeted-wait overlay above the body.
|
||||
///
|
||||
/// Other transitions still route away as before:
|
||||
///
|
||||
/// - `PairingBestieFoundData` → `/chat/found` (S9 Match screen).
|
||||
/// - `PairingActiveData` → `/chat/session/:id`.
|
||||
/// - `PairingTargetedUnavailableData` → bestie-unavailable dialog overlay
|
||||
/// (intermediate; payment stays confirmed; offers fallback-to-blast).
|
||||
/// - PairingFailedData → terminal; route to no-bestie screen.
|
||||
/// - PairingBestieFoundData → existing transition to bestie-found screen.
|
||||
/// - PairingCancelledData → customer cancelled; back home.
|
||||
/// - `PairingCancelledData` → `/home`.
|
||||
///
|
||||
/// Per project memory ("Riverpod ref.listen in build is unsafe"), we use
|
||||
/// ref.listenManual in initState for one-shot side effects rather than
|
||||
/// build-scoped listeners.
|
||||
/// Per project memory ("Riverpod ref.listen in build is unsafe"), one-shot
|
||||
/// transitions are wired through `ref.listenManual` in initState.
|
||||
class SearchingScreen extends ConsumerStatefulWidget {
|
||||
const SearchingScreen({super.key});
|
||||
|
||||
@@ -27,19 +34,12 @@ class SearchingScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
||||
/// Guard against re-firing the bestie-unavailable dialog if the notifier
|
||||
/// briefly emits multiple intermediate states (e.g. WS event arrives just
|
||||
/// after a 409 already opened the dialog).
|
||||
bool _unavailableDialogShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual<PairingData>(pairingProvider, _onPairingState);
|
||||
// The pairing state can already be PairingTargetedUnavailableData by
|
||||
// the time we mount (the payment screen awaits startTargetedSearch
|
||||
// before navigating; a 409 lands while we're still on the previous
|
||||
// screen). Inspect once after first frame to handle that case.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_onPairingState(null, ref.read(pairingProvider));
|
||||
@@ -58,18 +58,10 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
||||
}
|
||||
|
||||
if (next is PairingActiveData) {
|
||||
// Direct route into the active chat — happens after the brief "found"
|
||||
// animation if the user is already on this screen.
|
||||
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (next is PairingFailedData) {
|
||||
// Terminal — payment_session is failed_pairing.
|
||||
context.go('/chat/no-bestie');
|
||||
return;
|
||||
}
|
||||
|
||||
if (next is PairingCancelledData) {
|
||||
context.go('/home');
|
||||
return;
|
||||
@@ -78,22 +70,17 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
||||
if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) {
|
||||
_unavailableDialogShown = true;
|
||||
// ignore: discarded_futures
|
||||
BestieUnavailableDialog.show(
|
||||
BestieOfflinePopup.show(
|
||||
context,
|
||||
paymentSessionId: next.paymentSessionId,
|
||||
variant: BestieOfflineVariant.returning,
|
||||
mitraName: next.mitraName,
|
||||
paymentSessionId: next.paymentSessionId,
|
||||
topicSensitivity: next.topicSensitivity,
|
||||
).then((_) {
|
||||
if (mounted) _unavailableDialogShown = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (next is PairingErrorData) {
|
||||
// Inline error UX is preferred over SnackBars (project memory:
|
||||
// "Avoid SnackBars for provider errors"). The build below renders
|
||||
// a banner when the state is PairingErrorData.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -101,6 +88,7 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
||||
final pairingState = ref.watch(pairingProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: HaloTokens.bg,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
@@ -120,52 +108,314 @@ class _SearchingBody extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isTimeout = state is PairingFailedData;
|
||||
final isTargetedWaiting = state is PairingTargetedWaitingData;
|
||||
final errorMessage = state is PairingErrorData ? (state as PairingErrorData).message : null;
|
||||
final errorMessage =
|
||||
state is PairingErrorData ? (state as PairingErrorData).message : null;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
isTargetedWaiting ? 'Menghubungi bestie...' : 'Mencari Bestie...',
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s32,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'sambil nunggu, coba pikirin sebentar 🤍',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 24,
|
||||
height: 30 / 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
SizedBox(height: HaloSpacing.s8),
|
||||
Text(
|
||||
'gausah dipikirin formatnya. ngalir aja gimana enaknya buat kamu.',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
height: 22 / 14,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
SizedBox(height: HaloSpacing.s20),
|
||||
_PromptCard('apa yang lagi paling kamu rasain hari ini?'),
|
||||
SizedBox(height: HaloSpacing.s8),
|
||||
_PromptCard('kapan terakhir kamu ngerasa lega?'),
|
||||
SizedBox(height: HaloSpacing.s8),
|
||||
_PromptCard('ada satu hal yang pengen banget kamu cerita...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
if (errorMessage != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
_ErrorBanner(message: errorMessage),
|
||||
],
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
isTimeout
|
||||
? const _TimeoutPanel()
|
||||
: _SearchingPanel(targetedWaiting: isTargetedWaiting),
|
||||
if (isTimeout) ...[
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
HaloButton(
|
||||
label: 'coba cari lagi',
|
||||
fullWidth: true,
|
||||
size: HaloButtonSize.lg,
|
||||
onPressed: () {
|
||||
ref.read(pairingProvider.notifier).reset();
|
||||
context.go('/payment/entry');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
HaloButton(
|
||||
label: 'kembali ke home',
|
||||
variant: HaloButtonVariant.ghost,
|
||||
fullWidth: true,
|
||||
onPressed: () {
|
||||
ref.read(pairingProvider.notifier).reset();
|
||||
context.go('/home');
|
||||
},
|
||||
),
|
||||
] else if (!isTargetedWaiting) ...[
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
HaloButton(
|
||||
label: 'batalkan',
|
||||
variant: HaloButtonVariant.ghost,
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
ref.read(pairingProvider.notifier).cancelSearch(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PromptCard extends StatelessWidget {
|
||||
final String text;
|
||||
const _PromptCard(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: HaloTokens.border),
|
||||
),
|
||||
child: Text(
|
||||
errorMessage,
|
||||
style: TextStyle(color: Colors.red.shade900),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 48),
|
||||
// The targeted-waiting overlay owns its own cancel button — only
|
||||
// show the general cancel CTA when we're in a non-overlay state.
|
||||
if (!isTargetedWaiting)
|
||||
OutlinedButton(
|
||||
onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(),
|
||||
child: const Text('Batalkan'),
|
||||
),
|
||||
],
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
height: 20 / 13,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchingPanel extends StatelessWidget {
|
||||
final bool targetedWaiting;
|
||||
const _SearchingPanel({required this.targetedWaiting});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s20),
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: HaloTokens.brandSoft),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const _PulsingDots(),
|
||||
const SizedBox(width: HaloSpacing.s16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
targetedWaiting ? 'menghubungi bestie...' : 'lagi nyari bestie...',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text(
|
||||
'biasanya 30 detik · sambil baca prompt aja',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11.5,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimeoutPanel extends StatelessWidget {
|
||||
const _TimeoutPanel();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s20),
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: HaloTokens.brandSoft),
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text('🌙', style: TextStyle(fontSize: 26)),
|
||||
SizedBox(width: HaloSpacing.s16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'masih nyari nih...',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'bestie lagi rame. coba cari lagi atau kembali nanti',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 11.5,
|
||||
height: 16 / 11.5,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PulsingDots extends StatefulWidget {
|
||||
const _PulsingDots();
|
||||
|
||||
@override
|
||||
State<_PulsingDots> createState() => _PulsingDotsState();
|
||||
}
|
||||
|
||||
class _PulsingDotsState extends State<_PulsingDots>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1400),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _scaleAt(double t, double phase) {
|
||||
// Mirrors the keyframe in v3.jsx: (0,80,100) → 0.6 ; 40 → 1.
|
||||
final shifted = (t - phase) % 1.0;
|
||||
final eased = shifted < 0 ? shifted + 1.0 : shifted;
|
||||
if (eased < 0.4) {
|
||||
return 0.6 + (1.0 - 0.6) * (eased / 0.4);
|
||||
} else if (eased < 0.8) {
|
||||
return 1.0 - (1.0 - 0.6) * ((eased - 0.4) / 0.4);
|
||||
}
|
||||
return 0.6;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (i) {
|
||||
final scale = _scaleAt(_controller.value, i * 0.16);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: i == 2 ? 0 : 4),
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
final String message;
|
||||
const _ErrorBanner({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x14D86B6B),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: HaloTokens.danger.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
color: HaloTokens.danger,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../widgets/bestie_unavailable_dialog.dart';
|
||||
|
||||
/// Phase 4 Stage 5 — `SWaitingBestie` overlay.
|
||||
///
|
||||
/// Entry route: `/chat/waiting-targeted/:mitraId` — pushed from the chat
|
||||
/// history "Curhat lagi" CTA after the targeted payment session is confirmed.
|
||||
///
|
||||
/// Three sub-states mapped from `pairingProvider`:
|
||||
///
|
||||
/// - `waiting` (PairingTargetedWaitingData) — orb + 20s countdown + cancel.
|
||||
/// The countdown is purely cosmetic; the server owns the auto-reject timer.
|
||||
/// - `accepted` (PairingBestieFoundData / PairingActiveData) — routes into
|
||||
/// the chat screen immediately.
|
||||
/// - `declined` (PairingTargetedUnavailableData) — shows the
|
||||
/// [BestieOfflinePopup] returning variant; the popup may offer a
|
||||
/// fallback-to-blast CTA when other besties are reachable.
|
||||
class TargetedWaitingScreen extends ConsumerStatefulWidget {
|
||||
final String mitraId;
|
||||
const TargetedWaitingScreen({super.key, required this.mitraId});
|
||||
|
||||
@override
|
||||
ConsumerState<TargetedWaitingScreen> createState() =>
|
||||
_TargetedWaitingScreenState();
|
||||
}
|
||||
|
||||
class _TargetedWaitingScreenState extends ConsumerState<TargetedWaitingScreen> {
|
||||
bool _popupShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual<PairingData>(pairingProvider, _onPairingState);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_onPairingState(null, ref.read(pairingProvider));
|
||||
});
|
||||
}
|
||||
|
||||
void _onPairingState(PairingData? prev, PairingData next) {
|
||||
if (!mounted) return;
|
||||
|
||||
if (next is PairingBestieFoundData) {
|
||||
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
|
||||
return;
|
||||
}
|
||||
if (next is PairingActiveData) {
|
||||
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
|
||||
return;
|
||||
}
|
||||
if (next is PairingCancelledData) {
|
||||
context.go('/home');
|
||||
return;
|
||||
}
|
||||
if (next is PairingTargetedUnavailableData && !_popupShown) {
|
||||
_popupShown = true;
|
||||
// ignore: discarded_futures
|
||||
BestieOfflinePopup.show(
|
||||
context,
|
||||
variant: BestieOfflineVariant.returning,
|
||||
mitraName: next.mitraName,
|
||||
paymentSessionId: next.paymentSessionId,
|
||||
topicSensitivity: next.topicSensitivity,
|
||||
).then((_) {
|
||||
if (mounted) _popupShown = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(pairingProvider);
|
||||
|
||||
final waiting = state is PairingTargetedWaitingData ? state : null;
|
||||
final mitraName = waiting?.mitraName ?? 'bestie';
|
||||
final secondsRemaining = waiting?.secondsRemaining ?? 0;
|
||||
|
||||
return PopScope(
|
||||
// Targeted-wait is reachable directly from chat history; per the
|
||||
// deep-link pop-fallback rule (project memory), we drop the user
|
||||
// back to home if they swipe back rather than into a stale stack.
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
ref.read(pairingProvider.notifier).cancelSearch();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: HaloTokens.bg,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
HaloOrb(
|
||||
size: 120,
|
||||
seed: mitraName.hashCode,
|
||||
label: mitraName,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s20),
|
||||
const Text(
|
||||
'◦ MENUNGGU JAWABAN ◦',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.6,
|
||||
color: HaloTokens.brand,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Text(
|
||||
'lagi nungguin $mitraName',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 24,
|
||||
height: 30 / 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s16,
|
||||
vertical: HaloSpacing.s8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: HaloTokens.brandSoft),
|
||||
),
|
||||
child: Text(
|
||||
'${secondsRemaining}d',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: const Text(
|
||||
'kalau bestie nggak respon dalam 20 detik, kami bantu cariin yang lain.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
height: 20 / 13,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
HaloButton(
|
||||
label: 'batalkan',
|
||||
variant: HaloButtonVariant.ghost,
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
ref.read(pairingProvider.notifier).cancelSearch(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
client_app/lib/features/chat/screens/thank_you_screen.dart
Normal file
68
client_app/lib/features/chat/screens/thank_you_screen.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/halo_button.dart';
|
||||
|
||||
/// S11 — landing screen after a session has been closed. Replaces the
|
||||
/// previous "navigate straight to home" behavior so the user gets a soft
|
||||
/// acknowledgement before re-entering the home shell.
|
||||
class ThankYouScreen extends StatelessWidget {
|
||||
const ThankYouScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) context.go('/home');
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: HaloTokens.bg,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'🤍',
|
||||
style: TextStyle(fontSize: 72),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
const Text(
|
||||
'makasih udah curhat',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
const Text(
|
||||
'semoga kamu lebih plong sekarang. kalau butuh, bestie selalu siap nemenin lagi.',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
height: 22 / 15,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s40),
|
||||
HaloButton(
|
||||
label: 'balik ke home',
|
||||
fullWidth: true,
|
||||
onPressed: () => context.go('/home'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,50 +4,56 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../core/availability/mitra_availability_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/pairing/pairing_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../../support/widgets/tanya_admin_sheet.dart';
|
||||
|
||||
/// Shown when a "Curhat lagi" attempt against a specific bestie can't proceed
|
||||
/// — either a 409 `targeted_mitra_offline` response on the targeted POST, or
|
||||
/// one of the intermediate WS events (`returning_chat_timeout`,
|
||||
/// `returning_chat_rejected`).
|
||||
/// Phase 4 Stage 8 — `BestieOfflinePopup`.
|
||||
///
|
||||
/// CTAs:
|
||||
/// - "Chat dengan bestie lain" — only rendered when
|
||||
/// [mitraAvailabilityProvider] reports `available == true` at the time of
|
||||
/// build. Tapping calls [Pairing.fallbackToBlast] (reuses the same payment
|
||||
/// session — no double-charge) and closes the dialog. The caller is expected
|
||||
/// to be the searching screen, which will transition into PairingSearchingData
|
||||
/// and stay put.
|
||||
/// - "Kembali" — pops dialog and routes home. Backend has already audit-logged
|
||||
/// the targeted failure; payment session stays `confirmed` until the sweeper
|
||||
/// expires it.
|
||||
class BestieUnavailableDialog extends ConsumerWidget {
|
||||
final String paymentSessionId;
|
||||
final String mitraName;
|
||||
final TopicSensitivity topicSensitivity;
|
||||
/// Two 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.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
|
||||
/// [TanyaAdminSheet].
|
||||
enum BestieOfflineVariant { returning, new_ }
|
||||
|
||||
const BestieUnavailableDialog({
|
||||
class BestieOfflinePopup extends ConsumerWidget {
|
||||
final BestieOfflineVariant variant;
|
||||
final String mitraName;
|
||||
final String? paymentSessionId;
|
||||
final TopicSensitivity? topicSensitivity;
|
||||
|
||||
const BestieOfflinePopup({
|
||||
super.key,
|
||||
required this.paymentSessionId,
|
||||
required this.variant,
|
||||
required this.mitraName,
|
||||
required this.topicSensitivity,
|
||||
this.paymentSessionId,
|
||||
this.topicSensitivity,
|
||||
});
|
||||
|
||||
/// Convenience: show this dialog and return when it closes. Per project
|
||||
/// memory ("Riverpod ref.listen in build is unsafe"), callers should
|
||||
/// invoke this from `ref.listenManual` callbacks in `initState`, not from
|
||||
/// `build`.
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String paymentSessionId,
|
||||
required BestieOfflineVariant variant,
|
||||
required String mitraName,
|
||||
required TopicSensitivity topicSensitivity,
|
||||
String? paymentSessionId,
|
||||
TopicSensitivity? topicSensitivity,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => BestieUnavailableDialog(
|
||||
paymentSessionId: paymentSessionId,
|
||||
barrierColor: const Color(0x66000000),
|
||||
builder: (_) => BestieOfflinePopup(
|
||||
variant: variant,
|
||||
mitraName: mitraName,
|
||||
paymentSessionId: paymentSessionId,
|
||||
topicSensitivity: topicSensitivity,
|
||||
),
|
||||
);
|
||||
@@ -55,44 +61,122 @@ class BestieUnavailableDialog extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Snapshot at dialog-open time — we don't keep listening, we just check
|
||||
// whether other bestie are around right now.
|
||||
final availabilityAsync = ref.watch(mitraAvailabilityProvider);
|
||||
final hasOtherAvailable = availabilityAsync.valueOrNull ?? false;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Bestie sedang tidak online'),
|
||||
content: Text(
|
||||
'$mitraName sedang tidak bisa menerima chat saat ini. '
|
||||
'Kamu bisa coba chat dengan bestie lain atau kembali ke beranda.',
|
||||
final isReturning = variant == BestieOfflineVariant.returning;
|
||||
final title = isReturning ? '$mitraName lagi nggak online' : 'semua bestie lagi istirahat';
|
||||
final body = isReturning
|
||||
? '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.';
|
||||
|
||||
final canFallbackToBlast = isReturning &&
|
||||
hasOtherAvailable &&
|
||||
paymentSessionId != null &&
|
||||
topicSensitivity != null;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.xl),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.cloud_off_outlined,
|
||||
color: HaloTokens.brandDark,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
height: 28 / 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Text(
|
||||
body,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
height: 22 / 15,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
if (canFallbackToBlast)
|
||||
HaloButton(
|
||||
label: 'chat dengan bestie lain',
|
||||
fullWidth: true,
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// ignore: discarded_futures
|
||||
ref.read(pairingProvider.notifier).fallbackToBlast(
|
||||
paymentSessionId: paymentSessionId!,
|
||||
topicSensitivity: topicSensitivity!,
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
HaloButton(
|
||||
label: 'kembali ke home',
|
||||
fullWidth: true,
|
||||
onPressed: () {
|
||||
// Reset pairing state and route home. Payment session stays
|
||||
// confirmed until sweeper expires it — no extra API call needed.
|
||||
ref.read(pairingProvider.notifier).reset();
|
||||
Navigator.of(context).pop();
|
||||
context.go('/home');
|
||||
},
|
||||
child: const Text('Kembali'),
|
||||
),
|
||||
if (hasOtherAvailable)
|
||||
ElevatedButton(
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
HaloButton(
|
||||
label: 'tanya admin',
|
||||
variant: HaloButtonVariant.ghost,
|
||||
fullWidth: true,
|
||||
onPressed: () {
|
||||
// Close the dialog first, then kick off the fallback. The
|
||||
// searching screen will pick up the new PairingSearchingData
|
||||
// state and render normally (no targeted overlay).
|
||||
Navigator.of(context).pop();
|
||||
// Keep the popup open underneath; the sheet sits on top and
|
||||
// closes back to it.
|
||||
// ignore: discarded_futures
|
||||
ref.read(pairingProvider.notifier).fallbackToBlast(
|
||||
paymentSessionId: paymentSessionId,
|
||||
topicSensitivity: topicSensitivity,
|
||||
);
|
||||
TanyaAdminSheet.show(context);
|
||||
},
|
||||
),
|
||||
if (canFallbackToBlast) ...[
|
||||
const SizedBox(height: HaloSpacing.s4),
|
||||
HaloButton(
|
||||
label: 'kembali ke home',
|
||||
variant: HaloButtonVariant.ghost,
|
||||
fullWidth: true,
|
||||
onPressed: () {
|
||||
ref.read(pairingProvider.notifier).reset();
|
||||
Navigator.of(context).pop();
|
||||
context.go('/home');
|
||||
},
|
||||
child: const Text('Chat dengan bestie lain'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/halo_button.dart';
|
||||
|
||||
/// Floating banner injected above the chat input bar when the session timer
|
||||
/// has hit zero but the session is still in `closing` grace. Phase 4 Stage 6:
|
||||
/// gives the customer a soft, in-place way to extend instead of the modal-only
|
||||
/// flow from Phase 3.
|
||||
class ChatExpiredBanner extends StatelessWidget {
|
||||
final VoidCallback onExtend;
|
||||
|
||||
const ChatExpiredBanner({super.key, required this.onExtend});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s12,
|
||||
HaloSpacing.s8,
|
||||
HaloSpacing.s12,
|
||||
HaloSpacing.s8,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s16,
|
||||
HaloSpacing.s12,
|
||||
HaloSpacing.s12,
|
||||
HaloSpacing.s12,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.danger,
|
||||
borderRadius: HaloRadius.lg,
|
||||
boxShadow: HaloShadows.card,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('⏰', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: HaloSpacing.s12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'waktu curhat habis',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
HaloButton(
|
||||
label: 'perpanjang',
|
||||
size: HaloButtonSize.sm,
|
||||
variant: HaloButtonVariant.secondary,
|
||||
onPressed: onExtend,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
client_app/lib/features/chat/widgets/closing_message_sheet.dart
Normal file
138
client_app/lib/features/chat/widgets/closing_message_sheet.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/halo_bottom_sheet.dart';
|
||||
import '../../../core/theme/widgets/halo_button.dart';
|
||||
|
||||
/// Stage 7 — replaces the legacy goodbye-composer screen with a bottom sheet.
|
||||
/// The sheet is launched after the two-step confirm; it submits the goodbye
|
||||
/// message AND closes the session. Both CTAs end the session — the difference
|
||||
/// is whether a closing message is sent first.
|
||||
class ClosingMessageSheet {
|
||||
const ClosingMessageSheet._();
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String sessionId,
|
||||
required VoidCallback onCompleted,
|
||||
}) {
|
||||
return HaloBottomSheet.show<void>(
|
||||
context,
|
||||
isScrollControlled: true,
|
||||
child: _ClosingMessageBody(
|
||||
sessionId: sessionId,
|
||||
onCompleted: onCompleted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClosingMessageBody extends ConsumerStatefulWidget {
|
||||
final String sessionId;
|
||||
final VoidCallback onCompleted;
|
||||
|
||||
const _ClosingMessageBody({
|
||||
required this.sessionId,
|
||||
required this.onCompleted,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_ClosingMessageBody> createState() =>
|
||||
_ClosingMessageBodyState();
|
||||
}
|
||||
|
||||
class _ClosingMessageBodyState extends ConsumerState<_ClosingMessageBody> {
|
||||
final _controller = TextEditingController();
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendAndEnd() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || _busy) return;
|
||||
setState(() => _busy = true);
|
||||
final notifier = ref.read(sessionClosureProvider.notifier);
|
||||
await notifier.submitGoodbye(widget.sessionId, text);
|
||||
await notifier.closeSession(widget.sessionId);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
widget.onCompleted();
|
||||
}
|
||||
|
||||
Future<void> _skipAndEnd() async {
|
||||
if (_busy) return;
|
||||
setState(() => _busy = true);
|
||||
await ref.read(sessionClosureProvider.notifier).closeSession(widget.sessionId);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
widget.onCompleted();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewInsets = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: viewInsets),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'pesan penutup',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
const Text(
|
||||
'tulis sesuatu buat bestie sebelum sesi ditutup',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
maxLines: 4,
|
||||
minLines: 3,
|
||||
enabled: !_busy,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'makasih ya bestie...',
|
||||
filled: true,
|
||||
fillColor: HaloTokens.brandSofter,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: HaloRadius.lg,
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
HaloButton(
|
||||
label: 'kirim & akhiri sesi',
|
||||
fullWidth: true,
|
||||
onPressed: _busy ? null : _sendAndEnd,
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
HaloButton(
|
||||
label: 'lewat — langsung akhiri',
|
||||
variant: HaloButtonVariant.ghost,
|
||||
fullWidth: true,
|
||||
onPressed: _busy ? null : _skipAndEnd,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
26
client_app/lib/features/chat/widgets/confirm_end_step1.dart
Normal file
26
client_app/lib/features/chat/widgets/confirm_end_step1.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/widgets/halo_popup.dart';
|
||||
|
||||
/// Stage 7 — first of two confirm popups before ending the session. Surface
|
||||
/// the soft "balik" exit prominently because the most common path here is the
|
||||
/// user mis-tapping "akhiri sesi" while still wanting to continue.
|
||||
class ConfirmEndStep1 {
|
||||
const ConfirmEndStep1._();
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
return HaloPopup.show<void>(
|
||||
context,
|
||||
title: 'yakin mau akhiri sesi?',
|
||||
body: 'sesi akan ditutup dan kamu balik ke home',
|
||||
icon: const Text('🤔', style: TextStyle(fontSize: 40)),
|
||||
primary: HaloPopupAction(label: 'lanjut akhiri', onPressed: onConfirm),
|
||||
secondary: HaloPopupAction(
|
||||
label: 'gak jadi, balik',
|
||||
onPressed: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
client_app/lib/features/chat/widgets/confirm_end_step2.dart
Normal file
27
client_app/lib/features/chat/widgets/confirm_end_step2.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/widgets/halo_popup.dart';
|
||||
|
||||
/// Stage 7 — second confirm popup. Customer has already chosen to end; this
|
||||
/// step nudges (not forces) them to leave a closing message, with `lewati saja`
|
||||
/// as the bypass into the close-session API directly.
|
||||
class ConfirmEndStep2 {
|
||||
const ConfirmEndStep2._();
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required VoidCallback onWriteMessage,
|
||||
required VoidCallback onSkip,
|
||||
}) {
|
||||
return HaloPopup.show<void>(
|
||||
context,
|
||||
title: 'mau tinggalin pesan penutup?',
|
||||
body: 'kamu bisa tulis pesan terakhir buat bestie sebelum sesi ditutup',
|
||||
icon: const Text('💌', style: TextStyle(fontSize: 40)),
|
||||
primary: HaloPopupAction(
|
||||
label: 'tulis pesan penutup',
|
||||
onPressed: onWriteMessage,
|
||||
),
|
||||
secondary: HaloPopupAction(label: 'lewati saja', onPressed: onSkip),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/chat/session_closure_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/halo_button.dart';
|
||||
import '../../payment/state/payment_draft_provider.dart';
|
||||
|
||||
/// Extension-only pricing sheet.
|
||||
/// Extension-only pricing sheet — Phase 4 Stage 6 layout.
|
||||
///
|
||||
/// Used solely for in-session extension requests; the initial pairing flow
|
||||
/// goes through `/payment` instead. Free-trial is never offered for extensions.
|
||||
///
|
||||
/// Submit triggers [SessionClosure.requestExtension], which internally
|
||||
/// runs the payment-session create+confirm and then the extend POST.
|
||||
class PricingBottomSheet extends ConsumerWidget {
|
||||
/// Mirrors the Stage 3 duration-pick screen: chat/call toggle on top,
|
||||
/// 5-option tier list below, single CTA at the bottom. The `perpanjang`
|
||||
/// behavior is unchanged from Phase 3.7 — submit calls
|
||||
/// [SessionClosure.requestExtension], which runs the payment-session
|
||||
/// create+confirm and then the extend POST.
|
||||
class PricingBottomSheet extends ConsumerStatefulWidget {
|
||||
/// Required — the in-progress chat session id this extension targets.
|
||||
final String extensionSessionId;
|
||||
|
||||
@@ -22,62 +28,372 @@ class PricingBottomSheet extends ConsumerWidget {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: HaloTokens.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(24),
|
||||
topRight: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
builder: (_) => PricingBottomSheet(extensionSessionId: sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pricingAsync = ref.watch(chatPricingProvider);
|
||||
ConsumerState<PricingBottomSheet> createState() => _PricingBottomSheetState();
|
||||
}
|
||||
|
||||
return pricingAsync.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, _) => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
|
||||
),
|
||||
data: (pricing) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.8,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
children: [
|
||||
const Text(
|
||||
'Perpanjang Durasi',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// No free-trial path for extensions.
|
||||
...pricing.tiers.map((tier) => Card(
|
||||
child: ListTile(
|
||||
title: Text(tier.label),
|
||||
trailing: Text(
|
||||
formatRupiah(tier.price),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
onTap: () {
|
||||
class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
|
||||
PaymentMode _mode = PaymentMode.chat;
|
||||
String? _selectedDurationId;
|
||||
|
||||
List<PriceTier> _tiersForMode(PricingData pricing) {
|
||||
// Phase 4 — chat/call tier groups. Falls back to legacy `tiers` when the
|
||||
// backend hasn't been cut over yet (so the sheet still works locally
|
||||
// against an old backend).
|
||||
if (_mode == PaymentMode.call) {
|
||||
return pricing.callTiers.isNotEmpty ? pricing.callTiers : pricing.tiers;
|
||||
}
|
||||
return pricing.chatTiers.isNotEmpty ? pricing.chatTiers : pricing.tiers;
|
||||
}
|
||||
|
||||
void _onTierTap(PriceTier tier) {
|
||||
setState(() {
|
||||
_selectedDurationId = tier.id ?? tier.durationMinutes.toString();
|
||||
});
|
||||
}
|
||||
|
||||
void _onConfirm(PriceTier tier) {
|
||||
Navigator.of(context).pop();
|
||||
ref.read(sessionClosureProvider.notifier).requestExtension(
|
||||
extensionSessionId,
|
||||
widget.extensionSessionId,
|
||||
durationMinutes: tier.durationMinutes,
|
||||
price: tier.price,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pricingAsync = ref.watch(chatPricingProvider);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.65,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.92,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: pricingAsync.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 240,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (_, __) => const SizedBox(
|
||||
height: 240,
|
||||
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
|
||||
),
|
||||
data: (pricing) => _Body(
|
||||
pricing: pricing,
|
||||
mode: _mode,
|
||||
selectedDurationId: _selectedDurationId,
|
||||
tiers: _tiersForMode(pricing),
|
||||
scrollController: scrollController,
|
||||
onModeChanged: (m) => setState(() {
|
||||
_mode = m;
|
||||
_selectedDurationId = null;
|
||||
}),
|
||||
onTierTap: _onTierTap,
|
||||
onConfirm: _onConfirm,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
final PricingData pricing;
|
||||
final PaymentMode mode;
|
||||
final String? selectedDurationId;
|
||||
final List<PriceTier> tiers;
|
||||
final ScrollController scrollController;
|
||||
final ValueChanged<PaymentMode> onModeChanged;
|
||||
final ValueChanged<PriceTier> onTierTap;
|
||||
final ValueChanged<PriceTier> onConfirm;
|
||||
|
||||
const _Body({
|
||||
required this.pricing,
|
||||
required this.mode,
|
||||
required this.selectedDurationId,
|
||||
required this.tiers,
|
||||
required this.scrollController,
|
||||
required this.onModeChanged,
|
||||
required this.onTierTap,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedTier = tiers.firstWhere(
|
||||
(t) => (t.id ?? t.durationMinutes.toString()) == selectedDurationId,
|
||||
orElse: () => const PriceTier(durationMinutes: 0, price: 0, label: ''),
|
||||
);
|
||||
final hasSelection = selectedTier.durationMinutes > 0;
|
||||
final ctaLabel = hasSelection
|
||||
? '${mode == PaymentMode.call ? '📞' : '💬'} perpanjang ${formatRupiah(selectedTier.price)}'
|
||||
: 'pilih durasi dulu';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.border,
|
||||
borderRadius: HaloRadius.pill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
const Text(
|
||||
'waktu curhat habis',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s4),
|
||||
const Text(
|
||||
'mau tambah waktu?',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24),
|
||||
child: _ModeToggle(mode: mode, onChanged: onModeChanged),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Expanded(
|
||||
child: tiers.isEmpty
|
||||
? const _EmptyState()
|
||||
: ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s4,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s16,
|
||||
),
|
||||
itemCount: tiers.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: HaloSpacing.s8),
|
||||
itemBuilder: (context, i) {
|
||||
final tier = tiers[i];
|
||||
final id = tier.id ?? tier.durationMinutes.toString();
|
||||
final selected = id == selectedDurationId;
|
||||
return _TierCard(
|
||||
tier: tier,
|
||||
selected: selected,
|
||||
onTap: () => onTierTap(tier),
|
||||
);
|
||||
},
|
||||
),
|
||||
)),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s8,
|
||||
HaloSpacing.s24,
|
||||
HaloSpacing.s24,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: HaloTokens.border)),
|
||||
),
|
||||
child: HaloButton(
|
||||
label: ctaLabel,
|
||||
size: HaloButtonSize.lg,
|
||||
fullWidth: true,
|
||||
onPressed: hasSelection ? () => onConfirm(selectedTier) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeToggle extends StatelessWidget {
|
||||
final PaymentMode mode;
|
||||
final ValueChanged<PaymentMode> onChanged;
|
||||
|
||||
const _ModeToggle({required this.mode, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
borderRadius: HaloRadius.pill,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _Pill(label: 'chat', selected: mode == PaymentMode.chat, onTap: () => onChanged(PaymentMode.chat))),
|
||||
Expanded(child: _Pill(label: 'call', selected: mode == PaymentMode.call, onTap: () => onChanged(PaymentMode.call))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
const _Pill({required this.label, required this.selected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: selected ? HaloTokens.surface : Colors.transparent,
|
||||
borderRadius: HaloRadius.pill,
|
||||
child: InkWell(
|
||||
borderRadius: HaloRadius.pill,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: HaloSpacing.s8),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected ? HaloTokens.brandDark : HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TierCard extends StatelessWidget {
|
||||
final PriceTier tier;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TierCard({required this.tier, required this.selected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: selected ? HaloTokens.brandSofter : HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
child: InkWell(
|
||||
borderRadius: HaloRadius.lg,
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: HaloMotion.fast,
|
||||
padding: const EdgeInsets.all(HaloSpacing.s16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: selected ? HaloTokens.brand : HaloTokens.border,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
borderRadius: HaloRadius.lg,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.md,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${tier.durationMinutes}',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: HaloSpacing.s12),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${tier.durationMinutes} menit',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
if (tier.tag != null) ...[
|
||||
const SizedBox(width: HaloSpacing.s8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.mint,
|
||||
borderRadius: HaloRadius.pill,
|
||||
),
|
||||
child: Text(
|
||||
tier.tag!,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF1F4D34),
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatRupiah(tier.price),
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(HaloSpacing.s24),
|
||||
child: Text(
|
||||
'Belum ada paket untuk mode ini.',
|
||||
style: TextStyle(color: HaloTokens.inkSoft),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/availability/mitra_availability_notifier.dart';
|
||||
import '../../core/chat/active_session_notifier.dart';
|
||||
import '../chat/widgets/topic_selection_bottom_sheet.dart';
|
||||
import '../../core/notifications/notif_permission.dart';
|
||||
import '../../core/theme/halo_tokens.dart';
|
||||
import 'providers/bestie_history_provider.dart';
|
||||
import 'widgets/bestie_choice_sheet.dart';
|
||||
|
||||
/// Session-only dismiss flag for the "notif denied" banner. Resets on cold
|
||||
/// restart by design — `StateProvider` lives in memory only.
|
||||
final homeNotifBannerDismissedProvider = StateProvider<bool>((_) => false);
|
||||
|
||||
/// Home screen.
|
||||
///
|
||||
@@ -54,9 +61,27 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
}
|
||||
|
||||
Future<void> _onStartChatPressed(BuildContext context) async {
|
||||
final topic = await TopicSelectionBottomSheet.show(context);
|
||||
if (topic == null || !context.mounted) return;
|
||||
context.push('/payment', extra: {'topicSensitivity': topic});
|
||||
// Phase 4 Stage 2 removes the home-screen topic sensitivity prompt; the
|
||||
// ESP picks collected during onboarding feed the same column server-side
|
||||
// (info-only — no longer drives matching). Mitras still flip
|
||||
// `topic_sensitivity` mid-session via the AppBar toggle.
|
||||
//
|
||||
// Phase 4 Stage 8: returning users get the bestie-choice sheet first; new
|
||||
// users skip straight to the multi-screen payment shell. We fetch the
|
||||
// history-has-items flag on-tap so a stale cache from logout/login doesn't
|
||||
// mis-route. On error (e.g. offline), fall back to the new-user path.
|
||||
bool hasHistory;
|
||||
try {
|
||||
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
|
||||
} catch (_) {
|
||||
hasHistory = false;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (hasHistory) {
|
||||
await BestieChoiceSheet.show(context);
|
||||
return;
|
||||
}
|
||||
context.push('/payment/entry');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -101,9 +126,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
||||
child: ListView(
|
||||
// Force-scroll so RefreshIndicator can fire even on a short body.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(32),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
const _NotifDeniedBanner(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||
child: SizedBox(height: 32),
|
||||
),
|
||||
Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))),
|
||||
const SizedBox(height: 32),
|
||||
Center(
|
||||
@@ -229,3 +258,76 @@ class _ActiveSessionCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Above-the-fold amber banner shown when notif permission is denied. Tap
|
||||
/// "nyalain" → opens app settings; tap the close icon → hides for the
|
||||
/// in-memory session only (cold restart re-shows it).
|
||||
class _NotifDeniedBanner extends ConsumerWidget {
|
||||
const _NotifDeniedBanner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final statusAsync = ref.watch(notifPermissionStatusProvider);
|
||||
final dismissed = ref.watch(homeNotifBannerDismissedProvider);
|
||||
final isDenied = statusAsync.valueOrNull == NotifPermStatus.denied;
|
||||
if (!isDenied || dismissed) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: HaloTokens.accentSoft,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s16,
|
||||
vertical: HaloSpacing.s8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.notifications_off_outlined,
|
||||
size: 18,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
const SizedBox(width: HaloSpacing.s8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'notifikasi off — kamu bisa kelewat chat dari bestie',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 12.5,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: HaloTokens.brandDark,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s8,
|
||||
),
|
||||
minimumSize: const Size(0, 32),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
ref.read(notifPermissionStatusProvider.notifier).openAppSettings(),
|
||||
child: const Text('nyalain'),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
color: HaloTokens.inkSoft,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () =>
|
||||
ref.read(homeNotifBannerDismissedProvider.notifier).state = true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
|
||||
class BestieHistoryItem {
|
||||
final String sessionId;
|
||||
final String? mitraId;
|
||||
final String mitraName;
|
||||
final DateTime? endedAt;
|
||||
final List<String> topics;
|
||||
final int sessionsCount;
|
||||
final bool mitraIsOnline;
|
||||
|
||||
const BestieHistoryItem({
|
||||
required this.sessionId,
|
||||
required this.mitraId,
|
||||
required this.mitraName,
|
||||
required this.endedAt,
|
||||
required this.topics,
|
||||
required this.sessionsCount,
|
||||
required this.mitraIsOnline,
|
||||
});
|
||||
|
||||
factory BestieHistoryItem.fromJson(Map<String, dynamic> json) {
|
||||
final endedAtRaw = json['ended_at'];
|
||||
return BestieHistoryItem(
|
||||
sessionId: json['id'] as String,
|
||||
mitraId: json['mitra_id'] as String?,
|
||||
mitraName: json['mitra_display_name'] as String? ?? 'Bestie',
|
||||
endedAt: endedAtRaw is String ? DateTime.tryParse(endedAtRaw)?.toLocal() : null,
|
||||
topics: (json['topics'] as List?)?.cast<String>() ?? const [],
|
||||
sessionsCount: (json['sessions_count'] as num?)?.toInt() ?? 1,
|
||||
mitraIsOnline: json['mitra_is_online'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final bestieHistoryProvider = FutureProvider<List<BestieHistoryItem>>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/client/chat/history');
|
||||
final items = (response['data']['items'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
return items.map(BestieHistoryItem.fromJson).toList();
|
||||
});
|
||||
|
||||
/// Cheap derived provider used by the home CTA to decide whether to show the
|
||||
/// bestie-choice sheet or skip straight into the new-payment flow.
|
||||
final bestieHistoryHasItemsProvider = FutureProvider<bool>((ref) async {
|
||||
final items = await ref.watch(bestieHistoryProvider.future);
|
||||
return items.isNotEmpty;
|
||||
});
|
||||
141
client_app/lib/features/home/widgets/bestie_choice_sheet.dart
Normal file
141
client_app/lib/features/home/widgets/bestie_choice_sheet.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.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 {
|
||||
const BestieChoiceSheet({super.key});
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return HaloBottomSheet.show<void>(
|
||||
context,
|
||||
child: const BestieChoiceSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'mau curhat sama siapa?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
const Text(
|
||||
'pilih lanjut sama bestie yang udah kenal, atau coba bestie baru.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
_ChoiceCard(
|
||||
title: 'bestie yang udah kenal',
|
||||
subtitle: 'lanjut cerita ke bestie yang pernah dengerin kamu.',
|
||||
icon: Icons.favorite_outline,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.push('/chat/history');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
_ChoiceCard(
|
||||
title: 'bestie baru',
|
||||
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.push('/payment/entry');
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChoiceCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ChoiceCard({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: HaloTokens.brandSofter,
|
||||
borderRadius: HaloRadius.lg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: HaloRadius.lg,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.brandSoft,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, color: HaloTokens.brandDark, size: 24),
|
||||
),
|
||||
const SizedBox(width: HaloSpacing.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 13,
|
||||
height: 18 / 13,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: HaloTokens.brandDark),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user