Compare commits

..

2 Commits

Author SHA1 Message Date
root e4c81dce78 Merge pull request 'feat: implement toast notifications and update UI components' (#7) from feat/payment-link-flow into main
Reviewed-on: #7
2025-11-14 08:45:08 +00:00
Tengku Achmad e1f989447b feat: implement toast notifications and update UI components
refactor: remove dark mode styles and simplify UI components
style: update color scheme and branding to new blue theme
feat(toast): add toast notification system for user feedback
fix: correct merchant name and update payment sheet styling
docs: update comments and remove unused code
2025-11-14 15:13:46 +07:00
21 changed files with 224 additions and 173 deletions

View File

@ -1,9 +1,10 @@
import { Link, Outlet } from 'react-router-dom' import { Link, Outlet } from 'react-router-dom'
import { ToastHost } from '../components/ui/toast'
export function AppLayout() { export function AppLayout() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<header className="border-b border-black/10 dark:border-white/20 bg-white dark:bg-black"> {/* <header className="border-b border-black/10 dark:border-white/20 bg-white dark:bg-black">
<div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between"> <div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between">
<Link to="/checkout" className="font-semibold text-brand-600">Core Midtrans CIFO</Link> <Link to="/checkout" className="font-semibold text-brand-600">Core Midtrans CIFO</Link>
<nav className="flex gap-4 text-sm"> <nav className="flex gap-4 text-sm">
@ -11,13 +12,14 @@ export function AppLayout() {
<Link to="/history" className="hover:underline">History</Link> <Link to="/history" className="hover:underline">History</Link>
</nav> </nav>
</div> </div>
</header> </header> */}
<main className="mx-auto max-w-5xl px-4 py-6"> <main className="mx-auto max-w-5xl px-4 py-6">
<Outlet /> <Outlet />
</main> </main>
<footer className="mx-auto max-w-5xl px-4 pb-8 text-xs text-black/60 dark:text-white/60"> <ToastHost />
{/* <footer className="mx-auto max-w-5xl px-4 pb-8 text-xs text-black/60 dark:text-white/60">
Brand: MerahHitamPutih Sandbox UI skeleton Brand: MerahHitamPutih Sandbox UI skeleton
</footer> </footer> */}
</div> </div>
) )
} }

View File

@ -4,7 +4,7 @@ export function Alert({ title, children }: { title: string; children?: React.Rea
<div className="text-brand-600"></div> <div className="text-brand-600"></div>
<div> <div>
<div className="font-semibold">{title}</div> <div className="font-semibold">{title}</div>
{children ? <div className="text-sm text-black/70 dark:text-white/70">{children}</div> : null} {children ? <div className="text-sm text-black/70">{children}</div> : null}
</div> </div>
</div> </div>
) )

View File

@ -2,13 +2,13 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-black disabled:opacity-50 disabled:pointer-events-none', 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:opacity-50 disabled:pointer-events-none',
{ {
variants: { variants: {
variant: { variant: {
primary: 'bg-brand-600 text-white hover:bg-brand-700', primary: 'bg-brand-600 text-white hover:bg-brand-700',
secondary: 'bg-white text-black hover:bg-white/90 dark:bg-black dark:text-white dark:hover:bg-black/90', secondary: 'bg-white text-black border border-black/10 hover:bg-black/5',
outline: 'border border-black text-black hover:bg-black/5 dark:border-white dark:text-white dark:hover:bg-white/10', outline: 'border border-black text-black hover:bg-black/5',
}, },
size: { sm: 'h-8 px-3', md: 'h-10 px-4', lg: 'h-11 px-6' }, size: { sm: 'h-8 px-3', md: 'h-10 px-4', lg: 'h-11 px-6' },
}, },

View File

@ -0,0 +1,57 @@
import React from 'react'
type ToastKind = 'info' | 'success' | 'error'
type ToastItem = { id: number; message: string; kind: ToastKind }
let nextId = 1
const subs = new Set<(t: ToastItem) => void>()
export const toast = {
show(message: string, kind: ToastKind = 'info') {
const item: ToastItem = { id: nextId++, message, kind }
subs.forEach((fn) => fn(item))
},
info(message: string) { toast.show(message, 'info') },
success(message: string) { toast.show(message, 'success') },
error(message: string) { toast.show(message, 'error') },
}
export function ToastHost() {
const [items, setItems] = React.useState<ToastItem[]>([])
React.useEffect(() => {
function onPush(t: ToastItem) {
setItems((prev) => [...prev, t])
// auto-remove after 3.5s
setTimeout(() => {
setItems((prev) => prev.filter((x) => x.id !== t.id))
}, 3500)
}
subs.add(onPush)
return () => { subs.delete(onPush) }
}, [])
if (!items.length) return null
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{items.map((it) => (
<div
key={it.id}
role="status"
aria-live="polite"
className={
`min-w-[240px] max-w-[360px] rounded shadow-lg border p-3 text-sm ` +
(it.kind === 'success'
? 'bg-green-600 text-white border-green-600'
: it.kind === 'error'
? 'bg-red-600 text-white border-red-600'
: 'bg-black text-white border-black')
}
>
{it.message}
</div>
))}
</div>
)
}

View File

@ -3,11 +3,11 @@ import { usePaymentNavigation } from '../lib/navigation'
import React from 'react' import React from 'react'
import { PaymentInstructions } from './PaymentInstructions' import { PaymentInstructions } from './PaymentInstructions'
import { BcaInstructionList } from './BcaInstructionList' import { BcaInstructionList } from './BcaInstructionList'
import { TrustStrip } from './TrustStrip'
import { type BankKey } from './PaymentLogos' import { type BankKey } from './PaymentLogos'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
import { Alert } from '../../../components/alert/Alert' import { Alert } from '../../../components/alert/Alert'
import { InlinePaymentStatus } from './InlinePaymentStatus' import { InlinePaymentStatus } from './InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts // Global guard to prevent duplicate auto-charge across StrictMode double-mounts
const attemptedChargeKeys = new Set<string>() const attemptedChargeKeys = new Set<string>()
@ -29,7 +29,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
function copy(text: string, label: string) { function copy(text: string, label: string) {
if (!text) return if (!text) return
navigator.clipboard?.writeText(text) navigator.clipboard?.writeText(text)
alert(`${label} disalin: ${text}`) toast.success(`${label} disalin: ${text}`)
} }
// Auto-create VA immediately when a bank is selected (runs once per selection) // Auto-create VA immediately when a bank is selected (runs once per selection)
React.useEffect(() => { React.useEffect(() => {
@ -125,7 +125,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
} }
run() run()
return () => { cancelled = true } return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected]) }, [selected])
// Auto-show instructions when BCA is selected to reduce confusion // Auto-show instructions when BCA is selected to reduce confusion
@ -137,43 +137,43 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="font-medium">Transfer bank</div> <div className="font-medium">Transfer Bank</div>
{selected && ( {selected && (
<div className="flex items-center gap-2 text-base"> <div className="flex items-center gap-2 text-base">
<span className="text-black/60 dark:text-white/60">Bank:</span> <span className="text-black/60">Bank:</span>
<span className="text-black/80 dark:text-white/80 font-semibold">{selected.toUpperCase()}</span> <span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
</div> </div>
)} )}
<div className="text-sm text-black/70 dark:text-white/70">VA dibuat otomatis sesuai bank pilihan Anda.</div> <div className="text-sm text-black/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
{errorMessage && ( {errorMessage && (
<Alert title="Gagal membuat VA">{errorMessage}</Alert> <Alert title="Gagal membuat VA">{errorMessage}</Alert>
)} )}
{selected && ( {selected && (
<div className="pt-1"> <div className="pt-1">
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20"> <div className="rounded-lg p-3 border-2 border-black/30">
<div className="text-sm font-medium mb-2">Virtual Account</div> <div className="text-sm font-medium mb-2">Virtual Account</div>
<div className="text-sm text-black/70 dark:text-white/70"> <div className="text-sm text-black/70">
{vaCode ? ( {vaCode ? (
<span> <span>
Nomor VA: Nomor VA:
<span className="block break-all mt-1 font-mono text-xl sm:text-2xl md:text-3xl font-semibold tracking-normal text-black dark:text-white">{vaCode}</span> <span className="block break-all mt-1 font-mono text-xl sm:text-2xl md:text-3xl font-semibold tracking-normal text-black">{vaCode}</span>
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-2" role="status" aria-live="polite"> <span className="inline-flex items-center gap-2" role="status" aria-live="polite">
{busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />} {busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />}
{busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'} {busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'}
</span> </span>
)} )}
{billKey && ( {billKey && (
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black dark:text-white">{billKey}</span></span> <span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black">{billKey}</span></span>
)} )}
{billerCode && ( {billerCode && (
<span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black dark:text-white">{billerCode}</span></span> <span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black">{billerCode}</span></span>
)} )}
</div> </div>
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<Button variant="outline" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button> <Button variant="secondary" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button>
<Button variant="outline" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button> <Button variant="secondary" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button>
</div> </div>
</div> </div>
</div> </div>
@ -187,7 +187,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
{selected === 'bca' ? ( {selected === 'bca' ? (
<BcaInstructionList /> <BcaInstructionList />
) : ( ) : (
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20"> <div className="rounded-lg border-2 border-black/30 p-3 bg-white">
<div className="text-sm font-medium mb-2">Instruksi pembayaran</div> <div className="text-sm font-medium mb-2">Instruksi pembayaran</div>
<PaymentInstructions method="bank_transfer" /> <PaymentInstructions method="bank_transfer" />
</div> </div>
@ -195,7 +195,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
</div> </div>
)} )}
{locked && ( {locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.</div> <div className="text-xs text-black/60">Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.</div>
)} )}
<div className="pt-2 space-y-2"> <div className="pt-2 space-y-2">
{(!vaCode || errorMessage) && ( {(!vaCode || errorMessage) && (
@ -280,7 +280,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
> >
{busy ? ( {busy ? (
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite"> <span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 dark:border-black/40 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
Membuat VA Membuat VA
</span> </span>
) : 'Buat VA'} ) : 'Buat VA'}
@ -295,7 +295,6 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
Buka halaman status Buka halaman status
</Button> </Button>
</div> </div>
<TrustStrip />
</div> </div>
) )
} }

View File

@ -5,13 +5,13 @@ type TabKey = 'mobile' | 'atm' | 'ib'
export function BcaInstructionList() { export function BcaInstructionList() {
const [tab, setTab] = React.useState<TabKey>('mobile') const [tab, setTab] = React.useState<TabKey>('mobile')
return ( return (
<div className="mt-2 rounded-lg border-2 border-black/20 dark:border-white/20 bg-white dark:bg-black/20"> <div className="mt-2 rounded-lg border-2 border-gray-300 bg-white">
<div className="p-3"> <div className="p-3">
<div className="text-sm font-medium mb-2">Instruksi BCA</div> <div className="text-sm font-medium mb-2">Instruksi BCA</div>
<div <div
role="tablist" role="tablist"
aria-label="Metode pembayaran BCA" aria-label="Metode pembayaran BCA"
className="inline-flex mb-3 rounded-md border border-black/20 dark:border-white/20 overflow-hidden" className="inline-flex mb-3 rounded-md border border-gray-300 overflow-hidden"
> >
<button <button
type="button" type="button"
@ -20,7 +20,7 @@ export function BcaInstructionList() {
aria-selected={tab === 'mobile'} aria-selected={tab === 'mobile'}
aria-controls="panel-bca-mobile" aria-controls="panel-bca-mobile"
onClick={() => setTab('mobile')} onClick={() => setTab('mobile')}
className={`px-3 py-2 text-xs font-medium focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${tab === 'mobile' ? 'bg-black text-white' : 'bg-transparent hover:bg-black/5 dark:hover:bg-white/10'}`} className={`px-3 py-2 text-xs font-medium focus:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${tab === 'mobile' ? 'bg-black text-white' : 'bg-transparent hover:bg-gray-100'}`}
> >
BCA Mobile BCA Mobile
</button> </button>
@ -31,7 +31,7 @@ export function BcaInstructionList() {
aria-selected={tab === 'atm'} aria-selected={tab === 'atm'}
aria-controls="panel-bca-atm" aria-controls="panel-bca-atm"
onClick={() => setTab('atm')} onClick={() => setTab('atm')}
className={`px-3 py-2 text-xs font-medium focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${tab === 'atm' ? 'bg-black text-white' : 'bg-transparent hover:bg-black/5 dark:hover:bg-white/10'}`} className={`px-3 py-2 text-xs font-medium focus:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${tab === 'atm' ? 'bg-black text-white' : 'bg-transparent hover:bg-gray-100'}`}
> >
ATM BCA ATM BCA
</button> </button>
@ -42,7 +42,7 @@ export function BcaInstructionList() {
aria-selected={tab === 'ib'} aria-selected={tab === 'ib'}
aria-controls="panel-bca-ib" aria-controls="panel-bca-ib"
onClick={() => setTab('ib')} onClick={() => setTab('ib')}
className={`px-3 py-2 text-xs font-medium focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${tab === 'ib' ? 'bg-black text-white' : 'bg-transparent hover:bg-black/5 dark:hover:bg-white/10'}`} className={`px-3 py-2 text-xs font-medium focus:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${tab === 'ib' ? 'bg-black text-white' : 'bg-transparent hover:bg-gray-100'}`}
> >
KlikBCA KlikBCA
</button> </button>
@ -63,7 +63,7 @@ export function BcaInstructionList() {
</div> </div>
)} )}
</div> </div>
<div className="border-t border-black/10 dark:border-white/10 p-3 text-xs text-black/60 dark:text-white/60"> <div className="border-t border-gray-200 p-3 text-xs text-gray-600">
Catatan: Gunakan label "Nomor VA" secara konsisten di semua metode. Catatan: Gunakan label "Nomor VA" secara konsisten di semua metode.
</div> </div>
</div> </div>
@ -111,7 +111,7 @@ function StepsKlikBca() {
function StepList({ steps }: { steps: string[] }) { function StepList({ steps }: { steps: string[] }) {
return ( return (
<ol className="list-decimal list-inside space-y-2 text-sm text-black/80 dark:text-white/80"> <ol className="list-decimal list-inside space-y-2 text-sm text-gray-800">
{steps.map((s, i) => ( {steps.map((s, i) => (
<li key={i}>{s}</li> <li key={i}>{s}</li>
))} ))}

View File

@ -1,8 +1,8 @@
import { Button } from '../../../components/ui/button' import { Button } from '../../../components/ui/button'
import { toast } from '../../../components/ui/toast'
import { usePaymentNavigation } from '../lib/navigation' import { usePaymentNavigation } from '../lib/navigation'
import React from 'react' import React from 'react'
import { PaymentInstructions } from './PaymentInstructions' import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus' import { InlinePaymentStatus } from './InlinePaymentStatus'
@ -36,7 +36,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
if (typeof res?.store === 'string') setStoreFromRes(res.store) if (typeof res?.store === 'string') setStoreFromRes(res.store)
} }
} catch (e) { } catch (e) {
if (!cancelled) alert(`Gagal membuat kode pembayaran: ${(e as Error).message}`) if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
} finally { } finally {
if (!cancelled) setBusy(false) if (!cancelled) setBusy(false)
cstoreTasks.delete(chargeKey) cstoreTasks.delete(chargeKey)
@ -60,7 +60,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
if (typeof res?.store === 'string') setStoreFromRes(res.store) if (typeof res?.store === 'string') setStoreFromRes(res.store)
} }
} catch (e) { } catch (e) {
if (!cancelled) alert(`Gagal membuat kode pembayaran: ${(e as Error).message}`) if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
attemptedCStoreKeys.delete(chargeKey) attemptedCStoreKeys.delete(chargeKey)
} finally { } finally {
if (!cancelled) setBusy(false) if (!cancelled) setBusy(false)
@ -74,14 +74,14 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
function copy(text: string, label: string) { function copy(text: string, label: string) {
if (!text) return if (!text) return
navigator.clipboard?.writeText(text) navigator.clipboard?.writeText(text)
alert(`${label} disalin: ${text}`) toast.success(`${label} disalin: ${text}`)
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="font-medium">Convenience Store</div> <div className="font-medium">Convenience Store</div>
{selected && ( {selected && (
<div className="text-xs text-black/60 dark:text-white/60">Toko dipilih: <span className="font-medium text-black dark:text-white">{selected.toUpperCase()}</span></div> <div className="text-xs text-gray-600">Toko dipilih: <span className="font-medium text-gray-900">{selected.toUpperCase()}</span></div>
)} )}
<button <button
type="button" type="button"
@ -93,17 +93,17 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
</button> </button>
{showGuide && <PaymentInstructions method="cstore" />} {showGuide && <PaymentInstructions method="cstore" />}
{locked && ( {locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.</div> <div className="text-xs text-gray-600">Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.</div>
)} )}
<div className="pt-2 space-y-2"> <div className="pt-2 space-y-2">
<div className="rounded border border-black/10 dark:border-white/10 p-2 text-sm" aria-live="polite"> <div className="rounded border border-gray-300 p-2 text-sm" aria-live="polite">
<div className="font-medium">Kode Pembayaran</div> <div className="font-medium">Kode Pembayaran</div>
{!selected && ( {!selected && (
<div className="text-xs text-black/60 dark:text-white/60">Pilih toko terlebih dahulu di langkah sebelumnya.</div> <div className="text-xs text-gray-600">Pilih toko terlebih dahulu di langkah sebelumnya.</div>
)} )}
{selected && busy && ( {selected && busy && (
<div className="inline-flex items-center gap-2 text-xs text-black/60 dark:text-white/60"> <div className="inline-flex items-center gap-2 text-xs text-gray-600">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
Membuat kode Membuat kode
</div> </div>
)} )}
@ -125,7 +125,6 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
</Button> </Button>
<InlinePaymentStatus orderId={orderId} method="cstore" /> <InlinePaymentStatus orderId={orderId} method="cstore" />
</div> </div>
<TrustStrip />
</div> </div>
) )
} }

View File

@ -2,13 +2,13 @@ import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation' import { usePaymentNavigation } from '../lib/navigation'
import React from 'react' import React from 'react'
import { PaymentInstructions } from './PaymentInstructions' import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { CardLogosRow } from './PaymentLogos' import { CardLogosRow } from './PaymentLogos'
import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds' import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds'
import { Logger } from '../../../lib/logger' import { Logger } from '../../../lib/logger'
import { Env } from '../../../lib/env' import { Env } from '../../../lib/env'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus' import { InlinePaymentStatus } from './InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) { export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
const nav = usePaymentNavigation() const nav = usePaymentNavigation()
@ -41,16 +41,16 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
<CardLogosRow compact /> <CardLogosRow compact />
<div className="space-y-2"> <div className="space-y-2">
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Nomor kartu</div> <div className="text-xs text-gray-600">Nomor kartu</div>
<input type="text" inputMode="numeric" maxLength={23} placeholder="0000-0000-0000-0000" className="w-full rounded border px-3 py-2" value={cardNumber} onChange={(e) => setCardNumber(formatCard(e.target.value))} /> <input type="text" inputMode="numeric" maxLength={23} placeholder="0000-0000-0000-0000" className="w-full rounded border px-3 py-2" value={cardNumber} onChange={(e) => setCardNumber(formatCard(e.target.value))} />
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Masa berlaku</div> <div className="text-xs text-gray-600">Masa berlaku</div>
<input type="text" inputMode="numeric" maxLength={5} placeholder="MM/YY" className="w-full rounded border px-3 py-2" value={exp} onChange={(e) => setExp(formatExp(e.target.value))} /> <input type="text" inputMode="numeric" maxLength={5} placeholder="MM/YY" className="w-full rounded border px-3 py-2" value={exp} onChange={(e) => setExp(formatExp(e.target.value))} />
</label> </label>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">CVV</div> <div className="text-xs text-gray-600">CVV</div>
<input type="password" inputMode="numeric" maxLength={4} placeholder="123" className="w-full rounded border px-3 py-2" value={cvv} onChange={(e) => setCvv(formatCvv(e.target.value))} /> <input type="password" inputMode="numeric" maxLength={4} placeholder="123" className="w-full rounded border px-3 py-2" value={cvv} onChange={(e) => setCvv(formatCvv(e.target.value))} />
</label> </label>
</div> </div>
@ -69,7 +69,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
</button> </button>
{showGuide && <PaymentInstructions method="credit_card" />} {showGuide && <PaymentInstructions method="credit_card" />}
{locked && ( {locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Lanjutkan verifikasi 3DS/OTP pada kartu Anda.</div> <div className="text-xs text-gray-600">Metode terkunci. Lanjutkan verifikasi 3DS/OTP pada kartu Anda.</div>
)} )}
<div className="pt-2 space-y-2"> <div className="pt-2 space-y-2">
<Button <Button
@ -89,24 +89,24 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
// Minimal validation // Minimal validation
if (sanitizedCard.length < 13 || sanitizedCard.length > 19) { if (sanitizedCard.length < 13 || sanitizedCard.length > 19) {
Logger.warn('card.input.invalid', { reason: 'card_number_length', length: sanitizedCard.length }) Logger.warn('card.input.invalid', { reason: 'card_number_length', length: sanitizedCard.length })
alert('Nomor kartu tidak valid. Harus 1319 digit.') toast.error('Nomor kartu tidak valid. Harus 1319 digit.')
return return
} }
const mmNum = Number(mm) const mmNum = Number(mm)
if (!mm || mm.length !== 2 || mmNum < 1 || mmNum > 12) { if (!mm || mm.length !== 2 || mmNum < 1 || mmNum > 12) {
Logger.warn('card.input.invalid', { reason: 'exp_month', value: mm }) Logger.warn('card.input.invalid', { reason: 'exp_month', value: mm })
alert('Format bulan kedaluwarsa tidak valid (MM).') toast.error('Format bulan kedaluwarsa tidak valid (MM).')
return return
} }
if (!yy || yy.length < 2) { if (!yy || yy.length < 2) {
Logger.warn('card.input.invalid', { reason: 'exp_year', value: yy }) Logger.warn('card.input.invalid', { reason: 'exp_year', value: yy })
alert('Format tahun kedaluwarsa tidak valid (YY).') toast.error('Format tahun kedaluwarsa tidak valid (YY).')
return return
} }
const cvvSan = cvv.replace(/[^0-9]/g, '') const cvvSan = cvv.replace(/[^0-9]/g, '')
if (cvvSan.length < 3 || cvvSan.length > 4) { if (cvvSan.length < 3 || cvvSan.length > 4) {
Logger.warn('card.input.invalid', { reason: 'cvv_length', length: cvvSan.length }) Logger.warn('card.input.invalid', { reason: 'cvv_length', length: cvvSan.length })
alert('CVV harus 34 digit.') toast.error('CVV harus 34 digit.')
return return
} }
if (Env.LOG_LEVEL === 'debug') { if (Env.LOG_LEVEL === 'debug') {
@ -132,7 +132,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
} }
} catch (e) { } catch (e) {
Logger.error('card.process.error', { message: (e as Error)?.message }) Logger.error('card.process.error', { message: (e as Error)?.message })
alert(`Gagal memproses kartu: ${(e as Error).message}`) toast.error(`Gagal memproses kartu: ${(e as Error).message}`)
} finally { } finally {
setBusy(false) setBusy(false)
} }
@ -140,7 +140,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
> >
{busy ? ( {busy ? (
<span className="inline-flex items-center justify-center gap-2"> <span className="inline-flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 dark:border-black/40 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
Memproses pembayaran Memproses pembayaran
</span> </span>
) : 'Bayar sekarang'} ) : 'Bayar sekarang'}
@ -156,7 +156,6 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
</div> </div>
{/* Status inline dengan polling otomatis */} {/* Status inline dengan polling otomatis */}
<InlinePaymentStatus orderId={orderId} method="credit_card" compact /> <InlinePaymentStatus orderId={orderId} method="credit_card" compact />
<TrustStrip />
</div> </div>
) )
} }

View File

@ -2,10 +2,10 @@ import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation' import { usePaymentNavigation } from '../lib/navigation'
import React from 'react' import React from 'react'
import { PaymentInstructions } from './PaymentInstructions' import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { GoPayLogosRow } from './PaymentLogos' import { GoPayLogosRow } from './PaymentLogos'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus' import { InlinePaymentStatus } from './InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
// Global guards/tasks to stabilize QR generation across StrictMode remounts // Global guards/tasks to stabilize QR generation across StrictMode remounts
const attemptedChargeKeys = new Set<string>() const attemptedChargeKeys = new Set<string>()
@ -48,7 +48,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
a.target = '_blank' a.target = '_blank'
a.click() a.click()
} else { } else {
alert('QR belum tersedia. Klik "Buat QR" terlebih dulu.') toast.error('QR belum tersedia. Klik "Buat QR" terlebih dulu.')
} }
} }
return ( return (
@ -68,13 +68,13 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
<div className="font-medium">GoPay / QRIS</div> <div className="font-medium">GoPay / QRIS</div>
<GoPayLogosRow compact /> <GoPayLogosRow compact />
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="text-black/60 dark:text-white/60">Mode:</span> <span className="text-black/60">Mode:</span>
<div className="inline-flex rounded-md border-2 border-black/20 dark:border-white/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran"> <div className="inline-flex rounded-md border-2 border-black/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran">
<button <button
type="button" type="button"
onClick={() => setMode('gopay')} onClick={() => setMode('gopay')}
aria-pressed={mode==='gopay'} aria-pressed={mode==='gopay'}
className={`px-2 py-1 focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${mode==='gopay' ? 'bg-black text-white' : 'bg-white dark:bg-black text-black dark:text-white hover:bg-black/10 dark:hover:bg-white/10'}`} className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode==='gopay' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
> >
GoPay GoPay
</button> </button>
@ -82,25 +82,25 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
type="button" type="button"
onClick={() => setMode('qris')} onClick={() => setMode('qris')}
aria-pressed={mode==='qris'} aria-pressed={mode==='qris'}
className={`px-2 py-1 focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${mode==='qris' ? 'bg-black text-white' : 'bg-white dark:bg-black text-black dark:text-white hover:bg-black/10 dark:hover:bg-white/10'}`} className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode==='qris' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
> >
QRIS QRIS
</button> </button>
</div> </div>
</div> </div>
<div className="rounded border border-black/10 dark:border-white/10 p-3 flex flex-col items-center gap-2"> <div className="rounded border border-black/10 p-3 flex flex-col items-center gap-2">
<div className="text-xs text-black/60 dark:text-white/60">Scan QR berikut menggunakan aplikasi {mode === 'gopay' ? 'GoPay' : 'QRIS'}</div> <div className="text-xs text-black/60">Scan QR berikut menggunakan aplikasi {mode === 'gopay' ? 'GoPay' : 'QRIS'}</div>
<div className="relative w-full max-w-[280px] aspect-square grid place-items-center rounded-md border border-black/20 dark:border-white/20 bg-white"> <div className="relative w-full max-w-[280px] aspect-square grid place-items-center rounded-md border border-black/20 bg-white">
{mode === 'qris' && (!qrUrl || busy) ? ( {mode === 'qris' && (!qrUrl || busy) ? (
<span className="inline-flex items-center justify-center gap-2 text-xs text-black/60 dark:text-white/60" role="status" aria-live="polite"> <span className="inline-flex items-center justify-center gap-2 text-xs text-black/60" role="status" aria-live="polite">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
Membuat QR Membuat QR
</span> </span>
) : qrUrl ? ( ) : qrUrl ? (
<img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto" /> <img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto" />
) : null} ) : null}
</div> </div>
<div className="text-[10px] text-black/50 dark:text-white/50">Mode: {mode.toUpperCase()}</div> <div className="text-[10px] text-black/50">Mode: {mode.toUpperCase()}</div>
</div> </div>
<div className="flex flex-col sm:flex-row gap-2 w-full"> <div className="flex flex-col sm:flex-row gap-2 w-full">
<Button variant="outline" className="w-full sm:w-auto" onClick={downloadQR} disabled={!qrUrl}>Download QR</Button> <Button variant="outline" className="w-full sm:w-auto" onClick={downloadQR} disabled={!qrUrl}>Download QR</Button>
@ -125,7 +125,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
/> />
</div> </div>
{locked && ( {locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div> <div className="text-xs text-black/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div>
)} )}
<div className="pt-2"> <div className="pt-2">
<InlinePaymentStatus orderId={orderId} method={mode} /> <InlinePaymentStatus orderId={orderId} method={mode} />
@ -139,14 +139,13 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
> >
{busy ? ( {busy ? (
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite"> <span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
Menuju status Menuju status
</span> </span>
) : 'Buka halaman status'} ) : 'Buka halaman status'}
</Button> </Button>
</div> </div>
</div> </div>
<TrustStrip />
</div> </div>
) )
} }
@ -176,7 +175,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
onChargeInitiated?.() onChargeInitiated?.()
} }
} catch (e) { } catch (e) {
if (!cancelled) alert(`Gagal membuat QR: ${(e as Error).message}`) if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`)
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setBusy(false) setBusy(false)
@ -205,7 +204,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
onChargeInitiated?.() onChargeInitiated?.()
} }
} catch (e) { } catch (e) {
if (!cancelled) alert(`Gagal membuat QR: ${(e as Error).message}`) if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`)
attemptedChargeKeys.delete(chargeKey) attemptedChargeKeys.delete(chargeKey)
} finally { } finally {
if (!cancelled) { if (!cancelled) {

View File

@ -18,7 +18,7 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
const isFailure = ['deny', 'cancel', 'expire', 'refund', 'chargeback'].includes(status) const isFailure = ['deny', 'cancel', 'expire', 'refund', 'chargeback'].includes(status)
return ( return (
<div className={`rounded border ${compact ? 'p-2' : 'p-3'} border-black/10 dark:border-white/10 bg-white dark:bg-black/20`} aria-live="polite"> <div className={`rounded border ${compact ? 'p-2' : 'p-3'} border-black/10 bg-white`} aria-live="polite">
{/* Header minimal tanpa detail teknis */} {/* Header minimal tanpa detail teknis */}
<div className="text-sm font-medium">Status pembayaran</div> <div className="text-sm font-medium">Status pembayaran</div>
@ -26,10 +26,10 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
{isLoading ? ( {isLoading ? (
<div className="mt-2 text-sm"> <div className="mt-2 text-sm">
<span className="inline-flex items-center gap-2" role="status"> <span className="inline-flex items-center gap-2" role="status">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
Mengecek pembayaran Mengecek pembayaran
</span> </span>
<div className="mt-1 text-[11px] text-black/60 dark:text-white/60">Kami memeriksa otomatis setiap 3 detik.</div> <div className="mt-1 text-[11px] text-black/60">Kami memeriksa otomatis setiap 3 detik.</div>
</div> </div>
) : error ? ( ) : error ? (
<div className="mt-2 text-sm text-brand-600">Gagal memuat status. Coba refresh.</div> <div className="mt-2 text-sm text-brand-600">Gagal memuat status. Coba refresh.</div>
@ -45,9 +45,9 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
<div className="text-base font-semibold">Pembayaran berhasil</div> <div className="text-base font-semibold">Pembayaran berhasil</div>
</div> </div>
{data?.grossAmount ? ( {data?.grossAmount ? (
<div className="mt-1 text-sm text-black/70 dark:text-white/70">Total dibayar: {formatIDR(data.grossAmount)}</div> <div className="mt-1 text-sm text-black/70">Total dibayar: {formatIDR(data.grossAmount)}</div>
) : null} ) : null}
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Terima kasih! Pesanan Anda sedang diproses.</div> <div className="mt-1 text-xs text-black/60">Terima kasih! Pesanan Anda sedang diproses.</div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button className="w-full sm:w-auto" onClick={() => nav.toHistory()}>Lihat riwayat pembayaran</Button> <Button className="w-full sm:w-auto" onClick={() => nav.toHistory()}>Lihat riwayat pembayaran</Button>
<Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toCheckout()}>Kembali ke checkout</Button> <Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toCheckout()}>Kembali ke checkout</Button>
@ -66,7 +66,7 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
</span> </span>
<div className="text-base font-semibold">Pembayaran belum berhasil</div> <div className="text-base font-semibold">Pembayaran belum berhasil</div>
</div> </div>
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Silakan coba lagi atau pilih metode lain.</div> <div className="mt-1 text-xs text-black/60">Silakan coba lagi atau pilih metode lain.</div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button className="w-full sm:w-auto" onClick={() => nav.toCheckout()}>Coba lagi</Button> <Button className="w-full sm:w-auto" onClick={() => nav.toCheckout()}>Coba lagi</Button>
<Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toStatus(orderId, method)}>Lihat detail status</Button> <Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toStatus(orderId, method)}>Lihat detail status</Button>
@ -75,18 +75,18 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
) : ( ) : (
<div className="mt-2"> <div className="mt-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500/15 text-yellow-700 dark:text-yellow-300"> <span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500/15 text-yellow-700">
{/* hourglass/spinner icon */} {/* hourglass/spinner icon */}
<span className="h-4 w-4 animate-spin rounded-full border-2 border-yellow-600/50 dark:border-yellow-300/80 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-yellow-600/50 border-t-transparent" aria-hidden />
</span> </span>
<div className="text-base font-semibold">Menunggu pembayaran</div> <div className="text-base font-semibold">Menunggu pembayaran</div>
</div> </div>
<div className="mt-1 text-[11px] text-black/60 dark:text-white/60">Kami memeriksa otomatis setiap 3 detik sampai selesai.</div> <div className="mt-1 text-[11px] text-black/60">Kami memeriksa otomatis setiap 3 detik sampai selesai.</div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => refetch()} aria-busy={isRefetching} disabled={isRefetching}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => refetch()} aria-busy={isRefetching} disabled={isRefetching}>
{isRefetching ? ( {isRefetching ? (
<span className="inline-flex items-center gap-2" role="status" aria-live="polite"> <span className="inline-flex items-center gap-2" role="status" aria-live="polite">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden /> <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
Memuat Memuat
</span> </span>
) : 'Refresh sekarang'} ) : 'Refresh sekarang'}

View File

@ -5,9 +5,9 @@ export function PaymentInstructions({ method, title, steps }: { method?: Payment
const finalSteps = computed.length ? computed : ['Ikuti instruksi yang muncul pada layar pembayaran.'] const finalSteps = computed.length ? computed : ['Ikuti instruksi yang muncul pada layar pembayaran.']
const finalTitle = title ?? 'Cara bayar' const finalTitle = title ?? 'Cara bayar'
return ( return (
<div className="mt-2 rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20" role="region" aria-label={finalTitle}> <div className="mt-2 rounded-lg border-2 border-gray-300 p-3 bg-white" role="region" aria-label={finalTitle}>
<div className="text-sm font-medium mb-2">{finalTitle}</div> <div className="text-sm font-medium mb-2">{finalTitle}</div>
<ol className="list-decimal list-inside space-y-2 text-sm text-black/80 dark:text-white/80"> <ol className="list-decimal list-inside space-y-2 text-sm text-gray-800">
{finalSteps.map((s, i) => ( {finalSteps.map((s, i) => (
<li key={i}>{s}</li> <li key={i}>{s}</li>
))} ))}

View File

@ -13,7 +13,7 @@ export interface PaymentMethodListProps {
} }
const baseItems: Array<{ key: PaymentMethod; title: string; subtitle: string; icon?: React.ReactNode }> = [ const baseItems: Array<{ key: PaymentMethod; title: string; subtitle: string; icon?: React.ReactNode }> = [
{ key: 'bank_transfer', title: 'Transfer bank', subtitle: 'BCA • BNI • BRI • CIMB • Mandiri • Permata', icon: <img src="/logos/logo-semua-bank.PNG" alt="Semua bank yang didukung" className="h-6 sm:h-8 object-contain" /> }, { key: 'bank_transfer', title: 'Transfer Bank', subtitle: 'BCA • BNI • BRI • CIMB • Mandiri • Permata', icon: <img src="/logos/logo-semua-bank.PNG" alt="Semua bank yang didukung" className="h-6 sm:h-8 object-contain" /> },
{ key: 'credit_card', title: 'Kartu kredit/debit', subtitle: 'Visa • MasterCard • JCB • Amex', icon: <CardLogosRow compact size="xs" /> }, { key: 'credit_card', title: 'Kartu kredit/debit', subtitle: 'Visa • MasterCard • JCB • Amex', icon: <CardLogosRow compact size="xs" /> },
{ key: 'gopay', title: 'Gopay/QRIS', subtitle: 'Scan & bayar via QR', icon: <GoPayLogosRow compact size="xs" /> }, { key: 'gopay', title: 'Gopay/QRIS', subtitle: 'Scan & bayar via QR', icon: <GoPayLogosRow compact size="xs" /> },
{ key: 'cstore', title: 'Convenience Store', subtitle: '', icon: <CStoreLogosRow compact size="xs" /> }, { key: 'cstore', title: 'Convenience Store', subtitle: '', icon: <CStoreLogosRow compact size="xs" /> },
@ -32,26 +32,26 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-sm font-medium">Metode pembayaran</div> <div className="text-sm font-medium">Metode pembayaran</div>
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 divide-y-[2px] divide-black/20 dark:divide-white/20 bg-white dark:bg-black"> <div className="rounded-lg border-2 border-black/30 divide-y-[2px] divide-black/20 bg-white">
{items.map((it) => ( {items.map((it) => (
<div key={it.key}> <div key={it.key}>
<button <button
onClick={() => !disabled && onSelect(it.key)} onClick={() => !disabled && onSelect(it.key)}
disabled={disabled} disabled={disabled}
className={`w-full text-left p-4 min-h-[52px] flex items-center justify-between ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-black/10 dark:hover:bg-white/15'} ${selected === it.key ? 'bg-black/10 dark:bg-white/20' : ''} focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-black`} className={`w-full text-left p-4 min-h-[52px] flex items-center justify-between ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-black/10'} ${selected === it.key ? 'bg-black/10' : ''} focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white`}
aria-pressed={selected === it.key} aria-pressed={selected === it.key}
aria-expanded={selected === it.key} aria-expanded={selected === it.key}
aria-controls={`panel-${it.key}`} aria-controls={`panel-${it.key}`}
> >
<div className="flex-1"> <div className="flex-1">
<div className="text-base font-semibold text-black dark:text-white">{it.title}</div> <div className="text-base font-semibold text-black">{it.title}</div>
{it.key === 'bank_transfer' && it.subtitle && ( {it.key === 'bank_transfer' && it.subtitle && (
<div className="mt-1 text-xs text-black/60 dark:text-white/60"> <div className="mt-1 text-xs text-black/60">
{it.subtitle} {it.subtitle}
</div> </div>
)} )}
{it.key === 'cpay' && it.subtitle && ( {it.key === 'cpay' && it.subtitle && (
<div className="mt-1 text-xs text-black/60 dark:text-white/60"> <div className="mt-1 text-xs text-black/60">
{it.subtitle} {it.subtitle}
</div> </div>
)} )}
@ -62,11 +62,11 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
{it.icon} {it.icon}
</span> </span>
)} )}
<span aria-hidden className={`text-black/60 dark:text-white/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}></span> <span aria-hidden className={`text-black/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}></span>
</div> </div>
</button> </button>
{selected === it.key && renderPanel && ( {selected === it.key && renderPanel && (
<div id={`panel-${it.key}`} className="p-3 bg-white dark:bg-black/20"> <div id={`panel-${it.key}`} className="p-3 bg-white">
{renderPanel(it.key)} {renderPanel(it.key)}
</div> </div>
)} )}

View File

@ -28,7 +28,7 @@ export interface PaymentSheetProps {
showStatusCTA?: boolean showStatusCTA?: boolean
} }
export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) { export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) {
const countdown = useCountdown(expireAt) const countdown = useCountdown(expireAt)
const [expanded, setExpanded] = React.useState(true) const [expanded, setExpanded] = React.useState(true)
return ( return (
@ -38,7 +38,7 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
<div className="bg-[#0c1f3f] text-white p-3 sm:p-4 flex items-center justify-between"> <div className="bg-[#0c1f3f] text-white p-3 sm:p-4 flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="rounded bg-white text-black px-2 py-1 text-[11px] sm:text-xs font-bold" aria-hidden> <div className="rounded bg-white text-black px-2 py-1 text-[11px] sm:text-xs font-bold" aria-hidden>
ZARA SIMAYA
</div> </div>
<div className="font-semibold text-sm sm:text-base">{merchantName}</div> <div className="font-semibold text-sm sm:text-base">{merchantName}</div>
</div> </div>
@ -57,17 +57,19 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
className={`text-white/80 transition-transform ${expanded ? '' : 'rotate-180'} focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-white/80 focus-visible:ring-offset-2`} className={`text-white/80 transition-transform ${expanded ? '' : 'rotate-180'} focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-white/80 focus-visible:ring-offset-2`}
> >
˅ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M7 14L12 9L17 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button> </button>
</div> </div>
</div> </div>
{/* Amount panel */} {/* Amount panel */}
{expanded && ( {expanded && (
<div className="p-4 border-b border-black/10 dark:border-white/10 flex items-start justify-between"> <div className="p-4 border-b border-black/10 flex items-start justify-between">
<div> <div>
<div className="text-xs text-black/60 dark:text-white/60">Total</div> <div className="text-xs text-black/60">Total</div>
<div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div> <div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
<div className="text-xs text-black/60 dark:text-white/60">Order ID #{orderId}</div> <div className="text-xs text-black/60">Order ID #{orderId}</div>
</div> </div>
</div> </div>
)} )}
@ -78,11 +80,11 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
</div> </div>
{/* Sticky CTA (mobile-friendly) */} {/* Sticky CTA (mobile-friendly) */}
{showStatusCTA && ( {showStatusCTA && (
<div className="sticky bottom-0 bg-white/95 dark:bg-neutral-900/95 backdrop-blur border-t border-black/10 dark:border-white/10 p-3 pb-[env(safe-area-inset-bottom)]"> <div className="sticky bottom-0 bg-white/95 backdrop-blur border-t border-black/10 p-3 pb-[env(safe-area-inset-bottom)]">
<Link <Link
to={`/payments/${orderId}/status`} to={`/payments/${orderId}/status`}
aria-label="Buka halaman status pembayaran" aria-label="Buka halaman Status Pembayaran"
className="w-full block text-center rounded bg-[#0c1f3f] text-white py-3 text-base font-semibold hover:bg-[#0a1a35] focus:outline-none focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:ring-[#0c1f3f]" className="w-full block text-center rounded bg-[#0c1f3f] !text-white py-3 text-base font-semibold hover:bg-[#0a1a35] hover:!text-white focus:outline-none focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:ring-[#0c1f3f] visited:!text-white active:!text-white"
> >
Cek status pembayaran Cek status pembayaran
</Link> </Link>

View File

@ -2,14 +2,14 @@
export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) { export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) {
return ( return (
<div className={`text-[10px] ${location === 'sheet' ? 'mt-2' : ''} text-black/50 dark:text-white/50 flex items-center gap-2`}> <div className={`text-[10px] ${location === 'sheet' ? 'mt-2' : ''} text-black/50 flex items-center gap-2`}>
<span aria-hidden>🔒</span> <span aria-hidden>🔒</span>
<span>Secure payments by Midtrans</span> <span>Secure payments by Midtrans</span>
<span aria-hidden className="ml-auto flex items-center gap-1"> <span aria-hidden className="ml-auto flex items-center gap-1">
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">Visa</span> <span className="inline-block rounded bg-black/10 px-1">Visa</span>
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">Mastercard</span> <span className="inline-block rounded bg-black/10 px-1">Mastercard</span>
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">JCB</span> <span className="inline-block rounded bg-black/10 px-1">JCB</span>
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">Amex</span> <span className="inline-block rounded bg-black/10 px-1">Amex</span>
</span> </span>
</div> </div>
) )

View File

@ -72,13 +72,13 @@ export function CheckoutPage() {
</Alert> </Alert>
)} )}
<PaymentSheet merchantName="Zara" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}> <PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}>
{/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */} {/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */}
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-sm font-medium">Konfirmasi data checkout</div> <div className="text-sm font-medium">Konfirmasi data checkout</div>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Nama</div> <div className="text-xs text-gray-600">Nama</div>
<input <input
type="text" type="text"
className="w-full rounded border px-3 py-2" className="w-full rounded border px-3 py-2"
@ -87,7 +87,7 @@ export function CheckoutPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Email atau HP</div> <div className="text-xs text-gray-600">Email atau HP</div>
<input <input
type="text" type="text"
className="w-full rounded border px-3 py-2" className="w-full rounded border px-3 py-2"
@ -96,7 +96,7 @@ export function CheckoutPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Alamat</div> <div className="text-xs text-gray-600">Alamat</div>
<input <input
type="text" type="text"
className="w-full rounded border px-3 py-2" className="w-full rounded border px-3 py-2"
@ -105,7 +105,7 @@ export function CheckoutPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Catatan</div> <div className="text-xs text-gray-600">Catatan</div>
<textarea <textarea
className="w-full rounded border px-3 py-2" className="w-full rounded border px-3 py-2"
value={form.notes} value={form.notes}
@ -178,7 +178,7 @@ export function CheckoutPage() {
if (m === 'bank_transfer') { if (m === 'bank_transfer') {
return ( return (
<div className="space-y-2" aria-live="polite"> <div className="space-y-2" aria-live="polite">
<div className="text-xs text-black/60 dark:text-white/60">Pilih bank untuk membuat Virtual Account</div> <div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}> <div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => ( {(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
<button <button
@ -189,7 +189,7 @@ export function CheckoutPage() {
setIsBusy(true) setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300) setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}} }}
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10" className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
aria-label={`Pilih bank ${bk.toUpperCase()}`} aria-label={`Pilih bank ${bk.toUpperCase()}`}
> >
<BankLogo bank={bk} /> <BankLogo bank={bk} />
@ -197,8 +197,8 @@ export function CheckoutPage() {
))} ))}
</div> </div>
{isBusy && ( {isBusy && (
<div className="text-xs text-black/60 dark:text-white/60 inline-flex items-center gap-2"> <div className="text-xs text-gray-600 inline-flex items-center gap-2">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden /> <span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
Menyiapkan VA Menyiapkan VA
</div> </div>
)} )}
@ -208,7 +208,7 @@ export function CheckoutPage() {
if (m === 'cstore') { if (m === 'cstore') {
return ( return (
<div className="space-y-2" aria-live="polite"> <div className="space-y-2" aria-live="polite">
<div className="text-xs text-black/60 dark:text-white/60">Pilih toko untuk membuat kode pembayaran</div> <div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}> <div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['alfamart','indomaret'] as const).map((st) => ( {(['alfamart','indomaret'] as const).map((st) => (
<button <button
@ -219,7 +219,7 @@ export function CheckoutPage() {
setIsBusy(true) setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300) setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}} }}
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10" className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
aria-label={`Pilih toko ${st.toUpperCase()}`} aria-label={`Pilih toko ${st.toUpperCase()}`}
> >
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />} {st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
@ -259,7 +259,7 @@ export function CheckoutPage() {
)} )}
</PaymentSheet> </PaymentSheet>
<div className="text-xs text-black/60 dark:text-white/60"> <div className="text-xs text-gray-600">
API Base: {apiBase ?? '—'} | Client Key: {clientKey ? 'OK' : '—'} API Base: {apiBase ?? '—'} | Client Key: {clientKey ? 'OK' : '—'}
</div> </div>
</div> </div>

View File

@ -12,10 +12,10 @@ export function DemoStorePage() {
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="text-sm font-medium">Produk: T-Shirt Hitam</div> <div className="text-sm font-medium">Produk: T-Shirt Hitam</div>
<div className="text-xs text-black/60 dark:text-white/60">Ukuran: M Bahan: Cotton</div> <div className="text-xs text-black/60">Ukuran: M Bahan: Cotton</div>
<div className="mt-2 text-lg font-semibold">Rp 3.500.000</div> <div className="mt-2 text-lg font-semibold">Rp 3.500.000</div>
</div> </div>
<div className="w-24 h-24 rounded bg-black/10 dark:bg-white/10" aria-hidden /> <div className="w-24 h-24 rounded bg-black/10" aria-hidden />
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Button <Button

View File

@ -4,7 +4,7 @@ export function NotFoundPage() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<h1 className="text-xl font-semibold">Halaman tidak ditemukan</h1> <h1 className="text-xl font-semibold">Halaman tidak ditemukan</h1>
<p className="text-sm text-black/70 dark:text-white/70">Periksa URL atau kembali ke checkout.</p> <p className="text-sm text-black/70">Periksa URL atau kembali ke checkout.</p>
<Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link> <Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link>
</div> </div>
) )

View File

@ -53,7 +53,7 @@ export function PayPage() {
} }
}, [token]) }, [token])
const merchantName = useMemo(() => 'Demo Merchant', []) const merchantName = useMemo(() => 'Simaya Retail', [])
const isExpired = expireAt ? Date.now() > expireAt : false const isExpired = expireAt ? Date.now() > expireAt : false
const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => { const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => {
@ -86,20 +86,20 @@ export function PayPage() {
expireAt={expireAt} expireAt={expireAt}
showStatusCTA={false} showStatusCTA={false}
> >
<div className="space-y-4"> <div className="space-y-4 px-4 py-6">
<Alert title={title}>{msg}</Alert> <Alert title={title}>{msg}</Alert>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="primary" variant="secondary"
onClick={() => { try { window.location.reload() } catch {} }} onClick={() => { try { window.location.reload() } catch { } }}
> >
Muat ulang Muat ulang
</Button> </Button>
<a <a
href="mailto:support@example.com?subject=Permintaan%20Link%20Pembayaran&body=Order%20ID:%20" href="mailto:retailimaya@gmail.com?subject=Permintaan%20Link%20Pembayaran&body=Order%20ID:%20"
className="inline-flex items-center px-3 py-2 rounded border bg-black text-white dark:bg-white dark:text-black" className="inline-flex items-center px-3 py-2 rounded border bg-gray-800 !text-white hover:!text-white focus:!text-white visited:!text-white active:!text-white"
> >
Hubungi admin Hubungi Admin
</a> </a>
</div> </div>
</div> </div>
@ -115,7 +115,7 @@ export function PayPage() {
expireAt={expireAt} expireAt={expireAt}
showStatusCTA={currentStep === 3} showStatusCTA={currentStep === 3}
> >
<div className="space-y-4"> <div className="space-y-4 px-4 py-6">
{currentStep === 2 && ( {currentStep === 2 && (
<div className="space-y-3"> <div className="space-y-3">
<PaymentMethodList <PaymentMethodList
@ -127,7 +127,7 @@ export function PayPage() {
} else if (m === 'cpay') { } else if (m === 'cpay') {
try { try {
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank') window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
} catch {} } catch { }
} else { } else {
setIsBusy(true) setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300) setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
@ -147,9 +147,9 @@ export function PayPage() {
if (m === 'bank_transfer') { if (m === 'bank_transfer') {
return ( return (
<div className="space-y-2" aria-live="polite"> <div className="space-y-2" aria-live="polite">
<div className="text-xs text-black/60 dark:text-white/60">Pilih bank untuk membuat Virtual Account</div> <div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}> <div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => ( {(['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata'] as BankKey[]).map((bk) => (
<button <button
key={bk} key={bk}
type="button" type="button"
@ -158,7 +158,7 @@ export function PayPage() {
setIsBusy(true) setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300) setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}} }}
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10" className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
aria-label={`Pilih bank ${bk.toUpperCase()}`} aria-label={`Pilih bank ${bk.toUpperCase()}`}
> >
<BankLogo bank={bk} /> <BankLogo bank={bk} />
@ -166,8 +166,8 @@ export function PayPage() {
))} ))}
</div> </div>
{isBusy && ( {isBusy && (
<div className="text-xs text-black/60 dark:text-white/60 inline-flex items-center gap-2"> <div className="text-xs text-gray-600 inline-flex items-center gap-2">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden /> <span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
Menyiapkan VA Menyiapkan VA
</div> </div>
)} )}
@ -177,9 +177,9 @@ export function PayPage() {
if (m === 'cstore') { if (m === 'cstore') {
return ( return (
<div className="space-y-2" aria-live="polite"> <div className="space-y-2" aria-live="polite">
<div className="text-xs text-black/60 dark:text-white/60">Pilih toko untuk membuat kode pembayaran</div> <div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}> <div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['alfamart','indomaret'] as const).map((st) => ( {(['alfamart', 'indomaret'] as const).map((st) => (
<button <button
key={st} key={st}
type="button" type="button"
@ -188,7 +188,7 @@ export function PayPage() {
setIsBusy(true) setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300) setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}} }}
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10" className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center hover:bg-gray-100"
aria-label={`Pilih toko ${st.toUpperCase()}`} aria-label={`Pilih toko ${st.toUpperCase()}`}
> >
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />} {st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}

View File

@ -32,18 +32,18 @@ export function PaymentStatusPage() {
function statusBadgeClass(s: PaymentStatusResponse['status']) { function statusBadgeClass(s: PaymentStatusResponse['status']) {
switch (s) { switch (s) {
case 'pending': case 'pending':
return 'inline-block rounded px-2 py-0.5 text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200' return 'inline-block rounded px-2 py-0.5 text-xs bg-yellow-100 text-yellow-800'
case 'settlement': case 'settlement':
case 'capture': case 'capture':
return 'inline-block rounded px-2 py-0.5 text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' return 'inline-block rounded px-2 py-0.5 text-xs bg-green-100 text-green-800'
case 'deny': case 'deny':
case 'cancel': case 'cancel':
case 'expire': case 'expire':
case 'refund': case 'refund':
case 'chargeback': case 'chargeback':
return 'inline-block rounded px-2 py-0.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' return 'inline-block rounded px-2 py-0.5 text-xs bg-red-100 text-red-800'
default: default:
return 'inline-block rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-200' return 'inline-block rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-800'
} }
} }
@ -53,7 +53,7 @@ export function PaymentStatusPage() {
<div className="card p-4"> <div className="card p-4">
<div className="text-sm">Order ID: {orderId}</div> <div className="text-sm">Order ID: {orderId}</div>
{method || data?.method ? ( {method || data?.method ? (
<div className="text-xs text-black/60 dark:text-white/60">Metode: {data?.method ?? method}</div> <div className="text-xs text-gray-600">Metode: {data?.method ?? method}</div>
) : null} ) : null}
<div className="mt-2">Status: {isLoading ? ( <div className="mt-2">Status: {isLoading ? (
<span className="font-medium">memuat</span> <span className="font-medium">memuat</span>
@ -62,7 +62,7 @@ export function PaymentStatusPage() {
) : ( ) : (
<span className={statusBadgeClass(statusText)}>{statusText}</span> <span className={statusBadgeClass(statusText)}>{statusText}</span>
)}</div> )}</div>
<div className="mt-1 text-xs text-black/60 dark:text-white/60"> <div className="mt-1 text-xs text-gray-600">
{isFinal ? 'Status final — polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'} {isFinal ? 'Status final — polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'}
</div> </div>
{/* Method-specific details */} {/* Method-specific details */}
@ -70,18 +70,18 @@ export function PaymentStatusPage() {
<div className="mt-3 space-y-2 text-sm"> <div className="mt-3 space-y-2 text-sm">
{/* Bank Transfer / VA */} {/* Bank Transfer / VA */}
{data.vaNumber ? ( {data.vaNumber ? (
<div className="rounded border border-black/10 dark:border-white/10 p-2"> <div className="rounded border border-gray-200 p-2">
<div className="font-medium">Virtual Account</div> <div className="font-medium">Virtual Account</div>
<div>VA Number: <span className="font-mono">{data.vaNumber}</span></div> <div>VA Number: <span className="font-mono">{data.vaNumber}</span></div>
{data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null} {data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null}
{data.billKey && data.billerCode ? ( {data.billKey && data.billerCode ? (
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Mandiri E-Channel Bill Key: {data.billKey}, Biller: {data.billerCode}</div> <div className="mt-1 text-xs text-gray-600">Mandiri E-Channel Bill Key: {data.billKey}, Biller: {data.billerCode}</div>
) : null} ) : null}
</div> </div>
) : null} ) : null}
{/* C-store */} {/* C-store */}
{data.store || data.paymentCode ? ( {data.store || data.paymentCode ? (
<div className="rounded border border-black/10 dark:border-white/10 p-2"> <div className="rounded border border-gray-200 p-2">
<div className="font-medium">Convenience Store</div> <div className="font-medium">Convenience Store</div>
{data.store ? <div>Store: {data.store}</div> : null} {data.store ? <div>Store: {data.store}</div> : null}
{data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null} {data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null}
@ -89,9 +89,9 @@ export function PaymentStatusPage() {
) : null} ) : null}
{/* E-money (GoPay/QRIS) */} {/* E-money (GoPay/QRIS) */}
{data.actions && data.actions.length > 0 ? ( {data.actions && data.actions.length > 0 ? (
<div className="rounded border border-black/10 dark:border-white/10 p-2"> <div className="rounded border border-gray-200 p-2">
<div className="font-medium">QR / Deeplink</div> <div className="font-medium">QR / Deeplink</div>
<div className="text-xs text-black/60 dark:text-white/60">Gunakan link berikut untuk membuka aplikasi pembayaran.</div> <div className="text-xs text-gray-600">Gunakan link berikut untuk membuka aplikasi pembayaran.</div>
<div className="mt-1 flex flex-wrap gap-2"> <div className="mt-1 flex flex-wrap gap-2">
{data.actions.map((a, i) => ( {data.actions.map((a, i) => (
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600"> <a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600">
@ -103,7 +103,7 @@ export function PaymentStatusPage() {
) : null} ) : null}
{/* Card */} {/* Card */}
{data.maskedCard ? ( {data.maskedCard ? (
<div className="rounded border border-black/10 dark:border-white/10 p-2"> <div className="rounded border border-gray-200 p-2">
<div className="font-medium">Kartu</div> <div className="font-medium">Kartu</div>
<div>Masked Card: <span className="font-mono">{data.maskedCard}</span></div> <div>Masked Card: <span className="font-mono">{data.maskedCard}</span></div>
</div> </div>

View File

@ -13,13 +13,11 @@ body {
background-color: #ffffff; background-color: #ffffff;
color: #000000; color: #000000;
} }
.light, html[data-theme="light"] body { background-color: #ffffff; color: #000000; }
.dark body { background-color: #000000; color: #ffffff; }
a { color: #dc2626; }
a:hover { color: #b91c1c; }
.focus-ring { outline: none; box-shadow: 0 0 0 2px rgba(220,38,38,1), 0 0 0 4px var(--focus-offset, #ffffff); } a { color: #0c1f3f; }
.dark .focus-ring { box-shadow: 0 0 0 2px rgba(220,38,38,1), 0 0 0 4px #000000; } a:hover { color: #0a1a35; }
.focus-ring { outline: none; box-shadow: 0 0 0 2px rgba(37,99,235,1), 0 0 0 4px var(--focus-offset, #ffffff); }
.card { .card {
border-radius: var(--radius); border-radius: var(--radius);
@ -27,7 +25,3 @@ a:hover { color: #b91c1c; }
background-color: #ffffff; background-color: #ffffff;
box-shadow: 0 1px 2px rgba(0,0,0,0.05); box-shadow: 0 1px 2px rgba(0,0,0,0.05);
} }
.dark .card {
background-color: #000000;
border-color: rgba(255,255,255,0.2);
}

View File

@ -7,20 +7,20 @@ export default {
extend: { extend: {
colors: { colors: {
brand: { brand: {
50: '#fef2f2', 50: '#f1f5fb',
100: '#fee2e2', 100: '#e3e9f5',
200: '#fecaca', 200: '#c7d3ea',
300: '#fca5a5', 300: '#a6b9dd',
400: '#f87171', 400: '#6f8bc8',
500: '#ef4444', 500: '#3a5da7',
600: '#dc2626', 600: '#0c1f3f',
700: '#b91c1c', 700: '#0a1a35',
800: '#991b1b', 800: '#08152a',
900: '#7f1d1d', 900: '#050f20',
}, },
}, },
boxShadow: { boxShadow: {
focus: '0 0 0 3px rgba(220,38,38,0.45)', focus: '0 0 0 3px rgba(37,99,235,0.45)',
}, },
}, },
}, },