epic-6-snap-hybrid-complete #15

Merged
root merged 6 commits from epic-6-snap-hybrid-complete into dev 2025-12-04 08:25:50 +00:00
11 changed files with 459 additions and 127 deletions
Showing only changes of commit c6225e3d35 - Show all commits

View File

@ -25,11 +25,12 @@ export interface PaymentSheetProps {
orderId: string orderId: string
amount: number amount: number
expireAt: number // epoch ms expireAt: number // epoch ms
customerName?: string
children?: React.ReactNode children?: React.ReactNode
showStatusCTA?: boolean 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 countdown = useCountdown(expireAt)
const [expanded, setExpanded] = React.useState(true) const [expanded, setExpanded] = React.useState(true)
return ( return (
@ -71,6 +72,7 @@ export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireA
<div className="text-xs text-black">Total</div> <div className="text-xs text-black">Total</div>
<div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div> <div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
<div className="text-xs text-black/60">Order ID #{orderId}</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>
</div> </div>
)} )}

View File

@ -201,18 +201,4 @@ function SnapHostedPayment({ orderId, amount, customer, onSuccess, onError }: Om
{loading && <LoadingOverlay isLoading={loading} />} {loading && <LoadingOverlay isLoading={loading} />}
</div> </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
}
}
} }

132
src/lib/snapLoader.ts Normal file
View File

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

View File

@ -1,11 +1,13 @@
import React from 'react'
import { Alert } from '../components/alert/Alert' import { Alert } from '../components/alert/Alert'
import { Button } from '../components/ui/button' import { Button } from '../components/ui/button'
import { Env } from '../lib/env' import { Env } from '../lib/env'
import { Logger } from '../lib/logger'
import { loadSnapScript } from '../lib/snapLoader'
import { PaymentSheet } from '../features/payments/components/PaymentSheet' 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 { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
import { SnapTokenService } from '../features/payments/snap/SnapTokenService'
interface AutoSnapPaymentProps { interface AutoSnapPaymentProps {
orderId: string orderId: string
amount: number amount: number
@ -15,27 +17,39 @@ interface AutoSnapPaymentProps {
onError?: (error: any) => void 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 [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('') 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(() => { React.useEffect(() => {
// Only trigger when we have valid orderId and amount console.log('AutoSnapPayment useEffect triggered', { orderId, amount, paymentTriggered })
if (!orderId || !amount || hasTriggered.current) return // Only trigger when we have valid orderId and amount and not already triggered
hasTriggered.current = true if (!orderId || !amount || paymentTriggered) {
console.log('AutoSnapPayment useEffect early return', { hasOrderId: !!orderId, hasAmount: !!amount, alreadyTriggered: paymentTriggered })
return
}
const triggerPayment = async () => { const triggerPayment = async () => {
console.log('triggerPayment function called!')
setPaymentTriggered(true) // Mark as triggered immediately
try { try {
setLoading(true) setLoading(true)
setError('') setError('')
Logger.paymentInfo('checkout.auto.snap.init', { orderId, amount, customer }) Logger.paymentInfo('checkout.auto.snap.init', { orderId, amount, customer })
// Import SnapTokenService dynamically to avoid circular deps // Load Snap.js first
const { SnapTokenService } = await import('../features/payments/snap/SnapTokenService') 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 // Create Snap transaction token
Logger.paymentInfo('checkout.auto.snap.calling_api', { orderId, amount })
const token = await SnapTokenService.createToken({ const token = await SnapTokenService.createToken({
transaction_details: { transaction_details: {
order_id: orderId, 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) + '...' }) Logger.paymentInfo('checkout.auto.snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
console.log('Token berhasil dibuat:', token)
// Auto-trigger Snap payment popup // Verify Snap.js is loaded
if (window.snap && typeof window.snap.pay === 'function') { console.log('window.snap:', window.snap)
window.snap.pay(token, { console.log('window.snap.pay:', window.snap?.pay)
onSuccess: (result: any) => { console.log('typeof window.snap?.pay:', typeof window.snap?.pay)
Logger.paymentInfo('checkout.auto.snap.payment.success', { orderId, transactionId: result.transaction_id })
onSuccess?.(result) if (!window.snap || typeof window.snap.pay !== 'function') {
}, const errorMsg = `Snap.js not properly loaded: hasSnap=${!!window.snap}, hasPay=${typeof window.snap?.pay}`
onPending: (result: any) => { console.error(errorMsg)
Logger.paymentInfo('checkout.auto.snap.payment.pending', { orderId, transactionId: result.transaction_id }) throw new Error(errorMsg)
},
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')
} }
// 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) { } catch (e: any) {
Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: e.message }) Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: e.message, stack: e.stack })
const message = 'Gagal memuat pembayaran. Silakan refresh halaman.' console.error('Error membuat token Snap:', e)
setError(message)
// 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) onError?.(e)
} finally {
setLoading(false) setLoading(false)
} }
} }
// Small delay to ensure UI is rendered // Small delay to ensure UI is rendered
console.log('Setting timeout to call triggerPayment in 500ms...')
const timer = setTimeout(triggerPayment, 500) const timer = setTimeout(triggerPayment, 500)
return () => clearTimeout(timer) return () => {
}, [orderId, amount, customer, onChargeInitiated, onSuccess, onError]) console.log('Cleanup: clearing timeout')
clearTimeout(timer)
}
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError])
// Don't render anything until we have valid data // Don't render anything until we have valid data
if (!orderId || !amount) { if (!orderId || !amount) {
@ -111,7 +155,13 @@ function AutoSnapPayment({ orderId, amount, customer, onChargeInitiated, onSucce
<div className="space-y-4"> <div className="space-y-4">
{error && ( {error && (
<Alert title="Pembayaran Gagal"> <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> </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> <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> <p className="text-sm text-gray-600">Menyiapkan pembayaran...</p>
</div> </div>
) : ( ) : error ? (
<p className="text-sm text-gray-600"> <div className="space-y-2">
Membuka halaman pembayaran Midtrans... <p className="text-sm text-red-600">Gagal memuat pembayaran</p>
</p> <button
)} onClick={() => window.location.reload()}
className="text-sm text-blue-600 underline"
>
Coba lagi
</button>
</div>
) : null}
</div> </div>
</div> </div>
) )
@ -146,7 +202,6 @@ export function CheckoutPage() {
const amount = 3500000 const amount = 3500000
const expireAt = Date.now() + 59 * 60 * 1000 + 32 * 1000 // 00:59:32 const expireAt = Date.now() + 59 * 60 * 1000 + 32 * 1000 // 00:59:32
const [selected, setSelected] = React.useState<PaymentMethod | null>(null) const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
const [locked, setLocked] = React.useState(false)
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1) const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
const [isBusy, setIsBusy] = React.useState(false) const [isBusy, setIsBusy] = React.useState(false)
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({ const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
@ -188,7 +243,7 @@ export function CheckoutPage() {
</Alert> </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) */} {/* Wizard 2 langkah: Step 1 (Form Dummy) → Step 2 (Payment - Snap/Core auto-detect) */}
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-3"> <div className="space-y-3">
@ -254,6 +309,11 @@ export function CheckoutPage() {
{currentStep === 2 && ( {currentStep === 2 && (
<div className="space-y-3" aria-live="polite"> <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 <AutoSnapPayment
orderId={orderId} orderId={orderId}
amount={amount} amount={amount}
@ -262,7 +322,6 @@ export function CheckoutPage() {
email: form.contact.includes('@') ? form.contact : undefined, email: form.contact.includes('@') ? form.contact : undefined,
phone: !form.contact.includes('@') ? form.contact : undefined phone: !form.contact.includes('@') ? form.contact : undefined
}} }}
onChargeInitiated={() => setLocked(true)}
onSuccess={(result) => { onSuccess={(result) => {
Logger.info('checkout.payment.success', { orderId, result }) Logger.info('checkout.payment.success', { orderId, result })
// Handle successful payment // Handle successful payment
@ -281,13 +340,4 @@ export function CheckoutPage() {
</div> </div>
</div> </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,
}
} }

View File

@ -8,6 +8,9 @@ import { Button } from '../components/ui/button'
import { getPaymentLinkPayload } from '../services/api' import { getPaymentLinkPayload } from '../services/api'
import { isOrderLocked, lockOrder } from '../features/payments/lib/chargeLock' import { isOrderLocked, lockOrder } from '../features/payments/lib/chargeLock'
import { usePaymentNavigation } from '../features/payments/lib/navigation' 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' import React from 'react'
type Method = PaymentMethod | null type Method = PaymentMethod | null
@ -16,30 +19,37 @@ interface AutoSnapPaymentProps {
orderId: string orderId: string
amount: number amount: number
customer?: { name?: string; phone?: string; email?: string } customer?: { name?: string; phone?: string; email?: string }
onChargeInitiated?: () => void
onSuccess?: (result: any) => void onSuccess?: (result: any) => void
onError?: (error: 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 [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('') 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(() => { React.useEffect(() => {
// Only trigger when we have valid orderId and amount console.log('[PayPage] useEffect triggered', { orderId, amount, paymentTriggered })
if (!orderId || !amount || hasTriggered.current) return if (!orderId || !amount || paymentTriggered) {
hasTriggered.current = true console.log('[PayPage] Early return', { hasOrderId: !!orderId, hasAmount: !!amount, alreadyTriggered: paymentTriggered })
return
}
const triggerPayment = async () => { const triggerPayment = async () => {
console.log('[PayPage] triggerPayment called')
setPaymentTriggered(true)
try { try {
setLoading(true) setLoading(true)
setError('') 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 // Load Snap.js first
const { SnapTokenService } = await import('../features/payments/snap/SnapTokenService') await loadSnapScript()
Logger.paymentInfo('paypage.auto.snap.script_loaded', { orderId, hasSnap: !!window.snap })
// Create Snap transaction token // Create Snap transaction token
const token = await SnapTokenService.createToken({ 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') {
if (window.snap && typeof window.snap.pay === 'function') { throw new Error(`Snap.js not loaded: hasSnap=${!!window.snap}`)
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')
} }
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) { } catch (e: any) {
console.error('[PayPage] Auto-payment error:', e.message) Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: e.message })
const message = 'Gagal memuat pembayaran. Silakan refresh halaman.' console.error('[PayPage] Error:', e)
setError(message)
// 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) onError?.(e)
} finally {
setLoading(false) setLoading(false)
} }
} }
// Small delay to ensure UI is rendered console.log('[PayPage] Setting timeout')
const timer = setTimeout(triggerPayment, 500) const timer = setTimeout(triggerPayment, 500)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [orderId, amount, customer, onChargeInitiated, onSuccess, onError]) }, [orderId, amount, customer, paymentTriggered, onSuccess, onError])
// Don't render anything until we have valid data // Don't render anything until we have valid data
if (!orderId || !amount) { 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> <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> <p className="text-sm text-gray-600">Menyiapkan pembayaran...</p>
</div> </div>
) : ( ) : error ? (
<p className="text-sm text-gray-600"> <div className="space-y-2">
Membuka halaman pembayaran Midtrans... <p className="text-sm text-red-600">Gagal memuat pembayaran</p>
</p> <button
)} onClick={() => window.location.reload()}
className="text-sm text-blue-600 underline"
>
Coba lagi
</button>
</div>
) : null}
</div> </div>
</div> </div>
) )
@ -146,9 +184,8 @@ export function PayPage() {
const [selectedMethod] = useState<Method>(null) const [selectedMethod] = useState<Method>(null)
const [locked, setLocked] = useState<boolean>(false) const [locked, setLocked] = useState<boolean>(false)
const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined) 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 [error, setError] = useState<{ code?: string; message?: string } | null>(null)
const { data: runtimeCfg } = usePaymentConfig() usePaymentConfig()
const currentStep = 2 const currentStep = 2
useEffect(() => { useEffect(() => {
@ -162,7 +199,6 @@ export function PayPage() {
setAmount(payload.nominal) setAmount(payload.nominal)
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000) setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
setCustomer(payload.customer) setCustomer(payload.customer)
setAllowedMethods(payload.allowed_methods)
setError(null) setError(null)
if (isOrderLocked(payload.order_id)) setLocked(true) if (isOrderLocked(payload.order_id)) setLocked(true)
} catch { } catch {
@ -237,12 +273,10 @@ export function PayPage() {
orderId={orderId} orderId={orderId}
amount={amount} amount={amount}
customer={customer} customer={customer}
onChargeInitiated={() => {
lockOrder(orderId)
setLocked(true)
}}
onSuccess={(result) => { onSuccess={(result) => {
console.log('[PayPage] Payment success:', result) console.log('[PayPage] Payment success:', result)
lockOrder(orderId)
setLocked(true)
nav.toStatus(orderId, selectedMethod || undefined) nav.toStatus(orderId, selectedMethod || undefined)
}} }}
onError={(error) => { onError={(error) => {

View File

@ -53,7 +53,15 @@ api.interceptors.response.use(
const url = error.config?.url || '' const url = error.config?.url || ''
const status = error.response?.status const status = error.response?.status
const fullUrl = `${baseURL}${url}` 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 throw error
} }
) )

17
src/types/snap.d.ts vendored Normal file
View File

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

View File

@ -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();

35
test-frontend-payload.cjs Normal file
View File

@ -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();

34
test-snap-token.cjs Normal file
View File

@ -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();

View File

@ -1,12 +1,12 @@
{ {
"mercant_id": "REFNO-001", "mercant_id": "REFNO-002",
"timestamp": 1731300000000, "timestamp": 1733283600000,
"deskripsi": "Bayar Internet", "deskripsi": "Bayar Internet",
"nominal": 200000, "nominal": 200000,
"nama": "Demo User", "nama": "Demo User 2",
"no_telepon": "081234567890", "no_telepon": "081234567890",
"email": "demo@example.com", "email": "demo2@example.com",
"item": [ "item": [
{ "item_id": "TKG-2511131", "nama": "Internet", "harga": 200000, "qty": 1 } { "item_id": "TKG-2512041", "nama": "Internet Desember", "harga": 200000, "qty": 1 }
] ]
} }