import { Button } from '../../../components/ui/button' import { usePaymentNavigation } from '../lib/navigation' import React from 'react' import { PaymentInstructions } from './PaymentInstructions' import { BcaInstructionList } from './BcaInstructionList' 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' 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() // Share in-flight charge promises across mounts to avoid losing state on StrictMode remounts const chargeTasks = new Map>() 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(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('') const chargingKeyRef = React.useRef('') 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 = { 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 ( <>
Transfer Bank
{selected && (
Bank: {selected.toUpperCase()}
)}
VA dibuat otomatis sesuai bank pilihan Anda.
{errorMessage && ( {errorMessage} {recovery === 'view-existing' && (
)}
)} {selected && (
Virtual Account
{vaCode ? ( Nomor VA: {vaCode} ) : ( {busy && } {busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'} )} {billKey && ( Bill Key: {billKey} )} {billerCode && ( Biller Code: {billerCode} )}
)} {/* Status inline dengan polling otomatis */} {selected && ( )} {selected && (
{selected === 'bca' ? ( ) : (
Instruksi pembayaran
)}
)} {locked && (
Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.
)}
{(!vaCode || errorMessage) && ( )}
) } // (Hook internal dihapus untuk menjaga kompatibilitas Fast Refresh)