Midtrans-Middleware/src/features/payments/core/CardPanel.tsx

161 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 1319 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 34 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>
)
}