feat(payments): implement Story 1.1 - Add error messages utility, loading overlay, and update BankTransferPanel
- Created src/lib/errorMessages.ts for user-friendly Bahasa Indonesia error messages - Created src/components/LoadingOverlay.tsx with Framer Motion animations - Updated BankTransferPanel with LoadingOverlay and mapErrorToUserMessage - All 4 error catch blocks now use user-friendly messages - GoPayPanel imports restored (ready for next iteration) Story: 1.1 - Prevent Duplicate VA/QR/Code Generation & Improve Feedback Status: Partial (BankTransferPanel complete, GoPayPanel & CStorePanel pending)
This commit is contained in:
parent
9a0f6d85f8
commit
4eccff2c03
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
isLoading: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen loading overlay with spinner and message
|
||||||
|
* Prevents user interaction during payment code generation
|
||||||
|
*/
|
||||||
|
export function LoadingOverlay({ isLoading, message = 'Memproses...' }: LoadingOverlayProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isLoading && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-xl max-w-sm mx-4">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
{/* Spinner */}
|
||||||
|
<div
|
||||||
|
className="h-12 w-12 animate-spin rounded-full border-4 border-black/20 border-t-black"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<p className="text-center text-black font-medium">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ import { postCharge } from '../../../services/api'
|
||||||
import { Alert } from '../../../components/alert/Alert'
|
import { Alert } from '../../../components/alert/Alert'
|
||||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||||
import { toast } from '../../../components/ui/toast'
|
import { toast } from '../../../components/ui/toast'
|
||||||
|
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
||||||
|
import { mapErrorToUserMessage } from '../../../lib/errorMessages'
|
||||||
|
|
||||||
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts
|
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts
|
||||||
const attemptedChargeKeys = new Set<string>()
|
const attemptedChargeKeys = new Set<string>()
|
||||||
|
|
@ -62,8 +64,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
onChargeInitiated?.()
|
onChargeInitiated?.()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const ax = e as any
|
const msg = mapErrorToUserMessage(e)
|
||||||
const msg = ax?.response?.data?.message || ax?.message || 'Gagal membuat VA.'
|
|
||||||
if (!cancelled) setErrorMessage(msg)
|
if (!cancelled) setErrorMessage(msg)
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -108,8 +109,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
onChargeInitiated?.()
|
onChargeInitiated?.()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const ax = e as any
|
const msg = mapErrorToUserMessage(e)
|
||||||
const msg = ax?.response?.data?.message || ax?.message || 'Gagal membuat VA.'
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setErrorMessage(msg)
|
setErrorMessage(msg)
|
||||||
attemptedChargeKeys.delete(chargeKey)
|
attemptedChargeKeys.delete(chargeKey)
|
||||||
|
|
@ -136,166 +136,167 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
}, [selected])
|
}, [selected])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<>
|
||||||
<div className="font-medium">Transfer Bank</div>
|
<LoadingOverlay isLoading={busy} message="Sedang membuat kode pembayaran..." />
|
||||||
{selected && (
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 text-base">
|
<div className="font-medium">Transfer Bank</div>
|
||||||
<span className="text-black/60">Bank:</span>
|
{selected && (
|
||||||
<span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
|
<div className="flex items-center gap-2 text-base">
|
||||||
</div>
|
<span className="text-black/60">Bank:</span>
|
||||||
)}
|
<span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
|
||||||
<div className="text-sm text-black/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
|
</div>
|
||||||
{errorMessage && (
|
)}
|
||||||
<Alert title="Gagal membuat VA">{errorMessage}</Alert>
|
<div className="text-sm text-black/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
|
||||||
)}
|
{errorMessage && (
|
||||||
{selected && (
|
<Alert title="Gagal membuat VA">{errorMessage}</Alert>
|
||||||
<div className="pt-1">
|
)}
|
||||||
<div className="rounded-lg p-3 border-2 border-black/30">
|
{selected && (
|
||||||
<div className="text-sm font-medium mb-2">Virtual Account</div>
|
<div className="pt-1">
|
||||||
<div className="text-sm text-black/70">
|
<div className="rounded-lg p-3 border-2 border-black/30">
|
||||||
{vaCode ? (
|
<div className="text-sm font-medium mb-2">Virtual Account</div>
|
||||||
<span>
|
<div className="text-sm text-black/70">
|
||||||
Nomor VA:
|
{vaCode ? (
|
||||||
<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>
|
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 className="inline-flex items-center gap-2" role="status" aria-live="polite">
|
</span>
|
||||||
{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 className="inline-flex items-center gap-2" role="status" aria-live="polite">
|
||||||
</span>
|
{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.'}
|
||||||
{billKey && (
|
</span>
|
||||||
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black">{billKey}</span></span>
|
)}
|
||||||
)}
|
{billKey && (
|
||||||
{billerCode && (
|
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black">{billKey}</span></span>
|
||||||
<span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black">{billerCode}</span></span>
|
)}
|
||||||
)}
|
{billerCode && (
|
||||||
</div>
|
<span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black">{billerCode}</span></span>
|
||||||
<div className="mt-2 flex gap-2">
|
)}
|
||||||
<Button variant="secondary" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button>
|
</div>
|
||||||
<Button variant="secondary" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{/* Status inline dengan polling otomatis */}
|
||||||
{/* Status inline dengan polling otomatis */}
|
{selected && (
|
||||||
{selected && (
|
<InlinePaymentStatus orderId={orderId} method="bank_transfer" compact />
|
||||||
<InlinePaymentStatus orderId={orderId} method="bank_transfer" compact />
|
)}
|
||||||
)}
|
{selected && (
|
||||||
{selected && (
|
<div className="pt-2">
|
||||||
<div className="pt-2">
|
{selected === 'bca' ? (
|
||||||
{selected === 'bca' ? (
|
<BcaInstructionList />
|
||||||
<BcaInstructionList />
|
) : (
|
||||||
) : (
|
<div className="rounded-lg border-2 border-black/30 p-3 bg-white">
|
||||||
<div className="rounded-lg border-2 border-black/30 p-3 bg-white">
|
<div className="text-sm font-medium mb-2">Instruksi pembayaran</div>
|
||||||
<div className="text-sm font-medium mb-2">Instruksi pembayaran</div>
|
<PaymentInstructions method="bank_transfer" />
|
||||||
<PaymentInstructions method="bank_transfer" />
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{locked && (
|
||||||
{locked && (
|
<div className="text-xs text-black/60">Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.</div>
|
||||||
<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">
|
||||||
<div className="pt-2 space-y-2">
|
{(!vaCode || errorMessage) && (
|
||||||
{(!vaCode || errorMessage) && (
|
<Button
|
||||||
<Button
|
aria-busy={busy}
|
||||||
aria-busy={busy}
|
disabled={!selected || busy}
|
||||||
disabled={!selected || busy}
|
onClick={async () => {
|
||||||
onClick={async () => {
|
try {
|
||||||
try {
|
setErrorMessage('')
|
||||||
setErrorMessage('')
|
if (!selected) return
|
||||||
if (!selected) return
|
const chargeKey = `${orderId}:${selected}`
|
||||||
const chargeKey = `${orderId}:${selected}`
|
// Guard duplicate charges BEFORE setting busy to avoid stuck loading state
|
||||||
// Guard duplicate charges BEFORE setting busy to avoid stuck loading state
|
if (lastChargeKeyRef.current === chargeKey || chargingKeyRef.current === chargeKey) return
|
||||||
if (lastChargeKeyRef.current === chargeKey || chargingKeyRef.current === chargeKey) return
|
// If a charge is already in-flight, await it instead of starting a new one
|
||||||
// If a charge is already in-flight, await it instead of starting a new one
|
if (attemptedChargeKeys.has(chargeKey) && chargeTasks.has(chargeKey)) {
|
||||||
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)
|
setBusy(true)
|
||||||
chargingKeyRef.current = chargeKey
|
chargingKeyRef.current = chargeKey
|
||||||
try {
|
const payload: Record<string, any> = {
|
||||||
const res = await chargeTasks.get(chargeKey)!
|
payment_type: 'bank_transfer',
|
||||||
let va = ''
|
transaction_details: { order_id: orderId, gross_amount: amount },
|
||||||
if (Array.isArray(res?.va_numbers) && res.va_numbers.length) {
|
bank_transfer: { bank: selected },
|
||||||
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 ax = e as any
|
|
||||||
const msg = ax?.response?.data?.message || ax?.message || 'Gagal membuat VA.'
|
|
||||||
setErrorMessage(msg)
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
||||||
chargeTasks.delete(chargeKey)
|
|
||||||
}
|
}
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
setBusy(true)
|
}}
|
||||||
chargingKeyRef.current = chargeKey
|
className="w-full"
|
||||||
const payload: Record<string, any> = {
|
>
|
||||||
payment_type: 'bank_transfer',
|
{busy ? (
|
||||||
transaction_details: { order_id: orderId, gross_amount: amount },
|
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
|
||||||
bank_transfer: { bank: selected },
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
|
||||||
}
|
Membuat VA…
|
||||||
const task = postCharge(payload)
|
</span>
|
||||||
chargeTasks.set(chargeKey, task)
|
) : 'Buat VA'}
|
||||||
const res = await task
|
</Button>
|
||||||
// Extract VA / bill info from response
|
)}
|
||||||
let va = ''
|
<Button
|
||||||
if (Array.isArray(res?.va_numbers) && res.va_numbers.length) {
|
variant="outline"
|
||||||
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 ax = e as any
|
|
||||||
const msg = ax?.response?.data?.message || ax?.message || 'Gagal membuat VA.'
|
|
||||||
setErrorMessage(msg)
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
const chargeKey = `${orderId}:${selected}`
|
|
||||||
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
||||||
chargeTasks.delete(chargeKey)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
disabled={busy || (!locked && !vaCode && !billKey)}
|
||||||
|
onClick={() => nav.toStatus(orderId, 'bank_transfer')}
|
||||||
>
|
>
|
||||||
{busy ? (
|
Buka halaman status
|
||||||
<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>
|
||||||
)}
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
disabled={busy || (!locked && !vaCode && !billKey)}
|
|
||||||
onClick={() => nav.toStatus(orderId, 'bank_transfer')}
|
|
||||||
>
|
|
||||||
Buka halaman status
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,16 +73,16 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode('gopay')}
|
onClick={() => setMode('gopay')}
|
||||||
aria-pressed={mode==='gopay'}
|
aria-pressed={mode === 'gopay'}
|
||||||
className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode==='gopay' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
|
className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode === 'gopay' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
|
||||||
>
|
>
|
||||||
GoPay
|
GoPay
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode('qris')}
|
onClick={() => setMode('qris')}
|
||||||
aria-pressed={mode==='qris'}
|
aria-pressed={mode === 'qris'}
|
||||||
className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode==='qris' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
|
className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode === 'qris' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
|
||||||
>
|
>
|
||||||
QRIS
|
QRIS
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -135,7 +135,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
|
||||||
className="w-full"
|
className="w-full"
|
||||||
aria-busy={busy}
|
aria-busy={busy}
|
||||||
disabled={busy || (!locked && actions.length === 0)}
|
disabled={busy || (!locked && actions.length === 0)}
|
||||||
onClick={() => { setBusy(true); onChargeInitiated?.(); setTimeout(() => { nav.toStatus(orderId, mode) ; setBusy(false) }, 250) }}
|
onClick={() => { setBusy(true); onChargeInitiated?.(); setTimeout(() => { nav.toStatus(orderId, mode); setBusy(false) }, 250) }}
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
|
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
|
||||||
|
|
@ -216,7 +216,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
|
||||||
}
|
}
|
||||||
run()
|
run()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [mode])
|
}, [mode])
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps technical error responses to user-friendly messages in Bahasa Indonesia
|
||||||
|
* for non-tech-savvy users (ibu-ibu awam)
|
||||||
|
*/
|
||||||
|
export function mapErrorToUserMessage(error: unknown): string {
|
||||||
|
// Handle AxiosError
|
||||||
|
if (error && typeof error === 'object' && 'response' in error) {
|
||||||
|
const axiosError = error as AxiosError
|
||||||
|
const status = axiosError.response?.status
|
||||||
|
|
||||||
|
// HTTP 409 - Conflict (VA/QR/Code already created)
|
||||||
|
if (status === 409) {
|
||||||
|
return 'Kode pembayaran Anda sudah dibuat! Silakan gunakan kode yang sudah ada.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP 404 - Not Found
|
||||||
|
if (status === 404) {
|
||||||
|
return 'Terjadi kesalahan. Silakan coba lagi.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP 500 - Internal Server Error
|
||||||
|
if (status === 500) {
|
||||||
|
return 'Terjadi kesalahan server. Silakan coba lagi nanti.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP 503 - Service Unavailable
|
||||||
|
if (status === 503) {
|
||||||
|
return 'Layanan sedang sibuk. Silakan coba lagi dalam beberapa saat.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network error (no response)
|
||||||
|
if (axiosError.message === 'Network Error' || !axiosError.response) {
|
||||||
|
return 'Tidak dapat terhubung ke server. Periksa koneksi internet Anda.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get message from response data
|
||||||
|
const responseMessage = (axiosError.response?.data as any)?.message
|
||||||
|
if (typeof responseMessage === 'string' && responseMessage.length > 0) {
|
||||||
|
// If it's already in Indonesian, use it
|
||||||
|
if (/[a-zA-Z]/.test(responseMessage) === false) {
|
||||||
|
return responseMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Error object
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Network errors
|
||||||
|
if (error.message.includes('Network') || error.message.includes('network')) {
|
||||||
|
return 'Tidak dapat terhubung ke server. Periksa koneksi internet Anda.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout errors
|
||||||
|
if (error.message.includes('timeout') || error.message.includes('Timeout')) {
|
||||||
|
return 'Permintaan memakan waktu terlalu lama. Silakan coba lagi.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback message
|
||||||
|
return 'Terjadi kesalahan. Silakan coba lagi.'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets recovery action suggestion based on error type
|
||||||
|
*/
|
||||||
|
export function getErrorRecoveryAction(error: unknown): 'retry' | 'view-existing' | 'back' {
|
||||||
|
if (error && typeof error === 'object' && 'response' in error) {
|
||||||
|
const axiosError = error as AxiosError
|
||||||
|
const status = axiosError.response?.status
|
||||||
|
|
||||||
|
// HTTP 409 - Conflict (already exists) → view existing
|
||||||
|
if (status === 409) {
|
||||||
|
return 'view-existing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: allow retry
|
||||||
|
return 'retry'
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue