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>
This commit is contained in:
2026-05-27 21:33:51 +08:00
parent 1f6d8e09ae
commit 2c95fd040d
53 changed files with 2389 additions and 832 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View File

@@ -2,6 +2,18 @@ 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('')
@@ -34,18 +46,33 @@ const PasswordChangeForm = ({ onDone }) => {
}
return (
<form onSubmit={submit} style={{ padding: 8, border: '1px solid #eee', marginTop: 8 }}>
<input type="password" placeholder="Password lama" value={current}
onChange={e => setCurrent(e.target.value)} required
style={{ display: 'block', width: '100%', marginBottom: 6 }} />
<input type="password" placeholder="Password baru (min 8, huruf besar/kecil + angka)" value={next}
onChange={e => setNext(e.target.value)} required minLength={8}
style={{ display: 'block', width: '100%', marginBottom: 6 }} />
{error && <p style={{ color: 'red', margin: '4px 0', fontSize: 12 }}>{error}</p>}
{success && <p style={{ color: 'green', margin: '4px 0', fontSize: 12 }}>Password berhasil diubah.</p>}
<div style={{ display: 'flex', gap: 6 }}>
<button type="submit" disabled={saving}>{saving ? '...' : 'Simpan'}</button>
<button type="button" onClick={onDone}>Tutup</button>
<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>
)
@@ -56,27 +83,111 @@ export default function Layout() {
const [showPwForm, setShowPwForm] = useState(false)
return (
<div style={{ display: 'flex', minHeight: '100vh' }}>
<nav style={{ width: 220, borderRight: '1px solid #eee', padding: 16 }}>
<h2>Control Center</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li><NavLink to="/dashboard">Dashboard</NavLink></li>
<li><NavLink to="/mitras">Mitra</NavLink></li>
<li><NavLink to="/sessions">Sesi</NavLink></li>
<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>
<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={{ marginTop: 'auto', paddingTop: 16 }}>
<p style={{ fontSize: 12 }}>{user?.email}</p>
<button onClick={() => setShowPwForm(v => !v)} style={{ marginRight: 6 }}>Ganti password</button>
<button onClick={logout}>Logout</button>
<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: 24 }}>
<main style={{ flex: 1, padding: '28px 32px', minWidth: 0 }}>
<Outlet />
</main>
</div>

View File

@@ -0,0 +1,36 @@
// Brand mark — mirrors client_app/lib/features/splash/splash_screen.dart:
// pink #FF699F rounded-square tile, logo image scaled to 1.4× (the source PNG
// has ~25% internal whitespace), soft shadow.
import logoUrl from '../../assets/logo.png'
export default function HBLogo({ size = 56, radius, shadow = true, style = {} }) {
const r = radius ?? Math.round(size * 0.25) // matches Flutter radius:24 on 96px
return (
<div
style={{
width: size,
height: size,
borderRadius: r,
background: '#FF699F',
overflow: 'hidden',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: shadow ? 'var(--hb-shadow-soft)' : 'none',
...style,
}}
>
<img
src={logoUrl}
alt="Halo Bestie"
draggable={false}
style={{
width: '140%',
height: '140%',
objectFit: 'cover',
}}
/>
</div>
)
}

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './core/auth/AuthContext'
import './theme/global.css'
import App from './App'
const queryClient = new QueryClient()

View File

@@ -6,6 +6,67 @@ const fetchDashboardStats = async () => {
return res.data.data
}
const StatCard = ({ label, value, suffix, accent, tone = 'neutral' }) => {
const toneStyles = {
neutral: { bg: 'var(--hb-surface)', accent: accent || 'var(--hb-brand-dark)' },
brand: { bg: 'var(--hb-brand-softer)', accent: 'var(--hb-brand-dark)' },
success: { bg: '#E9F4ED', accent: 'var(--hb-success)' },
warn: { bg: 'var(--hb-accent-soft)', accent: '#A06B22' },
sensitive: { bg: '#FFF6E5', accent: '#B88900' },
}[tone]
return (
<div
className="hb-card"
style={{
padding: 22,
background: toneStyles.bg,
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: 'var(--hb-ink-soft)',
letterSpacing: '-0.005em',
}}
>
{label}
</div>
<div
style={{
fontFamily: 'var(--hb-font-display)',
fontSize: 34,
fontWeight: 700,
color: toneStyles.accent,
letterSpacing: '-0.02em',
lineHeight: 1.1,
display: 'flex',
alignItems: 'baseline',
gap: 8,
}}
>
{value}
{suffix && (
<span
style={{
fontSize: 14,
fontFamily: 'var(--hb-font-body)',
fontWeight: 500,
color: 'var(--hb-ink-soft)',
}}
>
{suffix}
</span>
)}
</div>
</div>
)
}
export default function DashboardPage() {
const { data, isLoading } = useQuery({
queryKey: ['dashboard-stats'],
@@ -13,59 +74,57 @@ export default function DashboardPage() {
refetchInterval: 10000,
})
if (isLoading) return <div>Loading...</div>
if (isLoading) return <div className="hb-soft">Loading</div>
return (
<div>
<h1>Dashboard</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 32 }}>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#2563eb' }}>{data?.active_chats ?? 0}</div>
<div style={{ color: '#666' }}>Chat Aktif</div>
</div>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#16a34a' }}>{data?.online_mitras ?? 0}</div>
<div style={{ color: '#666' }}>Mitra Online</div>
</div>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#f59e0b' }}>{data?.pending_requests ?? 0}</div>
<div style={{ color: '#666' }}>Request Pending</div>
</div>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8, background: '#FFF8DF' }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#B88900' }}>
{data?.sensitive?.last_30d_sensitive ?? 0}
<span style={{ fontSize: 16, color: '#666', marginLeft: 8 }}>
({data?.sensitive?.last_30d_percent ?? 0}%)
</span>
</div>
<div style={{ color: '#666' }}>Sesi Sensitif (30 hari)</div>
<div style={{ color: '#888', fontSize: 12, marginTop: 4 }}>
Total semua waktu: {data?.sensitive?.total ?? 0}
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 16,
marginBottom: 32,
}}
>
<StatCard label="Chat Aktif" value={data?.active_chats ?? 0} tone="brand" />
<StatCard label="Mitra Online" value={data?.online_mitras ?? 0} tone="success" />
<StatCard label="Request Pending" value={data?.pending_requests ?? 0} tone="warn" />
<StatCard
label="Sesi Sensitif (30 hari)"
value={data?.sensitive?.last_30d_sensitive ?? 0}
suffix={`(${data?.sensitive?.last_30d_percent ?? 0}%)`}
tone="sensitive"
/>
</div>
{data?.sensitive && (
<p className="hb-muted" style={{ fontSize: 12, marginTop: -16, marginBottom: 24 }}>
Total sesi sensitif sepanjang waktu: {data.sensitive.total ?? 0}
</p>
)}
<h2>Customer per Mitra</h2>
{data?.customers_per_mitra?.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<table>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Sesi Aktif</th>
<th>Mitra</th>
<th>Sesi Aktif</th>
</tr>
</thead>
<tbody>
{data.customers_per_mitra.map((m) => (
<tr key={m.id}>
<td style={{ padding: 8 }}>{m.display_name}</td>
<td style={{ padding: 8 }}>{m.active_session_count}</td>
<td>{m.display_name}</td>
<td>{m.active_session_count}</td>
</tr>
))}
</tbody>
</table>
) : (
<p style={{ color: '#666' }}>Tidak ada mitra online.</p>
<p className="hb-soft">Tidak ada mitra online.</p>
)}
</div>
)

View File

@@ -98,12 +98,12 @@ export default function FailedPairingsPage() {
<div>
<h1>Failed Pairings</h1>
<div style={{ marginBottom: 16, padding: 12, border: '1px solid #eee', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="hb-card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<strong style={{ marginRight: 8 }}>Cause:</strong>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 4 }}>
<strong style={{ marginRight: 8, color: 'var(--hb-ink)' }}>Cause:</strong>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
{CAUSE_OPTIONS.map((opt) => (
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13 }}>
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, margin: 0, color: 'var(--hb-ink)' }}>
<input
type="checkbox"
checked={selectedCauses.includes(opt.value)}
@@ -116,49 +116,51 @@ export default function FailedPairingsPage() {
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ fontSize: 13 }}>From:</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ fontSize: 13, margin: 0 }}>From:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
style={{ width: 'auto' }}
/>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ fontSize: 13 }}>To:</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ fontSize: 13, margin: 0 }}>To:</label>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
style={{ width: 'auto' }}
/>
</div>
<button onClick={clearFilters} style={{ fontSize: 12 }}>Clear filters</button>
<button className="hb-btn-ghost" onClick={clearFilters} style={{ fontSize: 12, minHeight: 32 }}>Clear filters</button>
</div>
</div>
{isLoading && <div>Loading...</div>}
{isError && <p style={{ color: 'red' }}>Gagal memuat data failed pairings.</p>}
{isLoading && <div className="hb-soft">Loading</div>}
{isError && <p className="hb-error">Gagal memuat data failed pairings.</p>}
{!isLoading && !isError && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<table>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Created</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Targeted Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Cause</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Amount</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Operator Action</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned By</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned At</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
<th>Created</th>
<th>Customer</th>
<th>Targeted Mitra</th>
<th>Cause</th>
<th>Amount</th>
<th>Operator Action</th>
<th>Actioned By</th>
<th>Actioned At</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={9} style={{ padding: 24, textAlign: 'center', color: '#666' }}>
<td colSpan={9} style={{ padding: 28, textAlign: 'center', color: 'var(--hb-ink-muted)' }}>
Belum ada data failed pairings.
</td>
</tr>
@@ -167,23 +169,24 @@ export default function FailedPairingsPage() {
const canAction = !row.operator_action
return (
<tr key={row.id}>
<td style={{ padding: 8 }}>{formatDateTime(row.created_at)}</td>
<td style={{ padding: 8 }}>{row.customer_call_name ?? '-'}</td>
<td style={{ padding: 8 }}>{row.targeted_mitra_call_name ?? '-'}</td>
<td style={{ padding: 8 }}>
<td>{formatDateTime(row.created_at)}</td>
<td>{row.customer_call_name ?? '-'}</td>
<td>{row.targeted_mitra_call_name ?? '-'}</td>
<td>
{PairingFailureCauseLabel[row.cause_tag] ?? row.cause_tag}
</td>
<td style={{ padding: 8 }}>{formatRupiah(row.amount)}</td>
<td style={{ padding: 8 }}>{operatorActionLabel(row)}</td>
<td style={{ padding: 8 }}>{row.actioned_by_name ?? '-'}</td>
<td style={{ padding: 8 }}>{formatDateTime(row.actioned_at)}</td>
<td style={{ padding: 8, position: 'relative' }}>
<td>{formatRupiah(row.amount)}</td>
<td>{operatorActionLabel(row)}</td>
<td>{row.actioned_by_name ?? '-'}</td>
<td>{formatDateTime(row.actioned_at)}</td>
<td style={{ position: 'relative' }}>
{canAction ? (
<>
<button
className="hb-btn-secondary"
onClick={() => setOpenMenuId(openMenuId === row.id ? null : row.id)}
disabled={actionMutation.isPending}
style={{ fontSize: 12 }}
style={{ fontSize: 12, minHeight: 32, padding: '6px 12px' }}
>
Action
</button>
@@ -192,11 +195,14 @@ export default function FailedPairingsPage() {
position: 'absolute',
right: 8,
top: '100%',
background: 'white',
border: '1px solid #ddd',
boxShadow: '0 2px 6px rgba(0,0,0,0.08)',
background: 'var(--hb-surface)',
border: '1px solid var(--hb-border)',
borderRadius: 'var(--hb-radius-md)',
boxShadow: 'var(--hb-shadow-card)',
zIndex: 10,
minWidth: 180,
minWidth: 200,
overflow: 'hidden',
marginTop: 4,
}}>
<button
style={menuItemStyle}
@@ -211,7 +217,7 @@ export default function FailedPairingsPage() {
Mark as credited
</button>
<button
style={menuItemStyle}
style={{ ...menuItemStyle, borderBottom: 'none' }}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.NO_ACTION })}
>
Mark as no-action
@@ -220,7 +226,7 @@ export default function FailedPairingsPage() {
)}
</>
) : (
<span style={{ color: '#999', fontSize: 12 }}></span>
<span className="hb-muted" style={{ fontSize: 12 }}></span>
)}
</td>
</tr>
@@ -229,16 +235,16 @@ export default function FailedPairingsPage() {
</tbody>
</table>
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center', alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Prev</button>
<span>Page {page} of {totalPages} ({total} total)</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
<div style={{ marginTop: 16, display: 'flex', gap: 12, justifyContent: 'center', alignItems: 'center' }}>
<button className="hb-btn-secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Prev</button>
<span className="hb-soft" style={{ fontSize: 13 }}>Page {page} of {totalPages} ({total} total)</span>
<button className="hb-btn-secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</>
)}
{actionMutation.isError && (
<p style={{ color: 'red', marginTop: 8 }}>Gagal menyimpan operator action.</p>
<p className="hb-error" style={{ marginTop: 8 }}>Gagal menyimpan operator action.</p>
)}
</div>
)
@@ -247,11 +253,16 @@ export default function FailedPairingsPage() {
const menuItemStyle = {
display: 'block',
width: '100%',
padding: '8px 12px',
background: 'white',
padding: '10px 14px',
background: 'var(--hb-surface)',
color: 'var(--hb-ink)',
border: 'none',
borderBottom: '1px solid #f0f0f0',
borderRadius: 0,
borderBottom: '1px solid var(--hb-border)',
textAlign: 'left',
cursor: 'pointer',
fontSize: 13,
minHeight: 36,
boxShadow: 'none',
fontWeight: 500,
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../core/auth/AuthContext'
import HBLogo from '../../components/ui/HBLogo'
const messageForError = (err) => {
const code = err?.response?.data?.error?.code
@@ -41,26 +42,93 @@ export default function LoginPage() {
}
}
if (authLoading) return <div style={{ padding: 24 }}>Loading...</div>
if (authLoading) {
return (
<div style={{ padding: 24, color: 'var(--hb-ink-soft)' }}>Loading</div>
)
}
return (
<div style={{ maxWidth: 360, margin: '100px auto', padding: 24 }}>
<h1>Halo Bestie</h1>
<h2>Control Center</h2>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="cc-login-email">Email</label>
<input id="cc-login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
<div
style={{
minHeight: '100vh',
background:
'radial-gradient(1100px 540px at 50% -120px, var(--hb-brand-softer), transparent 70%), var(--hb-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
}}
>
<div
className="hb-card"
style={{
width: '100%',
maxWidth: 380,
padding: 32,
boxShadow: 'var(--hb-shadow-card)',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
marginBottom: 24,
}}
>
<HBLogo size={72} />
<div style={{ textAlign: 'center' }}>
<h1 style={{ margin: 0, fontSize: 24 }}>Halo Bestie</h1>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--hb-brand-dark)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginTop: 4,
}}
>
Control Center
</div>
</div>
</div>
<div>
<label htmlFor="cc-login-password">Password</label>
<input id="cc-login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={loading} style={{ width: '100%' }}>
{loading ? 'Loading...' : 'Masuk'}
</button>
</form>
<form onSubmit={handleSubmit}>
<div className="hb-form-row">
<label htmlFor="cc-login-email">Email</label>
<input
id="cc-login-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
/>
</div>
<div className="hb-form-row">
<label htmlFor="cc-login-password">Password</label>
<input
id="cc-login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
{error && <p className="hb-error">{error}</p>}
<button
type="submit"
disabled={loading}
style={{ width: '100%', marginTop: 8, minHeight: 48, fontSize: 15 }}
>
{loading ? 'Memproses…' : 'Masuk'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -29,11 +29,11 @@ const fetchMitras = async () => {
const responseColor = (response) => {
switch (response) {
case 'accepted': return '#22c55e'
case 'declined': return '#ef4444'
case 'missed': return '#f97316'
case 'ignored': return '#9ca3af'
default: return '#6b7280'
case 'accepted': return 'var(--hb-success)'
case 'declined': return 'var(--hb-danger)'
case 'missed': return '#C97A2A'
case 'ignored': return 'var(--hb-ink-muted)'
default: return 'var(--hb-ink-soft)'
}
}
@@ -68,10 +68,10 @@ export default function MitraActivityPage() {
<div>
<h1>Aktivitas Mitra</h1>
<div style={{ display: 'flex', gap: 12, marginBottom: 24, flexWrap: 'wrap', alignItems: 'end' }}>
<div className="hb-card" style={{ display: 'flex', gap: 16, marginBottom: 24, flexWrap: 'wrap', alignItems: 'end' }}>
<div>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>Mitra</label>
<select value={mitraFilter} onChange={e => { setMitraFilter(e.target.value); setLogPage(1) }} style={{ padding: '6px 8px' }}>
<label>Mitra</label>
<select value={mitraFilter} onChange={e => { setMitraFilter(e.target.value); setLogPage(1) }} style={{ width: 'auto', minWidth: 200 }}>
<option value="">Semua Mitra</option>
{(mitras || []).map(m => (
<option key={m.id} value={m.id}>{m.display_name}</option>
@@ -79,32 +79,32 @@ export default function MitraActivityPage() {
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>Dari</label>
<input type="date" value={dateFrom} onChange={e => { setDateFrom(e.target.value); setLogPage(1) }} />
<label>Dari</label>
<input type="date" value={dateFrom} onChange={e => { setDateFrom(e.target.value); setLogPage(1) }} style={{ width: 'auto' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>Sampai</label>
<input type="date" value={dateTo} onChange={e => { setDateTo(e.target.value); setLogPage(1) }} />
<label>Sampai</label>
<input type="date" value={dateTo} onChange={e => { setDateTo(e.target.value); setLogPage(1) }} style={{ width: 'auto' }} />
</div>
</div>
<section style={{ marginBottom: 32 }}>
<h2>Ringkasan</h2>
{summaryLoading ? <p>Loading...</p> : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
{summaryLoading ? <p className="hb-soft">Loading</p> : (
<table>
<thead>
<tr style={{ borderBottom: '2px solid #ddd', textAlign: 'left' }}>
<th style={{ padding: 8 }}>Mitra</th>
<th style={{ padding: 8 }}>Total</th>
<th style={{ padding: 8 }}>Accepted</th>
<th style={{ padding: 8 }}>Rejected</th>
<th style={{ padding: 8 }}>Missed</th>
<th style={{ padding: 8 }}>Ignored</th>
<th style={{ padding: 8 }}>Rate (%)</th>
<th style={{ padding: 8 }}>Avg Response (s)</th>
<th style={{ padding: 8, background: '#FFF8DF' }}>Sensitif Total</th>
<th style={{ padding: 8, background: '#FFF8DF' }}>Sensitif Diterima</th>
<th style={{ padding: 8, background: '#FFF8DF' }}>Sensitif Rate (%)</th>
<tr>
<th>Mitra</th>
<th>Total</th>
<th>Accepted</th>
<th>Rejected</th>
<th>Missed</th>
<th>Ignored</th>
<th>Rate (%)</th>
<th>Avg Response (s)</th>
<th style={{ background: 'var(--hb-accent-soft)', color: '#8A5A20' }}>Sensitif Total</th>
<th style={{ background: 'var(--hb-accent-soft)', color: '#8A5A20' }}>Sensitif Diterima</th>
<th style={{ background: 'var(--hb-accent-soft)', color: '#8A5A20' }}>Sensitif Rate (%)</th>
</tr>
</thead>
<tbody>
@@ -113,25 +113,25 @@ export default function MitraActivityPage() {
const sensRate = s.sensitive_acceptance_rate != null ? Number(s.sensitive_acceptance_rate) : null
const flagSensRate = overall != null && sensRate != null && (overall - sensRate) >= 20
return (
<tr key={s.mitra_id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{s.mitra_display_name}</td>
<td style={{ padding: 8 }}>{s.total_requests}</td>
<td style={{ padding: 8, color: '#22c55e' }}>{s.accepted_count}</td>
<td style={{ padding: 8, color: '#ef4444' }}>{s.rejected_count}</td>
<td style={{ padding: 8, color: '#f97316' }}>{s.missed_count}</td>
<td style={{ padding: 8, color: '#9ca3af' }}>{s.ignored_count}</td>
<td style={{ padding: 8 }}>{s.acceptance_rate ?? '-'}%</td>
<td style={{ padding: 8 }}>{s.avg_response_time_seconds ?? '-'}</td>
<td style={{ padding: 8 }}>{s.sensitive_total || 0}</td>
<td style={{ padding: 8 }}>{s.sensitive_accepted || 0}</td>
<td style={{ padding: 8, color: flagSensRate ? '#ef4444' : undefined, fontWeight: flagSensRate ? 'bold' : undefined }}>
<tr key={s.mitra_id}>
<td>{s.mitra_display_name}</td>
<td>{s.total_requests}</td>
<td style={{ color: 'var(--hb-success)', fontWeight: 600 }}>{s.accepted_count}</td>
<td style={{ color: 'var(--hb-danger)', fontWeight: 600 }}>{s.rejected_count}</td>
<td style={{ color: '#C97A2A', fontWeight: 600 }}>{s.missed_count}</td>
<td className="hb-muted">{s.ignored_count}</td>
<td>{s.acceptance_rate ?? '-'}%</td>
<td>{s.avg_response_time_seconds ?? '-'}</td>
<td>{s.sensitive_total || 0}</td>
<td>{s.sensitive_accepted || 0}</td>
<td style={{ color: flagSensRate ? 'var(--hb-danger)' : undefined, fontWeight: flagSensRate ? 700 : undefined }}>
{(s.sensitive_total || 0) === 0 ? '—' : `${s.sensitive_acceptance_rate ?? 0}%`}
</td>
</tr>
)
})}
{(!summary || summary.length === 0) && (
<tr><td colSpan={11} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
<tr><td colSpan={11} style={{ padding: 24, textAlign: 'center', color: 'var(--hb-ink-muted)' }}>Tidak ada data</td></tr>
)}
</tbody>
</table>
@@ -140,54 +140,54 @@ export default function MitraActivityPage() {
<section>
<h2>Detail Log</h2>
{logLoading ? <p>Loading...</p> : (
{logLoading ? <p className="hb-soft">Loading</p> : (
<>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<table>
<thead>
<tr style={{ borderBottom: '2px solid #ddd', textAlign: 'left' }}>
<th style={{ padding: 8 }}>Mitra</th>
<th style={{ padding: 8 }}>Session</th>
<th style={{ padding: 8 }}>Topik</th>
<th style={{ padding: 8 }}>Response</th>
<th style={{ padding: 8 }}>Response Time (s)</th>
<th style={{ padding: 8 }}>Active Sessions</th>
<th style={{ padding: 8 }}>Notified At</th>
<th style={{ padding: 8 }}>Responded At</th>
<tr>
<th>Mitra</th>
<th>Session</th>
<th>Topik</th>
<th>Response</th>
<th>Response Time (s)</th>
<th>Active Sessions</th>
<th>Notified At</th>
<th>Responded At</th>
</tr>
</thead>
<tbody>
{(logData?.items || []).map(item => (
<tr key={item.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{item.mitra_display_name}</td>
<td style={{ padding: 8, fontSize: 11, fontFamily: 'monospace' }}>{item.session_id?.substring(0, 8)}...</td>
<td style={{ padding: 8 }}>
<tr key={item.id}>
<td>{item.mitra_display_name}</td>
<td style={{ fontSize: 11, fontFamily: 'var(--hb-font-mono)' }}>{item.session_id?.substring(0, 8)}</td>
<td>
{item.topic_sensitivity === 'sensitive' ? (
<span style={{ background: '#F7B500', color: '#3D2A00', padding: '2px 6px', borderRadius: 999, fontSize: 11, fontWeight: 600 }}>Sensitif</span>
<span className="hb-pill hb-pill-warn">Sensitif</span>
) : (
<span style={{ color: '#666', fontSize: 11 }}>Umum</span>
<span className="hb-muted" style={{ fontSize: 11 }}>Umum</span>
)}
</td>
<td style={{ padding: 8 }}>
<span style={{ color: responseColor(item.response), fontWeight: 'bold' }}>
<td>
<span style={{ color: responseColor(item.response), fontWeight: 600, textTransform: 'capitalize' }}>
{item.response || '-'}
</span>
</td>
<td style={{ padding: 8 }}>{item.response_time_seconds ?? '-'}</td>
<td style={{ padding: 8 }}>{item.active_session_count}</td>
<td style={{ padding: 8, fontSize: 12 }}>{formatDate(item.notified_at)}</td>
<td style={{ padding: 8, fontSize: 12 }}>{formatDate(item.responded_at)}</td>
<td>{item.response_time_seconds ?? '-'}</td>
<td>{item.active_session_count}</td>
<td style={{ fontSize: 12 }}>{formatDate(item.notified_at)}</td>
<td style={{ fontSize: 12 }}>{formatDate(item.responded_at)}</td>
</tr>
))}
{(!logData?.items || logData.items.length === 0) && (
<tr><td colSpan={8} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
<tr><td colSpan={8} style={{ padding: 24, textAlign: 'center', color: 'var(--hb-ink-muted)' }}>Tidak ada data</td></tr>
)}
</tbody>
</table>
{logData && logData.total > logLimit && (
<div style={{ display: 'flex', gap: 8, marginTop: 12, alignItems: 'center' }}>
<button disabled={logPage <= 1} onClick={() => setLogPage(p => p - 1)}>Prev</button>
<span>Halaman {logData.page} dari {Math.ceil(logData.total / logLimit)}</span>
<button disabled={logPage >= Math.ceil(logData.total / logLimit)} onClick={() => setLogPage(p => p + 1)}>Next</button>
<div style={{ display: 'flex', gap: 12, marginTop: 16, alignItems: 'center', justifyContent: 'center' }}>
<button className="hb-btn-secondary" disabled={logPage <= 1} onClick={() => setLogPage(p => p - 1)}>Prev</button>
<span className="hb-soft" style={{ fontSize: 13 }}>Halaman {logData.page} dari {Math.ceil(logData.total / logLimit)}</span>
<button className="hb-btn-secondary" disabled={logPage >= Math.ceil(logData.total / logLimit)} onClick={() => setLogPage(p => p + 1)}>Next</button>
</div>
)}
</>

View File

@@ -100,37 +100,49 @@ export default function MitrasPage() {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>Mitra</h1>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h1 style={{ margin: 0 }}>Mitra</h1>
<button onClick={() => setShowForm(!showForm)}>+ Tambah Mitra</button>
</div>
{showForm && (
<form onSubmit={(e) => { e.preventDefault(); createMutation.mutate(form) }}
style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
<h3>Tambah Mitra Baru</h3>
<input placeholder="Nomor HP (+628...)" value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
<input placeholder="Nama" value={form.display_name}
onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
<form
onSubmit={(e) => { e.preventDefault(); createMutation.mutate(form) }}
className="hb-card"
style={{ marginTop: 16, marginBottom: 24 }}
>
<h3 style={{ marginTop: 0 }}>Tambah Mitra Baru</h3>
<input
placeholder="Nomor HP (+628...)"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
required
style={{ marginBottom: 10 }}
/>
<input
placeholder="Nama"
value={form.display_name}
onChange={(e) => setForm((f) => ({ ...f, display_name: e.target.value }))}
required
style={{ marginBottom: 12 }}
/>
<button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
{createMutation.isPending ? 'Menyimpan' : 'Simpan'}
</button>
{createMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
{createMutation.isError && <p className="hb-error">Gagal menyimpan.</p>}
</form>
)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div style={{ marginTop: 16 }}>
<table>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nomor HP</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status Akun</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Online</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Sesi Aktif</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
<th>Nama</th>
<th>Nomor HP</th>
<th>Status Akun</th>
<th>Online</th>
<th>Sesi Aktif</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
@@ -140,7 +152,7 @@ export default function MitrasPage() {
return (
<Fragment key={mitra.id}>
<tr>
<td style={{ padding: 8 }}>
<td>
{isEditing ? (
<input
autoFocus
@@ -151,74 +163,79 @@ export default function MitrasPage() {
if (e.key === 'Escape') cancelEdit()
}}
disabled={nameMutation.isPending}
style={{ width: '100%' }}
/>
) : (
mitra.display_name
)}
</td>
<td style={{ padding: 8 }}>{mitra.phone}</td>
<td style={{ padding: 8 }}>{mitra.is_active ? 'Aktif' : 'Nonaktif'}</td>
<td style={{ padding: 8 }}>
<span style={{ color: onlineInfo ? 'green' : 'grey' }}>
<td>{mitra.phone}</td>
<td>
<span className={`hb-pill ${mitra.is_active ? 'hb-pill-success' : 'hb-pill-neutral'}`}>
{mitra.is_active ? 'Aktif' : 'Nonaktif'}
</span>
</td>
<td>
<span className={`hb-pill ${onlineInfo ? 'hb-pill-success' : 'hb-pill-neutral'}`}>
{onlineInfo ? '● Online' : '○ Offline'}
</span>
</td>
<td style={{ padding: 8 }}>{onlineInfo ? onlineInfo.active_session_count : '-'}</td>
<td style={{ padding: 8, display: 'flex', gap: 8 }}>
<td>{onlineInfo ? onlineInfo.active_session_count : '-'}</td>
<td>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{isEditing ? (
<>
<button
onClick={() => saveEdit(mitra.id)}
disabled={nameMutation.isPending || !editName.trim() || editName.trim() === mitra.display_name}
>
{nameMutation.isPending ? 'Menyimpan...' : 'Simpan'}
{nameMutation.isPending ? 'Menyimpan' : 'Simpan'}
</button>
<button onClick={cancelEdit} disabled={nameMutation.isPending}>Batal</button>
<button className="hb-btn-secondary" onClick={cancelEdit} disabled={nameMutation.isPending}>Batal</button>
</>
) : (
<>
<button onClick={() => startEdit(mitra)}>Edit Nama</button>
<button onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}>
<button className="hb-btn-ghost" onClick={() => startEdit(mitra)}>Edit Nama</button>
<button className="hb-btn-secondary" onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}>
{mitra.is_active ? 'Nonaktifkan' : 'Aktifkan'}
</button>
<button onClick={() => setLogsForMitra(logsForMitra === mitra.id ? null : mitra.id)}>
<button className="hb-btn-ghost" onClick={() => setLogsForMitra(logsForMitra === mitra.id ? null : mitra.id)}>
{logsForMitra === mitra.id ? 'Tutup Log' : 'Log Online'}
</button>
</>
)}
</div>
</td>
</tr>
{logsForMitra === mitra.id && (
<tr>
<td colSpan={6} style={{ padding: 0, background: '#fafafa', borderBottom: '1px solid #eee' }}>
<div style={{ padding: 16 }}>
<td colSpan={6} style={{ padding: 0, background: 'var(--hb-brand-softer)' }}>
<div style={{ padding: 18 }}>
<h4 style={{ margin: '0 0 12px' }}>Log Online/Offline · {mitra.display_name}</h4>
{logsLoading ? (
<p style={{ margin: 0 }}>Loading...</p>
<p className="hb-soft" style={{ margin: 0 }}>Loading</p>
) : logsData?.items?.length ? (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff' }}>
<table>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Waktu</th>
<th>Status</th>
<th>Waktu</th>
</tr>
</thead>
<tbody>
{logsData.items.map((log) => (
<tr key={log.id}>
<td style={{ padding: 8 }}>
<span style={{ color: log.status === 'online' ? 'green' : 'grey' }}>
<td>
<span className={`hb-pill ${log.status === 'online' ? 'hb-pill-success' : 'hb-pill-neutral'}`}>
{log.status === 'online' ? '● Online' : '○ Offline'}
</span>
</td>
<td style={{ padding: 8 }}>{new Date(log.timestamp).toLocaleString('id-ID')}</td>
<td>{new Date(log.timestamp).toLocaleString('id-ID')}</td>
</tr>
))}
</tbody>
</table>
) : (
<p style={{ margin: 0, color: '#888' }}>Belum ada log.</p>
<p className="hb-muted" style={{ margin: 0 }}>Belum ada log.</p>
)}
</div>
</td>
@@ -229,6 +246,7 @@ export default function MitrasPage() {
})}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -106,8 +106,8 @@ export default function PaymentCatalogPage() {
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>
if (groupsQ.isLoading || methodsQ.isLoading) return <div className="hb-soft">Loading</div>
if (groupsQ.error || methodsQ.error) return <div className="hb-error">Failed to load payment catalog.</div>
const groups = groupsQ.data ?? []
const methods = methodsQ.data ?? []
@@ -116,9 +116,9 @@ export default function PaymentCatalogPage() {
: methods
return (
<div style={{ padding: 24 }}>
<div>
<h1 style={{ marginBottom: 8 }}>Payment Catalog</h1>
<p style={{ color: '#666', marginBottom: 24, fontSize: 14 }}>
<p className="hb-soft" style={{ 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>,
@@ -127,12 +127,13 @@ export default function PaymentCatalogPage() {
{error && (
<div style={{
background: '#FFE6E6',
border: '1px solid #D86B6B',
background: '#FCE9E9',
border: '1px solid var(--hb-danger)',
color: '#8B2D2D',
padding: 12,
borderRadius: 8,
borderRadius: 'var(--hb-radius-md)',
marginBottom: 16,
fontSize: 13,
}}>
{error}
</div>
@@ -161,16 +162,18 @@ export default function PaymentCatalogPage() {
{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}>
<tr key={g.id} style={selectedGroupId === g.id ? { background: 'var(--hb-brand-softer)' } : undefined}>
<td style={tdStyle}>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<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>
<span className="hb-muted" style={{ marginLeft: 8 }}>{g.display_order}</span>
</div>
</td>
<td style={tdStyle}><strong>{g.name}</strong></td>
<td style={tdStyle}>{g.is_active ? '✓' : '✗'}</td>
<td style={tdStyle}><strong style={{ color: 'var(--hb-ink)' }}>{g.name}</strong></td>
<td style={tdStyle}>
{g.is_active ? <span className="hb-pill hb-pill-success">Aktif</span> : <span className="hb-pill hb-pill-neutral">Nonaktif</span>}
</td>
<td style={tdStyle}>{methodsInGroup}</td>
<td style={tdStyle}>
<button onClick={() => setSelectedGroupId(g.id === selectedGroupId ? null : g.id)} style={btnStyle('ghost')}>
@@ -191,7 +194,7 @@ export default function PaymentCatalogPage() {
)
})}
{groups.length === 0 && (
<tr><td colSpan={5} style={{ ...tdStyle, color: '#999' }}>No groups yet.</td></tr>
<tr><td colSpan={5} style={{ ...tdStyle, color: 'var(--hb-ink-muted)', textAlign: 'center', padding: 24 }}>No groups yet.</td></tr>
)}
</tbody>
</table>
@@ -212,6 +215,8 @@ export default function PaymentCatalogPage() {
display_name: '',
payment_code: '',
icon: '',
min_amount: '',
max_amount: '',
display_order: filteredMethods.length,
is_active: true,
})}
@@ -225,7 +230,9 @@ export default function PaymentCatalogPage() {
<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}>Icon slug(s)</th>
<th style={thStyle}>Min Rp</th>
<th style={thStyle}>Max Rp</th>
<th style={thStyle}>Active</th>
<th style={thStyle}>Actions</th>
</tr>
@@ -234,17 +241,21 @@ export default function PaymentCatalogPage() {
{filteredMethods.map((m, i, arr) => (
<tr key={m.id}>
<td style={tdStyle}>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<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>
<span className="hb-muted" style={{ marginLeft: 8 }}>{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}><strong style={{ color: 'var(--hb-ink)' }}>{m.display_name}</strong></td>
<td style={tdStyle}><code style={{ background: 'var(--hb-brand-softer)', padding: '2px 6px', borderRadius: 6, color: 'var(--hb-brand-dark)' }}>{m.payment_code}</code></td>
<td style={tdStyle}>{m.icon || <span className="hb-muted"></span>}</td>
<td style={tdStyle}>{m.min_amount != null ? m.min_amount.toLocaleString('id-ID') : <span className="hb-muted"></span>}</td>
<td style={tdStyle}>{m.max_amount != null ? m.max_amount.toLocaleString('id-ID') : <span className="hb-muted"></span>}</td>
<td style={tdStyle}>
{m.is_active ? <span className="hb-pill hb-pill-success">Aktif</span> : <span className="hb-pill hb-pill-neutral">Nonaktif</span>}
</td>
<td style={tdStyle}>
<button onClick={() => setMethodForm(m)} style={btnStyle('ghost')}>Edit</button>
<button
@@ -258,7 +269,7 @@ export default function PaymentCatalogPage() {
</tr>
))}
{filteredMethods.length === 0 && (
<tr><td colSpan={7} style={{ ...tdStyle, color: '#999' }}>
<tr><td colSpan={9} style={{ ...tdStyle, color: 'var(--hb-ink-muted)', textAlign: 'center', padding: 24 }}>
{selectedGroupId ? 'No methods in this group yet.' : 'No methods yet.'}
</td></tr>
)}
@@ -300,6 +311,15 @@ export default function PaymentCatalogPage() {
payment_code: methodForm.payment_code?.trim()?.toUpperCase(),
display_order: Number(methodForm.display_order) || 0,
icon: methodForm.icon?.trim() || null,
// Empty string → null (no bound). Other input goes through
// Number(), which leaves NaN for non-numeric — the server then
// rejects with VALIDATION.
min_amount: methodForm.min_amount === '' || methodForm.min_amount == null
? null
: Number(methodForm.min_amount),
max_amount: methodForm.max_amount === '' || methodForm.max_amount == null
? null
: Number(methodForm.max_amount),
is_active: !!methodForm.is_active,
}
if (methodForm.id) {
@@ -370,11 +390,33 @@ const MethodModal = ({ form, groups, onChange, onSubmit, onClose }) => (
style={inputStyle}
/>
</Field>
<Field label="Icon slug (file in client_app/assets/payment_icons/)">
<Field label="Icon slugs (comma-separated, idn-finlogos asset names — served by backend)">
<input
value={form.icon ?? ''}
onChange={(e) => onChange({ ...form, icon: e.target.value })}
placeholder="ovo, dana, qris, …"
placeholder="ovo-new OR visa,mastercard,jcb"
style={inputStyle}
/>
</Field>
<Field label="Minimum amount (Rp, inclusive, empty = no minimum)">
<input
type="number"
min="0"
step="1"
value={form.min_amount ?? ''}
onChange={(e) => onChange({ ...form, min_amount: e.target.value })}
placeholder="e.g. 10000"
style={inputStyle}
/>
</Field>
<Field label="Maximum amount (Rp, inclusive, empty = no maximum)">
<input
type="number"
min="0"
step="1"
value={form.max_amount ?? ''}
onChange={(e) => onChange({ ...form, max_amount: e.target.value })}
placeholder="e.g. 10000000"
style={inputStyle}
/>
</Field>
@@ -399,11 +441,19 @@ const MethodModal = ({ form, groups, onChange, onSubmit, onClose }) => (
const Modal = ({ title, onClose, children }) => (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
position: 'fixed', inset: 0, background: 'rgba(42, 24, 32, 0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
padding: 24,
}} onClick={onClose}>
<div
style={{ background: 'white', padding: 24, borderRadius: 12, minWidth: 420, maxWidth: 560 }}
className="hb-card"
style={{
padding: 28,
minWidth: 420,
maxWidth: 560,
width: '100%',
boxShadow: 'var(--hb-shadow-card)',
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0 }}>{title}</h3>
@@ -413,37 +463,79 @@ const Modal = ({ title, onClose, children }) => (
)
const Field = ({ label, children }) => (
<div style={{ marginBottom: 12, display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: 12, fontWeight: 600, color: '#444', marginBottom: 4 }}>{label}</label>
<div style={{ marginBottom: 14, display: 'flex', flexDirection: 'column' }}>
<label style={{ marginBottom: 6 }}>{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 style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20 }}>
<button onClick={onClose} className="hb-btn-secondary">Cancel</button>
<button onClick={onSubmit}>Save</button>
</div>
)
// ─────────────────────────── inline styles ───────────────────────────
// Use design tokens — defaults defined in src/theme/global.css.
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,
const tableStyle = {
width: '100%',
borderCollapse: 'separate',
borderSpacing: 0,
background: 'var(--hb-surface)',
borderRadius: 'var(--hb-radius-lg)',
overflow: 'hidden',
boxShadow: 'var(--hb-shadow-soft)',
}
const thStyle = {
textAlign: 'left',
padding: '12px 14px',
fontSize: 13,
fontWeight: 500,
...(variant === 'primary'
? { background: '#E17A9D', color: 'white' }
: variant === 'danger'
? { background: '#fff', color: '#D86B6B', border: '1px solid #D86B6B' }
: { background: '#F0F0F0', color: '#333' }),
})
fontWeight: 600,
fontFamily: 'var(--hb-font-display)',
color: 'var(--hb-brand-dark)',
background: 'var(--hb-brand-softer)',
borderBottom: '1px solid var(--hb-border)',
letterSpacing: '-0.005em',
}
const tdStyle = {
padding: '12px 14px',
fontSize: 13.5,
color: 'var(--hb-ink)',
borderBottom: '1px solid var(--hb-border)',
verticalAlign: 'top',
}
const inputStyle = {
padding: '10px 14px',
borderRadius: 'var(--hb-radius-md)',
border: '1.5px solid var(--hb-border)',
fontSize: 14,
fontFamily: 'var(--hb-font-body)',
color: 'var(--hb-ink)',
background: 'var(--hb-surface)',
outline: 'none',
}
const btnStyle = (variant) => {
const base = {
padding: variant === 'ghost' ? '6px 10px' : '8px 14px',
border: 'none',
borderRadius: 'var(--hb-radius-pill)',
cursor: 'pointer',
marginRight: 6,
fontSize: 13,
fontWeight: 600,
fontFamily: 'var(--hb-font-body)',
minHeight: variant === 'ghost' ? 30 : 34,
transition: 'background 120ms ease',
boxShadow: variant === 'primary' ? 'var(--hb-shadow-button)' : 'none',
}
if (variant === 'primary') {
return { ...base, background: 'var(--hb-brand)', color: '#fff' }
}
if (variant === 'danger') {
return { ...base, background: 'var(--hb-surface)', color: 'var(--hb-danger)', border: '1.5px solid var(--hb-danger)' }
}
return { ...base, background: 'var(--hb-brand-softer)', color: 'var(--hb-brand-dark)' }
}

View File

@@ -112,37 +112,35 @@ export default function SessionsPage() {
</div>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<table>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Topik</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Waktu</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
<th>Customer</th>
<th>Mitra</th>
<th>Status</th>
<th>Topik</th>
<th>Waktu</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{data?.items?.map((session) => (
<React.Fragment key={session.id}>
<tr>
<td style={{ padding: 8 }}>{session.customer_display_name}</td>
<td style={{ padding: 8 }}>{session.mitra_display_name ?? '-'}</td>
<td style={{ padding: 8 }}>{session.status}</td>
<td style={{ padding: 8 }}>
<td>{session.customer_display_name}</td>
<td>{session.mitra_display_name ?? '-'}</td>
<td><span className="hb-pill hb-pill-neutral">{session.status}</span></td>
<td>
{session.topic_sensitivity === 'sensitive' ? (
<span style={{ background: '#F7B500', color: '#3D2A00', padding: '2px 8px', borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
Sensitif
</span>
<span className="hb-pill hb-pill-warn">Sensitif</span>
) : (
<span style={{ color: '#666', fontSize: 12 }}>Umum</span>
<span className="hb-muted" style={{ fontSize: 12 }}>Umum</span>
)}
</td>
<td style={{ padding: 8 }}>{new Date(session.created_at).toLocaleString('id-ID')}</td>
<td style={{ padding: 8 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => toggleExpand(session.id)} style={{ fontSize: 12 }}>
<td>{new Date(session.created_at).toLocaleString('id-ID')}</td>
<td>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="hb-btn-ghost" onClick={() => toggleExpand(session.id)} style={{ fontSize: 12, minHeight: 32, padding: '6px 12px' }}>
{expandedId === session.id ? 'Tutup' : 'Detail'}
</button>
{['active', 'pending_payment'].includes(session.status) && (
@@ -150,20 +148,21 @@ export default function SessionsPage() {
<select
value={rerouteTarget[session.id] ?? ''}
onChange={e => setRerouteTarget(t => ({ ...t, [session.id]: e.target.value }))}
style={{ fontSize: 12 }}
style={{ fontSize: 12, padding: '6px 10px', width: 'auto' }}
>
<option value="">Reroute ke...</option>
<option value="">Reroute ke</option>
{(onlineMitras ?? [])
.filter(m => m.id !== session.mitra_id)
.map(m => <option key={m.id} value={m.id}>{m.display_name}</option>)}
</select>
<button
className="hb-btn-secondary"
disabled={!rerouteTarget[session.id] || rerouteMutation.isPending}
onClick={() => rerouteMutation.mutate({
sessionId: session.id,
new_mitra_id: rerouteTarget[session.id],
})}
style={{ fontSize: 12 }}
style={{ fontSize: 12, minHeight: 32, padding: '6px 12px' }}
>
Reroute
</button>
@@ -174,14 +173,14 @@ export default function SessionsPage() {
</tr>
{expandedId === session.id && (
<tr>
<td colSpan={6} style={{ padding: 16, background: '#fafafa' }}>
{detailLoading && <div>Memuat detail</div>}
{detail?.error && <div style={{ color: 'red' }}>Gagal memuat detail sesi.</div>}
<td colSpan={6} style={{ background: 'var(--hb-brand-softer)', padding: 18 }}>
{detailLoading && <div className="hb-soft">Memuat detail</div>}
{detail?.error && <div className="hb-error">Gagal memuat detail sesi.</div>}
{detail && !detail.error && (
<div>
<h3 style={{ margin: '0 0 8px' }}>Riwayat Topik Sensitif</h3>
{(!detail.sensitivity_log || detail.sensitivity_log.length === 0) ? (
<p style={{ color: '#666', fontSize: 13 }}>Belum ada perubahan topik oleh Mitra.</p>
<p className="hb-soft" style={{ fontSize: 13 }}>Belum ada perubahan topik oleh Mitra.</p>
) : (
<ul style={{ margin: 0, paddingLeft: 20 }}>
{detail.sensitivity_log.map(log => (
@@ -204,14 +203,14 @@ export default function SessionsPage() {
</table>
{data && (
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</button>
<span>Halaman {data.page} dari {Math.ceil(data.total / data.limit) || 1}</span>
<button disabled={page >= Math.ceil(data.total / data.limit)} onClick={() => setPage(p => p + 1)}>Next</button>
<div style={{ marginTop: 16, display: 'flex', gap: 12, justifyContent: 'center', alignItems: 'center' }}>
<button className="hb-btn-secondary" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</button>
<span className="hb-soft" style={{ fontSize: 13 }}>Halaman {data.page} dari {Math.ceil(data.total / data.limit) || 1}</span>
<button className="hb-btn-secondary" disabled={page >= Math.ceil(data.total / data.limit)} onClick={() => setPage(p => p + 1)}>Next</button>
</div>
)}
{rerouteMutation.isError && <p style={{ color: 'red', marginTop: 8 }}>Gagal reroute sesi.</p>}
{rerouteMutation.isError && <p className="hb-error" style={{ marginTop: 8 }}>Gagal reroute sesi.</p>}
</div>
)
}

View File

@@ -56,22 +56,22 @@ const ResetPasswordRow = ({ userId }) => {
})
if (!open) {
return <button onClick={() => { setOpen(true); setSuccess(false); setError('') }}>Reset password</button>
return <button className="hb-btn-secondary" onClick={() => { setOpen(true); setSuccess(false); setError('') }}>Reset password</button>
}
return (
<form onSubmit={(e) => { e.preventDefault(); setError(''); mutation.mutate({ id: userId, new_password: pw }) }}
style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<input type="text" placeholder="Password baru" value={pw}
onChange={e => setPw(e.target.value)} required minLength={8}
style={{ width: 180 }} />
<button type="button" onClick={() => setPw(generateTempPassword())}>Generate</button>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '...' : 'Simpan'}
style={{ width: 200, padding: '8px 12px' }} />
<button type="button" className="hb-btn-ghost" onClick={() => setPw(generateTempPassword())} style={{ minHeight: 36, padding: '6px 12px', fontSize: 12 }}>Generate</button>
<button type="submit" disabled={mutation.isPending} style={{ minHeight: 36, padding: '6px 14px', fontSize: 12 }}>
{mutation.isPending ? '' : 'Simpan'}
</button>
<button type="button" onClick={() => { setOpen(false); setError(''); setSuccess(false) }}>Batal</button>
{error && <span style={{ color: 'red', fontSize: 12 }}>{error}</span>}
{success && <span style={{ color: 'green', fontSize: 12 }}>Tersimpan.</span>}
<button type="button" className="hb-btn-secondary" onClick={() => { setOpen(false); setError(''); setSuccess(false) }} style={{ minHeight: 36, padding: '6px 12px', fontSize: 12 }}>Batal</button>
{error && <span className="hb-error" style={{ fontSize: 12, margin: 0 }}>{error}</span>}
{success && <span className="hb-success" style={{ fontSize: 12, margin: 0 }}>Tersimpan.</span>}
</form>
)
}
@@ -100,61 +100,66 @@ export default function UsersPage() {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>Control Center Users</h1>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h1 style={{ margin: 0 }}>Control Center Users</h1>
<button onClick={() => { setShowForm(!showForm); setCreateError('') }}>+ Tambah User</button>
</div>
{showForm && (
<form onSubmit={(e) => { e.preventDefault(); setCreateError(''); createMutation.mutate(form) }}
style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
<h3>Tambah User Baru</h3>
<form
onSubmit={(e) => { e.preventDefault(); setCreateError(''); createMutation.mutate(form) }}
className="hb-card"
style={{ marginTop: 16, marginBottom: 24 }}
>
<h3 style={{ marginTop: 0 }}>Tambah User Baru</h3>
<input placeholder="Email" type="email" value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
style={{ marginBottom: 10 }} />
<input placeholder="Nama" value={form.display_name}
onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
style={{ marginBottom: 10 }} />
<select value={form.role_id} onChange={e => setForm(f => ({ ...f, role_id: e.target.value }))} required
style={{ display: 'block', marginBottom: 8, width: '100%' }}>
style={{ marginBottom: 10 }}>
<option value="">Pilih Role</option>
{roles?.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input placeholder="Password awal (min 8, huruf besar/kecil + angka)" type="text" value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} required minLength={8}
style={{ flex: 1 }} />
<button type="button" onClick={() => setForm(f => ({ ...f, password: generateTempPassword() }))}>
<button type="button" className="hb-btn-ghost" onClick={() => setForm(f => ({ ...f, password: generateTempPassword() }))}>
Generate
</button>
</div>
<button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
{createMutation.isPending ? 'Menyimpan' : 'Simpan'}
</button>
{createError && <p style={{ color: 'red' }}>{createError}</p>}
{createError && <p className="hb-error">{createError}</p>}
</form>
)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div style={{ marginTop: 16 }}>
<table>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Email</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Role</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
<th>Nama</th>
<th>Email</th>
<th>Role</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{data?.items?.map((user) => (
<tr key={user.id}>
<td style={{ padding: 8 }}>{user.display_name}</td>
<td style={{ padding: 8 }}>{user.email}</td>
<td style={{ padding: 8 }}>{user.role?.name}</td>
<td style={{ padding: 8 }}><ResetPasswordRow userId={user.id} /></td>
<td>{user.display_name}</td>
<td>{user.email}</td>
<td><span className="hb-pill">{user.role?.name}</span></td>
<td><ResetPasswordRow userId={user.id} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,263 @@
/* Halo Bestie Control Center — design system applied as global CSS.
Tokens mirror src/theme/tokens.js. Element-level defaults let existing
pages inherit the warm theme without per-file rewrites.
*/
:root {
--hb-bg: #FDF7F4;
--hb-surface: #FFFFFF;
--hb-ink: #2A1820;
--hb-ink-soft: #6B5560;
--hb-ink-muted: #9C8590;
--hb-brand: #E17A9D;
--hb-brand-dark: #8C3255;
--hb-brand-soft: #F7E4E9;
--hb-brand-softer: #FBEFF3;
--hb-accent: #F7B26A;
--hb-accent-soft: #FCEAD3;
--hb-mint: #B8DBC8;
--hb-lilac: #D4C5E8;
--hb-success: #5BA67F;
--hb-danger: #D86B6B;
--hb-border: #F0E4E8;
--hb-radius-sm: 8px;
--hb-radius-md: 12px;
--hb-radius-lg: 16px;
--hb-radius-xl: 22px;
--hb-radius-pill: 9999px;
--hb-shadow-soft: 0 1px 2px rgba(140, 50, 85, 0.04), 0 8px 24px rgba(140, 50, 85, 0.06);
--hb-shadow-card: 0 2px 6px rgba(140, 50, 85, 0.05), 0 18px 40px rgba(140, 50, 85, 0.10);
--hb-shadow-button: 0 4px 14px rgba(225, 122, 157, 0.35);
--hb-font-display: "Bricolage Grotesque", "Poppins", system-ui, sans-serif;
--hb-font-body: "Poppins", "Inter", system-ui, sans-serif;
--hb-font-mono: "JetBrains Mono", ui-monospace, monospace;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root {
margin: 0;
padding: 0;
background: var(--hb-bg);
color: var(--hb-ink);
font-family: var(--hb-font-body);
font-size: 14px;
line-height: 1.5;
letter-spacing: -0.005em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root { min-height: 100vh; }
h1, h2, h3, h4, h5 {
font-family: var(--hb-font-display);
color: var(--hb-ink);
letter-spacing: -0.02em;
margin: 0 0 12px;
font-weight: 600;
}
h1 { font-size: 28px; line-height: 1.2; }
h2 { font-size: 20px; line-height: 1.3; }
h3 { font-size: 17px; line-height: 1.35; }
h4 { font-size: 15px; line-height: 1.4; }
p { margin: 0 0 12px; color: var(--hb-ink-soft); }
a {
color: var(--hb-brand-dark);
text-decoration: none;
transition: color 120ms ease;
}
a:hover { color: var(--hb-brand); }
code, pre { font-family: var(--hb-font-mono); }
/* ---------- buttons ---------- */
button {
font-family: var(--hb-font-body);
font-weight: 600;
font-size: 14px;
letter-spacing: -0.01em;
padding: 10px 18px;
min-height: 40px;
border-radius: var(--hb-radius-pill);
border: none;
background: var(--hb-brand);
color: #fff;
cursor: pointer;
box-shadow: var(--hb-shadow-button);
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, opacity 120ms ease;
}
button:hover:not(:disabled) { background: var(--hb-brand-dark); }
button:active:not(:disabled) { transform: scale(0.98); }
button:disabled, button[disabled] {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
}
/* Secondary / ghost buttons opt in via class */
button.hb-btn-secondary {
background: var(--hb-surface);
color: var(--hb-brand-dark);
border: 1.5px solid var(--hb-brand);
box-shadow: none;
}
button.hb-btn-secondary:hover:not(:disabled) {
background: var(--hb-brand-softer);
color: var(--hb-brand-dark);
}
button.hb-btn-ghost {
background: transparent;
color: var(--hb-brand-dark);
box-shadow: none;
padding: 8px 14px;
}
button.hb-btn-ghost:hover:not(:disabled) { background: var(--hb-brand-softer); }
button.hb-btn-danger {
background: var(--hb-danger);
color: #fff;
box-shadow: 0 4px 14px rgba(216, 107, 107, 0.32);
}
button.hb-btn-danger:hover:not(:disabled) { background: #B45656; }
button.hb-btn-dark {
background: var(--hb-ink);
color: #fff;
box-shadow: 0 6px 18px rgba(42, 24, 32, 0.25);
}
/* ---------- form controls ---------- */
input, select, textarea {
font-family: var(--hb-font-body);
font-size: 14px;
color: var(--hb-ink);
background: var(--hb-surface);
border: 1.5px solid var(--hb-border);
border-radius: var(--hb-radius-md);
padding: 10px 14px;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
width: 100%;
}
input:focus, select:focus, textarea:focus {
border-color: var(--hb-brand);
box-shadow: 0 0 0 3px var(--hb-brand-softer);
}
input[type="checkbox"], input[type="radio"] {
width: auto;
accent-color: var(--hb-brand);
margin-right: 6px;
cursor: pointer;
}
input[type="checkbox"]:focus, input[type="radio"]:focus {
box-shadow: none;
}
label {
display: inline-block;
font-size: 13px;
font-weight: 500;
color: var(--hb-ink-soft);
margin-bottom: 4px;
}
/* ---------- tables ---------- */
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: var(--hb-surface);
border-radius: var(--hb-radius-lg);
overflow: hidden;
box-shadow: var(--hb-shadow-soft);
}
thead {
background: var(--hb-brand-softer);
}
th {
text-align: left;
font-family: var(--hb-font-display);
font-weight: 600;
font-size: 13px;
color: var(--hb-brand-dark);
padding: 12px 14px;
border-bottom: 1px solid var(--hb-border);
letter-spacing: -0.005em;
}
td {
padding: 12px 14px;
color: var(--hb-ink);
border-bottom: 1px solid var(--hb-border);
font-size: 13.5px;
vertical-align: top;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--hb-brand-softer); }
/* ---------- cards / helpers ---------- */
.hb-card {
background: var(--hb-surface);
border-radius: var(--hb-radius-lg);
padding: 20px;
box-shadow: var(--hb-shadow-soft);
}
.hb-card-soft {
background: var(--hb-brand-softer);
border-radius: var(--hb-radius-lg);
padding: 20px;
}
.hb-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--hb-radius-pill);
background: var(--hb-brand-soft);
color: var(--hb-brand-dark);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.hb-pill-success { background: #DDEFE3; color: #2F7A53; }
.hb-pill-warn { background: var(--hb-accent-soft); color: #8A5A20; }
.hb-pill-danger { background: #F7D8D8; color: #8A3838; }
.hb-pill-neutral { background: var(--hb-border); color: var(--hb-ink-soft); }
/* Used by Layout side nav */
.hb-nav-link {
display: block;
padding: 9px 14px;
border-radius: var(--hb-radius-pill);
color: var(--hb-ink-soft);
font-weight: 500;
font-size: 14px;
text-decoration: none;
transition: background 120ms ease, color 120ms ease;
}
.hb-nav-link:hover { background: var(--hb-brand-softer); color: var(--hb-brand-dark); }
.hb-nav-link.active {
background: var(--hb-brand);
color: #fff;
box-shadow: var(--hb-shadow-button);
}
.hb-nav-link.active:hover { background: var(--hb-brand-dark); color: #fff; }
/* small badges / muted text */
.hb-muted { color: var(--hb-ink-muted); }
.hb-soft { color: var(--hb-ink-soft); }
/* form layouts */
.hb-form-row { margin-bottom: 12px; }
.hb-form-row label { display: block; }
/* error / success text */
.hb-error { color: var(--hb-danger); font-size: 13px; margin: 4px 0; }
.hb-success { color: var(--hb-success); font-size: 13px; margin: 4px 0; }

View File

@@ -0,0 +1,37 @@
// Halo Bestie design tokens — ported from mitra_app/figma-bestie/project/screens/tokens.jsx
// Control center uses the "warm" palette only (no theme switcher needed for an internal tool).
export const HB_TOKENS = {
palette: {
bg: '#FDF7F4',
surface: '#FFFFFF',
ink: '#2A1820',
inkSoft: '#6B5560',
inkMuted: '#9C8590',
brand: '#E17A9D',
brandDark: '#8C3255',
brandSoft: '#F7E4E9',
brandSofter: '#FBEFF3',
accent: '#F7B26A',
accentSoft: '#FCEAD3',
mint: '#B8DBC8',
lilac: '#D4C5E8',
success: '#5BA67F',
danger: '#D86B6B',
border: '#F0E4E8',
},
type: {
display: '"Bricolage Grotesque", "Poppins", system-ui, sans-serif',
body: '"Poppins", "Inter", system-ui, sans-serif',
mono: '"JetBrains Mono", ui-monospace, monospace',
},
radius: { sm: 8, md: 12, lg: 16, xl: 22, pill: 9999 },
shadow: {
soft: '0 1px 2px rgba(140,50,85,0.04), 0 8px 24px rgba(140,50,85,0.06)',
card: '0 2px 6px rgba(140,50,85,0.05), 0 18px 40px rgba(140,50,85,0.10)',
button: '0 4px 14px rgba(225,122,157,0.35)',
inner: 'inset 0 1px 0 rgba(255,255,255,0.6)',
},
}
export const t = HB_TOKENS.palette