Midtrans-Middleware/src/features/payments/components/BankTransferPanel.tsx

317 lines
14 KiB
TypeScript

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<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)