Schema (idempotent migration): - payment_sessions.is_free_trial -> is_first_session_discount (data copied) - payment_sessions.mode TEXT NOT NULL DEFAULT 'chat' CHECK (chat|call) - chat_sessions.topics TEXT[] for ESP picks (info-only) New endpoints: - GET /api/client/onboarding-state (drives verif sheet + S6 paywall gate) - GET /api/client/chat-pricing (rewrite: chat+call groups + first-session discount block, per-customer eligibility) - GET /api/shared/auth-providers (env-probed; replaces ENABLE_SOCIAL_AUTH build flag — frontend cutover lands in stage 2) - GET /api/client/support-handles (Tanya Admin handles, CC-config-driven) session_warning WS event fires once at 180s remaining. app_config seeds (mock pricing tiers, first-session discount, support handles, payment method order, end-session 2-step toggle). CC SettingsPage: 3 new sections (first-session discount, pricing tiers JSON editors, support handles). 15/15 Vitest passing. chat_sessions.is_free_trial also renamed for consistency (plan only specified payment_sessions; pairing.service.js read both). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
748 lines
29 KiB
JavaScript
748 lines
29 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
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')
|
|
return res.data.data
|
|
}
|
|
|
|
const updateAnonymityConfig = async (anonymity_enabled) => {
|
|
const res = await apiClient.patch('/internal/config/anonymity', { anonymity_enabled })
|
|
return res.data.data
|
|
}
|
|
|
|
const fetchMaxCustomersConfig = async () => {
|
|
const res = await apiClient.get('/internal/config/max-customers-per-mitra')
|
|
return res.data.data
|
|
}
|
|
|
|
const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
|
|
const res = await apiClient.patch('/internal/config/max-customers-per-mitra', { 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
|
|
}
|
|
|
|
// Phase 3.1: Mitra Ping Config
|
|
const fetchMitraPingConfig = async () => {
|
|
const res = await apiClient.get('/internal/config/mitra-ping')
|
|
return res.data.data
|
|
}
|
|
|
|
const updateMitraPingConfig = async (data) => {
|
|
const res = await apiClient.patch('/internal/config/mitra-ping', data)
|
|
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
|
|
}
|
|
|
|
// Phase 3.3: Topic Sensitivity
|
|
const fetchSensitivityConfig = async () => {
|
|
const res = await apiClient.get('/internal/config/sensitivity')
|
|
return res.data.data
|
|
}
|
|
|
|
const updateSensitivityConfig = async (data) => {
|
|
const res = await apiClient.patch('/internal/config/sensitivity', 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
|
|
}
|
|
|
|
// Phase 4: First-session discount
|
|
const fetchFirstSessionDiscount = async () => {
|
|
const res = await apiClient.get('/internal/config/first-session-discount')
|
|
return res.data.data
|
|
}
|
|
const updateFirstSessionDiscount = async (patch) => {
|
|
const res = await apiClient.patch('/internal/config/first-session-discount', patch)
|
|
return res.data.data
|
|
}
|
|
|
|
// Phase 4: Pricing tier groups
|
|
const fetchPricingTiers = async () => {
|
|
const res = await apiClient.get('/internal/config/pricing-tiers')
|
|
return res.data.data
|
|
}
|
|
const updatePricingTier = async ({ mode, tiers }) => {
|
|
const res = await apiClient.patch(`/internal/config/pricing-tiers/${mode}`, { tiers })
|
|
return res.data.data
|
|
}
|
|
|
|
// Phase 4: Support handles
|
|
const fetchSupportHandles = async () => {
|
|
const res = await apiClient.get('/internal/config/support-handles')
|
|
return res.data.data
|
|
}
|
|
const updateSupportHandles = async (patch) => {
|
|
const res = await apiClient.patch('/internal/config/support-handles', patch)
|
|
return res.data.data
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const queryClient = useQueryClient()
|
|
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
|
|
|
|
const anonymityMutation = useMutation({
|
|
mutationFn: updateAnonymityConfig,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-anonymity'] }),
|
|
})
|
|
|
|
const { data: maxData, isLoading: maxLoading } = useQuery({
|
|
queryKey: ['config-max-customers'],
|
|
queryFn: fetchMaxCustomersConfig,
|
|
})
|
|
|
|
const maxMutation = useMutation({
|
|
mutationFn: updateMaxCustomersConfig,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }),
|
|
})
|
|
|
|
// 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'] }),
|
|
})
|
|
|
|
// Phase 3.1: Mitra Ping
|
|
const { data: mpData, isLoading: mpLoading } = useQuery({
|
|
queryKey: ['config-mitra-ping'],
|
|
queryFn: fetchMitraPingConfig,
|
|
})
|
|
const mpMutation = useMutation({
|
|
mutationFn: updateMitraPingConfig,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-mitra-ping'] }),
|
|
})
|
|
|
|
// Phase 3.3: Topic Sensitivity
|
|
const { data: senData, isLoading: senLoading } = useQuery({
|
|
queryKey: ['config-sensitivity'],
|
|
queryFn: fetchSensitivityConfig,
|
|
})
|
|
const senMutation = useMutation({
|
|
mutationFn: updateSensitivityConfig,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-sensitivity'] }),
|
|
})
|
|
|
|
// 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'] }),
|
|
})
|
|
|
|
// Phase 4: First-session discount
|
|
const { data: fsdData, isLoading: fsdLoading } = useQuery({
|
|
queryKey: ['config-first-session-discount'],
|
|
queryFn: fetchFirstSessionDiscount,
|
|
})
|
|
const fsdMutation = useMutation({
|
|
mutationFn: updateFirstSessionDiscount,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-first-session-discount'] }),
|
|
})
|
|
|
|
// Phase 4: Pricing tier groups
|
|
const { data: ptData, isLoading: ptLoading } = useQuery({
|
|
queryKey: ['config-pricing-tiers'],
|
|
queryFn: fetchPricingTiers,
|
|
})
|
|
const ptMutation = useMutation({
|
|
mutationFn: updatePricingTier,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-pricing-tiers'] }),
|
|
})
|
|
|
|
// Phase 4: Support handles
|
|
const { data: shData, isLoading: shLoading } = useQuery({
|
|
queryKey: ['config-support-handles'],
|
|
queryFn: fetchSupportHandles,
|
|
})
|
|
const shMutation = useMutation({
|
|
mutationFn: updateSupportHandles,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-support-handles'] }),
|
|
})
|
|
|
|
if (
|
|
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
|
|
pbtLoading || pstLoading || rctLoading || edaLoading ||
|
|
fsdLoading || ptLoading || shLoading
|
|
) return <div>Loading...</div>
|
|
|
|
return (
|
|
<div>
|
|
<h1>Settings</h1>
|
|
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Anonymity</h2>
|
|
<p>Ketika dinonaktifkan, pengguna anonim akan diminta mendaftar setelah sesi selesai.</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={data?.anonymity_enabled ?? true}
|
|
onChange={e => anonymityMutation.mutate(e.target.checked)}
|
|
disabled={anonymityMutation.isPending}
|
|
/>
|
|
Izinkan pengguna anonim
|
|
</label>
|
|
{anonymityMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
</section>
|
|
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Maks Customer per Mitra</h2>
|
|
<p>Jumlah maksimal customer yang bisa ditangani satu Mitra secara bersamaan. Perubahan hanya berlaku untuk chat baru.</p>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={maxData?.max_customers_per_mitra ?? 3}
|
|
onChange={e => {
|
|
const val = parseInt(e.target.value, 10)
|
|
if (val >= 1) maxMutation.mutate(val)
|
|
}}
|
|
disabled={maxMutation.isPending}
|
|
style={{ width: 80 }}
|
|
/>
|
|
<span>customer</span>
|
|
</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>
|
|
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Mitra Online Status (Ping)</h2>
|
|
<p>Konfigurasi apakah mitra harus mengirim ping (heartbeat) untuk tetap online.</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={mpData?.require_ping ?? true}
|
|
onChange={e => mpMutation.mutate({ require_ping: e.target.checked })}
|
|
disabled={mpMutation.isPending}
|
|
/>
|
|
Wajibkan Mitra Ping (Heartbeat)
|
|
</label>
|
|
<p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
|
Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra.
|
|
</p>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<label>Interval Ping:</label>
|
|
<input
|
|
type="number"
|
|
min="5"
|
|
value={mpData?.ping_interval_seconds ?? 15}
|
|
onChange={e => {
|
|
const val = parseInt(e.target.value, 10)
|
|
if (val >= 5) mpMutation.mutate({ ping_interval_seconds: val })
|
|
}}
|
|
disabled={mpMutation.isPending}
|
|
style={{ width: 80 }}
|
|
/>
|
|
<span>detik</span>
|
|
</div>
|
|
{mpMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
</section>
|
|
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Sensitivitas Topik</h2>
|
|
<p>Konfigurasi untuk fitur penandaan sesi sebagai topik sensitif oleh Mitra.</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={senData?.flip_confirmation_enabled ?? true}
|
|
onChange={e => senMutation.mutate({ flip_confirmation_enabled: e.target.checked })}
|
|
disabled={senMutation.isPending}
|
|
/>
|
|
Aktifkan dialog konfirmasi saat Mitra menandai topik sensitif
|
|
</label>
|
|
<p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
|
Jika dinonaktifkan, Mitra langsung menandai tanpa dialog konfirmasi.
|
|
</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={senData?.one_way_latch ?? false}
|
|
onChange={e => senMutation.mutate({ one_way_latch: e.target.checked })}
|
|
disabled={senMutation.isPending}
|
|
/>
|
|
Kunci searah — Mitra tidak bisa membatalkan tanda topik sensitif
|
|
</label>
|
|
<p style={{ fontSize: 12, color: '#666' }}>
|
|
Jika diaktifkan, setelah sesi ditandai sensitif Mitra tidak dapat mengembalikannya ke topik umum.
|
|
</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>
|
|
|
|
{/* Phase 4: First-session discount */}
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Diskon Sesi Pertama (Phase 4)</h2>
|
|
<p>Diskon untuk sesi pertama customer yang sudah terverifikasi nomor HP. Menggantikan free trial Phase 3.</p>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={fsdData?.enabled ?? false}
|
|
onChange={e => fsdMutation.mutate({ enabled: e.target.checked })}
|
|
disabled={fsdMutation.isPending}
|
|
/>
|
|
Aktifkan diskon sesi pertama
|
|
</label>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<label>Harga aktual (IDR):</label>
|
|
<input
|
|
type="number" min="0"
|
|
value={fsdData?.actual_price_idr ?? 2000}
|
|
onChange={e => {
|
|
const v = parseInt(e.target.value, 10)
|
|
if (Number.isFinite(v) && v >= 0) fsdMutation.mutate({ actual_price_idr: v })
|
|
}}
|
|
disabled={fsdMutation.isPending}
|
|
style={{ width: 120 }}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<label>Harga gimik / coret (IDR):</label>
|
|
<input
|
|
type="number" min="0"
|
|
value={fsdData?.gimmick_price_idr ?? 12000}
|
|
onChange={e => {
|
|
const v = parseInt(e.target.value, 10)
|
|
if (Number.isFinite(v) && v >= 0) fsdMutation.mutate({ gimmick_price_idr: v })
|
|
}}
|
|
disabled={fsdMutation.isPending}
|
|
style={{ width: 120 }}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<label>Durasi (menit):</label>
|
|
<input
|
|
type="number" min="1"
|
|
value={fsdData?.duration_minutes ?? 12}
|
|
onChange={e => {
|
|
const v = parseInt(e.target.value, 10)
|
|
if (Number.isFinite(v) && v >= 1) fsdMutation.mutate({ duration_minutes: v })
|
|
}}
|
|
disabled={fsdMutation.isPending}
|
|
style={{ width: 80 }}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<span>Mode yang dapat diskon:</span>
|
|
{['chat', 'call'].map(m => (
|
|
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={(fsdData?.modes ?? []).includes(m)}
|
|
onChange={e => {
|
|
const current = new Set(fsdData?.modes ?? [])
|
|
if (e.target.checked) current.add(m)
|
|
else current.delete(m)
|
|
fsdMutation.mutate({ modes: Array.from(current) })
|
|
}}
|
|
disabled={fsdMutation.isPending}
|
|
/>
|
|
{m}
|
|
</label>
|
|
))}
|
|
</div>
|
|
{fsdMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
</section>
|
|
|
|
{/* Phase 4: Pricing tier groups (mock) */}
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Tier Harga (Mock — Phase 4)</h2>
|
|
<p>Daftar tier untuk chat dan voice call. JSON harus berupa array of {`{ id, minutes, price_idr, tag? }`}. Pricing masih di-mock — Xendit nyusul di phase berikutnya.</p>
|
|
{['chat', 'call'].map((mode) => (
|
|
<PricingTierEditor
|
|
key={mode}
|
|
mode={mode}
|
|
tiers={ptData?.[mode] ?? []}
|
|
onSave={(tiers) => ptMutation.mutate({ mode, tiers })}
|
|
isPending={ptMutation.isPending}
|
|
/>
|
|
))}
|
|
{ptMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan tier — pastikan JSON valid.</p>}
|
|
</section>
|
|
|
|
{/* Phase 4: Support handles */}
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Support Handles (Tanya Admin)</h2>
|
|
<p>Deeplink WA + Telegram untuk sheet "Tanya Admin" di client_app.</p>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<label style={{ width: 90 }}>WA label:</label>
|
|
<input
|
|
type="text"
|
|
defaultValue={shData?.wa?.label ?? 'WhatsApp'}
|
|
onBlur={e => shMutation.mutate({ wa: { label: e.target.value } })}
|
|
disabled={shMutation.isPending}
|
|
style={{ width: 240 }}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
|
<label style={{ width: 90 }}>WA deeplink:</label>
|
|
<input
|
|
type="text"
|
|
defaultValue={shData?.wa?.deeplink ?? ''}
|
|
onBlur={e => shMutation.mutate({ wa: { deeplink: e.target.value } })}
|
|
disabled={shMutation.isPending}
|
|
style={{ width: 360 }}
|
|
placeholder="https://wa.me/62..."
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<label style={{ width: 90 }}>TG label:</label>
|
|
<input
|
|
type="text"
|
|
defaultValue={shData?.telegram?.label ?? 'Telegram'}
|
|
onBlur={e => shMutation.mutate({ telegram: { label: e.target.value } })}
|
|
disabled={shMutation.isPending}
|
|
style={{ width: 240 }}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<label style={{ width: 90 }}>TG deeplink:</label>
|
|
<input
|
|
type="text"
|
|
defaultValue={shData?.telegram?.deeplink ?? ''}
|
|
onBlur={e => shMutation.mutate({ telegram: { deeplink: e.target.value } })}
|
|
disabled={shMutation.isPending}
|
|
style={{ width: 360 }}
|
|
placeholder="https://t.me/..."
|
|
/>
|
|
</div>
|
|
{shMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Local helper — JSON-validated textarea editor for one mode's tier list. Keeps the
|
|
// editing UX simple (paste JSON, hit Save) without forcing per-row form widgets.
|
|
function PricingTierEditor({ mode, tiers, onSave, isPending }) {
|
|
const initial = JSON.stringify(tiers, null, 2)
|
|
const [draft, setDraft] = useState(initial)
|
|
const [error, setError] = useState(null)
|
|
|
|
// Reset draft when the upstream tiers change (e.g. after a successful save).
|
|
useEffect(() => {
|
|
setDraft(JSON.stringify(tiers, null, 2))
|
|
setError(null)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [JSON.stringify(tiers)])
|
|
|
|
const handleSave = () => {
|
|
try {
|
|
const parsed = JSON.parse(draft)
|
|
if (!Array.isArray(parsed)) throw new Error('expected an array')
|
|
for (const t of parsed) {
|
|
if (typeof t.id !== 'string' || typeof t.minutes !== 'number' || typeof t.price_idr !== 'number') {
|
|
throw new Error('each tier needs id (string), minutes (number), price_idr (number)')
|
|
}
|
|
}
|
|
setError(null)
|
|
onSave(parsed)
|
|
} catch (e) {
|
|
setError(String(e.message || e))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<h3 style={{ margin: '12px 0 4px' }}>{mode === 'chat' ? 'Chat tiers' : 'Voice call tiers'}</h3>
|
|
<textarea
|
|
value={draft}
|
|
onChange={e => setDraft(e.target.value)}
|
|
rows={10}
|
|
style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }}
|
|
disabled={isPending}
|
|
/>
|
|
<div style={{ marginTop: 4 }}>
|
|
<button onClick={handleSave} disabled={isPending} type="button">Simpan tier {mode}</button>
|
|
{error && <span style={{ color: 'red', marginLeft: 8 }}>{error}</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|