fix ux
This commit is contained in:
parent
c6225e3d35
commit
3512fa4a4d
|
|
@ -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 LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10)
|
||||||
const recentLogs = []
|
const recentLogs = []
|
||||||
function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) }
|
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) {
|
function sanitize(obj) {
|
||||||
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
|
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ interface AutoSnapPaymentProps {
|
||||||
onChargeInitiated?: () => void
|
onChargeInitiated?: () => void
|
||||||
onSuccess?: (result: any) => void
|
onSuccess?: (result: any) => void
|
||||||
onError?: (error: 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 [loading, setLoading] = React.useState(false)
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
const [paymentTriggered, setPaymentTriggered] = React.useState(false)
|
const [paymentTriggered, setPaymentTriggered] = React.useState(false)
|
||||||
|
|
@ -105,6 +106,7 @@ function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Omit
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
Logger.paymentInfo('checkout.auto.snap.popup.closed', { orderId })
|
Logger.paymentInfo('checkout.auto.snap.popup.closed', { orderId })
|
||||||
setLoading(false)
|
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')
|
console.log('Cleanup: clearing timeout')
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError])
|
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError, onModalClosed])
|
||||||
|
|
||||||
// Don't render anything until we have valid data
|
// Don't render anything until we have valid data
|
||||||
if (!orderId || !amount) {
|
if (!orderId || !amount) {
|
||||||
|
|
@ -204,6 +206,7 @@ export function CheckoutPage() {
|
||||||
const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
|
const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
|
||||||
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
|
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
|
||||||
const [isBusy, setIsBusy] = React.useState(false)
|
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 }>({
|
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
|
||||||
name: 'Demo User',
|
name: 'Demo User',
|
||||||
contact: 'demo@example.com',
|
contact: 'demo@example.com',
|
||||||
|
|
@ -243,7 +246,7 @@ export function CheckoutPage() {
|
||||||
</Alert>
|
</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) */}
|
{/* Wizard 2 langkah: Step 1 (Form Dummy) → Step 2 (Payment - Snap/Core auto-detect) */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -328,8 +331,12 @@ export function CheckoutPage() {
|
||||||
}}
|
}}
|
||||||
onError={(error) => {
|
onError={(error) => {
|
||||||
Logger.error('checkout.payment.error', { orderId, error })
|
Logger.error('checkout.payment.error', { orderId, error })
|
||||||
|
setModalClosed(true) // Enable status button on error
|
||||||
// Handle payment error
|
// Handle payment error
|
||||||
}}
|
}}
|
||||||
|
onModalClosed={() => {
|
||||||
|
setModalClosed(true) // Enable status button when modal closed
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -104,23 +104,30 @@ function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Auto
|
||||||
Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: e.message })
|
Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: e.message })
|
||||||
console.error('[PayPage] Error:', e)
|
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 errorMessage = e.response?.data?.message || e.message || ''
|
||||||
const isOrderTaken = errorMessage.includes('already been taken') ||
|
const errorMessages = e.response?.data?.error_messages || []
|
||||||
errorMessage.includes('order_id has already been taken')
|
|
||||||
|
|
||||||
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
|
// Order already has payment, redirect to status page
|
||||||
Logger.paymentInfo('paypage.order.already_exists', { orderId })
|
Logger.paymentInfo('paypage.order.already_exists', { orderId })
|
||||||
console.log('[PayPage] Order already has payment, redirecting to status...')
|
console.log('[PayPage] Order already has payment, redirecting to status...')
|
||||||
|
|
||||||
// Show message briefly then redirect
|
// Show user-friendly message then redirect
|
||||||
setError('Pembayaran untuk order ini sudah dibuat. Mengalihkan ke halaman status...')
|
setError('Pembayaran untuk pesanan ini sudah dibuat sebelumnya. Anda akan diarahkan ke halaman status pembayaran...')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/payments/${orderId}/status`
|
window.location.href = `/payments/${orderId}/status`
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} else {
|
} 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)
|
onError?.(e)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,15 @@ export function PaymentStatusPage() {
|
||||||
const method = (search.get('m') ?? undefined) as ('bank_transfer' | 'gopay' | 'qris' | 'cstore' | 'credit_card' | undefined)
|
const method = (search.get('m') ?? undefined) as ('bank_transfer' | 'gopay' | 'qris' | 'cstore' | 'credit_card' | undefined)
|
||||||
const { data, isLoading, error } = usePaymentStatus(orderId)
|
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 statusText = data?.status ?? 'pending'
|
||||||
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(statusText)
|
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(statusText)
|
||||||
const isSuccess = statusText === 'settlement' || statusText === 'capture'
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
<h1 className="text-xl font-semibold">Status Pembayaran</h1>
|
<div className="max-w-2xl mx-auto px-4">
|
||||||
<div className="card p-4">
|
{/* Header Card */}
|
||||||
<div className="text-sm">Order ID: {orderId}</div>
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-4">
|
||||||
{method || data?.method ? (
|
<div className={`p-6 text-center ${
|
||||||
<div className="text-xs text-gray-600">Metode: {data?.method ?? method}</div>
|
statusMsg.color === 'green' ? 'bg-green-50 border-b border-green-100' :
|
||||||
) : null}
|
statusMsg.color === 'yellow' ? 'bg-yellow-50 border-b border-yellow-100' :
|
||||||
<div className="mt-2">Status: {isLoading ? (
|
statusMsg.color === 'red' ? 'bg-red-50 border-b border-red-100' :
|
||||||
<span className="font-medium">memuat…</span>
|
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 ? (
|
) : 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="text-5xl mb-3">{statusMsg.icon}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">
|
<div className="text-2xl font-bold text-gray-800 mb-2">{statusMsg.title}</div>
|
||||||
{isFinal ? 'Status final — polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'}
|
<div className="text-sm text-gray-600">{statusMsg.desc}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</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 ? (
|
{isSuccess ? (
|
||||||
<div className="mt-4">
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||||
<div className="text-lg font-semibold">✅ Pembayaran Berhasil!</div>
|
<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} />
|
<CountdownRedirect seconds={5} destination="dashboard" onComplete={handleRedirect} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Method-specific details */}
|
{/* Payment Instructions - Only show for pending status */}
|
||||||
{!isLoading && !error && data ? (
|
{!isLoading && !error && data && statusText === 'pending' ? (
|
||||||
<div className="mt-3 space-y-2 text-sm">
|
<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 ? (
|
{(!method || method === 'bank_transfer') && data.vaNumber ? (
|
||||||
<div className="rounded border border-gray-200 p-2">
|
<>
|
||||||
<div className="font-medium">Virtual Account</div>
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
<div>VA Number: <span className="font-mono">{data.vaNumber}</span></div>
|
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Nomor Virtual Account</div>
|
||||||
{data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null}
|
<div className="flex items-center justify-between bg-white rounded border border-gray-300 px-4 py-3">
|
||||||
{data.billKey && data.billerCode ? (
|
<div className="font-mono text-lg font-bold text-gray-900">{data.vaNumber}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Mandiri E-Channel — Bill Key: {data.billKey}, Biller: {data.billerCode}</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}
|
) : null}
|
||||||
</div>
|
</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}
|
) : null}
|
||||||
{(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
|
{(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
|
||||||
<div className="rounded border border-gray-200 p-2">
|
<>
|
||||||
<div className="font-medium">Convenience Store</div>
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
{data.store ? <div>Store: {data.store}</div> : null}
|
{data.store ? (
|
||||||
{data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null}
|
<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>
|
</div>
|
||||||
) : null}
|
) : 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)) ? (
|
{(!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 ? (
|
{qrSrc ? (
|
||||||
<div className="mt-2 grid place-items-center">
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
<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="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)
|
const next = qrCandidates.find((u) => u !== e.currentTarget.src)
|
||||||
if (next) setQrSrc(next)
|
if (next) setQrSrc(next)
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="text-xs text-gray-600">Gunakan link berikut untuk membuka aplikasi pembayaran.</div>
|
) : null}
|
||||||
)}
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<div className="mt-1 flex flex-wrap gap-2">
|
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
||||||
{(Array.isArray(data?.actions) ? data!.actions : []).map((a, i) => (
|
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||||
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600">
|
<li>Buka aplikasi {method === 'gopay' ? 'GoPay/Gojek' : 'e-wallet atau m-banking'}</li>
|
||||||
{a.name || a.method || 'Buka'}
|
<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>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{(!method || method === 'credit_card') && data.maskedCard ? (
|
{(!method || method === 'credit_card') && data.maskedCard ? (
|
||||||
<div className="rounded border border-gray-200 p-2">
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
<div className="font-medium">Kartu</div>
|
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Kartu Kredit/Debit</div>
|
||||||
<div>Masked Card: <span className="font-mono">{data.maskedCard}</span></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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
{/* Aksi bawah dihilangkan sesuai permintaan */}
|
|
||||||
</div>
|
</div>
|
||||||
{!Env.API_BASE_URL && (
|
) : null}
|
||||||
<Alert title="API Base belum diatur">
|
{/* Help Section */}
|
||||||
Tambahkan <code>VITE_API_BASE_URL</code> di env agar status memuat dari backend; saat ini menggunakan stub.
|
{!isLoading && !error && (
|
||||||
</Alert>
|
<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>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"mercant_id": "REFNO-002",
|
"mercant_id": "REFNO-003",
|
||||||
"timestamp": 1733283600000,
|
"timestamp": 1733331600000,
|
||||||
"deskripsi": "Bayar Internet",
|
"deskripsi": "Bayar Internet",
|
||||||
"nominal": 200000,
|
"nominal": 250000,
|
||||||
"nama": "Demo User 2",
|
"nama": "Test User 3",
|
||||||
"no_telepon": "081234567890",
|
"no_telepon": "081234567891",
|
||||||
"email": "demo2@example.com",
|
"email": "test3@example.com",
|
||||||
"item": [
|
"item": [
|
||||||
{ "item_id": "TKG-2512041", "nama": "Internet Desember", "harga": 200000, "qty": 1 }
|
{ "item_id": "TKG-2512042", "nama": "Internet Desember Premium", "harga": 250000, "qty": 1 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue