diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js
index d78e029..1ab6664 100644
--- a/backend/src/routes/internal/_test.routes.js
+++ b/backend/src/routes/internal/_test.routes.js
@@ -154,4 +154,47 @@ export const internalTestRoutes = async (fastify) => {
_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 }
+ })
}
diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js
index 83430ae..86d89f8 100644
--- a/backend/src/services/session.service.js
+++ b/backend/src/services/session.service.js
@@ -207,13 +207,18 @@ export const getActiveSessionsByMitraWithUnread = async (mitraId) => {
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit
const items = await sql`
- SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
+ SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
m.display_name AS mitra_display_name,
+ COALESCE(mos.is_online, false) AS mitra_is_online,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
- (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message
+ (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message,
+ (SELECT COUNT(*) FROM chat_sessions x
+ WHERE x.customer_id = ${customerId} AND x.mitra_id = cs.mitra_id
+ AND x.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})) AS sessions_count
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
+ LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC
diff --git a/client_app/.maestro/flows/08_returning_targeted.yaml b/client_app/.maestro/flows/08_returning_targeted.yaml
new file mode 100644
index 0000000..bcb5c86
--- /dev/null
+++ b/client_app/.maestro/flows/08_returning_targeted.yaml
@@ -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
diff --git a/client_app/.maestro/scripts/seed_history_session.js b/client_app/.maestro/scripts/seed_history_session.js
new file mode 100644
index 0000000..f87e293
--- /dev/null
+++ b/client_app/.maestro/scripts/seed_history_session.js
@@ -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
diff --git a/client_app/ios/Runner/Info.plist b/client_app/ios/Runner/Info.plist
index 77c070f..f4a4c60 100644
--- a/client_app/ios/Runner/Info.plist
+++ b/client_app/ios/Runner/Info.plist
@@ -49,6 +49,13 @@
+ LSApplicationQueriesSchemes
+
+ https
+ http
+ whatsapp
+ tg
+
UIApplicationSupportsIndirectInputEvents
UIBackgroundModes
diff --git a/client_app/lib/features/auth/widgets/otp_blocked_popup.dart b/client_app/lib/features/auth/widgets/otp_blocked_popup.dart
index 93aee82..fd2e679 100644
--- a/client_app/lib/features/auth/widgets/otp_blocked_popup.dart
+++ b/client_app/lib/features/auth/widgets/otp_blocked_popup.dart
@@ -2,12 +2,13 @@ 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 stub "hubungi admin"
-/// affordance — Stage 8 will wire the real Tanya Admin sheet.
+/// anonymous flow (preserving any ESP/USP state) and a "hubungi admin" CTA
+/// that opens the Tanya Admin sheet.
class OtpBlockedPopup {
const OtpBlockedPopup._();
@@ -44,12 +45,8 @@ class OtpBlockedPopup {
secondary: HaloPopupAction(
label: 'hubungi admin',
onPressed: () {
- // TODO(stage8): replace with Tanya Admin sheet.
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(
- content: Text('Tanya Admin akan tersedia segera.'),
- ),
- );
+ // ignore: discarded_futures
+ TanyaAdminSheet.show(context);
},
),
);
diff --git a/client_app/lib/features/chat/screens/chat_history_screen.dart b/client_app/lib/features/chat/screens/chat_history_screen.dart
index 0e48d71..a000163 100644
--- a/client_app/lib/features/chat/screens/chat_history_screen.dart
+++ b/client_app/lib/features/chat/screens/chat_history_screen.dart
@@ -3,140 +3,323 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart';
+import '../../../core/theme/halo_tokens.dart';
+import '../../../core/theme/widgets/widgets.dart';
+import '../../home/providers/bestie_history_provider.dart';
-/// Chat history with per-row "Curhat lagi" CTA.
+/// Phase 4 Stage 8 — `BestieHistoryList`.
///
-/// Tapping "Curhat lagi" routes to the payment screen with the targeted
-/// mitra id + display name as extras. The payment screen then:
-/// 1. POSTs `/api/client/payment-sessions` with `targeted_mitra_id`
-/// 2. On confirm, fires `pairingNotifier.startTargetedSearch(...)` instead
-/// of the general `startSearch(...)`.
+/// Renders past sessions with the v4 visual: orb + name + last-session date
+/// + topic chips + sessions count + ONLINE pill (per-row, sourced from the
+/// `mitra_is_online` field on the history payload).
///
-/// The CTA is per-row (not per-unique-mitra).
-class ChatHistoryScreen extends ConsumerStatefulWidget {
+/// Tapping a row routes to the targeted "Curhat lagi" payment flow when the
+/// row references a known mitra; closing-state rows still drop into the
+/// session screen so the user can finish the goodbye composer. Otherwise we
+/// fall back to the transcript view.
+class ChatHistoryScreen extends ConsumerWidget {
const ChatHistoryScreen({super.key});
@override
- ConsumerState createState() => _ChatHistoryScreenState();
-}
+ Widget build(BuildContext context, WidgetRef ref) {
+ final historyAsync = ref.watch(bestieHistoryProvider);
+ final fullSessionsAsync = ref.watch(_rawHistoryProvider);
-class _ChatHistoryScreenState extends ConsumerState {
- List