Phase 3 scaffold: chat engine (WebSocket, FCM, pricing, timer, extension, history)

- Backend: WebSocket plugin, chat/pricing/timer/extension/closure/notification services
- Client app: ChatBloc, pricing dialog, chat screen with message status, extension/goodbye flow, history
- Mitra app: MitraChatBloc, ExtensionBloc, chat screen, extension accept/reject, history
- Control center: free trial, extension timeout, early end config toggles
- DB migration: chat_messages, session_closures, session_extensions, customer_transactions tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:58:11 +08:00
parent 844d7234e6
commit b4efcf14c2
47 changed files with 4361 additions and 44 deletions

View File

@@ -21,6 +21,37 @@ const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
return res.data.data
}
// Phase 3 config fetchers
const fetchFreeTrialConfig = async () => {
const res = await apiClient.get('/internal/config/free-trial')
return res.data.data
}
const updateFreeTrialConfig = async (data) => {
const res = await apiClient.patch('/internal/config/free-trial', data)
return res.data.data
}
const fetchExtensionTimeoutConfig = async () => {
const res = await apiClient.get('/internal/config/extension-timeout')
return res.data.data
}
const updateExtensionTimeoutConfig = async (extension_timeout_seconds) => {
const res = await apiClient.patch('/internal/config/extension-timeout', { extension_timeout_seconds })
return res.data.data
}
const fetchEarlyEndConfig = async () => {
const res = await apiClient.get('/internal/config/early-end')
return res.data.data
}
const updateEarlyEndConfig = async (data) => {
const res = await apiClient.patch('/internal/config/early-end', data)
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
@@ -40,7 +71,37 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }),
})
if (isLoading || maxLoading) return <div>Loading...</div>
// Phase 3: Free Trial
const { data: ftData, isLoading: ftLoading } = useQuery({
queryKey: ['config-free-trial'],
queryFn: fetchFreeTrialConfig,
})
const ftMutation = useMutation({
mutationFn: updateFreeTrialConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-free-trial'] }),
})
// Phase 3: Extension Timeout
const { data: etData, isLoading: etLoading } = useQuery({
queryKey: ['config-extension-timeout'],
queryFn: fetchExtensionTimeoutConfig,
})
const etMutation = useMutation({
mutationFn: updateExtensionTimeoutConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-timeout'] }),
})
// Phase 3: Early End
const { data: eeData, isLoading: eeLoading } = useQuery({
queryKey: ['config-early-end'],
queryFn: fetchEarlyEndConfig,
})
const eeMutation = useMutation({
mutationFn: updateEarlyEndConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-early-end'] }),
})
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading) return <div>Loading...</div>
return (
<div>
@@ -80,6 +141,80 @@ export default function SettingsPage() {
</div>
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Free Trial</h2>
<p>Aktifkan free trial untuk customer baru yang belum pernah bertransaksi.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={ftData?.enabled ?? false}
onChange={e => ftMutation.mutate({ enabled: e.target.checked })}
disabled={ftMutation.isPending}
/>
Aktifkan Free Trial
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label>Durasi:</label>
<input
type="number"
min="1"
value={ftData?.duration_minutes ?? 5}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) ftMutation.mutate({ duration_minutes: val })
}}
disabled={ftMutation.isPending}
style={{ width: 80 }}
/>
<span>menit</span>
</div>
{ftMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Extension Timeout</h2>
<p>Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="10"
value={etData?.extension_timeout_seconds ?? 60}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 10) etMutation.mutate(val)
}}
disabled={etMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{etMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Akhiri Sesi Lebih Awal</h2>
<p>Izinkan mitra dan/atau customer untuk mengakhiri sesi sebelum waktu habis.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={eeData?.mitra_enabled ?? false}
onChange={e => eeMutation.mutate({ mitra_enabled: e.target.checked })}
disabled={eeMutation.isPending}
/>
Izinkan Mitra mengakhiri lebih awal
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={eeData?.customer_enabled ?? false}
onChange={e => eeMutation.mutate({ customer_enabled: e.target.checked })}
disabled={eeMutation.isPending}
/>
Izinkan Customer mengakhiri lebih awal
</label>
{eeMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)
}