feat/payments-ux-instructions-va #1
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -4,9 +4,10 @@ import React from 'react'
|
||||||
import { PaymentInstructions } from './PaymentInstructions'
|
import { PaymentInstructions } from './PaymentInstructions'
|
||||||
import { BcaInstructionList } from './BcaInstructionList'
|
import { BcaInstructionList } from './BcaInstructionList'
|
||||||
import { TrustStrip } from './TrustStrip'
|
import { TrustStrip } from './TrustStrip'
|
||||||
import { BankLogosRow, BankLogo, type BankKey } from './PaymentLogos'
|
import { type BankKey } from './PaymentLogos'
|
||||||
import { postCharge } from '../../../services/api'
|
import { postCharge } from '../../../services/api'
|
||||||
import { Alert } from '../../../components/alert/Alert'
|
import { Alert } from '../../../components/alert/Alert'
|
||||||
|
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||||
|
|
||||||
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts
|
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts
|
||||||
const attemptedChargeKeys = new Set<string>()
|
const attemptedChargeKeys = new Set<string>()
|
||||||
|
|
@ -137,11 +138,9 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="font-medium">Transfer bank</div>
|
<div className="font-medium">Transfer bank</div>
|
||||||
<BankLogosRow compact />
|
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="flex items-center gap-2 text-base">
|
<div className="flex items-center gap-2 text-base">
|
||||||
<span className="text-black/60 dark:text-white/60">Bank:</span>
|
<span className="text-black/60 dark:text-white/60">Bank:</span>
|
||||||
<BankLogo bank={selected} compact />
|
|
||||||
<span className="text-black/80 dark:text-white/80 font-semibold">{selected.toUpperCase()}</span>
|
<span className="text-black/80 dark:text-white/80 font-semibold">{selected.toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -155,7 +154,10 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
<div className="text-sm font-medium mb-2">Virtual Account</div>
|
<div className="text-sm font-medium mb-2">Virtual Account</div>
|
||||||
<div className="text-sm text-black/70 dark:text-white/70">
|
<div className="text-sm text-black/70 dark:text-white/70">
|
||||||
{vaCode ? (
|
{vaCode ? (
|
||||||
<span>Nomor VA: <span className="font-mono text-2xl md:text-3xl font-semibold tracking-wider text-black dark:text-white">{vaCode}</span></span>
|
<span>
|
||||||
|
Nomor VA:
|
||||||
|
<span className="block break-all mt-1 font-mono text-xl sm:text-2xl md:text-3xl font-semibold tracking-normal text-black dark:text-white">{vaCode}</span>
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-2" role="status" aria-live="polite">
|
<span className="inline-flex items-center gap-2" role="status" aria-live="polite">
|
||||||
{busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />}
|
{busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />}
|
||||||
|
|
@ -176,6 +178,10 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Status inline dengan polling otomatis */}
|
||||||
|
{selected && (
|
||||||
|
<InlinePaymentStatus orderId={orderId} method="bank_transfer" compact />
|
||||||
|
)}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
{selected === 'bca' ? (
|
{selected === 'bca' ? (
|
||||||
|
|
@ -281,12 +287,12 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={busy || (!locked && !vaCode && !billKey)}
|
disabled={busy || (!locked && !vaCode && !billKey)}
|
||||||
onClick={() => nav.toStatus(orderId, 'bank_transfer')}
|
onClick={() => nav.toStatus(orderId, 'bank_transfer')}
|
||||||
>
|
>
|
||||||
Cek Status Pembayaran
|
Buka halaman status
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TrustStrip />
|
<TrustStrip />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import React from 'react'
|
||||||
import { PaymentInstructions } from './PaymentInstructions'
|
import { PaymentInstructions } from './PaymentInstructions'
|
||||||
import { TrustStrip } from './TrustStrip'
|
import { TrustStrip } from './TrustStrip'
|
||||||
import { postCharge } from '../../../services/api'
|
import { postCharge } from '../../../services/api'
|
||||||
|
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||||
|
|
||||||
type StoreKey = 'alfamart' | 'indomaret'
|
type StoreKey = 'alfamart' | 'indomaret'
|
||||||
|
|
||||||
|
|
@ -115,13 +116,14 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
|
||||||
<div className="mt-2"><Button variant="outline" className="w-full sm:w-auto" onClick={() => copy(paymentCode, 'Kode pembayaran')} disabled={!paymentCode || busy}>Copy Kode</Button></div>
|
<div className="mt-2"><Button variant="outline" className="w-full sm:w-auto" onClick={() => copy(paymentCode, 'Kode pembayaran')} disabled={!paymentCode || busy}>Copy Kode</Button></div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={busy || (!locked && !paymentCode)}
|
disabled={busy || (!locked && !paymentCode)}
|
||||||
onClick={() => nav.toStatus(orderId, 'cstore')}
|
onClick={() => nav.toStatus(orderId, 'cstore')}
|
||||||
>
|
>
|
||||||
Cek Status Pembayaran
|
Buka halaman status
|
||||||
</Button>
|
</Button>
|
||||||
|
<InlinePaymentStatus orderId={orderId} method="cstore" />
|
||||||
</div>
|
</div>
|
||||||
<TrustStrip />
|
<TrustStrip />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtran
|
||||||
import { Logger } from '../../../lib/logger'
|
import { Logger } from '../../../lib/logger'
|
||||||
import { Env } from '../../../lib/env'
|
import { Env } from '../../../lib/env'
|
||||||
import { postCharge } from '../../../services/api'
|
import { postCharge } from '../../../services/api'
|
||||||
|
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||||
|
|
||||||
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
||||||
const nav = usePaymentNavigation()
|
const nav = usePaymentNavigation()
|
||||||
|
|
@ -145,14 +146,16 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
|
||||||
) : 'Bayar sekarang'}
|
) : 'Bayar sekarang'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={busy || (!charged && !locked)}
|
disabled={busy || (!charged && !locked)}
|
||||||
onClick={() => nav.toStatus(orderId, 'credit_card')}
|
onClick={() => nav.toStatus(orderId, 'credit_card')}
|
||||||
>
|
>
|
||||||
Cek Status Pembayaran
|
Buka halaman status
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Status inline dengan polling otomatis */}
|
||||||
|
<InlinePaymentStatus orderId={orderId} method="credit_card" compact />
|
||||||
<TrustStrip />
|
<TrustStrip />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { PaymentInstructions } from './PaymentInstructions'
|
||||||
import { TrustStrip } from './TrustStrip'
|
import { TrustStrip } from './TrustStrip'
|
||||||
import { GoPayLogosRow } from './PaymentLogos'
|
import { GoPayLogosRow } from './PaymentLogos'
|
||||||
import { postCharge } from '../../../services/api'
|
import { postCharge } from '../../../services/api'
|
||||||
|
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||||
|
|
||||||
// Global guards/tasks to stabilize QR generation across StrictMode remounts
|
// Global guards/tasks to stabilize QR generation across StrictMode remounts
|
||||||
const attemptedChargeKeys = new Set<string>()
|
const attemptedChargeKeys = new Set<string>()
|
||||||
|
|
@ -135,20 +136,23 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
|
||||||
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div>
|
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div>
|
||||||
)}
|
)}
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Button
|
<InlinePaymentStatus orderId={orderId} method={mode} />
|
||||||
variant="secondary"
|
<div className="mt-2">
|
||||||
className="w-full"
|
<Button
|
||||||
aria-busy={busy}
|
variant="outline"
|
||||||
disabled={busy || (!locked && actions.length === 0)}
|
className="w-full"
|
||||||
onClick={() => { setBusy(true); onChargeInitiated?.(); setTimeout(() => { nav.toStatus(orderId, mode) ; setBusy(false) }, 250) }}
|
aria-busy={busy}
|
||||||
>
|
disabled={busy || (!locked && actions.length === 0)}
|
||||||
{busy ? (
|
onClick={() => { setBusy(true); onChargeInitiated?.(); setTimeout(() => { nav.toStatus(orderId, mode) ; setBusy(false) }, 250) }}
|
||||||
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
|
>
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden />
|
{busy ? (
|
||||||
Menuju status…
|
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
|
||||||
</span>
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden />
|
||||||
) : 'Cek Status Pembayaran'}
|
Menuju status…
|
||||||
</Button>
|
</span>
|
||||||
|
) : 'Buka halaman status'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TrustStrip />
|
<TrustStrip />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Button } from '../../../components/ui/button'
|
||||||
|
import { usePaymentNavigation } from '../lib/navigation'
|
||||||
|
import { usePaymentStatus } from '../lib/usePaymentStatus'
|
||||||
|
import type { PaymentStatusResponse } from '../lib/midtrans'
|
||||||
|
|
||||||
|
function formatIDR(amount?: string) {
|
||||||
|
if (!amount) return ''
|
||||||
|
const n = Number(amount)
|
||||||
|
if (Number.isNaN(n)) return amount
|
||||||
|
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(Math.round(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlinePaymentStatus({ orderId, method, compact }: { orderId: string; method?: string; compact?: boolean }) {
|
||||||
|
const nav = usePaymentNavigation()
|
||||||
|
const { data, isLoading, error, refetch, isRefetching } = usePaymentStatus(orderId)
|
||||||
|
const status = (data?.status ?? 'pending') as PaymentStatusResponse['status']
|
||||||
|
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(status)
|
||||||
|
const isSuccess = status === 'settlement' || status === 'capture'
|
||||||
|
const isFailure = ['deny', 'cancel', 'expire', 'refund', 'chargeback'].includes(status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded border ${compact ? 'p-2' : 'p-3'} border-black/10 dark:border-white/10 bg-white dark:bg-black/20`} aria-live="polite">
|
||||||
|
{/* Header minimal tanpa detail teknis */}
|
||||||
|
<div className="text-sm font-medium">Status pembayaran</div>
|
||||||
|
|
||||||
|
{/* Konten berdasarkan status */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<span className="inline-flex items-center gap-2" role="status">
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden />
|
||||||
|
Mengecek pembayaran…
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 text-[11px] text-black/60 dark:text-white/60">Kami memeriksa otomatis setiap 3 detik.</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="mt-2 text-sm text-brand-600">Gagal memuat status. Coba refresh.</div>
|
||||||
|
) : isSuccess ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-green-500/15 text-green-600">
|
||||||
|
{/* check icon */}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="animate-[pop_200ms_ease-out]">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="text-base font-semibold">Pembayaran berhasil</div>
|
||||||
|
</div>
|
||||||
|
{data?.grossAmount ? (
|
||||||
|
<div className="mt-1 text-sm text-black/70 dark:text-white/70">Total dibayar: {formatIDR(data.grossAmount)}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Terima kasih! Pesanan Anda sedang diproses.</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button className="w-full sm:w-auto" onClick={() => nav.toHistory()}>Lihat riwayat pembayaran</Button>
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toCheckout()}>Kembali ke checkout</Button>
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toStatus(orderId, method)}>Lihat detail status</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isFailure ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-red-500/15 text-red-600">
|
||||||
|
{/* x icon */}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="text-base font-semibold">Pembayaran belum berhasil</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Silakan coba lagi atau pilih metode lain.</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button className="w-full sm:w-auto" onClick={() => nav.toCheckout()}>Coba lagi</Button>
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toStatus(orderId, method)}>Lihat detail status</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500/15 text-yellow-700 dark:text-yellow-300">
|
||||||
|
{/* hourglass/spinner icon */}
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-yellow-600/50 dark:border-yellow-300/80 border-t-transparent" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="text-base font-semibold">Menunggu pembayaran</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-black/60 dark:text-white/60">Kami memeriksa otomatis setiap 3 detik sampai selesai.</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto" onClick={() => refetch()} aria-busy={isRefetching} disabled={isRefetching}>
|
||||||
|
{isRefetching ? (
|
||||||
|
<span className="inline-flex items-center gap-2" role="status" aria-live="polite">
|
||||||
|
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden />
|
||||||
|
Memuat…
|
||||||
|
</span>
|
||||||
|
) : 'Refresh sekarang'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toStatus(orderId, method)}>Buka halaman status</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,13 +6,13 @@ function toProxy(url: string) {
|
||||||
return `https://images.weserv.nl/?url=${encodeURIComponent(url)}`
|
return `https://images.weserv.nl/?url=${encodeURIComponent(url)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrandImg({ src, alt, compact = false, fallbackSrc }: { src: string; alt: string; compact?: boolean; fallbackSrc?: string }) {
|
function BrandImg({ src, alt, compact = false, size, fallbackSrc }: { src: string; alt: string; compact?: boolean; size?: 'xs' | 'sm' | 'md'; fallbackSrc?: string }) {
|
||||||
const sizeClass = compact ? 'h-5' : 'h-6'
|
const sizeClass = size ? (size === 'xs' ? 'h-4' : size === 'sm' ? 'h-5' : 'h-6') : (compact ? 'h-5' : 'h-6')
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className={`${sizeClass} inline-block object-contain`}
|
className={`${sizeClass} inline-block object-contain max-w-full`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|
@ -45,23 +45,23 @@ const BANK_LOGOS: Record<BankKey, { alt: string; src: string; fb: string }> = {
|
||||||
permata: { alt: 'PermataBank', src: '/logos/permata Bank.png', fb: 'https://upload.wikimedia.org/wikipedia/commons/4/42/PermataBank_logo.svg' },
|
permata: { alt: 'PermataBank', src: '/logos/permata Bank.png', fb: 'https://upload.wikimedia.org/wikipedia/commons/4/42/PermataBank_logo.svg' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BankLogo({ bank, compact = false }: { bank: BankKey; compact?: boolean }) {
|
export function BankLogo({ bank, compact = false, size }: { bank: BankKey; compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
const cfg = BANK_LOGOS[bank]
|
const cfg = BANK_LOGOS[bank]
|
||||||
return <BrandImg src={cfg.src} alt={cfg.alt} compact={compact} fallbackSrc={cfg.fb} />
|
return <BrandImg src={cfg.src} alt={cfg.alt} compact={compact} size={size} fallbackSrc={cfg.fb} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BankLogosRow({ compact = false }: { compact?: boolean }) {
|
export function BankLogosRow({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
const all: BankKey[] = ['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata']
|
const all: BankKey[] = ['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata']
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{all.map((k) => (
|
{all.map((k) => (
|
||||||
<BankLogo key={k} bank={k} compact={compact} />
|
<BankLogo key={k} bank={k} compact={compact} size={size} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardLogosRow({ compact = false }: { compact?: boolean }) {
|
export function CardLogosRow({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
const logos = [
|
const logos = [
|
||||||
{ alt: 'Visa', src: '/logos/visa.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/0/04/Visa.svg' },
|
{ alt: 'Visa', src: '/logos/visa.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/0/04/Visa.svg' },
|
||||||
{ alt: 'Mastercard', src: '/logos/mastercard.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/2/2a/Mastercard-logo.svg' },
|
{ alt: 'Mastercard', src: '/logos/mastercard.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/2/2a/Mastercard-logo.svg' },
|
||||||
|
|
@ -71,25 +71,48 @@ export function CardLogosRow({ compact = false }: { compact?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{logos.map((l) => (
|
{logos.map((l) => (
|
||||||
<BrandImg key={l.alt} src={l.src} alt={l.alt} compact={compact} fallbackSrc={l.fb} />
|
<BrandImg key={l.alt} src={l.src} alt={l.alt} compact={compact} size={size} fallbackSrc={l.fb} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogoGoPay({ compact = false }: { compact?: boolean }) {
|
export function LogoGoPay({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
return <BrandImg src="/logos/gopay.svg" fallbackSrc="https://upload.wikimedia.org/wikipedia/commons/1/1e/Logo_GoPay.svg" alt="GoPay" compact={compact} />
|
// Gunakan file lokal yang tersedia di public/logos/
|
||||||
|
return <BrandImg src="/logos/Gopay_logo.svg" fallbackSrc="https://upload.wikimedia.org/wikipedia/commons/1/1e/Logo_GoPay.svg" alt="GoPay" compact={compact} size={size} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogoQRIS({ compact = false }: { compact?: boolean }) {
|
export function LogoQRIS({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
return <BrandImg src="/logos/qris.svg" fallbackSrc="https://upload.wikimedia.org/wikipedia/commons/0/0a/QRIS_Logo.svg" alt="QRIS" compact={compact} />
|
// Gunakan file lokal yang tersedia di public/logos/
|
||||||
|
return <BrandImg src="/logos/Logo_QRIS.svg" fallbackSrc="https://upload.wikimedia.org/wikipedia/commons/0/0a/QRIS_Logo.svg" alt="QRIS" compact={compact} size={size} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GoPayLogosRow({ compact = false }: { compact?: boolean }) {
|
export function LogoAlfamart({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
|
return <BrandImg src="/logos/ALFAMART_LOGO_BARU.png" alt="Alfamart" compact={compact} size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogoIndomaret({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
|
return <BrandImg src="/logos/Logo_Indomaret.png" alt="Indomaret" compact={compact} size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogoCpay({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
|
// Sumber lokal sesuai permintaan: public/logos/Cifo_cpay.png
|
||||||
|
return <BrandImg src="/logos/Cifo_cpay.png" alt="cPay" compact={compact} size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CStoreLogosRow({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<LogoGoPay compact={compact} />
|
<LogoAlfamart compact={compact} size={size} />
|
||||||
<LogoQRIS compact={compact} />
|
<LogoIndomaret compact={compact} size={size} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export function GoPayLogosRow({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LogoGoPay compact={compact} size={size} />
|
||||||
|
<LogoQRIS compact={compact} size={size} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BankLogosRow, CardLogosRow, GoPayLogosRow } from './PaymentLogos'
|
import { CardLogosRow, GoPayLogosRow, CStoreLogosRow, LogoCpay } from './PaymentLogos'
|
||||||
import { Env } from '../../../lib/env'
|
import { Env } from '../../../lib/env'
|
||||||
|
|
||||||
export type PaymentMethod = 'bank_transfer' | 'credit_card' | 'gopay' | 'cstore'
|
export type PaymentMethod = 'bank_transfer' | 'credit_card' | 'gopay' | 'cstore' | 'cpay'
|
||||||
|
|
||||||
export interface PaymentMethodListProps {
|
export interface PaymentMethodListProps {
|
||||||
selected?: PaymentMethod
|
selected?: PaymentMethod
|
||||||
|
|
@ -13,18 +13,20 @@ export interface PaymentMethodListProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseItems: Array<{ key: PaymentMethod; title: string; subtitle: string; icon?: React.ReactNode }> = [
|
const baseItems: Array<{ key: PaymentMethod; title: string; subtitle: string; icon?: React.ReactNode }> = [
|
||||||
{ key: 'bank_transfer', title: 'Transfer bank', subtitle: 'BCA • BNI • BRI • CIMB • Mandiri • Permata', icon: <BankLogosRow compact /> },
|
{ key: 'bank_transfer', title: 'Transfer bank', subtitle: 'BCA • BNI • BRI • CIMB • Mandiri • Permata', icon: <img src="/logos/logo-semua-bank.PNG" alt="Semua bank yang didukung" className="h-6 sm:h-8 object-contain" /> },
|
||||||
{ key: 'credit_card', title: 'Kartu kredit/debit', subtitle: 'Visa • MasterCard • JCB • Amex', icon: <CardLogosRow compact /> },
|
{ key: 'credit_card', title: 'Kartu kredit/debit', subtitle: 'Visa • MasterCard • JCB • Amex', icon: <CardLogosRow compact size="xs" /> },
|
||||||
{ key: 'gopay', title: 'Gopay/QRIS', subtitle: 'Scan & bayar via QR', icon: <GoPayLogosRow compact /> },
|
{ key: 'gopay', title: 'Gopay/QRIS', subtitle: 'Scan & bayar via QR', icon: <GoPayLogosRow compact size="xs" /> },
|
||||||
{ key: 'cstore', title: 'Convenience Store', subtitle: 'Alfamart • Indomaret' },
|
{ key: 'cstore', title: 'Convenience Store', subtitle: '', icon: <CStoreLogosRow compact size="xs" /> },
|
||||||
|
{ key: 'cpay', title: 'cPay', subtitle: 'Bayar via aplikasi CIFO Token', icon: <LogoCpay compact size="md" /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, enabled }: PaymentMethodListProps) {
|
export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, enabled }: PaymentMethodListProps) {
|
||||||
const enabledMap: Record<PaymentMethod, boolean> = enabled ?? {
|
const enabledMap: Record<PaymentMethod, boolean> = {
|
||||||
bank_transfer: Env.ENABLE_BANK_TRANSFER,
|
bank_transfer: enabled?.bank_transfer ?? Env.ENABLE_BANK_TRANSFER,
|
||||||
credit_card: Env.ENABLE_CREDIT_CARD,
|
credit_card: enabled?.credit_card ?? Env.ENABLE_CREDIT_CARD,
|
||||||
gopay: Env.ENABLE_GOPAY,
|
gopay: enabled?.gopay ?? Env.ENABLE_GOPAY,
|
||||||
cstore: Env.ENABLE_CSTORE,
|
cstore: enabled?.cstore ?? Env.ENABLE_CSTORE,
|
||||||
|
cpay: enabled?.cpay ?? Env.ENABLE_CPAY,
|
||||||
}
|
}
|
||||||
const items = baseItems.filter((it) => enabledMap[it.key])
|
const items = baseItems.filter((it) => enabledMap[it.key])
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,14 +43,25 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
|
||||||
aria-expanded={selected === it.key}
|
aria-expanded={selected === it.key}
|
||||||
aria-controls={`panel-${it.key}`}
|
aria-controls={`panel-${it.key}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<div className="text-base font-semibold text-black dark:text-white">{it.title}</div>
|
<div className="text-base font-semibold text-black dark:text-white">{it.title}</div>
|
||||||
<div className="text-sm text-black/70 dark:text-white/70">{it.subtitle}</div>
|
{it.key === 'bank_transfer' && it.subtitle && (
|
||||||
|
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||||
|
{it.subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{it.key === 'cpay' && it.subtitle && (
|
||||||
|
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||||
|
{it.subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="hidden sm:block" aria-hidden>
|
{it.icon && (
|
||||||
{it.icon}
|
<span aria-hidden>
|
||||||
</span>
|
{it.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span aria-hidden className={`text-black/60 dark:text-white/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}>›</span>
|
<span aria-hidden className={`text-black/60 dark:text-white/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}>›</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,10 @@ export interface PaymentSheetProps {
|
||||||
amount: number
|
amount: number
|
||||||
expireAt: number // epoch ms
|
expireAt: number // epoch ms
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
showStatusCTA?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt, children }: PaymentSheetProps) {
|
export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt, 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 (
|
||||||
|
|
@ -76,15 +77,17 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
|
||||||
<TrustStrip location="sheet" />
|
<TrustStrip location="sheet" />
|
||||||
</div>
|
</div>
|
||||||
{/* Sticky CTA (mobile-friendly) */}
|
{/* Sticky CTA (mobile-friendly) */}
|
||||||
<div className="sticky bottom-0 bg-white/95 dark:bg-neutral-900/95 backdrop-blur border-t border-black/10 dark:border-white/10 p-3 pb-[env(safe-area-inset-bottom)]">
|
{showStatusCTA && (
|
||||||
<Link
|
<div className="sticky bottom-0 bg-white/95 dark:bg-neutral-900/95 backdrop-blur border-t border-black/10 dark:border-white/10 p-3 pb-[env(safe-area-inset-bottom)]">
|
||||||
to={`/payments/${orderId}/status`}
|
<Link
|
||||||
aria-label="Buka halaman status pembayaran"
|
to={`/payments/${orderId}/status`}
|
||||||
className="w-full block text-center rounded bg-[#0c1f3f] text-white py-3 text-base font-semibold hover:bg-[#0a1a35] focus:outline-none focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:ring-[#0c1f3f]"
|
aria-label="Buka halaman status pembayaran"
|
||||||
>
|
className="w-full block text-center rounded bg-[#0c1f3f] text-white py-3 text-base font-semibold hover:bg-[#0a1a35] focus:outline-none focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:ring-[#0c1f3f]"
|
||||||
Cek status pembayaran
|
>
|
||||||
</Link>
|
Cek status pembayaran
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { BankTransferPanel } from '../features/payments/components/BankTransferP
|
||||||
import { CardPanel } from '../features/payments/components/CardPanel'
|
import { CardPanel } from '../features/payments/components/CardPanel'
|
||||||
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
||||||
import { CStorePanel } from '../features/payments/components/CStorePanel'
|
import { CStorePanel } from '../features/payments/components/CStorePanel'
|
||||||
import { BankLogo, type BankKey } from '../features/payments/components/PaymentLogos'
|
import { BankLogo, type BankKey, LogoAlfamart, LogoIndomaret } from '../features/payments/components/PaymentLogos'
|
||||||
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
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'
|
||||||
|
|
@ -72,7 +72,7 @@ export function CheckoutPage() {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PaymentSheet merchantName="Zara" orderId={orderId} amount={amount} expireAt={expireAt}>
|
<PaymentSheet merchantName="Zara" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}>
|
||||||
{/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */}
|
{/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -142,6 +142,15 @@ export function CheckoutPage() {
|
||||||
setSelected(m)
|
setSelected(m)
|
||||||
if (m === 'bank_transfer' || m === 'cstore') {
|
if (m === 'bank_transfer' || m === 'cstore') {
|
||||||
// Panel akan tampil di bawah item menggunakan renderPanel
|
// Panel akan tampil di bawah item menggunakan renderPanel
|
||||||
|
} else if (m === 'cpay') {
|
||||||
|
// Redirect ke aplikasi cPay (CIFO Token) di Play Store
|
||||||
|
try {
|
||||||
|
Logger.info('cpay.redirect.start')
|
||||||
|
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
|
||||||
|
Logger.info('cpay.redirect.done')
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('cpay.redirect.error', { message: (e as Error)?.message })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
|
|
@ -161,6 +170,13 @@ export function CheckoutPage() {
|
||||||
if (m === 'bank_transfer') {
|
if (m === 'bank_transfer') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2" aria-live="polite">
|
<div className="space-y-2" aria-live="polite">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img
|
||||||
|
src="/logos/logo-semua-bank.PNG"
|
||||||
|
alt="Logo semua bank yang didukung"
|
||||||
|
className="max-h-20 sm:max-h-24 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="text-xs text-black/60 dark:text-white/60">Pilih bank untuk membuat Virtual Account</div>
|
<div className="text-xs text-black/60 dark:text-white/60">Pilih bank untuk membuat Virtual Account</div>
|
||||||
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||||
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
|
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
|
||||||
|
|
@ -172,11 +188,10 @@ export function CheckoutPage() {
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
}}
|
}}
|
||||||
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex flex-col items-center gap-1 hover:bg-black/5 dark:hover:bg-white/10"
|
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
||||||
>
|
>
|
||||||
<BankLogo bank={bk} />
|
<BankLogo bank={bk} />
|
||||||
<span className="text-xs text-black/70 dark:text-white/70">{bk.toUpperCase()}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -203,10 +218,10 @@ export function CheckoutPage() {
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
}}
|
}}
|
||||||
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex flex-col items-center gap-1 hover:bg-black/5 dark:hover:bg-white/10"
|
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">{st.toUpperCase()}</span>
|
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -255,5 +270,6 @@ function defaultEnabled(): Record<PaymentMethod, boolean> {
|
||||||
credit_card: Env.ENABLE_CREDIT_CARD,
|
credit_card: Env.ENABLE_CREDIT_CARD,
|
||||||
gopay: Env.ENABLE_GOPAY,
|
gopay: Env.ENABLE_GOPAY,
|
||||||
cstore: Env.ENABLE_CSTORE,
|
cstore: Env.ENABLE_CSTORE,
|
||||||
|
cpay: Env.ENABLE_CPAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue