feat/payments-ux-instructions-va #1
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
- 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)"
|
||||
}
|
||||
```
|
||||
|
|
@ -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 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.
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 987 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 119 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -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 |
|
After Width: | Height: | Size: 135 KiB |
|
|
@ -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 |
|
|
@ -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 |
155
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})`)
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||