Midtrans-Middleware/src/pages/CheckoutPage.tsx

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,
}
}