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
Loading...
return (

Settings

Anonymity

Ketika dinonaktifkan, pengguna anonim akan diminta mendaftar setelah sesi selesai.

{anonymityMutation.isError &&

Gagal menyimpan.

}

Maks Customer per Mitra

Jumlah maksimal customer yang bisa ditangani satu Mitra secara bersamaan. Perubahan hanya berlaku untuk chat baru.

{ const val = parseInt(e.target.value, 10) if (val >= 1) maxMutation.mutate(val) }} disabled={maxMutation.isPending} style={{ width: 80 }} /> customer
{maxMutation.isError &&

Gagal menyimpan.

}

Free Trial

Aktifkan free trial untuk customer baru yang belum pernah bertransaksi.

{ const val = parseInt(e.target.value, 10) if (val >= 1) ftMutation.mutate({ duration_minutes: val }) }} disabled={ftMutation.isPending} style={{ width: 80 }} /> menit
{ftMutation.isError &&

Gagal menyimpan.

}

Extension Timeout

Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.

{ const val = parseInt(e.target.value, 10) if (val >= 10) etMutation.mutate(val) }} disabled={etMutation.isPending} style={{ width: 80 }} /> detik
{etMutation.isError &&

Gagal menyimpan.

}

Akhiri Sesi Lebih Awal

Izinkan mitra dan/atau customer untuk mengakhiri sesi sebelum waktu habis.

{eeMutation.isError &&

Gagal menyimpan.

}

Mitra Online Status (Ping)

Mitra dianggap online selama heartbeat terakhir berusia ≤ ambang batas. Cadence (frekuensi ping aplikasi) di-fix oleh server lewat env var.

Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra.

{ 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 }} /> detik

Cadence ping mitra: {mpData?.heartbeat_cadence_seconds ?? 30} detik (server-set via MITRA_HEARTBEAT_CADENCE_SECONDS env). Nilai ambang minimum mengikuti cadence — tidak bisa lebih rendah.

{mpMutation.isError &&

Gagal menyimpan.

}

Sensitivitas Topik

Konfigurasi untuk fitur penandaan sesi sebagai topik sensitif oleh Mitra.

Jika dinonaktifkan, Mitra langsung menandai tanpa dialog konfirmasi.

Jika diaktifkan, setelah sesi ditandai sensitif Mitra tidak dapat mengembalikannya ke topik umum.

{senMutation.isError &&

Gagal menyimpan.

}

Batas Waktu Blast Pairing

Berapa lama backend menunggu mitra menerima sebelum blast dianggap gagal.

{ const val = parseInt(e.target.value, 10) if (val >= 5) pbtMutation.mutate(val) }} disabled={pbtMutation.isPending} style={{ width: 80 }} /> detik
{pbtMutation.isError &&

Gagal menyimpan.

}

Batas Waktu Sesi Pembayaran

Sesi pembayaran customer akan kedaluwarsa otomatis setelah durasi ini jika belum dikonfirmasi atau belum menghasilkan chat.

{ const val = parseInt(e.target.value, 10) if (val >= 1) pstMutation.mutate(val) }} disabled={pstMutation.isPending} style={{ width: 80 }} /> menit
{pstMutation.isError &&

Gagal menyimpan.

}

Batas Waktu Konfirmasi Chat Lanjutan

Berapa detik bestie punya waktu untuk menerima/menolak permintaan chat lanjutan dari customer sebelum otomatis ditolak.

{ const val = parseInt(e.target.value, 10) if (val >= 5) rctMutation.mutate(val) }} disabled={rctMutation.isPending} style={{ width: 80 }} /> detik
{rctMutation.isError &&

Gagal menyimpan.

}

Aksi Default jika Bestie Tidak Menjawab Extension

Jika bestie tidak merespon permintaan extension dalam batas waktu, sistem akan otomatis menerima atau menolak sesuai pilihan ini.

{edaMutation.isError &&

Gagal menyimpan.

}
{/* Phase 4: First-session discount (Stage 4 — per-row optimistic lock) */} setPricingToast(null)} /> {/* Phase 4: Pricing tiers (Stage 4 — per-row CRUD) */}

Tier Harga (Phase 4)

Daftar tier untuk chat dan voice call. Setiap baris di-edit terpisah dengan optimistic locking — perubahan operator lain akan trigger auto-refresh.

{pricingToast && (
{pricingToast}
)} {[SessionMode.CHAT, SessionMode.CALL].map((mode) => ( ))}
{/* Phase 4: Support handles */}

Support Handles (Tanya Admin)

Deeplink WA + Telegram untuk sheet "Tanya Admin" di client_app.

shMutation.mutate({ wa: { label: e.target.value } })} disabled={shMutation.isPending} style={{ width: 240 }} />
shMutation.mutate({ wa: { deeplink: e.target.value } })} disabled={shMutation.isPending} style={{ width: 360 }} placeholder="https://wa.me/62..." />
shMutation.mutate({ telegram: { label: e.target.value } })} disabled={shMutation.isPending} style={{ width: 240 }} />
shMutation.mutate({ telegram: { deeplink: e.target.value } })} disabled={shMutation.isPending} style={{ width: 360 }} placeholder="https://t.me/..." />
{shMutation.isError &&

Gagal menyimpan.

}
) } // ============================================================================ // 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 (

{mode === SessionMode.CHAT ? 'Chat tiers' : 'Voice call tiers'}

{showAdd && ( createMutation.mutate(body, { onSuccess: () => setShowAdd(false), })} onCancel={() => setShowAdd(false)} isPending={createMutation.isPending} /> )} {tiers.length === 0 && ( )} {tiers.map((tier) => ( editingId === tier.id ? ( patchMutation.mutate( { id: tier.id, updated_at: tier.updated_at, ...patch }, { onSuccess: () => setEditingId(null) }, )} onCancel={() => setEditingId(null)} isPending={patchMutation.isPending} /> ) : ( 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} /> ) ))}
Menit Harga (IDR) Harga Coret (IDR) Tag Sort Aktif Aksi
Belum ada tier.
) } 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 ( {tier.minutes} {formatIdr(tier.price_idr)} {tier.original_price_idr ? formatIdr(tier.original_price_idr) : } {tier.tag || } {tier.sort_order} {tier.is_active ? 'ya' : 'tidak'} {tier.is_active && ( )} ) } 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 ( {/* 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. */} setMinutes(e.target.value)} style={{ width: 60 }} disabled title="Menit tidak bisa diubah — hapus dan buat tier baru." /> setPriceStr(formatIdr(parseIdr(e.target.value) ?? ''))} style={{ width: 110 }} disabled={isPending} /> setOrigStr(e.target.value === '' ? '' : formatIdr(parseIdr(e.target.value) ?? ''))} style={{ width: 110 }} disabled={isPending} title="Anchor / strikethrough price (optional). Harus >= harga." /> setTag(e.target.value)} style={{ width: 110 }} disabled={isPending} /> setSortOrder(e.target.value)} style={{ width: 60 }} disabled={isPending} /> setIsActive(e.target.checked)} disabled={isPending} /> {localError &&
{localError}
} ) } 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 (
{localError &&
{localError}
}
) } // ============================================================================ // 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 (

Diskon Sesi Pertama (Phase 4)

Diskon untuk sesi pertama customer yang sudah terverifikasi nomor HP. Menggantikan free trial Phase 3.

{toast && (
{toast}
)}
setActualStr(formatIdr(parseIdr(e.target.value) ?? ''))} style={{ width: 140 }} disabled={mutation.isPending} />
setGimmickStr(e.target.value === '' ? '' : formatIdr(parseIdr(e.target.value) ?? ''))} style={{ width: 140 }} disabled={mutation.isPending} title="Optional. Harus >= harga aktual." />
setDuration(e.target.value)} style={{ width: 80 }} disabled={mutation.isPending} />
Mode yang dapat diskon: {[SessionMode.CHAT, SessionMode.CALL].map(m => ( ))}
{localError && {localError}}
) }