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
2 changed files with 150 additions and 140 deletions
Showing only changes of commit d08b0bd312 - Show all commits

View File

@ -5,6 +5,8 @@ import React from 'react'
import { PaymentInstructions } from './PaymentInstructions' import { PaymentInstructions } from './PaymentInstructions'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus' import { InlinePaymentStatus } from './InlinePaymentStatus'
import { LoadingOverlay } from '../../../components/LoadingOverlay'
import { mapErrorToUserMessage } from '../../../lib/errorMessages'
type StoreKey = 'alfamart' | 'indomaret' type StoreKey = 'alfamart' | 'indomaret'
@ -36,7 +38,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
if (typeof res?.store === 'string') setStoreFromRes(res.store) if (typeof res?.store === 'string') setStoreFromRes(res.store)
} }
} catch (e) { } catch (e) {
if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`) if (!cancelled) toast.error(mapErrorToUserMessage(e))
} finally { } finally {
if (!cancelled) setBusy(false) if (!cancelled) setBusy(false)
cstoreTasks.delete(chargeKey) cstoreTasks.delete(chargeKey)
@ -60,7 +62,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
if (typeof res?.store === 'string') setStoreFromRes(res.store) if (typeof res?.store === 'string') setStoreFromRes(res.store)
} }
} catch (e) { } catch (e) {
if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`) if (!cancelled) toast.error(mapErrorToUserMessage(e))
attemptedCStoreKeys.delete(chargeKey) attemptedCStoreKeys.delete(chargeKey)
} finally { } finally {
if (!cancelled) setBusy(false) if (!cancelled) setBusy(false)
@ -78,53 +80,56 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
} }
return ( return (
<div className="space-y-3"> <>
<div className="font-medium">Convenience Store</div> <LoadingOverlay isLoading={busy} message="Sedang membuat kode pembayaran..." />
{selected && ( <div className="space-y-3">
<div className="text-xs text-gray-600">Toko dipilih: <span className="font-medium text-gray-900">{selected.toUpperCase()}</span></div> <div className="font-medium">Convenience Store</div>
)} {selected && (
<button <div className="text-xs text-gray-600">Toko dipilih: <span className="font-medium text-gray-900">{selected.toUpperCase()}</span></div>
type="button" )}
onClick={() => setShowGuide((v) => !v)} <button
className="text-xs text-brand-600 hover:underline" type="button"
aria-expanded={showGuide} onClick={() => setShowGuide((v) => !v)}
> className="text-xs text-brand-600 hover:underline"
Cara bayar aria-expanded={showGuide}
</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 Cara bayar
</Button> </button>
<InlinePaymentStatus orderId={orderId} method="cstore" /> {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>
</div> </>
) )
} }

View File

@ -6,6 +6,8 @@ import { GoPayLogosRow } from './PaymentLogos'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
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 guards/tasks to stabilize QR generation across StrictMode remounts // Global guards/tasks to stabilize QR generation across StrictMode remounts
const attemptedChargeKeys = new Set<string>() const attemptedChargeKeys = new Set<string>()
@ -52,101 +54,104 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
} }
} }
return ( return (
<div className="space-y-3"> <>
<GoPayPanel_AutoEffect <div className="space-y-3">
orderId={orderId} <LoadingOverlay isLoading={busy} message="Sedang membuat kode QR..." />
amount={amount} <GoPayPanel_AutoEffect
locked={locked} orderId={orderId}
mode={mode} amount={amount}
setBusy={setBusy} locked={locked}
setQrUrl={setQrUrl} mode={mode}
setActions={setActions} setBusy={setBusy}
onChargeInitiated={onChargeInitiated} setQrUrl={setQrUrl}
lastChargeKeyRef={lastChargeKeyRef} setActions={setActions}
chargingKeyRef={chargingKeyRef} onChargeInitiated={onChargeInitiated}
/> lastChargeKeyRef={lastChargeKeyRef}
<div className="font-medium">GoPay / QRIS</div> chargingKeyRef={chargingKeyRef}
<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> <div className="font-medium">GoPay / QRIS</div>
{locked && ( <GoPayLogosRow compact />
<div className="text-xs text-black/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div> <div className="flex items-center gap-2 text-xs">
)} <span className="text-black/60">Mode:</span>
<div className="pt-2"> <div className="inline-flex rounded-md border-2 border-black/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran">
<InlinePaymentStatus orderId={orderId} method={mode} /> <button
<div className="mt-2"> type="button"
<Button onClick={() => setMode('gopay')}
variant="outline" aria-pressed={mode === 'gopay'}
className="w-full" 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'}`}
aria-busy={busy} >
disabled={busy || (!locked && actions.length === 0)} GoPay
onClick={() => { setBusy(true); onChargeInitiated?.(); setTimeout(() => { nav.toStatus(orderId, mode); setBusy(false) }, 250) }} </button>
> <button
{busy ? ( type="button"
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite"> 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 /> <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> </span>
) : 'Buka halaman status'} ) : qrUrl ? (
</Button> <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> </div>
</div> </>
) )
} }
@ -175,7 +180,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
onChargeInitiated?.() onChargeInitiated?.()
} }
} catch (e) { } catch (e) {
if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`) if (!cancelled) toast.error(mapErrorToUserMessage(e))
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setBusy(false) setBusy(false)
@ -204,7 +209,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
onChargeInitiated?.() onChargeInitiated?.()
} }
} catch (e) { } catch (e) {
if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`) if (!cancelled) toast.error(mapErrorToUserMessage(e))
attemptedChargeKeys.delete(chargeKey) attemptedChargeKeys.delete(chargeKey)
} finally { } finally {
if (!cancelled) { if (!cancelled) {