epic-6-snap-hybrid-complete #15
|
|
@ -9,6 +9,118 @@ import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
||||||
import { Logger } from '../lib/logger'
|
import { Logger } from '../lib/logger'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
const [error, setError] = React.useState('')
|
||||||
|
const hasTriggered = React.useRef(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (hasTriggered.current) return
|
||||||
|
hasTriggered.current = true
|
||||||
|
|
||||||
|
const triggerPayment = async () => {
|
||||||
|
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')
|
||||||
|
|
||||||
|
// Create Snap transaction token
|
||||||
|
const token = await SnapTokenService.createToken({
|
||||||
|
transaction_details: {
|
||||||
|
order_id: orderId,
|
||||||
|
gross_amount: amount
|
||||||
|
},
|
||||||
|
customer_details: customer ? {
|
||||||
|
first_name: customer.name,
|
||||||
|
email: customer.email,
|
||||||
|
phone: customer.phone
|
||||||
|
} : undefined,
|
||||||
|
item_details: [{
|
||||||
|
id: orderId,
|
||||||
|
name: 'Payment',
|
||||||
|
price: amount,
|
||||||
|
quantity: 1
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
Logger.paymentInfo('checkout.auto.snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: e.message })
|
||||||
|
const message = 'Gagal memuat pembayaran. Silakan refresh halaman.'
|
||||||
|
setError(message)
|
||||||
|
onError?.(e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure UI is rendered
|
||||||
|
const timer = setTimeout(triggerPayment, 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [orderId, amount, customer, onChargeInitiated, onSuccess, onError])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert title="Pembayaran Gagal">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
||||||
|
<p className="text-sm text-gray-600">Menyiapkan pembayaran...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Membuka halaman pembayaran Midtrans...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function CheckoutPage() {
|
export function CheckoutPage() {
|
||||||
const apiBase = Env.API_BASE_URL
|
const apiBase = Env.API_BASE_URL
|
||||||
const clientKey = Env.MIDTRANS_CLIENT_KEY
|
const clientKey = Env.MIDTRANS_CLIENT_KEY
|
||||||
|
|
@ -132,7 +244,7 @@ export function CheckoutPage() {
|
||||||
|
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="space-y-3" aria-live="polite">
|
<div className="space-y-3" aria-live="polite">
|
||||||
<SnapPaymentTrigger
|
<AutoSnapPayment
|
||||||
orderId={orderId}
|
orderId={orderId}
|
||||||
amount={amount}
|
amount={amount}
|
||||||
customer={{
|
customer={{
|
||||||
|
|
@ -140,7 +252,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
|
||||||
}}
|
}}
|
||||||
paymentMethod={'bank_transfer'}
|
|
||||||
onChargeInitiated={() => setLocked(true)}
|
onChargeInitiated={() => setLocked(true)}
|
||||||
onSuccess={(result) => {
|
onSuccess={(result) => {
|
||||||
Logger.info('checkout.payment.success', { orderId, result })
|
Logger.info('checkout.payment.success', { orderId, result })
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,128 @@ import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
||||||
import type { PaymentMethod } 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 { Alert } from '../components/alert/Alert'
|
import { Alert } from '../components/alert/Alert'
|
||||||
import { Button } from '../components/ui/button'
|
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 React from 'react'
|
||||||
|
|
||||||
type Method = PaymentMethod | null
|
type Method = PaymentMethod | null
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
const [error, setError] = React.useState('')
|
||||||
|
const hasTriggered = React.useRef(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (hasTriggered.current) return
|
||||||
|
hasTriggered.current = true
|
||||||
|
|
||||||
|
const triggerPayment = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
console.log('[PayPage] Auto-triggering Snap payment:', { orderId, amount, customer })
|
||||||
|
|
||||||
|
// Import SnapTokenService dynamically to avoid circular deps
|
||||||
|
const { SnapTokenService } = await import('../features/payments/snap/SnapTokenService')
|
||||||
|
|
||||||
|
// Create Snap transaction token
|
||||||
|
const token = await SnapTokenService.createToken({
|
||||||
|
transaction_details: {
|
||||||
|
order_id: orderId,
|
||||||
|
gross_amount: amount
|
||||||
|
},
|
||||||
|
customer_details: customer ? {
|
||||||
|
first_name: customer.name,
|
||||||
|
email: customer.email,
|
||||||
|
phone: customer.phone
|
||||||
|
} : undefined,
|
||||||
|
item_details: [{
|
||||||
|
id: orderId,
|
||||||
|
name: 'Payment',
|
||||||
|
price: amount,
|
||||||
|
quantity: 1
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[PayPage] Snap token received:', token.substring(0, 10) + '...')
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[PayPage] Auto-payment error:', e.message)
|
||||||
|
const message = 'Gagal memuat pembayaran. Silakan refresh halaman.'
|
||||||
|
setError(message)
|
||||||
|
onError?.(e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure UI is rendered
|
||||||
|
const timer = setTimeout(triggerPayment, 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [orderId, amount, customer, onChargeInitiated, onSuccess, onError])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert title="Pembayaran Gagal">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
||||||
|
<p className="text-sm text-gray-600">Menyiapkan pembayaran...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Membuka halaman pembayaran Midtrans...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function PayPage() {
|
export function PayPage() {
|
||||||
const { token } = useParams()
|
const { token } = useParams()
|
||||||
const nav = usePaymentNavigation()
|
const nav = usePaymentNavigation()
|
||||||
|
|
@ -108,11 +220,10 @@ export function PayPage() {
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="space-y-3" aria-live="polite">
|
<div className="space-y-3" aria-live="polite">
|
||||||
<SnapPaymentTrigger
|
<AutoSnapPayment
|
||||||
orderId={orderId}
|
orderId={orderId}
|
||||||
amount={amount}
|
amount={amount}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
paymentMethod={selectedMethod || 'bank_transfer'}
|
|
||||||
onChargeInitiated={() => {
|
onChargeInitiated={() => {
|
||||||
lockOrder(orderId)
|
lockOrder(orderId)
|
||||||
setLocked(true)
|
setLocked(true)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue