Phase 5.x payment catalog + customer-app splash/register polish

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>
This commit is contained in:
2026-05-26 23:06:46 +08:00
parent d60c048776
commit 1f6d8e09ae
39 changed files with 2634 additions and 370 deletions

View File

@@ -8,6 +8,7 @@ import UsersPage from './pages/users/UsersPage'
import SettingsPage from './pages/settings/SettingsPage'
import MitraActivityPage from './pages/mitra-activity/MitraActivityPage'
import FailedPairingsPage from './pages/failed-pairings/FailedPairingsPage'
import PaymentCatalogPage from './pages/payment-catalog/PaymentCatalogPage'
import Layout from './components/Layout'
const ProtectedRoute = ({ children }) => {
@@ -28,6 +29,7 @@ export default function App() {
<Route path="failed-pairings" element={<FailedPairingsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="payment-catalog" element={<PaymentCatalogPage />} />
<Route path="mitra-activity" element={<MitraActivityPage />} />
</Route>
</Routes>

View File

@@ -66,6 +66,7 @@ export default function Layout() {
<li><NavLink to="/failed-pairings">Failed Pairings</NavLink></li>
<li><NavLink to="/users">Users</NavLink></li>
<li><NavLink to="/mitra-activity">Aktivitas Mitra</NavLink></li>
<li><NavLink to="/payment-catalog">Payment Catalog</NavLink></li>
<li><NavLink to="/settings">Settings</NavLink></li>
</ul>
<div style={{ marginTop: 'auto', paddingTop: 16 }}>

View File

@@ -0,0 +1,449 @@
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' }),
})