epic-6-snap-hybrid-complete #15

Merged
root merged 6 commits from epic-6-snap-hybrid-complete into dev 2025-12-04 08:25:50 +00:00
5 changed files with 282 additions and 90 deletions
Showing only changes of commit 3512fa4a4d - Show all commits

View File

@ -73,7 +73,13 @@ const LOG_EXPOSE_API = parseEnable(process.env.LOG_EXPOSE_API)
const LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10)
const recentLogs = []
function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) }
function ts() { return new Date().toISOString() }
function ts() {
// Jakarta timezone (WIB = UTC+7)
const now = new Date()
const jakartaOffset = 7 * 60 // minutes
const localTime = new Date(now.getTime() + jakartaOffset * 60 * 1000)
return localTime.toISOString().replace('Z', '+07:00')
}
function sanitize(obj) {
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
}

View File

@ -15,9 +15,10 @@ interface AutoSnapPaymentProps {
onChargeInitiated?: () => void
onSuccess?: (result: any) => void
onError?: (error: any) => void
onModalClosed?: () => void
}
function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Omit<AutoSnapPaymentProps, 'onChargeInitiated'>) {
function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError, onModalClosed }: Omit<AutoSnapPaymentProps, 'onChargeInitiated'>) {
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('')
const [paymentTriggered, setPaymentTriggered] = React.useState(false)
@ -105,6 +106,7 @@ function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Omit
onClose: () => {
Logger.paymentInfo('checkout.auto.snap.popup.closed', { orderId })
setLoading(false)
onModalClosed?.() // Enable status button when modal closed
}
})
@ -137,7 +139,7 @@ function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Omit
console.log('Cleanup: clearing timeout')
clearTimeout(timer)
}
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError])
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError, onModalClosed])
// Don't render anything until we have valid data
if (!orderId || !amount) {
@ -204,6 +206,7 @@ export function CheckoutPage() {
const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
const [isBusy, setIsBusy] = React.useState(false)
const [modalClosed, setModalClosed] = React.useState(false)
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
name: 'Demo User',
contact: 'demo@example.com',
@ -243,7 +246,7 @@ export function CheckoutPage() {
</Alert>
)}
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} customerName={form.name} showStatusCTA={currentStep === 2}>
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} customerName={form.name} showStatusCTA={modalClosed}>
{/* Wizard 2 langkah: Step 1 (Form Dummy) → Step 2 (Payment - Snap/Core auto-detect) */}
{currentStep === 1 && (
<div className="space-y-3">
@ -328,8 +331,12 @@ export function CheckoutPage() {
}}
onError={(error) => {
Logger.error('checkout.payment.error', { orderId, error })
setModalClosed(true) // Enable status button on error
// Handle payment error
}}
onModalClosed={() => {
setModalClosed(true) // Enable status button when modal closed
}}
/>
</div>
)}

View File

@ -104,23 +104,30 @@ function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Auto
Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: e.message })
console.error('[PayPage] Error:', e)
// Handle specific error: order_id already taken (payment already exists)
// Handle specific errors with user-friendly messages
const errorMessage = e.response?.data?.message || e.message || ''
const isOrderTaken = errorMessage.includes('already been taken') ||
errorMessage.includes('order_id has already been taken')
const errorMessages = e.response?.data?.error_messages || []
if (isOrderTaken) {
// Check for "order_id already used" from Midtrans
const isOrderIdUsed = errorMessage.includes('sudah digunakan') ||
errorMessage.includes('already been taken') ||
errorMessage.includes('order_id has already been taken') ||
errorMessages.some((msg: string) => msg.includes('sudah digunakan'))
if (isOrderIdUsed) {
// Order already has payment, redirect to status page
Logger.paymentInfo('paypage.order.already_exists', { orderId })
console.log('[PayPage] Order already has payment, redirecting to status...')
// Show message briefly then redirect
setError('Pembayaran untuk order ini sudah dibuat. Mengalihkan ke halaman status...')
// Show user-friendly message then redirect
setError('Pembayaran untuk pesanan ini sudah dibuat sebelumnya. Anda akan diarahkan ke halaman status pembayaran...')
setTimeout(() => {
window.location.href = `/payments/${orderId}/status`
}, 2000)
} else {
setError(e.response?.data?.message || e.message || 'Gagal memuat pembayaran')
// Generic error with user-friendly message
const userMessage = 'Maaf, terjadi kesalahan saat memuat pembayaran. Silakan coba lagi atau hubungi customer service.'
setError(userMessage)
}
onError?.(e)

View File

@ -15,6 +15,15 @@ export function PaymentStatusPage() {
const method = (search.get('m') ?? undefined) as ('bank_transfer' | 'gopay' | 'qris' | 'cstore' | 'credit_card' | undefined)
const { data, isLoading, error } = usePaymentStatus(orderId)
// Check if error is "transaction not found" from Midtrans
const errorData = (error as any)?.response?.data
const isTransactionNotFound = error &&
(String(error).includes("doesn't exist") ||
String(error).includes("404") ||
String(error).includes("Transaction doesn't exist") ||
errorData?.message?.includes("doesn't exist") ||
errorData?.message?.includes("404"))
const statusText = data?.status ?? 'pending'
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(statusText)
const isSuccess = statusText === 'settlement' || statusText === 'capture'
@ -89,87 +98,250 @@ export function PaymentStatusPage() {
}
}
// User-friendly status messages
function getStatusMessage(s: PaymentStatusResponse['status']) {
switch (s) {
case 'pending':
return { title: 'Menunggu Pembayaran', desc: 'Silakan selesaikan pembayaran Anda', icon: '⏳', color: 'yellow' }
case 'settlement':
case 'capture':
return { title: 'Pembayaran Berhasil', desc: 'Terima kasih! Pembayaran Anda telah dikonfirmasi', icon: '✅', color: 'green' }
case 'deny':
return { title: 'Pembayaran Ditolak', desc: 'Maaf, pembayaran Anda ditolak. Silakan coba metode lain', icon: '❌', color: 'red' }
case 'cancel':
return { title: 'Pembayaran Dibatalkan', desc: 'Transaksi telah dibatalkan', icon: '🚫', color: 'red' }
case 'expire':
return { title: 'Pembayaran Kedaluwarsa', desc: 'Waktu pembayaran habis. Silakan buat transaksi baru', icon: '⏰', color: 'red' }
case 'refund':
return { title: 'Pembayaran Dikembalikan', desc: 'Dana telah dikembalikan ke rekening Anda', icon: '↩️', color: 'blue' }
default:
return { title: 'Status Tidak Diketahui', desc: 'Hubungi customer service untuk bantuan', icon: '', color: 'gray' }
}
}
const statusMsg = getStatusMessage(statusText)
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">Status Pembayaran</h1>
<div className="card p-4">
<div className="text-sm">Order ID: {orderId}</div>
{method || data?.method ? (
<div className="text-xs text-gray-600">Metode: {data?.method ?? method}</div>
) : null}
<div className="mt-2">Status: {isLoading ? (
<span className="font-medium">memuat</span>
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-2xl mx-auto px-4">
{/* Header Card */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-4">
<div className={`p-6 text-center ${
statusMsg.color === 'green' ? 'bg-green-50 border-b border-green-100' :
statusMsg.color === 'yellow' ? 'bg-yellow-50 border-b border-yellow-100' :
statusMsg.color === 'red' ? 'bg-red-50 border-b border-red-100' :
statusMsg.color === 'blue' ? 'bg-blue-50 border-b border-blue-100' :
'bg-gray-50 border-b border-gray-100'
}`}>
{isLoading ? (
<>
<div className="text-4xl mb-2"></div>
<div className="text-xl font-semibold text-gray-700">Memuat status...</div>
<div className="text-sm text-gray-600 mt-1">Mohon tunggu sebentar</div>
</>
) : isTransactionNotFound ? (
<>
<div className="text-4xl mb-2">📋</div>
<div className="text-xl font-semibold text-blue-700">Transaksi Belum Dibuat</div>
<div className="text-sm text-blue-600 mt-1">Silakan kembali ke halaman checkout untuk membuat pembayaran</div>
</>
) : error ? (
<span className="font-medium text-brand-600">gagal memuat</span>
<>
<div className="text-4xl mb-2"></div>
<div className="text-xl font-semibold text-red-700">Gagal Memuat Status</div>
<div className="text-sm text-red-600 mt-1">Terjadi kesalahan. Silakan refresh halaman</div>
</>
) : (
<span className={statusBadgeClass(statusText)}>{statusText}</span>
)}</div>
<div className="mt-1 text-xs text-gray-600">
{isFinal ? 'Status final — polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'}
<>
<div className="text-5xl mb-3">{statusMsg.icon}</div>
<div className="text-2xl font-bold text-gray-800 mb-2">{statusMsg.title}</div>
<div className="text-sm text-gray-600">{statusMsg.desc}</div>
</>
)}
</div>
{/* Order Info */}
<div className="p-6 bg-white">
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
<div>
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">ID Pesanan</div>
<div className="font-mono text-sm font-semibold text-gray-900">{orderId}</div>
</div>
{!isLoading && !isFinal && !isTransactionNotFound && (
<div className="flex items-center gap-2 text-xs text-gray-500">
<div className="h-2 w-2 bg-blue-500 rounded-full animate-pulse"></div>
<span>Memperbarui otomatis...</span>
</div>
)}
</div>
{method || data?.method ? (
<div className="mb-4">
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">Metode Pembayaran</div>
<div className="text-sm font-medium text-gray-900 capitalize">{(data?.method ?? method)?.replace('_', ' ')}</div>
</div>
) : null}
</div>
</div>
{isSuccess ? (
<div className="mt-4">
<div className="text-lg font-semibold"> Pembayaran Berhasil!</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<div className="text-2xl">🎉</div>
<div className="flex-1">
<div className="font-semibold text-green-900 mb-1">Transaksi Selesai!</div>
<div className="text-sm text-green-700">Anda akan diarahkan ke halaman utama dalam beberapa detik</div>
<CountdownRedirect seconds={5} destination="dashboard" onComplete={handleRedirect} />
</div>
</div>
</div>
) : null}
{/* Method-specific details */}
{!isLoading && !error && data ? (
<div className="mt-3 space-y-2 text-sm">
{/* Payment Instructions - Only show for pending status */}
{!isLoading && !error && data && statusText === 'pending' ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-4">
<div className="bg-blue-50 border-b border-blue-100 px-6 py-3">
<div className="text-sm font-semibold text-blue-900">📝 Cara Pembayaran</div>
</div>
<div className="p-6 space-y-4">
{(!method || method === 'bank_transfer') && data.vaNumber ? (
<div className="rounded border border-gray-200 p-2">
<div className="font-medium">Virtual Account</div>
<div>VA Number: <span className="font-mono">{data.vaNumber}</span></div>
{data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null}
{data.billKey && data.billerCode ? (
<div className="mt-1 text-xs text-gray-600">Mandiri E-Channel Bill Key: {data.billKey}, Biller: {data.billerCode}</div>
<>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Nomor Virtual Account</div>
<div className="flex items-center justify-between bg-white rounded border border-gray-300 px-4 py-3">
<div className="font-mono text-lg font-bold text-gray-900">{data.vaNumber}</div>
<button
onClick={() => navigator.clipboard.writeText(data.vaNumber || '')}
className="text-xs bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
>
📋 Salin
</button>
</div>
{data.bank ? (
<div className="mt-2">
<div className="text-xs text-gray-500">Bank</div>
<div className="text-sm font-semibold text-gray-900 uppercase">{data.bank}</div>
</div>
) : null}
</div>
<div className="text-sm text-gray-600 space-y-2">
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>Buka aplikasi mobile banking atau ATM</li>
<li>Pilih menu Transfer / Bayar</li>
<li>Masukkan nomor Virtual Account di atas</li>
<li>Konfirmasi pembayaran</li>
<li>Simpan bukti transaksi</li>
</ol>
</div>
{data.billKey && data.billerCode ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm">
<div className="font-medium text-yellow-900 mb-1">Khusus Mandiri E-Channel:</div>
<div className="text-yellow-800">Kode Biller: <span className="font-mono font-semibold">{data.billerCode}</span></div>
<div className="text-yellow-800">Kode Bayar: <span className="font-mono font-semibold">{data.billKey}</span></div>
</div>
) : null}
</>
) : null}
{(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
<div className="rounded border border-gray-200 p-2">
<div className="font-medium">Convenience Store</div>
{data.store ? <div>Store: {data.store}</div> : null}
{data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null}
<>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
{data.store ? (
<div className="mb-3">
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">Toko</div>
<div className="text-lg font-bold text-gray-900 uppercase">{data.store}</div>
</div>
) : null}
{data.paymentCode ? (
<>
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Kode Pembayaran</div>
<div className="flex items-center justify-between bg-white rounded border border-gray-300 px-4 py-3">
<div className="font-mono text-lg font-bold text-gray-900">{data.paymentCode}</div>
<button
onClick={() => navigator.clipboard.writeText(data.paymentCode || '')}
className="text-xs bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
>
📋 Salin
</button>
</div>
</>
) : null}
</div>
<div className="text-sm text-gray-600 space-y-2">
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>Kunjungi toko {data.store || 'convenience store'} terdekat</li>
<li>Berikan kode pembayaran kepada kasir</li>
<li>Lakukan pembayaran tunai</li>
<li>Simpan bukti pembayaran</li>
</ol>
</div>
</>
) : null}
{(!method || method === 'gopay' || method === 'qris') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
<div className="rounded border border-gray-200 p-2">
<div className="font-medium">QR / Deeplink</div>
<>
{qrSrc ? (
<div className="mt-2 grid place-items-center">
<img src={qrSrc} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto rounded border border-black/10" onError={(e) => {
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="text-xs text-gray-500 uppercase tracking-wide text-center mb-3">Scan QR Code</div>
<div className="bg-white rounded-lg p-4 inline-block mx-auto">
<img src={qrSrc} alt="QR Code Pembayaran" className="w-64 h-64 mx-auto" onError={(e) => {
const next = qrCandidates.find((u) => u !== e.currentTarget.src)
if (next) setQrSrc(next)
}} />
</div>
) : (
<div className="text-xs text-gray-600">Gunakan link berikut untuk membuka aplikasi pembayaran.</div>
)}
<div className="mt-1 flex flex-wrap gap-2">
{(Array.isArray(data?.actions) ? data!.actions : []).map((a, i) => (
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600">
{a.name || a.method || 'Buka'}
</div>
) : null}
<div className="text-sm text-gray-600 space-y-2">
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>Buka aplikasi {method === 'gopay' ? 'GoPay/Gojek' : 'e-wallet atau m-banking'}</li>
<li>Pilih menu Scan QR atau QRIS</li>
<li>Arahkan kamera ke QR code di atas</li>
<li>Konfirmasi pembayaran</li>
</ol>
</div>
{(Array.isArray(data?.actions) && data.actions.length > 0) ? (
<div className="flex flex-wrap gap-2 pt-2">
{data.actions.map((a, i) => (
<a
key={i}
href={a.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm font-medium"
>
📱 {a.name || 'Buka Aplikasi'}
</a>
))}
</div>
</div>
) : null}
</>
) : null}
{(!method || method === 'credit_card') && data.maskedCard ? (
<div className="rounded border border-gray-200 p-2">
<div className="font-medium">Kartu</div>
<div>Masked Card: <span className="font-mono">{data.maskedCard}</span></div>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Kartu Kredit/Debit</div>
<div className="font-mono text-lg font-bold text-gray-900">{data.maskedCard}</div>
<div className="text-sm text-gray-600 mt-3">
Pembayaran dengan kartu telah diproses. Tunggu konfirmasi dari bank Anda.
</div>
</div>
) : null}
</div>
) : null}
{/* Aksi bawah dihilangkan sesuai permintaan */}
</div>
{!Env.API_BASE_URL && (
<Alert title="API Base belum diatur">
Tambahkan <code>VITE_API_BASE_URL</code> di env agar status memuat dari backend; saat ini menggunakan stub.
</Alert>
) : null}
{/* Help Section */}
{!isLoading && !error && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="text-sm text-gray-600">
<p className="font-medium text-gray-900 mb-2">💡 Butuh bantuan?</p>
<ul className="space-y-1 text-sm">
<li> Jika pembayaran belum terkonfirmasi dalam 24 jam, hubungi customer service</li>
<li> Simpan nomor pesanan untuk referensi</li>
<li> Halaman ini akan diperbarui otomatis saat status berubah</li>
</ul>
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -1,12 +1,12 @@
{
"mercant_id": "REFNO-002",
"timestamp": 1733283600000,
"mercant_id": "REFNO-003",
"timestamp": 1733331600000,
"deskripsi": "Bayar Internet",
"nominal": 200000,
"nama": "Demo User 2",
"no_telepon": "081234567890",
"email": "demo2@example.com",
"nominal": 250000,
"nama": "Test User 3",
"no_telepon": "081234567891",
"email": "test3@example.com",
"item": [
{ "item_id": "TKG-2512041", "nama": "Internet Desember", "harga": 200000, "qty": 1 }
{ "item_id": "TKG-2512042", "nama": "Internet Desember Premium", "harga": 250000, "qty": 1 }
]
}