Phase 4 Stage 1: backend foundation (additive endpoints + schema)

Schema (idempotent migration):
- payment_sessions.is_free_trial -> is_first_session_discount (data copied)
- payment_sessions.mode TEXT NOT NULL DEFAULT 'chat' CHECK (chat|call)
- chat_sessions.topics TEXT[] for ESP picks (info-only)

New endpoints:
- GET /api/client/onboarding-state (drives verif sheet + S6 paywall gate)
- GET /api/client/chat-pricing (rewrite: chat+call groups + first-session
  discount block, per-customer eligibility)
- GET /api/shared/auth-providers (env-probed; replaces ENABLE_SOCIAL_AUTH
  build flag — frontend cutover lands in stage 2)
- GET /api/client/support-handles (Tanya Admin handles, CC-config-driven)

session_warning WS event fires once at 180s remaining.

app_config seeds (mock pricing tiers, first-session discount, support
handles, payment method order, end-session 2-step toggle).

CC SettingsPage: 3 new sections (first-session discount, pricing tiers
JSON editors, support handles).

15/15 Vitest passing. chat_sessions.is_free_trial also renamed for
consistency (plan only specified payment_sessions; pairing.service.js
read both).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 15:56:28 +08:00
parent 4ada7c991a
commit d33d4419ea
24 changed files with 1347 additions and 162 deletions

View File

@@ -1,3 +1,4 @@
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'
@@ -116,6 +117,36 @@ const updateExtensionDefaultAction = async (extension_default_action_on_timeout)
return res.data.data
}
// Phase 4: First-session discount
const fetchFirstSessionDiscount = async () => {
const res = await apiClient.get('/internal/config/first-session-discount')
return res.data.data
}
const updateFirstSessionDiscount = async (patch) => {
const res = await apiClient.patch('/internal/config/first-session-discount', patch)
return res.data.data
}
// Phase 4: Pricing tier groups
const fetchPricingTiers = async () => {
const res = await apiClient.get('/internal/config/pricing-tiers')
return res.data.data
}
const updatePricingTier = async ({ mode, tiers }) => {
const res = await apiClient.patch(`/internal/config/pricing-tiers/${mode}`, { tiers })
return res.data.data
}
// Phase 4: Support handles
const fetchSupportHandles = async () => {
const res = await apiClient.get('/internal/config/support-handles')
return res.data.data
}
const updateSupportHandles = async (patch) => {
const res = await apiClient.patch('/internal/config/support-handles', patch)
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
@@ -225,9 +256,40 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-default-action'] }),
})
// Phase 4: First-session discount
const { data: fsdData, isLoading: fsdLoading } = useQuery({
queryKey: ['config-first-session-discount'],
queryFn: fetchFirstSessionDiscount,
})
const fsdMutation = useMutation({
mutationFn: updateFirstSessionDiscount,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-first-session-discount'] }),
})
// Phase 4: Pricing tier groups
const { data: ptData, isLoading: ptLoading } = useQuery({
queryKey: ['config-pricing-tiers'],
queryFn: fetchPricingTiers,
})
const ptMutation = useMutation({
mutationFn: updatePricingTier,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-pricing-tiers'] }),
})
// Phase 4: Support handles
const { data: shData, isLoading: shLoading } = useQuery({
queryKey: ['config-support-handles'],
queryFn: fetchSupportHandles,
})
const shMutation = useMutation({
mutationFn: updateSupportHandles,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-support-handles'] }),
})
if (
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
pbtLoading || pstLoading || rctLoading || edaLoading
pbtLoading || pstLoading || rctLoading || edaLoading ||
fsdLoading || ptLoading || shLoading
) return <div>Loading...</div>
return (
@@ -493,6 +555,193 @@ export default function SettingsPage() {
</label>
{edaMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
{/* Phase 4: First-session discount */}
<section style={{ marginBottom: 24 }}>
<h2>Diskon Sesi Pertama (Phase 4)</h2>
<p>Diskon untuk sesi pertama customer yang sudah terverifikasi nomor HP. Menggantikan free trial Phase 3.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={fsdData?.enabled ?? false}
onChange={e => fsdMutation.mutate({ enabled: e.target.checked })}
disabled={fsdMutation.isPending}
/>
Aktifkan diskon sesi pertama
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<label>Harga aktual (IDR):</label>
<input
type="number" min="0"
value={fsdData?.actual_price_idr ?? 2000}
onChange={e => {
const v = parseInt(e.target.value, 10)
if (Number.isFinite(v) && v >= 0) fsdMutation.mutate({ actual_price_idr: v })
}}
disabled={fsdMutation.isPending}
style={{ width: 120 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<label>Harga gimik / coret (IDR):</label>
<input
type="number" min="0"
value={fsdData?.gimmick_price_idr ?? 12000}
onChange={e => {
const v = parseInt(e.target.value, 10)
if (Number.isFinite(v) && v >= 0) fsdMutation.mutate({ gimmick_price_idr: v })
}}
disabled={fsdMutation.isPending}
style={{ width: 120 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<label>Durasi (menit):</label>
<input
type="number" min="1"
value={fsdData?.duration_minutes ?? 12}
onChange={e => {
const v = parseInt(e.target.value, 10)
if (Number.isFinite(v) && v >= 1) fsdMutation.mutate({ duration_minutes: v })
}}
disabled={fsdMutation.isPending}
style={{ width: 80 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span>Mode yang dapat diskon:</span>
{['chat', 'call'].map(m => (
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="checkbox"
checked={(fsdData?.modes ?? []).includes(m)}
onChange={e => {
const current = new Set(fsdData?.modes ?? [])
if (e.target.checked) current.add(m)
else current.delete(m)
fsdMutation.mutate({ modes: Array.from(current) })
}}
disabled={fsdMutation.isPending}
/>
{m}
</label>
))}
</div>
{fsdMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
{/* Phase 4: Pricing tier groups (mock) */}
<section style={{ marginBottom: 24 }}>
<h2>Tier Harga (Mock Phase 4)</h2>
<p>Daftar tier untuk chat dan voice call. JSON harus berupa array of {`{ id, minutes, price_idr, tag? }`}. Pricing masih di-mock Xendit nyusul di phase berikutnya.</p>
{['chat', 'call'].map((mode) => (
<PricingTierEditor
key={mode}
mode={mode}
tiers={ptData?.[mode] ?? []}
onSave={(tiers) => ptMutation.mutate({ mode, tiers })}
isPending={ptMutation.isPending}
/>
))}
{ptMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan tier pastikan JSON valid.</p>}
</section>
{/* Phase 4: Support handles */}
<section style={{ marginBottom: 24 }}>
<h2>Support Handles (Tanya Admin)</h2>
<p>Deeplink WA + Telegram untuk sheet "Tanya Admin" di client_app.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<label style={{ width: 90 }}>WA label:</label>
<input
type="text"
defaultValue={shData?.wa?.label ?? 'WhatsApp'}
onBlur={e => shMutation.mutate({ wa: { label: e.target.value } })}
disabled={shMutation.isPending}
style={{ width: 240 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<label style={{ width: 90 }}>WA deeplink:</label>
<input
type="text"
defaultValue={shData?.wa?.deeplink ?? ''}
onBlur={e => shMutation.mutate({ wa: { deeplink: e.target.value } })}
disabled={shMutation.isPending}
style={{ width: 360 }}
placeholder="https://wa.me/62..."
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<label style={{ width: 90 }}>TG label:</label>
<input
type="text"
defaultValue={shData?.telegram?.label ?? 'Telegram'}
onBlur={e => shMutation.mutate({ telegram: { label: e.target.value } })}
disabled={shMutation.isPending}
style={{ width: 240 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label style={{ width: 90 }}>TG deeplink:</label>
<input
type="text"
defaultValue={shData?.telegram?.deeplink ?? ''}
onBlur={e => shMutation.mutate({ telegram: { deeplink: e.target.value } })}
disabled={shMutation.isPending}
style={{ width: 360 }}
placeholder="https://t.me/..."
/>
</div>
{shMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)
}
// Local helper — JSON-validated textarea editor for one mode's tier list. Keeps the
// editing UX simple (paste JSON, hit Save) without forcing per-row form widgets.
function PricingTierEditor({ mode, tiers, onSave, isPending }) {
const initial = JSON.stringify(tiers, null, 2)
const [draft, setDraft] = useState(initial)
const [error, setError] = useState(null)
// Reset draft when the upstream tiers change (e.g. after a successful save).
useEffect(() => {
setDraft(JSON.stringify(tiers, null, 2))
setError(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(tiers)])
const handleSave = () => {
try {
const parsed = JSON.parse(draft)
if (!Array.isArray(parsed)) throw new Error('expected an array')
for (const t of parsed) {
if (typeof t.id !== 'string' || typeof t.minutes !== 'number' || typeof t.price_idr !== 'number') {
throw new Error('each tier needs id (string), minutes (number), price_idr (number)')
}
}
setError(null)
onSave(parsed)
} catch (e) {
setError(String(e.message || e))
}
}
return (
<div style={{ marginBottom: 16 }}>
<h3 style={{ margin: '12px 0 4px' }}>{mode === 'chat' ? 'Chat tiers' : 'Voice call tiers'}</h3>
<textarea
value={draft}
onChange={e => setDraft(e.target.value)}
rows={10}
style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }}
disabled={isPending}
/>
<div style={{ marginTop: 4 }}>
<button onClick={handleSave} disabled={isPending} type="button">Simpan tier {mode}</button>
{error && <span style={{ color: 'red', marginLeft: 8 }}>{error}</span>}
</div>
</div>
)
}