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
9 changed files with 514 additions and 72 deletions
Showing only changes of commit 343afa2af9 - Show all commits

264
docs/front-end-spec.md Normal file
View File

@ -0,0 +1,264 @@
# Core Midtrans CIFO UI/UX Specification
Purpose
- Improve legibility and contrast for older users by strengthening color, typography, and separators across Checkout flows (QRIS, GoPay, Convenience Store, Bank Transfer).
- Establish clear theme tokens so implementation stays consistent across components.
Change Log
- 2025-11-10: Initial draft focused on contrast, typography, and card/divider clarity.
Theme Foundations
- Color Roles (recommended hex values):
- Page background: `#F7FAFC` (very light slate)
- Surface/background (cards, panels): `#FFFFFF`
- Text primary: `#0B1020` (near-black for high contrast)
- Text secondary: `#374151` (medium slate; still readable)
- Border default: `#94A3B8` (slate 400)
- Border strong: `#64748B` (slate 500)
- Accent/primary: `#2563EB` (blue 600)
- Success: `#16A34A` (green 600)
- Danger: `#DC2626` (red 600)
- Warning: `#D97706` (amber 600)
- Info: `#0EA5E9` (sky 500)
- Focus and States:
- Focus ring color: `#2563EB`
- Focus ring width: `3px`
- Hover/active states should increase contrast by at least one shade (e.g., blue 600 → blue 700 on hover).
- Typography:
- Font stack (sans): `Inter, Segoe UI, system-ui, -apple-system, Roboto, Noto Sans, Arial, sans-serif`
- Base body size: `16px` minimum; labels `15px`; buttons `16px`
- Headings: `H4 20px`, `H3 24px`, `H2 28px` (line-height ~1.3)
- Body line-height: `1.5` for readability
- Avoid ultra-light weights; prefer `400600` for body/labels
- Separators and Borders:
- Default divider: `1.5px` `#94A3B8`
- Strong divider: `2px` `#64748B` for section breaks and card boundaries
- Cards: visible 2px border, subtle shadow `0 1px 2px rgba(0,0,0,0.08)`
- Spacing Scale (base):
- `4px` units; common spacing: `8/12/16/24/32px`
- Minimum interior padding for cards: `1624px`
Accessibility Standards
- Contrast:
- Normal text contrast ≥ `4.5:1`; large text (≥18px) ≥ `3:1`
- Verify accent on white and on light surfaces meets ratios
- Touch Targets:
- Minimum hit area: `44x44px` for interactive elements
- Minimum tap spacing: `8px` around grouped actions
- Focus & Keyboard:
- Always-visible focus outline with `3px` ring in accent color
- Skip links available on long forms
- Motion & Feedback:
- Respect reduced motion; avoid aggressive animations
- Provide textual or icon feedback in addition to color changes
Component Guidance
- Cards/Panels:
- Use strong border (`2px #64748B`) for outer frame when content density is high
- Title uses `H4/H3` with primary text color; subtitle uses secondary
- Interior dividers use default border (`1.5px #94A3B8`)
- Buttons:
- Primary: solid `#2563EB` with white text; hover `#1D4ED8`
- Secondary: outline with strong border and primary text
- Disabled: `#CBD5E1` background, `#64748B` text; maintain contrast
- Focus: `3px` accent ring outside button
- Inputs:
- Label size `1516px` and primary text color; never rely on placeholder as label
- Field border default; on focus switch to strong border and accent ring
- Error state uses danger color and text hint; icon optional
- Lists/Selection (e.g., store selection):
- Row height ≥ `48px`; radio/checkbox minimum `24px`
- Selected state uses strong border and light accent background (`blue-50`)
Implementation Notes
- CSS Variables (define once):
```css
:root {
--color-bg-page: #F7FAFC;
--color-bg-surface: #FFFFFF;
--color-text-primary: #0B1020;
--color-text-secondary: #374151;
--color-border-default: #94A3B8;
--color-border-strong: #64748B;
--color-accent: #2563EB;
--color-success: #16A34A;
--color-danger: #DC2626;
--color-warning: #D97706;
--color-info: #0EA5E9;
--focus-ring-width: 3px;
--focus-ring-color: #2563EB;
--shadow-card: 0 1px 2px rgba(0,0,0,0.08);
--border-width-default: 1.5px;
--border-width-strong: 2px;
--font-family-sans: Inter, Segoe UI, system-ui, -apple-system, Roboto, Noto Sans, Arial, sans-serif;
--font-size-body: 16px;
--font-size-label: 15px;
--font-size-button: 16px;
--font-size-h4: 20px;
--font-size-h3: 24px;
--font-size-h2: 28px;
--line-height-body: 1.5;
}
```
- Tailwind Theme (high-level):
- Map CSS variables to custom colors via CSS-in-JS or add a custom palette in `tailwind.config.*` aligned to the hex values above
- Increase `ringWidth` default to `3`, add `ringColor` for focus, and define `borderWidth` scale with `1.5` and `2`
- Component Application:
- Replace `border-gray-200/300` with `border-[#94A3B8]` for dividers
- Use `border-[2px]` + `border-[#64748B]` for card frames when needed
- Set `text-[#0B1020]` as default text and avoid pure black for softer rendering
Validation Checklist
- Body text ≥16px and headings per spec
- Divider visibility validated on standard laptop and low-contrast displays
- All critical actions meet contrast and focus outline requirements
- Component states (hover, active, disabled, error) meet color and contrast standards
Instruksi Langkah Pembayaran
- Tujuan: Menyajikan cara bayar yang jelas tanpa Step Wizard. Fokus pada daftar langkah ringkas untuk tiap metode (QRIS, GoPay, Transfer Bank via Mobile/Internet Banking/ATM, Convenience Store).
- Prinsip Penyajian:
- Judul panel: "Cara Bayar" + nama metode/bank (mis. "Cara Bayar: BCA Mobile", "Cara Bayar: ATM BNI", "Cara Bayar: QRIS").
- Gunakan daftar bernomor (1., 2., 3.) dengan jarak antar langkah `1216px` dan body `1618px`.
- Kalimat singkat, langsung, maksimal ±100 karakter per langkah.
- Gunakan teks primer `#0B1020`; hindari abu-abu pucat untuk keterbacaan.
- Divider opsional antar kelompok langkah memakai border default `#94A3B8`.
- Ikon langkah opsional; jangan menggantikan teks instruksi.
Pola per Metode
- QRIS
- 1. Buka aplikasi e-wallet/mbanking yang mendukung QRIS.
- 2. Arahkan kamera ke QR di layar.
- 3. Periksa detail pembayaran dan konfirmasi.
- 4. Selesai. Simpan bukti transaksi.
- Catatan: tampilkan countdown kedaluwarsa dan state "QR kedaluwarsa" dengan aksi "Buat Ulang" jika diperlukan.
- GoPay
- 1. Ketuk tombol "Buka GoPay" (atau buka aplikasi GoPay manual).
- 2. Periksa detail pembayaran.
- 3. Konfirmasi dan selesaikan pembayaran.
- 4. Simpan bukti transaksi.
- Transfer Bank via Mobile Banking (contoh umum)
- 1. Buka aplikasi mobile banking dan login.
- 2. Pilih menu "Transfer" → "Virtual Account".
- 3. Masukkan nomor VA dan nominal.
- 4. Periksa detail, konfirmasi dengan PIN/OTP.
- 5. Simpan bukti transaksi.
- Transfer Bank via Internet Banking (contoh umum)
- 1. Buka situs internet banking dan login.
- 2. Pilih menu "Transfer" → "Virtual Account".
- 3. Masukkan nomor VA dan nominal.
- 4. Periksa detail, konfirmasi dengan OTP.
- 5. Simpan bukti transaksi.
- Transfer Bank via ATM (contoh umum)
- 1. Kunjungi ATM dan masukkan kartu, pilih bahasa.
- 2. Pilih menu "Transfer" → "Virtual Account".
- 3. Masukkan nomor VA dan nominal.
- 4. Periksa detail, konfirmasi.
- 5. Ambil dan simpan struk transaksi.
- Convenience Store (Alfamart/Indomaret)
- 1. Kunjungi toko yang dipilih (Alfamart/Indomaret).
- 2. Tunjukkan "Kode Pembayaran" kepada kasir.
- 3. Lakukan pembayaran sebelum kedaluwarsa.
- 4. Simpan struk. Kunjungi halaman "Cek Status Pembayaran" jika diperlukan.
Copywriting Baku
- Gunakan label konsisten: "Kode Pembayaran", "Salin Kode", "Cek Status Pembayaran", "Buka GoPay", "QRIS", "Kedaluwarsa dalam", "Buat Ulang".
- Bahasa Indonesia, kalimat aktif, hindari istilah teknis berlebihan.
- Pastikan penulisan brand dan bank sesuai pedoman resmi.
Aksesibilitas & Lansia
- Body `1618px`, line-height `1.5`; jarak antar langkah `1216px`.
- Kontras AA untuk teks dan divider; fokus `ring 3px` selalu terlihat.
- Comfort Mode opsional: font `18px`, border kuat `3px`, spacing +20%.
Mobile UX — Pembayaran
- Tata letak:
- Satu kolom pada lebar kecil; gunakan container `max-w-md`.
- Spasi internal panel `1624px`; hindari konten menempel ke tepi.
- Target sentuh:
- Tinggi area tap minimal `44px`; jarak antar aksi `812px`.
- Baris pemilihan metode memakai `min-h-[44px]` dan `p-3`.
- Tipografi:
- Body `16px` (min); langkah instruksi `1618px` dengan `line-height 1.5`.
- Hindari `text-xs` untuk konten utama di mobile.
- Aksi utama:
- Tombol utama `w-full`; boleh sticky di bawah layar.
- Pertimbangkan padding aman: `padding-bottom: env(safe-area-inset-bottom)`.
- Panel QR/Code:
- QR minimum `min(68vw, 280px)`; grid pusat; border jelas.
- Kode pembayaran memakai `font-mono`, `text-lg`, `letter-spacing ~0.06em`, `select-all`.
- Tombol “Salin Kode” `w-full` di mobile.
- Loading & status:
- Pakai teks pendamping pada spinner; tambahkan `aria-live="polite"`.
- Tampilkan countdown kedaluwarsa dan aksi “Buat Ulang” jika relevan.
- Performa:
- Lazy-load gambar brand/QR; batasi ukuran logo; cache aktif.
- Hormati `prefers-reduced-motion`.
Implementasi Komponen (Opsional)
- InstructionList API:
```ts
type InstructionListProps = {
title: string; // "Cara Bayar: QRIS", "Cara Bayar: BCA Mobile"
steps: string[]; // daftar langkah pendek
footnote?: string; // catatan opsional (mis. kedaluwarsa)
}
```
- Gaya default:
- Judul `H4/H3` dengan teks primer; langkah bernomor dengan spacing `1216px`.
- Divider antar kelompok langkah memakai `#94A3B8`; tidak wajib.
- Ikon kecil opsional di kiri; jangan menggantikan teks.
Bank Spesifik: BCA
- BCA Mobile (m-BCA) — Virtual Account
- 1. Buka aplikasi BCA mobile dan login.
- 2. Pilih menu "m-BCA" → "m-Transfer".
- 3. Pilih "BCA Virtual Account".
- 4. Masukkan "Nomor VA" yang tertera di halaman pembayaran.
- 5. Masukkan nominal jika tidak terisi otomatis.
- 6. Periksa detail pembayaran, lalu ketuk "Send".
- 7. Masukkan PIN m-BCA untuk konfirmasi.
- 8. Simpan bukti transaksi.
- Catatan: Nama menu dapat berbeda pada versi aplikasi tertentu; sesuaikan jika perlu.
- ATM BCA — Virtual Account
- 1. Masukkan kartu BCA dan PIN.
- 2. Pilih "Transaksi Lainnya".
- 3. Pilih "Transfer".
- 4. Pilih "Ke BCA Virtual Account".
- 5. Masukkan "Nomor VA" yang tertera di halaman pembayaran.
- 6. Masukkan nominal jika diminta.
- 7. Periksa detail pembayaran, lalu konfirmasi.
- 8. Ambil dan simpan struk transaksi.
- Catatan: Urutan menu pada beberapa ATM bisa berbeda; gunakan opsi Virtual Account ketika tersedia.
- Internet Banking (KlikBCA) — Virtual Account
- 1. Buka situs resmi KlikBCA dan login dengan user ID.
- 2. Pilih menu "Transfer Dana".
- 3. Pilih "Ke BCA Virtual Account".
- 4. Masukkan "Nomor VA" yang tertera di halaman pembayaran.
- 5. Masukkan nominal jika diperlukan.
- 6. Periksa detail pembayaran, lalu konfirmasi.
- 7. Masukkan OTP/KeyBCA (token) untuk menyetujui transaksi.
- 8. Simpan bukti transaksi.
- Catatan: Nama menu dapat berbeda antar versi; pastikan memilih opsi Virtual Account.
Copywriting Baku — Tambahan
- Untuk metode Virtual Account, gunakan label "Nomor VA" secara konsisten pada UI dan instruksi.
Roadmap Implementasi — Mobile Pembayaran (10 TODO)
- [x] Tambah panduan "Mobile UX — Pembayaran" di spesifikasi.
- [x] Perbesar area sentuh PaymentMethodList (min height ≥44px).
- [x] Tingkatkan keterbacaan PaymentInstructions (text-sm, spacing yang cukup).
- [x] Perbesar keterbacaan Kode Pembayaran di CStore (font-mono, text-lg, tracking, select-all).
- [x] Perbesar kontainer QR GoPay/QRIS dan buat tombol aksi full-width di mobile.
- [ ] PaymentSheet: optimalkan header mobile dan countdown jelas.
- Catatan: tombol utama sticky di bawah sudah diimplementasi.
- [x] BankTransferPanel: implementasi komponen InstructionList untuk BCA (Mobile/ATM/KlikBCA).
- [ ] Aksesibilitas: standarisasi aria-live untuk spinner/QR/kode, fokus ring 3px di semua panel.
- [ ] Comfort Mode: tambah toggle (font 18px, border 3px, spacing +20%) dan token gaya terkait.
- [ ] QA lintas perangkat: uji di layar kecil (iPhone SE/Android kecil), sesuaikan token bila perlu.

View File

@ -2,7 +2,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:focus-ring disabled:opacity-50 disabled:pointer-events-none',
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-black disabled:opacity-50 disabled:pointer-events-none',
{
variants: {
variant: {

View File

@ -2,6 +2,7 @@ import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation'
import React from 'react'
import { PaymentInstructions } from './PaymentInstructions'
import { BcaInstructionList } from './BcaInstructionList'
import { TrustStrip } from './TrustStrip'
import { BankLogosRow, BankLogo, type BankKey } from './PaymentLogos'
import { postCharge } from '../../../services/api'
@ -126,6 +127,13 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected])
// Auto-show instructions when BCA is selected to reduce confusion
React.useEffect(() => {
if (selected === 'bca' && !showGuide) {
setShowGuide(true)
}
}, [selected])
return (
<div className="space-y-3">
<div className="font-medium">Transfer bank</div>
@ -137,26 +145,19 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
<span className="text-black/80 dark:text-white/80 font-semibold">{selected.toUpperCase()}</span>
</div>
)}
<div className="text-sm text-black/60 dark:text-white/60">VA dibuat otomatis sesuai bank pilihan Anda.</div>
<div className="text-sm text-black/70 dark:text-white/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
{errorMessage && (
<Alert title="Gagal membuat VA">{errorMessage}</Alert>
)}
<button
type="button"
onClick={() => setShowGuide((v) => !v)}
className="text-xs text-brand-600 hover:underline"
aria-expanded={showGuide}
>
Cara bayar
</button>
{showGuide && <PaymentInstructions method="bank_transfer" />}
{selected && (
<div className="flex flex-col gap-3 pt-1">
<div className="text-sm text-black/60 dark:text-white/60">
<div className="pt-1">
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20">
<div className="text-sm font-medium mb-2">Virtual Account</div>
<div className="text-sm text-black/70 dark:text-white/70">
{vaCode ? (
<span>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="font-mono text-2xl md:text-3xl font-semibold tracking-wider text-black dark:text-white">{vaCode}</span></span>
) : (
<span className="inline-flex items-center gap-2">
<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 ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'}
</span>
@ -168,11 +169,24 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
<span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black dark:text-white">{billerCode}</span></span>
)}
</div>
<div className="flex gap-2">
<div className="mt-2 flex gap-2">
<Button variant="outline" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button>
<Button variant="outline" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button>
</div>
</div>
</div>
)}
{selected && (
<div className="pt-2">
{selected === 'bca' ? (
<BcaInstructionList />
) : (
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20">
<div className="text-sm font-medium mb-2">Instruksi pembayaran</div>
<PaymentInstructions method="bank_transfer" />
</div>
)}
</div>
)}
{locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.</div>
@ -259,7 +273,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
className="w-full"
>
{busy ? (
<span className="inline-flex items-center justify-center gap-2">
<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-white/70 dark:border-black/40 border-t-transparent" aria-hidden />
Membuat VA
</span>

View File

@ -0,0 +1,120 @@
import React from 'react'
type TabKey = 'mobile' | 'atm' | 'ib'
export function BcaInstructionList() {
const [tab, setTab] = React.useState<TabKey>('mobile')
return (
<div className="mt-2 rounded-lg border-2 border-black/20 dark:border-white/20 bg-white dark:bg-black/20">
<div className="p-3">
<div className="text-sm font-medium mb-2">Instruksi BCA</div>
<div
role="tablist"
aria-label="Metode pembayaran BCA"
className="inline-flex mb-3 rounded-md border border-black/20 dark:border-white/20 overflow-hidden"
>
<button
type="button"
id="tab-bca-mobile"
role="tab"
aria-selected={tab === 'mobile'}
aria-controls="panel-bca-mobile"
onClick={() => setTab('mobile')}
className={`px-3 py-2 text-xs font-medium focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${tab === 'mobile' ? 'bg-black text-white' : 'bg-transparent hover:bg-black/5 dark:hover:bg-white/10'}`}
>
BCA Mobile
</button>
<button
type="button"
id="tab-bca-atm"
role="tab"
aria-selected={tab === 'atm'}
aria-controls="panel-bca-atm"
onClick={() => setTab('atm')}
className={`px-3 py-2 text-xs font-medium focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${tab === 'atm' ? 'bg-black text-white' : 'bg-transparent hover:bg-black/5 dark:hover:bg-white/10'}`}
>
ATM BCA
</button>
<button
type="button"
id="tab-bca-ib"
role="tab"
aria-selected={tab === 'ib'}
aria-controls="panel-bca-ib"
onClick={() => setTab('ib')}
className={`px-3 py-2 text-xs font-medium focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${tab === 'ib' ? 'bg-black text-white' : 'bg-transparent hover:bg-black/5 dark:hover:bg-white/10'}`}
>
KlikBCA
</button>
</div>
{tab === 'mobile' && (
<div role="tabpanel" id="panel-bca-mobile" aria-labelledby="tab-bca-mobile">
<StepsMobile />
</div>
)}
{tab === 'atm' && (
<div role="tabpanel" id="panel-bca-atm" aria-labelledby="tab-bca-atm">
<StepsAtm />
</div>
)}
{tab === 'ib' && (
<div role="tabpanel" id="panel-bca-ib" aria-labelledby="tab-bca-ib">
<StepsKlikBca />
</div>
)}
</div>
<div className="border-t border-black/10 dark:border-white/10 p-3 text-xs text-black/60 dark:text-white/60">
Catatan: Gunakan label "Nomor VA" secara konsisten di semua metode.
</div>
</div>
)
}
function StepsMobile() {
const steps = [
'Buka BCA Mobile, pilih m-BCA dan login.',
'Masuk ke menu m-Transfer.',
'Pilih BCA Virtual Account.',
'Masukkan Nomor VA dari halaman ini.',
'Periksa nama tujuan dan nominal, pastikan sesuai.',
'Kirim dan konfirmasi.',
'Masukkan PIN m-BCA untuk menyelesaikan pembayaran.'
]
return <StepList steps={steps} />
}
function StepsAtm() {
const steps = [
'Masukkan kartu dan PIN di ATM BCA.',
'Pilih Transaksi Lainnya.',
'Pilih Transfer.',
'Pilih ke BCA Virtual Account.',
'Masukkan Nomor VA dari halaman ini.',
'Periksa detail dan nominal, lalu konfirmasi.',
'Selesaikan transaksi dan simpan bukti.'
]
return <StepList steps={steps} />
}
function StepsKlikBca() {
const steps = [
'Login ke KlikBCA (Internet Banking).',
'Pilih menu Transfer Dana.',
'Pilih ke BCA Virtual Account.',
'Masukkan Nomor VA dari halaman ini.',
'Periksa detail dan nominal, lalu lanjutkan.',
'Masukkan respon KeyBCA untuk konfirmasi.',
'Transaksi selesai, simpan bukti/nota.'
]
return <StepList steps={steps} />
}
function StepList({ steps }: { steps: string[] }) {
return (
<ol className="list-decimal list-inside space-y-2 text-sm text-black/80 dark:text-white/80">
{steps.map((s, i) => (
<li key={i}>{s}</li>
))}
</ol>
)
}

View File

@ -95,7 +95,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.</div>
)}
<div className="pt-2 space-y-2">
<div className="rounded border border-black/10 dark:border-white/10 p-2 text-sm">
<div className="rounded border border-black/10 dark:border-white/10 p-2 text-sm" aria-live="polite">
<div className="font-medium">Kode Pembayaran</div>
{!selected && (
<div className="text-xs text-black/60 dark:text-white/60">Pilih toko terlebih dahulu di langkah sebelumnya.</div>
@ -109,10 +109,10 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
{selected && !busy && (storeFromRes || paymentCode) && (
<>
{storeFromRes ? <div>Toko: {storeFromRes.toUpperCase()}</div> : null}
{paymentCode ? <div>Kode: <span className="font-mono">{paymentCode}</span></div> : null}
{paymentCode ? <div>Kode: <span className="font-mono text-lg tracking-[0.06em] select-all">{paymentCode}</span></div> : null}
</>
)}
<div className="mt-2"><Button variant="outline" size="sm" 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>
<Button
variant="secondary"

View File

@ -37,7 +37,6 @@ function QRPlaceholder() {
export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
const nav = usePaymentNavigation()
const [showGuide, setShowGuide] = React.useState(false)
const [busy, setBusy] = React.useState(false)
const [qrUrl, setQrUrl] = React.useState<string>('')
const [actions, setActions] = React.useState<Array<{ name?: string; method?: string; url: string }>>([])
@ -77,38 +76,61 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
<GoPayLogosRow compact />
<div className="flex items-center gap-2 text-xs">
<span className="text-black/60 dark:text-white/60">Mode:</span>
<div className="inline-flex rounded border border-black/10 dark:border-white/10 overflow-hidden">
<button type="button" onClick={() => setMode('gopay')} className={`px-2 py-1 ${mode==='gopay' ? 'bg-black/80 text-white' : 'bg-white dark:bg-black text-black dark:text-white'}`}>GoPay</button>
<button type="button" onClick={() => setMode('qris')} className={`px-2 py-1 ${mode==='qris' ? 'bg-black/80 text-white' : 'bg-white dark:bg-black text-black dark:text-white'}`}>QRIS</button>
<div className="inline-flex rounded-md border-2 border-black/20 dark:border-white/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran">
<button
type="button"
onClick={() => setMode('gopay')}
aria-pressed={mode==='gopay'}
className={`px-2 py-1 focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${mode==='gopay' ? 'bg-black text-white' : 'bg-white dark:bg-black text-black dark:text-white hover:bg-black/10 dark:hover:bg-white/10'}`}
>
GoPay
</button>
<button
type="button"
onClick={() => setMode('qris')}
aria-pressed={mode==='qris'}
className={`px-2 py-1 focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${mode==='qris' ? 'bg-black text-white' : 'bg-white dark:bg-black text-black dark:text-white hover:bg-black/10 dark:hover:bg-white/10'}`}
>
QRIS
</button>
</div>
</div>
<div className="rounded border border-black/10 dark:border-white/10 p-3 flex flex-col items-center gap-2">
<div className="text-xs text-black/60 dark:text-white/60">Scan QR berikut menggunakan aplikasi {mode === 'gopay' ? 'GoPay' : 'QRIS'}</div>
<div className="relative w-full max-w-[240px] aspect-square grid place-items-center rounded-md border border-black/20 dark:border-white/20 bg-white">
<div className="relative w-full max-w-[280px] aspect-square grid place-items-center rounded-md border border-black/20 dark:border-white/20 bg-white">
{mode === 'qris' && (!qrUrl || busy) ? (
<span className="inline-flex items-center justify-center gap-2 text-xs text-black/60 dark:text-white/60" role="status" aria-live="polite">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />
Membuat QR
</span>
) : qrUrl ? (
<img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[220px] mx-auto" />
<img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto" />
) : null}
</div>
<div className="text-[10px] text-black/50 dark:text-white/50">Mode: {mode.toUpperCase()}</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={downloadQR} disabled={!qrUrl}>Download QR</Button>
<Button variant="outline" size="sm" onClick={openGoPay} disabled={mode === 'qris'}>Buka GoPay</Button>
<div className="flex flex-col sm:flex-row gap-2 w-full">
<Button variant="outline" className="w-full sm:w-auto" onClick={downloadQR} disabled={!qrUrl}>Download QR</Button>
<Button variant="outline" className="w-full sm:w-auto" onClick={openGoPay} disabled={mode === 'qris'}>Buka GoPay</Button>
</div>
<div className="pt-2">
<PaymentInstructions
title={`Instruksi ${mode === 'gopay' ? 'GoPay' : 'QRIS'}`}
steps={mode === 'gopay'
? [
'Buka aplikasi GoPay dan pilih menu Scan.',
'Arahkan kamera ke QR di layar.',
'Periksa detail dan konfirmasi pembayaran di aplikasi.',
'Simpan bukti pembayaran; status akan diperbarui otomatis.'
]
: [
'Buka aplikasi e-wallet/Bank yang mendukung QRIS (GoPay, ShopeePay, dll).',
'Pilih menu Scan, arahkan kamera ke QR di layar.',
'Periksa detail dan konfirmasi pembayaran di aplikasi.',
'Simpan bukti pembayaran; status akan diperbarui otomatis.'
]}
/>
</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="gopay" />}
{locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div>
)}
@ -121,7 +143,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
onClick={() => { setBusy(true); onChargeInitiated?.(); setTimeout(() => { nav.toStatus(orderId, mode) ; setBusy(false) }, 250) }}
>
{busy ? (
<span className="inline-flex items-center justify-center gap-2">
<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 />
Menuju status
</span>

View File

@ -1,13 +1,15 @@
import React from 'react'
import type { PaymentMethod } from './PaymentMethodList'
export function PaymentInstructions({ method }: { method: PaymentMethod }) {
const steps = getSteps(method)
export function PaymentInstructions({ method, title, steps }: { method?: PaymentMethod; title?: string; steps?: string[] }) {
const computed = steps ?? (method ? getSteps(method) : [])
const finalSteps = computed.length ? computed : ['Ikuti instruksi yang muncul pada layar pembayaran.']
const finalTitle = title ?? 'Cara bayar'
return (
<div className="mt-2 rounded border border-black/10 dark:border-white/10 p-3 bg-black/2 dark:bg-white/5">
<div className="text-sm font-medium mb-2">Cara bayar</div>
<ol className="list-decimal list-inside space-y-1 text-xs text-black/80 dark:text-white/80">
{steps.map((s, i) => (
<div className="mt-2 rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20" role="region" aria-label={finalTitle}>
<div className="text-sm font-medium mb-2">{finalTitle}</div>
<ol className="list-decimal list-inside space-y-2 text-sm text-black/80 dark:text-white/80">
{finalSteps.map((s, i) => (
<li key={i}>{s}</li>
))}
</ol>

View File

@ -30,26 +30,26 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
return (
<div className="space-y-3">
<div className="text-sm font-medium">Metode pembayaran</div>
<div className="divide-y divide-black/10 dark:divide-white/10 rounded border border-black/10 dark:border-white/10">
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 divide-y-[2px] divide-black/20 dark:divide-white/20 bg-white dark:bg-black">
{items.map((it) => (
<div key={it.key}>
<button
onClick={() => !disabled && onSelect(it.key)}
disabled={disabled}
className={`w-full text-left p-3 flex items-center justify-between ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-black/5 dark:hover:bg-white/10'} ${selected === it.key ? 'bg-black/5 dark:bg-white/10' : ''}`}
className={`w-full text-left p-4 min-h-[52px] flex items-center justify-between ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-black/10 dark:hover:bg-white/15'} ${selected === it.key ? 'bg-black/10 dark:bg-white/20' : ''} focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-black`}
aria-pressed={selected === it.key}
aria-expanded={selected === it.key}
aria-controls={`panel-${it.key}`}
>
<div>
<div className="font-medium">{it.title}</div>
<div className="text-xs text-black/60 dark:text-white/60">{it.subtitle}</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>
</div>
<div className="flex items-center gap-2">
<span className="hidden sm:block" aria-hidden>
{it.icon}
</span>
<span aria-hidden className={`text-black/40 dark:text-white/40 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>
</button>
{selected === it.key && renderPanel && (

View File

@ -1,4 +1,5 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { TrustStrip } from './TrustStrip'
function formatCurrencyIDR(amount: number) {
@ -33,18 +34,27 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
<div className="max-w-md">
<div className="card overflow-hidden">
{/* Header */}
<div className="bg-[#0c1f3f] text-white p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded bg-white text-black px-2 py-1 text-xs font-bold">ZARA</div>
<div className="font-semibold">{merchantName}</div>
<div className="bg-[#0c1f3f] text-white p-3 sm:p-4 flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="rounded bg-white text-black px-2 py-1 text-[11px] sm:text-xs font-bold" aria-hidden>
ZARA
</div>
<div className="font-semibold text-sm sm:text-base">{merchantName}</div>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<div
className="text-xs sm:text-sm text-white/80"
role="timer"
aria-live="polite"
aria-label={`Kedaluwarsa dalam ${countdown}`}
>
Kedaluwarsa dalam <span className="font-semibold text-white">{countdown}</span>
</div>
<div className="flex items-center gap-3">
<div className="text-xs text-white/80">Bayar dalam <span className="font-semibold text-white">{countdown}</span></div>
<button
aria-label={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
className={`text-white/80 transition-transform ${expanded ? '' : 'rotate-180'}`}
className={`text-white/80 transition-transform ${expanded ? '' : 'rotate-180'} focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-white/80 focus-visible:ring-offset-2`}
>
˅
</button>
@ -65,6 +75,16 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
{children}
<TrustStrip location="sheet" />
</div>
{/* 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)]">
<Link
to={`/payments/${orderId}/status`}
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>
</div>
</div>
</div>
)