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 { sessionManagementRoutes } from './routes/internal/session.routes.js'
|
||||||
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
|
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
|
||||||
import { failedPairingsRoutes } from './routes/internal/failed-pairings.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'
|
import { errorHandler } from './plugins/error-handler.js'
|
||||||
|
|
||||||
export const buildInternalApp = async () => {
|
export const buildInternalApp = async () => {
|
||||||
@@ -38,5 +39,10 @@ export const buildInternalApp = async () => {
|
|||||||
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
|
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
|
||||||
app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' })
|
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
|
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 { clientAuthRoutes } from './routes/public/client.auth.routes.js'
|
||||||
import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js'
|
import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js'
|
||||||
import { sharedConfigRoutes } from './routes/public/shared.config.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 { mitraStatusRoutes } from './routes/public/mitra.status.routes.js'
|
||||||
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
||||||
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
||||||
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
|
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
|
||||||
import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.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 { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
||||||
import { errorHandler } from './plugins/error-handler.js'
|
import { errorHandler } from './plugins/error-handler.js'
|
||||||
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.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(sharedAuthRoutes, { prefix: '/api/shared/auth' })
|
||||||
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
|
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
|
||||||
|
app.register(sharedAuthProvidersRoutes, { prefix: '/api/shared/auth-providers' })
|
||||||
app.register(sharedChatRoutes, { prefix: '/api/shared' })
|
app.register(sharedChatRoutes, { prefix: '/api/shared' })
|
||||||
app.register(clientAuthRoutes, { prefix: '/api/client/auth' })
|
app.register(clientAuthRoutes, { prefix: '/api/client/auth' })
|
||||||
app.register(mitraAuthRoutes, { prefix: '/api/mitra/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(clientChatRoutes, { prefix: '/api/client/chat' })
|
||||||
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
|
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
|
||||||
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
|
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)
|
// WebSocket route (registered at app level, not prefixed)
|
||||||
registerWebSocketRoute(app)
|
registerWebSocketRoute(app)
|
||||||
|
|||||||
@@ -48,11 +48,20 @@ export const ExtensionStatus = Object.freeze({
|
|||||||
|
|
||||||
// Customer transaction types
|
// Customer transaction types
|
||||||
export const TransactionType = Object.freeze({
|
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',
|
PAID: 'paid',
|
||||||
EXTENSION: 'extension',
|
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
|
// Payment session lifecycle
|
||||||
export const PaymentSessionStatus = Object.freeze({
|
export const PaymentSessionStatus = Object.freeze({
|
||||||
PENDING: 'pending',
|
PENDING: 'pending',
|
||||||
@@ -144,6 +153,9 @@ export const WsMessage = Object.freeze({
|
|||||||
|
|
||||||
// Session lifecycle
|
// Session lifecycle
|
||||||
SESSION_TIMER: 'session_timer',
|
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_EXPIRED: 'session_expired',
|
||||||
SESSION_CLOSING: 'session_closing',
|
SESSION_CLOSING: 'session_closing',
|
||||||
SESSION_COMPLETED: 'session_completed',
|
SESSION_COMPLETED: 'session_completed',
|
||||||
|
|||||||
@@ -549,6 +549,111 @@ const migrate = async () => {
|
|||||||
ON CONFLICT (key) DO NOTHING
|
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.')
|
console.log('Migration complete.')
|
||||||
await sql.end()
|
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,
|
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
|
||||||
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
||||||
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
||||||
|
getFirstSessionDiscountConfig, setFirstSessionDiscountConfig,
|
||||||
|
getSupportHandles, setSupportHandles,
|
||||||
|
getPricingTierGroups, setPricingTierGroup,
|
||||||
} from '../../services/config.service.js'
|
} from '../../services/config.service.js'
|
||||||
|
|
||||||
const attachCcUser = async (request, reply) => {
|
const attachCcUser = async (request, reply) => {
|
||||||
@@ -284,4 +287,104 @@ export const internalConfigRoutes = async (app) => {
|
|||||||
await publishConfigInvalidate('pairing_blast_timeout_seconds')
|
await publishConfigInvalidate('pairing_blast_timeout_seconds')
|
||||||
return reply.send({ success: true, data: config })
|
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) => {
|
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) => {
|
app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const pricing = await getPricingForCustomer(request.customer.id)
|
const pricing = await getPricingForCustomer(request.customer.id)
|
||||||
return reply.send({ success: true, data: pricing })
|
return reply.send({ success: true, data: pricing })
|
||||||
@@ -171,7 +174,7 @@ export const clientChatRoutes = async (app) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension request REQUIRES `extension_payment_session_id`.
|
* 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.
|
* Pricing/duration come from the payment session via the extension service.
|
||||||
*/
|
*/
|
||||||
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
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,
|
getPaymentSession,
|
||||||
} from '../../services/payment.service.js'
|
} from '../../services/payment.service.js'
|
||||||
import {
|
import {
|
||||||
isCustomerEligibleForFreeTrial,
|
isCustomerEligibleForFirstSessionDiscount,
|
||||||
isValidTier,
|
isValidTier,
|
||||||
getPriceTiers,
|
findTier,
|
||||||
} from '../../services/pricing.service.js'
|
} 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) => {
|
const resolveCustomer = async (request, reply) => {
|
||||||
if (request.auth?.userType !== UserType.CUSTOMER) {
|
if (request.auth?.userType !== UserType.CUSTOMER) {
|
||||||
@@ -30,6 +33,25 @@ const resolveCustomer = async (request, reply) => {
|
|||||||
request.customer = customer
|
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).
|
* Payment session lifecycle (mocked — no Xendit yet).
|
||||||
*
|
*
|
||||||
@@ -39,12 +61,13 @@ const resolveCustomer = async (request, reply) => {
|
|||||||
* GET /api/client/payment-sessions/:id
|
* GET /api/client/payment-sessions/:id
|
||||||
*/
|
*/
|
||||||
export const clientPaymentRoutes = async (app) => {
|
export const clientPaymentRoutes = async (app) => {
|
||||||
// Create a payment session (status = pending). Free-trial logic is server-side: if the
|
// Create a payment session (status = pending). First-session-discount is server-authoritative:
|
||||||
// customer is eligible AND this is NOT an extension, amount is forced to 0 and
|
// if the customer is eligible AND this is NOT an extension AND mode is in the configured
|
||||||
// is_free_trial = true regardless of what the client passes.
|
// modes list, amount is forced to the configured discount price.
|
||||||
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const {
|
const {
|
||||||
duration_minutes,
|
duration_minutes,
|
||||||
|
mode = SessionMode.CHAT,
|
||||||
targeted_mitra_id = null,
|
targeted_mitra_id = null,
|
||||||
is_extension = false,
|
is_extension = false,
|
||||||
} = request.body ?? {}
|
} = request.body ?? {}
|
||||||
@@ -55,33 +78,44 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' },
|
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 isFirstSessionDiscount = false
|
||||||
let isFreeTrial = false
|
|
||||||
let amount
|
let amount
|
||||||
|
|
||||||
if (!is_extension) {
|
if (!is_extension) {
|
||||||
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
|
const eligible = await isCustomerEligibleForFirstSessionDiscount(request.customer.id)
|
||||||
if (eligible) {
|
if (eligible) {
|
||||||
isFreeTrial = true
|
const discount = await readDiscountConfig()
|
||||||
amount = 0
|
// 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) {
|
if (!isFirstSessionDiscount) {
|
||||||
// Resolve amount from the price tiers (duration-keyed). The client passes
|
// Resolve amount from the configured tier list for the requested mode.
|
||||||
// duration_minutes; we look up the matching tier to get the canonical price.
|
const tier = await findTier({ mode, durationMinutes: duration_minutes })
|
||||||
const tiers = await getPriceTiers()
|
|
||||||
const tier = tiers.find((t) => t.duration_minutes === duration_minutes)
|
|
||||||
if (!tier) {
|
if (!tier) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
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
|
amount = tier.price_idr
|
||||||
// Sanity check (defense-in-depth) — duration+price should match a known tier.
|
if (!(await isValidTier({ mode, durationMinutes: duration_minutes, priceIdr: amount }))) {
|
||||||
if (!(await isValidTier(duration_minutes, amount))) {
|
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
||||||
@@ -93,9 +127,10 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
customerId: request.customer.id,
|
customerId: request.customer.id,
|
||||||
durationMinutes: duration_minutes,
|
durationMinutes: duration_minutes,
|
||||||
amount,
|
amount,
|
||||||
isFreeTrial,
|
isFirstSessionDiscount,
|
||||||
isExtension: Boolean(is_extension),
|
isExtension: Boolean(is_extension),
|
||||||
targetedMitraId: targeted_mitra_id || null,
|
targetedMitraId: targeted_mitra_id || null,
|
||||||
|
mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
@@ -104,8 +139,9 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
id: session.id,
|
id: session.id,
|
||||||
amount: session.amount,
|
amount: session.amount,
|
||||||
duration_minutes: session.duration_minutes,
|
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,
|
is_extension: session.is_extension,
|
||||||
|
mode: session.mode,
|
||||||
targeted_mitra_id: session.targeted_mitra_id,
|
targeted_mitra_id: session.targeted_mitra_id,
|
||||||
expires_at: session.expires_at,
|
expires_at: session.expires_at,
|
||||||
status: session.status,
|
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 }
|
return { max_customers_per_mitra: value }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Phase 3 config ---
|
// --- Phase 4: First-session discount (replaces Phase 3 free-trial config) ---
|
||||||
|
|
||||||
export const getFreeTrialConfig = async () => {
|
export const getFirstSessionDiscountConfig = async () => {
|
||||||
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
const rows = await sql`
|
||||||
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
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 {
|
return {
|
||||||
enabled: enabledRow?.value?.value ?? false,
|
enabled: byKey.first_session_discount_enabled ?? true,
|
||||||
duration_minutes: durationRow?.value?.value ?? 5,
|
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 }) => {
|
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||||
if (enabled !== undefined) {
|
return setFirstSessionDiscountConfig({
|
||||||
await sql`
|
...(enabled !== undefined ? { enabled } : {}),
|
||||||
INSERT INTO app_config (key, value, updated_at)
|
...(duration_minutes !== undefined ? { duration_minutes } : {}),
|
||||||
VALUES ('free_trial_enabled', ${sql.json({ value: enabled })}, NOW())
|
})
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
}
|
||||||
`
|
|
||||||
|
// --- 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: '' },
|
||||||
}
|
}
|
||||||
if (duration_minutes !== undefined) {
|
}
|
||||||
await sql`
|
|
||||||
INSERT INTO app_config (key, value, updated_at)
|
export const setSupportHandles = async ({ wa, telegram }) => {
|
||||||
VALUES ('free_trial_duration_minutes', ${sql.json({ value: duration_minutes })}, NOW())
|
const current = await getSupportHandles()
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
const next = {
|
||||||
`
|
wa: { ...current.wa, ...(wa || {}) },
|
||||||
|
telegram: { ...current.telegram, ...(telegram || {}) },
|
||||||
}
|
}
|
||||||
return getFreeTrialConfig()
|
await sql`
|
||||||
|
INSERT INTO app_config (key, value, updated_at)
|
||||||
|
VALUES ('support_handles_json', ${sql.json(next)}, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
`
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 (${key}, ${sql.json({ tiers })}, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
`
|
||||||
|
return getPricingTierGroups()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getExtensionTimeoutConfig = async () => {
|
export const getExtensionTimeoutConfig = async () => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const getExtensionTimeoutAction = async () => {
|
|||||||
* - belong to this customer
|
* - belong to this customer
|
||||||
* - be in `confirmed` status (not yet consumed)
|
* - be in `confirmed` status (not yet consumed)
|
||||||
* - have `is_extension = true`
|
* - 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
|
* The payment session is NOT consumed at request time. It is consumed at approval moment
|
||||||
* (mitra explicit accept OR auto-approve fires).
|
* (mitra explicit accept OR auto-approve fires).
|
||||||
@@ -83,9 +83,9 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
|
|||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (paySession.is_free_trial) {
|
if (paySession.is_first_session_discount) {
|
||||||
throw Object.assign(new Error('Free trial is not available for extensions'), {
|
throw Object.assign(new Error('First-session discount is not available for extensions'), {
|
||||||
code: 'FREE_TRIAL_NOT_ALLOWED', statusCode: 400,
|
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')
|
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 fazpassSendStub = async ({ phone, channel }) => {
|
||||||
const reference = `stub_${crypto.randomUUID()}`
|
const reference = `stub_${crypto.randomUUID()}`
|
||||||
const code = generate6DigitCode()
|
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.
|
// Log the code so developers can read it during dev testing.
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
|
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.
|
* 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.
|
* 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
|
* `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.
|
// Create session sourced from the payment session.
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
INSERT INTO chat_sessions (
|
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 (
|
VALUES (
|
||||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
${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}
|
${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
|
// 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,
|
request_type: PairingRequestType.GENERAL,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
duration_minutes: session.duration_minutes,
|
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,
|
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.
|
// Create session sourced from the payment session, status = pending_acceptance.
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
INSERT INTO chat_sessions (
|
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 (
|
VALUES (
|
||||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
${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}
|
${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
|
// Single notification to the targeted mitra
|
||||||
@@ -330,7 +330,7 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
|
|||||||
request_type: PairingRequestType.RETURNING,
|
request_type: PairingRequestType.RETURNING,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
duration_minutes: session.duration_minutes,
|
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,
|
topic_sensitivity: session.topic_sensitivity,
|
||||||
confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds,
|
confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds,
|
||||||
})
|
})
|
||||||
@@ -399,12 +399,12 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
WHERE id = ${sessionId}
|
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
|
// Record transaction
|
||||||
if (activeSession.duration_minutes) {
|
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`
|
await sql`
|
||||||
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
||||||
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
|
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
|
||||||
@@ -787,7 +787,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
|
|||||||
SELECT
|
SELECT
|
||||||
cs.id AS session_id,
|
cs.id AS session_id,
|
||||||
cs.duration_minutes,
|
cs.duration_minutes,
|
||||||
cs.is_free_trial,
|
cs.is_first_session_discount,
|
||||||
cs.topic_sensitivity,
|
cs.topic_sensitivity,
|
||||||
cs.created_at,
|
cs.created_at,
|
||||||
CASE
|
CASE
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getDb } from '../db/client.js'
|
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 { recordFailure } from './pairing-failure.service.js'
|
||||||
import { sendToUser } from '../plugins/websocket.js'
|
import { sendToUser } from '../plugins/websocket.js'
|
||||||
import { sendPushNotification } from './notification.service.js'
|
import { sendPushNotification } from './notification.service.js'
|
||||||
@@ -15,14 +15,19 @@ const getPaymentSessionTimeoutMinutes = async () => {
|
|||||||
/**
|
/**
|
||||||
* Create a new payment session in `pending` status.
|
* Create a new payment session in `pending` status.
|
||||||
* Reads `payment_session_timeout_minutes` from config to compute expires_at.
|
* 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 ({
|
export const createPaymentSession = async ({
|
||||||
customerId,
|
customerId,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
amount,
|
amount,
|
||||||
isFreeTrial = false,
|
isFirstSessionDiscount = false,
|
||||||
isExtension = false,
|
isExtension = false,
|
||||||
targetedMitraId = null,
|
targetedMitraId = null,
|
||||||
|
mode = SessionMode.CHAT,
|
||||||
}) => {
|
}) => {
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
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) {
|
if (typeof amount !== 'number' || amount < 0) {
|
||||||
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
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 ttlMinutes = await getPaymentSessionTimeoutMinutes()
|
||||||
|
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
INSERT INTO payment_sessions (
|
INSERT INTO payment_sessions (
|
||||||
customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||||
status, targeted_mitra_id, expires_at
|
status, targeted_mitra_id, mode, expires_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${customerId}, ${amount}, ${durationMinutes}, ${isFreeTrial}, ${isExtension},
|
${customerId}, ${amount}, ${durationMinutes}, ${isFirstSessionDiscount}, ${isExtension},
|
||||||
${PaymentSessionStatus.PENDING}, ${targetedMitraId},
|
${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${mode},
|
||||||
NOW() + (${ttlMinutes} || ' minutes')::interval
|
NOW() + (${ttlMinutes} || ' minutes')::interval
|
||||||
)
|
)
|
||||||
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||||
`
|
`
|
||||||
|
|
||||||
return row
|
return row
|
||||||
@@ -87,8 +95,8 @@ export const confirmPaymentSession = async (paymentSessionId, customerId) => {
|
|||||||
UPDATE payment_sessions
|
UPDATE payment_sessions
|
||||||
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW()
|
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW()
|
||||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
||||||
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||||
`
|
`
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
|
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) => {
|
export const getPaymentSession = async (id) => {
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
SELECT id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
SELECT id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||||
FROM payment_sessions
|
FROM payment_sessions
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -1,75 +1,175 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
|
import { SessionStatus } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
// Default tiers as fallback
|
// Default tiers as fallback (used if app_config row is missing). Match the seed
|
||||||
const DEFAULT_TIERS = [
|
// values in migrate.js so a missing row never breaks pricing in the wild.
|
||||||
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
|
const DEFAULT_CHAT_TIERS = [
|
||||||
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
|
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
|
||||||
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
|
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||||
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
|
{ id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||||
{ duration_minutes: 60, price: 150000, label: '60 Menit' },
|
{ id: '60', minutes: 60, price_idr: 45000, tag: null },
|
||||||
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
|
{ id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||||
]
|
]
|
||||||
|
const DEFAULT_CALL_TIERS = [
|
||||||
export const getPriceTiers = async () => {
|
{ id: '10', minutes: 10, price_idr: 9000, tag: null },
|
||||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'price_tiers'`
|
{ id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||||
return row?.value?.tiers ?? DEFAULT_TIERS
|
{ 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 readChatTiers = async () => {
|
||||||
const tiers = await getPriceTiers()
|
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
|
||||||
return tiers.some(
|
return row?.value?.tiers ?? DEFAULT_CHAT_TIERS
|
||||||
(t) => t.duration_minutes === durationMinutes && t.price === price
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFreeTrial = async () => {
|
const readCallTiers = async () => {
|
||||||
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
|
||||||
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
return row?.value?.tiers ?? DEFAULT_CALL_TIERS
|
||||||
return {
|
|
||||||
enabled: enabledRow?.value?.value ?? false,
|
|
||||||
duration_minutes: durationRow?.value?.value ?? 5,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isCustomerEligibleForFreeTrial = async (customerId) => {
|
const readDiscountConfig = async () => {
|
||||||
const freeTrial = await getFreeTrial()
|
const keys = [
|
||||||
if (!freeTrial.enabled) return false
|
'first_session_discount_enabled',
|
||||||
|
'first_session_discount_actual_price_idr',
|
||||||
const [tx] = await sql`
|
'first_session_discount_gimmick_price_idr',
|
||||||
SELECT id FROM customer_transactions
|
'first_session_discount_duration_minutes',
|
||||||
WHERE customer_id = ${customerId}
|
'first_session_discount_modes',
|
||||||
LIMIT 1
|
]
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT key, value FROM app_config WHERE key IN ${sql(keys)}
|
||||||
`
|
`
|
||||||
return !tx // Eligible only if no transactions at all
|
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||||
}
|
|
||||||
|
|
||||||
export const getPricingForCustomer = async (customerId) => {
|
|
||||||
const tiers = await getPriceTiers()
|
|
||||||
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
|
|
||||||
const freeTrial = await getFreeTrial()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tiers,
|
enabled: byKey.first_session_discount_enabled ?? DEFAULT_DISCOUNT.enabled,
|
||||||
free_trial: freeTrialEligible
|
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? DEFAULT_DISCOUNT.actual_price_idr,
|
||||||
? { eligible: true, duration_minutes: freeTrial.duration_minutes }
|
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? DEFAULT_DISCOUNT.gimmick_price_idr,
|
||||||
: { eligible: false },
|
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.
|
* Predicate (Phase 4):
|
||||||
* The customerId is accepted for API symmetry/future tier personalization.
|
* - 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
|
// eslint-disable-next-line no-unused-vars
|
||||||
export const getExtensionPriceTiers = async (customerId) => {
|
export const getExtensionPriceTiers = async (customerId) => {
|
||||||
const tiers = await getPriceTiers()
|
const tiers = await readChatTiers()
|
||||||
return {
|
return {
|
||||||
tiers,
|
tiers,
|
||||||
free_trial: { eligible: false },
|
first_session_discount: { eligible: false },
|
||||||
is_free_trial: 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()
|
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()
|
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) => {
|
export const startSessionTimer = (sessionId, expiresAt) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const expiresMs = new Date(expiresAt).getTime()
|
const expiresMs = new Date(expiresAt).getTime()
|
||||||
const warningMs = expiresMs - 60_000 // 1 minute before expiry
|
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)
|
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)
|
// Warning timer (1 min before expiry)
|
||||||
if (warningMs > now) {
|
if (warningMs > now) {
|
||||||
@@ -43,6 +86,7 @@ export const startSessionTimer = (sessionId, expiresAt) => {
|
|||||||
export const clearSessionTimer = (sessionId) => {
|
export const clearSessionTimer = (sessionId) => {
|
||||||
const timers = sessionTimers.get(sessionId)
|
const timers = sessionTimers.get(sessionId)
|
||||||
if (timers) {
|
if (timers) {
|
||||||
|
if (timers.threeMinTimeout) clearTimeout(timers.threeMinTimeout)
|
||||||
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
|
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
|
||||||
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
|
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
|
||||||
sessionTimers.delete(sessionId)
|
sessionTimers.delete(sessionId)
|
||||||
@@ -69,6 +113,29 @@ const onSessionWarning = (sessionId) => {
|
|||||||
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
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
|
// Grace period timers for auto-completing abandoned sessions
|
||||||
const closureGraceTimers = new Map()
|
const closureGraceTimers = new Map()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const sql = getDb()
|
|||||||
export const getActiveSessionByCustomer = async (customerId) => {
|
export const getActiveSessionByCustomer = async (customerId) => {
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
|
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
|
m.display_name AS mitra_display_name
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
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) => {
|
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`
|
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.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,
|
c.display_name AS customer_display_name,
|
||||||
m.display_name AS mitra_display_name
|
m.display_name AS mitra_display_name
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
INNER JOIN customers c ON c.id = cs.customer_id
|
INNER JOIN customers c ON c.id = cs.customer_id
|
||||||
LEFT JOIN mitras m ON m.id = cs.mitra_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}
|
WHERE cs.id = ${sessionId}
|
||||||
`
|
`
|
||||||
return session
|
return session
|
||||||
@@ -168,7 +173,7 @@ export const getSessionById = async (sessionId) => {
|
|||||||
export const getActiveSessionByCustomerWithUnread = async (customerId) => {
|
export const getActiveSessionByCustomerWithUnread = async (customerId) => {
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
|
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,
|
m.display_name AS mitra_display_name,
|
||||||
(SELECT COUNT(*) FROM chat_messages cm
|
(SELECT COUNT(*) FROM chat_messages cm
|
||||||
WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.MITRA}
|
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 } = {}) => {
|
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
|
||||||
const offset = (page - 1) * limit
|
const offset = (page - 1) * limit
|
||||||
const items = await sql`
|
const items = await sql`
|
||||||
SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
|
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_free_trial, cs.extended_minutes,
|
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
|
||||||
m.display_name AS mitra_display_name,
|
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.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
|
FROM chat_sessions cs
|
||||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
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}
|
WHERE cs.customer_id = ${customerId}
|
||||||
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||||
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC
|
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 offset = (page - 1) * limit
|
||||||
const items = await sql`
|
const items = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
|
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,
|
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.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
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export const resetDbHard = async () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop and re-seed the configurable app_config rows back to their canonical defaults.
|
* 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 () => {
|
export const resetAppConfig = async () => {
|
||||||
const sql = db()
|
const sql = db()
|
||||||
@@ -61,8 +62,6 @@ export const resetAppConfig = async () => {
|
|||||||
const defaults = [
|
const defaults = [
|
||||||
['anonymity', { enabled: false }],
|
['anonymity', { enabled: false }],
|
||||||
['max_customers_per_mitra', { value: 3 }],
|
['max_customers_per_mitra', { value: 3 }],
|
||||||
['free_trial_enabled', { value: true }],
|
|
||||||
['free_trial_duration_minutes', { value: 5 }],
|
|
||||||
['extension_timeout_seconds', { value: 60 }],
|
['extension_timeout_seconds', { value: 60 }],
|
||||||
['early_end_mitra_enabled', { value: false }],
|
['early_end_mitra_enabled', { value: false }],
|
||||||
['early_end_customer_enabled', { value: false }],
|
['early_end_customer_enabled', { value: false }],
|
||||||
@@ -70,6 +69,13 @@ export const resetAppConfig = async () => {
|
|||||||
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
||||||
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
||||||
['pairing_blast_timeout_seconds', { value: 60 }],
|
['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) {
|
for (const [key, value] of defaults) {
|
||||||
await sql`
|
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 () => {
|
beforeEach(async () => {
|
||||||
await resetDb()
|
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)
|
token = customerJwt(customer.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,25 +46,25 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
await app?.close()
|
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({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/client/payment-sessions',
|
url: '/api/client/payment-sessions',
|
||||||
headers: authHeader(token),
|
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)
|
expect(res.statusCode).toBe(201)
|
||||||
const body = res.json()
|
const body = res.json()
|
||||||
expect(body.success).toBe(true)
|
expect(body.success).toBe(true)
|
||||||
expect(body.data.status).toBe(PaymentSessionStatus.PENDING)
|
expect(body.data.status).toBe(PaymentSessionStatus.PENDING)
|
||||||
expect(body.data.duration_minutes).toBe(15)
|
expect(body.data.duration_minutes).toBe(12)
|
||||||
// Default tier for 15min from migrate.js is 30000 — but the eligibility logic
|
expect(body.data.is_first_session_discount).toBe(true)
|
||||||
// also needs `free_trial_enabled` to be true (default) AND no prior tx. Customer is
|
expect(body.data.amount).toBe(2000)
|
||||||
// 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.is_extension).toBe(false)
|
expect(body.data.is_extension).toBe(false)
|
||||||
|
expect(body.data.mode).toBe('chat')
|
||||||
|
|
||||||
// Verify persistence
|
// Verify persistence
|
||||||
const sql = db()
|
const sql = db()
|
||||||
@@ -69,35 +73,41 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
expect(row.customer_id).toBe(customer.id)
|
expect(row.customer_id).toBe(customer.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('POST /:id/confirm transitions the row and returns 200', async () => {
|
it('non-eligible customer pays the standard tier price', async () => {
|
||||||
// Create a paid (non-trial) tier so the row has a non-zero amount and we exercise the
|
// Drop first-session-discount eligibility by inserting a completed session.
|
||||||
// confirm path with a "real" payment. Insert a transaction first so the customer is
|
|
||||||
// ineligible for the free trial.
|
|
||||||
const sql = db()
|
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`
|
await sql`
|
||||||
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
|
||||||
VALUES (${customer.id}, ${prior.id}, 'paid', 30000)
|
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({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/client/payment-sessions',
|
url: '/api/client/payment-sessions',
|
||||||
headers: authHeader(token),
|
headers: authHeader(token),
|
||||||
payload: { duration_minutes: 15 },
|
payload: { duration_minutes: 5 },
|
||||||
})
|
})
|
||||||
expect(createRes.statusCode).toBe(201)
|
expect(createRes.statusCode).toBe(201)
|
||||||
const created = createRes.json().data
|
const created = createRes.json().data
|
||||||
expect(created.status).toBe(PaymentSessionStatus.PENDING)
|
expect(created.status).toBe(PaymentSessionStatus.PENDING)
|
||||||
expect(created.is_free_trial).toBe(false)
|
expect(created.is_first_session_discount).toBe(false)
|
||||||
expect(created.amount).toBe(30000)
|
expect(created.amount).toBe(5000)
|
||||||
|
|
||||||
const confirmRes = await app.inject({
|
const confirmRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -112,4 +122,21 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
|
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
|
||||||
expect(confirmed.confirmed_at).toBeTruthy()
|
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.customer_id).toBe(customer.id)
|
||||||
expect(session.duration_minutes).toBe(15)
|
expect(session.duration_minutes).toBe(15)
|
||||||
expect(session.amount).toBe(30000)
|
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.is_extension).toBe(false)
|
||||||
|
expect(session.mode).toBe('chat')
|
||||||
expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before)
|
expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before)
|
||||||
|
|
||||||
// Verify it's actually persisted (not just returned from the INSERT)
|
// 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.
|
# Smoke test: cold-start onboarding, registers a new customer via the
|
||||||
# Use this flow first to verify Maestro can talk to your device/emulator at all.
|
# 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:
|
# Run:
|
||||||
# maestro test client_app/.maestro/flows/01_smoke.yaml
|
# 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.
|
# Pre-req: client_app debug APK installed, backend reachable at
|
||||||
appId: ${APP_ID_ANDROID} # Maestro uses APP_ID_IOS automatically when running against an iOS device
|
# 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:
|
- launchApp:
|
||||||
clearState: false # keep existing auth — set to true to test cold-start onboarding
|
clearState: true
|
||||||
- assertVisible:
|
- extendedWaitUntil:
|
||||||
text: "Mulai Curhat"
|
visible:
|
||||||
timeout: 10000 # 10s — give Riverpod time to hydrate the home screen
|
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: 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)
|
- **Framework:** Flutter (iOS + Android)
|
||||||
- **Auth:** Self-managed (Phase 3.4). Anonymous-first + phone OTP + (Google / Apple when creds arrive).
|
- **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`.
|
- 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.
|
- `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`.
|
- **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes). Refresh + logout live on `shared.auth`.
|
||||||
- **Payment:** Xendit (paid sessions, optional trial)
|
- **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
|
- 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
|
- 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
|
- 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">
|
<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
|
<application
|
||||||
android:label="client_app"
|
android:label="client_app"
|
||||||
android:name="${applicationName}"
|
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>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSUserNotificationsUsageDescription</key>
|
||||||
|
<string>Halo Bestie kirim notifikasi pas bestie udah siap dengerin dan pas ada chat baru.</string>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -47,6 +49,13 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>https</string>
|
||||||
|
<string>http</string>
|
||||||
|
<string>whatsapp</string>
|
||||||
|
<string>tg</string>
|
||||||
|
</array>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<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
|
// 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
|
/// 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
|
/// screen is in the foreground. Polling is gated by the home screen calling
|
||||||
@@ -16,10 +16,10 @@ String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9';
|
|||||||
/// - resumed → setActive(true)
|
/// - resumed → setActive(true)
|
||||||
/// - paused/inactive → setActive(false)
|
/// - 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
|
/// The endpoint also returns a `count`, but the customer UI must only read the
|
||||||
/// only read the binary `available` field — the count is for CC/debug only.
|
/// binary `available` field — the count is for CC/debug only.
|
||||||
///
|
///
|
||||||
/// Copied from [MitraAvailability].
|
/// Copied from [MitraAvailability].
|
||||||
@ProviderFor(MitraAvailability)
|
@ProviderFor(MitraAvailability)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
@@ -32,6 +33,12 @@ class ChatConnectedData extends ChatData {
|
|||||||
final bool sessionClosing;
|
final bool sessionClosing;
|
||||||
final bool goodbyeSubmitted;
|
final bool goodbyeSubmitted;
|
||||||
final Map<String, dynamic>? extensionResponse;
|
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({
|
const ChatConnectedData({
|
||||||
required this.messages,
|
required this.messages,
|
||||||
@@ -42,6 +49,8 @@ class ChatConnectedData extends ChatData {
|
|||||||
this.sessionClosing = false,
|
this.sessionClosing = false,
|
||||||
this.goodbyeSubmitted = false,
|
this.goodbyeSubmitted = false,
|
||||||
this.extensionResponse,
|
this.extensionResponse,
|
||||||
|
this.mode = SessionMode.chat,
|
||||||
|
this.expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
ChatConnectedData copyWith({
|
ChatConnectedData copyWith({
|
||||||
@@ -53,6 +62,8 @@ class ChatConnectedData extends ChatData {
|
|||||||
bool? sessionClosing,
|
bool? sessionClosing,
|
||||||
bool? goodbyeSubmitted,
|
bool? goodbyeSubmitted,
|
||||||
Map<String, dynamic>? extensionResponse,
|
Map<String, dynamic>? extensionResponse,
|
||||||
|
SessionMode? mode,
|
||||||
|
DateTime? expiresAt,
|
||||||
}) {
|
}) {
|
||||||
return ChatConnectedData(
|
return ChatConnectedData(
|
||||||
messages: messages ?? this.messages,
|
messages: messages ?? this.messages,
|
||||||
@@ -63,6 +74,8 @@ class ChatConnectedData extends ChatData {
|
|||||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||||
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
|
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
|
||||||
extensionResponse: extensionResponse ?? this.extensionResponse,
|
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)
|
@Riverpod(keepAlive: true)
|
||||||
class Chat extends _$Chat {
|
class Chat extends _$Chat {
|
||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
@@ -109,10 +141,22 @@ class Chat extends _$Chat {
|
|||||||
Timer? _typingTimer;
|
Timer? _typingTimer;
|
||||||
String? _connectedSessionId;
|
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);
|
ApiClient get _apiClient => ref.read(apiClientProvider);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ChatData build() => const ChatInitialData();
|
ChatData build() {
|
||||||
|
ref.onDispose(() {
|
||||||
|
_warningController.close();
|
||||||
|
});
|
||||||
|
return const ChatInitialData();
|
||||||
|
}
|
||||||
|
|
||||||
/// Idempotent connect: if we're already connected to [sessionId], refresh
|
/// Idempotent connect: if we're already connected to [sessionId], refresh
|
||||||
/// the session status from the server (in case it transitioned to closing /
|
/// the session status from the server (in case it transitioned to closing /
|
||||||
@@ -155,11 +199,16 @@ class Chat extends _$Chat {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final goodbyeSubmittedByMe = data?['goodbye_submitted_by_me'] as bool? ?? false;
|
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(
|
state = current.copyWith(
|
||||||
sessionClosing: status == SessionStatus.closing,
|
sessionClosing: status == SessionStatus.closing,
|
||||||
sessionPaused: status == SessionStatus.extending,
|
sessionPaused: status == SessionStatus.extending,
|
||||||
sessionExpired: false,
|
sessionExpired: false,
|
||||||
goodbyeSubmitted: goodbyeSubmittedByMe,
|
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||||
|
mode: mode,
|
||||||
|
expiresAt: expiresAt,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
@@ -184,6 +233,9 @@ class Chat extends _$Chat {
|
|||||||
|
|
||||||
final isClosing = sessionStatus == SessionStatus.closing;
|
final isClosing = sessionStatus == SessionStatus.closing;
|
||||||
final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false;
|
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 response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
|
||||||
final messagesData = response['data'] as List<dynamic>;
|
final messagesData = response['data'] as List<dynamic>;
|
||||||
@@ -225,6 +277,8 @@ class Chat extends _$Chat {
|
|||||||
messages: messages,
|
messages: messages,
|
||||||
sessionClosing: isClosing,
|
sessionClosing: isClosing,
|
||||||
goodbyeSubmitted: goodbyeSubmittedByMe,
|
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||||
|
mode: mode,
|
||||||
|
expiresAt: expiresAt,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = const ChatErrorData('Gagal terhubung ke chat.');
|
state = const ChatErrorData('Gagal terhubung ke chat.');
|
||||||
@@ -351,11 +405,41 @@ class Chat extends _$Chat {
|
|||||||
|
|
||||||
case WsMessage.sessionTimer:
|
case WsMessage.sessionTimer:
|
||||||
final remaining = data['remaining_seconds'] as int?;
|
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;
|
break;
|
||||||
|
|
||||||
case WsMessage.sessionExpired:
|
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;
|
break;
|
||||||
|
|
||||||
case WsMessage.sessionPaused:
|
case WsMessage.sessionPaused:
|
||||||
|
|||||||
@@ -6,7 +6,31 @@ part of 'chat_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// 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].
|
/// See also [Chat].
|
||||||
@ProviderFor(Chat)
|
@ProviderFor(Chat)
|
||||||
|
|||||||
@@ -8,27 +8,97 @@ class PriceTier {
|
|||||||
final int durationMinutes;
|
final int durationMinutes;
|
||||||
final int price;
|
final int price;
|
||||||
final String label;
|
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) {
|
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(
|
return PriceTier(
|
||||||
durationMinutes: json['duration_minutes'] as int,
|
durationMinutes: minutes,
|
||||||
price: json['price'] as int,
|
price: price,
|
||||||
label: json['label'] as String,
|
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 {
|
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 List<PriceTier> tiers;
|
||||||
final bool freeTrialEligible;
|
final bool freeTrialEligible;
|
||||||
final int freeTrialDurationMinutes;
|
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({
|
const PricingData({
|
||||||
required this.tiers,
|
required this.tiers,
|
||||||
required this.freeTrialEligible,
|
required this.freeTrialEligible,
|
||||||
this.freeTrialDurationMinutes = 5,
|
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 apiClient = ref.read(apiClientProvider);
|
||||||
final response = await apiClient.get('/api/client/chat/pricing');
|
final response = await apiClient.get('/api/client/chat/pricing');
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
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 tiersJson = data['tiers'] as List<dynamic>;
|
||||||
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
|
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(
|
return PricingData(
|
||||||
tiers: tiers,
|
tiers: tiers,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'chat_opening_provider.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$chatPricingHash() => r'53ca829d46f7ba3b481e7a6b54482c62f9fe3ad0';
|
String _$chatPricingHash() => r'6dfbdf77942a67d3da689849eda89fc1fa3e6e39';
|
||||||
|
|
||||||
/// See also [chatPricing].
|
/// See also [chatPricing].
|
||||||
@ProviderFor(chatPricing)
|
@ProviderFor(chatPricing)
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ class ClosureCompleteData extends SessionClosureData {
|
|||||||
const ClosureCompleteData();
|
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 {
|
class ClosureErrorData extends SessionClosureData {
|
||||||
final String message;
|
final String message;
|
||||||
const ClosureErrorData(this.message);
|
const ClosureErrorData(this.message);
|
||||||
@@ -111,4 +118,37 @@ class SessionClosure extends _$SessionClosure {
|
|||||||
state = const ClosureErrorData('Gagal mengirim pesan penutup.');
|
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
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$sessionClosureHash() => r'f238e4098aefdd942314a13eb3fb77ed19cd2b77';
|
String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b';
|
||||||
|
|
||||||
/// See also [SessionClosure].
|
/// See also [SessionClosure].
|
||||||
@ProviderFor(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._();
|
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
|
/// Session topic sensitivity
|
||||||
enum TopicSensitivity {
|
enum TopicSensitivity {
|
||||||
regular('regular'),
|
regular('regular'),
|
||||||
@@ -101,6 +116,9 @@ class WsMessage {
|
|||||||
static const sessionCompleted = 'session_completed';
|
static const sessionCompleted = 'session_completed';
|
||||||
static const sessionPaused = 'session_paused';
|
static const sessionPaused = 'session_paused';
|
||||||
static const sessionResumed = 'session_resumed';
|
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
|
// Extension
|
||||||
static const extensionRequest = 'extension_request';
|
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
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad';
|
String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb';
|
||||||
|
|
||||||
/// See also [Pairing].
|
/// See also [Pairing].
|
||||||
@ProviderFor(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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/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 {
|
class DisplayNameScreen extends ConsumerStatefulWidget {
|
||||||
const DisplayNameScreen({super.key});
|
const DisplayNameScreen({super.key});
|
||||||
@@ -11,9 +16,33 @@ class DisplayNameScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||||
final _controller = TextEditingController();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_authSub?.close();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -21,46 +50,99 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
|||||||
void _submit() {
|
void _submit() {
|
||||||
final name = _controller.text.trim();
|
final name = _controller.text.trim();
|
||||||
if (name.isEmpty) return;
|
if (name.isEmpty) return;
|
||||||
|
setState(() => _errorMessage = null);
|
||||||
ref.read(authProvider.notifier).loginAnonymous(name);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final isLoading = authState is AsyncLoading;
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Siapa namamu?')),
|
appBar: AppBar(title: const Text('Siapa namamu?')),
|
||||||
body: Padding(
|
body: SafeArea(
|
||||||
padding: const EdgeInsets.all(24),
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
|
children: [
|
||||||
const SizedBox(height: 24),
|
const Text(
|
||||||
TextField(
|
'Pilih nama yang ingin kamu gunakan. Nama ini akan terlihat oleh bestie kamu.',
|
||||||
controller: _controller,
|
style: TextStyle(
|
||||||
decoration: const InputDecoration(
|
fontFamily: HaloTokens.fontBody,
|
||||||
labelText: 'Nama panggilan',
|
fontSize: 15,
|
||||||
border: OutlineInputBorder(),
|
height: 22 / 15,
|
||||||
|
color: HaloTokens.inkSoft,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.done,
|
const SizedBox(height: HaloSpacing.s24),
|
||||||
onSubmitted: (_) => _submit(),
|
TextField(
|
||||||
),
|
controller: _controller,
|
||||||
const SizedBox(height: 24),
|
decoration: const InputDecoration(
|
||||||
ElevatedButton(
|
labelText: 'Nama panggilan',
|
||||||
onPressed: isLoading ? null : _submit,
|
),
|
||||||
child: isLoading
|
textInputAction: TextInputAction.done,
|
||||||
? const CircularProgressIndicator()
|
onSubmitted: (_) => _submit(),
|
||||||
: 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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/auth/auth_notifier.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.
|
/// Shown when anonymity is disabled by admin.
|
||||||
/// User must identify themselves (phone OTP / Google / Apple).
|
/// User must identify themselves (phone OTP / Google / Apple).
|
||||||
@@ -28,6 +28,9 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final isLoading = authState is AsyncLoading;
|
final isLoading = authState is AsyncLoading;
|
||||||
|
final providersAsync = ref.watch(authProvidersProvider);
|
||||||
|
final providers =
|
||||||
|
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
|
||||||
|
|
||||||
ref.listen(authProvider, (prev, next) {
|
ref.listen(authProvider, (prev, next) {
|
||||||
final data = next.valueOrNull;
|
final data = next.valueOrNull;
|
||||||
@@ -51,20 +54,24 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
|||||||
style: TextStyle(fontSize: 16),
|
style: TextStyle(fontSize: 16),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (kSocialAuthEnabled) ...[
|
if (providers.hasAnySocial) ...[
|
||||||
ElevatedButton.icon(
|
if (providers.google) ...[
|
||||||
icon: const Icon(Icons.g_mobiledata),
|
ElevatedButton.icon(
|
||||||
onPressed: isLoading ? null
|
icon: const Icon(Icons.g_mobiledata),
|
||||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
onPressed: isLoading ? null
|
||||||
label: const Text('Lanjut dengan Google'),
|
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||||
),
|
label: const Text('Lanjut dengan Google'),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
ElevatedButton.icon(
|
const SizedBox(height: 12),
|
||||||
icon: const Icon(Icons.apple),
|
],
|
||||||
onPressed: isLoading ? null
|
if (providers.apple) ...[
|
||||||
: () => ref.read(authProvider.notifier).loginApple(),
|
ElevatedButton.icon(
|
||||||
label: const Text('Lanjut dengan Apple'),
|
icon: const Icon(Icons.apple),
|
||||||
),
|
onPressed: isLoading ? null
|
||||||
|
: () => ref.read(authProvider.notifier).loginApple(),
|
||||||
|
label: const Text('Lanjut dengan Apple'),
|
||||||
|
),
|
||||||
|
],
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
|
import '../widgets/otp_blocked_popup.dart';
|
||||||
|
|
||||||
const int _kOtpLength = 6;
|
const int _kOtpLength = 6;
|
||||||
const int _kFallbackResendCooldownSeconds = 60;
|
const int _kFallbackResendCooldownSeconds = 60;
|
||||||
|
|
||||||
const Color _kAccentPink = Color(0xFFBE7C8A);
|
// Codes that mean "the user cannot make progress without waiting" — these
|
||||||
const Color _kBoxBorder = Color(0xFFE0E0E0);
|
// 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 {
|
class OtpScreen extends ConsumerStatefulWidget {
|
||||||
final String phone;
|
final String phone;
|
||||||
@@ -29,6 +37,7 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
String? _otpRequestId;
|
String? _otpRequestId;
|
||||||
bool _autoSubmitted = false;
|
bool _autoSubmitted = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
bool _blockedPopupShown = false;
|
||||||
|
|
||||||
int _resendSeconds = _kFallbackResendCooldownSeconds;
|
int _resendSeconds = _kFallbackResendCooldownSeconds;
|
||||||
int _resendCooldown = _kFallbackResendCooldownSeconds;
|
int _resendCooldown = _kFallbackResendCooldownSeconds;
|
||||||
@@ -41,24 +50,26 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
final data = ref.read(authProvider).valueOrNull;
|
final data = ref.read(authProvider).valueOrNull;
|
||||||
if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId;
|
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) {
|
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||||
if (next is AsyncError) {
|
if (next is AsyncError) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final err = next.error;
|
final err = next.error;
|
||||||
setState(() => _errorMessage = err.toString());
|
setState(() => _errorMessage = err.toString());
|
||||||
_clearBoxes();
|
_clearBoxes();
|
||||||
// If the server says we're rate-limited, extend the resend countdown
|
if (err is AuthErrorInfo) {
|
||||||
// to match — disables "Kirim ulang kode" until the lockout clears.
|
if (_kOtpBlockedCodes.contains(err.code) && !_blockedPopupShown) {
|
||||||
if (err is AuthErrorInfo &&
|
_blockedPopupShown = true;
|
||||||
err.retryAfterSeconds != null &&
|
OtpBlockedPopup.show(context).then((_) {
|
||||||
(err.code == 'OTP_COOLDOWN' ||
|
if (mounted) _blockedPopupShown = false;
|
||||||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
|
});
|
||||||
err.code == 'OTP_RATE_LIMIT_IP')) {
|
}
|
||||||
_resendCooldown = err.retryAfterSeconds!;
|
if (err.retryAfterSeconds != null &&
|
||||||
_startResendCountdown();
|
(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) {
|
} else if (next is AsyncLoading || next is AsyncData) {
|
||||||
if (_errorMessage != null && mounted) {
|
if (_errorMessage != null && mounted) {
|
||||||
@@ -131,7 +142,6 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDigitChanged(int index, String value) {
|
void _onDigitChanged(int index, String value) {
|
||||||
// Move forward when a digit is entered, back when cleared.
|
|
||||||
if (value.isNotEmpty && index < _kOtpLength - 1) {
|
if (value.isNotEmpty && index < _kOtpLength - 1) {
|
||||||
_focusNodes[index + 1].requestFocus();
|
_focusNodes[index + 1].requestFocus();
|
||||||
}
|
}
|
||||||
@@ -142,9 +152,6 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
final code = _readCode();
|
final code = _readCode();
|
||||||
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
||||||
_autoSubmitted = true;
|
_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);
|
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,47 +176,76 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Masukkan OTP')),
|
appBar: AppBar(title: const Text('Masukkan OTP')),
|
||||||
body: Padding(
|
body: SafeArea(
|
||||||
padding: const EdgeInsets.all(24),
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Text('Kode OTP telah dikirim ke ${widget.phone}'),
|
children: [
|
||||||
const SizedBox(height: 32),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: List.generate(_kOtpLength, _buildBox),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (_errorMessage != null)
|
|
||||||
Text(
|
Text(
|
||||||
_errorMessage!,
|
'Kode OTP telah dikirim ke ${widget.phone}',
|
||||||
textAlign: TextAlign.center,
|
style: const TextStyle(
|
||||||
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
|
fontFamily: HaloTokens.fontBody,
|
||||||
),
|
fontSize: 15,
|
||||||
const SizedBox(height: 12),
|
color: HaloTokens.inkSoft,
|
||||||
if (isLoading)
|
|
||||||
const Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: HaloSpacing.s32),
|
||||||
_buildResendRow(),
|
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: const TextStyle(
|
||||||
|
fontFamily: HaloTokens.fontBody,
|
||||||
|
color: HaloTokens.danger,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: HaloSpacing.s12),
|
||||||
|
if (isLoading)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
EdgeInsets.symmetric(vertical: HaloSpacing.s8),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: HaloSpacing.s16),
|
||||||
|
_buildResendRow(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBox(int index) {
|
Widget _buildBox(int index, double width) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 48,
|
width: width,
|
||||||
height: 56,
|
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(
|
child: Focus(
|
||||||
canRequestFocus: false,
|
canRequestFocus: false,
|
||||||
onKeyEvent: (node, event) {
|
onKeyEvent: (node, event) {
|
||||||
@@ -230,18 +266,25 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
maxLength: 1,
|
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],
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
counterText: '',
|
counterText: '',
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
filled: true,
|
||||||
|
fillColor: HaloTokens.surface,
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: _kBoxBorder, width: 1.5),
|
borderSide: const BorderSide(color: HaloTokens.border, width: 1.5),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: _kAccentPink, width: 2),
|
borderSide: const BorderSide(color: HaloTokens.brand, width: 2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: (v) => _onDigitChanged(index, v),
|
onChanged: (v) => _onDigitChanged(index, v),
|
||||||
@@ -259,7 +302,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
child: const Text(
|
child: const Text(
|
||||||
'Kirim ulang kode',
|
'Kirim ulang kode',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _kAccentPink,
|
fontFamily: HaloTokens.fontBody,
|
||||||
|
color: HaloTokens.brandDark,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
),
|
),
|
||||||
@@ -267,7 +311,10 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
'Kirim ulang dalam ${formatCountdown(_resendSeconds)}',
|
'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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/auth/auth_notifier.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/constants.dart';
|
||||||
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
|
|
||||||
class RegisterScreen extends ConsumerStatefulWidget {
|
class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
const RegisterScreen({super.key});
|
const RegisterScreen({super.key});
|
||||||
@@ -26,8 +28,6 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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) {
|
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final data = next.valueOrNull;
|
final data = next.valueOrNull;
|
||||||
@@ -82,68 +82,103 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
final isLoading = authState is AsyncLoading;
|
final isLoading = authState is AsyncLoading;
|
||||||
final isLockedOut = _lockoutSeconds > 0;
|
final isLockedOut = _lockoutSeconds > 0;
|
||||||
final canSubmit = !isLoading && !isLockedOut;
|
final canSubmit = !isLoading && !isLockedOut;
|
||||||
|
final providersAsync = ref.watch(authProvidersProvider);
|
||||||
|
final providers =
|
||||||
|
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
||||||
body: Padding(
|
body: SafeArea(
|
||||||
padding: const EdgeInsets.all(24),
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
if (kSocialAuthEnabled) ...[
|
children: [
|
||||||
ElevatedButton.icon(
|
if (providers.hasAnySocial) ...[
|
||||||
icon: const Icon(Icons.g_mobiledata),
|
if (providers.google) ...[
|
||||||
onPressed: isLoading ? null
|
HaloButton(
|
||||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
label: 'lanjut dengan Google',
|
||||||
label: const Text('Lanjut dengan Google'),
|
icon: const Icon(Icons.g_mobiledata),
|
||||||
|
variant: HaloButtonVariant.secondary,
|
||||||
|
fullWidth: true,
|
||||||
|
onPressed: isLoading
|
||||||
|
? null
|
||||||
|
: () => 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: isLoading
|
||||||
|
? null
|
||||||
|
: () => ref.read(authProvider.notifier).loginApple(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: HaloSpacing.s12),
|
||||||
|
],
|
||||||
|
const Padding(
|
||||||
|
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(
|
||||||
|
controller: _phoneController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nomor HP',
|
||||||
|
hintText: '+628xxxxxxxxxx',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: HaloSpacing.s16),
|
||||||
ElevatedButton.icon(
|
HaloButton(
|
||||||
icon: const Icon(Icons.apple),
|
label: isLoading
|
||||||
onPressed: isLoading ? null
|
? 'memproses...'
|
||||||
: () => ref.read(authProvider.notifier).loginApple(),
|
: isLockedOut
|
||||||
label: const Text('Lanjut dengan Apple'),
|
? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
|
||||||
),
|
: 'kirim OTP',
|
||||||
const Padding(
|
fullWidth: true,
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
onPressed: canSubmit
|
||||||
child: Row(children: [
|
? () {
|
||||||
Expanded(child: Divider()),
|
final phone = _phoneController.text.trim();
|
||||||
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
|
if (phone.isEmpty) return;
|
||||||
Expanded(child: Divider()),
|
ref.read(authProvider.notifier).requestOtp(phone);
|
||||||
]),
|
}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: HaloSpacing.s12),
|
||||||
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: HaloTokens.fontBody,
|
||||||
|
color: HaloTokens.danger,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
TextField(
|
),
|
||||||
controller: _phoneController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Nomor HP',
|
|
||||||
hintText: '+628xxxxxxxxxx',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
ElevatedButton(
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
if (_errorMessage != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
_errorMessage!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +1,84 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/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});
|
const WelcomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final providersAsync = ref.watch(authProvidersProvider);
|
||||||
|
final providers =
|
||||||
|
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
const Center(child: HaloOrb(seed: 0, size: 96, label: 'H')),
|
||||||
|
const SizedBox(height: HaloSpacing.s24),
|
||||||
const Text(
|
const Text(
|
||||||
'Halo Bestie',
|
'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,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: HaloSpacing.s8),
|
||||||
const Text(
|
const Text(
|
||||||
'Tempat curhat kamu',
|
'Tempat curhat kamu',
|
||||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
style: TextStyle(
|
||||||
|
fontFamily: HaloTokens.fontBody,
|
||||||
|
fontSize: 15,
|
||||||
|
color: HaloTokens.inkSoft,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: HaloSpacing.s48),
|
||||||
ElevatedButton(
|
HaloButton(
|
||||||
|
label: 'Lanjut sebagai Tamu',
|
||||||
|
fullWidth: true,
|
||||||
onPressed: () => context.push('/auth/display-name'),
|
onPressed: () => context.push('/auth/display-name'),
|
||||||
child: const Text('Lanjut sebagai Tamu'),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: HaloSpacing.s12),
|
||||||
OutlinedButton(
|
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'),
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/chat/active_session_notifier.dart';
|
||||||
import '../../../core/pairing/pairing_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 sessionId;
|
||||||
final String mitraName;
|
final String mitraName;
|
||||||
|
|
||||||
@@ -14,34 +28,127 @@ class BestieFoundScreen extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<BestieFoundScreen> createState() => _BestieFoundScreenState();
|
||||||
ref.listen(pairingProvider, (prev, next) {
|
}
|
||||||
|
|
||||||
|
class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
|
||||||
|
if (!mounted) return;
|
||||||
if (next is PairingActiveData) {
|
if (next is PairingActiveData) {
|
||||||
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
|
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(
|
return Scaffold(
|
||||||
body: Center(
|
backgroundColor: HaloTokens.bg,
|
||||||
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
HaloSpacing.s24,
|
||||||
|
HaloSpacing.s24,
|
||||||
|
HaloSpacing.s24,
|
||||||
|
HaloSpacing.s24,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.check_circle, size: 80, color: Colors.green),
|
Expanded(
|
||||||
const SizedBox(height: 24),
|
child: Center(
|
||||||
const Text(
|
child: Column(
|
||||||
'Bestie ditemukan!',
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
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(
|
||||||
|
'◦ MATCHED ◦',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: HaloTokens.fontBody,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1.6,
|
||||||
|
color: HaloTokens.brand,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: HaloSpacing.s8),
|
||||||
|
Text(
|
||||||
|
'halo, aku bestie ${widget.mitraName}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
HaloButton(
|
||||||
Text(
|
label: ctaLabel,
|
||||||
'Menghubungkan kamu ke $mitraName',
|
fullWidth: true,
|
||||||
textAlign: TextAlign.center,
|
size: HaloButtonSize.lg,
|
||||||
style: const TextStyle(fontSize: 16, color: Colors.grey),
|
onPressed: _enterChat,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
const CircularProgressIndicator(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,140 +3,323 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/constants.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
|
/// Renders past sessions with the v4 visual: orb + name + last-session date
|
||||||
/// mitra id + display name as extras. The payment screen then:
|
/// + topic chips + sessions count + ONLINE pill (per-row, sourced from the
|
||||||
/// 1. POSTs `/api/client/payment-sessions` with `targeted_mitra_id`
|
/// `mitra_is_online` field on the history payload).
|
||||||
/// 2. On confirm, fires `pairingNotifier.startTargetedSearch(...)` instead
|
|
||||||
/// of the general `startSearch(...)`.
|
|
||||||
///
|
///
|
||||||
/// The CTA is per-row (not per-unique-mitra).
|
/// Tapping a row routes to the targeted "Curhat lagi" payment flow when the
|
||||||
class ChatHistoryScreen extends ConsumerStatefulWidget {
|
/// 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});
|
const ChatHistoryScreen({super.key});
|
||||||
|
|
||||||
@override
|
@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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Riwayat Chat')),
|
backgroundColor: HaloTokens.bg,
|
||||||
body: _loading
|
appBar: AppBar(
|
||||||
? const Center(child: CircularProgressIndicator())
|
backgroundColor: HaloTokens.bg,
|
||||||
: _sessions.isEmpty
|
foregroundColor: HaloTokens.ink,
|
||||||
? const Center(child: Text('Belum ada riwayat chat'))
|
elevation: 0,
|
||||||
: ListView.separated(
|
title: const Text(
|
||||||
itemCount: _sessions.length,
|
'Riwayat Chat',
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
style: TextStyle(
|
||||||
itemBuilder: (context, index) {
|
fontFamily: HaloTokens.fontDisplay,
|
||||||
final s = _sessions[index];
|
fontWeight: FontWeight.w700,
|
||||||
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?;
|
body: historyAsync.when(
|
||||||
final isClosing = status == 'closing';
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
final endedAt = s['ended_at'] != null
|
error: (_, __) => const Center(
|
||||||
? DateTime.parse(s['ended_at'] as String).toLocal()
|
child: Text(
|
||||||
: null;
|
'gagal memuat riwayat. tarik untuk muat ulang.',
|
||||||
final duration = s['duration_minutes'] as int?;
|
style: TextStyle(fontFamily: HaloTokens.fontBody),
|
||||||
final closureMsg = s['customer_closure_message'] as String?;
|
),
|
||||||
|
),
|
||||||
return ListTile(
|
data: (items) {
|
||||||
leading: const CircleAvatar(child: Icon(Icons.person)),
|
if (items.isEmpty) {
|
||||||
title: Row(
|
return const Center(
|
||||||
children: [
|
child: Text(
|
||||||
Flexible(child: Text(mitraName, overflow: TextOverflow.ellipsis)),
|
'Belum ada riwayat chat',
|
||||||
if (isClosing) ...[
|
style: TextStyle(
|
||||||
const SizedBox(width: 8),
|
fontFamily: HaloTokens.fontBody,
|
||||||
const _OutstandingClosureBadge(),
|
color: HaloTokens.inkSoft,
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
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'),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OutstandingClosureBadge extends StatelessWidget {
|
/// Raw history payload — used to read fields the v4 `BestieHistoryItem`
|
||||||
const _OutstandingClosureBadge();
|
/// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.amber.shade100,
|
color: HaloTokens.success.withAlpha(36),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: Colors.amber.shade700, width: 0.5),
|
),
|
||||||
|
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(
|
child: Text(
|
||||||
'Belum ditutup',
|
label,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 10,
|
fontFamily: HaloTokens.fontBody,
|
||||||
color: Colors.amber.shade900,
|
fontSize: 11.5,
|
||||||
fontWeight: FontWeight.w600,
|
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/active_session_notifier.dart';
|
||||||
import '../../../core/chat/chat_notifier.dart';
|
import '../../../core/chat/chat_notifier.dart';
|
||||||
import '../../../core/chat/session_closure_notifier.dart';
|
import '../../../core/chat/session_closure_notifier.dart';
|
||||||
|
import '../../../core/config/app_config_provider.dart';
|
||||||
import '../../../core/constants.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';
|
import '../widgets/pricing_bottom_sheet.dart';
|
||||||
|
|
||||||
// Chat theme colors
|
// Chat theme colors
|
||||||
@@ -31,9 +39,15 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
final _goodbyeController = TextEditingController();
|
final _goodbyeController = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
Timer? _typingThrottle;
|
Timer? _typingThrottle;
|
||||||
|
StreamSubscription<String>? _warningSub;
|
||||||
bool _showBestieBanner = true;
|
bool _showBestieBanner = true;
|
||||||
bool _showUserBanner = 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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -48,6 +62,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
ref.read(sessionClosureProvider.notifier).reset();
|
ref.read(sessionClosureProvider.notifier).reset();
|
||||||
ref.read(chatProvider.notifier).connectIfNotConnected(widget.sessionId);
|
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
|
@override
|
||||||
@@ -56,6 +83,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
_goodbyeController.dispose();
|
_goodbyeController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_typingThrottle?.cancel();
|
_typingThrottle?.cancel();
|
||||||
|
_warningSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
// Intentionally do NOT disconnect the WS here. The global lifecycle in
|
// Intentionally do NOT disconnect the WS here. The global lifecycle in
|
||||||
// `App` decides when to disconnect (logout / no active session).
|
// `App` decides when to disconnect (logout / no active session).
|
||||||
@@ -95,53 +123,83 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showSessionExpiredDialog() async {
|
/// Stage 7 entry point — wired to both the AppBar "akhiri sesi" button and
|
||||||
if (_expiredDialogShown) return;
|
/// the menu equivalent. Reads `endSessionTwoStepConfirmProvider`: when the
|
||||||
_expiredDialogShown = true;
|
/// 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;
|
if (!mounted) return;
|
||||||
await showDialog<void>(
|
ConfirmEndStep2.show(
|
||||||
context: context,
|
context,
|
||||||
barrierDismissible: false,
|
onWriteMessage: _showClosingSheet,
|
||||||
builder: (dialogContext) => AlertDialog(
|
onSkip: _closeWithoutMessage,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final chatState = ref.watch(chatProvider);
|
final chatState = ref.watch(chatProvider);
|
||||||
final closureState = ref.watch(sessionClosureProvider);
|
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) {
|
ref.listen(sessionClosureProvider, (prev, next) {
|
||||||
if (next is ClosureCompleteData) {
|
if (next is ClosureCompleteData) {
|
||||||
// Make doubly sure home picks up the cleared session.
|
|
||||||
ref.invalidate(activeSessionProvider);
|
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) {
|
ref.listen(chatProvider, (prev, next) {
|
||||||
if (next is ChatConnectedData) {
|
if (next is ChatConnectedData) {
|
||||||
// Early-end (mitra/customer ended before timer): show goodbye composer.
|
// Early-end (mitra/customer ended before timer): show goodbye composer.
|
||||||
@@ -151,19 +209,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
ref.read(sessionClosureProvider.notifier).declineExtension();
|
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) {
|
if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
|
||||||
final closure = ref.read(sessionClosureProvider);
|
final closure = ref.read(sessionClosureProvider);
|
||||||
if (closure is! ClosureInitialData) {
|
if (closure is! ClosureInitialData) {
|
||||||
ref.read(sessionClosureProvider.notifier).reset();
|
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();
|
_scrollToBottom();
|
||||||
final unread = next.messages
|
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(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
@@ -193,29 +248,89 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
icon: const Icon(Icons.chevron_left, size: 28),
|
icon: const Icon(Icons.chevron_left, size: 28),
|
||||||
onPressed: _exitChat,
|
onPressed: _exitChat,
|
||||||
),
|
),
|
||||||
title: Text(widget.mitraName),
|
title: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
widget.mitraName,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (chatState is ChatConnectedData && chatState.mode == SessionMode.call) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildVoiceCallPill(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (chatState is ChatConnectedData && chatState.remainingSeconds != null)
|
if (chatState is ChatConnectedData && remainingTick != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 16),
|
padding: const EdgeInsets.only(right: 4),
|
||||||
child: Center(
|
child: Center(child: _buildTimerPill(remainingTick)),
|
||||||
child: Text(
|
),
|
||||||
'${chatState.remainingSeconds}s',
|
if (chatState is ChatConnectedData &&
|
||||||
style: TextStyle(
|
!chatState.sessionClosing)
|
||||||
color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black,
|
TextButton(
|
||||||
fontWeight: FontWeight.bold,
|
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) {
|
if (chatState is ChatConnectingData) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -223,12 +338,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
return Center(child: Text(chatState.message));
|
return Center(child: Text(chatState.message));
|
||||||
}
|
}
|
||||||
if (chatState is ChatConnectedData) {
|
if (chatState is ChatConnectedData) {
|
||||||
return _buildChatBody(chatState, closureState);
|
return _buildChatBody(chatState, closureState, remainingTick);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
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
|
// Show goodbye composer when closure flow is in goodbye/submitting OR when
|
||||||
// we mounted directly into a `closing` session (e.g. opened from history).
|
// 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
|
// 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)),
|
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)
|
// Input bar — disabled when timer expired (modal handles next step)
|
||||||
if (!state.sessionExpired) _buildInputBar(),
|
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) {
|
Widget _buildGoodbyeView(SessionClosureData closureState) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
|
|||||||
@@ -2,23 +2,30 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/pairing/pairing_notifier.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/bestie_unavailable_dialog.dart';
|
||||||
import '../widgets/targeted_waiting_overlay.dart';
|
import '../widgets/targeted_waiting_overlay.dart';
|
||||||
|
|
||||||
/// Searching screen, also responsible for routing all downstream pairing
|
/// Searching screen — Phase 4 Stage 5 reskin of the SSearchPrompt soft-prompt
|
||||||
/// transitions:
|
/// + searching panel. Renders three pairing-driven phases inline:
|
||||||
///
|
///
|
||||||
/// - PairingTargetedWaitingData → render the targeted waiting overlay above
|
/// - `PairingSearchingData` → reflective-prompt cards + pulsing-dots panel.
|
||||||
/// the searching shell (the customer sees the 20s countdown + cancel CTA).
|
/// - `PairingFailedData` (cause = noMitraAvailable / allMitrasRejected /
|
||||||
/// - PairingTargetedUnavailableData → show the bestie-unavailable dialog
|
/// 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).
|
/// (intermediate; payment stays confirmed; offers fallback-to-blast).
|
||||||
/// - PairingFailedData → terminal; route to no-bestie screen.
|
/// - `PairingCancelledData` → `/home`.
|
||||||
/// - PairingBestieFoundData → existing transition to bestie-found screen.
|
|
||||||
/// - PairingCancelledData → customer cancelled; back home.
|
|
||||||
///
|
///
|
||||||
/// Per project memory ("Riverpod ref.listen in build is unsafe"), we use
|
/// Per project memory ("Riverpod ref.listen in build is unsafe"), one-shot
|
||||||
/// ref.listenManual in initState for one-shot side effects rather than
|
/// transitions are wired through `ref.listenManual` in initState.
|
||||||
/// build-scoped listeners.
|
|
||||||
class SearchingScreen extends ConsumerStatefulWidget {
|
class SearchingScreen extends ConsumerStatefulWidget {
|
||||||
const SearchingScreen({super.key});
|
const SearchingScreen({super.key});
|
||||||
|
|
||||||
@@ -27,19 +34,12 @@ class SearchingScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
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;
|
bool _unavailableDialogShown = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
ref.listenManual<PairingData>(pairingProvider, _onPairingState);
|
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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_onPairingState(null, ref.read(pairingProvider));
|
_onPairingState(null, ref.read(pairingProvider));
|
||||||
@@ -58,18 +58,10 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (next is PairingActiveData) {
|
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);
|
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next is PairingFailedData) {
|
|
||||||
// Terminal — payment_session is failed_pairing.
|
|
||||||
context.go('/chat/no-bestie');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next is PairingCancelledData) {
|
if (next is PairingCancelledData) {
|
||||||
context.go('/home');
|
context.go('/home');
|
||||||
return;
|
return;
|
||||||
@@ -78,22 +70,17 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
|||||||
if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) {
|
if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) {
|
||||||
_unavailableDialogShown = true;
|
_unavailableDialogShown = true;
|
||||||
// ignore: discarded_futures
|
// ignore: discarded_futures
|
||||||
BestieUnavailableDialog.show(
|
BestieOfflinePopup.show(
|
||||||
context,
|
context,
|
||||||
paymentSessionId: next.paymentSessionId,
|
variant: BestieOfflineVariant.returning,
|
||||||
mitraName: next.mitraName,
|
mitraName: next.mitraName,
|
||||||
|
paymentSessionId: next.paymentSessionId,
|
||||||
topicSensitivity: next.topicSensitivity,
|
topicSensitivity: next.topicSensitivity,
|
||||||
).then((_) {
|
).then((_) {
|
||||||
if (mounted) _unavailableDialogShown = false;
|
if (mounted) _unavailableDialogShown = false;
|
||||||
});
|
});
|
||||||
return;
|
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
|
@override
|
||||||
@@ -101,6 +88,7 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
|||||||
final pairingState = ref.watch(pairingProvider);
|
final pairingState = ref.watch(pairingProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: HaloTokens.bg,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -120,52 +108,314 @@ class _SearchingBody extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isTimeout = state is PairingFailedData;
|
||||||
final isTargetedWaiting = state is PairingTargetedWaitingData;
|
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(
|
return Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
padding: const EdgeInsets.all(32),
|
HaloSpacing.s24,
|
||||||
child: Column(
|
HaloSpacing.s24,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
HaloSpacing.s24,
|
||||||
children: [
|
HaloSpacing.s32,
|
||||||
const CircularProgressIndicator(),
|
),
|
||||||
const SizedBox(height: 32),
|
child: Column(
|
||||||
Text(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
isTargetedWaiting ? 'Menghubungi bestie...' : 'Mencari Bestie...',
|
children: [
|
||||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
const Expanded(
|
||||||
),
|
child: SingleChildScrollView(
|
||||||
const SizedBox(height: 8),
|
child: Column(
|
||||||
const Text(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu',
|
children: [
|
||||||
textAlign: TextAlign.center,
|
Text(
|
||||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
'sambil nunggu, coba pikirin sebentar 🤍',
|
||||||
),
|
style: TextStyle(
|
||||||
if (errorMessage != null) ...[
|
fontFamily: HaloTokens.fontDisplay,
|
||||||
const SizedBox(height: 24),
|
fontSize: 24,
|
||||||
Container(
|
height: 30 / 24,
|
||||||
padding: const EdgeInsets.all(12),
|
fontWeight: FontWeight.w700,
|
||||||
decoration: BoxDecoration(
|
color: HaloTokens.brandDark,
|
||||||
color: Colors.red.shade50,
|
letterSpacing: -0.4,
|
||||||
borderRadius: BorderRadius.circular(8),
|
),
|
||||||
border: Border.all(color: Colors.red.shade200),
|
),
|
||||||
),
|
SizedBox(height: HaloSpacing.s8),
|
||||||
child: Text(
|
Text(
|
||||||
errorMessage,
|
'gausah dipikirin formatnya. ngalir aja gimana enaknya buat kamu.',
|
||||||
style: TextStyle(color: Colors.red.shade900),
|
style: TextStyle(
|
||||||
textAlign: TextAlign.center,
|
fontFamily: HaloTokens.fontBody,
|
||||||
),
|
fontSize: 14,
|
||||||
),
|
height: 22 / 14,
|
||||||
],
|
color: HaloTokens.inkSoft,
|
||||||
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.
|
SizedBox(height: HaloSpacing.s20),
|
||||||
if (!isTargetedWaiting)
|
_PromptCard('apa yang lagi paling kamu rasain hari ini?'),
|
||||||
OutlinedButton(
|
SizedBox(height: HaloSpacing.s8),
|
||||||
onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(),
|
_PromptCard('kapan terakhir kamu ngerasa lega?'),
|
||||||
child: const Text('Batalkan'),
|
SizedBox(height: HaloSpacing.s8),
|
||||||
|
_PromptCard('ada satu hal yang pengen banget kamu cerita...'),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (errorMessage != null) ...[
|
||||||
|
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: HaloTokens.surface,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: HaloTokens.border),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
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/availability/mitra_availability_notifier.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../core/pairing/pairing_notifier.dart';
|
import '../../../core/pairing/pairing_notifier.dart';
|
||||||
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
|
import '../../support/widgets/tanya_admin_sheet.dart';
|
||||||
|
|
||||||
/// Shown when a "Curhat lagi" attempt against a specific bestie can't proceed
|
/// Phase 4 Stage 8 — `BestieOfflinePopup`.
|
||||||
/// — either a 409 `targeted_mitra_offline` response on the targeted POST, or
|
|
||||||
/// one of the intermediate WS events (`returning_chat_timeout`,
|
|
||||||
/// `returning_chat_rejected`).
|
|
||||||
///
|
///
|
||||||
/// CTAs:
|
/// Two variants:
|
||||||
/// - "Chat dengan bestie lain" — only rendered when
|
/// - [BestieOfflineVariant.returning] — the customer tried to chat with a
|
||||||
/// [mitraAvailabilityProvider] reports `available == true` at the time of
|
/// specific mitra (history "Curhat lagi"); the targeted attempt failed
|
||||||
/// build. Tapping calls [Pairing.fallbackToBlast] (reuses the same payment
|
/// (409 `targeted_mitra_offline`, or WS `returning_chat_timeout` /
|
||||||
/// session — no double-charge) and closes the dialog. The caller is expected
|
/// `returning_chat_rejected`). Payment session is still `confirmed`, so we
|
||||||
/// to be the searching screen, which will transition into PairingSearchingData
|
/// surface a `Chat dengan bestie lain` primary CTA when other besties are
|
||||||
/// and stay put.
|
/// reachable (calls [Pairing.fallbackToBlast]).
|
||||||
/// - "Kembali" — pops dialog and routes home. Backend has already audit-logged
|
/// - [BestieOfflineVariant.new_] — the customer triggered a general blast
|
||||||
/// the targeted failure; payment session stays `confirmed` until the sweeper
|
/// that bottomed out (no online besties). No fallback button; just a
|
||||||
/// expires it.
|
/// ghost `tanya admin` and a `kembali ke home` exit.
|
||||||
class BestieUnavailableDialog extends ConsumerWidget {
|
///
|
||||||
final String paymentSessionId;
|
/// Both variants expose `tanya admin` via a ghost CTA that opens the
|
||||||
final String mitraName;
|
/// [TanyaAdminSheet].
|
||||||
final TopicSensitivity topicSensitivity;
|
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,
|
super.key,
|
||||||
required this.paymentSessionId,
|
required this.variant,
|
||||||
required this.mitraName,
|
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(
|
static Future<void> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String paymentSessionId,
|
required BestieOfflineVariant variant,
|
||||||
required String mitraName,
|
required String mitraName,
|
||||||
required TopicSensitivity topicSensitivity,
|
String? paymentSessionId,
|
||||||
|
TopicSensitivity? topicSensitivity,
|
||||||
}) {
|
}) {
|
||||||
return showDialog<void>(
|
return showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (_) => BestieUnavailableDialog(
|
barrierColor: const Color(0x66000000),
|
||||||
paymentSessionId: paymentSessionId,
|
builder: (_) => BestieOfflinePopup(
|
||||||
|
variant: variant,
|
||||||
mitraName: mitraName,
|
mitraName: mitraName,
|
||||||
|
paymentSessionId: paymentSessionId,
|
||||||
topicSensitivity: topicSensitivity,
|
topicSensitivity: topicSensitivity,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -55,44 +61,122 @@ class BestieUnavailableDialog extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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 availabilityAsync = ref.watch(mitraAvailabilityProvider);
|
||||||
final hasOtherAvailable = availabilityAsync.valueOrNull ?? false;
|
final hasOtherAvailable = availabilityAsync.valueOrNull ?? false;
|
||||||
|
|
||||||
return AlertDialog(
|
final isReturning = variant == BestieOfflineVariant.returning;
|
||||||
title: const Text('Bestie sedang tidak online'),
|
final title = isReturning ? '$mitraName lagi nggak online' : 'semua bestie lagi istirahat';
|
||||||
content: Text(
|
final body = isReturning
|
||||||
'$mitraName sedang tidak bisa menerima chat saat ini. '
|
? 'bestie kamu belum bisa nerima chat sekarang. coba bestie lain atau balik ke beranda dulu ya.'
|
||||||
'Kamu bisa coba chat dengan bestie lain atau kembali ke beranda.',
|
: 'lagi nggak ada bestie yang siap dengerin. coba lagi bentar, atau hubungin admin biar dibantu.';
|
||||||
),
|
|
||||||
actions: [
|
final canFallbackToBlast = isReturning &&
|
||||||
TextButton(
|
hasOtherAvailable &&
|
||||||
onPressed: () {
|
paymentSessionId != null &&
|
||||||
// Reset pairing state and route home. Payment session stays
|
topicSensitivity != null;
|
||||||
// confirmed until sweeper expires it — no extra API call needed.
|
|
||||||
ref.read(pairingProvider.notifier).reset();
|
return Dialog(
|
||||||
Navigator.of(context).pop();
|
backgroundColor: HaloTokens.surface,
|
||||||
context.go('/home');
|
elevation: 0,
|
||||||
},
|
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.xl),
|
||||||
child: const Text('Kembali'),
|
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,
|
||||||
|
),
|
||||||
|
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: () {
|
||||||
|
ref.read(pairingProvider.notifier).reset();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
context.go('/home');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: HaloSpacing.s8),
|
||||||
|
HaloButton(
|
||||||
|
label: 'tanya admin',
|
||||||
|
variant: HaloButtonVariant.ghost,
|
||||||
|
fullWidth: true,
|
||||||
|
onPressed: () {
|
||||||
|
// Keep the popup open underneath; the sheet sits on top and
|
||||||
|
// closes back to it.
|
||||||
|
// ignore: discarded_futures
|
||||||
|
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');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (hasOtherAvailable)
|
),
|
||||||
ElevatedButton(
|
|
||||||
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();
|
|
||||||
// ignore: discarded_futures
|
|
||||||
ref.read(pairingProvider.notifier).fallbackToBlast(
|
|
||||||
paymentSessionId: paymentSessionId,
|
|
||||||
topicSensitivity: topicSensitivity,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
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/chat_opening_provider.dart';
|
||||||
import '../../../core/chat/session_closure_notifier.dart';
|
import '../../../core/chat/session_closure_notifier.dart';
|
||||||
import '../../../core/constants.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
|
/// Used solely for in-session extension requests; the initial pairing flow
|
||||||
/// goes through `/payment` instead. Free-trial is never offered for extensions.
|
/// goes through `/payment` instead. Free-trial is never offered for extensions.
|
||||||
///
|
///
|
||||||
/// Submit triggers [SessionClosure.requestExtension], which internally
|
/// Mirrors the Stage 3 duration-pick screen: chat/call toggle on top,
|
||||||
/// runs the payment-session create+confirm and then the extend POST.
|
/// 5-option tier list below, single CTA at the bottom. The `perpanjang`
|
||||||
class PricingBottomSheet extends ConsumerWidget {
|
/// 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.
|
/// Required — the in-progress chat session id this extension targets.
|
||||||
final String extensionSessionId;
|
final String extensionSessionId;
|
||||||
|
|
||||||
@@ -22,62 +28,372 @@ class PricingBottomSheet extends ConsumerWidget {
|
|||||||
return showModalBottomSheet(
|
return showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
backgroundColor: HaloTokens.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(24),
|
||||||
|
topRight: Radius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
builder: (_) => PricingBottomSheet(extensionSessionId: sessionId),
|
builder: (_) => PricingBottomSheet(extensionSessionId: sessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<PricingBottomSheet> createState() => _PricingBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
widget.extensionSessionId,
|
||||||
|
durationMinutes: tier.durationMinutes,
|
||||||
|
price: tier.price,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final pricingAsync = ref.watch(chatPricingProvider);
|
final pricingAsync = ref.watch(chatPricingProvider);
|
||||||
|
|
||||||
return pricingAsync.when(
|
return DraggableScrollableSheet(
|
||||||
loading: () => const SizedBox(
|
initialChildSize: 0.65,
|
||||||
height: 200,
|
minChildSize: 0.5,
|
||||||
child: Center(child: CircularProgressIndicator()),
|
maxChildSize: 0.92,
|
||||||
),
|
expand: false,
|
||||||
error: (error, _) => const SizedBox(
|
builder: (_, scrollController) {
|
||||||
height: 200,
|
return SafeArea(
|
||||||
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
|
top: false,
|
||||||
),
|
child: pricingAsync.when(
|
||||||
data: (pricing) => DraggableScrollableSheet(
|
loading: () => const SizedBox(
|
||||||
initialChildSize: 0.6,
|
height: 240,
|
||||||
minChildSize: 0.4,
|
child: Center(child: CircularProgressIndicator()),
|
||||||
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: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
ref.read(sessionClosureProvider.notifier).requestExtension(
|
|
||||||
extensionSessionId,
|
|
||||||
durationMinutes: tier.durationMinutes,
|
|
||||||
price: tier.price,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
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/auth/auth_notifier.dart';
|
||||||
import '../../core/availability/mitra_availability_notifier.dart';
|
import '../../core/availability/mitra_availability_notifier.dart';
|
||||||
import '../../core/chat/active_session_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.
|
/// Home screen.
|
||||||
///
|
///
|
||||||
@@ -54,9 +61,27 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onStartChatPressed(BuildContext context) async {
|
Future<void> _onStartChatPressed(BuildContext context) async {
|
||||||
final topic = await TopicSelectionBottomSheet.show(context);
|
// Phase 4 Stage 2 removes the home-screen topic sensitivity prompt; the
|
||||||
if (topic == null || !context.mounted) return;
|
// ESP picks collected during onboarding feed the same column server-side
|
||||||
context.push('/payment', extra: {'topicSensitivity': topic});
|
// (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
|
@override
|
||||||
@@ -101,9 +126,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
// Force-scroll so RefreshIndicator can fire even on a short body.
|
// Force-scroll so RefreshIndicator can fire even on a short body.
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(32),
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
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))),
|
Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Center(
|
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