diff --git a/src/components/LoadingOverlay.tsx b/src/components/LoadingOverlay.tsx new file mode 100644 index 0000000..10253f2 --- /dev/null +++ b/src/components/LoadingOverlay.tsx @@ -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 ( + + {isLoading && ( + +
+
+ {/* Spinner */} + +
+ + )} + + ) +} diff --git a/src/features/payments/components/BankTransferPanel.tsx b/src/features/payments/components/BankTransferPanel.tsx index d77780b..d8b7cac 100644 --- a/src/features/payments/components/BankTransferPanel.tsx +++ b/src/features/payments/components/BankTransferPanel.tsx @@ -8,6 +8,8 @@ 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 { mapErrorToUserMessage } from '../../../lib/errorMessages' // Global guard to prevent duplicate auto-charge across StrictMode double-mounts const attemptedChargeKeys = new Set() @@ -62,8 +64,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, onChargeInitiated?.() } } catch (e) { - const ax = e as any - const msg = ax?.response?.data?.message || ax?.message || 'Gagal membuat VA.' + const msg = mapErrorToUserMessage(e) if (!cancelled) setErrorMessage(msg) } finally { if (!cancelled) { @@ -108,8 +109,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, onChargeInitiated?.() } } catch (e) { - const ax = e as any - const msg = ax?.response?.data?.message || ax?.message || 'Gagal membuat VA.' + const msg = mapErrorToUserMessage(e) if (!cancelled) { setErrorMessage(msg) attemptedChargeKeys.delete(chargeKey) @@ -136,166 +136,167 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, }, [selected]) return ( -
-
Transfer Bank
- {selected && ( -
- Bank: - {selected.toUpperCase()} -
- )} -
VA dibuat otomatis sesuai bank pilihan Anda.
- {errorMessage && ( - {errorMessage} - )} - {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} - )} -
-
- - + <> + +
+
Transfer Bank
+ {selected && ( +
+ Bank: + {selected.toUpperCase()} +
+ )} +
VA dibuat otomatis sesuai bank pilihan Anda.
+ {errorMessage && ( + {errorMessage} + )} + {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) && ( - + )} + - )} - +
-
+ ) } diff --git a/src/features/payments/components/GoPayPanel.tsx b/src/features/payments/components/GoPayPanel.tsx index 38bb405..680b572 100644 --- a/src/features/payments/components/GoPayPanel.tsx +++ b/src/features/payments/components/GoPayPanel.tsx @@ -73,16 +73,16 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord @@ -135,7 +135,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord className="w-full" aria-busy={busy} 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 ? ( @@ -216,7 +216,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy, } run() return () => { cancelled = true } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]) return null } \ No newline at end of file diff --git a/src/lib/errorMessages.ts b/src/lib/errorMessages.ts new file mode 100644 index 0000000..8a4bcca --- /dev/null +++ b/src/lib/errorMessages.ts @@ -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' +}