Compare commits

..

5 Commits

Author SHA1 Message Date
root b0ded99d34 Merge pull request 'feat/payments-ux-instructions-va' (#1) from feat/payments-ux-instructions-va into main
Reviewed-on: #1
2025-11-10 08:50:51 +00:00
CIFO Dev 85d0c9a58b improvisasi 2025-11-10 15:32:41 +07:00
CIFO Dev 0201274182 payments: konsistenkan varian tombol dan penataan VA
- Ubah semua Button varian 'ghost' menjadi 'outline' di CardPanel, GoPayPanel, CStorePanel, InlinePaymentStatus untuk konsistensi tipe dan styling\n- Hilangkan logo per-bank dari BankTransferPanel; hanya tampilkan nama bank\n- Perbaiki tampilan Nomor VA: pindah ke baris baru, wrap (reak-all), ukuran font responsif, kurangi tracking\n- Tampilkan gambar 'logo semua bank' di header metode Transfer bank (kanan) dan panel pilihan di Step 2\n- Perbesar ikon logo agar jelas dan seragam di PaymentMethodList\n- Penyesuaian ringan di PaymentSheet untuk CTA status
2025-11-10 14:50:55 +07:00
CIFO Dev b07c267704 chore(assets): point bank logos to files in public/logos
PaymentLogos: update BANK_LOGOS src to match current asset filenames (BCA, BNI, BRI, CIMB, Mandiri, Permata). Ensures PaymentMethodList and panels render new logos.
2025-11-10 13:28:58 +07:00
CIFO Dev 343afa2af9 feat: improve payments UX (VA layout, instructions, accessibility)
BankTransferPanel: VA card above instructions; clearer borders; aria-live for VA status; dynamic 'Instruksi pembayaran' panel.\nBcaInstructionList: ARIA roles for tablist/tabs/tabpanels; stronger visual cues; 3px focus ring.\nGoPayPanel: auto display instructions with dynamic steps per mode; clearer mode toggle buttons; 3px focus ring.\nButton: standardize 3px focus ring with 2px offset and accent color.\nPaymentMethodList: enhanced contrast, thicker borders, larger targets for seniors.\nPaymentInstructions: flexible title and steps; never empty with sensible fallback.
2025-11-10 12:06:34 +07:00
38 changed files with 1586 additions and 181 deletions

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

@ -66,3 +66,27 @@ Pastikan `VITE_API_BASE_URL` menunjuk ke `http://localhost:8000/api` agar fronte
- Midtrans Docs: Credit Card with 3DS (Core API).
- Contoh: `coreApiSimpleExample.js` untuk pola panggilan Core API.
## Webhook & ERP Callback
- Endpoint webhook Midtrans: `POST /api/payments/webhook` (server Express)
- Verifikasi `signature_key = sha512(order_id + status_code + gross_amount + MIDTRANS_SERVER_KEY)`.
- Menerima notifikasi status dan menandai sukses untuk `settlement` (umum) atau `capture` dengan `fraud_status=accept` (kartu).
- ERP Notification (opsional, via env feature flag):
- Konfigurasi `.env` backend:
- `ERP_NOTIFICATION_URL="https://apibackend.erpskrip.id/paymentnotification/"`
- `ERP_CLIENT_ID="<dari ERP>"`
- `ERP_MERCANT_ID="<dari ERP>"`
- `ERP_ENABLE_NOTIF=true`
- Payload dikirim saat sukses:
```json
{
"data": {
"mercant_id": "<id>",
"status_code": "200",
"nominal": "<gross_amount>",
"client_id": "<id>"
},
"signature": "sha512(mercant_id + status_code + nominal + client_id)"
}
```

View File

@ -0,0 +1,183 @@
# Story: End-to-End Midtrans — Checkout → Webhook (Payment Sukses)
## User Story
Sebagai pembeli,
Saya ingin menyelesaikan pembayaran melalui Midtrans dan melihat status pembayaran otomatis diperbarui melalui webhook,
Sehingga saya mendapatkan konfirmasi yang jelas dan pesanan saya segera diproses.
## Story Context
**Integrates with:**
- Frontend: `src/pages/CheckoutPage.tsx`, `src/features/payments/components/*` (BankTransferPanel, CardPanel, GoPayPanel, CStorePanel), `src/pages/PaymentStatusPage.tsx`, hooks `usePaymentStatus`, navigasi `usePaymentNavigation`
- Backend: `server/index.cjs` — endpoint `POST /api/payments/charge`, `GET /api/payments/:orderId/status`, penambahan `POST /api/payments/webhook`
- Services: `src/services/api.ts` (`postCharge`, `getPaymentStatus`)
**Technology:** React + Vite, TanStack Query (polling status), Express, `midtrans-client` Core API, Midtrans 3DS (kartu).
**Follows pattern:** Semua panggilan ke Midtrans dilakukan via backend; UI menavigasi ke halaman status (`/payments/:orderId/status`) dan melakukan polling hingga status final; webhook memperbarui status di backend sebagai sumber kebenaran utama.
## Flow Overview
1) Checkout
- Pengguna memilih metode: `bank_transfer` (VA/echannel), `gopay` (QRIS/GoPay), `cstore` (Alfamart/Indomaret), `credit_card` (tokenisasi + 3DS).
- UI menampilkan panel sesuai metode dan mengumpulkan input yang diperlukan.
2) Charge (Backend)
- Frontend memanggil `POST /api/payments/charge` via `postCharge(payload)`.
- Backend meneruskan ke Midtrans `core.charge(payload)` dan mengembalikan respons (termasuk `order_id`, `transaction_status`, serta `redirect_url` untuk kartu jika 3DS diperlukan).
3) 3DS (Kartu)
- Bila respons berisi `redirect_url`, UI memanggil `authenticate3ds(redirect_url)` untuk challenge 3DS.
- Setelah 3DS selesai, status akan menjadi `capture` (berhasil) atau status lain sesuai penilaian fraud.
4) Status Page + Polling
- UI menavigasi ke `/payments/:orderId/status` (opsional: `?m=<method>`).
- Halaman melakukan polling `GET /api/payments/:orderId/status` setiap 3 detik sampai status final: `settlement`, `capture`, `expire`, `cancel`, `deny`, `refund`, `chargeback`.
5) Webhook (Notifikasi Midtrans)
- Backend menyediakan `POST /api/payments/webhook` untuk menerima notifikasi transaksi.
- Backend memverifikasi `signature_key` dan memperbarui status transaksi (DB/in-memory) sebagai sumber kebenaran.
- UI tetap polling hingga menangkap status final (atau dapat diinformasikan melalui SSE jika ditambahkan kemudian).
6) Success Outcome
- Untuk `bank_transfer/gopay/cstore`: status `settlement` dianggap sukses.
- Untuk `credit_card`: status `capture` dengan `fraud_status=accept` dianggap sukses.
- UI menampilkan badge hijau “Pembayaran berhasil” dan detail metode (VA, QR links, masked card, dsb.).
7) ERP Notification (External Callback)
- Ketika pembayaran sukses, backend mengirim callback ke ERP pada URL `https://apibackend.erpskrip.id/paymentnotification/`.
- Metode: `POST`, Body JSON berisi:
- `data`: `{ channel, nominal, mercant_id, customer_name }`
- `status_code`: selalu `"200"` untuk pembayaran sukses
- `signature`: `sha512(mercant_id + status_code + nominal + client_id)` dalam hex lowercase
- `client_id` disediakan oleh konfigurasi backend (contoh env `ERP_CLIENT_ID`).
## Acceptance Criteria
- Checkout
- Pengguna dapat memilih metode Midtrans dan melihat panel input/instruksi yang sesuai.
- Tombol “Bayar” mengirim charge ke backend dan menampilkan feedback loading/error yang ramah.
- Charge & 3DS
- `credit_card`: tokenisasi via 3DS SDK; jika wajib 3DS, UI mengarahkan ke challenge lalu kembali ke halaman status.
- `bank_transfer/gopay/cstore`: charge mengembalikan detail VA/QR/payment code sesuai; UI menampilkan instruksi yang relevan.
- Status Page
- Halaman status menampilkan `order_id`, metode, dan badge status: `pending` (kuning), `settlement/capture` (hijau), `deny/cancel/expire/refund/chargeback` (merah).
- Polling berhenti otomatis ketika status final.
- Webhook Backend
- Endpoint `POST /api/payments/webhook` tersedia dan tervalidasi signature Midtrans.
- Status transaksi diperbarui idempoten (repeat notification tidak merusak data), menyimpan metadata minimal: `source=webhook`, `occurred_at`.
- Logging mencatat event webhook, hasil verifikasi, dan perubahan status.
- Success Case
- Setelah pembayaran sukses (settlement/capture), UI menampilkan konfirmasi “Pembayaran berhasil” dan menyediakan navigasi “Lihat Riwayat” dan “Kembali ke Checkout”.
- ERP Notification
- Saat status sukses (VA/GoPay/QRIS/Cstore = `settlement`, Kartu = `capture` + `fraud_status=accept`), backend melakukan `POST` ke `https://apibackend.erpskrip.id/paymentnotification/` dengan body:
- `data`: `{ channel: <string>, nominal: <number>, mercant_id: <string>, customer_name: <string> }`
- `status_code`: `"200"`
- `signature`: hasil `sha512(mercant_id + status_code + nominal + client_id)`
- Signature tervalidasi di sisi ERP (nilai harus cocok dengan rumus).
- Callback idempoten (jika dipanggil ulang karena retry, tidak menggandakan efek di ERP).
- Keamanan & Ketahanan
- Server Key tidak terekspos ke frontend.
- Verifikasi signature sesuai rumus Midtrans; input sensitif (token_id, card data) tidak tercatat di log.
- Fallback polling tetap bekerja jika webhook terlambat.
## Technical Notes
- Endpoint backend saat ini:
- `POST /api/payments/charge` — sudah ada (pass-through ke Midtrans Core API)
- `GET /api/payments/:orderId/status` — sudah ada (memanggil `core.transaction.status(orderId)`).
- Tambahkan: `POST /api/payments/webhook` — menerima notifikasi Midtrans.
- Verifikasi Signature Midtrans (HTTP Notification)
- `signature_key = sha512(order_id + status_code + gross_amount + serverKey)` (hex lowercase)
- Contoh Node.js:
```js
const crypto = require('crypto')
const signature = crypto
.createHash('sha512')
.update(orderId + statusCode + grossAmount + serverKey)
.digest('hex')
const isValid = signature === req.body.signature_key
```
- Status Mapping (UI)
- Final: `settlement`, `capture`, `expire`, `cancel`, `deny`, `refund`, `chargeback`.
- Sukses: `settlement` (VA/QR/Cstore), `capture` + `fraud_status=accept` (Card).
- Normalisasi di `src/features/payments/lib/midtrans.ts` melalui `normalizeMidtransStatus`.
- ERP External Notification (Backend → ERP)
- Kirim callback hanya ketika status sukses:
- VA/GoPay/QRIS/Cstore: `transaction_status === 'settlement'`
- Kartu: `transaction_status === 'capture'` dan `fraud_status === 'accept'`
- Payload contoh:
```json
{
"data": {
"channel": "DANA",
"nominal": 200000,
"mercant_id": "TKG-250520029803",
"customer_name": "Dwiki Kurnia Sandi"
},
"status_code": "200",
"signature": "<hex sha512>"
}
```
- Perhitungan signature (Node.js):
```js
const crypto = require('crypto')
const mercantId = data.mercant_id
const nominal = String(data.nominal)
const statusCode = '200'
const clientId = process.env.ERP_CLIENT_ID || ''
const raw = `${mercantId}${statusCode}${nominal}${clientId}`
const signature = crypto.createHash('sha512').update(raw).digest('hex')
```
- Pengiriman (contoh menggunakan fetch):
```js
const res = await fetch('https://apibackend.erpskrip.id/paymentnotification/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data, status_code: '200', signature })
})
if (!res.ok) throw new Error(`ERP notify failed: ${res.status}`)
```
- Idempotensi: gunakan kunci unik (mis. `order_id`) untuk memastikan sekali proses pada ERP; lakukan retry dengan backoff jika gagal.
## Test Cases (Sandbox)
- Credit Card (3DS)
- Input kartu sandbox → tokenisasi → challenge 3DS → kembali ke status → `capture` + `fraud_status=accept` → UI sukses.
- Bank Transfer (VA Permata/Mandiri E-Channel)
- Charge menghasilkan VA → UI menampilkan VA dan bank → simulasi pembayaran → webhook atau polling → `settlement` → UI sukses.
- GoPay/QRIS
- Charge menghasilkan `actions` (deeplink/QR) → buka tautan → `settlement` → UI sukses.
- Backend mengirim ERP callback dengan `status_code="200"` dan signature valid.
- Negative/Edge
- Tidak bayar dalam waktu batas → `expire` → UI merah, polling berhenti.
- `deny/cancel` → UI merah; riwayat mencatat status akhir.
- ERP callback tidak dikirim untuk status non-sukses.
## Dependencies & Config
- Env Frontend: `VITE_API_BASE_URL`, `VITE_MIDTRANS_CLIENT_KEY`, `VITE_MIDTRANS_ENV` (sandbox/production).
- Env Backend: `MIDTRANS_SERVER_KEY`, `MIDTRANS_CLIENT_KEY` (opsional untuk log), `MIDTRANS_IS_PRODUCTION`.
- Toggle fitur: `GET/POST /api/config` (bank_transfer, credit_card, gopay, cstore). Pastikan metode yang digunakan aktif.
- ERP Notifikasi:
- `ERP_NOTIFICATION_URL="https://apibackend.erpskrip.id/paymentnotification/"`
- `ERP_CLIENT_ID="<dari ERP>"`
- Opsional: `ERP_ENABLE_NOTIF=true` (feature flag untuk mengaktifkan/nonaktifkan callback)
## Validation Checklist
- [ ] Perubahan dapat selesai dalam satu sesi (dokumen + endpoint webhook kecil)
- [ ] Integrasi mengikuti pola yang ada (`postCharge`, `getPaymentStatus`, polling)
- [ ] Tidak ada kebutuhan arsitektur baru besar
- [ ] Acceptance criteria dapat diuji di sandbox
- [ ] Rollback sederhana: nonaktifkan webhook, andalkan polling sementara
- [ ] ERP callback dikirim pada status sukses dengan `status_code="200"` dan signature sesuai
## Rollback & Risk
- Rollback: nonaktifkan konsumsi webhook (feature flag), gunakan polling status sementara.
- Risiko: signature salah, keterlambatan webhook, idempotensi tidak benar. Mitigasi: verifikasi ketat, logging, dan retry aman.
- Risiko ERP: endpoint tidak tersedia atau timeouts. Mitigasi: retry dengan backoff, dead-letter queue (opsional), observabilitas.
## Out of Scope
- Metode eksternal nonMidtrans (contoh: cPay/CIFO Token) tidak termasuk dalam skenario sukses Midtrans ini.
- Refund/chargeback flow admin; hanya ditampilkan sebagai status jika terjadi.
## Notes
- Rujuk `docs/integration-midtrans.md` untuk detail kartu/3DS.
- Tambahkan dokumentasi runbook QA untuk simulasi webhook dan verifikasi status manual.

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
public/logos/BRI_2025.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="59.999561mm"
height="17.510006mm"
viewBox="0 0 59.999561 17.510006"
version="1.1"
id="svg945"
inkscape:version="0.92.2 (5c3e80d, 2017-08-06)"
sodipodi:docname="Bank Mandiri logo 2016.svg">
<defs
id="defs939">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath20">
<path
d="M 382.677,748.082 H 552.755 V 799.37 H 382.677 Z"
id="path18"
inkscape:connector-curvature="0" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="140.52785"
inkscape:cy="-472.62453"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="1600"
inkscape:window-height="837"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1" />
<metadata
id="metadata942">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(60.237875,-6.2747549)">
<g
transform="matrix(0.35277777,0,0,-0.35277777,-195.23799,287.69146)"
id="g14">
<g
id="g16"
clip-path="url(#clipPath20)">
<g
id="g22"
transform="translate(382.8575,763.6027)">
<path
d="M 0,0 C 0,2.465 -0.047,4.533 -0.18,6.378 H 4.484 L 4.703,3.211 h 0.13 c 1.055,1.674 2.992,3.654 6.598,3.654 2.815,0 5.013,-1.59 5.936,-3.962 h 0.09 c 0.751,1.189 1.629,2.065 2.638,2.683 1.187,0.835 2.552,1.279 4.314,1.279 3.558,0 7.161,-2.42 7.161,-9.285 v -12.619 h -5.276 v 11.831 c 0,3.561 -1.23,5.673 -3.821,5.673 -1.851,0 -3.217,-1.321 -3.785,-2.861 -0.132,-0.527 -0.265,-1.186 -0.265,-1.799 v -12.844 h -5.279 v 12.402 c 0,2.99 -1.186,5.102 -3.692,5.102 C 7.428,2.465 6.067,0.882 5.583,-0.611 5.364,-1.143 5.271,-1.757 5.271,-2.373 V -15.039 H 0 Z"
style="fill:#003a70;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path24"
inkscape:connector-curvature="0" />
</g>
<g
id="g26"
transform="translate(429.7098,759.028)">
<path
d="m 0,0 c -3.827,0.09 -7.475,-0.743 -7.475,-4 0,-2.108 1.364,-3.079 3.079,-3.079 2.151,0 3.738,1.407 4.217,2.946 C -0.046,-3.738 0,-3.296 0,-2.946 Z m 5.276,-5.32 c 0,-1.934 0.089,-3.818 0.311,-5.144 H 0.704 L 0.353,-8.088 H 0.216 c -1.317,-1.672 -3.559,-2.858 -6.327,-2.858 -4.312,0 -6.729,3.116 -6.729,6.377 0,5.409 4.79,8.133 12.707,8.09 v 0.352 c 0,1.408 -0.573,3.739 -4.351,3.739 -2.113,0 -4.315,-0.663 -5.763,-1.586 l -1.057,3.517 c 1.583,0.971 4.356,1.897 7.742,1.897 6.861,0 8.838,-4.358 8.838,-9.018 z"
style="fill:#003a70;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path28"
inkscape:connector-curvature="0" />
</g>
<g
id="g30"
transform="translate(438.7279,763.6027)">
<path
d="m 0,0 c 0,2.465 -0.049,4.533 -0.177,6.378 h 4.745 l 0.265,-3.21 h 0.13 c 0.923,1.668 3.257,3.697 6.816,3.697 3.742,0 7.611,-2.42 7.611,-9.193 v -12.711 h -5.411 v 12.093 c 0,3.079 -1.144,5.411 -4.089,5.411 C 7.735,2.465 6.243,0.928 5.668,-0.701 5.496,-1.187 5.449,-1.848 5.449,-2.457 V -15.039 H 0 Z"
style="fill:#003a70;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path32"
inkscape:connector-curvature="0" />
</g>
<g
id="g34"
transform="translate(476.1589,761.0557)">
<path
d="m 0,0 c 0,0.437 -0.045,0.965 -0.131,1.405 -0.485,2.111 -2.198,3.825 -4.662,3.825 -3.474,0 -5.408,-3.08 -5.408,-7.081 0,-3.914 1.934,-6.776 5.366,-6.776 2.196,0 4.133,1.497 4.659,3.828 C -0.045,-4.314 0,-3.782 0,-3.211 Z M 5.408,17.53 V -6.466 c 0,-2.198 0.09,-4.575 0.176,-6.024 H 0.747 l -0.216,3.383 h -0.09 c -1.275,-2.371 -3.876,-3.867 -6.994,-3.867 -5.1,0 -9.147,4.347 -9.147,10.946 -0.043,7.171 4.444,11.44 9.589,11.44 2.944,0 5.057,-1.233 6.023,-2.822 H 0 v 10.94 z"
style="fill:#003a70;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path36"
inkscape:connector-curvature="0" />
</g>
<path
d="m 485.391,769.981 h 5.461 v -21.415 h -5.461 z"
style="fill:#003a70;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path38"
inkscape:connector-curvature="0" />
<g
id="g40"
transform="translate(494.6713,763.0753)">
<path
d="M 0,0 C 0,2.902 -0.044,4.972 -0.177,6.904 H 4.53 L 4.704,2.818 h 0.179 c 1.054,3.034 3.562,4.086 5.849,4.086 0.527,0 0.835,0.091 1.275,0 V 2.153 C 11.567,2.243 11.084,2.331 10.421,2.331 7.829,2.331 6.07,0.661 5.588,-1.757 5.5,-2.24 5.413,-2.816 5.413,-3.434 V -14.51 H 0 Z"
style="fill:#003a70;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path42"
inkscape:connector-curvature="0" />
</g>
<path
d="m 509.185,769.981 h 5.45 v -21.415 h -5.45 z"
style="fill:#003a70;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path44"
inkscape:connector-curvature="0" />
<g
id="g46"
transform="translate(548.2855,792.6043)">
<path
d="m 0,0 c -2.691,3.065 -5.547,1.681 -7.827,0.551 -0.955,-0.472 -4.422,-2.78 -7.858,-4.389 -2.448,-1.147 -5.962,-0.8 -7.889,1.58 -0.117,0.144 -3.232,3.918 -3.562,4.383 -2.023,2.537 -7.14,4.641 -13.254,1.142 -3.276,-1.896 -10.996,-6.326 -13.882,-7.974 -1.754,-0.895 -5.826,-1.286 -8.126,1.821 -0.038,0.051 -3.06,3.822 -3.181,3.967 -0.089,0.102 -2.041,2.997 -6.392,3.086 -0.64,0.016 -3.836,0.035 -6.959,-1.738 -4.141,-2.369 -13.779,-7.879 -13.779,-7.879 -0.007,0 -0.007,-0.005 -0.007,-0.005 -3.962,-2.268 -7.052,-4.029 -7.052,-4.029 l 3.648,-4.484 c 1.708,-2.117 5.554,-3.758 8.892,-1.842 0,0 12.33,7.14 12.373,7.16 5.334,2.927 9.45,2.927 12.177,1.837 2.453,-1.033 4.585,-3.613 4.585,-3.613 0,0 2.789,-3.456 3.279,-4.06 1.586,-1.952 4.209,-1.186 4.209,-1.186 0,0 0.969,0.112 2.441,0.982 0,0 11.94,6.923 11.947,6.925 3.794,2.225 7.274,2.64 9.049,2.478 5.567,-0.507 7.298,-4.396 9.714,-7.108 1.421,-1.599 2.702,-2.503 4.663,-2.457 1.291,0.029 2.746,0.808 2.961,0.948 l 14.3,8.255 c 0,0 -1.465,2.208 -4.47,5.649"
style="fill:#ffb700;fill-opacity:1;fill-rule:evenodd;stroke:none"
id="path48"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -0,0 +1,151 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200"
height="57.930748"
viewBox="0 0 52.916666 15.327511"
version="1.1"
id="svg5028"
sodipodi:docname="Bank_Negara_Indonesia_2004.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs5022">
<clipPath
id="clipPath112"
clipPathUnits="userSpaceOnUse">
<path
inkscape:connector-curvature="0"
id="path114"
d="M 0,0 H 595.276 V 841.89 H 0 Z" />
</clipPath>
<clipPath
id="clipPath78"
clipPathUnits="userSpaceOnUse">
<path
inkscape:connector-curvature="0"
id="path80"
d="M 0,0 H 595.276 V 841.89 H 0 Z" />
</clipPath>
<clipPath
id="clipPath56"
clipPathUnits="userSpaceOnUse">
<path
inkscape:connector-curvature="0"
id="path58"
d="M 0,0 H 595.276 V 841.89 H 0 Z" />
</clipPath>
<clipPath
id="clipPath40"
clipPathUnits="userSpaceOnUse">
<path
inkscape:connector-curvature="0"
id="path42"
d="M 0,0 H 595.276 V 841.89 H 0 Z" />
</clipPath>
<clipPath
id="clipPath26"
clipPathUnits="userSpaceOnUse">
<path
inkscape:connector-curvature="0"
id="path28"
d="M 0,0 H 595.276 V 841.89 H 0 Z" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.7"
inkscape:cx="674.28571"
inkscape:cy="155"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1094"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<metadata
id="metadata5025">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-7.903e-7,-220.36267)">
<path
d="M 14.507339,234.87054 H 1.779604e-4 V 220.36267 H 14.507339 Z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0530009"
id="path176"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path188"
style="fill:#f15a22;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.355742"
d="m 5.8338147,230.15493 1.5464108,-1.2483 c -0.259336,-0.3255 -0.5055096,-0.65385 -0.7374534,-0.99287 -1.788671,-2.58803 -2.8484265,-5.22087 -2.0832253,-7.55098 H 7.903e-7 v 2.51474 z" />
<path
inkscape:connector-curvature="0"
id="path192"
style="fill:#f15a22;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.355742"
d="m 8.2722841,226.39438 c 0.096762,-0.82141 0.3457812,-2.37066 1.8523489,-3.52149 1.304506,-0.99323 2.937006,-1.18426 4.382742,-0.57025 v -1.93986 H 6.4583553 c -0.5311228,2.36034 0.8640974,4.95406 1.8139288,6.0316" />
<path
inkscape:connector-curvature="0"
id="path196"
style="fill:#f15a22;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.355742"
d="m 10.22481,224.02724 -0.0057,0.004 c -1.5147497,1.23087 -1.1422877,3.16255 0.267874,4.89466 1.08608,1.33687 2.539287,2.27532 4.020241,1.56384 v -5.70326 c -1.330476,-1.36285 -2.927402,-1.86195 -4.282423,-0.75951" />
<path
inkscape:connector-curvature="0"
id="path200"
style="fill:#f15a22;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.355742"
d="m 6.9788481e-5,225.8523 v 9.01664 H 0.00220424 l 4.38914556,-3.5503 z" />
<path
inkscape:connector-curvature="0"
id="path212"
style="fill:#f15a22;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.355742"
d="m 7.9827457,229.62178 c -0.4311594,0.35432 -0.9637052,0.78797 -1.5545928,1.27071 l 0.5702546,0.71006 c 0.8242542,1.03094 2.0305756,2.81713 3.5243365,2.33367 l 0.0555,0.0679 -1.0615345,0.86481 h 4.9907055 v -3.43007 c -2.567746,1.39522 -4.7754815,0.12522 -6.5246653,-1.81713" />
<path
inkscape:connector-curvature="0"
id="path216"
style="fill:#f15a22;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.355742"
d="m 5.5741585,232.79262 -0.5908876,-0.73497 c -1.2113015,0.97225 -2.4827237,1.99145 -3.5147314,2.81143 h 5.584083 c -0.3625012,-0.76947 -1.0213354,-1.50657 -1.478464,-2.07646" />
<path
inkscape:connector-curvature="0"
id="path220"
style="fill:#006885;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.355742"
d="m 26.590332,226.42266 c 1.336878,-0.23372 2.207024,-1.3881 2.207024,-2.86123 0,-1.96298 -1.699024,-3.19848 -4.60686,-3.19848 h -5.754839 v 0.0669 c 1.175727,0.64461 1.148691,1.68515 1.148691,2.6791 v 8.99316 c 0,1.01101 0.02704,2.06223 -1.148691,2.69652 v 0.0655 h 2.771231 c 2.707196,0 4.783662,0.0462 6.333276,-0.68232 1.646374,-0.77373 2.643874,-2.1622 2.643874,-3.85517 0,-2.16078 -1.607598,-3.48627 -3.593706,-3.90392 m -4.749156,-4.72212 1.821398,0.0299 c 1.350754,0 2.731744,0.66132 2.731744,2.18247 0,1.80753 -1.22233,2.29063 -2.963686,2.29063 l -1.589456,0.002 v -0.002 z m 1.755586,11.67332 -1.755586,0.002 v -5.71748 -0.0811 l 1.887566,-0.009 c 2.196708,0 4.009926,0.81572 4.009926,2.95444 0,2.09425 -1.903576,2.8502 -4.141906,2.8502" />
<path
inkscape:connector-curvature="0"
id="path224"
style="fill:#006885;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.355742"
d="m 34.596058,232.10283 c 0,1.23727 -0.02241,2.1156 1.1462,2.80254 v 0.0623 h -3.901066 v -0.0623 c 1.16719,-0.68694 1.146912,-1.56527 1.146912,-2.80254 v -8.81208 c 0,-1.22803 0.02028,-2.20169 -1.14549,-2.85981 v -0.0679 h 3.23832 v 0.0217 c 0.08609,0.26289 0.229454,0.41052 0.329418,0.55282 0.103876,0.13732 0.308072,0.43863 0.308072,0.43863 l 7.428606,10.01201 v -8.18314 c 0,-1.23371 0.0192,-2.11595 -1.14549,-2.77407 v -0.0679 h 3.877588 v 0.0679 c -1.145132,0.65812 -1.145132,1.54036 -1.145132,2.77407 v 12.48512 c -0.708284,-0.28103 -2.683008,-1.92527 -4.089256,-3.81711 -2.524346,-3.39022 -6.048682,-8.10522 -6.048682,-8.10522 z" />
<path
inkscape:connector-curvature="0"
id="path228"
style="fill:#006885;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.355742"
d="m 49.496208,222.99167 c 0,-1.01351 -0.01744,-1.91709 -1.14371,-2.56063 v -0.0683 h 4.56417 v 0.0683 c -1.130904,0.62397 -1.140864,1.56705 -1.140864,2.56063 v 9.26886 c 0,0.99039 -0.02633,1.99429 1.140864,2.63997 v 0.0654 h -4.56417 v -0.0654 c 1.173948,-0.65493 1.14371,-1.64104 1.14371,-2.63392 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="1084pt" height="166pt" viewBox="0 0 1084 166" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g id="#790008ff">
<path fill="#790008" opacity="1.00" d=" M 0.00 0.00 L 165.25 0.00 C 165.25 55.08 165.25 110.16 165.24 165.24 C 110.16 165.25 55.08 165.24 0.00 165.25 L 0.00 0.00 M 22.01 31.02 C 34.87 48.24 47.92 65.31 60.84 82.48 C 48.03 99.72 34.90 116.72 22.07 133.95 C 51.40 134.07 80.72 133.95 110.05 134.01 C 123.25 116.93 135.97 99.48 149.26 82.46 L 149.01 82.47 C 136.06 65.30 123.05 48.17 110.12 31.00 C 80.75 31.02 51.38 30.97 22.01 31.02 Z" />
<path fill="#790008" opacity="1.00" d=" M 221.25 48.29 C 231.09 36.02 246.22 28.14 261.94 27.25 C 276.83 26.10 291.98 31.33 303.17 41.18 C 298.12 46.27 293.06 51.33 287.97 56.38 C 279.36 49.40 267.40 46.65 256.68 49.71 C 244.72 52.85 235.12 63.07 232.45 75.11 C 229.19 88.18 233.90 103.03 244.65 111.31 C 256.93 121.36 275.98 120.85 288.05 110.70 C 293.11 115.77 298.28 120.75 303.21 125.96 C 293.22 134.64 280.23 139.86 266.97 140.02 C 250.26 140.48 233.52 133.13 222.61 120.46 C 214.98 111.77 210.12 100.67 209.03 89.15 C 207.50 74.63 211.97 59.58 221.25 48.29 Z" />
<path fill="#790008" opacity="1.00" d=" M 323.25 30.25 C 330.50 30.25 337.74 30.26 344.99 30.24 C 345.01 65.83 344.99 101.41 345.00 137.00 C 337.75 137.00 330.50 137.01 323.25 136.99 C 323.26 101.41 323.25 65.83 323.25 30.25 Z" />
<path fill="#790008" opacity="1.00" d=" M 371.00 30.00 C 378.05 29.98 385.10 30.04 392.16 29.97 C 404.73 52.58 416.98 75.39 429.71 97.92 C 442.05 75.24 454.57 52.67 466.91 29.99 C 474.02 30.01 481.13 29.99 488.25 30.00 C 488.21 65.60 488.32 101.19 488.19 136.79 C 480.95 136.68 473.72 136.81 466.48 136.72 C 466.53 115.65 466.48 94.59 466.51 73.53 C 454.04 94.82 442.16 116.47 429.47 137.62 C 417.35 116.19 404.90 94.95 392.73 73.55 C 392.77 94.62 392.74 115.68 392.75 136.75 C 385.50 136.75 378.26 136.74 371.01 136.76 C 370.98 101.17 371.01 65.59 371.00 30.00 Z" />
<path fill="#790008" opacity="1.00" d=" M 513.01 30.24 C 527.69 30.26 542.36 30.24 557.04 30.25 C 563.83 30.38 570.80 31.81 576.60 35.50 C 590.61 44.06 593.83 66.29 582.13 78.07 C 580.63 79.67 578.85 80.99 577.08 82.27 C 583.75 85.78 588.87 92.29 590.11 99.79 C 591.92 109.91 589.61 121.53 581.76 128.67 C 575.50 134.66 566.60 136.82 558.16 136.93 C 543.11 137.09 528.05 136.96 513.00 137.00 C 513.01 101.41 512.98 65.83 513.01 30.24 M 534.75 49.75 C 534.74 57.50 534.75 65.25 534.75 73.00 C 541.15 72.98 547.56 73.03 553.97 72.99 C 557.70 72.92 561.73 72.14 564.46 69.39 C 568.34 65.27 568.44 58.32 564.99 53.90 C 562.34 50.71 557.96 49.72 553.99 49.74 C 547.57 49.75 541.16 49.75 534.75 49.75 M 534.75 92.51 C 534.75 100.75 534.74 109.00 534.75 117.25 C 541.82 117.26 548.89 117.24 555.96 117.27 C 559.27 117.37 562.75 116.44 565.23 114.15 C 570.17 109.39 570.26 100.52 565.22 95.80 C 562.50 93.22 558.60 92.55 554.99 92.50 C 548.24 92.49 541.49 92.50 534.75 92.51 Z" />
<path fill="#790008" opacity="1.00" d=" M 635.24 30.01 C 638.87 30.05 642.50 29.88 646.13 30.06 C 647.79 31.35 648.72 33.37 650.05 34.99 C 669.97 62.20 689.73 89.53 709.76 116.66 C 709.74 87.77 709.76 58.89 709.75 30.00 C 713.50 30.00 717.25 30.00 721.00 30.00 C 721.00 65.50 721.00 101.00 721.00 136.50 C 717.21 136.50 713.42 136.49 709.64 136.52 C 688.60 107.58 667.57 78.63 646.50 49.71 C 646.50 78.64 646.50 107.57 646.50 136.49 C 642.72 136.48 638.94 136.57 635.18 136.41 C 635.35 100.95 635.21 65.48 635.24 30.01 Z" />
<path fill="#790008" opacity="1.00" d=" M 749.00 30.00 C 752.75 30.00 756.49 30.00 760.25 30.00 C 760.25 65.50 760.24 100.99 760.25 136.48 C 756.50 136.51 752.75 136.49 749.01 136.50 C 748.98 101.00 749.01 65.50 749.00 30.00 Z" />
<path fill="#790008" opacity="1.00" d=" M 777.57 137.22 C 790.54 101.71 803.52 66.20 816.58 30.73 C 821.78 30.78 826.97 30.74 832.17 30.75 C 844.54 66.00 856.89 101.27 869.30 136.50 C 864.88 136.49 860.46 136.51 856.05 136.49 C 852.76 127.06 849.42 117.64 846.03 108.24 C 831.34 108.25 816.66 108.25 801.98 108.25 C 798.70 117.90 795.55 127.61 792.26 137.26 C 787.36 137.22 782.46 137.28 777.57 137.22 M 806.48 95.48 C 818.17 95.53 829.87 95.49 841.56 95.50 C 835.70 78.42 829.92 61.31 824.16 44.20 C 818.29 61.30 812.30 78.36 806.48 95.48 Z" />
<path fill="#790008" opacity="1.00" d=" M 894.27 48.18 C 903.15 39.04 915.03 32.47 927.84 31.29 C 943.63 29.70 959.35 35.56 972.12 44.55 C 969.63 47.78 967.01 50.90 964.54 54.14 C 960.29 48.09 952.76 45.83 945.97 44.00 C 934.16 40.75 920.76 42.51 910.79 49.84 C 901.78 56.64 895.76 66.97 893.37 77.92 C 892.02 89.24 893.14 101.60 899.86 111.18 C 902.17 114.80 905.85 117.13 909.00 119.94 C 915.68 125.39 924.44 127.86 932.98 127.78 C 943.49 127.88 953.89 125.19 963.50 121.06 C 963.49 110.12 963.52 99.18 963.49 88.24 C 955.16 88.25 946.83 88.25 938.50 88.24 C 938.50 84.92 938.50 81.60 938.49 78.28 C 950.32 78.21 962.15 78.28 973.99 78.24 C 974.01 95.16 973.99 112.07 974.00 128.99 C 962.71 135.29 949.77 137.93 937.03 139.20 C 923.54 139.48 909.45 136.02 898.95 127.21 C 888.69 118.78 882.70 105.95 880.94 92.95 C 879.59 77.13 883.61 60.28 894.27 48.18 Z" />
<path fill="#790008" opacity="1.00" d=" M 992.04 137.11 C 1005.07 101.68 1018.04 66.23 1030.97 30.76 C 1036.17 30.73 1041.37 30.79 1046.58 30.71 C 1058.53 64.36 1070.21 98.11 1082.12 131.78 C 1082.68 133.29 1082.99 134.87 1083.12 136.49 C 1078.93 136.51 1074.76 136.49 1070.59 136.51 C 1067.20 127.11 1063.90 117.67 1060.56 108.26 C 1045.82 108.24 1031.08 108.23 1016.35 108.27 C 1013.19 117.94 1009.91 127.57 1006.73 137.23 C 1001.83 137.20 996.93 137.39 992.04 137.11 M 1038.58 44.27 C 1032.78 61.38 1026.73 78.40 1020.98 95.53 C 1032.63 95.47 1044.29 95.51 1055.94 95.51 C 1050.27 78.39 1044.39 61.34 1038.58 44.27 Z" />
</g>
<g id="#ffffffff">
<path fill="#ffffff" opacity="1.00" d=" M 22.01 31.02 C 51.38 30.97 80.75 31.02 110.12 31.00 C 123.05 48.17 136.06 65.30 149.01 82.47 C 119.62 82.53 90.23 82.50 60.84 82.48 C 47.92 65.31 34.87 48.24 22.01 31.02 Z" />
</g>
<g id="#ed3024ff">
<path fill="#ed3024" opacity="1.00" d=" M 60.84 82.48 C 90.23 82.50 119.62 82.53 149.01 82.47 L 149.26 82.46 C 135.97 99.48 123.25 116.93 110.05 134.01 C 80.72 133.95 51.40 134.07 22.07 133.95 C 34.90 116.72 48.03 99.72 60.84 82.48 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
public/logos/Cifo_cpay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 KiB

View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="63" height="16" viewBox="0 0 63 16">
<g fill="none" fill-rule="evenodd">
<path fill="#FFF" fill-opacity=".01" d="M0 0h63v16H0z"/>
<g transform="translate(0 1.143)">
<ellipse cx="6.811" cy="6.857" fill="#00AED6" fill-rule="nonzero" rx="6.811" ry="6.857"/>
<path fill="#FFF" d="M10.778 6.644a1.587 1.587 0 0 0-1.652-1.5H4.824a.285.285 0 0 1-.284-.286c0-.158.127-.286.284-.286h4.359a1.362 1.362 0 0 0-.993-1.26 10.97 10.97 0 0 0-3.84 0 1.82 1.82 0 0 0-1.362 1.526 13.711 13.711 0 0 0 0 4.06 1.92 1.92 0 0 0 1.552 1.526 19.13 19.13 0 0 0 4.748 0 1.669 1.669 0 0 0 1.317-1.44c.14-.772.199-1.556.173-2.34zm-1.413.96v.254a.285.285 0 0 1-.284.286.285.285 0 0 1-.284-.286v-.254a.427.427 0 0 1 .284-.746.427.427 0 0 1 .284.746z"/>
</g>
<g fill="#000" fill-rule="nonzero">
<path d="M18.937 11.414a2.921 2.921 0 0 0 2.545 1.252c1.187 0 2.059-.763 2.059-1.8v-.547h-.029c-.65.64-1.537.974-2.444.922a3.955 3.955 0 0 1-3.513-1.94 4.012 4.012 0 0 1-.037-4.033 3.956 3.956 0 0 1 3.478-2.002 3.39 3.39 0 0 1 2.516.892h.029V3.41h2.03v7.428c0 2.159-1.7 3.656-4.089 3.656a4.87 4.87 0 0 1-4.06-1.814l1.515-1.266zm4.519-4.622c0-.863-.973-1.655-2.059-1.655-1.373 0-2.288.835-2.288 2.087-.04.594.18 1.175.605 1.588a1.995 1.995 0 0 0 1.597.557c1.187 0 2.145-.748 2.145-1.684v-.893zM30.916 3.194c2.474 0 4.276 1.77 4.276 4.03 0 2.26-1.802 4.031-4.276 4.031a4.005 4.005 0 0 1-3.692-1.935 4.063 4.063 0 0 1 0-4.191 4.005 4.005 0 0 1 3.692-1.935zm0 1.87a2.152 2.152 0 0 0-2.13 2.17 2.152 2.152 0 0 0 2.15 2.15 2.152 2.152 0 0 0 2.14-2.16 2.075 2.075 0 0 0-.605-1.562 2.045 2.045 0 0 0-1.555-.597zM36.29 3.41h2.03v.676h.03a3.359 3.359 0 0 1 2.444-.892c2.18.04 3.928 1.828 3.932 4.023.004 2.196-1.738 3.99-3.918 4.038-.86.02-1.7-.265-2.373-.806h-.029v3.829H36.29V3.41zm4.176 1.67c-1.116 0-2.06.791-2.06 1.655v.964c0 .922.916 1.684 2.073 1.684a2.145 2.145 0 0 0 2.131-2.158 2.145 2.145 0 0 0-2.144-2.146zM48.803 6.49c1.387-.187 1.802-.388 1.802-.777 0-.504-.53-.806-1.344-.806a1.79 1.79 0 0 0-1.888 1.367l-2.002-.417c.286-1.555 1.874-2.663 3.832-2.663 2.216 0 3.59 1.137 3.59 2.993v4.852H50.89v-.835h-.03a3.117 3.117 0 0 1-2.559 1.051c-1.673 0-2.83-.921-2.83-2.275 0-1.425.943-2.159 3.331-2.49zm1.973.806h-.028c-.187.274-.587.432-1.616.62-1.244.23-1.687.474-1.687.92 0 .461.372.663 1.172.663 1.216 0 2.16-.562 2.16-1.296v-.907zM56.82 10.622L53.317 3.41h2.331l2.302 4.98h.028l2.274-4.98h2.345L57.35 14.278h-2.331z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

150
public/logos/Logo_QRIS.svg Normal file
View File

@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="45.938mm"
height="17.413mm"
viewBox="0 0 45.938003 17.413"
version="1.1"
id="svg845"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="Logo QRIS.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs839">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath96">
<path
d="M -1.2207e-4,2.4414e-4 H 959.99988 V 540.00024 H -1.2207e-4 Z"
clip-rule="evenodd"
id="path94" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath106">
<path
d="M 0,0 H 130.46 V 23.577 H 0 Z"
id="path104" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="4"
inkscape:cx="81.75"
inkscape:cy="51.875"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="1920"
inkscape:window-height="991"
inkscape:window-x="-9"
inkscape:window-y="-9"
inkscape:window-maximized="1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<metadata
id="metadata842">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-31.991243,-118.40059)">
<g
id="g90"
transform="matrix(0.7313755,0,0,-0.7313755,-550.54458,504.98281)"
style="fill:#000000;stroke-width:0.999487;stroke-dasharray:none">
<g
id="g92"
clip-path="url(#clipPath96)"
style="fill:#000000;stroke-width:0.999487;stroke-dasharray:none">
<g
id="g98"
transform="matrix(2.1033,-7.54e-7,-7.54e-7,0.375,795.24,502.8)"
style="fill:#000000;stroke-width:1.12541;stroke-dasharray:none">
<g
id="g100"
transform="scale(0.55189,3.0538)"
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none">
<g
id="g102"
clip-path="url(#clipPath106)"
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none">
<g
id="g108"
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none">
<g
id="g110"
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none">
<g
id="g112"
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none" />
</g>
<path
d="M 18.813,19.108 H 32.767 V 10.622 H 26.913 L 32.767,5.1912 H 27.794 L 22.053,10.622 V 5.1912 h -3.24 v 8.6268 h 10.231 v 2.065 H 18.813 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
id="path114" />
<path
d="m 34.216,5.1912 h 3.4103 v 13.945 H 34.216 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
id="path116" />
<path
d="M 6.9058,8.5572 V 19.108 H 4.2344 c -0.4831,0 -0.8526,-0.368 -0.8526,-0.82 0,-2.49 -0.0284,-9.8157 -0.0284,-12.2482 0,-0.4526 0.3695,-0.8486 0.8242,-0.8486 1.8472,0 7.1614,0 7.9284,0 v 3.366 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
id="path118" />
<path
d="m 13.868,1.712 c 0.313,0 2.7,0 3.496,0 0,0 0,6.8735 0,6.9866 -1.137,0 -2.33,0 -3.496,0 0,-2.3194 0,-4.6388 0,-6.9866 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
id="path120" />
<path
d="m 8.6109,19.108 c 0,-0.057 0,-3.479 0,-3.479 1.5631,0 3.5521,0 5.2571,0 0,-2.405 0,-5.205 0,-5.205 0.796,0 3.183,0 3.496,0 v 7.835 c 0,0.453 -0.37,0.821 -0.824,0.849 -1.705,0 -5.741,0 -7.9291,0 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
id="path122" />
<path
d="m 8.6109,13.932 c 0,-1.16 0,-2.32 0,-3.536 0.2842,0 0.5968,0 0.881,0 0.7961,0 1.7901,0 2.5861,0 0,0 0,3.479 0,3.536 -1.137,0 -2.3019,0 -3.4671,0 z m 2.4441,-2.461 v 0 c -0.341,0 -0.739,0 -1.052,0 -0.1132,0 -0.2269,0 -0.369,0 0,0.481 0,0.933 0,1.414 0.483,0 0.938,0 1.421,0 0,-0.028 0,-1.414 0,-1.414 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
id="path124" />
<path
d="M 54.365,8.6986 V 3.494 c 0,-0.4525 -0.398,-0.8203 -0.852,-0.8203 H 48.284 V 1.8252 h 6.081 0.029 c 0.426,0 0.795,0.3677 0.795,0.8203 v 0.0282 6.0249 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
id="path126" />
<path
d="m 1.9325,15.657 v 5.205 c 0,0.452 0.341,0.792 0.7957,0.792 h 5.2291 c 0.0284,0 0.0284,0.028 0.0284,0.028 v 0.792 c 0,0 0,0.028 -0.0284,0.028 H 1.9325 c -0.4831,0 -0.8526,-0.368 -0.8526,-0.848 v -5.997 c 0,-0.028 0.0284,-0.028 0.0284,-0.028 h 0.7958 c 0,0 0.0284,0 0.0284,0.028 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
id="path128" />
<path
d="m 52.887,15.657 v 3.451 H 39.104 v -5.176 -3.48 h 9.18 V 8.7269 h -9.18 V 5.2478 h 13.783 v 8.6552 h -9.179 v 1.754 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
id="path130" />
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="24" viewBox="0 0 80 24">
<rect width="80" height="24" rx="4" fill="#0B63A9"/>
<text x="40" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">BCA</text>
</svg>

Before

Width:  |  Height:  |  Size: 272 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="24" viewBox="0 0 80 24">
<rect width="80" height="24" rx="4" fill="#F36E21"/>
<text x="40" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">BNI</text>
</svg>

Before

Width:  |  Height:  |  Size: 272 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="24" viewBox="0 0 80 24">
<rect width="80" height="24" rx="4" fill="#003A8E"/>
<text x="40" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">BRI</text>
</svg>

Before

Width:  |  Height:  |  Size: 272 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="24" viewBox="0 0 80 24">
<rect width="80" height="24" rx="4" fill="#C10E1A"/>
<text x="40" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">CIMB</text>
</svg>

Before

Width:  |  Height:  |  Size: 273 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="24" viewBox="0 0 72 24">
<rect width="72" height="24" rx="4" fill="#00AED6"/>
<text x="36" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">GoPay</text>
</svg>

Before

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="24" viewBox="0 0 80 24">
<rect width="80" height="24" rx="4" fill="#1E2A4A"/>
<text x="40" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">Mandiri</text>
</svg>

Before

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="24" viewBox="0 0 80 24">
<rect width="80" height="24" rx="4" fill="#009B4C"/>
<text x="40" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">Permata</text>
</svg>

Before

Width:  |  Height:  |  Size: 276 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="24" viewBox="0 0 72 24">
<rect width="72" height="24" rx="4" fill="#000000"/>
<text x="36" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">QRIS</text>
</svg>

Before

Width:  |  Height:  |  Size: 273 B

View File

@ -2,6 +2,8 @@ const express = require('express')
const cors = require('cors')
const dotenv = require('dotenv')
const midtransClient = require('midtrans-client')
const crypto = require('crypto')
const https = require('https')
dotenv.config()
@ -23,6 +25,22 @@ const core = new midtransClient.CoreApi({
clientKey,
})
// --- ERP Notification Config
function parseEnable(v) {
if (typeof v === 'string') {
const s = v.trim().toLowerCase()
return s === 'true' || s === '1' || s === 'yes' || s === 'on'
}
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v === 1
return true
}
const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || ''
const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF)
const ERP_CLIENT_ID = process.env.ERP_CLIENT_ID || ''
const ERP_MERCANT_ID = process.env.ERP_MERCANT_ID || process.env.ERP_MERCHANT_ID || ''
const notifiedOrders = new Set()
// --- Logger utilities
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }
@ -73,16 +91,6 @@ app.use((req, res, next) => {
next()
})
function parseEnable(v) {
if (typeof v === 'string') {
const s = v.trim().toLowerCase()
return s === 'true' || s === '1' || s === 'yes' || s === 'on'
}
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v === 1
return true
}
const ENABLE = {
bank_transfer: parseEnable(process.env.ENABLE_BANK_TRANSFER),
credit_card: parseEnable(process.env.ENABLE_CREDIT_CARD),
@ -172,6 +180,133 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
}
})
// --- Helpers: Midtrans signature verification & ERP notify
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
try {
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey)
return crypto.createHash('sha512').update(raw).digest('hex')
} catch {
return ''
}
}
function isSuccessfulMidtransStatus(body) {
const s = (body?.transaction_status || '').toLowerCase()
const fraud = (body?.fraud_status || '').toLowerCase()
// Success for most methods: settlement; Card: capture with fraud_status=accept also success
if (s === 'settlement') return true
if (s === 'capture' && fraud === 'accept') return true
return false
}
function postJson(url, data) {
return new Promise((resolve, reject) => {
try {
const u = new URL(url)
const body = JSON.stringify(data)
const opts = {
method: 'POST',
hostname: u.hostname,
path: u.pathname + (u.search || ''),
port: u.port || (u.protocol === 'https:' ? 443 : 80),
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}
const req = (u.protocol === 'https:' ? https : require('http')).request(opts, (res) => {
let chunks = ''
res.on('data', (d) => { chunks += d.toString() })
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) resolve({ status: res.statusCode, body: chunks })
else reject(new Error(`HTTP ${res.statusCode}: ${chunks}`))
})
})
req.on('error', reject)
req.write(body)
req.end()
} catch (e) {
reject(e)
}
})
}
function computeErpSignature(mercantId, statusCode, nominal, clientId) {
try {
const raw = String(mercantId) + String(statusCode) + String(nominal) + String(clientId)
return crypto.createHash('sha512').update(raw).digest('hex')
} catch {
return ''
}
}
async function notifyERP({ orderId, nominal }) {
if (!ERP_ENABLE_NOTIF) {
logInfo('erp.notify.skip', { reason: 'disabled' })
return
}
if (!ERP_NOTIFICATION_URL || !ERP_CLIENT_ID || !ERP_MERCANT_ID) {
logWarn('erp.notify.missing_config', { hasUrl: !!ERP_NOTIFICATION_URL, hasClientId: !!ERP_CLIENT_ID, hasMercantId: !!ERP_MERCANT_ID })
return
}
const statusCode = '200'
const signature = computeErpSignature(ERP_MERCANT_ID, statusCode, nominal, ERP_CLIENT_ID)
const payload = {
data: {
mercant_id: ERP_MERCANT_ID,
status_code: statusCode,
nominal: nominal,
client_id: ERP_CLIENT_ID,
},
signature,
}
logInfo('erp.notify.start', { orderId, url: ERP_NOTIFICATION_URL })
try {
const res = await postJson(ERP_NOTIFICATION_URL, payload)
logInfo('erp.notify.success', { orderId, status: res.status })
} catch (e) {
logError('erp.notify.error', { orderId, message: e?.message })
}
}
// Webhook endpoint for Midtrans notifications
app.post('/api/payments/webhook', async (req, res) => {
try {
const body = req.body || {}
const orderId = body?.order_id
const statusCode = body?.status_code
const grossAmount = body?.gross_amount
const signatureKey = (body?.signature_key || '').toLowerCase()
logInfo('webhook.receive', { order_id: orderId, transaction_status: body?.transaction_status })
// Verify signature
const expectedSig = computeMidtransSignature(orderId, statusCode, grossAmount, serverKey)
if (!expectedSig || signatureKey !== expectedSig) {
logWarn('webhook.signature.invalid', { order_id: orderId })
return res.status(401).json({ error: 'INVALID_SIGNATURE' })
}
// Acknowledge quickly
res.json({ ok: true })
// Process success callbacks asynchronously
if (isSuccessfulMidtransStatus(body)) {
const nominal = String(grossAmount)
if (!notifiedOrders.has(orderId)) {
notifiedOrders.add(orderId)
await notifyERP({ orderId, nominal })
} else {
logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
}
} else {
logInfo('webhook.non_success', { order_id: orderId, transaction_status: body?.transaction_status })
}
} catch (e) {
logError('webhook.error', { message: e?.message })
try { res.status(500).json({ error: 'WEBHOOK_ERROR' }) } catch {}
}
})
const port = process.env.PORT || 8000
app.listen(port, () => {
console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`)

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,10 +2,12 @@ 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 { type BankKey } from './PaymentLogos'
import { postCharge } from '../../../services/api'
import { Alert } from '../../../components/alert/Alert'
import { InlinePaymentStatus } from './InlinePaymentStatus'
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts
const attemptedChargeKeys = new Set<string>()
@ -126,54 +128,72 @@ 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>
<BankLogosRow compact />
{selected && (
<div className="flex items-center gap-2 text-base">
<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>
</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">
{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 className="inline-flex items-center gap-2">
{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>
)}
{billKey && (
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black dark:text-white">{billKey}</span></span>
)}
{billerCode && (
<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">
<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 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>
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">
{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>
)}
{billKey && (
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black dark:text-white">{billKey}</span></span>
)}
{billerCode && (
<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="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>
)}
{/* Status inline dengan polling otomatis */}
{selected && (
<InlinePaymentStatus orderId={orderId} method="bank_transfer" compact />
)}
{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 +279,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>
@ -267,12 +287,12 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
</Button>
)}
<Button
variant="secondary"
variant="outline"
className="w-full"
disabled={busy || (!locked && !vaCode && !billKey)}
onClick={() => nav.toStatus(orderId, 'bank_transfer')}
>
Cek Status Pembayaran
Buka halaman status
</Button>
</div>
<TrustStrip />

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

@ -4,6 +4,7 @@ import React from 'react'
import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus'
type StoreKey = 'alfamart' | 'indomaret'
@ -95,7 +96,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,19 +110,20 @@ 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"
variant="outline"
className="w-full"
disabled={busy || (!locked && !paymentCode)}
onClick={() => nav.toStatus(orderId, 'cstore')}
>
Cek Status Pembayaran
Buka halaman status
</Button>
<InlinePaymentStatus orderId={orderId} method="cstore" />
</div>
<TrustStrip />
</div>

View File

@ -8,6 +8,7 @@ import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtran
import { Logger } from '../../../lib/logger'
import { Env } from '../../../lib/env'
import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus'
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
const nav = usePaymentNavigation()
@ -145,14 +146,16 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
) : 'Bayar sekarang'}
</Button>
<Button
variant="secondary"
variant="outline"
className="w-full"
disabled={busy || (!charged && !locked)}
onClick={() => nav.toStatus(orderId, 'credit_card')}
>
Cek Status Pembayaran
Buka halaman status
</Button>
</div>
{/* Status inline dengan polling otomatis */}
<InlinePaymentStatus orderId={orderId} method="credit_card" compact />
<TrustStrip />
</div>
)

View File

@ -5,6 +5,7 @@ import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { GoPayLogosRow } from './PaymentLogos'
import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus'
// Global guards/tasks to stabilize QR generation across StrictMode remounts
const attemptedChargeKeys = new Set<string>()
@ -37,7 +38,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,56 +77,82 @@ 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>
)}
<div className="pt-2">
<Button
variant="secondary"
className="w-full"
aria-busy={busy}
disabled={busy || (!locked && actions.length === 0)}
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="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>
) : 'Cek Status Pembayaran'}
</Button>
<InlinePaymentStatus orderId={orderId} method={mode} />
<div className="mt-2">
<Button
variant="outline"
className="w-full"
aria-busy={busy}
disabled={busy || (!locked && actions.length === 0)}
onClick={() => { setBusy(true); onChargeInitiated?.(); setTimeout(() => { nav.toStatus(orderId, mode) ; setBusy(false) }, 250) }}
>
{busy ? (
<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>
) : 'Buka halaman status'}
</Button>
</div>
</div>
<TrustStrip />
</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

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

@ -6,13 +6,13 @@ function toProxy(url: string) {
return `https://images.weserv.nl/?url=${encodeURIComponent(url)}`
}
function BrandImg({ src, alt, compact = false, fallbackSrc }: { src: string; alt: string; compact?: boolean; fallbackSrc?: string }) {
const sizeClass = compact ? 'h-5' : 'h-6'
function BrandImg({ src, alt, compact = false, size, fallbackSrc }: { src: string; alt: string; compact?: boolean; size?: 'xs' | 'sm' | 'md'; fallbackSrc?: string }) {
const sizeClass = size ? (size === 'xs' ? 'h-4' : size === 'sm' ? 'h-5' : 'h-6') : (compact ? 'h-5' : 'h-6')
return (
<img
src={src}
alt={alt}
className={`${sizeClass} inline-block object-contain`}
className={`${sizeClass} inline-block object-contain max-w-full`}
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => {
@ -36,31 +36,32 @@ function BrandImg({ src, alt, compact = false, fallbackSrc }: { src: string; alt
}
const BANK_LOGOS: Record<BankKey, { alt: string; src: string; fb: string }> = {
bca: { alt: 'BCA', src: '/logos/bca.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/3/3f/Bank_Central_Asia.svg' },
bni: { alt: 'BNI', src: '/logos/bni.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/2/23/Bank_Negara_Indonesia_logo.svg' },
bri: { alt: 'BRI', src: '/logos/bri.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/4/42/BRI_2020.svg' },
cimb: { alt: 'CIMB Niaga', src: '/logos/cimb.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/2/2d/CIMB_Niaga_logo.svg' },
mandiri: { alt: 'Bank Mandiri', src: '/logos/mandiri.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/d/df/Bank_Mandiri_logo_2016.svg' },
permata: { alt: 'PermataBank', src: '/logos/permata.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/4/42/PermataBank_logo.svg' },
// Sumber lokal mengikuti file yang tersedia di public/logos/
bca: { alt: 'BCA', src: '/logos/1199px-Bank_Central_Asia.svg.png', fb: 'https://upload.wikimedia.org/wikipedia/commons/3/3f/Bank_Central_Asia.svg' },
bni: { alt: 'BNI', src: '/logos/Bank_Negara_Indonesia_logo_(2004).svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/2/23/Bank_Negara_Indonesia_logo.svg' },
bri: { alt: 'BRI', src: '/logos/BRI_2025.png', fb: 'https://upload.wikimedia.org/wikipedia/commons/4/42/BRI_2020.svg' },
cimb: { alt: 'CIMB Niaga', src: '/logos/CIMB_Niaga_logo.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/2/2d/CIMB_Niaga_logo.svg' },
mandiri: { alt: 'Bank Mandiri', src: '/logos/Bank_Mandiri_logo_2016.svg', fb: 'https://upload.wikimedia.org/wikipedia/commons/d/df/Bank_Mandiri_logo_2016.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]
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']
return (
<div className="flex items-center gap-2">
{all.map((k) => (
<BankLogo key={k} bank={k} compact={compact} />
<BankLogo key={k} bank={k} compact={compact} size={size} />
))}
</div>
)
}
export function CardLogosRow({ compact = false }: { compact?: boolean }) {
export function CardLogosRow({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
const logos = [
{ 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' },
@ -70,25 +71,48 @@ export function CardLogosRow({ compact = false }: { compact?: boolean }) {
return (
<div className="flex items-center gap-2">
{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>
)
}
export function LogoGoPay({ compact = false }: { compact?: boolean }) {
return <BrandImg src="/logos/gopay.svg" fallbackSrc="https://upload.wikimedia.org/wikipedia/commons/1/1e/Logo_GoPay.svg" alt="GoPay" compact={compact} />
export function LogoGoPay({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
// 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 }) {
return <BrandImg src="/logos/qris.svg" fallbackSrc="https://upload.wikimedia.org/wikipedia/commons/0/0a/QRIS_Logo.svg" alt="QRIS" compact={compact} />
export function LogoQRIS({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) {
// 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 (
<div className="flex items-center gap-2">
<LogoGoPay compact={compact} />
<LogoQRIS compact={compact} />
<LogoAlfamart compact={compact} size={size} />
<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>
)
}

View File

@ -1,8 +1,8 @@
import React from 'react'
import { BankLogosRow, CardLogosRow, GoPayLogosRow } from './PaymentLogos'
import { CardLogosRow, GoPayLogosRow, CStoreLogosRow, LogoCpay } from './PaymentLogos'
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 {
selected?: PaymentMethod
@ -13,43 +13,56 @@ export interface PaymentMethodListProps {
}
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: 'credit_card', title: 'Kartu kredit/debit', subtitle: 'Visa • MasterCard • JCB • Amex', icon: <CardLogosRow compact /> },
{ key: 'gopay', title: 'Gopay/QRIS', subtitle: 'Scan & bayar via QR', icon: <GoPayLogosRow compact /> },
{ key: 'cstore', title: 'Convenience Store', subtitle: 'Alfamart • Indomaret' },
{ 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 size="xs" /> },
{ key: 'gopay', title: 'Gopay/QRIS', subtitle: 'Scan & bayar via QR', icon: <GoPayLogosRow compact size="xs" /> },
{ 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) {
const enabledMap: Record<PaymentMethod, boolean> = enabled ?? {
bank_transfer: Env.ENABLE_BANK_TRANSFER,
credit_card: Env.ENABLE_CREDIT_CARD,
gopay: Env.ENABLE_GOPAY,
cstore: Env.ENABLE_CSTORE,
const enabledMap: Record<PaymentMethod, boolean> = {
bank_transfer: enabled?.bank_transfer ?? Env.ENABLE_BANK_TRANSFER,
credit_card: enabled?.credit_card ?? Env.ENABLE_CREDIT_CARD,
gopay: enabled?.gopay ?? Env.ENABLE_GOPAY,
cstore: enabled?.cstore ?? Env.ENABLE_CSTORE,
cpay: enabled?.cpay ?? Env.ENABLE_CPAY,
}
const items = baseItems.filter((it) => enabledMap[it.key])
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="flex-1">
<div className="text-base font-semibold text-black dark:text-white">{it.title}</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 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>
{it.icon && (
<span aria-hidden>
{it.icon}
</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) {
@ -24,27 +25,37 @@ export interface PaymentSheetProps {
amount: number
expireAt: number // epoch ms
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 [expanded, setExpanded] = React.useState(true)
return (
<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-3">
<div className="text-xs text-white/80">Bayar dalam <span className="font-semibold text-white">{countdown}</span></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>
<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 +76,18 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
{children}
<TrustStrip location="sheet" />
</div>
{/* Sticky CTA (mobile-friendly) */}
{showStatusCTA && (
<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>
)

View File

@ -18,4 +18,5 @@ export const Env = {
ENABLE_CREDIT_CARD: parseEnable(import.meta.env.VITE_ENABLE_CREDIT_CARD),
ENABLE_GOPAY: parseEnable(import.meta.env.VITE_ENABLE_GOPAY),
ENABLE_CSTORE: parseEnable(import.meta.env.VITE_ENABLE_CSTORE),
ENABLE_CPAY: parseEnable(import.meta.env.VITE_ENABLE_CPAY),
}

View File

@ -8,7 +8,7 @@ import { BankTransferPanel } from '../features/payments/components/BankTransferP
import { CardPanel } from '../features/payments/components/CardPanel'
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
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 { Logger } from '../lib/logger'
import React from 'react'
@ -72,7 +72,7 @@ export function CheckoutPage() {
</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) */}
{currentStep === 1 && (
<div className="space-y-3">
@ -142,6 +142,15 @@ export function CheckoutPage() {
setSelected(m)
if (m === 'bank_transfer' || m === 'cstore') {
// 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 {
setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
@ -161,6 +170,13 @@ export function CheckoutPage() {
if (m === 'bank_transfer') {
return (
<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={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
@ -172,11 +188,10 @@ export function CheckoutPage() {
setIsBusy(true)
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()}`}
>
<BankLogo bank={bk} />
<span className="text-xs text-black/70 dark:text-white/70">{bk.toUpperCase()}</span>
</button>
))}
</div>
@ -203,10 +218,10 @@ export function CheckoutPage() {
setIsBusy(true)
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()}`}
>
<span className="text-sm font-medium">{st.toUpperCase()}</span>
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
</button>
))}
</div>
@ -255,5 +270,6 @@ function defaultEnabled(): Record<PaymentMethod, boolean> {
credit_card: Env.ENABLE_CREDIT_CARD,
gopay: Env.ENABLE_GOPAY,
cstore: Env.ENABLE_CSTORE,
cpay: Env.ENABLE_CPAY,
}
}

View File

@ -85,6 +85,7 @@ export type RuntimeConfigResponse = {
credit_card: boolean
gopay: boolean
cstore: boolean
cpay?: boolean
}
midtransEnv?: 'production' | 'sandbox'
clientKey?: string
@ -105,6 +106,7 @@ export async function getRuntimeConfig(): Promise<RuntimeConfigResponse> {
credit_card: Env.ENABLE_CREDIT_CARD,
gopay: Env.ENABLE_GOPAY,
cstore: Env.ENABLE_CSTORE,
cpay: Env.ENABLE_CPAY,
},
midtransEnv: Env.MIDTRANS_ENV,
clientKey: Env.MIDTRANS_CLIENT_KEY,