Pricing: migrate from app_config JSON to relational tables
Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*` keys in app_config with dedicated `pricing_tiers` and `pricing_promotions` tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes) natural-key constraint, optimistic-lock via `updated_at` token returning 409 STALE_WRITE on conflicts. Every mutation writes a history row capturing the operator (changed_by from request.auth.userId) and change_kind. CC SettingsPage replaces the JSON-textarea editors with per-row tables — add / edit / soft-delete / reactivate / reorder, plus a buffered first-session discount form with the same optimistic-lock contract. `minutes` and `mode` are read-only on edit since they form the natural key; operators soft-delete and recreate to change duration. Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local `readDiscountConfig` that still read from app_config — would have silently fallen to hardcoded defaults once the legacy rows were deleted. Now reads from pricing_promotions via the shared service helper, so CC edits to the first- session discount affect actual payment pricing on the next request. Customer-facing GET /api/client/chat/pricing shape unchanged (id values are now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so no app changes needed). 27 new backend tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,3 +44,16 @@ export const ExtensionTimeoutAction = Object.freeze({
|
||||
AUTO_APPROVE: 'auto_approve',
|
||||
AUTO_REJECT: 'auto_reject',
|
||||
})
|
||||
|
||||
// Session / pricing modes. Mirrors backend `SessionMode` enum.
|
||||
export const SessionMode = Object.freeze({
|
||||
CHAT: 'chat',
|
||||
CALL: 'call',
|
||||
})
|
||||
|
||||
// Mirror of backend error codes returned on optimistic-lock conflict / validation.
|
||||
export const ApiErrorCode = Object.freeze({
|
||||
STALE_WRITE: 'STALE_WRITE',
|
||||
VALIDATION: 'VALIDATION',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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'
|
||||
import { ExtensionTimeoutAction, SessionMode, ApiErrorCode } from '../../core/constants'
|
||||
|
||||
const fetchAnonymityConfig = async () => {
|
||||
const res = await apiClient.get('/internal/config/anonymity')
|
||||
@@ -127,13 +127,26 @@ const updateFirstSessionDiscount = async (patch) => {
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
// Phase 4: Pricing tier groups
|
||||
// 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 updatePricingTier = async ({ mode, tiers }) => {
|
||||
const res = await apiClient.patch(`/internal/config/pricing-tiers/${mode}`, { tiers })
|
||||
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
|
||||
}
|
||||
|
||||
@@ -256,24 +269,56 @@ export default function SettingsPage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-default-action'] }),
|
||||
})
|
||||
|
||||
// Phase 4: First-session discount
|
||||
const { data: fsdData, isLoading: fsdLoading } = useQuery({
|
||||
// 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: Pricing tier groups
|
||||
const { data: ptData, isLoading: ptLoading } = useQuery({
|
||||
// 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 ptMutation = useMutation({
|
||||
mutationFn: updatePricingTier,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-pricing-tiers'] }),
|
||||
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
|
||||
@@ -556,94 +601,42 @@ export default function SettingsPage() {
|
||||
{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: First-session discount (Stage 4 — per-row optimistic lock) */}
|
||||
<FirstSessionDiscountSection
|
||||
data={fsdData}
|
||||
mutation={fsdMutation}
|
||||
toast={pricingToast}
|
||||
onDismissToast={() => setPricingToast(null)}
|
||||
/>
|
||||
|
||||
{/* Phase 4: Pricing tier groups (mock) */}
|
||||
{/* Phase 4: Pricing tiers (Stage 4 — per-row CRUD) */}
|
||||
<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
|
||||
<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] ?? []}
|
||||
onSave={(tiers) => ptMutation.mutate({ mode, tiers })}
|
||||
isPending={ptMutation.isPending}
|
||||
createMutation={ptCreateMutation}
|
||||
patchMutation={ptPatchMutation}
|
||||
deleteMutation={ptDeleteMutation}
|
||||
/>
|
||||
))}
|
||||
{ptMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan tier — pastikan JSON valid.</p>}
|
||||
</section>
|
||||
|
||||
{/* Phase 4: Support handles */}
|
||||
@@ -698,50 +691,413 @@ export default function SettingsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// ============================================================================
|
||||
// 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.
|
||||
|
||||
// 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 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 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))
|
||||
}
|
||||
}
|
||||
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: 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 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user