259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
import { Alert } from '../components/alert/Alert'
|
|
import { Button } from '../components/ui/button'
|
|
import { Env } from '../lib/env'
|
|
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
|
import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
|
|
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
|
|
import { BankTransferPanel } from '../features/payments/components/BankTransferPanel'
|
|
import { CardPanel } from '../features/payments/components/CardPanel'
|
|
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
|
import { CStorePanel } from '../features/payments/components/CStorePanel'
|
|
import { BankLogo, type BankKey } from '../features/payments/components/PaymentLogos'
|
|
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
|
import { Logger } from '../lib/logger'
|
|
import React from 'react'
|
|
|
|
export function CheckoutPage() {
|
|
const apiBase = Env.API_BASE_URL
|
|
const clientKey = Env.MIDTRANS_CLIENT_KEY
|
|
const { data: runtimeCfg } = usePaymentConfig()
|
|
// Generate unique order id per checkout session to avoid Midtrans 406 conflicts
|
|
const orderIdRef = React.useRef<string>('')
|
|
if (!orderIdRef.current) {
|
|
const ts = Date.now()
|
|
const rand = Math.floor(Math.random() * 100000).toString().padStart(5, '0')
|
|
orderIdRef.current = `order-${ts}-${rand}`
|
|
}
|
|
const orderId = orderIdRef.current
|
|
const amount = 3500000
|
|
const expireAt = Date.now() + 59 * 60 * 1000 + 32 * 1000 // 00:59:32
|
|
const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
|
|
const [locked, setLocked] = React.useState(false)
|
|
const [currentStep, setCurrentStep] = React.useState<1 | 2 | 3>(1)
|
|
const [isBusy, setIsBusy] = React.useState(false)
|
|
const [selectedBank, setSelectedBank] = React.useState<'bca' | 'bni' | 'bri' | 'cimb' | 'mandiri' | 'permata' | null>(null)
|
|
const [selectedStore, setSelectedStore] = React.useState<'alfamart' | 'indomaret' | null>(null)
|
|
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
|
|
name: 'Demo User',
|
|
contact: 'demo@example.com',
|
|
address: 'Jl. Contoh No. 1',
|
|
notes: '',
|
|
})
|
|
|
|
const configMissing = !clientKey || !apiBase
|
|
|
|
React.useEffect(() => {
|
|
Logger.info('checkout.init', { apiBase, hasClientKey: !!clientKey })
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
if (runtimeCfg) {
|
|
Logger.info('runtime.config.applied', runtimeCfg.paymentToggles)
|
|
}
|
|
}, [runtimeCfg])
|
|
|
|
React.useEffect(() => {
|
|
if (selected) Logger.info('checkout.method.selected', { method: selected })
|
|
}, [selected])
|
|
|
|
const prevStep = React.useRef(currentStep)
|
|
React.useEffect(() => {
|
|
if (prevStep.current !== currentStep) {
|
|
Logger.info('checkout.step', { from: prevStep.current, to: currentStep })
|
|
prevStep.current = currentStep
|
|
}
|
|
}, [currentStep])
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{configMissing && (
|
|
<Alert title="Konfigurasi belum lengkap">
|
|
Set <code>VITE_MIDTRANS_CLIENT_KEY</code> untuk tokenisasi kartu dan <code>VITE_API_BASE_URL</code> ke server backend (contoh: <code>http://localhost:8000</code>). Tanpa API base, pembayaran tidak dapat diproses.
|
|
</Alert>
|
|
)}
|
|
|
|
<PaymentSheet merchantName="Zara" orderId={orderId} amount={amount} expireAt={expireAt}>
|
|
{/* 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>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded border px-3 py-2"
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<div className="text-xs text-black/60 dark:text-white/60">Email atau HP</div>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded border px-3 py-2"
|
|
value={form.contact}
|
|
onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<div className="text-xs text-black/60 dark:text-white/60">Alamat</div>
|
|
<input
|
|
type="text"
|
|
className="w-full rounded border px-3 py-2"
|
|
value={form.address}
|
|
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<div className="text-xs text-black/60 dark:text-white/60">Catatan</div>
|
|
<textarea
|
|
className="w-full rounded border px-3 py-2"
|
|
value={form.notes}
|
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
|
/>
|
|
</label>
|
|
<div className="pt-2 flex gap-2">
|
|
<Button
|
|
variant="primary"
|
|
className="w-full"
|
|
aria-busy={isBusy}
|
|
disabled={isBusy}
|
|
onClick={() => {
|
|
setIsBusy(true)
|
|
setTimeout(() => { setCurrentStep(2); setIsBusy(false) }, 400)
|
|
}}
|
|
>
|
|
{isBusy ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
|
|
Memuat…
|
|
</span>
|
|
) : 'Next'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentStep === 2 && (
|
|
<div className="space-y-3">
|
|
<PaymentMethodList
|
|
selected={selected ?? undefined}
|
|
onSelect={(m) => {
|
|
setSelected(m)
|
|
if (m === 'bank_transfer' || m === 'cstore') {
|
|
// Panel akan tampil di bawah item menggunakan renderPanel
|
|
} else {
|
|
setIsBusy(true)
|
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
|
}
|
|
}}
|
|
disabled={locked}
|
|
enabled={runtimeCfg?.paymentToggles}
|
|
renderPanel={(m) => {
|
|
const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled()
|
|
if (!methodEnabled[m]) {
|
|
return (
|
|
<div className="p-2">
|
|
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
|
</div>
|
|
)
|
|
}
|
|
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={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
|
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
|
|
<button
|
|
key={bk}
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedBank(bk)
|
|
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 flex-col items-center gap-1 hover:bg-black/5 dark:hover:bg-white/10"
|
|
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
|
>
|
|
<BankLogo bank={bk} />
|
|
<span className="text-xs text-black/70 dark:text-white/70">{bk.toUpperCase()}</span>
|
|
</button>
|
|
))}
|
|
</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 />
|
|
Menyiapkan VA…
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
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={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
|
{(['alfamart','indomaret'] as const).map((st) => (
|
|
<button
|
|
key={st}
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedStore(st)
|
|
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 flex-col items-center gap-1 hover:bg-black/5 dark:hover:bg-white/10"
|
|
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
|
>
|
|
<span className="text-sm font-medium">{st.toUpperCase()}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return null
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{currentStep === 3 && (
|
|
<div className="space-y-3" aria-live="polite">
|
|
{selected === 'bank_transfer' && (
|
|
<BankTransferPanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} defaultBank={(selectedBank ?? 'bca')} />
|
|
)}
|
|
{selected === 'credit_card' && (
|
|
<CardPanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} />
|
|
)}
|
|
{selected === 'gopay' && (runtimeCfg?.paymentToggles ?? defaultEnabled()).gopay && (
|
|
<GoPayPanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} />
|
|
)}
|
|
{selected === 'cstore' && (runtimeCfg?.paymentToggles ?? defaultEnabled()).cstore && (
|
|
<CStorePanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} defaultStore={selectedStore ?? undefined} />
|
|
)}
|
|
{selected && !(runtimeCfg?.paymentToggles ?? defaultEnabled())[selected] && (
|
|
<div className="mt-2">
|
|
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
|
</div>
|
|
)}
|
|
{/* No back/next controls on Step 3 as requested */}
|
|
</div>
|
|
)}
|
|
</PaymentSheet>
|
|
|
|
<div className="text-xs text-black/60 dark:text-white/60">
|
|
API Base: {apiBase ?? '—'} | Client Key: {clientKey ? 'OK' : '—'}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
function defaultEnabled(): Record<PaymentMethod, boolean> {
|
|
return {
|
|
bank_transfer: Env.ENABLE_BANK_TRANSFER,
|
|
credit_card: Env.ENABLE_CREDIT_CARD,
|
|
gopay: Env.ENABLE_GOPAY,
|
|
cstore: Env.ENABLE_CSTORE,
|
|
}
|
|
} |