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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user