Midtrans-Middleware/src/pages/PayPage.tsx

299 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
import { Alert } from '../components/alert/Alert'
import { Button } from '../components/ui/button'
import { getPaymentLinkPayload } from '../services/api'
import { isOrderLocked, lockOrder } from '../features/payments/lib/chargeLock'
import { usePaymentNavigation } from '../features/payments/lib/navigation'
import { Logger } from '../lib/logger'
import { loadSnapScript } from '../lib/snapLoader'
import { SnapTokenService } from '../features/payments/snap/SnapTokenService'
import React from 'react'
type Method = PaymentMethod | null
interface AutoSnapPaymentProps {
orderId: string
amount: number
customer?: { name?: string; phone?: string; email?: string }
onSuccess?: (result: any) => void
onError?: (error: any) => void
}
function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: AutoSnapPaymentProps) {
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('')
const [paymentTriggered, setPaymentTriggered] = React.useState(false)
console.log('[PayPage] AutoSnapPayment mounted:', { orderId, amount, customer })
Logger.info('paypage.autosnapPayment.mount', { orderId, amount, hasCustomer: !!customer })
React.useEffect(() => {
console.log('[PayPage] useEffect triggered', { orderId, amount, paymentTriggered })
if (!orderId || !amount || paymentTriggered) {
console.log('[PayPage] Early return', { hasOrderId: !!orderId, hasAmount: !!amount, alreadyTriggered: paymentTriggered })
return
}
const triggerPayment = async () => {
console.log('[PayPage] triggerPayment called')
setPaymentTriggered(true)
try {
setLoading(true)
setError('')
Logger.paymentInfo('paypage.auto.snap.init', { orderId, amount, customer })
// Load Snap.js first
await loadSnapScript()
Logger.paymentInfo('paypage.auto.snap.script_loaded', { orderId, hasSnap: !!window.snap })
// Create Snap transaction token
const token = await SnapTokenService.createToken({
transaction_details: {
order_id: orderId,
gross_amount: amount
},
customer_details: customer ? {
first_name: customer.name,
email: customer.email,
phone: customer.phone
} : undefined,
item_details: [{
id: orderId,
name: 'Payment',
price: amount,
quantity: 1
}]
})
Logger.paymentInfo('paypage.auto.snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
console.log('[PayPage] Token received:', token)
if (!window.snap || typeof window.snap.pay !== 'function') {
throw new Error(`Snap.js not loaded: hasSnap=${!!window.snap}`)
}
console.log('[PayPage] Calling window.snap.pay')
setLoading(false)
window.snap.pay(token, {
onSuccess: (result: any) => {
Logger.paymentInfo('paypage.auto.snap.payment.success', { orderId, transactionId: result.transaction_id })
onSuccess?.(result)
},
onPending: (result: any) => {
Logger.paymentInfo('paypage.auto.snap.payment.pending', { orderId, transactionId: result.transaction_id })
},
onError: (result: any) => {
Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: result })
setError('Pembayaran gagal. Silakan coba lagi.')
setLoading(false)
onError?.(result)
},
onClose: () => {
Logger.paymentInfo('paypage.auto.snap.popup.closed', { orderId })
setLoading(false)
}
})
} catch (e: any) {
Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: e.message })
console.error('[PayPage] Error:', e)
// Handle specific errors with user-friendly messages
const errorMessage = e.response?.data?.message || e.message || ''
const errorMessages = e.response?.data?.error_messages || []
// 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 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 {
// 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)
setLoading(false)
}
}
console.log('[PayPage] Setting timeout')
const timer = setTimeout(triggerPayment, 500)
return () => clearTimeout(timer)
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError])
// Don't render anything until we have valid data
if (!orderId || !amount) {
return (
<div className="text-center">
<div className="space-y-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
<p className="text-sm text-gray-600">Memuat data pembayaran...</p>
</div>
</div>
)
}
return (
<div className="space-y-4">
{error && (
<Alert title="Pembayaran Gagal">
{error}
</Alert>
)}
<div className="text-center">
{loading ? (
<div className="space-y-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
<p className="text-sm text-gray-600">Menyiapkan pembayaran...</p>
</div>
) : error ? (
<div className="space-y-2">
<p className="text-sm text-red-600">Gagal memuat pembayaran</p>
<button
onClick={() => window.location.reload()}
className="text-sm text-blue-600 underline"
>
Coba lagi
</button>
</div>
) : null}
</div>
</div>
)
}
export function PayPage() {
const { token } = useParams()
const nav = usePaymentNavigation()
const [orderId, setOrderId] = useState<string>('')
const [amount, setAmount] = useState<number>(0)
const [expireAt, setExpireAt] = useState<number>(Date.now() + 24 * 60 * 60 * 1000)
const [selectedMethod] = useState<Method>(null)
const [locked, setLocked] = useState<boolean>(false)
const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined)
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
usePaymentConfig()
const currentStep = 2
useEffect(() => {
let cancelled = false
async function resolve() {
if (!token) return
try {
const payload = await getPaymentLinkPayload(token)
if (cancelled) return
setOrderId(payload.order_id)
setAmount(payload.nominal)
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
setCustomer(payload.customer)
setError(null)
if (isOrderLocked(payload.order_id)) setLocked(true)
} catch {
if (cancelled) return
setError({ code: 'TOKEN_RESOLVE_ERROR' })
}
}
resolve()
return () => {
cancelled = true
}
}, [token])
const merchantName = useMemo(() => '', [])
const isExpired = expireAt ? Date.now() > expireAt : false
if (error || isExpired) {
const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid'
const msg = isExpired ? 'Silakan minta link baru dari admin atau ERP.' : 'Token tidak dapat diverifikasi. Hubungi admin untuk bantuan.'
return (
<PaymentSheet
merchantName={merchantName}
orderId={orderId || (token ?? '')}
amount={amount}
expireAt={expireAt}
showStatusCTA={false}
>
<div className="space-y-4 px-4 py-6">
<Alert title={title}>{msg}</Alert>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="secondary"
onClick={() => { try { window.location.reload() } catch { void 0 } }}
className="w-full sm:w-auto"
>
Muat ulang
</Button>
<a
href="mailto:retailimaya@gmail.com?subject=Permintaan%20Link%20Pembayaran&body=Order%20ID:%20"
className="inline-flex items-center px-3 py-2 rounded border bg-gray-800 !text-white hover:!text-white focus:!text-white visited:!text-white active:!text-white w-full sm:w-auto justify-center"
>
Hubungi Admin
</a>
</div>
</div>
</PaymentSheet>
)
}
return (
<PaymentSheet
merchantName={merchantName}
orderId={orderId}
amount={amount}
expireAt={expireAt}
showStatusCTA={currentStep === 2}
>
<div className="space-y-4 px-4 py-6">
{locked && currentStep === 2 && (
<Alert title="Pembayaran sudah dibuat">
Kode/QR telah digenerate untuk order ini. Buka halaman status untuk melanjutkan.
<div className="mt-2">
<Button variant="primary" onClick={() => nav.toStatus(orderId)}>
Buka status
</Button>
</div>
</Alert>
)}
{currentStep === 2 && (
<div className="space-y-3" aria-live="polite">
<AutoSnapPayment
orderId={orderId}
amount={amount}
customer={customer}
onSuccess={(result) => {
console.log('[PayPage] Payment success:', result)
lockOrder(orderId)
setLocked(true)
nav.toStatus(orderId, selectedMethod || undefined)
}}
onError={(error) => {
console.error('[PayPage] Payment error:', error)
}}
/>
</div>
)}
</div>
</PaymentSheet>
)
}