266 lines
13 KiB
TypeScript
266 lines
13 KiB
TypeScript
import { Button } from '../../../components/ui/button'
|
|
import { usePaymentNavigation } from '../lib/navigation'
|
|
import React from 'react'
|
|
import { PaymentInstructions } from '../components/PaymentInstructions'
|
|
import { GoPayLogosRow } from '../components/PaymentLogos'
|
|
import { postCharge } from '../../../services/api'
|
|
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
|
import { toast } from '../../../components/ui/toast'
|
|
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
|
import { Alert } from '../../../components/alert/Alert'
|
|
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
|
|
|
|
// Global guards/tasks to stabilize QR generation across StrictMode remounts
|
|
const attemptedChargeKeys = new Set<string>()
|
|
const chargeTasks = new Map<string, Promise<any>>()
|
|
|
|
function sanitizeUrl(u?: string) {
|
|
return (u || '').replace(/[`\s]+$/g, '').replace(/^\s+|\s+$/g, '').replace(/`/g, '')
|
|
}
|
|
|
|
function pickQrImageUrl(res: any, acts: Array<{ name?: string; method?: string; url: string }>) {
|
|
const byName = acts.find((a) => /qr/i.test(a.name ?? '') || /qr-code/i.test(a.name ?? ''))
|
|
const candidateFromActions = sanitizeUrl(byName?.url)
|
|
if (candidateFromActions) return candidateFromActions
|
|
const imageUrl = typeof res?.image_url === 'string' ? sanitizeUrl(res.image_url) : (typeof res?.qr_url === 'string' ? sanitizeUrl(res.qr_url) : '')
|
|
if (imageUrl) return imageUrl
|
|
const qrString = typeof res?.qr_string === 'string' ? res.qr_string : ''
|
|
if (qrString) return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(qrString)}`
|
|
return ''
|
|
}
|
|
|
|
//
|
|
|
|
export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
|
const nav = usePaymentNavigation()
|
|
const [busy, setBusy] = React.useState(false)
|
|
const [qrUrl, setQrUrl] = React.useState<string>('')
|
|
const [actions, setActions] = React.useState<Array<{ name?: string; method?: string; url: string }>>([])
|
|
const [mode, setMode] = React.useState<'gopay' | 'qris'>('qris')
|
|
const lastChargeKeyRef = React.useRef<string>('')
|
|
const chargingKeyRef = React.useRef<string>('')
|
|
const [errorMessage, setErrorMessage] = React.useState('')
|
|
const [recovery, setRecovery] = React.useState<'retry' | 'view-existing' | 'back'>('retry')
|
|
function openGoPay() {
|
|
const deeplink = actions.find((a) => (a.name ?? '').toLowerCase().includes('deeplink'))
|
|
window.open(deeplink?.url || 'https://www.gojek.com/gopay/', '_blank')
|
|
}
|
|
function downloadQR() {
|
|
if (qrUrl) {
|
|
const a = document.createElement('a')
|
|
a.href = qrUrl
|
|
a.download = `QR-${orderId}.png`
|
|
a.target = '_blank'
|
|
a.click()
|
|
} else {
|
|
toast.error('QR belum tersedia. Klik "Buat QR" terlebih dulu.')
|
|
}
|
|
}
|
|
return (
|
|
<>
|
|
<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}
|
|
setErrorMessage={setErrorMessage}
|
|
setRecovery={setRecovery}
|
|
/>
|
|
<div className="font-medium">GoPay / QRIS</div>
|
|
{errorMessage && (
|
|
<Alert title="Informasi">
|
|
{errorMessage}
|
|
{recovery === 'view-existing' && (
|
|
<div className="mt-2">
|
|
<Button variant="outline" onClick={() => nav.toStatus(orderId, mode)}>Lihat Kode Pembayaran</Button>
|
|
</div>
|
|
)}
|
|
</Alert>
|
|
)}
|
|
<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>
|
|
{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 && recovery !== 'view-existing')}
|
|
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>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Auto-generate QR for QRIS when mode is set to 'qris'
|
|
// Use effect to trigger charge once per order+mode, with guards for StrictMode/HMR
|
|
export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy, setQrUrl, setActions, onChargeInitiated, lastChargeKeyRef, chargingKeyRef, setErrorMessage, setRecovery }:
|
|
{ orderId: string; amount: number; locked?: boolean; mode: 'gopay' | 'qris'; setBusy: (b: boolean) => void; setQrUrl: (u: string) => void; setActions: (a: Array<{ name?: string; method?: string; url: string }>) => void; onChargeInitiated?: () => void; lastChargeKeyRef: React.MutableRefObject<string>; chargingKeyRef: React.MutableRefObject<string>; setErrorMessage: (m: string) => void; setRecovery: (a: 'retry' | 'view-existing' | 'back') => void }) {
|
|
React.useEffect(() => {
|
|
let cancelled = false
|
|
async function run() {
|
|
if (mode !== 'qris' || locked) return
|
|
const chargeKey = `${orderId}:qris`
|
|
// If a QRIS charge is already in-flight (StrictMode remount), await the shared task
|
|
if (attemptedChargeKeys.has(chargeKey) && chargeTasks.has(chargeKey)) {
|
|
setBusy(true)
|
|
chargingKeyRef.current = chargeKey
|
|
try {
|
|
const res = await chargeTasks.get(chargeKey)!
|
|
const acts: Array<{ name?: string; method?: string; url: string }> = Array.isArray(res?.actions) ? res.actions : []
|
|
if (!cancelled) {
|
|
setActions(acts)
|
|
const url = pickQrImageUrl(res, acts)
|
|
if (url) setQrUrl(url)
|
|
try {
|
|
const raw = localStorage.getItem('qrisCache')
|
|
const map = raw ? (JSON.parse(raw) as Record<string, { url?: string; actions?: Array<{ name?: string; method?: string; url: string }> }>) : {}
|
|
map[orderId] = { url, actions: acts }
|
|
localStorage.setItem('qrisCache', JSON.stringify(map))
|
|
} catch { void 0 }
|
|
lastChargeKeyRef.current = chargeKey
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
onChargeInitiated?.()
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled) {
|
|
const msg = mapErrorToUserMessage(e)
|
|
const act = getErrorRecoveryAction(e)
|
|
setErrorMessage(msg)
|
|
setRecovery(act)
|
|
toast.error(msg)
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setBusy(false)
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
}
|
|
chargeTasks.delete(chargeKey)
|
|
}
|
|
return
|
|
}
|
|
if (lastChargeKeyRef.current === chargeKey || chargingKeyRef.current === chargeKey) return
|
|
setBusy(true)
|
|
chargingKeyRef.current = chargeKey
|
|
try {
|
|
const payload = { payment_type: 'qris', transaction_details: { order_id: orderId, gross_amount: amount }, qris: {} }
|
|
attemptedChargeKeys.add(chargeKey)
|
|
const task = postCharge(payload)
|
|
chargeTasks.set(chargeKey, task)
|
|
const res = await task
|
|
const acts: Array<{ name?: string; method?: string; url: string }> = Array.isArray(res?.actions) ? res.actions : []
|
|
if (!cancelled) {
|
|
setActions(acts)
|
|
const url = pickQrImageUrl(res, acts)
|
|
if (url) setQrUrl(url)
|
|
try {
|
|
const raw = localStorage.getItem('qrisCache')
|
|
const map = raw ? (JSON.parse(raw) as Record<string, { url?: string; actions?: Array<{ name?: string; method?: string; url: string }> }>) : {}
|
|
map[orderId] = { url, actions: acts }
|
|
localStorage.setItem('qrisCache', JSON.stringify(map))
|
|
} catch { void 0 }
|
|
lastChargeKeyRef.current = chargeKey
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
onChargeInitiated?.()
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled) {
|
|
const msg = mapErrorToUserMessage(e)
|
|
const act = getErrorRecoveryAction(e)
|
|
setErrorMessage(msg)
|
|
setRecovery(act)
|
|
toast.error(msg)
|
|
}
|
|
attemptedChargeKeys.delete(chargeKey)
|
|
} finally {
|
|
if (!cancelled) {
|
|
setBusy(false)
|
|
if (chargingKeyRef.current === chargeKey) chargingKeyRef.current = ''
|
|
}
|
|
chargeTasks.delete(chargeKey)
|
|
}
|
|
}
|
|
run()
|
|
return () => { cancelled = true }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mode])
|
|
return null
|
|
} |