299 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|