diff --git a/docs/front-end-spec.md b/docs/front-end-spec.md new file mode 100644 index 0000000..d857c6a --- /dev/null +++ b/docs/front-end-spec.md @@ -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 `400–600` 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: `16–24px` + +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 `15–16px` 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 `12–16px` dan body `16–18px`. + - 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 `16–18px`, line-height `1.5`; jarak antar langkah `12–16px`. +- 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 `16–24px`; hindari konten menempel ke tepi. +- Target sentuh: + - Tinggi area tap minimal `44px`; jarak antar aksi `8–12px`. + - Baris pemilihan metode memakai `min-h-[44px]` dan `p-3`. +- Tipografi: + - Body `16px` (min); langkah instruksi `16–18px` 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 `12–16px`. + - 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. \ No newline at end of file diff --git a/docs/integration-midtrans.md b/docs/integration-midtrans.md index 555d869..b0029be 100644 --- a/docs/integration-midtrans.md +++ b/docs/integration-midtrans.md @@ -65,4 +65,28 @@ Pastikan `VITE_API_BASE_URL` menunjuk ke `http://localhost:8000/api` agar fronte ## Referensi - Midtrans Docs: Credit Card with 3DS (Core API). -- Contoh: `coreApiSimpleExample.js` untuk pola panggilan Core API. \ No newline at end of file +- 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=""` + - `ERP_MERCANT_ID=""` + - `ERP_ENABLE_NOTIF=true` + - Payload dikirim saat sukses: + ```json + { + "data": { + "mercant_id": "", + "status_code": "200", + "nominal": "", + "client_id": "" + }, + "signature": "sha512(mercant_id + status_code + nominal + client_id)" + } + ``` \ No newline at end of file diff --git a/docs/stories/midtrans-e2e-checkout-to-webhook.md b/docs/stories/midtrans-e2e-checkout-to-webhook.md new file mode 100644 index 0000000..9af3895 --- /dev/null +++ b/docs/stories/midtrans-e2e-checkout-to-webhook.md @@ -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=`). +- 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: , nominal: , mercant_id: , customer_name: }` + - `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": "" + } + ``` + - 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=""` + - 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 non‑Midtrans (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. \ No newline at end of file diff --git a/public/logos/1199px-Bank_Central_Asia.svg.png b/public/logos/1199px-Bank_Central_Asia.svg.png new file mode 100644 index 0000000..9f6c06e Binary files /dev/null and b/public/logos/1199px-Bank_Central_Asia.svg.png differ diff --git a/public/logos/ALFAMART_LOGO_BARU.png b/public/logos/ALFAMART_LOGO_BARU.png new file mode 100644 index 0000000..e5cf0d2 Binary files /dev/null and b/public/logos/ALFAMART_LOGO_BARU.png differ diff --git a/public/logos/BRI_2025.png b/public/logos/BRI_2025.png new file mode 100644 index 0000000..4121dd0 Binary files /dev/null and b/public/logos/BRI_2025.png differ diff --git a/public/logos/Bank_Mandiri_logo_2016.svg b/public/logos/Bank_Mandiri_logo_2016.svg new file mode 100644 index 0000000..95eae56 --- /dev/null +++ b/public/logos/Bank_Mandiri_logo_2016.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/Bank_Negara_Indonesia_logo_(2004).svg b/public/logos/Bank_Negara_Indonesia_logo_(2004).svg new file mode 100644 index 0000000..21e9360 --- /dev/null +++ b/public/logos/Bank_Negara_Indonesia_logo_(2004).svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/public/logos/CIMB_Niaga_logo.svg b/public/logos/CIMB_Niaga_logo.svg new file mode 100644 index 0000000..6d7878b --- /dev/null +++ b/public/logos/CIMB_Niaga_logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/Cifo_cpay.png b/public/logos/Cifo_cpay.png new file mode 100644 index 0000000..f032040 Binary files /dev/null and b/public/logos/Cifo_cpay.png differ diff --git a/public/logos/Gopay_logo.svg b/public/logos/Gopay_logo.svg new file mode 100644 index 0000000..c33e4f0 --- /dev/null +++ b/public/logos/Gopay_logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/Logo_Indomaret.png b/public/logos/Logo_Indomaret.png new file mode 100644 index 0000000..d44590d Binary files /dev/null and b/public/logos/Logo_Indomaret.png differ diff --git a/public/logos/Logo_QRIS.svg b/public/logos/Logo_QRIS.svg new file mode 100644 index 0000000..27c8279 --- /dev/null +++ b/public/logos/Logo_QRIS.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/bca.svg b/public/logos/bca.svg deleted file mode 100644 index 501da62..0000000 --- a/public/logos/bca.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - BCA - \ No newline at end of file diff --git a/public/logos/bni.svg b/public/logos/bni.svg deleted file mode 100644 index 6e1d86e..0000000 --- a/public/logos/bni.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - BNI - \ No newline at end of file diff --git a/public/logos/bri.svg b/public/logos/bri.svg deleted file mode 100644 index 2e73134..0000000 --- a/public/logos/bri.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - BRI - \ No newline at end of file diff --git a/public/logos/cimb.svg b/public/logos/cimb.svg deleted file mode 100644 index 41e300e..0000000 --- a/public/logos/cimb.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - CIMB - \ No newline at end of file diff --git a/public/logos/gopay.svg b/public/logos/gopay.svg deleted file mode 100644 index 81ab62e..0000000 --- a/public/logos/gopay.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - GoPay - \ No newline at end of file diff --git a/public/logos/logo-semua-bank.PNG b/public/logos/logo-semua-bank.PNG new file mode 100644 index 0000000..093f718 Binary files /dev/null and b/public/logos/logo-semua-bank.PNG differ diff --git a/public/logos/mandiri.svg b/public/logos/mandiri.svg deleted file mode 100644 index 21f6a4f..0000000 --- a/public/logos/mandiri.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - Mandiri - \ No newline at end of file diff --git a/public/logos/permata Bank.png b/public/logos/permata Bank.png new file mode 100644 index 0000000..f160267 Binary files /dev/null and b/public/logos/permata Bank.png differ diff --git a/public/logos/permata.svg b/public/logos/permata.svg deleted file mode 100644 index d002961..0000000 --- a/public/logos/permata.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - Permata - \ No newline at end of file diff --git a/public/logos/qris.svg b/public/logos/qris.svg deleted file mode 100644 index 4197460..0000000 --- a/public/logos/qris.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - QRIS - \ No newline at end of file diff --git a/server/index.cjs b/server/index.cjs index fab40ce..f923bbb 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -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})`) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e92b1af..0a8eb36 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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: { diff --git a/src/features/payments/components/BankTransferPanel.tsx b/src/features/payments/components/BankTransferPanel.tsx index ab16556..61cc295 100644 --- a/src/features/payments/components/BankTransferPanel.tsx +++ b/src/features/payments/components/BankTransferPanel.tsx @@ -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() @@ -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 (
Transfer bank
- {selected && (
Bank: - {selected.toUpperCase()}
)} -
VA dibuat otomatis sesuai bank pilihan Anda.
+
VA dibuat otomatis sesuai bank pilihan Anda.
{errorMessage && ( {errorMessage} )} - - {showGuide && } {selected && ( -
-
- {vaCode ? ( - VA: {vaCode} - ) : ( - - {busy && } - {busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'} - - )} - {billKey && ( - Bill Key: {billKey} - )} - {billerCode && ( - Biller Code: {billerCode} - )} -
-
- - +
+
+
Virtual Account
+
+ {vaCode ? ( + + Nomor VA: + {vaCode} + + ) : ( + + {busy && } + {busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'} + + )} + {billKey && ( + Bill Key: {billKey} + )} + {billerCode && ( + Biller Code: {billerCode} + )} +
+
+ + +
)} + {/* Status inline dengan polling otomatis */} + {selected && ( + + )} + {selected && ( +
+ {selected === 'bca' ? ( + + ) : ( +
+
Instruksi pembayaran
+ +
+ )} +
+ )} {locked && (
Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.
)} @@ -259,7 +279,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, className="w-full" > {busy ? ( - + Membuat VA… @@ -267,12 +287,12 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, )}
diff --git a/src/features/payments/components/BcaInstructionList.tsx b/src/features/payments/components/BcaInstructionList.tsx new file mode 100644 index 0000000..ea969de --- /dev/null +++ b/src/features/payments/components/BcaInstructionList.tsx @@ -0,0 +1,120 @@ +import React from 'react' + +type TabKey = 'mobile' | 'atm' | 'ib' + +export function BcaInstructionList() { + const [tab, setTab] = React.useState('mobile') + return ( +
+
+
Instruksi BCA
+
+ + + +
+ {tab === 'mobile' && ( +
+ +
+ )} + {tab === 'atm' && ( +
+ +
+ )} + {tab === 'ib' && ( +
+ +
+ )} +
+
+ Catatan: Gunakan label "Nomor VA" secara konsisten di semua metode. +
+
+ ) +} + +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 +} + +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 +} + +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 +} + +function StepList({ steps }: { steps: string[] }) { + return ( +
    + {steps.map((s, i) => ( +
  1. {s}
  2. + ))} +
+ ) +} \ No newline at end of file diff --git a/src/features/payments/components/CStorePanel.tsx b/src/features/payments/components/CStorePanel.tsx index cac1e82..8c524c2 100644 --- a/src/features/payments/components/CStorePanel.tsx +++ b/src/features/payments/components/CStorePanel.tsx @@ -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
Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.
)}
-
+
Kode Pembayaran
{!selected && (
Pilih toko terlebih dahulu di langkah sebelumnya.
@@ -109,19 +110,20 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul {selected && !busy && (storeFromRes || paymentCode) && ( <> {storeFromRes ?
Toko: {storeFromRes.toUpperCase()}
: null} - {paymentCode ?
Kode: {paymentCode}
: null} + {paymentCode ?
Kode: {paymentCode}
: null} )} -
+
+
diff --git a/src/features/payments/components/CardPanel.tsx b/src/features/payments/components/CardPanel.tsx index d0286e9..a7940c6 100644 --- a/src/features/payments/components/CardPanel.tsx +++ b/src/features/payments/components/CardPanel.tsx @@ -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'}
+ {/* Status inline dengan polling otomatis */} +
) diff --git a/src/features/payments/components/GoPayPanel.tsx b/src/features/payments/components/GoPayPanel.tsx index 7b321bd..bdc6b6e 100644 --- a/src/features/payments/components/GoPayPanel.tsx +++ b/src/features/payments/components/GoPayPanel.tsx @@ -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() @@ -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('') const [actions, setActions] = React.useState>([]) @@ -77,56 +77,82 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
Mode: -
- - +
+ +
Scan QR berikut menggunakan aplikasi {mode === 'gopay' ? 'GoPay' : 'QRIS'}
-
+
{mode === 'qris' && (!qrUrl || busy) ? ( Membuat QR… ) : qrUrl ? ( - QR untuk pembayaran + QR untuk pembayaran ) : null}
Mode: {mode.toUpperCase()}
-
- - +
+ + +
+
+
- - {showGuide && } {locked && (
Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.
)}
- + +
+ +
diff --git a/src/features/payments/components/InlinePaymentStatus.tsx b/src/features/payments/components/InlinePaymentStatus.tsx new file mode 100644 index 0000000..d70ff4c --- /dev/null +++ b/src/features/payments/components/InlinePaymentStatus.tsx @@ -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 ( +
+ {/* Header minimal tanpa detail teknis */} +
Status pembayaran
+ + {/* Konten berdasarkan status */} + {isLoading ? ( +
+ + + Mengecek pembayaran… + +
Kami memeriksa otomatis setiap 3 detik.
+
+ ) : error ? ( +
Gagal memuat status. Coba refresh.
+ ) : isSuccess ? ( +
+
+ + {/* check icon */} + + + + +
Pembayaran berhasil
+
+ {data?.grossAmount ? ( +
Total dibayar: {formatIDR(data.grossAmount)}
+ ) : null} +
Terima kasih! Pesanan Anda sedang diproses.
+
+ + + +
+
+ ) : isFailure ? ( +
+
+ + {/* x icon */} + + + + + +
Pembayaran belum berhasil
+
+
Silakan coba lagi atau pilih metode lain.
+
+ + +
+
+ ) : ( +
+
+ + {/* hourglass/spinner icon */} + + +
Menunggu pembayaran
+
+
Kami memeriksa otomatis setiap 3 detik sampai selesai.
+
+ + +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/features/payments/components/PaymentInstructions.tsx b/src/features/payments/components/PaymentInstructions.tsx index cf91fbc..cb432c7 100644 --- a/src/features/payments/components/PaymentInstructions.tsx +++ b/src/features/payments/components/PaymentInstructions.tsx @@ -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 ( -
-
Cara bayar
-
    - {steps.map((s, i) => ( +
    +
    {finalTitle}
    +
      + {finalSteps.map((s, i) => (
    1. {s}
    2. ))}
    diff --git a/src/features/payments/components/PaymentLogos.tsx b/src/features/payments/components/PaymentLogos.tsx index 8dfa875..f3a5c79 100644 --- a/src/features/payments/components/PaymentLogos.tsx +++ b/src/features/payments/components/PaymentLogos.tsx @@ -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 ( {alt} { @@ -36,31 +36,32 @@ function BrandImg({ src, alt, compact = false, fallbackSrc }: { src: string; alt } const BANK_LOGOS: Record = { - 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 + return } -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 (
    {all.map((k) => ( - + ))}
    ) } -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 (
    {logos.map((l) => ( - + ))}
    ) } -export function LogoGoPay({ compact = false }: { compact?: boolean }) { - return +export function LogoGoPay({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) { + // Gunakan file lokal yang tersedia di public/logos/ + return } -export function LogoQRIS({ compact = false }: { compact?: boolean }) { - return +export function LogoQRIS({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) { + // Gunakan file lokal yang tersedia di public/logos/ + return } -export function GoPayLogosRow({ compact = false }: { compact?: boolean }) { +export function LogoAlfamart({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) { + return +} + +export function LogoIndomaret({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) { + return +} + +export function LogoCpay({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) { + // Sumber lokal sesuai permintaan: public/logos/Cifo_cpay.png + return +} + +export function CStoreLogosRow({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) { return (
    - - + + +
    + ) +} +export function GoPayLogosRow({ compact = false, size }: { compact?: boolean; size?: 'xs' | 'sm' | 'md' }) { + return ( +
    + +
    ) } \ No newline at end of file diff --git a/src/features/payments/components/PaymentMethodList.tsx b/src/features/payments/components/PaymentMethodList.tsx index 15742cb..e62af20 100644 --- a/src/features/payments/components/PaymentMethodList.tsx +++ b/src/features/payments/components/PaymentMethodList.tsx @@ -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: }, - { key: 'credit_card', title: 'Kartu kredit/debit', subtitle: 'Visa • MasterCard • JCB • Amex', icon: }, - { key: 'gopay', title: 'Gopay/QRIS', subtitle: 'Scan & bayar via QR', icon: }, - { key: 'cstore', title: 'Convenience Store', subtitle: 'Alfamart • Indomaret' }, + { key: 'bank_transfer', title: 'Transfer bank', subtitle: 'BCA • BNI • BRI • CIMB • Mandiri • Permata', icon: Semua bank yang didukung }, + { key: 'credit_card', title: 'Kartu kredit/debit', subtitle: 'Visa • MasterCard • JCB • Amex', icon: }, + { key: 'gopay', title: 'Gopay/QRIS', subtitle: 'Scan & bayar via QR', icon: }, + { key: 'cstore', title: 'Convenience Store', subtitle: '', icon: }, + { key: 'cpay', title: 'cPay', subtitle: 'Bayar via aplikasi CIFO Token', icon: }, ] export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, enabled }: PaymentMethodListProps) { - const enabledMap: Record = enabled ?? { - bank_transfer: Env.ENABLE_BANK_TRANSFER, - credit_card: Env.ENABLE_CREDIT_CARD, - gopay: Env.ENABLE_GOPAY, - cstore: Env.ENABLE_CSTORE, + const enabledMap: Record = { + 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 (
    Metode pembayaran
    -
    +
    {items.map((it) => (
    {selected === it.key && renderPanel && ( diff --git a/src/features/payments/components/PaymentSheet.tsx b/src/features/payments/components/PaymentSheet.tsx index e5cd44b..2f8d2fe 100644 --- a/src/features/payments/components/PaymentSheet.tsx +++ b/src/features/payments/components/PaymentSheet.tsx @@ -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 (
    {/* Header */} -
    -
    -
    ZARA
    -
    {merchantName}
    +
    +
    +
    + ZARA +
    +
    {merchantName}
    -
    -
    Bayar dalam {countdown}
    +
    +
    + Kedaluwarsa dalam {countdown} +
    @@ -65,6 +76,18 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt, {children}
    + {/* Sticky CTA (mobile-friendly) */} + {showStatusCTA && ( +
    + + Cek status pembayaran + +
    + )}
    ) diff --git a/src/lib/env.ts b/src/lib/env.ts index d0b1baa..d71823a 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -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), } \ No newline at end of file diff --git a/src/pages/CheckoutPage.tsx b/src/pages/CheckoutPage.tsx index 2c91718..00bf98b 100644 --- a/src/pages/CheckoutPage.tsx +++ b/src/pages/CheckoutPage.tsx @@ -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() { )} - + {/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */} {currentStep === 1 && (
    @@ -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 (
    +
    + Logo semua bank yang didukung +
    Pilih bank untuk membuat Virtual Account
    {(['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()}`} > - {bk.toUpperCase()} ))}
    @@ -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()}`} > - {st.toUpperCase()} + {st === 'alfamart' ? : } ))}
    @@ -255,5 +270,6 @@ function defaultEnabled(): Record { credit_card: Env.ENABLE_CREDIT_CARD, gopay: Env.ENABLE_GOPAY, cstore: Env.ENABLE_CSTORE, + cpay: Env.ENABLE_CPAY, } } \ No newline at end of file diff --git a/src/services/api.ts b/src/services/api.ts index 05fcd22..ae99a41 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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 { 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,