feat/payment-ux-story-1-1 #14

Merged
root merged 7 commits from feat/payment-ux-story-1-1 into dev 2025-11-28 01:14:26 +00:00
4 changed files with 286 additions and 160 deletions
Showing only changes of commit 4eccff2c03 - Show all commits

View File

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

View File

@ -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<string>()
@ -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,6 +136,8 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
}, [selected])
return (
<>
<LoadingOverlay isLoading={busy} message="Sedang membuat kode pembayaran..." />
<div className="space-y-3">
<div className="font-medium">Transfer Bank</div>
{selected && (
@ -230,8 +232,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
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.'
const msg = mapErrorToUserMessage(e)
setErrorMessage(msg)
} finally {
setBusy(false)
@ -266,8 +267,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
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.'
const msg = mapErrorToUserMessage(e)
setErrorMessage(msg)
} finally {
setBusy(false)
@ -296,6 +296,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
</Button>
</div>
</div>
</>
)
}

81
src/lib/errorMessages.ts Normal file
View File

@ -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'
}