Phase 3.7: paid pairing flow + returning chat + extension flip

- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
import { ExtensionTimeoutAction } from '../../core/constants'
const fetchAnonymityConfig = async () => {
const res = await apiClient.get('/internal/config/anonymity')
@@ -74,6 +75,47 @@ const updateSensitivityConfig = async (data) => {
return res.data.data
}
// Paid pairing flow + extension flip
const fetchPairingBlastTimeout = async () => {
const res = await apiClient.get('/internal/config/pairing-blast-timeout')
return res.data.data
}
const updatePairingBlastTimeout = async (pairing_blast_timeout_seconds) => {
const res = await apiClient.patch('/internal/config/pairing-blast-timeout', { pairing_blast_timeout_seconds })
return res.data.data
}
const fetchPaymentSessionTimeout = async () => {
const res = await apiClient.get('/internal/config/payment-session-timeout')
return res.data.data
}
const updatePaymentSessionTimeout = async (payment_session_timeout_minutes) => {
const res = await apiClient.patch('/internal/config/payment-session-timeout', { payment_session_timeout_minutes })
return res.data.data
}
const fetchReturningChatTimeout = async () => {
const res = await apiClient.get('/internal/config/returning-chat-timeout')
return res.data.data
}
const updateReturningChatTimeout = async (returning_chat_confirmation_timeout_seconds) => {
const res = await apiClient.patch('/internal/config/returning-chat-timeout', { returning_chat_confirmation_timeout_seconds })
return res.data.data
}
const fetchExtensionDefaultAction = async () => {
const res = await apiClient.get('/internal/config/extension-default-action')
return res.data.data
}
const updateExtensionDefaultAction = async (extension_default_action_on_timeout) => {
const res = await apiClient.patch('/internal/config/extension-default-action', { extension_default_action_on_timeout })
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
@@ -143,7 +185,50 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-sensitivity'] }),
})
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading) return <div>Loading...</div>
// Pairing Blast Timeout
const { data: pbtData, isLoading: pbtLoading } = useQuery({
queryKey: ['config-pairing-blast-timeout'],
queryFn: fetchPairingBlastTimeout,
})
const pbtMutation = useMutation({
mutationFn: updatePairingBlastTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-pairing-blast-timeout'] }),
})
// Payment Session Timeout
const { data: pstData, isLoading: pstLoading } = useQuery({
queryKey: ['config-payment-session-timeout'],
queryFn: fetchPaymentSessionTimeout,
})
const pstMutation = useMutation({
mutationFn: updatePaymentSessionTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-payment-session-timeout'] }),
})
// Returning Chat Confirmation Timeout
const { data: rctData, isLoading: rctLoading } = useQuery({
queryKey: ['config-returning-chat-timeout'],
queryFn: fetchReturningChatTimeout,
})
const rctMutation = useMutation({
mutationFn: updateReturningChatTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-returning-chat-timeout'] }),
})
// Extension Default Action on Timeout
const { data: edaData, isLoading: edaLoading } = useQuery({
queryKey: ['config-extension-default-action'],
queryFn: fetchExtensionDefaultAction,
})
const edaMutation = useMutation({
mutationFn: updateExtensionDefaultAction,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-default-action'] }),
})
if (
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
pbtLoading || pstLoading || rctLoading || edaLoading
) return <div>Loading...</div>
return (
<div>
@@ -320,6 +405,94 @@ export default function SettingsPage() {
</p>
{senMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Blast Pairing</h2>
<p>Berapa lama backend menunggu mitra menerima sebelum blast dianggap gagal.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="5"
value={pbtData?.pairing_blast_timeout_seconds ?? 60}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 5) pbtMutation.mutate(val)
}}
disabled={pbtMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{pbtMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Sesi Pembayaran</h2>
<p>Sesi pembayaran customer akan kedaluwarsa otomatis setelah durasi ini jika belum dikonfirmasi atau belum menghasilkan chat.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="1"
value={pstData?.payment_session_timeout_minutes ?? 20}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) pstMutation.mutate(val)
}}
disabled={pstMutation.isPending}
style={{ width: 80 }}
/>
<span>menit</span>
</div>
{pstMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Konfirmasi Chat Lanjutan</h2>
<p>Berapa detik bestie punya waktu untuk menerima/menolak permintaan chat lanjutan dari customer sebelum otomatis ditolak.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="5"
value={rctData?.returning_chat_confirmation_timeout_seconds ?? 20}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 5) rctMutation.mutate(val)
}}
disabled={rctMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{rctMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Aksi Default jika Bestie Tidak Menjawab Extension</h2>
<p>Jika bestie tidak merespon permintaan extension dalam batas waktu, sistem akan otomatis menerima atau menolak sesuai pilihan ini.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="radio"
name="extension_default_action_on_timeout"
value={ExtensionTimeoutAction.AUTO_APPROVE}
checked={(edaData?.extension_default_action_on_timeout ?? ExtensionTimeoutAction.AUTO_APPROVE) === ExtensionTimeoutAction.AUTO_APPROVE}
onChange={() => edaMutation.mutate(ExtensionTimeoutAction.AUTO_APPROVE)}
disabled={edaMutation.isPending}
/>
Otomatis disetujui (auto-approve)
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="radio"
name="extension_default_action_on_timeout"
value={ExtensionTimeoutAction.AUTO_REJECT}
checked={edaData?.extension_default_action_on_timeout === ExtensionTimeoutAction.AUTO_REJECT}
onChange={() => edaMutation.mutate(ExtensionTimeoutAction.AUTO_REJECT)}
disabled={edaMutation.isPending}
/>
Otomatis ditolak (auto-reject)
</label>
{edaMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)
}