Files
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

135 lines
3.7 KiB
JavaScript

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
const msg = err?.response?.data?.error?.message
switch (code) {
case 'ACCOUNT_LOCKED':
return msg || 'Akun terkunci sementara. Coba lagi nanti.'
case 'INVALID_CREDENTIALS':
return 'Email atau password salah.'
case 'VALIDATION_ERROR':
return 'Email dan password wajib diisi.'
default:
return 'Gagal masuk. Coba lagi.'
}
}
export default function LoginPage() {
const { user, loading: authLoading, login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (user) navigate('/', { replace: true })
}, [user, navigate])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(email, password)
} catch (err) {
setError(messageForError(err))
setLoading(false)
}
}
if (authLoading) {
return (
<div style={{ padding: 24, color: 'var(--hb-ink-soft)' }}>Loading</div>
)
}
return (
<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>
<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>
)
}