161 lines
7.6 KiB
TypeScript
161 lines
7.6 KiB
TypeScript
import { Button } from '../../../components/ui/button'
|
||
import { usePaymentNavigation } from '../lib/navigation'
|
||
import React from 'react'
|
||
import { PaymentInstructions } from '../components/PaymentInstructions'
|
||
import { CardLogosRow } from '../components/PaymentLogos'
|
||
import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds'
|
||
import { Logger } from '../../../lib/logger'
|
||
import { Env } from '../../../lib/env'
|
||
import { postCharge } from '../../../services/api'
|
||
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
||
import { toast } from '../../../components/ui/toast'
|
||
|
||
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
||
const nav = usePaymentNavigation()
|
||
const [saveCard, setSaveCard] = React.useState(false)
|
||
const [showGuide, setShowGuide] = React.useState(false)
|
||
const [busy, setBusy] = React.useState(false)
|
||
const [charged, setCharged] = React.useState(false)
|
||
const [cardNumber, setCardNumber] = React.useState('')
|
||
const [exp, setExp] = React.useState('')
|
||
const [cvv, setCvv] = React.useState('')
|
||
function formatCard(value: string) {
|
||
const digits = value.replace(/\D/g, '')
|
||
const groups = digits.match(/.{1,4}/g)
|
||
return groups ? groups.join('-') : digits
|
||
}
|
||
function formatExp(value: string) {
|
||
let digits = value.replace(/\D/g, '')
|
||
if (digits.length > 4) digits = digits.slice(0, 4)
|
||
if (digits.length >= 3) return `${digits.slice(0,2)}/${digits.slice(2)}`
|
||
if (digits.length >= 1) return digits
|
||
return ''
|
||
}
|
||
function formatCvv(value: string) {
|
||
return value.replace(/\D/g, '').slice(0, 4)
|
||
}
|
||
React.useEffect(() => { ensureMidtrans3ds().catch(() => {}) }, [])
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="font-medium">Kartu kredit/debit</div>
|
||
<CardLogosRow compact />
|
||
<div className="space-y-2">
|
||
<label className="block">
|
||
<div className="text-xs text-gray-600">Nomor kartu</div>
|
||
<input type="text" inputMode="numeric" maxLength={23} placeholder="0000-0000-0000-0000" className="w-full rounded border px-3 py-2" value={cardNumber} onChange={(e) => setCardNumber(formatCard(e.target.value))} />
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<label className="block">
|
||
<div className="text-xs text-gray-600">Masa berlaku</div>
|
||
<input type="text" inputMode="numeric" maxLength={5} placeholder="MM/YY" className="w-full rounded border px-3 py-2" value={exp} onChange={(e) => setExp(formatExp(e.target.value))} />
|
||
</label>
|
||
<label className="block">
|
||
<div className="text-xs text-gray-600">CVV</div>
|
||
<input type="password" inputMode="numeric" maxLength={4} placeholder="123" className="w-full rounded border px-3 py-2" value={cvv} onChange={(e) => setCvv(formatCvv(e.target.value))} />
|
||
</label>
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input type="checkbox" checked={saveCard} onChange={(e) => setSaveCard(e.target.checked)} />
|
||
Simpan kartu ini
|
||
</label>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowGuide((v) => !v)}
|
||
className="text-xs text-brand-600 hover:underline"
|
||
aria-expanded={showGuide}
|
||
>
|
||
Cara bayar
|
||
</button>
|
||
{showGuide && <PaymentInstructions method="credit_card" />}
|
||
{locked && (
|
||
<div className="text-xs text-gray-600">Metode terkunci. Lanjutkan verifikasi 3DS/OTP pada kartu Anda.</div>
|
||
)}
|
||
<div className="pt-2 space-y-2">
|
||
<Button
|
||
className="w-full"
|
||
aria-busy={busy}
|
||
disabled={busy}
|
||
onClick={async () => {
|
||
try {
|
||
setBusy(true)
|
||
onChargeInitiated?.()
|
||
Logger.info('card.pay.click', { order_id: orderId, amount })
|
||
// Basic parse for MM/YY
|
||
const [mmRaw, yyRaw] = exp.split('/')
|
||
const mm = (mmRaw || '').trim()
|
||
const yy = (yyRaw || '').trim()
|
||
const sanitizedCard = cardNumber.replace(/[^0-9]/g, '')
|
||
// Minimal validation
|
||
if (sanitizedCard.length < 13 || sanitizedCard.length > 19) {
|
||
Logger.warn('card.input.invalid', { reason: 'card_number_length', length: sanitizedCard.length })
|
||
toast.error('Nomor kartu tidak valid. Harus 13–19 digit.')
|
||
return
|
||
}
|
||
const mmNum = Number(mm)
|
||
if (!mm || mm.length !== 2 || mmNum < 1 || mmNum > 12) {
|
||
Logger.warn('card.input.invalid', { reason: 'exp_month', value: mm })
|
||
toast.error('Format bulan kedaluwarsa tidak valid (MM).')
|
||
return
|
||
}
|
||
if (!yy || yy.length < 2) {
|
||
Logger.warn('card.input.invalid', { reason: 'exp_year', value: yy })
|
||
toast.error('Format tahun kedaluwarsa tidak valid (YY).')
|
||
return
|
||
}
|
||
const cvvSan = cvv.replace(/[^0-9]/g, '')
|
||
if (cvvSan.length < 3 || cvvSan.length > 4) {
|
||
Logger.warn('card.input.invalid', { reason: 'cvv_length', length: cvvSan.length })
|
||
toast.error('CVV harus 3–4 digit.')
|
||
return
|
||
}
|
||
if (Env.LOG_LEVEL === 'debug') {
|
||
Logger.debug('card.input.sanitized', Logger.mask({ card_number: sanitizedCard, card_cvv: cvvSan, card_exp_month: mm, card_exp_year: yy }))
|
||
}
|
||
const tokenId = await getCardToken({ card_number: sanitizedCard, card_exp_month: mm, card_exp_year: yy, card_cvv: cvv })
|
||
Logger.info('card.token.success', Logger.mask({ token_id: tokenId }))
|
||
const payload = {
|
||
payment_type: 'credit_card',
|
||
transaction_details: { order_id: orderId, gross_amount: amount },
|
||
credit_card: { token_id: tokenId, authentication: true, save_token_id: saveCard },
|
||
}
|
||
Logger.info('card.charge.start', { order_id: orderId })
|
||
const res = await postCharge(payload)
|
||
Logger.info('card.charge.done', { status_code: res?.status_code, transaction_status: res?.transaction_status })
|
||
if (res?.redirect_url) {
|
||
Logger.info('card.3ds.redirect')
|
||
authenticate3ds(res.redirect_url)
|
||
}
|
||
if (res?.transaction_status === 'capture' || res?.transaction_status === 'settlement') {
|
||
setCharged(true)
|
||
Logger.info('card.charge.success', { transaction_status: res?.transaction_status })
|
||
}
|
||
} catch (e) {
|
||
Logger.error('card.process.error', { message: (e as Error)?.message })
|
||
toast.error(`Gagal memproses kartu: ${(e as Error).message}`)
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
{busy ? (
|
||
<span className="inline-flex items-center justify-center gap-2">
|
||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
|
||
Memproses pembayaran…
|
||
</span>
|
||
) : 'Bayar sekarang'}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="w-full"
|
||
disabled={busy || (!charged && !locked)}
|
||
onClick={() => nav.toStatus(orderId, 'credit_card')}
|
||
>
|
||
Buka halaman status
|
||
</Button>
|
||
</div>
|
||
{/* Status inline dengan polling otomatis */}
|
||
<InlinePaymentStatus orderId={orderId} method="credit_card" compact />
|
||
</div>
|
||
)
|
||
} |