317 lines
14 KiB
TypeScript
317 lines
14 KiB
TypeScript
import { Button } from '../../../components/ui/button'
|
|
import { usePaymentNavigation } from '../lib/navigation'
|
|
import React from 'react'
|
|
import { PaymentInstructions } from '../components/PaymentInstructions'
|
|
import { BcaInstructionList } from '../components/BcaInstructionList'
|
|
import { type BankKey } from '../components/PaymentLogos'
|
|
import { postCharge } from '../../../services/api'
|
|
import { Alert } from '../../../components/alert/Alert'
|
|
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
|
import { toast } from '../../../components/ui/toast'
|
|
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
|
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
|
|
|
|
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts
|
|
const attemptedChargeKeys = new Set<string>()
|
|
// Share in-flight charge promises across mounts to avoid losing state on StrictMode remounts
|
|
const chargeTasks = new Map<string, Promise<any>>()
|
|
|
|
export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, defaultBank }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultBank?: BankKey }) {
|
|
const nav = usePaymentNavigation()
|
|
const [selected] = React.useState<BankKey | null>(defaultBank ?? null)
|
|
const [showGuide, setShowGuide] = React.useState(false)
|
|
const [busy, setBusy] = React.useState(false)
|
|
const [vaCode, setVaCode] = React.useState('')
|
|
const [billKey, setBillKey] = React.useState('')
|
|
const [billerCode, setBillerCode] = React.useState('')
|
|
const [errorMessage, setErrorMessage] = React.useState('')
|
|
const [recovery, setRecovery] = React.useState<'retry' | 'view-existing' | 'back'>('retry')
|
|
const lastChargeKeyRef = React.useRef<string>('')
|
|
const chargingKeyRef = React.useRef<string>('')
|
|
|
|
function copy(text: string, label: string) {
|
|
if (!text) return
|
|
navigator.clipboard?.writeText(text)
|
|
toast.success(`${label} disalin: ${text}`)
|
|
}
|
|
// Auto-create VA immediately when a bank is selected (runs once per selection)
|
|
React.useEffect(() => {
|
|
let cancelled = false
|
|
async function run() {
|
|
if (!selected || locked) return
|
|
const chargeKey = `${orderId}:${selected}`
|
|
// If a charge for this key is already in-flight (StrictMode remount), await the shared promise
|
|
if (attemptedChargeKeys.has(chargeKey) && chargeTasks.has(chargeKey)) {
|
|
setErrorMessage('')
|
|
setBusy(true)
|
|
chargingKeyRef.current = chargeKey
|
|
try {
|
|
const res = await chargeTasks.get(chargeKey)!
|
|
// Extract VA / bill info from response
|
|
let va = ''
|
|
if (Array.isArray(res?.va_numbers) && res.va_numbers.length) {
|
|
const match = res.va_numbers.find((v: any) => v?.bank?.toLowerCase() === selected) || res.va_numbers[0]
|
|
va = match?.va_number || ''
|
|
}
|
|
if (!va && typeof res?.permata_va_number === 'string') {
|
|
va = res.permata_va_number
|
|
}
|
|
if (!cancelled) {
|
|
setVaCode(va)
|
|
if (typeof res?.bill_key === 'string') setBillKey(res.bill_key)
|
|
if (typeof res?.biller_code === 'string') setBillerCode(res.biller_code)
|
|
lastChargeKeyRef.current = chargeKey
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
onChargeInitiated?.()
|
|
}
|
|
} catch (e) {
|
|
const msg = mapErrorToUserMessage(e)
|
|
const act = getErrorRecoveryAction(e)
|
|
if (!cancelled) {
|
|
setErrorMessage(msg)
|
|
setRecovery(act)
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setBusy(false)
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
}
|
|
// Cleanup shared task once finished
|
|
chargeTasks.delete(chargeKey)
|
|
}
|
|
return
|
|
}
|
|
if (lastChargeKeyRef.current === chargeKey || chargingKeyRef.current === chargeKey) return
|
|
setErrorMessage('')
|
|
setBusy(true)
|
|
chargingKeyRef.current = chargeKey
|
|
attemptedChargeKeys.add(chargeKey)
|
|
try {
|
|
const payload: Record<string, any> = {
|
|
payment_type: 'bank_transfer',
|
|
transaction_details: { order_id: orderId, gross_amount: amount },
|
|
bank_transfer: { bank: selected },
|
|
}
|
|
// Share this in-flight request so remounts can await the same promise
|
|
const task = postCharge(payload)
|
|
chargeTasks.set(chargeKey, task)
|
|
const res = await task
|
|
// Extract VA / bill info from response
|
|
let va = ''
|
|
if (Array.isArray(res?.va_numbers) && res.va_numbers.length) {
|
|
const match = res.va_numbers.find((v: any) => v?.bank?.toLowerCase() === selected) || res.va_numbers[0]
|
|
va = match?.va_number || ''
|
|
}
|
|
if (!va && typeof res?.permata_va_number === 'string') {
|
|
va = res.permata_va_number
|
|
}
|
|
if (!cancelled) {
|
|
setVaCode(va)
|
|
if (typeof res?.bill_key === 'string') setBillKey(res.bill_key)
|
|
if (typeof res?.biller_code === 'string') setBillerCode(res.biller_code)
|
|
lastChargeKeyRef.current = chargeKey
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
onChargeInitiated?.()
|
|
}
|
|
} catch (e) {
|
|
const msg = mapErrorToUserMessage(e)
|
|
const act = getErrorRecoveryAction(e)
|
|
if (!cancelled) {
|
|
setErrorMessage(msg)
|
|
setRecovery(act)
|
|
attemptedChargeKeys.delete(chargeKey)
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setBusy(false)
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
}
|
|
// Cleanup shared task once finished
|
|
chargeTasks.delete(chargeKey)
|
|
}
|
|
}
|
|
run()
|
|
return () => { cancelled = true }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selected])
|
|
|
|
// Auto-show instructions when BCA is selected to reduce confusion
|
|
React.useEffect(() => {
|
|
if (selected === 'bca' && !showGuide) {
|
|
setShowGuide(true)
|
|
}
|
|
}, [selected])
|
|
|
|
return (
|
|
<>
|
|
<LoadingOverlay isLoading={busy} message="Sedang membuat kode pembayaran..." />
|
|
<div className="space-y-3">
|
|
<div className="font-medium">Transfer Bank</div>
|
|
{selected && (
|
|
<div className="flex items-center gap-2 text-base">
|
|
<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">VA dibuat otomatis sesuai bank pilihan Anda.</div>
|
|
{errorMessage && (
|
|
<Alert title="Gagal membuat VA">
|
|
{errorMessage}
|
|
{recovery === 'view-existing' && (
|
|
<div className="mt-2">
|
|
<Button size="sm" onClick={() => nav.toStatus(orderId, 'bank_transfer')}>Lihat Kode Pembayaran</Button>
|
|
</div>
|
|
)}
|
|
</Alert>
|
|
)}
|
|
{selected && (
|
|
<div className="pt-1">
|
|
<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">
|
|
{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">{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 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">{billKey}</span></span>
|
|
)}
|
|
{billerCode && (
|
|
<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="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>
|
|
)}
|
|
{/* Status inline dengan polling otomatis */}
|
|
{selected && (
|
|
<InlinePaymentStatus orderId={orderId} method="bank_transfer" compact />
|
|
)}
|
|
{selected && (
|
|
<div className="pt-2">
|
|
{selected === 'bca' ? (
|
|
<BcaInstructionList />
|
|
) : (
|
|
<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>
|
|
)}
|
|
</div>
|
|
)}
|
|
{locked && (
|
|
<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) && (
|
|
<Button
|
|
aria-busy={busy}
|
|
disabled={!selected || busy}
|
|
onClick={async () => {
|
|
try {
|
|
setErrorMessage('')
|
|
if (!selected) return
|
|
const chargeKey = `${orderId}:${selected}`
|
|
// Guard duplicate charges BEFORE setting busy to avoid stuck loading state
|
|
if (lastChargeKeyRef.current === chargeKey || chargingKeyRef.current === chargeKey) return
|
|
// If a charge is already in-flight, await it instead of starting a new one
|
|
if (attemptedChargeKeys.has(chargeKey) && chargeTasks.has(chargeKey)) {
|
|
setBusy(true)
|
|
chargingKeyRef.current = chargeKey
|
|
try {
|
|
const res = await chargeTasks.get(chargeKey)!
|
|
let va = ''
|
|
if (Array.isArray(res?.va_numbers) && res.va_numbers.length) {
|
|
const match = res.va_numbers.find((v: any) => v?.bank?.toLowerCase() === selected) || res.va_numbers[0]
|
|
va = match?.va_number || ''
|
|
}
|
|
if (!va && typeof res?.permata_va_number === 'string') {
|
|
va = res.permata_va_number
|
|
}
|
|
setVaCode(va)
|
|
if (typeof res?.bill_key === 'string') setBillKey(res.bill_key)
|
|
if (typeof res?.biller_code === 'string') setBillerCode(res.biller_code)
|
|
lastChargeKeyRef.current = `${orderId}:${selected}`
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
onChargeInitiated?.()
|
|
} catch (e) {
|
|
const msg = mapErrorToUserMessage(e)
|
|
setErrorMessage(msg)
|
|
} finally {
|
|
setBusy(false)
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
chargeTasks.delete(chargeKey)
|
|
}
|
|
return
|
|
}
|
|
setBusy(true)
|
|
chargingKeyRef.current = chargeKey
|
|
const payload: Record<string, any> = {
|
|
payment_type: 'bank_transfer',
|
|
transaction_details: { order_id: orderId, gross_amount: amount },
|
|
bank_transfer: { bank: selected },
|
|
}
|
|
const task = postCharge(payload)
|
|
chargeTasks.set(chargeKey, task)
|
|
const res = await task
|
|
// Extract VA / bill info from response
|
|
let va = ''
|
|
if (Array.isArray(res?.va_numbers) && res.va_numbers.length) {
|
|
const match = res.va_numbers.find((v: any) => v?.bank?.toLowerCase() === selected) || res.va_numbers[0]
|
|
va = match?.va_number || ''
|
|
}
|
|
if (!va && typeof res?.permata_va_number === 'string') {
|
|
va = res.permata_va_number
|
|
}
|
|
setVaCode(va)
|
|
if (typeof res?.bill_key === 'string') setBillKey(res.bill_key)
|
|
if (typeof res?.biller_code === 'string') setBillerCode(res.biller_code)
|
|
lastChargeKeyRef.current = `${orderId}:${selected}`
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
onChargeInitiated?.()
|
|
} catch (e) {
|
|
const msg = mapErrorToUserMessage(e)
|
|
setErrorMessage(msg)
|
|
} finally {
|
|
setBusy(false)
|
|
const chargeKey = `${orderId}:${selected}`
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
chargeTasks.delete(chargeKey)
|
|
}
|
|
}}
|
|
className="w-full"
|
|
>
|
|
{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 border-t-transparent" aria-hidden />
|
|
Membuat VA…
|
|
</span>
|
|
) : 'Buat VA'}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={busy || (!locked && !vaCode && !billKey && recovery !== 'view-existing')}
|
|
onClick={() => nav.toStatus(orderId, 'bank_transfer')}
|
|
>
|
|
Buka halaman status
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// (Hook internal dihapus untuk menjaga kompatibilitas Fast Refresh)
|