386 lines
20 KiB
TypeScript
386 lines
20 KiB
TypeScript
import React from 'react'
|
||
import { useParams, useSearchParams } from 'react-router-dom'
|
||
import { usePaymentStatus } from '../features/payments/lib/usePaymentStatus'
|
||
import type { PaymentStatusResponse } from '../features/payments/lib/midtrans'
|
||
import { Logger } from '../lib/logger'
|
||
|
||
export function PaymentStatusPage() {
|
||
const { orderId } = useParams()
|
||
const [search] = useSearchParams()
|
||
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'
|
||
function sanitizeUrl(u?: string) {
|
||
return (u || '').replace(/[`\s]+$/g, '').replace(/^\s+|\s+$/g, '').replace(/`/g, '')
|
||
}
|
||
function pickQrFromCache(id?: string) {
|
||
try {
|
||
const raw = localStorage.getItem('qrisCache')
|
||
if (!raw) return ''
|
||
const map = JSON.parse(raw) as Record<string, { url?: string }>
|
||
return sanitizeUrl(map[id || '']?.url)
|
||
} catch { return '' }
|
||
}
|
||
function qrFromData(d?: PaymentStatusResponse) {
|
||
if (!d) return ''
|
||
const img = sanitizeUrl(d.imageUrl)
|
||
if (img) return img
|
||
const s = d.qrString
|
||
if (typeof s === 'string' && s.length > 0) {
|
||
return `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(s)}`
|
||
}
|
||
return ''
|
||
}
|
||
function collectQrActionUrls(acts: Array<{ name?: string; method?: string; url: string }> | undefined) {
|
||
const list = Array.isArray(acts) ? acts : []
|
||
const urls = list
|
||
.filter((a) => /qr/i.test(a.name ?? '') || /qr-code/i.test(a.name ?? ''))
|
||
.map((a) => sanitizeUrl(a.url))
|
||
.filter((u) => !!u)
|
||
urls.sort((x, y) => {
|
||
const xv4 = x.includes('/v4/') ? 1 : 0
|
||
const yv4 = y.includes('/v4/') ? 1 : 0
|
||
return yv4 - xv4
|
||
})
|
||
return urls
|
||
}
|
||
const qrCandidates = [qrFromData(data), ...collectQrActionUrls(data?.actions), pickQrFromCache(orderId || undefined)].filter((u) => !!u)
|
||
const [qrSrc, setQrSrc] = React.useState<string>('')
|
||
React.useEffect(() => { setQrSrc(qrCandidates[0] || '') }, [statusText, method, orderId, data, qrCandidates])
|
||
|
||
// Get customer name from localStorage
|
||
const customerName = React.useMemo(() => {
|
||
try {
|
||
const customerCache = JSON.parse(localStorage.getItem('customerCache') || '{}')
|
||
return customerCache[orderId || '']?.name
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}, [orderId])
|
||
|
||
// Logs for debugging status lifecycle
|
||
React.useEffect(() => {
|
||
Logger.info('status.init', { orderId, method })
|
||
}, [])
|
||
|
||
React.useEffect(() => {
|
||
if (!isLoading && !error && data) {
|
||
Logger.info('status.update', { orderId: data.orderId, status: data.status })
|
||
}
|
||
}, [isLoading, error, data])
|
||
|
||
// 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="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 ? (
|
||
<>
|
||
<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>
|
||
</>
|
||
) : (
|
||
<>
|
||
<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>
|
||
|
||
{customerName ? (
|
||
<div className="mb-4">
|
||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">Nama Pelanggan</div>
|
||
<div className="text-sm font-medium text-gray-900">{customerName}</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{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>
|
||
|
||
{/* 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">
|
||
{/* Bank Transfer OR Mandiri E-Channel */}
|
||
{(!method || method === 'bank_transfer' || data.method === 'bank_transfer' || data.method === 'echannel') && (data.vaNumber || (data.billKey && data.billerCode)) ? (
|
||
<>
|
||
{data.vaNumber ? (
|
||
<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>
|
||
) : null}
|
||
|
||
{/* Mandiri E-Channel specific */}
|
||
{data.billKey && data.billerCode ? (
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||
<div className="font-semibold text-yellow-900 mb-3 text-base">💳 Mandiri E-Channel</div>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<div className="text-xs text-yellow-700 uppercase tracking-wide mb-1">Kode Perusahaan (Biller Code)</div>
|
||
<div className="flex items-center justify-between bg-white rounded border border-yellow-300 px-4 py-3">
|
||
<div className="font-mono text-lg font-bold text-gray-900">{data.billerCode}</div>
|
||
<button
|
||
onClick={() => navigator.clipboard.writeText(data.billerCode || '')}
|
||
className="text-xs bg-yellow-600 text-white px-3 py-1.5 rounded hover:bg-yellow-700"
|
||
>
|
||
📋 Salin
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-yellow-700 uppercase tracking-wide mb-1">Kode Bayar (Bill Key)</div>
|
||
<div className="flex items-center justify-between bg-white rounded border border-yellow-300 px-4 py-3">
|
||
<div className="font-mono text-lg font-bold text-gray-900">{data.billKey}</div>
|
||
<button
|
||
onClick={() => navigator.clipboard.writeText(data.billKey || '')}
|
||
className="text-xs bg-yellow-600 text-white px-3 py-1.5 rounded hover:bg-yellow-700"
|
||
>
|
||
📋 Salin
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="text-sm text-gray-600 space-y-2">
|
||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
||
{data.billKey && data.billerCode ? (
|
||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||
<li>Buka aplikasi Livin' by Mandiri atau ATM Mandiri</li>
|
||
<li>Pilih menu <strong>Bayar</strong> / <strong>Multi Payment</strong></li>
|
||
<li>Pilih penyedia jasa: <strong>Midtrans</strong> (atau cari dengan Biller Code)</li>
|
||
<li>Masukkan Kode Perusahaan: <strong>{data.billerCode}</strong></li>
|
||
<li>Masukkan Kode Bayar: <strong>{data.billKey}</strong></li>
|
||
<li>Periksa detail tagihan dan konfirmasi pembayaran</li>
|
||
<li>Simpan bukti transaksi</li>
|
||
</ol>
|
||
) : (
|
||
<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>
|
||
</>
|
||
) : null}
|
||
{(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
|
||
<>
|
||
<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' || data.method === 'qris' || data.method === 'gopay') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
|
||
<>
|
||
{qrSrc ? (
|
||
<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>
|
||
) : 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' || data.method === 'gopay' ? 'GoPay/Gojek' : 'e-wallet atau m-banking yang mendukung QRIS'}</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>
|
||
) : null}
|
||
</>
|
||
) : (data.method === 'qris' || data.method === 'gopay') ? (
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">📱</div>
|
||
<div className="flex-1">
|
||
<div className="font-semibold text-blue-900 mb-2">QR Code Pembayaran</div>
|
||
<div className="text-sm text-blue-700 space-y-2">
|
||
<p>QR code untuk pembayaran ini ditampilkan di jendela pembayaran Snap.</p>
|
||
<p>Jika Anda menutup jendela tersebut, silakan:</p>
|
||
<ol className="list-decimal list-inside ml-2 mt-2 space-y-1">
|
||
<li>Kembali ke halaman checkout</li>
|
||
<li>Buat pembayaran baru dengan order ID yang sama</li>
|
||
<li>QR code akan muncul kembali di jendela Snap</li>
|
||
</ol>
|
||
<p className="mt-3 text-blue-600 italic">Atau tunggu hingga pembayaran kedaluwarsa dan buat transaksi baru.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{(!method || method === 'credit_card') && data.maskedCard ? (
|
||
<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>
|
||
</div>
|
||
) : 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>
|
||
)
|
||
} |