feat/payments-ux-instructions-va #1

Merged
root merged 4 commits from feat/payments-ux-instructions-va into main 2025-11-10 08:50:52 +00:00
10 changed files with 244 additions and 72 deletions
Showing only changes of commit 0201274182 - Show all commits

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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 />

View File

@ -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>

View File

@ -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>
) )

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
) )
} }

View File

@ -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>

View File

@ -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>
) )

View File

@ -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,
} }
} }