Midtrans-Middleware/src/features/payments/core/GoPayPanel.tsx

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
}