Splits the single mitra_ping_interval_seconds config (which conflated
"how often the app pings" with "how long until offline" through a
hidden ×3 multiplier) into two orthogonal knobs:
- mitra_stale_after_seconds (CC-tunable, app_config DB row): the
operator-facing offline threshold. What you set is what you get —
no multiplier. Default 45s (preserves today's effective grace at
the legacy 15s ping default).
- MITRA_HEARTBEAT_CADENCE_SECONDS (env var, default 30s): how often
the mitra app sends a heartbeat. Backend-fixed per deployment;
surfaced to the mitra app via /api/mitra/status.
Backend:
- config.service: getMitraPingConfig returns the new tuple
{require_ping, stale_after_seconds, heartbeat_cadence_seconds}.
Env parser handles blank/non-numeric → 30 fallback.
- mitra-status.service::autoOfflineStaleMitras drops the *3 and uses
stale_after_seconds directly.
- mitra-status.service::getStatus returns heartbeat_cadence_seconds
instead of ping_interval_seconds.
- /internal/config/mitra-ping PATCH validates
stale_after_seconds >= cadence, returns 422 with a clear message
("stale_after_seconds must be a number >= heartbeat cadence (30s)").
- migrate.js: adds mitra_stale_after_seconds default 45. The old
mitra_ping_interval_seconds key is left in place (vestigial) —
no live code reads it; safe to drop after one release.
Mitra app:
- status_notifier reads heartbeat_cadence_seconds, uses it directly
as the Timer.periodic interval. Defaults to 30s if missing (older
backend safety).
Control center:
- SettingsPage: renames "Interval Ping" → "Ambang offline", input
min={heartbeat_cadence_seconds}, shows the cadence as a read-only
value with explanation that it's env-controlled.
Verified end-to-end on dev backend:
- GET /api/mitra/status returns {…, heartbeat_cadence_seconds: 30}
- GET /internal/config/mitra-ping returns {require_ping,
stale_after_seconds: 45, heartbeat_cadence_seconds: 30}
- PATCH with stale_after_seconds=20 → 422 with cadence message
- PATCH with stale_after_seconds=120 → 200, persisted
- Env override (=60, blank, "foo") parses correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1110 lines
44 KiB
JavaScript
1110 lines
44 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { apiClient } from '../../core/api/api-client'
|
|
import { ExtensionTimeoutAction, SessionMode, ApiErrorCode } 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 / Stage 4: Per-row CRUD for pricing tiers (relational migration).
|
|
// GET shape: { chat: [{id, mode, minutes, price_idr, original_price_idr, tag,
|
|
// sort_order, is_active, updated_at, created_at}, ...], call: [...] }
|
|
const fetchPricingTiers = async () => {
|
|
const res = await apiClient.get('/internal/config/pricing-tiers')
|
|
return res.data.data
|
|
}
|
|
const createPricingTier = async (body) => {
|
|
const res = await apiClient.post('/internal/config/pricing-tiers', body)
|
|
return res.data.data
|
|
}
|
|
const patchPricingTier = async ({ id, ...body }) => {
|
|
const res = await apiClient.patch(`/internal/config/pricing-tiers/${id}`, body)
|
|
return res.data.data
|
|
}
|
|
// Soft-delete: backend sets is_active=false. updated_at is the optimistic-lock token.
|
|
const deletePricingTier = async ({ id, updated_at }) => {
|
|
const res = await apiClient.delete(`/internal/config/pricing-tiers/${id}`, {
|
|
data: { updated_at },
|
|
})
|
|
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'] }),
|
|
})
|
|
|
|
// Stale-write toast surfaced from any of the per-row pricing mutations.
|
|
// Plain string for now — settings page has no toast lib; the banner sits above the section.
|
|
const [pricingToast, setPricingToast] = useState(null)
|
|
|
|
const handlePricingError = (err, { onRefetch }) => {
|
|
const code = err?.response?.data?.error?.code
|
|
const msg = err?.response?.data?.error?.message
|
|
if (code === ApiErrorCode.STALE_WRITE || code === ApiErrorCode.NOT_FOUND) {
|
|
setPricingToast('Someone else just edited this. Refreshing...')
|
|
onRefetch?.()
|
|
return
|
|
}
|
|
if (code === ApiErrorCode.VALIDATION) {
|
|
setPricingToast(msg ? `Validation: ${msg}` : 'Validation failed.')
|
|
return
|
|
}
|
|
setPricingToast(msg || 'Failed to save.')
|
|
}
|
|
|
|
// Phase 4 / Stage 4: First-session discount with optimistic locking.
|
|
const { data: fsdData, isLoading: fsdLoading, refetch: refetchFsd } = useQuery({
|
|
queryKey: ['config-first-session-discount'],
|
|
queryFn: fetchFirstSessionDiscount,
|
|
})
|
|
const fsdMutation = useMutation({
|
|
mutationFn: updateFirstSessionDiscount,
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-first-session-discount'] }),
|
|
onError: (err) => handlePricingError(err, { onRefetch: refetchFsd }),
|
|
})
|
|
|
|
// Phase 4 / Stage 4: Pricing tiers — per-row CRUD with optimistic locking.
|
|
const { data: ptData, isLoading: ptLoading, refetch: refetchTiers } = useQuery({
|
|
queryKey: ['config-pricing-tiers'],
|
|
queryFn: fetchPricingTiers,
|
|
})
|
|
const invalidateTiers = () => queryClient.invalidateQueries({ queryKey: ['config-pricing-tiers'] })
|
|
const ptCreateMutation = useMutation({
|
|
mutationFn: createPricingTier,
|
|
onSuccess: invalidateTiers,
|
|
onError: (err) => handlePricingError(err, { onRefetch: refetchTiers }),
|
|
})
|
|
const ptPatchMutation = useMutation({
|
|
mutationFn: patchPricingTier,
|
|
onSuccess: invalidateTiers,
|
|
onError: (err) => handlePricingError(err, { onRefetch: refetchTiers }),
|
|
})
|
|
const ptDeleteMutation = useMutation({
|
|
mutationFn: deletePricingTier,
|
|
onSuccess: invalidateTiers,
|
|
onError: (err) => handlePricingError(err, { onRefetch: refetchTiers }),
|
|
})
|
|
|
|
// 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>Mitra dianggap online selama heartbeat terakhir berusia ≤ ambang batas. Cadence (frekuensi ping aplikasi) di-fix oleh server lewat env var.</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, marginBottom: 6 }}>
|
|
<label>Ambang offline (heartbeat terakhir lebih lama dari):</label>
|
|
<input
|
|
type="number"
|
|
min={mpData?.heartbeat_cadence_seconds ?? 30}
|
|
value={mpData?.stale_after_seconds ?? 45}
|
|
onChange={e => {
|
|
const val = parseInt(e.target.value, 10)
|
|
const floor = mpData?.heartbeat_cadence_seconds ?? 30
|
|
if (Number.isFinite(val) && val >= floor) {
|
|
mpMutation.mutate({ stale_after_seconds: val })
|
|
}
|
|
}}
|
|
disabled={mpMutation.isPending}
|
|
style={{ width: 80 }}
|
|
/>
|
|
<span>detik</span>
|
|
</div>
|
|
<p style={{ fontSize: 12, color: '#666', marginBottom: 0 }}>
|
|
Cadence ping mitra: <strong>{mpData?.heartbeat_cadence_seconds ?? 30} detik</strong> (server-set via MITRA_HEARTBEAT_CADENCE_SECONDS env). Nilai ambang minimum mengikuti cadence — tidak bisa lebih rendah.
|
|
</p>
|
|
{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 (Stage 4 — per-row optimistic lock) */}
|
|
<FirstSessionDiscountSection
|
|
data={fsdData}
|
|
mutation={fsdMutation}
|
|
toast={pricingToast}
|
|
onDismissToast={() => setPricingToast(null)}
|
|
/>
|
|
|
|
{/* Phase 4: Pricing tiers (Stage 4 — per-row CRUD) */}
|
|
<section style={{ marginBottom: 24 }}>
|
|
<h2>Tier Harga (Phase 4)</h2>
|
|
<p>
|
|
Daftar tier untuk chat dan voice call. Setiap baris di-edit terpisah dengan
|
|
optimistic locking — perubahan operator lain akan trigger auto-refresh.
|
|
</p>
|
|
{pricingToast && (
|
|
<div style={{
|
|
marginBottom: 8, padding: 8, background: '#fff3cd', border: '1px solid #ffeeba',
|
|
color: '#856404', borderRadius: 4, display: 'flex', justifyContent: 'space-between',
|
|
}}>
|
|
<span>{pricingToast}</span>
|
|
<button type="button" onClick={() => setPricingToast(null)} style={{ marginLeft: 12 }}>
|
|
Tutup
|
|
</button>
|
|
</div>
|
|
)}
|
|
{[SessionMode.CHAT, SessionMode.CALL].map((mode) => (
|
|
<PricingTierTable
|
|
key={mode}
|
|
mode={mode}
|
|
tiers={ptData?.[mode] ?? []}
|
|
createMutation={ptCreateMutation}
|
|
patchMutation={ptPatchMutation}
|
|
deleteMutation={ptDeleteMutation}
|
|
/>
|
|
))}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Pricing tiers — per-row table editor (Stage 4)
|
|
// ============================================================================
|
|
//
|
|
// Per-mode table. Each row carries its own `updated_at` token; PATCH/DELETE
|
|
// echo it back to the backend so concurrent edits 409 instead of clobbering.
|
|
//
|
|
// Layout choice: inline-edit rows over modal. Operators here typically tweak one
|
|
// price at a time; a modal would add a click without adding clarity.
|
|
|
|
const formatIdr = (n) => {
|
|
if (n === null || n === undefined || n === '') return ''
|
|
const v = typeof n === 'number' ? n : parseInt(n, 10)
|
|
if (!Number.isFinite(v)) return ''
|
|
return v.toLocaleString('id-ID')
|
|
}
|
|
|
|
const parseIdr = (s) => {
|
|
if (s === null || s === undefined) return null
|
|
const cleaned = String(s).replace(/[^\d-]/g, '')
|
|
if (cleaned === '' || cleaned === '-') return null
|
|
const v = parseInt(cleaned, 10)
|
|
return Number.isFinite(v) ? v : null
|
|
}
|
|
|
|
function PricingTierTable({ mode, tiers, createMutation, patchMutation, deleteMutation }) {
|
|
const [showAdd, setShowAdd] = useState(false)
|
|
const [editingId, setEditingId] = useState(null)
|
|
|
|
return (
|
|
<div style={{ marginBottom: 20 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
<h3 style={{ margin: 0 }}>
|
|
{mode === SessionMode.CHAT ? 'Chat tiers' : 'Voice call tiers'}
|
|
</h3>
|
|
<button type="button" onClick={() => setShowAdd(s => !s)} disabled={createMutation.isPending}>
|
|
{showAdd ? 'Batal tambah' : '+ Tambah tier'}
|
|
</button>
|
|
</div>
|
|
|
|
{showAdd && (
|
|
<AddTierForm
|
|
mode={mode}
|
|
onSubmit={(body) => createMutation.mutate(body, {
|
|
onSuccess: () => setShowAdd(false),
|
|
})}
|
|
onCancel={() => setShowAdd(false)}
|
|
isPending={createMutation.isPending}
|
|
/>
|
|
)}
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr style={{ background: '#f7f7f7', textAlign: 'left' }}>
|
|
<th style={th}>Menit</th>
|
|
<th style={th}>Harga (IDR)</th>
|
|
<th style={th}>Harga Coret (IDR)</th>
|
|
<th style={th}>Tag</th>
|
|
<th style={th}>Sort</th>
|
|
<th style={th}>Aktif</th>
|
|
<th style={th}>Aksi</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tiers.length === 0 && (
|
|
<tr><td colSpan={7} style={{ ...td, color: '#999', fontStyle: 'italic' }}>Belum ada tier.</td></tr>
|
|
)}
|
|
{tiers.map((tier) => (
|
|
editingId === tier.id ? (
|
|
<EditTierRow
|
|
key={tier.id}
|
|
tier={tier}
|
|
onSave={(patch) => patchMutation.mutate(
|
|
{ id: tier.id, updated_at: tier.updated_at, ...patch },
|
|
{ onSuccess: () => setEditingId(null) },
|
|
)}
|
|
onCancel={() => setEditingId(null)}
|
|
isPending={patchMutation.isPending}
|
|
/>
|
|
) : (
|
|
<ReadOnlyTierRow
|
|
key={tier.id}
|
|
tier={tier}
|
|
onEdit={() => setEditingId(tier.id)}
|
|
onDelete={() => {
|
|
if (!window.confirm(`Soft-delete tier ${tier.minutes} menit (${formatIdr(tier.price_idr)} IDR)?`)) return
|
|
deleteMutation.mutate({ id: tier.id, updated_at: tier.updated_at })
|
|
}}
|
|
isDeleting={deleteMutation.isPending}
|
|
/>
|
|
)
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const th = { padding: '6px 8px', borderBottom: '1px solid #ddd' }
|
|
const td = { padding: '6px 8px', borderBottom: '1px solid #eee' }
|
|
|
|
function ReadOnlyTierRow({ tier, onEdit, onDelete, isDeleting }) {
|
|
return (
|
|
<tr style={{ opacity: tier.is_active ? 1 : 0.55 }}>
|
|
<td style={td}>{tier.minutes}</td>
|
|
<td style={td}>{formatIdr(tier.price_idr)}</td>
|
|
<td style={td}>{tier.original_price_idr ? formatIdr(tier.original_price_idr) : <span style={{ color: '#aaa' }}>—</span>}</td>
|
|
<td style={td}>{tier.tag || <span style={{ color: '#aaa' }}>—</span>}</td>
|
|
<td style={td}>{tier.sort_order}</td>
|
|
<td style={td}>{tier.is_active ? 'ya' : 'tidak'}</td>
|
|
<td style={td}>
|
|
<button type="button" onClick={onEdit} style={{ marginRight: 4 }}>Edit</button>
|
|
{tier.is_active && (
|
|
<button type="button" onClick={onDelete} disabled={isDeleting} style={{ color: '#a00' }}>
|
|
Soft-delete
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
function EditTierRow({ tier, onSave, onCancel, isPending }) {
|
|
// Local draft state; only fields that differ are sent on submit (PATCH semantics).
|
|
const [priceStr, setPriceStr] = useState(formatIdr(tier.price_idr))
|
|
const [origStr, setOrigStr] = useState(tier.original_price_idr ? formatIdr(tier.original_price_idr) : '')
|
|
const [tag, setTag] = useState(tier.tag ?? '')
|
|
const [sortOrder, setSortOrder] = useState(String(tier.sort_order))
|
|
const [isActive, setIsActive] = useState(tier.is_active)
|
|
const [minutes, setMinutes] = useState(String(tier.minutes))
|
|
const [localError, setLocalError] = useState(null)
|
|
|
|
const submit = () => {
|
|
const price = parseIdr(priceStr)
|
|
const orig = origStr.trim() === '' ? null : parseIdr(origStr)
|
|
const so = parseInt(sortOrder, 10)
|
|
const mins = parseInt(minutes, 10)
|
|
|
|
if (price === null || price < 0) return setLocalError('Harga wajib diisi dan >= 0.')
|
|
if (orig !== null && orig < price) return setLocalError('Harga coret harus >= harga.')
|
|
if (!Number.isInteger(so)) return setLocalError('Sort order harus integer.')
|
|
if (!Number.isInteger(mins) || mins <= 0) return setLocalError('Menit harus integer positif.')
|
|
|
|
// Build minimal patch: only include changed fields. mode/minutes are not patchable
|
|
// server-side (backend PATCH ignores them) so we skip those here.
|
|
const patch = {}
|
|
if (price !== tier.price_idr) patch.price_idr = price
|
|
if (orig !== tier.original_price_idr) patch.original_price_idr = orig
|
|
if ((tag || null) !== (tier.tag || null)) patch.tag = tag.trim() === '' ? null : tag
|
|
if (so !== tier.sort_order) patch.sort_order = so
|
|
if (isActive !== tier.is_active) patch.is_active = isActive
|
|
|
|
if (Object.keys(patch).length === 0) {
|
|
onCancel()
|
|
return
|
|
}
|
|
setLocalError(null)
|
|
onSave(patch)
|
|
}
|
|
|
|
return (
|
|
<tr style={{ background: '#fffdf3' }}>
|
|
<td style={td}>
|
|
{/* minutes is part of the unique key; backend doesn't support changing it,
|
|
so we render it read-only but show the value for context. */}
|
|
<input
|
|
type="number" min="1" value={minutes}
|
|
onChange={e => setMinutes(e.target.value)}
|
|
style={{ width: 60 }}
|
|
disabled
|
|
title="Menit tidak bisa diubah — hapus dan buat tier baru."
|
|
/>
|
|
</td>
|
|
<td style={td}>
|
|
<input
|
|
type="text" inputMode="numeric"
|
|
value={priceStr}
|
|
onChange={e => setPriceStr(formatIdr(parseIdr(e.target.value) ?? ''))}
|
|
style={{ width: 110 }}
|
|
disabled={isPending}
|
|
/>
|
|
</td>
|
|
<td style={td}>
|
|
<input
|
|
type="text" inputMode="numeric" placeholder="opsional"
|
|
value={origStr}
|
|
onChange={e => setOrigStr(e.target.value === '' ? '' : formatIdr(parseIdr(e.target.value) ?? ''))}
|
|
style={{ width: 110 }}
|
|
disabled={isPending}
|
|
title="Anchor / strikethrough price (optional). Harus >= harga."
|
|
/>
|
|
</td>
|
|
<td style={td}>
|
|
<input
|
|
type="text" value={tag}
|
|
onChange={e => setTag(e.target.value)}
|
|
style={{ width: 110 }}
|
|
disabled={isPending}
|
|
/>
|
|
</td>
|
|
<td style={td}>
|
|
<input
|
|
type="number" value={sortOrder}
|
|
onChange={e => setSortOrder(e.target.value)}
|
|
style={{ width: 60 }}
|
|
disabled={isPending}
|
|
/>
|
|
</td>
|
|
<td style={td}>
|
|
<input
|
|
type="checkbox" checked={isActive}
|
|
onChange={e => setIsActive(e.target.checked)}
|
|
disabled={isPending}
|
|
/>
|
|
</td>
|
|
<td style={td}>
|
|
<button type="button" onClick={submit} disabled={isPending} style={{ marginRight: 4 }}>
|
|
{isPending ? '...' : 'Simpan'}
|
|
</button>
|
|
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
|
|
{localError && <div style={{ color: 'red', fontSize: 11, marginTop: 2 }}>{localError}</div>}
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
function AddTierForm({ mode, onSubmit, onCancel, isPending }) {
|
|
const [minutes, setMinutes] = useState('')
|
|
const [priceStr, setPriceStr] = useState('')
|
|
const [origStr, setOrigStr] = useState('')
|
|
const [tag, setTag] = useState('')
|
|
const [sortOrder, setSortOrder] = useState('0')
|
|
const [localError, setLocalError] = useState(null)
|
|
|
|
const submit = (e) => {
|
|
e.preventDefault()
|
|
const mins = parseInt(minutes, 10)
|
|
const price = parseIdr(priceStr)
|
|
const orig = origStr.trim() === '' ? null : parseIdr(origStr)
|
|
const so = parseInt(sortOrder, 10)
|
|
|
|
if (!Number.isInteger(mins) || mins <= 0) return setLocalError('Menit harus integer positif.')
|
|
if (price === null || price < 0) return setLocalError('Harga wajib diisi dan >= 0.')
|
|
if (orig !== null && orig < price) return setLocalError('Harga coret harus >= harga.')
|
|
if (!Number.isInteger(so)) return setLocalError('Sort order harus integer.')
|
|
|
|
setLocalError(null)
|
|
onSubmit({
|
|
mode,
|
|
minutes: mins,
|
|
price_idr: price,
|
|
original_price_idr: orig,
|
|
tag: tag.trim() === '' ? null : tag,
|
|
sort_order: so,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={submit} style={{
|
|
padding: 8, marginBottom: 8, background: '#f0f7ff', border: '1px solid #cde',
|
|
display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end',
|
|
}}>
|
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
|
Menit
|
|
<input type="number" min="1" value={minutes} onChange={e => setMinutes(e.target.value)}
|
|
style={{ width: 70 }} disabled={isPending} required />
|
|
</label>
|
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
|
Harga (IDR)
|
|
<input type="text" inputMode="numeric" value={priceStr}
|
|
onChange={e => setPriceStr(formatIdr(parseIdr(e.target.value) ?? ''))}
|
|
style={{ width: 110 }} disabled={isPending} required />
|
|
</label>
|
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
|
Harga Coret (opsional)
|
|
<input type="text" inputMode="numeric" value={origStr}
|
|
onChange={e => setOrigStr(e.target.value === '' ? '' : formatIdr(parseIdr(e.target.value) ?? ''))}
|
|
style={{ width: 110 }} disabled={isPending}
|
|
title="Anchor / strikethrough price (optional). Harus >= harga." />
|
|
</label>
|
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
|
Tag
|
|
<input type="text" value={tag} onChange={e => setTag(e.target.value)}
|
|
style={{ width: 110 }} disabled={isPending} placeholder="paling pas" />
|
|
</label>
|
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
|
Sort
|
|
<input type="number" value={sortOrder} onChange={e => setSortOrder(e.target.value)}
|
|
style={{ width: 60 }} disabled={isPending} />
|
|
</label>
|
|
<button type="submit" disabled={isPending}>{isPending ? '...' : 'Tambah'}</button>
|
|
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
|
|
{localError && <div style={{ color: 'red', fontSize: 12, width: '100%' }}>{localError}</div>}
|
|
</form>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// First-session discount — single-row form with optimistic locking
|
|
// ============================================================================
|
|
//
|
|
// Buffered "edit then Save" UX (not the per-keystroke pattern the old code used)
|
|
// because optimistic locking requires us to send a single coherent patch with
|
|
// the last-seen `updated_at`. Each field-level mutation would race itself.
|
|
|
|
function FirstSessionDiscountSection({ data, mutation, toast, onDismissToast }) {
|
|
// Local form state, hydrated from server data. Reset whenever upstream changes
|
|
// (e.g. after a successful PATCH or after a 409 auto-refetch).
|
|
const [enabled, setEnabled] = useState(data?.enabled ?? false)
|
|
const [actualStr, setActualStr] = useState(formatIdr(data?.actual_price_idr ?? 2000))
|
|
const [gimmickStr, setGimmickStr] = useState(data?.gimmick_price_idr ? formatIdr(data.gimmick_price_idr) : '')
|
|
const [duration, setDuration] = useState(String(data?.duration_minutes ?? 12))
|
|
const [modes, setModes] = useState(data?.modes ?? [SessionMode.CHAT])
|
|
const [localError, setLocalError] = useState(null)
|
|
|
|
useEffect(() => {
|
|
if (!data) return
|
|
setEnabled(data.enabled ?? false)
|
|
setActualStr(formatIdr(data.actual_price_idr ?? 2000))
|
|
setGimmickStr(data.gimmick_price_idr ? formatIdr(data.gimmick_price_idr) : '')
|
|
setDuration(String(data.duration_minutes ?? 12))
|
|
setModes(data.modes ?? [SessionMode.CHAT])
|
|
setLocalError(null)
|
|
}, [data?.updated_at])
|
|
|
|
const toggleMode = (m, checked) => {
|
|
setModes(prev => checked ? Array.from(new Set([...prev, m])) : prev.filter(x => x !== m))
|
|
}
|
|
|
|
const save = () => {
|
|
if (!data?.updated_at) return setLocalError('Belum ada data — tunggu fetch awal.')
|
|
const actual = parseIdr(actualStr)
|
|
const gimmick = gimmickStr.trim() === '' ? null : parseIdr(gimmickStr)
|
|
const dur = parseInt(duration, 10)
|
|
|
|
if (actual === null || actual < 0) return setLocalError('Harga aktual harus >= 0.')
|
|
if (gimmick !== null && gimmick < actual) return setLocalError('Harga gimik harus >= harga aktual.')
|
|
if (!Number.isInteger(dur) || dur <= 0) return setLocalError('Durasi harus integer > 0.')
|
|
if (modes.length === 0) return setLocalError('Pilih minimal satu mode.')
|
|
|
|
setLocalError(null)
|
|
mutation.mutate({
|
|
updated_at: data.updated_at,
|
|
enabled,
|
|
actual_price_idr: actual,
|
|
gimmick_price_idr: gimmick,
|
|
duration_minutes: dur,
|
|
modes,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<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>
|
|
{toast && (
|
|
<div style={{
|
|
marginBottom: 8, padding: 8, background: '#fff3cd', border: '1px solid #ffeeba',
|
|
color: '#856404', borderRadius: 4, display: 'flex', justifyContent: 'space-between',
|
|
}}>
|
|
<span>{toast}</span>
|
|
<button type="button" onClick={onDismissToast} style={{ marginLeft: 12 }}>Tutup</button>
|
|
</div>
|
|
)}
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)}
|
|
disabled={mutation.isPending} />
|
|
Aktifkan diskon sesi pertama
|
|
</label>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<label>Harga aktual (IDR):</label>
|
|
<input type="text" inputMode="numeric" value={actualStr}
|
|
onChange={e => setActualStr(formatIdr(parseIdr(e.target.value) ?? ''))}
|
|
style={{ width: 140 }} disabled={mutation.isPending} />
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<label>Harga gimik / coret (IDR):</label>
|
|
<input type="text" inputMode="numeric" value={gimmickStr}
|
|
onChange={e => setGimmickStr(e.target.value === '' ? '' : formatIdr(parseIdr(e.target.value) ?? ''))}
|
|
style={{ width: 140 }} disabled={mutation.isPending}
|
|
title="Optional. Harus >= harga aktual." />
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<label>Durasi (menit):</label>
|
|
<input type="number" min="1" value={duration} onChange={e => setDuration(e.target.value)}
|
|
style={{ width: 80 }} disabled={mutation.isPending} />
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
|
<span>Mode yang dapat diskon:</span>
|
|
{[SessionMode.CHAT, SessionMode.CALL].map(m => (
|
|
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<input type="checkbox" checked={modes.includes(m)}
|
|
onChange={e => toggleMode(m, e.target.checked)}
|
|
disabled={mutation.isPending} />
|
|
{m}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<button type="button" onClick={save} disabled={mutation.isPending || !data}>
|
|
{mutation.isPending ? 'Menyimpan...' : 'Simpan'}
|
|
</button>
|
|
{localError && <span style={{ color: 'red', fontSize: 12 }}>{localError}</span>}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|