131 lines
5.5 KiB
TypeScript
131 lines
5.5 KiB
TypeScript
import { Button } from '../../../components/ui/button'
|
|
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'
|
|
|
|
type StoreKey = 'alfamart' | 'indomaret'
|
|
|
|
// Shared guards/tasks to prevent duplicate charges under StrictMode/HMR and double clicks
|
|
const attemptedCStoreKeys = new Set<string>()
|
|
const cstoreTasks = new Map<string, Promise<any>>()
|
|
|
|
export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaultStore }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultStore?: StoreKey }) {
|
|
const nav = usePaymentNavigation()
|
|
const [selected] = React.useState<StoreKey | null>(defaultStore ?? null)
|
|
const [showGuide, setShowGuide] = React.useState(false)
|
|
const [busy, setBusy] = React.useState(false)
|
|
const [paymentCode, setPaymentCode] = React.useState('')
|
|
const [storeFromRes, setStoreFromRes] = React.useState('')
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false
|
|
async function run() {
|
|
// Only auto-charge when a store is selected, not locked, and code not yet generated
|
|
if (!selected || locked || paymentCode) return
|
|
const chargeKey = `${orderId}:${selected}`
|
|
// If there's already an in-flight task, await it
|
|
if (attemptedCStoreKeys.has(chargeKey) && cstoreTasks.has(chargeKey)) {
|
|
setBusy(true)
|
|
try {
|
|
const res = await cstoreTasks.get(chargeKey)!
|
|
if (!cancelled) {
|
|
if (typeof res?.payment_code === 'string') setPaymentCode(res.payment_code)
|
|
if (typeof res?.store === 'string') setStoreFromRes(res.store)
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled) alert(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
|
|
} finally {
|
|
if (!cancelled) setBusy(false)
|
|
cstoreTasks.delete(chargeKey)
|
|
}
|
|
return
|
|
}
|
|
setBusy(true)
|
|
onChargeInitiated?.()
|
|
try {
|
|
const payload: Record<string, any> = {
|
|
payment_type: 'cstore',
|
|
transaction_details: { order_id: orderId, gross_amount: amount },
|
|
cstore: { store: selected, message: `Pembayaran untuk order ${orderId}` },
|
|
}
|
|
attemptedCStoreKeys.add(chargeKey)
|
|
const task = postCharge(payload)
|
|
cstoreTasks.set(chargeKey, task)
|
|
const res = await task
|
|
if (!cancelled) {
|
|
if (typeof res?.payment_code === 'string') setPaymentCode(res.payment_code)
|
|
if (typeof res?.store === 'string') setStoreFromRes(res.store)
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled) alert(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
|
|
attemptedCStoreKeys.delete(chargeKey)
|
|
} finally {
|
|
if (!cancelled) setBusy(false)
|
|
cstoreTasks.delete(chargeKey)
|
|
}
|
|
}
|
|
run()
|
|
return () => { cancelled = true }
|
|
}, [selected, orderId, amount])
|
|
|
|
function copy(text: string, label: string) {
|
|
if (!text) return
|
|
navigator.clipboard?.writeText(text)
|
|
alert(`${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>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowGuide((v) => !v)}
|
|
className="text-xs text-brand-600 hover:underline"
|
|
aria-expanded={showGuide}
|
|
>
|
|
Cara bayar
|
|
</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="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="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>
|
|
)}
|
|
{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 />
|
|
Membuat kode…
|
|
</div>
|
|
)}
|
|
{selected && !busy && (storeFromRes || paymentCode) && (
|
|
<>
|
|
{storeFromRes ? <div>Toko: {storeFromRes.toUpperCase()}</div> : null}
|
|
{paymentCode ? <div>Kode: <span className="font-mono text-lg tracking-[0.06em] select-all">{paymentCode}</span></div> : null}
|
|
</>
|
|
)}
|
|
<div className="mt-2"><Button variant="outline" className="w-full sm:w-auto" onClick={() => copy(paymentCode, 'Kode pembayaran')} disabled={!paymentCode || busy}>Copy Kode</Button></div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={busy || (!locked && !paymentCode)}
|
|
onClick={() => nav.toStatus(orderId, 'cstore')}
|
|
>
|
|
Buka halaman status
|
|
</Button>
|
|
<InlinePaymentStatus orderId={orderId} method="cstore" />
|
|
</div>
|
|
<TrustStrip />
|
|
</div>
|
|
)
|
|
} |