epic-6-snap-hybrid-complete #15
|
|
@ -25,11 +25,12 @@ export interface PaymentSheetProps {
|
|||
orderId: string
|
||||
amount: number
|
||||
expireAt: number // epoch ms
|
||||
customerName?: string
|
||||
children?: React.ReactNode
|
||||
showStatusCTA?: boolean
|
||||
}
|
||||
|
||||
export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) {
|
||||
export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireAt, customerName, children, showStatusCTA = true }: PaymentSheetProps) {
|
||||
const countdown = useCountdown(expireAt)
|
||||
const [expanded, setExpanded] = React.useState(true)
|
||||
return (
|
||||
|
|
@ -71,6 +72,7 @@ export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireA
|
|||
<div className="text-xs text-black">Total</div>
|
||||
<div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
|
||||
<div className="text-xs text-black/60">Order ID #{orderId}</div>
|
||||
{customerName && <div className="text-xs text-black/60 mt-1">Nama: {customerName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -202,17 +202,3 @@ function SnapHostedPayment({ orderId, amount, customer, onSuccess, onError }: Om
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Type declaration for window.snap
|
||||
declare global {
|
||||
interface Window {
|
||||
snap?: {
|
||||
pay: (token: string, options: {
|
||||
onSuccess: (result: any) => void
|
||||
onPending: (result: any) => void
|
||||
onError: (result: any) => void
|
||||
onClose: () => void
|
||||
}) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { Env } from './env'
|
||||
import { Logger } from './logger'
|
||||
|
||||
let snapLoaded = false
|
||||
let snapPromise: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Dynamically loads Midtrans Snap.js script
|
||||
* Returns a promise that resolves when Snap.js is ready
|
||||
*/
|
||||
export function loadSnapScript(): Promise<void> {
|
||||
// Return existing promise if already loading
|
||||
if (snapPromise) {
|
||||
return snapPromise
|
||||
}
|
||||
|
||||
// Already loaded
|
||||
if (snapLoaded && window.snap) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Start loading
|
||||
snapPromise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
const clientKey = Env.MIDTRANS_CLIENT_KEY
|
||||
const midtransEnv = Env.MIDTRANS_ENV || 'sandbox'
|
||||
|
||||
if (!clientKey) {
|
||||
const error = 'MIDTRANS_CLIENT_KEY not configured'
|
||||
Logger.error('snap.load.error', { error })
|
||||
reject(new Error(error))
|
||||
return
|
||||
}
|
||||
|
||||
// Determine Snap.js URL based on environment
|
||||
const snapUrl = midtransEnv === 'production'
|
||||
? 'https://app.midtrans.com/snap/snap.js'
|
||||
: 'https://app.sandbox.midtrans.com/snap/snap.js'
|
||||
|
||||
Logger.info('snap.load.start', { snapUrl, clientKey: clientKey.substring(0, 10) + '...' })
|
||||
|
||||
// Check if script already exists
|
||||
const existingScript = document.querySelector(`script[src="${snapUrl}"]`)
|
||||
if (existingScript) {
|
||||
Logger.info('snap.load.exists', { snapUrl })
|
||||
// Wait a bit and check if window.snap is available
|
||||
setTimeout(() => {
|
||||
if (window.snap) {
|
||||
snapLoaded = true
|
||||
Logger.info('snap.load.ready', { hasSnap: true })
|
||||
resolve()
|
||||
} else {
|
||||
Logger.error('snap.load.error', { error: 'Script loaded but window.snap not available' })
|
||||
reject(new Error('Snap.js loaded but window.snap not available'))
|
||||
}
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Create script element
|
||||
const script = document.createElement('script')
|
||||
script.src = snapUrl
|
||||
script.setAttribute('data-client-key', clientKey)
|
||||
|
||||
script.onload = () => {
|
||||
Logger.info('snap.script.loaded', { snapUrl })
|
||||
console.log('Snap.js script loaded, waiting for initialization...')
|
||||
// Wait a bit for Snap to initialize
|
||||
setTimeout(() => {
|
||||
console.log('After 500ms delay - window.snap:', window.snap)
|
||||
console.log('After 500ms delay - window.snap?.pay:', window.snap?.pay)
|
||||
if (window.snap && typeof window.snap.pay === 'function') {
|
||||
snapLoaded = true
|
||||
Logger.info('snap.load.success', { hasSnap: true, hasPay: true })
|
||||
console.log('✓ Snap.js ready!')
|
||||
resolve()
|
||||
} else {
|
||||
const error = 'Snap.js loaded but window.snap.pay not available'
|
||||
Logger.error('snap.load.error', { error, hasSnap: !!window.snap })
|
||||
console.error('✗ Snap.js error:', error, { hasSnap: !!window.snap, snapObj: window.snap })
|
||||
reject(new Error(error))
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
script.onerror = (error) => {
|
||||
Logger.error('snap.script.error', { error, snapUrl })
|
||||
reject(new Error('Failed to load Snap.js script'))
|
||||
}
|
||||
|
||||
// Append script to document
|
||||
document.head.appendChild(script)
|
||||
Logger.info('snap.script.appended', { snapUrl })
|
||||
|
||||
} catch (error: any) {
|
||||
Logger.error('snap.load.exception', { error: error.message })
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
return snapPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Snap.js is already loaded and ready
|
||||
*/
|
||||
export function isSnapReady(): boolean {
|
||||
return snapLoaded && !!window.snap && typeof window.snap.pay === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Snap.js to be ready, with timeout
|
||||
*/
|
||||
export function waitForSnap(timeoutMs: number = 5000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isSnapReady()) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const checkInterval = setInterval(() => {
|
||||
if (isSnapReady()) {
|
||||
clearInterval(checkInterval)
|
||||
resolve()
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
clearInterval(checkInterval)
|
||||
reject(new Error(`Snap.js not ready after ${timeoutMs}ms`))
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import React from 'react'
|
||||
import { Alert } from '../components/alert/Alert'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Env } from '../lib/env'
|
||||
import { Logger } from '../lib/logger'
|
||||
import { loadSnapScript } from '../lib/snapLoader'
|
||||
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
||||
import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
|
||||
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
|
||||
import { SnapPaymentTrigger } from '../features/payments/snap/SnapPaymentTrigger'
|
||||
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
||||
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
|
||||
import { SnapTokenService } from '../features/payments/snap/SnapTokenService'
|
||||
interface AutoSnapPaymentProps {
|
||||
orderId: string
|
||||
amount: number
|
||||
|
|
@ -15,27 +17,39 @@ interface AutoSnapPaymentProps {
|
|||
onError?: (error: any) => void
|
||||
}
|
||||
|
||||
function AutoSnapPayment({ orderId, amount, customer, onChargeInitiated, onSuccess, onError }: AutoSnapPaymentProps) {
|
||||
function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Omit<AutoSnapPaymentProps, 'onChargeInitiated'>) {
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState('')
|
||||
const hasTriggered = React.useRef(false)
|
||||
const [paymentTriggered, setPaymentTriggered] = React.useState(false)
|
||||
|
||||
// Debug log immediately on component mount
|
||||
console.log('AutoSnapPayment mounted with:', { orderId, amount, customer })
|
||||
Logger.info('autosnapPayment.mount', { orderId, amount, hasCustomer: !!customer })
|
||||
|
||||
React.useEffect(() => {
|
||||
// Only trigger when we have valid orderId and amount
|
||||
if (!orderId || !amount || hasTriggered.current) return
|
||||
hasTriggered.current = true
|
||||
console.log('AutoSnapPayment useEffect triggered', { orderId, amount, paymentTriggered })
|
||||
// Only trigger when we have valid orderId and amount and not already triggered
|
||||
if (!orderId || !amount || paymentTriggered) {
|
||||
console.log('AutoSnapPayment useEffect early return', { hasOrderId: !!orderId, hasAmount: !!amount, alreadyTriggered: paymentTriggered })
|
||||
return
|
||||
}
|
||||
|
||||
const triggerPayment = async () => {
|
||||
console.log('triggerPayment function called!')
|
||||
setPaymentTriggered(true) // Mark as triggered immediately
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
Logger.paymentInfo('checkout.auto.snap.init', { orderId, amount, customer })
|
||||
|
||||
// Import SnapTokenService dynamically to avoid circular deps
|
||||
const { SnapTokenService } = await import('../features/payments/snap/SnapTokenService')
|
||||
// Load Snap.js first
|
||||
Logger.paymentInfo('checkout.auto.snap.loading_script', { orderId })
|
||||
await loadSnapScript()
|
||||
Logger.paymentInfo('checkout.auto.snap.script_loaded', { orderId, hasSnap: !!window.snap })
|
||||
|
||||
// Create Snap transaction token
|
||||
Logger.paymentInfo('checkout.auto.snap.calling_api', { orderId, amount })
|
||||
const token = await SnapTokenService.createToken({
|
||||
transaction_details: {
|
||||
order_id: orderId,
|
||||
|
|
@ -55,45 +69,75 @@ function AutoSnapPayment({ orderId, amount, customer, onChargeInitiated, onSucce
|
|||
})
|
||||
|
||||
Logger.paymentInfo('checkout.auto.snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
|
||||
console.log('Token berhasil dibuat:', token)
|
||||
|
||||
// Auto-trigger Snap payment popup
|
||||
if (window.snap && typeof window.snap.pay === 'function') {
|
||||
window.snap.pay(token, {
|
||||
onSuccess: (result: any) => {
|
||||
Logger.paymentInfo('checkout.auto.snap.payment.success', { orderId, transactionId: result.transaction_id })
|
||||
onSuccess?.(result)
|
||||
},
|
||||
onPending: (result: any) => {
|
||||
Logger.paymentInfo('checkout.auto.snap.payment.pending', { orderId, transactionId: result.transaction_id })
|
||||
},
|
||||
onError: (result: any) => {
|
||||
Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: result })
|
||||
const message = 'Pembayaran gagal. Silakan coba lagi.'
|
||||
setError(message)
|
||||
onError?.(result)
|
||||
},
|
||||
onClose: () => {
|
||||
Logger.paymentInfo('checkout.auto.snap.popup.closed', { orderId })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
throw new Error('Snap.js not loaded')
|
||||
// Verify Snap.js is loaded
|
||||
console.log('window.snap:', window.snap)
|
||||
console.log('window.snap.pay:', window.snap?.pay)
|
||||
console.log('typeof window.snap?.pay:', typeof window.snap?.pay)
|
||||
|
||||
if (!window.snap || typeof window.snap.pay !== 'function') {
|
||||
const errorMsg = `Snap.js not properly loaded: hasSnap=${!!window.snap}, hasPay=${typeof window.snap?.pay}`
|
||||
console.error(errorMsg)
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
|
||||
// Auto-trigger Snap payment popup
|
||||
console.log('Memanggil window.snap.pay dengan token:', token.substring(0, 20) + '...')
|
||||
console.log('Full token:', token)
|
||||
setLoading(false) // Stop loading indicator before showing modal
|
||||
|
||||
window.snap.pay(token, {
|
||||
onSuccess: (result: any) => {
|
||||
Logger.paymentInfo('checkout.auto.snap.payment.success', { orderId, transactionId: result.transaction_id })
|
||||
onSuccess?.(result)
|
||||
},
|
||||
onPending: (result: any) => {
|
||||
Logger.paymentInfo('checkout.auto.snap.payment.pending', { orderId, transactionId: result.transaction_id })
|
||||
},
|
||||
onError: (result: any) => {
|
||||
Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: result })
|
||||
const message = 'Pembayaran gagal. Silakan coba lagi.'
|
||||
setError(message)
|
||||
setLoading(false)
|
||||
onError?.(result)
|
||||
},
|
||||
onClose: () => {
|
||||
Logger.paymentInfo('checkout.auto.snap.popup.closed', { orderId })
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: any) {
|
||||
Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: e.message })
|
||||
const message = 'Gagal memuat pembayaran. Silakan refresh halaman.'
|
||||
setError(message)
|
||||
Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: e.message, stack: e.stack })
|
||||
console.error('Error membuat token Snap:', e)
|
||||
|
||||
// Handle specific error: order_id already taken
|
||||
const errorMessage = e.response?.data?.message || e.message || ''
|
||||
const isOrderTaken = errorMessage.includes('already been taken') ||
|
||||
errorMessage.includes('order_id has already been taken')
|
||||
|
||||
if (isOrderTaken) {
|
||||
const message = 'Order ID sudah digunakan. Pembayaran untuk order ini sudah dibuat. Silakan cek halaman status pembayaran.'
|
||||
setError(message)
|
||||
} else {
|
||||
const message = e.response?.data?.message || e.message || 'Gagal memuat pembayaran. Silakan refresh halaman.'
|
||||
setError(message)
|
||||
}
|
||||
|
||||
onError?.(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to ensure UI is rendered
|
||||
console.log('Setting timeout to call triggerPayment in 500ms...')
|
||||
const timer = setTimeout(triggerPayment, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [orderId, amount, customer, onChargeInitiated, onSuccess, onError])
|
||||
return () => {
|
||||
console.log('Cleanup: clearing timeout')
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError])
|
||||
|
||||
// Don't render anything until we have valid data
|
||||
if (!orderId || !amount) {
|
||||
|
|
@ -111,7 +155,13 @@ function AutoSnapPayment({ orderId, amount, customer, onChargeInitiated, onSucce
|
|||
<div className="space-y-4">
|
||||
{error && (
|
||||
<Alert title="Pembayaran Gagal">
|
||||
{error}
|
||||
<div className="space-y-2">
|
||||
<p>{error}</p>
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer">Detail Error</summary>
|
||||
<pre className="mt-2 bg-gray-100 p-2 rounded overflow-auto">{JSON.stringify({ orderId, amount, customer }, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
|
@ -121,11 +171,17 @@ function AutoSnapPayment({ orderId, amount, customer, onChargeInitiated, onSucce
|
|||
<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>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">
|
||||
Membuka halaman pembayaran Midtrans...
|
||||
</p>
|
||||
)}
|
||||
) : 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>
|
||||
)
|
||||
|
|
@ -146,7 +202,6 @@ export function CheckoutPage() {
|
|||
const amount = 3500000
|
||||
const expireAt = Date.now() + 59 * 60 * 1000 + 32 * 1000 // 00:59:32
|
||||
const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
|
||||
const [locked, setLocked] = React.useState(false)
|
||||
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
|
||||
const [isBusy, setIsBusy] = React.useState(false)
|
||||
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
|
||||
|
|
@ -188,7 +243,7 @@ export function CheckoutPage() {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 2}>
|
||||
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} customerName={form.name} showStatusCTA={currentStep === 2}>
|
||||
{/* Wizard 2 langkah: Step 1 (Form Dummy) → Step 2 (Payment - Snap/Core auto-detect) */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -254,6 +309,11 @@ export function CheckoutPage() {
|
|||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
{(() => {
|
||||
console.log('Rendering step 2 - AutoSnapPayment', { orderId, amount, currentStep })
|
||||
Logger.info('checkout.step2.render', { orderId, amount })
|
||||
return null
|
||||
})()}
|
||||
<AutoSnapPayment
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
|
|
@ -262,7 +322,6 @@ export function CheckoutPage() {
|
|||
email: form.contact.includes('@') ? form.contact : undefined,
|
||||
phone: !form.contact.includes('@') ? form.contact : undefined
|
||||
}}
|
||||
onChargeInitiated={() => setLocked(true)}
|
||||
onSuccess={(result) => {
|
||||
Logger.info('checkout.payment.success', { orderId, result })
|
||||
// Handle successful payment
|
||||
|
|
@ -282,12 +341,3 @@ export function CheckoutPage() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
function defaultEnabled(): Record<PaymentMethod, boolean> {
|
||||
return {
|
||||
bank_transfer: Env.ENABLE_BANK_TRANSFER,
|
||||
credit_card: Env.ENABLE_CREDIT_CARD,
|
||||
gopay: Env.ENABLE_GOPAY,
|
||||
cstore: Env.ENABLE_CSTORE,
|
||||
cpay: Env.ENABLE_CPAY,
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,9 @@ 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
|
||||
|
|
@ -16,30 +19,37 @@ interface AutoSnapPaymentProps {
|
|||
orderId: string
|
||||
amount: number
|
||||
customer?: { name?: string; phone?: string; email?: string }
|
||||
onChargeInitiated?: () => void
|
||||
onSuccess?: (result: any) => void
|
||||
onError?: (error: any) => void
|
||||
}
|
||||
|
||||
function AutoSnapPayment({ orderId, amount, customer, onChargeInitiated, onSuccess, onError }: AutoSnapPaymentProps) {
|
||||
function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: AutoSnapPaymentProps) {
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState('')
|
||||
const hasTriggered = React.useRef(false)
|
||||
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(() => {
|
||||
// Only trigger when we have valid orderId and amount
|
||||
if (!orderId || !amount || hasTriggered.current) return
|
||||
hasTriggered.current = true
|
||||
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('')
|
||||
|
||||
console.log('[PayPage] Auto-triggering Snap payment:', { orderId, amount, customer })
|
||||
Logger.paymentInfo('paypage.auto.snap.init', { orderId, amount, customer })
|
||||
|
||||
// Import SnapTokenService dynamically to avoid circular deps
|
||||
const { SnapTokenService } = await import('../features/payments/snap/SnapTokenService')
|
||||
// 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({
|
||||
|
|
@ -60,46 +70,68 @@ function AutoSnapPayment({ orderId, amount, customer, onChargeInitiated, onSucce
|
|||
}]
|
||||
})
|
||||
|
||||
console.log('[PayPage] Snap token received:', token.substring(0, 10) + '...')
|
||||
Logger.paymentInfo('paypage.auto.snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
|
||||
console.log('[PayPage] Token received:', token)
|
||||
|
||||
// Auto-trigger Snap payment popup
|
||||
if (window.snap && typeof window.snap.pay === 'function') {
|
||||
window.snap.pay(token, {
|
||||
onSuccess: (result: any) => {
|
||||
console.log('[PayPage] Payment success:', result)
|
||||
onSuccess?.(result)
|
||||
},
|
||||
onPending: (result: any) => {
|
||||
console.log('[PayPage] Payment pending:', result)
|
||||
},
|
||||
onError: (result: any) => {
|
||||
console.error('[PayPage] Payment error:', result)
|
||||
const message = 'Pembayaran gagal. Silakan coba lagi.'
|
||||
setError(message)
|
||||
onError?.(result)
|
||||
},
|
||||
onClose: () => {
|
||||
console.log('[PayPage] Snap popup closed')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
throw new Error('Snap.js not loaded')
|
||||
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) {
|
||||
console.error('[PayPage] Auto-payment error:', e.message)
|
||||
const message = 'Gagal memuat pembayaran. Silakan refresh halaman.'
|
||||
setError(message)
|
||||
Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: e.message })
|
||||
console.error('[PayPage] Error:', e)
|
||||
|
||||
// Handle specific error: order_id already taken (payment already exists)
|
||||
const errorMessage = e.response?.data?.message || e.message || ''
|
||||
const isOrderTaken = errorMessage.includes('already been taken') ||
|
||||
errorMessage.includes('order_id has already been taken')
|
||||
|
||||
if (isOrderTaken) {
|
||||
// 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 message briefly then redirect
|
||||
setError('Pembayaran untuk order ini sudah dibuat. Mengalihkan ke halaman status...')
|
||||
setTimeout(() => {
|
||||
window.location.href = `/payments/${orderId}/status`
|
||||
}, 2000)
|
||||
} else {
|
||||
setError(e.response?.data?.message || e.message || 'Gagal memuat pembayaran')
|
||||
}
|
||||
|
||||
onError?.(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to ensure UI is rendered
|
||||
console.log('[PayPage] Setting timeout')
|
||||
const timer = setTimeout(triggerPayment, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [orderId, amount, customer, onChargeInitiated, onSuccess, onError])
|
||||
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError])
|
||||
|
||||
// Don't render anything until we have valid data
|
||||
if (!orderId || !amount) {
|
||||
|
|
@ -127,11 +159,17 @@ function AutoSnapPayment({ orderId, amount, customer, onChargeInitiated, onSucce
|
|||
<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>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">
|
||||
Membuka halaman pembayaran Midtrans...
|
||||
</p>
|
||||
)}
|
||||
) : 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>
|
||||
)
|
||||
|
|
@ -146,9 +184,8 @@ export function PayPage() {
|
|||
const [selectedMethod] = useState<Method>(null)
|
||||
const [locked, setLocked] = useState<boolean>(false)
|
||||
const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined)
|
||||
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
|
||||
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
||||
const { data: runtimeCfg } = usePaymentConfig()
|
||||
usePaymentConfig()
|
||||
const currentStep = 2
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -162,7 +199,6 @@ export function PayPage() {
|
|||
setAmount(payload.nominal)
|
||||
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
|
||||
setCustomer(payload.customer)
|
||||
setAllowedMethods(payload.allowed_methods)
|
||||
setError(null)
|
||||
if (isOrderLocked(payload.order_id)) setLocked(true)
|
||||
} catch {
|
||||
|
|
@ -237,12 +273,10 @@ export function PayPage() {
|
|||
orderId={orderId}
|
||||
amount={amount}
|
||||
customer={customer}
|
||||
onChargeInitiated={() => {
|
||||
lockOrder(orderId)
|
||||
setLocked(true)
|
||||
}}
|
||||
onSuccess={(result) => {
|
||||
console.log('[PayPage] Payment success:', result)
|
||||
lockOrder(orderId)
|
||||
setLocked(true)
|
||||
nav.toStatus(orderId, selectedMethod || undefined)
|
||||
}}
|
||||
onError={(error) => {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,15 @@ api.interceptors.response.use(
|
|||
const url = error.config?.url || ''
|
||||
const status = error.response?.status
|
||||
const fullUrl = `${baseURL}${url}`
|
||||
Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message })
|
||||
const responseData = error.response?.data
|
||||
Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message, responseData })
|
||||
console.error('API Error:', {
|
||||
fullUrl,
|
||||
status,
|
||||
message: error.message,
|
||||
responseData,
|
||||
config: error.config
|
||||
})
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
// Midtrans Snap.js type definitions
|
||||
interface SnapPaymentOptions {
|
||||
onSuccess?: (result: any) => void
|
||||
onPending?: (result: any) => void
|
||||
onError?: (result: any) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
interface Snap {
|
||||
pay: (token: string, options?: SnapPaymentOptions) => void
|
||||
hide: () => void
|
||||
show: () => void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
snap?: Snap
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
|
||||
async function createPaymentLink() {
|
||||
// Read file and remove BOM if present
|
||||
let jsonContent = fs.readFileSync('c:/laragon/www/core-midtrans-cifo/tmp-createtransaksi.json', 'utf8');
|
||||
// Remove BOM
|
||||
if (jsonContent.charCodeAt(0) === 0xFEFF) {
|
||||
jsonContent = jsonContent.slice(1);
|
||||
}
|
||||
|
||||
const payload = JSON.parse(jsonContent);
|
||||
|
||||
try {
|
||||
console.log('Creating payment link...');
|
||||
console.log('Payload:', JSON.stringify(payload, null, 2));
|
||||
|
||||
const response = await axios.post('http://localhost:8000/createtransaksi', payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': 'dev-key'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n✓ Success!');
|
||||
console.log('Response:', JSON.stringify(response.data, null, 2));
|
||||
console.log('\n🔗 Payment URL:', response.data.data.url);
|
||||
} catch (error) {
|
||||
console.log('✗ Error:', error.response?.status, error.response?.data);
|
||||
console.log('Full error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
createPaymentLink();
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
const axios = require('axios');
|
||||
|
||||
async function testFrontendPayload() {
|
||||
// Simulate the exact payload sent from CheckoutPage.tsx AutoSnapPayment
|
||||
const payload = {
|
||||
transaction_details: {
|
||||
order_id: 'order-1733280000000-12345', // example orderId
|
||||
gross_amount: 3500000
|
||||
},
|
||||
customer_details: {
|
||||
first_name: 'Demo User',
|
||||
email: 'demo@example.com',
|
||||
phone: undefined // as sent from frontend when contact is email
|
||||
},
|
||||
item_details: [{
|
||||
id: 'order-1733280000000-12345',
|
||||
name: 'Payment',
|
||||
price: 3500000,
|
||||
quantity: 1
|
||||
}]
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('Testing frontend-like payload...');
|
||||
console.log('Payload:', JSON.stringify(payload, null, 2));
|
||||
|
||||
const response = await axios.post('http://localhost:8000/api/payments/snap/token', payload);
|
||||
console.log('Success:', response.data);
|
||||
} catch (error) {
|
||||
console.log('Error:', error.response?.status, error.response?.data);
|
||||
console.log('Full error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
testFrontendPayload();
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
const axios = require('axios');
|
||||
|
||||
async function testSnapToken() {
|
||||
const payload = {
|
||||
transaction_details: {
|
||||
order_id: 'test-order-123',
|
||||
gross_amount: 100000
|
||||
},
|
||||
customer_details: {
|
||||
first_name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phone: '08123456789'
|
||||
},
|
||||
item_details: [{
|
||||
id: 'test-order-123',
|
||||
name: 'Test Payment',
|
||||
price: 100000,
|
||||
quantity: 1
|
||||
}]
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('Testing Snap token creation...');
|
||||
console.log('Payload:', JSON.stringify(payload, null, 2));
|
||||
|
||||
const response = await axios.post('http://localhost:8000/api/payments/snap/token', payload);
|
||||
console.log('Success:', response.data);
|
||||
} catch (error) {
|
||||
console.log('Error:', error.response?.status, error.response?.data);
|
||||
console.log('Full error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
testSnapToken();
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"mercant_id": "REFNO-001",
|
||||
"timestamp": 1731300000000,
|
||||
"mercant_id": "REFNO-002",
|
||||
"timestamp": 1733283600000,
|
||||
"deskripsi": "Bayar Internet",
|
||||
"nominal": 200000,
|
||||
"nama": "Demo User",
|
||||
"nama": "Demo User 2",
|
||||
"no_telepon": "081234567890",
|
||||
"email": "demo@example.com",
|
||||
"email": "demo2@example.com",
|
||||
"item": [
|
||||
{ "item_id": "TKG-2511131", "nama": "Internet", "harga": 200000, "qty": 1 }
|
||||
{ "item_id": "TKG-2512041", "nama": "Internet Desember", "harga": 200000, "qty": 1 }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue