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:
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
449
control_center/src/pages/payment-catalog/PaymentCatalogPage.jsx
Normal file
449
control_center/src/pages/payment-catalog/PaymentCatalogPage.jsx
Normal 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' }),
|
||||
})
|
||||
Reference in New Issue
Block a user