Files
halobestie-clone/control_center/src/components/Layout.jsx
Ramadhan Sjamsani 2c95fd040d Phase 5.x payment revamp + Xendit Stage-8 prep
- Backend wraps idn-finlogos npm at /assets/payment-icons/<slug>.svg with
  1y immutable cache. Mobile drops bundled SVGs (only placeholder remains)
  and fetches via flutter_cache_manager. payment_methods.icon is now a
  CSV of slugs; catalog emits icon_urls[]. CARDS tile renders Visa + MC +
  JCB side by side.
- Per-method min/max amount bounds (BIGINT, nullable). Picker greys out
  out-of-range tiles with subtitle; backend gates with INVALID_PAYMENT_AMOUNT
  (422). Defense in depth against stale-catalog clients.
- Xendit channel codes corrected from authoritative docs
  (BCA_VA -> BCA_VIRTUAL_ACCOUNT, CREDIT_CARD -> CARDS, ovo -> ovo-new,
  shopeepay -> shopee-pay, ...). 18 methods x 5 groups seeded with
  Xendit-published per-channel min/max.
- Re-runnable seed (ON CONFLICT DO NOTHING on payment_code + new unique
  index on group name). Operator CC edits never clobbered across re-runs.
  One-shot reset + inspect scripts under backend/.dev/.
- Customer redirect HTML pages at /payment/return/{success,failure},
  brand-styled with "Buka HaloBestie" CTA firing halobestie:// deeplink.
  URL scheme registered on Android (intent-filter w/ BROWSABLE on
  MainActivity) and iOS (CFBundleURLTypes). Waiting-payment poller still
  owns confirmation; deeplink just brings the activity to foreground.
- Control center payment-catalog page: min/max inputs + columns. Other
  CC pages restyled with new theme tokens (separate work, bundled here).

169/169 backend tests pass. See requirement/phase5-payment-revamp-2026-05-27.md
for the full revamp doc. Stage 8 (E2E) still pending: webhook URL routing
decision + two client_app follow-ups (legacy /chat/request removal,
extension Custom Tab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:33:51 +08:00

196 lines
5.6 KiB
JavaScript

import { useState } from 'react'
import { Outlet, NavLink } from 'react-router-dom'
import { useAuth } from '../core/auth/AuthContext'
import { apiClient } from '../core/api/api-client'
import HBLogo from './ui/HBLogo'
const NAV_ITEMS = [
{ to: '/dashboard', label: 'Dashboard' },
{ to: '/mitras', label: 'Mitra' },
{ to: '/sessions', label: 'Sesi' },
{ to: '/failed-pairings', label: 'Failed Pairings' },
{ to: '/users', label: 'Users' },
{ to: '/mitra-activity', label: 'Aktivitas Mitra' },
{ to: '/payment-catalog', label: 'Payment Catalog' },
{ to: '/settings', label: 'Settings' },
]
const PasswordChangeForm = ({ onDone }) => {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const submit = async (e) => {
e.preventDefault()
setError('')
setSaving(true)
try {
await apiClient.patch('/internal/control-center-users/me/password', {
current_password: current,
new_password: next,
})
setSuccess(true)
setCurrent('')
setNext('')
} catch (err) {
const code = err?.response?.data?.error?.code
const msg = err?.response?.data?.error?.message
if (code === 'INVALID_CREDENTIALS') setError('Password saat ini salah.')
else if (code?.startsWith('PASSWORD_')) setError(msg || 'Password tidak memenuhi syarat.')
else setError('Gagal mengubah password.')
} finally {
setSaving(false)
}
}
return (
<form onSubmit={submit} className="hb-card" style={{ marginTop: 10, padding: 14 }}>
<input
type="password"
placeholder="Password lama"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
style={{ marginBottom: 8 }}
/>
<input
type="password"
placeholder="Password baru (min 8, huruf besar/kecil + angka)"
value={next}
onChange={(e) => setNext(e.target.value)}
required
minLength={8}
style={{ marginBottom: 8 }}
/>
{error && <p className="hb-error">{error}</p>}
{success && <p className="hb-success">Password berhasil diubah.</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" disabled={saving} style={{ flex: 1 }}>
{saving ? 'Menyimpan…' : 'Simpan'}
</button>
<button type="button" className="hb-btn-secondary" onClick={onDone}>
Tutup
</button>
</div>
</form>
)
}
export default function Layout() {
const { user, logout } = useAuth()
const [showPwForm, setShowPwForm] = useState(false)
return (
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--hb-bg)' }}>
<nav
style={{
width: 260,
padding: '24px 20px',
background: 'var(--hb-surface)',
borderRight: '1px solid var(--hb-border)',
display: 'flex',
flexDirection: 'column',
gap: 24,
position: 'sticky',
top: 0,
height: '100vh',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<HBLogo size={44} />
<div style={{ lineHeight: 1.15 }}>
<div
style={{
fontFamily: 'var(--hb-font-display)',
fontWeight: 700,
fontSize: 17,
color: 'var(--hb-ink)',
letterSpacing: '-0.02em',
}}
>
Halo Bestie
</div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--hb-brand-dark)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
Control Center
</div>
</div>
</div>
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: 4,
flex: 1,
}}
>
{NAV_ITEMS.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) => `hb-nav-link${isActive ? ' active' : ''}`}
>
{item.label}
</NavLink>
</li>
))}
</ul>
<div
style={{
paddingTop: 16,
borderTop: '1px solid var(--hb-border)',
}}
>
<p
className="hb-soft"
style={{
fontSize: 12,
margin: '0 0 10px',
wordBreak: 'break-all',
}}
>
{user?.email}
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="hb-btn-ghost"
onClick={() => setShowPwForm((v) => !v)}
style={{ flex: 1, minHeight: 36, fontSize: 13 }}
>
Ganti password
</button>
<button
type="button"
className="hb-btn-secondary"
onClick={logout}
style={{ minHeight: 36, fontSize: 13 }}
>
Logout
</button>
</div>
{showPwForm && <PasswordChangeForm onDone={() => setShowPwForm(false)} />}
</div>
</nav>
<main style={{ flex: 1, padding: '28px 32px', minWidth: 0 }}>
<Outlet />
</main>
</div>
)
}