Payment catalog (Phase 5.x — see requirement/phase5-payment-catalog-plan.md):
- New tables payment_method_groups + payment_methods with seed (3 groups,
10 methods; GoPay seeded inactive pending Xendit channel confirmation).
- payment-catalog.service.js with two-layer cache (60s in-process + 1h
Valkey) and config:invalidate pub/sub fanout. Mutator API + casing-
tolerant findActiveMethodByCode for downstream validation.
- App-facing GET /api/client/payment-methods returns pre-grouped JSON,
active-only, empty groups dropped server-side.
- POST /api/client/payment-requests now validates `method` against the
catalog (INVALID_PAYMENT_METHOD 422) and stamps
product_metadata.preferred_payment_code (upper-cased).
- Control-center /internal/payment-{groups,methods}{,/:id,/reorder}
endpoints (full CRUD + idempotent reorder). New Payment Catalog page
wired into the CC nav.
- Customer app renders the catalog as collapsible groups (first expanded)
via paymentCatalogProvider; QRIS-only hardcoded fallback on 5xx so
checkout never hard-fails. Replaces the hardcoded _PayMethod enum.
- 10 brand SVGs (~63KB) bundled in client_app/assets/payment_icons/ from
github.com/hafidznoor/idn-finlogos. Xendit's per-channel media-asset
pages were planned but found decommissioned during implementation —
switched to idn-finlogos with the standard "channels-we-accept"
trademark posture. See assets/payment_icons/README.md for the workflow
to add new methods.
- 16 vitest cases covering the service + cache; full backend suite green
(162/162).
Customer-app splash + register polish:
- Splash rewritten per figma S1: warm vertical gradient, two ImageFiltered
radial orbs, 96×96 rounded-square logo tile, "HaloBestie" + "kamu gak
harus ngerasain ini sendirian." Self-driving navigation via context.go
after a 2.5s post-frame timer (native Android splash burns ~1-1.5s
before Flutter paints — 1s timer yielded near-zero visible duration).
Router early-returns null for isSplash so it never moves us off /splash
on its own.
- 3-page onboarding carousel removed: user clarified the new splash
REPLACES that carousel. Dropped /onboarding route, OnboardingScreen,
onboardingDoneProvider + gating, dead splash_{1,2,3}.png + the
splash_chat_hebat.png Flutter asset. Phase 4 /onboarding/* subroutes
untouched; Android-native launch_background drawable left alone.
- Register screen (login-by-phone) polished: circular pink back button +
72×72 logo badge (same brandLogoBg pink as splash, Transform.scale 1.4
to fill the tile). Step-dots indicator removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
450 lines
18 KiB
JavaScript
450 lines
18 KiB
JavaScript
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { apiClient } from '../../core/api/api-client'
|
|
|
|
// ─────────────────────────── data fetching ───────────────────────────
|
|
|
|
const fetchGroups = async () => {
|
|
const res = await apiClient.get('/internal/payment-groups')
|
|
return res.data.data.groups
|
|
}
|
|
|
|
const fetchMethods = async () => {
|
|
const res = await apiClient.get('/internal/payment-methods')
|
|
return res.data.data.methods
|
|
}
|
|
|
|
const errorMessage = (err) => {
|
|
const code = err?.response?.data?.error?.code
|
|
const msg = err?.response?.data?.error?.message
|
|
if (code === 'CONFLICT') return msg || 'Konflik data.'
|
|
if (code === 'VALIDATION') return msg || 'Input tidak valid.'
|
|
if (code === 'NOT_FOUND') return msg || 'Data tidak ditemukan.'
|
|
return msg || 'Gagal menyimpan.'
|
|
}
|
|
|
|
// ─────────────────────────── page ───────────────────────────
|
|
|
|
export default function PaymentCatalogPage() {
|
|
const qc = useQueryClient()
|
|
const groupsQ = useQuery({ queryKey: ['payment-groups'], queryFn: fetchGroups })
|
|
const methodsQ = useQuery({ queryKey: ['payment-methods'], queryFn: fetchMethods })
|
|
|
|
const [selectedGroupId, setSelectedGroupId] = useState(null)
|
|
const [groupForm, setGroupForm] = useState(null) // null | {id?, name, display_order, is_active}
|
|
const [methodForm, setMethodForm] = useState(null)
|
|
const [error, setError] = useState(null)
|
|
|
|
const invalidate = () => {
|
|
qc.invalidateQueries({ queryKey: ['payment-groups'] })
|
|
qc.invalidateQueries({ queryKey: ['payment-methods'] })
|
|
}
|
|
|
|
// ─────────────── mutations ───────────────
|
|
|
|
const groupCreate = useMutation({
|
|
mutationFn: (body) => apiClient.post('/internal/payment-groups', body),
|
|
onSuccess: () => { invalidate(); setGroupForm(null); setError(null) },
|
|
onError: (e) => setError(errorMessage(e)),
|
|
})
|
|
const groupUpdate = useMutation({
|
|
mutationFn: ({ id, body }) => apiClient.patch(`/internal/payment-groups/${id}`, body),
|
|
onSuccess: () => { invalidate(); setGroupForm(null); setError(null) },
|
|
onError: (e) => setError(errorMessage(e)),
|
|
})
|
|
const groupDelete = useMutation({
|
|
mutationFn: (id) => apiClient.delete(`/internal/payment-groups/${id}`),
|
|
onSuccess: () => { invalidate(); setError(null) },
|
|
onError: (e) => setError(errorMessage(e)),
|
|
})
|
|
const groupsReorder = useMutation({
|
|
mutationFn: (orderedIds) => apiClient.post('/internal/payment-groups/reorder', { ordered_ids: orderedIds }),
|
|
onSuccess: invalidate,
|
|
onError: (e) => setError(errorMessage(e)),
|
|
})
|
|
|
|
const methodCreate = useMutation({
|
|
mutationFn: (body) => apiClient.post('/internal/payment-methods', body),
|
|
onSuccess: () => { invalidate(); setMethodForm(null); setError(null) },
|
|
onError: (e) => setError(errorMessage(e)),
|
|
})
|
|
const methodUpdate = useMutation({
|
|
mutationFn: ({ id, body }) => apiClient.patch(`/internal/payment-methods/${id}`, body),
|
|
onSuccess: () => { invalidate(); setMethodForm(null); setError(null) },
|
|
onError: (e) => setError(errorMessage(e)),
|
|
})
|
|
const methodDelete = useMutation({
|
|
mutationFn: (id) => apiClient.delete(`/internal/payment-methods/${id}`),
|
|
onSuccess: () => { invalidate(); setError(null) },
|
|
onError: (e) => setError(errorMessage(e)),
|
|
})
|
|
const methodsReorder = useMutation({
|
|
mutationFn: (orderedIds) => apiClient.post('/internal/payment-methods/reorder', { ordered_ids: orderedIds }),
|
|
onSuccess: invalidate,
|
|
onError: (e) => setError(errorMessage(e)),
|
|
})
|
|
|
|
// ─────────────── helpers ───────────────
|
|
|
|
const moveGroup = (id, delta) => {
|
|
const ordered = (groupsQ.data ?? []).map((g) => g.id)
|
|
const idx = ordered.indexOf(id)
|
|
const newIdx = idx + delta
|
|
if (idx < 0 || newIdx < 0 || newIdx >= ordered.length) return
|
|
;[ordered[idx], ordered[newIdx]] = [ordered[newIdx], ordered[idx]]
|
|
groupsReorder.mutate(ordered)
|
|
}
|
|
|
|
const moveMethod = (id, delta, groupId) => {
|
|
const inGroup = (methodsQ.data ?? [])
|
|
.filter((m) => m.group_id === groupId)
|
|
.map((m) => m.id)
|
|
const idx = inGroup.indexOf(id)
|
|
const newIdx = idx + delta
|
|
if (idx < 0 || newIdx < 0 || newIdx >= inGroup.length) return
|
|
;[inGroup[idx], inGroup[newIdx]] = [inGroup[newIdx], inGroup[idx]]
|
|
methodsReorder.mutate(inGroup)
|
|
}
|
|
|
|
if (groupsQ.isLoading || methodsQ.isLoading) return <div>Loading…</div>
|
|
if (groupsQ.error || methodsQ.error) return <div>Failed to load payment catalog.</div>
|
|
|
|
const groups = groupsQ.data ?? []
|
|
const methods = methodsQ.data ?? []
|
|
const filteredMethods = selectedGroupId
|
|
? methods.filter((m) => m.group_id === selectedGroupId)
|
|
: methods
|
|
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<h1 style={{ marginBottom: 8 }}>Payment Catalog</h1>
|
|
<p style={{ color: '#666', marginBottom: 24, fontSize: 14 }}>
|
|
Groups and methods rendered by the customer app on the "cara bayar"
|
|
screen. Reorder controls the visible order. <code>payment_code</code> must
|
|
match the Xendit channel code exactly (uppercase, e.g. <code>OVO</code>,
|
|
<code>BCA_VA</code>).
|
|
</p>
|
|
|
|
{error && (
|
|
<div style={{
|
|
background: '#FFE6E6',
|
|
border: '1px solid #D86B6B',
|
|
color: '#8B2D2D',
|
|
padding: 12,
|
|
borderRadius: 8,
|
|
marginBottom: 16,
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* ───────────── groups ───────────── */}
|
|
<section style={{ marginBottom: 32 }}>
|
|
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
<h2 style={{ margin: 0 }}>Groups</h2>
|
|
<button
|
|
onClick={() => setGroupForm({ name: '', display_order: groups.length, is_active: true })}
|
|
style={btnStyle('primary')}
|
|
>+ Add group</button>
|
|
</header>
|
|
<table style={tableStyle}>
|
|
<thead>
|
|
<tr>
|
|
<th style={thStyle}>Order</th>
|
|
<th style={thStyle}>Name</th>
|
|
<th style={thStyle}>Active</th>
|
|
<th style={thStyle}>Methods</th>
|
|
<th style={thStyle}>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{groups.map((g, i) => {
|
|
const methodsInGroup = methods.filter((m) => m.group_id === g.id).length
|
|
return (
|
|
<tr key={g.id} style={selectedGroupId === g.id ? { background: '#FBEFF3' } : undefined}>
|
|
<td style={tdStyle}>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button disabled={i === 0} onClick={() => moveGroup(g.id, -1)} style={btnStyle('ghost')}>↑</button>
|
|
<button disabled={i === groups.length - 1} onClick={() => moveGroup(g.id, +1)} style={btnStyle('ghost')}>↓</button>
|
|
<span style={{ marginLeft: 8, color: '#666' }}>{g.display_order}</span>
|
|
</div>
|
|
</td>
|
|
<td style={tdStyle}><strong>{g.name}</strong></td>
|
|
<td style={tdStyle}>{g.is_active ? '✓' : '✗'}</td>
|
|
<td style={tdStyle}>{methodsInGroup}</td>
|
|
<td style={tdStyle}>
|
|
<button onClick={() => setSelectedGroupId(g.id === selectedGroupId ? null : g.id)} style={btnStyle('ghost')}>
|
|
{selectedGroupId === g.id ? 'Hide methods' : 'Show methods'}
|
|
</button>
|
|
<button onClick={() => setGroupForm(g)} style={btnStyle('ghost')}>Edit</button>
|
|
<button
|
|
onClick={() => {
|
|
if (!confirm(`Delete group "${g.name}"?`)) return
|
|
groupDelete.mutate(g.id)
|
|
}}
|
|
style={btnStyle('danger')}
|
|
disabled={methodsInGroup > 0}
|
|
title={methodsInGroup > 0 ? 'Move methods out first' : ''}
|
|
>Delete</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
{groups.length === 0 && (
|
|
<tr><td colSpan={5} style={{ ...tdStyle, color: '#999' }}>No groups yet.</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
{/* ───────────── methods ───────────── */}
|
|
<section>
|
|
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
<h2 style={{ margin: 0 }}>
|
|
Methods{selectedGroupId && groups.find(g => g.id === selectedGroupId)
|
|
? ` — ${groups.find(g => g.id === selectedGroupId).name}`
|
|
: ''}
|
|
</h2>
|
|
<button
|
|
disabled={groups.length === 0}
|
|
onClick={() => setMethodForm({
|
|
group_id: selectedGroupId ?? groups[0]?.id,
|
|
display_name: '',
|
|
payment_code: '',
|
|
icon: '',
|
|
display_order: filteredMethods.length,
|
|
is_active: true,
|
|
})}
|
|
style={btnStyle('primary')}
|
|
>+ Add method</button>
|
|
</header>
|
|
<table style={tableStyle}>
|
|
<thead>
|
|
<tr>
|
|
<th style={thStyle}>Order</th>
|
|
<th style={thStyle}>Group</th>
|
|
<th style={thStyle}>Display name</th>
|
|
<th style={thStyle}>Code</th>
|
|
<th style={thStyle}>Icon slug</th>
|
|
<th style={thStyle}>Active</th>
|
|
<th style={thStyle}>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredMethods.map((m, i, arr) => (
|
|
<tr key={m.id}>
|
|
<td style={tdStyle}>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button disabled={i === 0} onClick={() => moveMethod(m.id, -1, m.group_id)} style={btnStyle('ghost')}>↑</button>
|
|
<button disabled={i === arr.length - 1} onClick={() => moveMethod(m.id, +1, m.group_id)} style={btnStyle('ghost')}>↓</button>
|
|
<span style={{ marginLeft: 8, color: '#666' }}>{m.display_order}</span>
|
|
</div>
|
|
</td>
|
|
<td style={tdStyle}>{groups.find((g) => g.id === m.group_id)?.name ?? '—'}</td>
|
|
<td style={tdStyle}><strong>{m.display_name}</strong></td>
|
|
<td style={tdStyle}><code>{m.payment_code}</code></td>
|
|
<td style={tdStyle}>{m.icon || <span style={{ color: '#999' }}>—</span>}</td>
|
|
<td style={tdStyle}>{m.is_active ? '✓' : '✗'}</td>
|
|
<td style={tdStyle}>
|
|
<button onClick={() => setMethodForm(m)} style={btnStyle('ghost')}>Edit</button>
|
|
<button
|
|
onClick={() => {
|
|
if (!confirm(`Delete "${m.display_name}"?`)) return
|
|
methodDelete.mutate(m.id)
|
|
}}
|
|
style={btnStyle('danger')}
|
|
>Delete</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{filteredMethods.length === 0 && (
|
|
<tr><td colSpan={7} style={{ ...tdStyle, color: '#999' }}>
|
|
{selectedGroupId ? 'No methods in this group yet.' : 'No methods yet.'}
|
|
</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
{/* ───────────── modals ───────────── */}
|
|
{groupForm && (
|
|
<GroupModal
|
|
form={groupForm}
|
|
onChange={setGroupForm}
|
|
onClose={() => { setGroupForm(null); setError(null) }}
|
|
onSubmit={() => {
|
|
const body = {
|
|
name: groupForm.name?.trim(),
|
|
display_order: Number(groupForm.display_order) || 0,
|
|
is_active: !!groupForm.is_active,
|
|
}
|
|
if (groupForm.id) {
|
|
groupUpdate.mutate({ id: groupForm.id, body })
|
|
} else {
|
|
groupCreate.mutate(body)
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{methodForm && (
|
|
<MethodModal
|
|
form={methodForm}
|
|
groups={groups}
|
|
onChange={setMethodForm}
|
|
onClose={() => { setMethodForm(null); setError(null) }}
|
|
onSubmit={() => {
|
|
const body = {
|
|
group_id: methodForm.group_id,
|
|
display_name: methodForm.display_name?.trim(),
|
|
payment_code: methodForm.payment_code?.trim()?.toUpperCase(),
|
|
display_order: Number(methodForm.display_order) || 0,
|
|
icon: methodForm.icon?.trim() || null,
|
|
is_active: !!methodForm.is_active,
|
|
}
|
|
if (methodForm.id) {
|
|
methodUpdate.mutate({ id: methodForm.id, body })
|
|
} else {
|
|
methodCreate.mutate(body)
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─────────────────────────── modals ───────────────────────────
|
|
|
|
const GroupModal = ({ form, onChange, onSubmit, onClose }) => (
|
|
<Modal title={form.id ? 'Edit group' : 'New group'} onClose={onClose}>
|
|
<Field label="Name">
|
|
<input
|
|
value={form.name ?? ''}
|
|
onChange={(e) => onChange({ ...form, name: e.target.value })}
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="Display order">
|
|
<input
|
|
type="number"
|
|
value={form.display_order ?? 0}
|
|
onChange={(e) => onChange({ ...form, display_order: e.target.value })}
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="Active">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!form.is_active}
|
|
onChange={(e) => onChange({ ...form, is_active: e.target.checked })}
|
|
/>
|
|
</Field>
|
|
<Footer onClose={onClose} onSubmit={onSubmit} />
|
|
</Modal>
|
|
)
|
|
|
|
const MethodModal = ({ form, groups, onChange, onSubmit, onClose }) => (
|
|
<Modal title={form.id ? 'Edit method' : 'New method'} onClose={onClose}>
|
|
<Field label="Group">
|
|
<select
|
|
value={form.group_id ?? ''}
|
|
onChange={(e) => onChange({ ...form, group_id: e.target.value })}
|
|
style={inputStyle}
|
|
>
|
|
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="Display name">
|
|
<input
|
|
value={form.display_name ?? ''}
|
|
onChange={(e) => onChange({ ...form, display_name: e.target.value })}
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="Payment code (Xendit channel)">
|
|
<input
|
|
value={form.payment_code ?? ''}
|
|
onChange={(e) => onChange({ ...form, payment_code: e.target.value })}
|
|
placeholder="OVO, DANA, QRIS, BCA_VA, …"
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="Icon slug (file in client_app/assets/payment_icons/)">
|
|
<input
|
|
value={form.icon ?? ''}
|
|
onChange={(e) => onChange({ ...form, icon: e.target.value })}
|
|
placeholder="ovo, dana, qris, …"
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="Display order">
|
|
<input
|
|
type="number"
|
|
value={form.display_order ?? 0}
|
|
onChange={(e) => onChange({ ...form, display_order: e.target.value })}
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="Active">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!form.is_active}
|
|
onChange={(e) => onChange({ ...form, is_active: e.target.checked })}
|
|
/>
|
|
</Field>
|
|
<Footer onClose={onClose} onSubmit={onSubmit} />
|
|
</Modal>
|
|
)
|
|
|
|
const Modal = ({ title, onClose, children }) => (
|
|
<div style={{
|
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
|
|
}} onClick={onClose}>
|
|
<div
|
|
style={{ background: 'white', padding: 24, borderRadius: 12, minWidth: 420, maxWidth: 560 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 style={{ marginTop: 0 }}>{title}</h3>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const Field = ({ label, children }) => (
|
|
<div style={{ marginBottom: 12, display: 'flex', flexDirection: 'column' }}>
|
|
<label style={{ fontSize: 12, fontWeight: 600, color: '#444', marginBottom: 4 }}>{label}</label>
|
|
{children}
|
|
</div>
|
|
)
|
|
|
|
const Footer = ({ onClose, onSubmit }) => (
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
|
<button onClick={onClose} style={btnStyle('ghost')}>Cancel</button>
|
|
<button onClick={onSubmit} style={btnStyle('primary')}>Save</button>
|
|
</div>
|
|
)
|
|
|
|
// ─────────────────────────── inline styles ───────────────────────────
|
|
|
|
const tableStyle = { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: 8, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }
|
|
const thStyle = { textAlign: 'left', padding: 10, fontSize: 12, fontWeight: 600, color: '#444', background: '#FAFAFA', borderBottom: '1px solid #EEE' }
|
|
const tdStyle = { padding: 10, fontSize: 13, borderBottom: '1px solid #F2F2F2' }
|
|
const inputStyle = { padding: '8px 10px', borderRadius: 6, border: '1px solid #DDD', fontSize: 14 }
|
|
|
|
const btnStyle = (variant) => ({
|
|
padding: variant === 'ghost' ? '4px 8px' : '6px 12px',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
marginRight: 4,
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
...(variant === 'primary'
|
|
? { background: '#E17A9D', color: 'white' }
|
|
: variant === 'danger'
|
|
? { background: '#fff', color: '#D86B6B', border: '1px solid #D86B6B' }
|
|
: { background: '#F0F0F0', color: '#333' }),
|
|
})
|