epic-6-snap-hybrid-complete #15
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -202,17 +202,3 @@ function SnapHostedPayment({ orderId, amount, customer, onSuccess, onError }: Om
|
||||||
</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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 { 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
|
||||||
|
|
@ -282,12 +341,3 @@ export function CheckoutPage() {
|
||||||
</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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue