feat(payments): complete Story 1.1 - Add loading overlay and error messages to all payment panels
- Updated GoPayPanel with LoadingOverlay and mapErrorToUserMessage - Updated CStorePanel with LoadingOverlay and mapErrorToUserMessage - All 3 payment methods now show user-friendly Bahasa Indonesia error messages - Full-screen loading overlay prevents duplicate payment code generation Story: 1.1 - Prevent Duplicate VA/QR/Code Generation & Improve Feedback Status: Complete
This commit is contained in:
parent
4eccff2c03
commit
d08b0bd312
|
|
@ -5,6 +5,8 @@ import React from 'react'
|
|||
import { PaymentInstructions } from './PaymentInstructions'
|
||||
import { postCharge } from '../../../services/api'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
||||
import { mapErrorToUserMessage } from '../../../lib/errorMessages'
|
||||
|
||||
type StoreKey = 'alfamart' | 'indomaret'
|
||||
|
||||
|
|
@ -36,7 +38,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
|
|||
if (typeof res?.store === 'string') setStoreFromRes(res.store)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
|
||||
if (!cancelled) toast.error(mapErrorToUserMessage(e))
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false)
|
||||
cstoreTasks.delete(chargeKey)
|
||||
|
|
@ -60,7 +62,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
|
|||
if (typeof res?.store === 'string') setStoreFromRes(res.store)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
|
||||
if (!cancelled) toast.error(mapErrorToUserMessage(e))
|
||||
attemptedCStoreKeys.delete(chargeKey)
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false)
|
||||
|
|
@ -78,53 +80,56 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium">Convenience Store</div>
|
||||
{selected && (
|
||||
<div className="text-xs text-gray-600">Toko dipilih: <span className="font-medium text-gray-900">{selected.toUpperCase()}</span></div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGuide((v) => !v)}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
aria-expanded={showGuide}
|
||||
>
|
||||
Cara bayar
|
||||
</button>
|
||||
{showGuide && <PaymentInstructions method="cstore" />}
|
||||
{locked && (
|
||||
<div className="text-xs text-gray-600">Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.</div>
|
||||
)}
|
||||
<div className="pt-2 space-y-2">
|
||||
<div className="rounded border border-gray-300 p-2 text-sm" aria-live="polite">
|
||||
<div className="font-medium">Kode Pembayaran</div>
|
||||
{!selected && (
|
||||
<div className="text-xs text-gray-600">Pilih toko terlebih dahulu di langkah sebelumnya.</div>
|
||||
)}
|
||||
{selected && busy && (
|
||||
<div className="inline-flex items-center gap-2 text-xs text-gray-600">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
|
||||
Membuat kode…
|
||||
</div>
|
||||
)}
|
||||
{selected && !busy && (storeFromRes || paymentCode) && (
|
||||
<>
|
||||
{storeFromRes ? <div>Toko: {storeFromRes.toUpperCase()}</div> : null}
|
||||
{paymentCode ? <div>Kode: <span className="font-mono text-lg tracking-[0.06em] select-all">{paymentCode}</span></div> : null}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2"><Button variant="outline" className="w-full sm:w-auto" onClick={() => copy(paymentCode, 'Kode pembayaran')} disabled={!paymentCode || busy}>Copy Kode</Button></div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={busy || (!locked && !paymentCode)}
|
||||
onClick={() => nav.toStatus(orderId, 'cstore')}
|
||||
<>
|
||||
<LoadingOverlay isLoading={busy} message="Sedang membuat kode pembayaran..." />
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium">Convenience Store</div>
|
||||
{selected && (
|
||||
<div className="text-xs text-gray-600">Toko dipilih: <span className="font-medium text-gray-900">{selected.toUpperCase()}</span></div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGuide((v) => !v)}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
aria-expanded={showGuide}
|
||||
>
|
||||
Buka halaman status
|
||||
</Button>
|
||||
<InlinePaymentStatus orderId={orderId} method="cstore" />
|
||||
Cara bayar
|
||||
</button>
|
||||
{showGuide && <PaymentInstructions method="cstore" />}
|
||||
{locked && (
|
||||
<div className="text-xs text-gray-600">Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.</div>
|
||||
)}
|
||||
<div className="pt-2 space-y-2">
|
||||
<div className="rounded border border-gray-300 p-2 text-sm" aria-live="polite">
|
||||
<div className="font-medium">Kode Pembayaran</div>
|
||||
{!selected && (
|
||||
<div className="text-xs text-gray-600">Pilih toko terlebih dahulu di langkah sebelumnya.</div>
|
||||
)}
|
||||
{selected && busy && (
|
||||
<div className="inline-flex items-center gap-2 text-xs text-gray-600">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
|
||||
Membuat kode…
|
||||
</div>
|
||||
)}
|
||||
{selected && !busy && (storeFromRes || paymentCode) && (
|
||||
<>
|
||||
{storeFromRes ? <div>Toko: {storeFromRes.toUpperCase()}</div> : null}
|
||||
{paymentCode ? <div>Kode: <span className="font-mono text-lg tracking-[0.06em] select-all">{paymentCode}</span></div> : null}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2"><Button variant="outline" className="w-full sm:w-auto" onClick={() => copy(paymentCode, 'Kode pembayaran')} disabled={!paymentCode || busy}>Copy Kode</Button></div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={busy || (!locked && !paymentCode)}
|
||||
onClick={() => nav.toStatus(orderId, 'cstore')}
|
||||
>
|
||||
Buka halaman status
|
||||
</Button>
|
||||
<InlinePaymentStatus orderId={orderId} method="cstore" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { GoPayLogosRow } from './PaymentLogos'
|
|||
import { postCharge } from '../../../services/api'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
import { toast } from '../../../components/ui/toast'
|
||||
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
||||
import { mapErrorToUserMessage } from '../../../lib/errorMessages'
|
||||
|
||||
// Global guards/tasks to stabilize QR generation across StrictMode remounts
|
||||
const attemptedChargeKeys = new Set<string>()
|
||||
|
|
@ -52,101 +54,104 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
|
|||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<GoPayPanel_AutoEffect
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
locked={locked}
|
||||
mode={mode}
|
||||
setBusy={setBusy}
|
||||
setQrUrl={setQrUrl}
|
||||
setActions={setActions}
|
||||
onChargeInitiated={onChargeInitiated}
|
||||
lastChargeKeyRef={lastChargeKeyRef}
|
||||
chargingKeyRef={chargingKeyRef}
|
||||
/>
|
||||
<div className="font-medium">GoPay / QRIS</div>
|
||||
<GoPayLogosRow compact />
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-black/60">Mode:</span>
|
||||
<div className="inline-flex rounded-md border-2 border-black/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('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'}`}
|
||||
>
|
||||
GoPay
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('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'}`}
|
||||
>
|
||||
QRIS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border border-black/10 p-3 flex flex-col items-center gap-2">
|
||||
<div className="text-xs text-black/60">Scan QR berikut menggunakan aplikasi {mode === 'gopay' ? 'GoPay' : 'QRIS'}</div>
|
||||
<div className="relative w-full max-w-[280px] aspect-square grid place-items-center rounded-md border border-black/20 bg-white">
|
||||
{mode === 'qris' && (!qrUrl || busy) ? (
|
||||
<span className="inline-flex items-center justify-center gap-2 text-xs text-black/60" role="status" aria-live="polite">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
|
||||
Membuat QR…
|
||||
</span>
|
||||
) : qrUrl ? (
|
||||
<img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[10px] text-black/50">Mode: {mode.toUpperCase()}</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={downloadQR} disabled={!qrUrl}>Download QR</Button>
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={openGoPay} disabled={mode === 'qris'}>Buka GoPay</Button>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<PaymentInstructions
|
||||
title={`Instruksi ${mode === 'gopay' ? 'GoPay' : 'QRIS'}`}
|
||||
steps={mode === 'gopay'
|
||||
? [
|
||||
'Buka aplikasi GoPay dan pilih menu Scan.',
|
||||
'Arahkan kamera ke QR di layar.',
|
||||
'Periksa detail dan konfirmasi pembayaran di aplikasi.',
|
||||
'Simpan bukti pembayaran; status akan diperbarui otomatis.'
|
||||
]
|
||||
: [
|
||||
'Buka aplikasi e-wallet/Bank yang mendukung QRIS (GoPay, ShopeePay, dll).',
|
||||
'Pilih menu Scan, arahkan kamera ke QR di layar.',
|
||||
'Periksa detail dan konfirmasi pembayaran di aplikasi.',
|
||||
'Simpan bukti pembayaran; status akan diperbarui otomatis.'
|
||||
]}
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<LoadingOverlay isLoading={busy} message="Sedang membuat kode QR..." />
|
||||
<GoPayPanel_AutoEffect
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
locked={locked}
|
||||
mode={mode}
|
||||
setBusy={setBusy}
|
||||
setQrUrl={setQrUrl}
|
||||
setActions={setActions}
|
||||
onChargeInitiated={onChargeInitiated}
|
||||
lastChargeKeyRef={lastChargeKeyRef}
|
||||
chargingKeyRef={chargingKeyRef}
|
||||
/>
|
||||
</div>
|
||||
{locked && (
|
||||
<div className="text-xs text-black/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<InlinePaymentStatus orderId={orderId} method={mode} />
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
aria-busy={busy}
|
||||
disabled={busy || (!locked && actions.length === 0)}
|
||||
onClick={() => { setBusy(true); onChargeInitiated?.(); setTimeout(() => { nav.toStatus(orderId, mode); setBusy(false) }, 250) }}
|
||||
>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
|
||||
<div className="font-medium">GoPay / QRIS</div>
|
||||
<GoPayLogosRow compact />
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-black/60">Mode:</span>
|
||||
<div className="inline-flex rounded-md border-2 border-black/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('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'}`}
|
||||
>
|
||||
GoPay
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('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'}`}
|
||||
>
|
||||
QRIS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border border-black/10 p-3 flex flex-col items-center gap-2">
|
||||
<div className="text-xs text-black/60">Scan QR berikut menggunakan aplikasi {mode === 'gopay' ? 'GoPay' : 'QRIS'}</div>
|
||||
<div className="relative w-full max-w-[280px] aspect-square grid place-items-center rounded-md border border-black/20 bg-white">
|
||||
{mode === 'qris' && (!qrUrl || busy) ? (
|
||||
<span className="inline-flex items-center justify-center gap-2 text-xs text-black/60" role="status" aria-live="polite">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
|
||||
Menuju status…
|
||||
Membuat QR…
|
||||
</span>
|
||||
) : 'Buka halaman status'}
|
||||
</Button>
|
||||
) : qrUrl ? (
|
||||
<img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[10px] text-black/50">Mode: {mode.toUpperCase()}</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={downloadQR} disabled={!qrUrl}>Download QR</Button>
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={openGoPay} disabled={mode === 'qris'}>Buka GoPay</Button>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<PaymentInstructions
|
||||
title={`Instruksi ${mode === 'gopay' ? 'GoPay' : 'QRIS'}`}
|
||||
steps={mode === 'gopay'
|
||||
? [
|
||||
'Buka aplikasi GoPay dan pilih menu Scan.',
|
||||
'Arahkan kamera ke QR di layar.',
|
||||
'Periksa detail dan konfirmasi pembayaran di aplikasi.',
|
||||
'Simpan bukti pembayaran; status akan diperbarui otomatis.'
|
||||
]
|
||||
: [
|
||||
'Buka aplikasi e-wallet/Bank yang mendukung QRIS (GoPay, ShopeePay, dll).',
|
||||
'Pilih menu Scan, arahkan kamera ke QR di layar.',
|
||||
'Periksa detail dan konfirmasi pembayaran di aplikasi.',
|
||||
'Simpan bukti pembayaran; status akan diperbarui otomatis.'
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{locked && (
|
||||
<div className="text-xs text-black/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<InlinePaymentStatus orderId={orderId} method={mode} />
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
aria-busy={busy}
|
||||
disabled={busy || (!locked && actions.length === 0)}
|
||||
onClick={() => { setBusy(true); onChargeInitiated?.(); setTimeout(() => { nav.toStatus(orderId, mode); setBusy(false) }, 250) }}
|
||||
>
|
||||
{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-black/40 border-t-transparent" aria-hidden />
|
||||
Menuju status…
|
||||
</span>
|
||||
) : 'Buka halaman status'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +180,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
|
|||
onChargeInitiated?.()
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`)
|
||||
if (!cancelled) toast.error(mapErrorToUserMessage(e))
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setBusy(false)
|
||||
|
|
@ -204,7 +209,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
|
|||
onChargeInitiated?.()
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`)
|
||||
if (!cancelled) toast.error(mapErrorToUserMessage(e))
|
||||
attemptedChargeKeys.delete(chargeKey)
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue