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 { ToastHost } from '../components/ui/toast'
export function AppLayout() {
return (
<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">
<Link to="/checkout" className="font-semibold text-brand-600">Core Midtrans CIFO</Link>
<nav className="flex gap-4 text-sm">
@ -11,13 +12,14 @@ export function AppLayout() {
<Link to="/history" className="hover:underline">History</Link>
</nav>
</div>
</header>
</header> */}
<main className="mx-auto max-w-5xl px-4 py-6">
<Outlet />
</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
</footer>
</footer> */}
</div>
)
}

View File

@ -4,7 +4,7 @@ export function Alert({ title, children }: { title: string; children?: React.Rea
<div className="text-brand-600"></div>
<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>
)

View File

@ -2,13 +2,13 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
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: {
variant: {
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',
outline: 'border border-black text-black hover:bg-black/5 dark:border-white dark:text-white dark:hover:bg-white/10',
secondary: 'bg-white text-black border border-black/10 hover:bg-black/5',
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' },
},

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 { PaymentInstructions } from './PaymentInstructions'
import { BcaInstructionList } from './BcaInstructionList'
import { TrustStrip } from './TrustStrip'
import { type BankKey } from './PaymentLogos'
import { postCharge } from '../../../services/api'
import { Alert } from '../../../components/alert/Alert'
import { InlinePaymentStatus } from './InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts
const attemptedChargeKeys = new Set<string>()
@ -29,7 +29,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
function copy(text: string, label: string) {
if (!text) return
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)
React.useEffect(() => {
@ -125,7 +125,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
}
run()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected])
// Auto-show instructions when BCA is selected to reduce confusion
@ -137,43 +137,43 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
return (
<div className="space-y-3">
<div className="font-medium">Transfer bank</div>
<div className="font-medium">Transfer Bank</div>
{selected && (
<div className="flex items-center gap-2 text-base">
<span className="text-black/60 dark:text-white/60">Bank:</span>
<span className="text-black/80 dark:text-white/80 font-semibold">{selected.toUpperCase()}</span>
<span className="text-black/60">Bank:</span>
<span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
</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 && (
<Alert title="Gagal membuat VA">{errorMessage}</Alert>
)}
{selected && (
<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 text-black/70 dark:text-white/70">
<div className="text-sm text-black/70">
{vaCode ? (
<span>
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 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.'}
</span>
)}
{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 && (
<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 className="mt-2 flex gap-2">
<Button variant="outline" 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(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button>
<Button variant="secondary" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button>
</div>
</div>
</div>
@ -187,7 +187,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
{selected === 'bca' ? (
<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>
<PaymentInstructions method="bank_transfer" />
</div>
@ -195,7 +195,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
</div>
)}
{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">
{(!vaCode || errorMessage) && (
@ -280,7 +280,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
>
{busy ? (
<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
</span>
) : 'Buat VA'}
@ -295,7 +295,6 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
Buka halaman status
</Button>
</div>
<TrustStrip />
</div>
)
}

View File

@ -5,13 +5,13 @@ type TabKey = 'mobile' | 'atm' | 'ib'
export function BcaInstructionList() {
const [tab, setTab] = React.useState<TabKey>('mobile')
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="text-sm font-medium mb-2">Instruksi BCA</div>
<div
role="tablist"
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
type="button"
@ -20,7 +20,7 @@ export function BcaInstructionList() {
aria-selected={tab === 'mobile'}
aria-controls="panel-bca-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
</button>
@ -31,7 +31,7 @@ export function BcaInstructionList() {
aria-selected={tab === 'atm'}
aria-controls="panel-bca-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
</button>
@ -42,7 +42,7 @@ export function BcaInstructionList() {
aria-selected={tab === 'ib'}
aria-controls="panel-bca-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
</button>
@ -63,7 +63,7 @@ export function BcaInstructionList() {
</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.
</div>
</div>
@ -111,7 +111,7 @@ function StepsKlikBca() {
function StepList({ steps }: { steps: string[] }) {
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) => (
<li key={i}>{s}</li>
))}

View File

@ -1,8 +1,8 @@
import { Button } from '../../../components/ui/button'
import { toast } from '../../../components/ui/toast'
import { usePaymentNavigation } from '../lib/navigation'
import React from 'react'
import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus'
@ -36,7 +36,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
if (typeof res?.store === 'string') setStoreFromRes(res.store)
}
} 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 {
if (!cancelled) setBusy(false)
cstoreTasks.delete(chargeKey)
@ -60,7 +60,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
if (typeof res?.store === 'string') setStoreFromRes(res.store)
}
} 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)
} finally {
if (!cancelled) setBusy(false)
@ -74,14 +74,14 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
function copy(text: string, label: string) {
if (!text) return
navigator.clipboard?.writeText(text)
alert(`${label} disalin: ${text}`)
toast.success(`${label} disalin: ${text}`)
}
return (
<div className="space-y-3">
<div className="font-medium">Convenience Store</div>
{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
type="button"
@ -93,17 +93,17 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
</button>
{showGuide && <PaymentInstructions method="cstore" />}
{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="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>
{!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 && (
<div className="inline-flex items-center gap-2 text-xs text-black/60 dark:text-white/60">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />
<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-gray-400 border-t-transparent" aria-hidden />
Membuat kode
</div>
)}
@ -125,7 +125,6 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
</Button>
<InlinePaymentStatus orderId={orderId} method="cstore" />
</div>
<TrustStrip />
</div>
)
}

View File

@ -2,13 +2,13 @@ import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation'
import React from 'react'
import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { CardLogosRow } from './PaymentLogos'
import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds'
import { Logger } from '../../../lib/logger'
import { Env } from '../../../lib/env'
import { postCharge } from '../../../services/api'
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 }) {
const nav = usePaymentNavigation()
@ -41,16 +41,16 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
<CardLogosRow compact />
<div className="space-y-2">
<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))} />
</label>
<div className="grid grid-cols-2 gap-2">
<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))} />
</label>
<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))} />
</label>
</div>
@ -69,7 +69,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
</button>
{showGuide && <PaymentInstructions method="credit_card" />}
{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">
<Button
@ -89,24 +89,24 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
// Minimal validation
if (sanitizedCard.length < 13 || sanitizedCard.length > 19) {
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
}
const mmNum = Number(mm)
if (!mm || mm.length !== 2 || mmNum < 1 || mmNum > 12) {
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
}
if (!yy || yy.length < 2) {
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
}
const cvvSan = cvv.replace(/[^0-9]/g, '')
if (cvvSan.length < 3 || cvvSan.length > 4) {
Logger.warn('card.input.invalid', { reason: 'cvv_length', length: cvvSan.length })
alert('CVV harus 34 digit.')
toast.error('CVV harus 34 digit.')
return
}
if (Env.LOG_LEVEL === 'debug') {
@ -132,7 +132,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
}
} catch (e) {
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 {
setBusy(false)
}
@ -140,7 +140,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
>
{busy ? (
<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
</span>
) : 'Bayar sekarang'}
@ -156,7 +156,6 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
</div>
{/* Status inline dengan polling otomatis */}
<InlinePaymentStatus orderId={orderId} method="credit_card" compact />
<TrustStrip />
</div>
)
}

View File

@ -2,10 +2,10 @@ import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation'
import React from 'react'
import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { GoPayLogosRow } from './PaymentLogos'
import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
// Global guards/tasks to stabilize QR generation across StrictMode remounts
const attemptedChargeKeys = new Set<string>()
@ -48,7 +48,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
a.target = '_blank'
a.click()
} else {
alert('QR belum tersedia. Klik "Buat QR" terlebih dulu.')
toast.error('QR belum tersedia. Klik "Buat QR" terlebih dulu.')
}
}
return (
@ -68,13 +68,13 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
<div className="font-medium">GoPay / QRIS</div>
<GoPayLogosRow compact />
<div className="flex items-center gap-2 text-xs">
<span className="text-black/60 dark:text-white/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">
<span className="text-black/60">Mode:</span>
<div className="inline-flex rounded-md border-2 border-black/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran">
<button
type="button"
onClick={() => setMode('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
</button>
@ -82,25 +82,25 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
type="button"
onClick={() => setMode('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
</button>
</div>
</div>
<div className="rounded border border-black/10 dark:border-white/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="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="rounded border border-black/10 p-3 flex flex-col items-center gap-2">
<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 bg-white">
{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="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />
<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 border-t-transparent" aria-hidden />
Membuat QR
</span>
) : qrUrl ? (
<img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto" />
) : null}
</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 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>
@ -125,7 +125,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
/>
</div>
{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">
<InlinePaymentStatus orderId={orderId} method={mode} />
@ -139,14 +139,13 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
>
{busy ? (
<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
</span>
) : 'Buka halaman status'}
</Button>
</div>
</div>
<TrustStrip />
</div>
)
}
@ -176,7 +175,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
onChargeInitiated?.()
}
} catch (e) {
if (!cancelled) alert(`Gagal membuat QR: ${(e as Error).message}`)
if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`)
} finally {
if (!cancelled) {
setBusy(false)
@ -205,7 +204,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
onChargeInitiated?.()
}
} 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)
} finally {
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)
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 */}
<div className="text-sm font-medium">Status pembayaran</div>
@ -26,10 +26,10 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
{isLoading ? (
<div className="mt-2 text-sm">
<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
</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>
) : error ? (
<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>
{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}
<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">
<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>
@ -66,7 +66,7 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
</span>
<div className="text-base font-semibold">Pembayaran belum berhasil</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">
<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>
@ -75,18 +75,18 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
) : (
<div className="mt-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 */}
<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>
<div className="text-base font-semibold">Menunggu pembayaran</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">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => refetch()} aria-busy={isRefetching} disabled={isRefetching}>
{isRefetching ? (
<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
</span>
) : '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 finalTitle = title ?? 'Cara bayar'
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>
<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) => (
<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 }> = [
{ 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: '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" /> },
@ -32,26 +32,26 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
return (
<div className="space-y-3">
<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) => (
<div key={it.key}>
<button
onClick={() => !disabled && onSelect(it.key)}
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-expanded={selected === it.key}
aria-controls={`panel-${it.key}`}
>
<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 && (
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
<div className="mt-1 text-xs text-black/60">
{it.subtitle}
</div>
)}
{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}
</div>
)}
@ -62,11 +62,11 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
{it.icon}
</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>
</button>
{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)}
</div>
)}

View File

@ -28,7 +28,7 @@ export interface PaymentSheetProps {
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 [expanded, setExpanded] = React.useState(true)
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="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>
ZARA
SIMAYA
</div>
<div className="font-semibold text-sm sm:text-base">{merchantName}</div>
</div>
@ -57,17 +57,19 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
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`}
>
˅
<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>
</div>
</div>
{/* Amount panel */}
{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 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-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>
)}
@ -78,11 +80,11 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
</div>
{/* Sticky CTA (mobile-friendly) */}
{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
to={`/payments/${orderId}/status`}
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]"
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] 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
</Link>

View File

@ -2,14 +2,14 @@
export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) {
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>Secure payments by Midtrans</span>
<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 dark:bg-white/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 dark:bg-white/10 px-1">Amex</span>
<span className="inline-block rounded bg-black/10 px-1">Visa</span>
<span className="inline-block rounded bg-black/10 px-1">Mastercard</span>
<span className="inline-block rounded bg-black/10 px-1">JCB</span>
<span className="inline-block rounded bg-black/10 px-1">Amex</span>
</span>
</div>
)

View File

@ -72,13 +72,13 @@ export function CheckoutPage() {
</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) */}
{currentStep === 1 && (
<div className="space-y-3">
<div className="text-sm font-medium">Konfirmasi data checkout</div>
<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
type="text"
className="w-full rounded border px-3 py-2"
@ -87,7 +87,7 @@ export function CheckoutPage() {
/>
</label>
<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
type="text"
className="w-full rounded border px-3 py-2"
@ -96,7 +96,7 @@ export function CheckoutPage() {
/>
</label>
<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
type="text"
className="w-full rounded border px-3 py-2"
@ -105,7 +105,7 @@ export function CheckoutPage() {
/>
</label>
<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
className="w-full rounded border px-3 py-2"
value={form.notes}
@ -178,7 +178,7 @@ export function CheckoutPage() {
if (m === 'bank_transfer') {
return (
<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' : ''}`}>
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
<button
@ -189,7 +189,7 @@ export function CheckoutPage() {
setIsBusy(true)
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()}`}
>
<BankLogo bank={bk} />
@ -197,8 +197,8 @@ export function CheckoutPage() {
))}
</div>
{isBusy && (
<div className="text-xs text-black/60 dark:text-white/60 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 />
<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-gray-400 border-t-transparent" aria-hidden />
Menyiapkan VA
</div>
)}
@ -208,7 +208,7 @@ export function CheckoutPage() {
if (m === 'cstore') {
return (
<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' : ''}`}>
{(['alfamart','indomaret'] as const).map((st) => (
<button
@ -219,7 +219,7 @@ export function CheckoutPage() {
setIsBusy(true)
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()}`}
>
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
@ -259,7 +259,7 @@ export function CheckoutPage() {
)}
</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' : '—'}
</div>
</div>

View File

@ -12,10 +12,10 @@ export function DemoStorePage() {
<div className="flex items-start justify-between gap-4">
<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>
<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 className="mt-4">
<Button

View File

@ -4,7 +4,7 @@ export function NotFoundPage() {
return (
<div className="space-y-3">
<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>
</div>
)

View File

@ -53,7 +53,7 @@ export function PayPage() {
}
}, [token])
const merchantName = useMemo(() => 'Demo Merchant', [])
const merchantName = useMemo(() => 'Simaya Retail', [])
const isExpired = expireAt ? Date.now() > expireAt : false
const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => {
@ -86,20 +86,20 @@ export function PayPage() {
expireAt={expireAt}
showStatusCTA={false}
>
<div className="space-y-4">
<div className="space-y-4 px-4 py-6">
<Alert title={title}>{msg}</Alert>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => { try { window.location.reload() } catch {} }}
variant="secondary"
onClick={() => { try { window.location.reload() } catch { } }}
>
Muat ulang
</Button>
<a
href="mailto:support@example.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"
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-gray-800 !text-white hover:!text-white focus:!text-white visited:!text-white active:!text-white"
>
Hubungi admin
Hubungi Admin
</a>
</div>
</div>
@ -115,7 +115,7 @@ export function PayPage() {
expireAt={expireAt}
showStatusCTA={currentStep === 3}
>
<div className="space-y-4">
<div className="space-y-4 px-4 py-6">
{currentStep === 2 && (
<div className="space-y-3">
<PaymentMethodList
@ -127,7 +127,7 @@ export function PayPage() {
} else if (m === 'cpay') {
try {
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
} catch {}
} catch { }
} else {
setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
@ -147,9 +147,9 @@ export function PayPage() {
if (m === 'bank_transfer') {
return (
<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' : ''}`}>
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
{(['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata'] as BankKey[]).map((bk) => (
<button
key={bk}
type="button"
@ -158,7 +158,7 @@ export function PayPage() {
setIsBusy(true)
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()}`}
>
<BankLogo bank={bk} />
@ -166,8 +166,8 @@ export function PayPage() {
))}
</div>
{isBusy && (
<div className="text-xs text-black/60 dark:text-white/60 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 />
<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-gray-400 border-t-transparent" aria-hidden />
Menyiapkan VA
</div>
)}
@ -177,9 +177,9 @@ export function PayPage() {
if (m === 'cstore') {
return (
<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' : ''}`}>
{(['alfamart','indomaret'] as const).map((st) => (
{(['alfamart', 'indomaret'] as const).map((st) => (
<button
key={st}
type="button"
@ -188,7 +188,7 @@ export function PayPage() {
setIsBusy(true)
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()}`}
>
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}

View File

@ -32,18 +32,18 @@ export function PaymentStatusPage() {
function statusBadgeClass(s: PaymentStatusResponse['status']) {
switch (s) {
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 '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 'cancel':
case 'expire':
case 'refund':
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:
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="text-sm">Order ID: {orderId}</div>
{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}
<div className="mt-2">Status: {isLoading ? (
<span className="font-medium">memuat</span>
@ -62,7 +62,7 @@ export function PaymentStatusPage() {
) : (
<span className={statusBadgeClass(statusText)}>{statusText}</span>
)}</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.'}
</div>
{/* Method-specific details */}
@ -70,18 +70,18 @@ export function PaymentStatusPage() {
<div className="mt-3 space-y-2 text-sm">
{/* Bank Transfer / VA */}
{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>VA Number: <span className="font-mono">{data.vaNumber}</span></div>
{data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null}
{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}
</div>
) : null}
{/* C-store */}
{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>
{data.store ? <div>Store: {data.store}</div> : null}
{data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null}
@ -89,9 +89,9 @@ export function PaymentStatusPage() {
) : null}
{/* E-money (GoPay/QRIS) */}
{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="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">
{data.actions.map((a, i) => (
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600">
@ -103,7 +103,7 @@ export function PaymentStatusPage() {
) : null}
{/* Card */}
{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>Masked Card: <span className="font-mono">{data.maskedCard}</span></div>
</div>

View File

@ -13,21 +13,15 @@ body {
background-color: #ffffff;
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); }
.dark .focus-ring { box-shadow: 0 0 0 2px rgba(220,38,38,1), 0 0 0 4px #000000; }
a { color: #0c1f3f; }
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 {
border-radius: var(--radius);
border: 1px solid rgba(0,0,0,0.1);
background-color: #ffffff;
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: {
colors: {
brand: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
50: '#f1f5fb',
100: '#e3e9f5',
200: '#c7d3ea',
300: '#a6b9dd',
400: '#6f8bc8',
500: '#3a5da7',
600: '#0c1f3f',
700: '#0a1a35',
800: '#08152a',
900: '#050f20',
},
},
boxShadow: {
focus: '0 0 0 3px rgba(220,38,38,0.45)',
focus: '0 0 0 3px rgba(37,99,235,0.45)',
},
},
},