Midtrans-Middleware/src/pages/PaymentStatusPage.tsx

386 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}