Compare commits

...

30 Commits

Author SHA1 Message Date
root c5995f7b39 Merge pull request 'feat/payment-link-flow' (#13) from feat/payment-link-flow into main
Reviewed-on: #13
2025-11-24 13:36:36 +00:00
Tengku Achmad b98c50cea8 refactor(payments): remove indomaret support and update navigation 2025-11-24 20:22:01 +07:00
Tengku Achmad 5034ecf1e4 feat: replace demo store with initial page and improve payment UI 2025-11-24 09:53:20 +07:00
root 18513b5968 Merge pull request 'feat/payment-link-flow' (#12) from feat/payment-link-flow into main
Reviewed-on: #12
2025-11-22 05:01:17 +00:00
Tengku Achmad c6b17bfab6 feat: update favicon and page title for branding 2025-11-22 11:58:37 +07:00
Tengku Achmad ec96b71161 feat(payments): extend payment link expiration to 24 hours
Update default payment link TTL from 30 minutes to 24 hours across frontend and backend. Also modify countdown display to show hours in addition to minutes and seconds.
2025-11-22 11:54:46 +07:00
root 2494f3cedd Merge pull request 'feat(server): support multiple ERP notification URLs (ERP_NOTIFICATION_URLS)\n\n- Add env ERP_NOTIFICATION_URLS (comma-separated) with fallback to ERP_NOTIFICATION_URL\n- Update' (#11) from feat/payment-link-flow into main
Reviewed-on: #11
2025-11-17 06:40:53 +00:00
CIFO Dev 96c4cd3aba feat(server): support multiple ERP notification URLs (ERP_NOTIFICATION_URLS)\n\n- Add env ERP_NOTIFICATION_URLS (comma-separated) with fallback to ERP_NOTIFICATION_URL\n- Update
otifyERP to broadcast payload to all endpoints and aggregate results\n- Log per-endpoint result and summary via erp.notify.success and erp.notify.summary\n- Add dev endpoint /api/echo2 for local multi-URL testing\n\nThis ensures signature is included in body for all endpoints and improves visibility in logs.
2025-11-17 13:33:03 +07:00
root 9a4e14575b Merge pull request 'ERP: gunakan ERP_CLIENT_SECRET untuk signature; perbaiki fallback; tambah log detail HTTP request/response; endpoint GET /api/logs untuk akses log via browser; log payload ERP dengan signature length dan presence' (#10) from feat/payment-link-flow into main
Reviewed-on: #10
2025-11-17 06:08:55 +00:00
CIFO Dev 8c42768ec3 ERP: gunakan ERP_CLIENT_SECRET untuk signature; perbaiki fallback; tambah log detail HTTP request/response; endpoint GET /api/logs untuk akses log via browser; log payload ERP dengan signature length dan presence 2025-11-17 10:58:44 +07:00
root 11197bf3c7 Merge pull request 'refactor(erp): make computeErpSignature async and improve signature handling' (#9) from feat/payment-link-flow into main
Reviewed-on: #9
2025-11-14 10:47:32 +00:00
Tengku Achmad e1dbe911c9 refactor(erp): make computeErpSignature async and improve signature handling 2025-11-14 16:43:31 +07:00
root 33df1b326c Merge pull request 'refactor: remove unused Link import from AppLayout' (#8) from feat/payment-link-flow into main
Reviewed-on: #8
2025-11-14 08:47:56 +00:00
Tengku Achmad b234556f92 refactor: remove unused Link import from AppLayout 2025-11-14 15:47:17 +07:00
root e4c81dce78 Merge pull request 'feat: implement toast notifications and update UI components' (#7) from feat/payment-link-flow into main
Reviewed-on: #7
2025-11-14 08:45:08 +00:00
root f4e0ca4741 Merge pull request 'feat/payment-link-flow' (#6) from feat/payment-link-flow into main
Reviewed-on: #6
2025-11-14 08:42:08 +00:00
Tengku Achmad e1f989447b feat: implement toast notifications and update UI components
refactor: remove dark mode styles and simplify UI components
style: update color scheme and branding to new blue theme
feat(toast): add toast notification system for user feedback
fix: correct merchant name and update payment sheet styling
docs: update comments and remove unused code
2025-11-14 15:13:46 +07:00
CIFO Dev 9edcd6191a notify erp 2025-11-14 10:07:11 +07:00
CIFO Dev 80fb683dcc Remove dev-only status mock; always query Midtrans API. Add helper script to compute Midtrans webhook signature for local tests. 2025-11-14 10:04:53 +07:00
root d430e82d3f Merge pull request 'fixing merchant id' (#5) from feat/payment-link-flow into main
Reviewed-on: #5
2025-11-12 05:24:48 +00:00
CIFO Dev 6472e95310 fixing merchant id 2025-11-12 10:29:37 +07:00
root 9b8a62f1d8 Merge pull request 'feat/payment-link-flow' (#4) from feat/payment-link-flow into main
Reviewed-on: #4
2025-11-11 08:55:06 +00:00
CIFO Dev 4b43c61365 update merchat order id 2025-11-11 15:11:22 +07:00
CIFO Dev 92ec715632 QA: Update Postman Create Transaction body to ERP schema (mercant_id,timestamp,deskripsi,nominal,nama,no_telepon,email,item[]) 2025-11-11 13:53:20 +07:00
root ef4dcb8795 Merge pull request 'Align PayPage flow and update /createtransaksi schema' (#3) from feat/payment-link-flow into main
Reviewed-on: #3
2025-11-11 06:09:03 +00:00
CIFO Dev baed44ecd7 Align PayPage flow and update /createtransaksi schema 2025-11-11 10:19:50 +07:00
root 11ca3f5117 Merge pull request 'feat/payment-link-flow' (#2) from feat/payment-link-flow into main
Reviewed-on: #2
2025-11-10 10:02:10 +00:00
CIFO Dev 4d10f0e121 perbaikan flow 2025-11-10 16:59:31 +07:00
CIFO Dev a940fda6b2 payment link change 2025-11-10 16:39:10 +07:00
root b0ded99d34 Merge pull request 'feat/payments-ux-instructions-va' (#1) from feat/payments-ux-instructions-va into main
Reviewed-on: #1
2025-11-10 08:50:51 +00:00
37 changed files with 1590 additions and 248 deletions

310
docs/payment-link-e2e.md Normal file
View File

@ -0,0 +1,310 @@
# Panduan QA End-to-End: Payment Link
Dokumen ini menjelaskan alur end-to-end Payment Link yang tersedia di proyek, mencakup konfigurasi, endpoint backend, format token, langkah QA dengan contoh request, hingga troubleshooting dan integrasi frontend.
## Ringkasan Alur
- ERP/eksternal membuat Payment Link via `POST /createtransaksi` dan menerima `url` serta `token`.
- Frontend membuka halaman `Pay` di route `pay/:token`, lalu me-resolve token via `GET /api/payment-links/:token`.
- Pengguna memilih metode pembayaran (sesuai `allowed_methods` dan toggle runtime), kemudian frontend memanggil `POST /api/payments/charge`.
- Status pembayaran dapat dicek via `GET /api/payments/:orderId/status` dan disinkronkan via webhook `POST /api/payments/webhook`.
## Prasyarat & Konfigurasi
- Backend: jalankan `node server/index.cjs` (default port `8000`).
- Frontend: jalankan `npm run dev` (contoh dev port: `5175`).
- Penyesuaian environment penting:
- `EXTERNAL_API_KEY`: API Key luar untuk `POST /createtransaksi`. Jika tidak diset, di dev akan diizinkan tanpa key.
- `PAYMENT_LINK_SECRET`: secret untuk penandatanganan token Payment Link (HMAC SHA-256). Default dev: `dev-secret`.
- `PAYMENT_LINK_TTL_MINUTES`: waktu kedaluwarsa token (default: `30`).
- `PAYMENT_LINK_BASE`: base URL untuk halaman `Pay` (default: `http://localhost:5174/pay`). Sesuaikan ke port frontend yang aktif (misal `http://localhost:5175/pay`).
- `PORT`: port backend (default: `8000`).
- `ERP_NOTIFICATION_URL`, `ERP_CLIENT_ID`, `ERP_MERCANT_ID`, `ERP_ENABLE_NOTIF`: konfigurasi notifikasi ERP saat settlement.
- Midtrans keys: Server Key dan Client Key harus tersedia untuk charge/status.
- Frontend env: `VITE_API_BASE_URL` (contoh: `http://localhost:8000/api`), `VITE_MIDTRANS_CLIENT_KEY`.
## Endpoint Backend
- `POST /createtransaksi`
- Header: `X-API-KEY` (opsional di dev jika `EXTERNAL_API_KEY` tidak diset).
- Body: `{ item_id | order_id, nominal, customer?, allowed_methods? }`
- Respon: `{ url, token, order_id, nominal, expire_at }`
- Error: `UNAUTHORIZED`, `BAD_REQUEST`, `ORDER_COMPLETED`, `ORDER_ACTIVE`, `CREATE_ERROR`.
- `GET /api/payment-links/:token`
- Respon: `{ order_id, nominal, customer?, expire_at?, allowed_methods? }`
- Error: `410 TOKEN_EXPIRED`, `400 INVALID_*` (di dev ada fallback payload jika token invalid).
- `POST /api/payments/charge`
- Body: payload Midtrans (contoh di bawah). Diblokir jika method dimatikan oleh runtime toggles.
- Error: `PAYMENT_TYPE_DISABLED`, `CHARGE_ERROR`.
- `GET /api/payments/:orderId/status`
- Respon: pass-through dari Midtrans (transaction status, VA, dll.).
- `POST /api/payments/webhook`
- Verifikasi signature: `sha512(orderId + statusCode + grossAmount + serverKey)`.
- Pada sukses (settlement atau capture+accept untuk kartu), backend kirim notifikasi ke ERP (jika diaktifkan) dan menandai order sebagai completed.
- `GET /api/health`, `GET/POST /api/config`
- Health: cek ketersediaan key dan environment.
- Config: baca/ubah toggles (dev-only untuk `POST`).
## Format Token Payment Link
- Token adalah `base64url(JSON)` dengan fields minimal: `{ v, order_id, nominal, expire_at, sig, customer?, allowed_methods? }`.
- `sig` adalah HMAC SHA-256 dari string kanonik: `"order_id|nominal|expire_at"` menggunakan `PAYMENT_LINK_SECRET`.
## Langkah QA (Contoh)
1) Buat Payment Link
PowerShell (Windows):
```powershell
$body = @{
item_id = 'INV-PL-001';
nominal = 150000;
customer = @{ name='QA Tester'; phone='081234567890'; email='qa@example.com' };
allowed_methods = @('bank_transfer','cstore','gopay','credit_card')
} | ConvertTo-Json -Depth 5;
Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/createtransaksi' -ContentType 'application/json' -Body $body | ConvertTo-Json -Depth 5
```
curl:
```bash
curl -X POST http://localhost:8000/createtransaksi \
-H 'Content-Type: application/json' \
-H 'X-API-KEY: <jika-diperlukan>' \
-d '{
"item_id":"INV-PL-001",
"nominal":150000,
"customer": {"name":"QA Tester","phone":"081234567890","email":"qa@example.com"},
"allowed_methods":["bank_transfer","cstore","gopay","credit_card"]
}'
```
2) Resolve Token
```powershell
Invoke-RestMethod -Method GET -Uri "http://localhost:8000/api/payment-links/<token>" | ConvertTo-Json -Depth 5
```
3) Buka Halaman Pay
- Buka `http://localhost:5175/pay/<token>` (sesuaikan `PAYMENT_LINK_BASE` dengan port frontend).
- Periksa daftar metode, panel, serta batasan dari `allowed_methods` dan runtime toggles.
4) Charge Bank Transfer (BCA)
```powershell
$bt = @{
payment_type = 'bank_transfer';
transaction_details = @{ order_id = 'INV-PL-001'; gross_amount = 150000 };
bank_transfer = @{ bank = 'bca' }
} | ConvertTo-Json -Depth 5;
Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/api/payments/charge' -ContentType 'application/json' -Body $bt | ConvertTo-Json -Depth 5
```
5) Charge GoPay/QR (opsional)
```powershell
$qr = @{
payment_type = 'gopay';
transaction_details = @{ order_id = 'INV-PL-001'; gross_amount = 150000 };
gopay = @{ enable_qr = $true }
} | ConvertTo-Json -Depth 5;
Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/api/payments/charge' -ContentType 'application/json' -Body $qr | ConvertTo-Json -Depth 5
```
Catatan: Bila `400 Bad Request`, cek konfigurasi akun sandbox dan parameter GoPay/QRIS (lihat bagian troubleshooting).
6) Status Check
```powershell
Invoke-RestMethod -Method GET -Uri 'http://localhost:8000/api/payments/INV-PL-001/status' | ConvertTo-Json -Depth 5
```
7) Webhook (uji manual)
Untuk uji manual, kirim payload menyerupai notifikasi Midtrans dengan `signature_key` yang valid. Signature dihitung:
```js
// Node.js contoh perhitungan signature
const crypto = require('crypto')
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey)
return crypto.createHash('sha512').update(raw).digest('hex')
}
```
Kemudian `POST` ke `http://localhost:8000/api/payments/webhook` dengan body berisi fields Midtrans (order_id, status_code, gross_amount, signature_key, dll.).
## Troubleshooting
- Frontend tidak sesuai `PAYMENT_LINK_BASE` (5174 vs 5175): set `PAYMENT_LINK_BASE=http://localhost:5175/pay` di backend agar URL link mengarah ke port yang benar.
- `400 Bad Request` untuk GoPay/QR:
- Pastikan `gopay` payload memenuhi kebutuhan sandbox (mis. `enable_qr`, kadang perlu `qr_black_white`, atau `callback_url`).
- Periksa toggles runtime (`/api/config`) dan ketersediaan Midtrans keys.
- Beberapa merchant sandbox memiliki batasan; rujuk dokumentasi Midtrans untuk parameter terbaru.
- `UNAUTHORIZED` saat `createtransaksi`: set header `X-API-KEY` sesuai `EXTERNAL_API_KEY` jika dikonfigurasi.
- `ORDER_ACTIVE` atau `ORDER_COMPLETED`: backend menjaga `activeOrders` dan `notifiedOrders` untuk mencegah duplikasi; tunggu TTL atau gunakan order baru.
## Integrasi Frontend
- Route: `pay/:token` (lihat `src/app/router.tsx`).
- Resolver: `getPaymentLinkPayload(token)` (lihat `src/services/api.ts`).
- Toggle & Allowed Methods: `PayPage` menggabungkan `runtimeCfg.paymentToggles` dengan `allowed_methods`. Kunci metode: `bank_transfer`, `credit_card`, `gopay`, `cstore`, `cpay`.
## Notifikasi ERP
- Di settlement sukses, backend menghitung signature ERP (`sha512`) dan mengirim payload ke `ERP_NOTIFICATION_URL` jika `ERP_ENABLE_NOTIF=true` dan konfigurasi lengkap.
## Postman Collection
- Anda dapat mengimpor koleksi: `docs/qa/payment-link.postman_collection.json` untuk mencoba endpoint di atas.
## Pengujian via Postman (Langkah Lengkap)
### 1) Import & Setup Environment
- Import koleksi: `docs/qa/payment-link.postman_collection.json`.
- Buat Environment (mis. `Midtrans-CIFO-Local`) dengan variabel:
- `baseUrl`: `http://localhost:8000`
- `paymentLinkBase`: `http://localhost:5175/pay` (sesuaikan port frontend)
- `externalApiKey`: kosong atau set sesuai `EXTERNAL_API_KEY` (jika diaktifkan)
- `token`: kosong (akan diisi dari respon create)
- `order_id`: kosong (akan diisi dari respon create)
Catatan: Koleksi memakai variabel ini pada URL/body. Pastikan environment terpilih saat menjalankan request.
### 2) Jalankan Koleksi (Urutan Dasar)
1. `1) Create Transaction`
- Body default: `item_id`, `nominal`, `customer`, `allowed_methods`.
- Jika `EXTERNAL_API_KEY` aktif, pastikan header `X-API-KEY: {{externalApiKey}}` terisi.
2. `2) Resolve Token`
- Memakai `{{token}}` dari langkah 1.
3. `3) Charge - Bank Transfer (BCA)` atau `3) Charge - CStore (Indomaret)`
- Memakai `{{order_id}}` dari langkah 1.
4. `4) Payment Status`
- Memakai `{{order_id}}` untuk cek status.
### 3) Skrip Test untuk Otomatis Mengisi Variabel
Tambahkan skrip berikut pada tab `Tests` di request `1) Create Transaction` agar `token`, `order_id`, dan `paymentLinkUrl` otomatis tersimpan ke variabel koleksi:
```javascript
// Tests: 1) Create Transaction
pm.test('Create returns token and url', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('token');
pm.expect(json).to.have.property('url');
pm.expect(json).to.have.property('order_id');
});
const res = pm.response.json();
pm.collectionVariables.set('token', res.token);
pm.collectionVariables.set('order_id', res.order_id);
pm.collectionVariables.set('paymentLinkUrl', res.url);
```
Tambahkan skrip sederhana di `2) Resolve Token` untuk validasi payload:
```javascript
pm.test('Resolve returns payload fields', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('order_id');
pm.expect(json).to.have.property('nominal');
});
```
Di `3) Charge - Bank Transfer (BCA)`, tambahkan assert dasar:
```javascript
pm.test('Charge is created (pending)', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('status_code');
pm.expect(json.status_code).to.eql('201');
pm.expect(json.transaction_status).to.eql('pending');
});
```
Di `4) Payment Status`, periksa status:
```javascript
pm.test('Status returns transaction info', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('order_id');
pm.expect(json).to.have.property('transaction_status');
});
```
### 4) Menambah Request GoPay/QR (Opsional)
Jika Anda ingin menguji GoPay/QR, tambahkan request baru di koleksi:
- Method: `POST`
- URL: `{{baseUrl}}/api/payments/charge`
- Body (raw JSON):
```json
{
"payment_type": "gopay",
"transaction_details": { "order_id": "{{order_id}}", "gross_amount": 150000 },
"gopay": { "enable_qr": true }
}
```
Catatan: Bila menghasilkan `400 Bad Request`, cek kembali konfigurasi sandbox merchant dan parameter tambahan yang dibutuhkan (misal `qr_black_white`, `callback_url`).
### 5) Membuka Halaman Pay dari Postman
Setelah `1) Create Transaction`, variabel `paymentLinkUrl` tersimpan. Anda bisa klik tombol `Open in Browser` (ikon tautan) pada Postman untuk membuka `{{paymentLinkUrl}}` langsung di browser.
### 6) Uji Webhook dengan Pre-request Script
Tambahkan request baru `Webhook (Manual)` dengan URL `{{baseUrl}}/api/payments/webhook`. Isi body dengan fields Midtrans. Untuk menghitung `signature_key` otomatis, gunakan Pre-request Script berikut (CryptoJS tersedia di sandbox Postman):
```javascript
// Pre-request: Webhook signature
const orderId = pm.collectionVariables.get('order_id');
const statusCode = pm.collectionVariables.get('status_code') || '200';
const grossAmount = pm.collectionVariables.get('gross_amount') || '150000';
const serverKey = pm.collectionVariables.get('server_key'); // set di environment
if (!serverKey) {
console.warn('server_key is not set in environment');
}
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(serverKey);
const sig = CryptoJS.SHA512(raw).toString(CryptoJS.enc.Hex);
pm.collectionVariables.set('signature_key', sig);
```
Body contoh (raw JSON):
```json
{
"order_id": "{{order_id}}",
"status_code": "{{status_code}}",
"gross_amount": "{{gross_amount}}",
"signature_key": "{{signature_key}}",
"transaction_status": "settlement"
}
```
Tambahkan test sederhana untuk memverifikasi respons webhook:
```javascript
pm.test('Webhook acknowledged', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('ok');
pm.expect(json.ok).to.eql(true);
});
```
### 7) Postman Runner (Otomasi)
- Gunakan Runner untuk menjalankan berurutan: `1) Create Transaction``2) Resolve Token``3) Charge``4) Status`.
- Pastikan environment dipilih. Anda dapat menambah delay antar request jika diperlukan.
### 8) Tips & Variabel Tambahan
- Sesuaikan `paymentLinkBase` ke port frontend aktif (mis. `5175`).
- Jika `EXTERNAL_API_KEY` aktif, isi `externalApiKey` di environment.
- Simpan `server_key` (Midtrans Server Key) di environment untuk uji webhook.
- Tambahkan `gross_amount`, `status_code` variabel supaya mudah dikustom saat uji webhook/status.

View File

@ -0,0 +1,137 @@
{
"info": {
"name": "Payment Link QA",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{ "key": "baseUrl", "value": "https://be-midtrans-cifo.winteraccess.id" },
{ "key": "paymentLinkBase", "value": "https://midtrans-cifo.winteraccess.id/pay" },
{ "key": "externalApiKey", "value": "" },
{ "key": "token", "value": "" },
{ "key": "order_id", "value": "" }
],
"item": [
{
"name": "1) Create Transaction",
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "X-API-KEY", "value": "{{externalApiKey}}" }
],
"url": { "raw": "{{baseUrl}}/createtransaksi", "host": ["{{baseUrl}}"], "path": ["createtransaksi"] },
"body": {
"mode": "raw",
"raw": "{\n \"mercant_id\": \"REFNO-001\",\n \"timestamp\": 1731300000000,\n \"deskripsi\": \"Bayar Internet\",\n \"nominal\": 200000,\n \"nama\": \"Demo User\",\n \"no_telepon\": \"081234567890\",\n \"email\": \"demo@example.com\",\n \"item\": [\n { \"item_id\": \"TKG-2511101\", \"nama\": \"Internet\", \"harga\": 200000, \"qty\": 1 }\n ]\n}"
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"let res = {};",
"try { res = pm.response.json(); } catch(e) { res = {}; }",
"const url = (res && res.data && res.data.url) ? res.data.url : (res && res.url ? res.url : '');",
"if (url) {",
" pm.collectionVariables.set('paymentLinkUrl', url);",
" const trimmed = url.replace(/\\\/$/, '');",
" const parts = trimmed.split('/');",
" const tok = parts[parts.length - 1];",
" pm.collectionVariables.set('token', tok);",
"}",
"pm.test('Create Transaction returns data.url and token', function () {",
" pm.expect(url, 'data.url exists').to.be.a('string');",
"});"
]
}
}
],
"response": []
},
{
"name": "2) Resolve Token",
"request": {
"method": "GET",
"header": [],
"url": { "raw": "{{baseUrl}}/api/payment-links/{{token}}", "host": ["{{baseUrl}}"], "path": ["api","payment-links","{{token}}"] }
},
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
"const t = pm.variables.get('token');",
"if (!t) { throw new Error(\"Missing token. Run '1) Create Transaction' first.\"); }"
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"let res = {};",
"try { res = pm.response.json(); } catch(e) { res = {}; }",
"if (res && res.order_id) { pm.collectionVariables.set('order_id', res.order_id); }",
"pm.test('Resolve Token payload has order_id and nominal', function () {",
" pm.expect(res.order_id, 'order_id').to.be.a('string');",
" pm.expect(res.nominal, 'nominal').to.exist;",
"});"
]
}
}
],
"response": []
},
{
"name": "3) Charge - Bank Transfer (BCA)",
"request": {
"method": "POST",
"header": [ { "key": "Content-Type", "value": "application/json" } ],
"url": { "raw": "{{baseUrl}}/api/payments/charge", "host": ["{{baseUrl}}"], "path": ["api","payments","charge"] },
"body": {
"mode": "raw",
"raw": "{\n \"payment_type\": \"bank_transfer\",\n \"transaction_details\": { \"order_id\": \"{{order_id}}\", \"gross_amount\": 150000 },\n \"bank_transfer\": { \"bank\": \"bca\" }\n}"
}
},
"response": []
},
{
"name": "3) Charge - CStore (Indomaret)",
"request": {
"method": "POST",
"header": [ { "key": "Content-Type", "value": "application/json" } ],
"url": { "raw": "{{baseUrl}}/api/payments/charge", "host": ["{{baseUrl}}"], "path": ["api","payments","charge"] },
"body": {
"mode": "raw",
"raw": "{\n \"payment_type\": \"cstore\",\n \"transaction_details\": { \"order_id\": \"{{order_id}}\", \"gross_amount\": 150000 },\n \"cstore\": { \"store\": \"indomaret\" }\n}"
}
},
"response": []
},
{
"name": "4) Payment Status",
"request": {
"method": "GET",
"header": [],
"url": { "raw": "{{baseUrl}}/api/payments/{{order_id}}/status", "host": ["{{baseUrl}}"], "path": ["api","payments","{{order_id}}","status"] }
},
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
"const oid = pm.variables.get('order_id');",
"if (!oid) { throw new Error(\"Missing order_id. Run '1) Create Transaction' first.\"); }"
]
}
}
],
"response": []
}
]
}

View File

@ -0,0 +1,146 @@
# Sprint Change Proposal — ERP → Create Transaction → Payment Link Flow
## Summary
- Trigger: Checkout tidak lagi dimulai dari tombol “Buy Now” di frontend. Sistem eksternal (ERP/billing script) mengirim data transaksi ke server dan menerima tautan pembayaran terenkripsi untuk dibagikan ke pengguna.
- Outcome: Backend menjadi titik inisiasi transaksi. Pengguna membuka Payment Page via link (`/pay/:token`), memilih metode (VA/GoPay/Cstore/Kartu), lalu UI mengeksekusi charge seperti biasa. Webhook Midtrans dan ERP callback tetap berjalan.
## Objectives (Sprint)
- Menyediakan endpoint backend `POST /createtransaksi` yang mengeluarkan payment link bertanda-tangan (HMACSHA256) dengan TTL dan antireplay.
- Menambahkan rute frontend `"/pay/:token"` (Payment Page) untuk memvalidasi token, menampilkan metode, dan melakukan charge menggunakan `order_id` dari token.
- Menjaga kompatibilitas endpoint yang ada: `POST /api/payments/charge`, `GET /api/payments/:orderId/status`, `POST /api/payments/webhook`.
- Menyelaraskan PRD, Arsitektur UI, dan Story E2E dengan alur baru.
## Scope & Impact
- PRD (`docs/prd.md`): Tambah FR “External Create Transaction & Payment Link”, ubah sumber `order_id` (berasal dari `item_id`), tambahkan ketentuan TTL, signature, dan antireplay.
- Arsitektur UI (`docs/ui-architecture.md`): Tambah rute `"/pay/:token"`, alur tokendriven. `CheckoutPage` tetap sebagai demo/QA.
- Story E2E (`docs/stories/midtrans-e2e-checkout-to-webhook.md`): Mulai dari Payment Page via token, bukan dari CheckoutPage langsung.
- Backend (`server/index.cjs`): Tambah endpoint `POST /createtransaksi` dan resolver token (API untuk FE). Env baru: `EXTERNAL_API_KEY`, `PAYMENT_LINK_SECRET`, `PAYMENT_LINK_TTL_MINUTES`.
- Frontend: Tambah halaman `PayPage` (`/pay/:token`), service untuk resolve token. `postCharge/getPaymentStatus` tetap.
## Proposed Design
### 1) Backend — Create Transaction API
- Endpoint: `POST /createtransaksi`
- Auth: Header `X-API-KEY: <EXTERNAL_API_KEY>` (validasi exact match; opsi IP whitelist & rate limit di reverse proxy).
- Body (contoh minimal):
```json
{
"merchant_id": "TKG-250520029803",
"deskripsi": "Pembelian item",
"nominal": 200000,
"nama": "Dwiki Kurnia Sandi",
"no_telepon": "081234567890",
"email": "demo@example.com",
"item": [
{ "item_id": "ITEM-12345", "qty": 1, "price": 200000, "name": "Produk A" }
]
}
```
- Mapping `order_id`: gunakan `"mercant_id:item_id"` bila keduanya tersedia (contoh: `MERC-001:ITEM-12345`). Jika salah satu tidak ada, fallback ke `item[0].item_id` atau `mercant_id`. Tujuan: item yang sama pada merchant berbeda tetap menghasilkan order unik sehingga link bisa diterbitkan.
- Idempotensi: jika `order_id` + `nominal` sama dan status transaksi masih `pending`, kembalikan payment link sebelumnya; jika `nominal` berbeda, kembalikan `422 AMOUNT_MISMATCH`.
- Response (sukses):
```json
{
"status_code": "200",
"status_message": "OK",
"url": "https://cifopayment.id/pay/<token>?sig=<signature>",
"exp": 1730000000
}
```
- `token`: opaque base64url berisi klaim minimal (mis. `order_id`, `nominal`, `exp`) yang disimpan server.
- `signature`: `hex(HMAC_SHA256(token, PAYMENT_LINK_SECRET))`.
- `exp`: UNIX epoch seconds (TTL default 30 menit; dapat dikonfigurasi).
### 2) Backend — Payment Link Resolve API
- Endpoint: `GET /api/payment-links/:token` (digunakan frontend untuk bootstrap Payment Page).
- Query: otomatis memverifikasi signature (`sig` di query atau header), TTL/antireplay, dan mengembalikan payload:
```json
{
"order_id": "ITEM-12345",
"nominal": 200000,
"customer": { "name": "Dwiki", "phone": "081234567890", "email": "demo@example.com" },
"expire_at": 1730000000,
"allowed_methods": ["bank_transfer", "gopay", "cstore", "credit_card"]
}
```
- Error: `401 INVALID_SIGNATURE`, `410 LINK_EXPIRED`, `409 LINK_USED` (opsional jika antireplay menandai sekali pakai).
### 3) Frontend — Payment Page (`/pay/:token`)
- Flow:
- Ambil `token` dari URL → panggil `GET /api/payment-links/:token`.
- Set lokal `orderId`, `amount`, `expireAt`, dan info pelanggan.
- Render komponen metode (VA/GoPay/Cstore/Kartu) seperti di Checkout, tetapi `orderId` berasal dari token.
- Setelah charge, navigasi ke `"/payments/:orderId/status"` (polling status tetap).
- Catatan: `CheckoutPage` tetap ada untuk demo/QA; jalur produksi menggunakan Payment Page via link.
### 4) Webhook & ERP Callback
- Tetap: `POST /api/payments/webhook` memverifikasi signature Midtrans dan memperbarui status.
- ERP Callback: kirim `POST` ke `https://apibackend.erpskrip.id/paymentnotification/` pada status sukses, signature `sha512(mercant_id + status_code + nominal + client_id)` seperti implementasi saat ini.
### 5) Security & Config
- Secrets:
- `EXTERNAL_API_KEY`: memvalidasi `X-API-KEY` dari ERP.
- `PAYMENT_LINK_SECRET`: kunci HMAC untuk signature token.
- `PAYMENT_LINK_TTL_MINUTES`: default 30.
- Praktik:
- Ratelimit `POST /createtransaksi` dan audit log.
- Antireplay: tandai token sebagai “used” setelah berhasil charge (opsional; atau izinkan reuse sampai status final).
- Rotasi secret terjadwal; invalidasi token lama secara bertahap bila diperlukan.
## Acceptance Criteria (Sprint)
- Backend mengeluarkan payment link dengan `token` + `signature`, valid hingga TTL.
- Resolve API mengembalikan `order_id` dan `nominal` yang konsisten; invalid jika signature/TTL gagal.
- Frontend Payment Page (`/pay/:token`) dapat:
- Memvalidasi token dan menampilkan metode pembayaran.
- Melakukan charge via endpoint yang ada menggunakan `order_id` dari token.
- Menavigasi ke halaman status dan menampilkan detail (VA/QR/payment code/kartu) sesuai metode.
- Webhook menerima notifikasi Midtrans dan ERP callback tetap terkirim saat sukses.
- Dokumentasi PRD, Arsitektur UI, dan Story E2E diperbarui mencerminkan alur baru.
## NonFunctional Requirements
- Observability: logging request ID, event penting (`link.create`, `link.resolve`, `charge.start/success`, `webhook.receive`).
- Idempotensi: kembalikan link lama untuk transaksi `pending` dengan `order_id` + `nominal` yang sama.
- Keamanan: tidak mengekspos server key; signature Midtrans diverifikasi; token link ditandatangani HMACSHA256.
- Kinerja: endpoint create/resolver respon <200ms p95 (lokal dev).
## Risks & Mitigations
- Replay/penyalahgunaan link: enforce TTL, antireplay flag, ratelimit.
- Konflik `order_id`: validasi unik; strategy untuk recurring (suffix waktu/sequence bila diperlukan).
- Ketidaksesuaian nominal: `422 AMOUNT_MISMATCH` untuk `order_id` sama namun nominal beda.
- Gangguan ERP: retry callback dengan backoff, feature flag `ERP_ENABLE_NOTIF` untuk mematikan sementara.
## Rollback Plan
- Nonaktifkan konsumsi resolver token (feature flag) dan kembalikan ke alur Checkout demo.
- Pertahankan endpoint status & webhook agar UI tetap dapat polling status.
## Deliverables (Sprint)
- Backend: `POST /createtransaksi`, `GET /api/payment-links/:token` (validator signature/TTL), konfigurasi env baru.
- Frontend: halaman `PayPage` (`/pay/:token`) dengan integrasi panel metode yang ada.
- Docs: update PRD, Arsitektur UI, dan Story E2E sesuai tokendriven flow.
## Timeline & Tasking (5 hari)
- Day 1: Skeleton endpoint `createtransaksi` + HMAC signing + env wiring.
- Day 2: Resolver API + idempotensi + antireplay dasar.
- Day 3: FE `PayPage` + service `getPaymentLinkPayload(token)` + navigasi.
- Day 4: QA manual (sandbox) + webhook/ERP callback sanity.
- Day 5: Dokumentasi & polish (error codes, logging, runbook).
## Decisions Confirmed
- TTL payment link: 30 menit (konfigurabel; default 30) — disetujui.
- Token: HMACSHA256 signature (tanpa enkripsi payload; confidentiality tidak diwajibkan saat ini).
- Recurring transaksi: tidak didukung. Kebijakan:
- Satu transaksi aktif per `item_id` pada satu waktu.
- Jika status sukses (`settlement` atau `capture + fraud_status=accept`), permintaan baru untuk `item_id` yang sama ditolak (`409 ORDER_COMPLETED`).
- Re-attempt diperbolehkan hanya jika transaksi sebelumnya tidak sukses dan sudah berakhir (`expire/cancel/deny`), menggunakan kebijakan unik `order_id` sesuai kebutuhan implementasi Midtrans.
## Appendix — Example
- Request `POST /createtransaksi` (ringkas): lihat contoh pada bagian Backend di atas.
- Response sukses:
```json
{
"status_code": "200",
"status_message": "OK",
"url": "https://cifopayment.id/pay/eyJvcmRlcl9pZCI6IklURU0tMTIzNDUiLCJub21pbmFsIjoyMDAwMDAsImV4cCI6MTczMDAwMDAwMH0?sig=4c7f...",
"exp": 1730000000
}
```

View File

@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/simaya.png"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>core-midtrans-cifo</title> <title>Simaya Midtrans | Retail Payment</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

BIN
public/simaya.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -37,13 +37,31 @@ function parseEnable(v) {
} }
const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || '' const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || ''
const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF) const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF)
const ERP_CLIENT_ID = process.env.ERP_CLIENT_ID || '' // Gunakan secret untuk signature; fallback ke CLIENT_ID bila SECRET belum ada
const ERP_MERCANT_ID = process.env.ERP_MERCANT_ID || process.env.ERP_MERCHANT_ID || '' const ERP_CLIENT_SECRET = process.env.ERP_CLIENT_SECRET || process.env.ERP_CLIENT_ID || ''
const notifiedOrders = new Set() const notifiedOrders = new Set()
// Mendukung banyak endpoint ERP (comma-separated) via env ERP_NOTIFICATION_URLS
function parseList(value) {
if (!value) return []
return String(value)
.split(',')
.map(s => s.trim())
.filter(Boolean)
}
const ERP_NOTIFICATION_URLS = (() => {
const multi = parseList(process.env.ERP_NOTIFICATION_URLS)
if (multi.length > 0) return multi
return ERP_NOTIFICATION_URL ? [ERP_NOTIFICATION_URL] : []
})()
// --- Logger utilities // --- Logger utilities
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase() const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 } const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }
const LOG_EXPOSE_API = parseEnable(process.env.LOG_EXPOSE_API)
const LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10)
const recentLogs = []
function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) } function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) }
function ts() { return new Date().toISOString() } function ts() { return new Date().toISOString() }
function sanitize(obj) { function sanitize(obj) {
@ -66,6 +84,11 @@ function maskPayload(obj) {
function log(level, msg, meta) { function log(level, msg, meta) {
if (!shouldLog(level)) return if (!shouldLog(level)) return
const line = `[${ts()}] [${level}] ${msg}` const line = `[${ts()}] [${level}] ${msg}`
try {
const entry = { ts: ts(), level, msg, meta: sanitize(meta) }
recentLogs.push(entry)
if (recentLogs.length > LOG_BUFFER_SIZE) recentLogs.shift()
} catch {}
if (meta) { if (meta) {
const data = typeof meta === 'string' ? meta : JSON.stringify(meta) const data = typeof meta === 'string' ? meta : JSON.stringify(meta)
console.log(line, data) console.log(line, data)
@ -98,6 +121,55 @@ const ENABLE = {
cstore: parseEnable(process.env.ENABLE_CSTORE), cstore: parseEnable(process.env.ENABLE_CSTORE),
} }
// --- Payment Link Config
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || ''
const PAYMENT_LINK_SECRET = process.env.PAYMENT_LINK_SECRET || ''
const PAYMENT_LINK_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES || '1440', 10)
const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay'
const activeOrders = new Map() // order_id -> expire_at
// Map untuk menyimpan mercant_id per order_id agar notifikasi ERP bisa dinamis
const orderMerchantId = new Map() // order_id -> mercant_id
function isDevEnv() { return (process.env.NODE_ENV || '').toLowerCase() !== 'production' }
function verifyExternalKey(req) {
const key = (req.headers['x-api-key'] || req.headers['X-API-KEY'] || '').toString()
if (EXTERNAL_API_KEY) return key === EXTERNAL_API_KEY
// Allow if not configured only in dev for easier local testing
return isDevEnv()
}
function base64UrlEncode(buf) {
return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
function base64UrlDecode(str) {
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
const s = str.replace(/-/g, '+').replace(/_/g, '/') + pad
return Buffer.from(s, 'base64').toString('utf8')
}
function computeTokenSignature(orderId, nominal, expireAt) {
const canonical = `${String(orderId)}|${String(nominal)}|${String(expireAt)}`
return crypto.createHmac('sha256', PAYMENT_LINK_SECRET || 'dev-secret').update(canonical).digest('hex')
}
function createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) {
const v = 1
const sig = computeTokenSignature(order_id, nominal, expire_at)
const payload = { v, order_id, nominal, expire_at, sig, customer, allowed_methods }
return base64UrlEncode(JSON.stringify(payload))
}
function resolvePaymentLinkToken(token) {
try {
const json = JSON.parse(base64UrlDecode(token))
const { order_id, nominal, expire_at, sig } = json || {}
if (!order_id || !nominal || !expire_at || !sig) return { error: 'INVALID_TOKEN' }
const expected = computeTokenSignature(order_id, nominal, expire_at)
if (String(sig) !== String(expected)) return { error: 'INVALID_SIGNATURE' }
if (Date.now() > Number(expire_at)) return { error: 'TOKEN_EXPIRED', payload: { order_id, nominal, expire_at } }
return { payload: json }
} catch (e) {
return { error: 'TOKEN_PARSE_ERROR', message: e?.message }
}
}
// Health check // Health check
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey }) logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey })
@ -115,6 +187,26 @@ app.get('/api/config', (_req, res) => {
res.json(payload) res.json(payload)
}) })
// Logs endpoint (dev/debug): GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword
app.get('/api/logs', (req, res) => {
if (!LOG_EXPOSE_API) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
}
const limit = Math.max(1, Math.min(1000, parseInt(String(req.query.limit || '100'), 10)))
const level = String(req.query.level || '').toLowerCase()
const q = String(req.query.q || '').toLowerCase()
let items = recentLogs
if (level) items = items.filter((e) => e.level === level)
if (q) items = items.filter((e) => {
try {
const m = e.meta ? JSON.stringify(e.meta).toLowerCase() : ''
return e.msg.toLowerCase().includes(q) || m.includes(q)
} catch { return false }
})
const sliced = items.slice(Math.max(0, items.length - limit))
res.json({ count: sliced.length, items: sliced })
})
// Dev-only: allow updating toggles at runtime without restart // Dev-only: allow updating toggles at runtime without restart
app.post('/api/config', (req, res) => { app.post('/api/config', (req, res) => {
const isDev = process.env.NODE_ENV !== 'production' const isDev = process.env.NODE_ENV !== 'production'
@ -131,12 +223,59 @@ app.post('/api/config', (req, res) => {
res.json(result) res.json(result)
}) })
// Payment Link Resolver: GET /api/payment-links/:token
app.get('/api/payment-links/:token', (req, res) => {
const { token } = req.params
const result = resolvePaymentLinkToken(token)
if (result.error === 'TOKEN_EXPIRED') {
logWarn('paymentlink.expired', { order_id: result.payload?.order_id })
return res.status(410).json({ error: result.error, ...result.payload })
}
if (result.error) {
logWarn('paymentlink.invalid', { error: result.error })
if (isDevEnv()) {
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
const fallback = { order_id: token, nominal: 150000, expire_at: Date.now() + ttlMin * 60 * 1000 }
logInfo('paymentlink.dev.fallback', { order_id: fallback.order_id })
return res.json(fallback)
}
return res.status(400).json({ error: result.error })
}
const p = result.payload
logInfo('paymentlink.resolve.success', { order_id: p.order_id, expire_at: p.expire_at })
res.json({ order_id: p.order_id, nominal: p.nominal, customer: p.customer, expire_at: p.expire_at, allowed_methods: p.allowed_methods })
})
// Charge endpoint (pass-through to Midtrans Core API) // Charge endpoint (pass-through to Midtrans Core API)
app.post('/api/payments/charge', async (req, res) => { app.post('/api/payments/charge', async (req, res) => {
try { try {
const pt = req?.body?.payment_type const pt = req?.body?.payment_type
logInfo('charge.request', { id: req.id, payment_type: pt }) logInfo('charge.request', { id: req.id, payment_type: pt })
logDebug('charge.payload', maskPayload(req.body)) logDebug('charge.payload', maskPayload(req.body))
// Idempotency guard: if an order is already pending in Midtrans, block re-charge for the same order_id
const orderId = req?.body?.transaction_details?.order_id || req?.body?.order_id || ''
if (orderId) {
try {
const st = await core.transaction.status(orderId)
const ts = (st?.transaction_status || '').toLowerCase()
if (ts === 'pending') {
logWarn('charge.blocked.pending_exists', { id: req.id, order_id: orderId })
return res.status(409).json({
error: 'ORDER_ACTIVE',
message: 'Order sudah memiliki transaksi pending di Midtrans; tidak dapat membuat ulang. Gunakan instruksi pembayaran yang ada atau buat order baru.',
status: st,
})
}
} catch (e) {
const msg = (e?.message || '').toLowerCase()
if (msg.includes('not found') || msg.includes('404')) {
logDebug('charge.status_not_found', { order_id: orderId })
} else {
logDebug('charge.status_check_error', { order_id: orderId, message: e?.message })
}
}
}
const isBankType = pt === 'bank_transfer' || pt === 'echannel' || pt === 'permata' const isBankType = pt === 'bank_transfer' || pt === 'echannel' || pt === 'permata'
if (isBankType && !ENABLE.bank_transfer) { if (isBankType && !ENABLE.bank_transfer) {
logWarn('charge.blocked', { id: req.id, reason: 'bank_transfer disabled' }) logWarn('charge.blocked', { id: req.id, reason: 'bank_transfer disabled' })
@ -172,7 +311,31 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
logInfo('status.request', { id: req.id, orderId }) logInfo('status.request', { id: req.id, orderId })
const status = await core.transaction.status(orderId) const status = await core.transaction.status(orderId)
logInfo('status.success', { id: req.id, orderId, transaction_status: status?.transaction_status }) logInfo('status.success', { id: req.id, orderId, transaction_status: status?.transaction_status })
// Respond immediately with status
res.json(status) res.json(status)
// Fallback: selain webhook, jika status di sini sudah sukses (settlement/capture+accept), kirim notifikasi ke ERP
setImmediate(async () => {
try {
if (isSuccessfulMidtransStatus(status)) {
const nominal = String(status?.gross_amount || '')
if (!notifiedOrders.has(orderId)) {
activeOrders.delete(orderId)
logInfo('status.notify.erp.trigger', { orderId, transaction_status: status?.transaction_status })
const ok = await notifyERP({ orderId, nominal })
if (ok) {
notifiedOrders.add(orderId)
} else {
logWarn('erp.notify.defer', { orderId, reason: 'post_failed_or_missing_data' })
}
} else {
logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
}
}
} catch (e) {
logError('status.notify.error', { orderId, message: e?.message })
}
})
} catch (e) { } catch (e) {
const msg = e?.message || 'Status check failed' const msg = e?.message || 'Status check failed'
logError('status.error', { id: req.id, orderId, message: msg }) logError('status.error', { id: req.id, orderId, message: msg })
@ -180,6 +343,99 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
} }
}) })
// External ERP Create Transaction → issue payment link
app.post('/createtransaksi', async (req, res) => {
try {
if (!verifyExternalKey(req)) {
logWarn('createtransaksi.unauthorized', { id: req.id })
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'X-API-KEY invalid' })
}
// Skema baru:
// {
// mercant_id, timestamp, deskripsi, nominal,
// nama, no_telepon, email,
// item: [{ item_id, nama, harga, qty }, ...]
// }
const mercantId = req?.body?.mercant_id
const nominalRaw = req?.body?.nominal
const items = Array.isArray(req?.body?.item) ? req.body.item : []
const primaryItemId = items?.[0]?.item_id
// Mapping order_id: gunakan "mercant_id:item_id" bila keduanya tersedia,
// jika tidak, fallback ke item_id atau mercant_id atau field order_id/item_id yang disediakan.
const order_id = String(
(primaryItemId && mercantId) ? `${mercantId}:${primaryItemId}` :
(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
)
// Simpan mercant_id per order agar dapat digunakan saat notifikasi ERP
if (mercantId) {
try { orderMerchantId.set(order_id, mercantId) } catch {}
}
// Bentuk customer dari field nama/no_telepon/email
const customer = {
name: req?.body?.nama,
phone: req?.body?.no_telepon,
email: req?.body?.email,
}
const allowed_methods = req?.body?.allowed_methods
if (!order_id || typeof nominalRaw === 'undefined') {
logWarn('createtransaksi.bad_request', { id: req.id, hasOrderId: !!order_id, hasNominal: typeof nominalRaw !== 'undefined' })
return res.status(400).json({ error: 'BAD_REQUEST', message: 'order_id (mercant_id atau item[0].item_id) dan nominal wajib ada' })
}
const nominal = Number(nominalRaw)
const now = Date.now()
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
const expire_at = now + ttlMin * 60 * 1000
// Block jika sudah selesai
if (notifiedOrders.has(order_id)) {
logWarn('createtransaksi.completed', { order_id })
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
}
// Block jika ada link aktif belum expired
const existing = activeOrders.get(order_id)
if (existing && existing > now) {
logWarn('createtransaksi.active_exists', { order_id })
return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' })
}
// Guard tambahan: cek ke Midtrans apakah order_id sudah memiliki transaksi pending
try {
const status = await core.transaction.status(order_id)
const s = (status?.transaction_status || '').toLowerCase()
if (s === 'pending') {
logWarn('createtransaksi.midtrans_pending', { order_id })
return res.status(409).json({
error: 'ORDER_ACTIVE',
message: 'Order sudah memiliki transaksi pending di Midtrans; gunakan instruksi pembayaran yang ada atau buat order baru.',
status: { order_id: status?.order_id, status_code: status?.status_code, status_message: status?.status_message, payment_type: status?.payment_type },
})
}
} catch (e) {
// Jika 404/not found, lanjut membuat payment link; error lain tetap diteruskan
const msg = (e?.message || '').toLowerCase()
if (msg.includes('not found') || msg.includes('404')) {
logDebug('createtransaksi.midtrans_status_not_found', { order_id })
} else {
logDebug('createtransaksi.midtrans_status_check_error', { order_id, message: e?.message })
}
}
const token = createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods })
const url = `${PAYMENT_LINK_BASE}/${token}`
activeOrders.set(order_id, expire_at)
logInfo('createtransaksi.issued', { order_id, expire_at })
// Respons mengikuti format yang diminta
res.json({ status: '200', messages: 'SUCCESS', data: { url } })
} catch (e) {
logError('createtransaksi.error', { id: req.id, message: e?.message })
res.status(500).json({ error: 'CREATE_ERROR', message: e?.message || 'Internal error' })
}
})
// --- Helpers: Midtrans signature verification & ERP notify // --- Helpers: Midtrans signature verification & ERP notify
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) { function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
try { try {
@ -199,74 +455,149 @@ function isSuccessfulMidtransStatus(body) {
return false return false
} }
function postJson(url, data) { class HttpError extends Error {
constructor(statusCode, body) {
super(`HTTP ${statusCode}`)
this.statusCode = statusCode
this.body = body
}
}
function postJson(url, data, extraHeaders = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const u = new URL(url) const u = new URL(url)
const body = JSON.stringify(data) const body = JSON.stringify(data)
const headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
...extraHeaders,
}
logDebug('http.post', {
url,
headers: sanitize(headers),
body_length: Buffer.byteLength(body),
body_preview: body.slice(0, 256),
})
const opts = { const opts = {
method: 'POST', method: 'POST',
hostname: u.hostname, hostname: u.hostname,
path: u.pathname + (u.search || ''), path: u.pathname + (u.search || ''),
port: u.port || (u.protocol === 'https:' ? 443 : 80), port: u.port || (u.protocol === 'https:' ? 443 : 80),
headers: { headers,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
} }
const req = (u.protocol === 'https:' ? https : require('http')).request(opts, (res) => { const req = (u.protocol === 'https:' ? https : require('http')).request(opts, (res) => {
let chunks = '' let chunks = ''
res.on('data', (d) => { chunks += d.toString() }) res.on('data', (d) => { chunks += d.toString() })
res.on('end', () => { res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) resolve({ status: res.statusCode, body: chunks }) const info = {
else reject(new Error(`HTTP ${res.statusCode}: ${chunks}`)) url,
statusCode: res.statusCode,
body_length: chunks.length,
body_preview: chunks.slice(0, 256),
}
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
logDebug('http.response', info)
resolve({ status: res.statusCode, body: chunks })
} else {
logWarn('http.response.error', info)
reject(new HttpError(res.statusCode, chunks))
}
}) })
}) })
req.on('error', reject) req.on('error', (e) => {
logError('http.request.error', { url, message: e?.message })
reject(e)
})
req.write(body) req.write(body)
req.end() req.end()
} catch (e) { } catch (e) {
logError('http.build.error', { url, message: e?.message })
reject(e) reject(e)
} }
}) })
} }
function computeErpSignature(mercantId, statusCode, nominal, clientId) { async function computeErpSignature(mercantId, statusCode, nominal, clientId) {
try { try {
const raw = String(mercantId) + String(statusCode) + String(nominal) + String(clientId) const raw = String(mercantId) + String(statusCode) + String(nominal) + String(clientId)
return crypto.createHash('sha512').update(raw).digest('hex') const sign = crypto.createHash('sha512').update(raw).digest('hex')
return sign;
} catch { } catch {
return '' return ''
} }
} }
async function notifyERP({ orderId, nominal }) { // Resolve mercant_id untuk sebuah order_id:
// 1) gunakan map yang tersimpan dari createtransaksi
// 2) jika order_id memakai skema "mercant_id:item_id", ambil prefix sebelum ':'
// 3) fallback ke ERP_MERCANT_ID dari env (untuk kasus lama)
function resolveMercantId(orderId) {
try {
if (orderMerchantId.has(orderId)) return orderMerchantId.get(orderId)
if (typeof orderId === 'string' && orderId.includes(':')) {
const [m] = orderId.split(':')
if (m) return m
}
} catch {}
return ''
}
async function notifyERP({ orderId, nominal, mercantId }) {
if (!ERP_ENABLE_NOTIF) { if (!ERP_ENABLE_NOTIF) {
logInfo('erp.notify.skip', { reason: 'disabled' }) logInfo('erp.notify.skip', { reason: 'disabled' })
return return false
} }
if (!ERP_NOTIFICATION_URL || !ERP_CLIENT_ID || !ERP_MERCANT_ID) { // Untuk notifikasi dinamis, hanya URL dan client secret yang wajib
logWarn('erp.notify.missing_config', { hasUrl: !!ERP_NOTIFICATION_URL, hasClientId: !!ERP_CLIENT_ID, hasMercantId: !!ERP_MERCANT_ID }) if (ERP_NOTIFICATION_URLS.length === 0 || !ERP_CLIENT_SECRET) {
return logWarn('erp.notify.missing_config', { urlsCount: ERP_NOTIFICATION_URLS.length, hasClientSecret: !!ERP_CLIENT_SECRET })
return false
} }
const statusCode = '200' const statusCode = '200'
const signature = computeErpSignature(ERP_MERCANT_ID, statusCode, nominal, ERP_CLIENT_ID) const mId = mercantId || resolveMercantId(orderId)
if (!mId) {
logWarn('erp.notify.skip', { orderId, reason: 'missing_mercant_id' })
return false
}
const signature = await computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_SECRET)
const payload = { const payload = {
data: { mercant_id: mId,
mercant_id: ERP_MERCANT_ID, status_code: statusCode,
status_code: statusCode, nominal: nominal,
nominal: nominal, signature: signature,
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 })
} }
// Tambahan debug: pastikan signature benar-benar ikut terkirim
logDebug('erp.notify.payload.outgoing', {
mercant_id: mId,
status_code: statusCode,
nominal: nominal,
signature_present: typeof signature !== 'undefined',
signature_length: (typeof signature === 'string') ? signature.length : -1,
})
logInfo('erp.notify.start', { orderId, urls: ERP_NOTIFICATION_URLS })
const results = await Promise.allSettled(
ERP_NOTIFICATION_URLS.map(async (url) => {
try {
const res = await postJson(url, payload)
logInfo('erp.notify.success', { orderId, url, status: res.status })
return true
} catch (e) {
logError('erp.notify.error', {
orderId,
url,
status: e?.statusCode,
message: e?.message,
body: typeof e?.body === 'string' ? e.body.slice(0, 256) : undefined,
})
return false
}
})
)
const okCount = results.reduce((acc, r) => acc + (r.status === 'fulfilled' && r.value ? 1 : 0), 0)
const failCount = ERP_NOTIFICATION_URLS.length - okCount
logInfo('erp.notify.summary', { orderId, okCount, failCount, total: ERP_NOTIFICATION_URLS.length })
return okCount > 0
} }
// Webhook endpoint for Midtrans notifications // Webhook endpoint for Midtrans notifications
@ -291,10 +622,17 @@ app.post('/api/payments/webhook', async (req, res) => {
// Process success callbacks asynchronously // Process success callbacks asynchronously
if (isSuccessfulMidtransStatus(body)) { if (isSuccessfulMidtransStatus(body)) {
logInfo('webhook.success_status', { order_id: orderId, transaction_status: body?.transaction_status, fraud_status: body?.fraud_status })
const nominal = String(grossAmount) const nominal = String(grossAmount)
if (!notifiedOrders.has(orderId)) { if (!notifiedOrders.has(orderId)) {
notifiedOrders.add(orderId) // Mark order inactive upon completion
await notifyERP({ orderId, nominal }) activeOrders.delete(orderId)
const ok = await notifyERP({ orderId, nominal })
if (ok) {
notifiedOrders.add(orderId)
} else {
logWarn('erp.notify.defer', { orderId, reason: 'post_failed_or_missing_data' })
}
} else { } else {
logInfo('erp.notify.skip', { orderId, reason: 'already_notified' }) logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
} }
@ -307,6 +645,59 @@ app.post('/api/payments/webhook', async (req, res) => {
} }
}) })
// Dev-only helpers: echo endpoint and manual ERP notify trigger
if (LOG_EXPOSE_API) {
// Echo incoming JSON for local testing (can be used as ERP_NOTIFICATION_URL)
app.post('/api/echo', async (req, res) => {
try {
const body = req.body || {}
const sig = body?.signature
logDebug('erp.mock.receive', {
has_signature: typeof sig !== 'undefined',
signature_length: (typeof sig === 'string') ? sig.length : -1,
body_length: JSON.stringify(body).length,
})
return res.json({ ok: true, received: body })
} catch (e) {
logError('erp.mock.error', { message: e?.message })
return res.status(500).json({ error: 'ECHO_ERROR', message: e?.message || 'Echo failed' })
}
})
// Echo kedua untuk pengujian multi-URL lokal
app.post('/api/echo2', async (req, res) => {
try {
const body = req.body || {}
const sig = body?.signature
logDebug('erp.mock.receive', {
endpoint: 'echo2',
has_signature: typeof sig !== 'undefined',
signature_length: (typeof sig === 'string') ? sig.length : -1,
body_length: JSON.stringify(body).length,
})
return res.json({ ok: true, received: body })
} catch (e) {
logError('erp.mock.error', { endpoint: 'echo2', message: e?.message })
return res.status(500).json({ error: 'ECHO2_ERROR', message: e?.message || 'Echo2 failed' })
}
})
// Manually trigger ERP notification for testing signature presence in body
app.post('/api/test/notify-erp', async (req, res) => {
try {
const { orderId, nominal, mercant_id } = req.body || {}
if (!orderId && !mercant_id) {
return res.status(400).json({ error: 'BAD_REQUEST', message: 'Provide orderId or mercant_id and nominal' })
}
const ok = await notifyERP({ orderId, nominal: String(nominal || ''), mercantId: mercant_id })
return res.json({ ok })
} catch (e) {
logError('test.notify.error', { message: e?.message })
return res.status(500).json({ error: 'TEST_NOTIFY_ERROR', message: e?.message || 'Notify test failed' })
}
})
}
const port = process.env.PORT || 8000 const port = process.env.PORT || 8000
app.listen(port, () => { app.listen(port, () => {
console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`) console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`)

View File

@ -1,9 +1,10 @@
import { Link, Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { ToastHost } from '../components/ui/toast'
export function AppLayout() { export function AppLayout() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<header className="border-b border-black/10 dark:border-white/20 bg-white dark:bg-black"> {/* <header className="border-b border-black/10 dark:border-white/20 bg-white dark:bg-black">
<div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between"> <div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between">
<Link to="/checkout" className="font-semibold text-brand-600">Core Midtrans CIFO</Link> <Link to="/checkout" className="font-semibold text-brand-600">Core Midtrans CIFO</Link>
<nav className="flex gap-4 text-sm"> <nav className="flex gap-4 text-sm">
@ -11,13 +12,14 @@ export function AppLayout() {
<Link to="/history" className="hover:underline">History</Link> <Link to="/history" className="hover:underline">History</Link>
</nav> </nav>
</div> </div>
</header> </header> */}
<main className="mx-auto max-w-5xl px-4 py-6"> <main className="mx-auto max-w-5xl px-4 py-6">
<Outlet /> <Outlet />
</main> </main>
<footer className="mx-auto max-w-5xl px-4 pb-8 text-xs text-black/60 dark:text-white/60"> <ToastHost />
{/* <footer className="mx-auto max-w-5xl px-4 pb-8 text-xs text-black/60 dark:text-white/60">
Brand: MerahHitamPutih Sandbox UI skeleton Brand: MerahHitamPutih Sandbox UI skeleton
</footer> </footer> */}
</div> </div>
) )
} }

View File

@ -1,10 +1,12 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom' import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { AppLayout } from './AppLayout' import { AppLayout } from './AppLayout'
import { CheckoutPage } from '../pages/CheckoutPage' // import { CheckoutPage } from '../pages/CheckoutPage'
import { PaymentStatusPage } from '../pages/PaymentStatusPage' import { PaymentStatusPage } from '../pages/PaymentStatusPage'
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage' import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
import { NotFoundPage } from '../pages/NotFoundPage' import { NotFoundPage } from '../pages/NotFoundPage'
import { DemoStorePage } from '../pages/DemoStorePage' // import { DemoStorePage } from '../pages/DemoStorePage'
import { InitPage } from '../pages/InitialPage'
import { PayPage } from '../pages/PayPage'
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -12,8 +14,9 @@ const router = createBrowserRouter([
element: <AppLayout />, element: <AppLayout />,
errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>, errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>,
children: [ children: [
{ index: true, element: <DemoStorePage /> }, { index: true, element: <InitPage /> },
{ path: 'checkout', element: <CheckoutPage /> }, // { path: 'checkout', element: <CheckoutPage /> },
{ path: 'pay/:token', element: <PayPage /> },
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> }, { path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
{ path: 'history', element: <PaymentHistoryPage /> }, { path: 'history', element: <PaymentHistoryPage /> },
{ path: '*', element: <NotFoundPage /> }, { path: '*', element: <NotFoundPage /> },

View File

@ -4,7 +4,7 @@ export function Alert({ title, children }: { title: string; children?: React.Rea
<div className="text-brand-600"></div> <div className="text-brand-600"></div>
<div> <div>
<div className="font-semibold">{title}</div> <div className="font-semibold">{title}</div>
{children ? <div className="text-sm text-black/70 dark:text-white/70">{children}</div> : null} {children ? <div className="text-sm text-black/70">{children}</div> : null}
</div> </div>
</div> </div>
) )

View File

@ -2,13 +2,13 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
const buttonVariants = cva( const buttonVariants = cva(
'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', '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 disabled:opacity-50 disabled:pointer-events-none',
{ {
variants: { variants: {
variant: { variant: {
primary: 'bg-brand-600 text-white hover:bg-brand-700', primary: 'bg-brand-600 text-white hover:bg-brand-700',
secondary: 'bg-white text-black hover:bg-white/90 dark:bg-black dark:text-white dark:hover:bg-black/90', secondary: 'bg-white text-black border border-black/10 hover:bg-black/5',
outline: 'border border-black text-black hover:bg-black/5 dark:border-white dark:text-white dark:hover:bg-white/10', outline: 'border border-black text-black hover:bg-black/5',
}, },
size: { sm: 'h-8 px-3', md: 'h-10 px-4', lg: 'h-11 px-6' }, size: { sm: 'h-8 px-3', md: 'h-10 px-4', lg: 'h-11 px-6' },
}, },

View File

@ -0,0 +1,57 @@
import React from 'react'
type ToastKind = 'info' | 'success' | 'error'
type ToastItem = { id: number; message: string; kind: ToastKind }
let nextId = 1
const subs = new Set<(t: ToastItem) => void>()
export const toast = {
show(message: string, kind: ToastKind = 'info') {
const item: ToastItem = { id: nextId++, message, kind }
subs.forEach((fn) => fn(item))
},
info(message: string) { toast.show(message, 'info') },
success(message: string) { toast.show(message, 'success') },
error(message: string) { toast.show(message, 'error') },
}
export function ToastHost() {
const [items, setItems] = React.useState<ToastItem[]>([])
React.useEffect(() => {
function onPush(t: ToastItem) {
setItems((prev) => [...prev, t])
// auto-remove after 3.5s
setTimeout(() => {
setItems((prev) => prev.filter((x) => x.id !== t.id))
}, 3500)
}
subs.add(onPush)
return () => { subs.delete(onPush) }
}, [])
if (!items.length) return null
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{items.map((it) => (
<div
key={it.id}
role="status"
aria-live="polite"
className={
`min-w-[240px] max-w-[360px] rounded shadow-lg border p-3 text-sm ` +
(it.kind === 'success'
? 'bg-green-600 text-white border-green-600'
: it.kind === 'error'
? 'bg-red-600 text-white border-red-600'
: 'bg-black text-white border-black')
}
>
{it.message}
</div>
))}
</div>
)
}

View File

@ -3,11 +3,11 @@ import { usePaymentNavigation } from '../lib/navigation'
import React from 'react' import React from 'react'
import { PaymentInstructions } from './PaymentInstructions' import { PaymentInstructions } from './PaymentInstructions'
import { BcaInstructionList } from './BcaInstructionList' import { BcaInstructionList } from './BcaInstructionList'
import { TrustStrip } from './TrustStrip'
import { type BankKey } from './PaymentLogos' import { type BankKey } from './PaymentLogos'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
import { Alert } from '../../../components/alert/Alert' import { Alert } from '../../../components/alert/Alert'
import { InlinePaymentStatus } from './InlinePaymentStatus' import { InlinePaymentStatus } from './InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts // Global guard to prevent duplicate auto-charge across StrictMode double-mounts
const attemptedChargeKeys = new Set<string>() const attemptedChargeKeys = new Set<string>()
@ -16,7 +16,7 @@ const chargeTasks = new Map<string, Promise<any>>()
export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, defaultBank }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultBank?: BankKey }) { export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, defaultBank }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultBank?: BankKey }) {
const nav = usePaymentNavigation() const nav = usePaymentNavigation()
const [selected, setSelected] = React.useState<BankKey | null>(defaultBank ?? null) const [selected] = React.useState<BankKey | null>(defaultBank ?? null)
const [showGuide, setShowGuide] = React.useState(false) const [showGuide, setShowGuide] = React.useState(false)
const [busy, setBusy] = React.useState(false) const [busy, setBusy] = React.useState(false)
const [vaCode, setVaCode] = React.useState('') const [vaCode, setVaCode] = React.useState('')
@ -29,7 +29,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
function copy(text: string, label: string) { function copy(text: string, label: string) {
if (!text) return if (!text) return
navigator.clipboard?.writeText(text) navigator.clipboard?.writeText(text)
alert(`${label} disalin: ${text}`) toast.success(`${label} disalin: ${text}`)
} }
// Auto-create VA immediately when a bank is selected (runs once per selection) // Auto-create VA immediately when a bank is selected (runs once per selection)
React.useEffect(() => { React.useEffect(() => {
@ -125,7 +125,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
} }
run() run()
return () => { cancelled = true } return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected]) }, [selected])
// Auto-show instructions when BCA is selected to reduce confusion // Auto-show instructions when BCA is selected to reduce confusion
@ -137,43 +137,43 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="font-medium">Transfer bank</div> <div className="font-medium">Transfer Bank</div>
{selected && ( {selected && (
<div className="flex items-center gap-2 text-base"> <div className="flex items-center gap-2 text-base">
<span className="text-black/60 dark:text-white/60">Bank:</span> <span className="text-black/60">Bank:</span>
<span className="text-black/80 dark:text-white/80 font-semibold">{selected.toUpperCase()}</span> <span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
</div> </div>
)} )}
<div className="text-sm text-black/70 dark:text-white/70">VA dibuat otomatis sesuai bank pilihan Anda.</div> <div className="text-sm text-black/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
{errorMessage && ( {errorMessage && (
<Alert title="Gagal membuat VA">{errorMessage}</Alert> <Alert title="Gagal membuat VA">{errorMessage}</Alert>
)} )}
{selected && ( {selected && (
<div className="pt-1"> <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="rounded-lg p-3 border-2 border-black/30">
<div className="text-sm font-medium mb-2">Virtual Account</div> <div className="text-sm font-medium mb-2">Virtual Account</div>
<div className="text-sm text-black/70 dark:text-white/70"> <div className="text-sm text-black/70">
{vaCode ? ( {vaCode ? (
<span> <span>
Nomor VA: 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 className="block break-all mt-1 font-mono text-xl sm:text-2xl md:text-3xl font-semibold tracking-normal text-black">{vaCode}</span>
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-2" role="status" aria-live="polite"> <span className="inline-flex items-center gap-2" role="status" aria-live="polite">
{busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />} {busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />}
{busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'} {busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'}
</span> </span>
)} )}
{billKey && ( {billKey && (
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black dark:text-white">{billKey}</span></span> <span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black">{billKey}</span></span>
)} )}
{billerCode && ( {billerCode && (
<span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black dark:text-white">{billerCode}</span></span> <span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black">{billerCode}</span></span>
)} )}
</div> </div>
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<Button variant="outline" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button> <Button variant="secondary" 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> <Button variant="secondary" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button>
</div> </div>
</div> </div>
</div> </div>
@ -187,7 +187,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
{selected === 'bca' ? ( {selected === 'bca' ? (
<BcaInstructionList /> <BcaInstructionList />
) : ( ) : (
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20"> <div className="rounded-lg border-2 border-black/30 p-3 bg-white">
<div className="text-sm font-medium mb-2">Instruksi pembayaran</div> <div className="text-sm font-medium mb-2">Instruksi pembayaran</div>
<PaymentInstructions method="bank_transfer" /> <PaymentInstructions method="bank_transfer" />
</div> </div>
@ -195,7 +195,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
</div> </div>
)} )}
{locked && ( {locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.</div> <div className="text-xs text-black/60">Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.</div>
)} )}
<div className="pt-2 space-y-2"> <div className="pt-2 space-y-2">
{(!vaCode || errorMessage) && ( {(!vaCode || errorMessage) && (
@ -280,7 +280,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
> >
{busy ? ( {busy ? (
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite"> <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 /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
Membuat VA Membuat VA
</span> </span>
) : 'Buat VA'} ) : 'Buat VA'}
@ -295,7 +295,6 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
Buka halaman status Buka halaman status
</Button> </Button>
</div> </div>
<TrustStrip />
</div> </div>
) )
} }

View File

@ -5,13 +5,13 @@ type TabKey = 'mobile' | 'atm' | 'ib'
export function BcaInstructionList() { export function BcaInstructionList() {
const [tab, setTab] = React.useState<TabKey>('mobile') const [tab, setTab] = React.useState<TabKey>('mobile')
return ( return (
<div className="mt-2 rounded-lg border-2 border-black/20 dark:border-white/20 bg-white dark:bg-black/20"> <div className="mt-2 rounded-lg border-2 border-gray-300 bg-white">
<div className="p-3"> <div className="p-3">
<div className="text-sm font-medium mb-2">Instruksi BCA</div> <div className="text-sm font-medium mb-2">Instruksi BCA</div>
<div <div
role="tablist" role="tablist"
aria-label="Metode pembayaran BCA" aria-label="Metode pembayaran BCA"
className="inline-flex mb-3 rounded-md border border-black/20 dark:border-white/20 overflow-hidden" className="inline-flex mb-3 rounded-md border border-gray-300 overflow-hidden"
> >
<button <button
type="button" type="button"
@ -20,7 +20,7 @@ export function BcaInstructionList() {
aria-selected={tab === 'mobile'} aria-selected={tab === 'mobile'}
aria-controls="panel-bca-mobile" aria-controls="panel-bca-mobile"
onClick={() => setTab('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'}`} className={`px-3 py-2 text-xs font-medium focus:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${tab === 'mobile' ? 'bg-black text-white' : 'bg-transparent hover:bg-gray-100'}`}
> >
BCA Mobile BCA Mobile
</button> </button>
@ -31,7 +31,7 @@ export function BcaInstructionList() {
aria-selected={tab === 'atm'} aria-selected={tab === 'atm'}
aria-controls="panel-bca-atm" aria-controls="panel-bca-atm"
onClick={() => setTab('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'}`} className={`px-3 py-2 text-xs font-medium focus:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${tab === 'atm' ? 'bg-black text-white' : 'bg-transparent hover:bg-gray-100'}`}
> >
ATM BCA ATM BCA
</button> </button>
@ -42,7 +42,7 @@ export function BcaInstructionList() {
aria-selected={tab === 'ib'} aria-selected={tab === 'ib'}
aria-controls="panel-bca-ib" aria-controls="panel-bca-ib"
onClick={() => setTab('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'}`} className={`px-3 py-2 text-xs font-medium focus:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${tab === 'ib' ? 'bg-black text-white' : 'bg-transparent hover:bg-gray-100'}`}
> >
KlikBCA KlikBCA
</button> </button>
@ -63,7 +63,7 @@ export function BcaInstructionList() {
</div> </div>
)} )}
</div> </div>
<div className="border-t border-black/10 dark:border-white/10 p-3 text-xs text-black/60 dark:text-white/60"> <div className="border-t border-gray-200 p-3 text-xs text-gray-600">
Catatan: Gunakan label "Nomor VA" secara konsisten di semua metode. Catatan: Gunakan label "Nomor VA" secara konsisten di semua metode.
</div> </div>
</div> </div>
@ -111,7 +111,7 @@ function StepsKlikBca() {
function StepList({ steps }: { steps: string[] }) { function StepList({ steps }: { steps: string[] }) {
return ( return (
<ol className="list-decimal list-inside space-y-2 text-sm text-black/80 dark:text-white/80"> <ol className="list-decimal list-inside space-y-2 text-sm text-gray-800">
{steps.map((s, i) => ( {steps.map((s, i) => (
<li key={i}>{s}</li> <li key={i}>{s}</li>
))} ))}

View File

@ -1,8 +1,8 @@
import { Button } from '../../../components/ui/button' import { Button } from '../../../components/ui/button'
import { toast } from '../../../components/ui/toast'
import { usePaymentNavigation } from '../lib/navigation' import { usePaymentNavigation } from '../lib/navigation'
import React from 'react' import React from 'react'
import { PaymentInstructions } from './PaymentInstructions' import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus' import { InlinePaymentStatus } from './InlinePaymentStatus'
@ -14,7 +14,7 @@ const cstoreTasks = new Map<string, Promise<any>>()
export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaultStore }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultStore?: StoreKey }) { export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaultStore }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultStore?: StoreKey }) {
const nav = usePaymentNavigation() const nav = usePaymentNavigation()
const [selected, setSelected] = React.useState<StoreKey | null>(defaultStore ?? null) const [selected] = React.useState<StoreKey | null>(defaultStore ?? null)
const [showGuide, setShowGuide] = React.useState(false) const [showGuide, setShowGuide] = React.useState(false)
const [busy, setBusy] = React.useState(false) const [busy, setBusy] = React.useState(false)
const [paymentCode, setPaymentCode] = React.useState('') const [paymentCode, setPaymentCode] = React.useState('')
@ -36,7 +36,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
if (typeof res?.store === 'string') setStoreFromRes(res.store) if (typeof res?.store === 'string') setStoreFromRes(res.store)
} }
} catch (e) { } catch (e) {
if (!cancelled) alert(`Gagal membuat kode pembayaran: ${(e as Error).message}`) if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
} finally { } finally {
if (!cancelled) setBusy(false) if (!cancelled) setBusy(false)
cstoreTasks.delete(chargeKey) cstoreTasks.delete(chargeKey)
@ -60,7 +60,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
if (typeof res?.store === 'string') setStoreFromRes(res.store) if (typeof res?.store === 'string') setStoreFromRes(res.store)
} }
} catch (e) { } catch (e) {
if (!cancelled) alert(`Gagal membuat kode pembayaran: ${(e as Error).message}`) if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
attemptedCStoreKeys.delete(chargeKey) attemptedCStoreKeys.delete(chargeKey)
} finally { } finally {
if (!cancelled) setBusy(false) if (!cancelled) setBusy(false)
@ -74,14 +74,14 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
function copy(text: string, label: string) { function copy(text: string, label: string) {
if (!text) return if (!text) return
navigator.clipboard?.writeText(text) navigator.clipboard?.writeText(text)
alert(`${label} disalin: ${text}`) toast.success(`${label} disalin: ${text}`)
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="font-medium">Convenience Store</div> <div className="font-medium">Convenience Store</div>
{selected && ( {selected && (
<div className="text-xs text-black/60 dark:text-white/60">Toko dipilih: <span className="font-medium text-black dark:text-white">{selected.toUpperCase()}</span></div> <div className="text-xs text-gray-600">Toko dipilih: <span className="font-medium text-gray-900">{selected.toUpperCase()}</span></div>
)} )}
<button <button
type="button" type="button"
@ -93,17 +93,17 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
</button> </button>
{showGuide && <PaymentInstructions method="cstore" />} {showGuide && <PaymentInstructions method="cstore" />}
{locked && ( {locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.</div> <div className="text-xs text-gray-600">Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.</div>
)} )}
<div className="pt-2 space-y-2"> <div className="pt-2 space-y-2">
<div className="rounded border border-black/10 dark:border-white/10 p-2 text-sm" aria-live="polite"> <div className="rounded border border-gray-300 p-2 text-sm" aria-live="polite">
<div className="font-medium">Kode Pembayaran</div> <div className="font-medium">Kode Pembayaran</div>
{!selected && ( {!selected && (
<div className="text-xs text-black/60 dark:text-white/60">Pilih toko terlebih dahulu di langkah sebelumnya.</div> <div className="text-xs text-gray-600">Pilih toko terlebih dahulu di langkah sebelumnya.</div>
)} )}
{selected && busy && ( {selected && busy && (
<div className="inline-flex items-center gap-2 text-xs text-black/60 dark:text-white/60"> <div className="inline-flex items-center gap-2 text-xs text-gray-600">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
Membuat kode Membuat kode
</div> </div>
)} )}
@ -125,7 +125,6 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
</Button> </Button>
<InlinePaymentStatus orderId={orderId} method="cstore" /> <InlinePaymentStatus orderId={orderId} method="cstore" />
</div> </div>
<TrustStrip />
</div> </div>
) )
} }

View File

@ -2,13 +2,13 @@ import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation' import { usePaymentNavigation } from '../lib/navigation'
import React from 'react' import React from 'react'
import { PaymentInstructions } from './PaymentInstructions' import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { CardLogosRow } from './PaymentLogos' import { CardLogosRow } from './PaymentLogos'
import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds' import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds'
import { Logger } from '../../../lib/logger' import { Logger } from '../../../lib/logger'
import { Env } from '../../../lib/env' import { Env } from '../../../lib/env'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus' import { InlinePaymentStatus } from './InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) { export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
const nav = usePaymentNavigation() const nav = usePaymentNavigation()
@ -41,16 +41,16 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
<CardLogosRow compact /> <CardLogosRow compact />
<div className="space-y-2"> <div className="space-y-2">
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Nomor kartu</div> <div className="text-xs text-gray-600">Nomor kartu</div>
<input type="text" inputMode="numeric" maxLength={23} placeholder="0000-0000-0000-0000" className="w-full rounded border px-3 py-2" value={cardNumber} onChange={(e) => setCardNumber(formatCard(e.target.value))} /> <input type="text" inputMode="numeric" maxLength={23} placeholder="0000-0000-0000-0000" className="w-full rounded border px-3 py-2" value={cardNumber} onChange={(e) => setCardNumber(formatCard(e.target.value))} />
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Masa berlaku</div> <div className="text-xs text-gray-600">Masa berlaku</div>
<input type="text" inputMode="numeric" maxLength={5} placeholder="MM/YY" className="w-full rounded border px-3 py-2" value={exp} onChange={(e) => setExp(formatExp(e.target.value))} /> <input type="text" inputMode="numeric" maxLength={5} placeholder="MM/YY" className="w-full rounded border px-3 py-2" value={exp} onChange={(e) => setExp(formatExp(e.target.value))} />
</label> </label>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">CVV</div> <div className="text-xs text-gray-600">CVV</div>
<input type="password" inputMode="numeric" maxLength={4} placeholder="123" className="w-full rounded border px-3 py-2" value={cvv} onChange={(e) => setCvv(formatCvv(e.target.value))} /> <input type="password" inputMode="numeric" maxLength={4} placeholder="123" className="w-full rounded border px-3 py-2" value={cvv} onChange={(e) => setCvv(formatCvv(e.target.value))} />
</label> </label>
</div> </div>
@ -69,7 +69,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
</button> </button>
{showGuide && <PaymentInstructions method="credit_card" />} {showGuide && <PaymentInstructions method="credit_card" />}
{locked && ( {locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Lanjutkan verifikasi 3DS/OTP pada kartu Anda.</div> <div className="text-xs text-gray-600">Metode terkunci. Lanjutkan verifikasi 3DS/OTP pada kartu Anda.</div>
)} )}
<div className="pt-2 space-y-2"> <div className="pt-2 space-y-2">
<Button <Button
@ -89,24 +89,24 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
// Minimal validation // Minimal validation
if (sanitizedCard.length < 13 || sanitizedCard.length > 19) { if (sanitizedCard.length < 13 || sanitizedCard.length > 19) {
Logger.warn('card.input.invalid', { reason: 'card_number_length', length: sanitizedCard.length }) Logger.warn('card.input.invalid', { reason: 'card_number_length', length: sanitizedCard.length })
alert('Nomor kartu tidak valid. Harus 1319 digit.') toast.error('Nomor kartu tidak valid. Harus 1319 digit.')
return return
} }
const mmNum = Number(mm) const mmNum = Number(mm)
if (!mm || mm.length !== 2 || mmNum < 1 || mmNum > 12) { if (!mm || mm.length !== 2 || mmNum < 1 || mmNum > 12) {
Logger.warn('card.input.invalid', { reason: 'exp_month', value: mm }) Logger.warn('card.input.invalid', { reason: 'exp_month', value: mm })
alert('Format bulan kedaluwarsa tidak valid (MM).') toast.error('Format bulan kedaluwarsa tidak valid (MM).')
return return
} }
if (!yy || yy.length < 2) { if (!yy || yy.length < 2) {
Logger.warn('card.input.invalid', { reason: 'exp_year', value: yy }) Logger.warn('card.input.invalid', { reason: 'exp_year', value: yy })
alert('Format tahun kedaluwarsa tidak valid (YY).') toast.error('Format tahun kedaluwarsa tidak valid (YY).')
return return
} }
const cvvSan = cvv.replace(/[^0-9]/g, '') const cvvSan = cvv.replace(/[^0-9]/g, '')
if (cvvSan.length < 3 || cvvSan.length > 4) { if (cvvSan.length < 3 || cvvSan.length > 4) {
Logger.warn('card.input.invalid', { reason: 'cvv_length', length: cvvSan.length }) Logger.warn('card.input.invalid', { reason: 'cvv_length', length: cvvSan.length })
alert('CVV harus 34 digit.') toast.error('CVV harus 34 digit.')
return return
} }
if (Env.LOG_LEVEL === 'debug') { if (Env.LOG_LEVEL === 'debug') {
@ -132,7 +132,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
} }
} catch (e) { } catch (e) {
Logger.error('card.process.error', { message: (e as Error)?.message }) Logger.error('card.process.error', { message: (e as Error)?.message })
alert(`Gagal memproses kartu: ${(e as Error).message}`) toast.error(`Gagal memproses kartu: ${(e as Error).message}`)
} finally { } finally {
setBusy(false) setBusy(false)
} }
@ -140,7 +140,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
> >
{busy ? ( {busy ? (
<span className="inline-flex items-center justify-center gap-2"> <span className="inline-flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 dark:border-black/40 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
Memproses pembayaran Memproses pembayaran
</span> </span>
) : 'Bayar sekarang'} ) : 'Bayar sekarang'}
@ -156,7 +156,6 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
</div> </div>
{/* Status inline dengan polling otomatis */} {/* Status inline dengan polling otomatis */}
<InlinePaymentStatus orderId={orderId} method="credit_card" compact /> <InlinePaymentStatus orderId={orderId} method="credit_card" compact />
<TrustStrip />
</div> </div>
) )
} }

View File

@ -2,10 +2,10 @@ import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation' import { usePaymentNavigation } from '../lib/navigation'
import React from 'react' import React from 'react'
import { PaymentInstructions } from './PaymentInstructions' import { PaymentInstructions } from './PaymentInstructions'
import { TrustStrip } from './TrustStrip'
import { GoPayLogosRow } from './PaymentLogos' import { GoPayLogosRow } from './PaymentLogos'
import { postCharge } from '../../../services/api' import { postCharge } from '../../../services/api'
import { InlinePaymentStatus } from './InlinePaymentStatus' import { InlinePaymentStatus } from './InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
// Global guards/tasks to stabilize QR generation across StrictMode remounts // Global guards/tasks to stabilize QR generation across StrictMode remounts
const attemptedChargeKeys = new Set<string>() const attemptedChargeKeys = new Set<string>()
@ -26,15 +26,7 @@ function pickQrImageUrl(res: any, acts: Array<{ name?: string; method?: string;
return '' return ''
} }
function QRPlaceholder() { //
return (
<div className="aspect-square w-full max-w-[220px] mx-auto bg-white dark:bg-black grid grid-cols-9 grid-rows-9">
{Array.from({ length: 81 }).map((_, i) => (
<div key={i} className={(i + Math.floor(i / 9)) % 2 === 0 ? 'bg-black' : 'bg-white'} />
))}
</div>
)
}
export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) { export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
const nav = usePaymentNavigation() const nav = usePaymentNavigation()
@ -56,7 +48,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
a.target = '_blank' a.target = '_blank'
a.click() a.click()
} else { } else {
alert('QR belum tersedia. Klik "Buat QR" terlebih dulu.') toast.error('QR belum tersedia. Klik "Buat QR" terlebih dulu.')
} }
} }
return ( return (
@ -76,13 +68,13 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
<div className="font-medium">GoPay / QRIS</div> <div className="font-medium">GoPay / QRIS</div>
<GoPayLogosRow compact /> <GoPayLogosRow compact />
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="text-black/60 dark:text-white/60">Mode:</span> <span className="text-black/60">Mode:</span>
<div className="inline-flex rounded-md border-2 border-black/20 dark:border-white/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran"> <div className="inline-flex rounded-md border-2 border-black/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran">
<button <button
type="button" type="button"
onClick={() => setMode('gopay')} onClick={() => setMode('gopay')}
aria-pressed={mode==='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'}`} className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode==='gopay' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
> >
GoPay GoPay
</button> </button>
@ -90,25 +82,25 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
type="button" type="button"
onClick={() => setMode('qris')} onClick={() => setMode('qris')}
aria-pressed={mode==='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'}`} className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode==='qris' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
> >
QRIS QRIS
</button> </button>
</div> </div>
</div> </div>
<div className="rounded border border-black/10 dark:border-white/10 p-3 flex flex-col items-center gap-2"> <div className="rounded border border-black/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="text-xs text-black/60">Scan QR berikut menggunakan aplikasi {mode === 'gopay' ? 'GoPay' : 'QRIS'}</div>
<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"> <div className="relative w-full max-w-[280px] aspect-square grid place-items-center rounded-md border border-black/20 bg-white">
{mode === 'qris' && (!qrUrl || busy) ? ( {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="inline-flex items-center justify-center gap-2 text-xs text-black/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 /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
Membuat QR Membuat QR
</span> </span>
) : qrUrl ? ( ) : qrUrl ? (
<img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto" /> <img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto" />
) : null} ) : null}
</div> </div>
<div className="text-[10px] text-black/50 dark:text-white/50">Mode: {mode.toUpperCase()}</div> <div className="text-[10px] text-black/50">Mode: {mode.toUpperCase()}</div>
</div> </div>
<div className="flex flex-col sm:flex-row gap-2 w-full"> <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={downloadQR} disabled={!qrUrl}>Download QR</Button>
@ -133,7 +125,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
/> />
</div> </div>
{locked && ( {locked && (
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div> <div className="text-xs text-black/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div>
)} )}
<div className="pt-2"> <div className="pt-2">
<InlinePaymentStatus orderId={orderId} method={mode} /> <InlinePaymentStatus orderId={orderId} method={mode} />
@ -147,14 +139,13 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
> >
{busy ? ( {busy ? (
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite"> <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 /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
Menuju status Menuju status
</span> </span>
) : 'Buka halaman status'} ) : 'Buka halaman status'}
</Button> </Button>
</div> </div>
</div> </div>
<TrustStrip />
</div> </div>
) )
} }
@ -184,7 +175,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
onChargeInitiated?.() onChargeInitiated?.()
} }
} catch (e) { } catch (e) {
if (!cancelled) alert(`Gagal membuat QR: ${(e as Error).message}`) if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`)
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setBusy(false) setBusy(false)
@ -213,7 +204,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
onChargeInitiated?.() onChargeInitiated?.()
} }
} catch (e) { } catch (e) {
if (!cancelled) alert(`Gagal membuat QR: ${(e as Error).message}`) if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`)
attemptedChargeKeys.delete(chargeKey) attemptedChargeKeys.delete(chargeKey)
} finally { } finally {
if (!cancelled) { if (!cancelled) {

View File

@ -1,4 +1,3 @@
import React from 'react'
import { Button } from '../../../components/ui/button' import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation' import { usePaymentNavigation } from '../lib/navigation'
import { usePaymentStatus } from '../lib/usePaymentStatus' import { usePaymentStatus } from '../lib/usePaymentStatus'
@ -15,12 +14,11 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
const nav = usePaymentNavigation() const nav = usePaymentNavigation()
const { data, isLoading, error, refetch, isRefetching } = usePaymentStatus(orderId) const { data, isLoading, error, refetch, isRefetching } = usePaymentStatus(orderId)
const status = (data?.status ?? 'pending') as PaymentStatusResponse['status'] 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 isSuccess = status === 'settlement' || status === 'capture'
const isFailure = ['deny', 'cancel', 'expire', 'refund', 'chargeback'].includes(status) const isFailure = ['deny', 'cancel', 'expire', 'refund', 'chargeback'].includes(status)
return ( 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"> <div className={`rounded border ${compact ? 'p-2' : 'p-3'} border-black/10 bg-white`} aria-live="polite">
{/* Header minimal tanpa detail teknis */} {/* Header minimal tanpa detail teknis */}
<div className="text-sm font-medium">Status pembayaran</div> <div className="text-sm font-medium">Status pembayaran</div>
@ -28,10 +26,10 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
{isLoading ? ( {isLoading ? (
<div className="mt-2 text-sm"> <div className="mt-2 text-sm">
<span className="inline-flex items-center gap-2" role="status"> <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 /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
Mengecek pembayaran Mengecek pembayaran
</span> </span>
<div className="mt-1 text-[11px] text-black/60 dark:text-white/60">Kami memeriksa otomatis setiap 3 detik.</div> <div className="mt-1 text-[11px] text-black/60">Kami memeriksa otomatis setiap 3 detik.</div>
</div> </div>
) : error ? ( ) : error ? (
<div className="mt-2 text-sm text-brand-600">Gagal memuat status. Coba refresh.</div> <div className="mt-2 text-sm text-brand-600">Gagal memuat status. Coba refresh.</div>
@ -47,9 +45,9 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
<div className="text-base font-semibold">Pembayaran berhasil</div> <div className="text-base font-semibold">Pembayaran berhasil</div>
</div> </div>
{data?.grossAmount ? ( {data?.grossAmount ? (
<div className="mt-1 text-sm text-black/70 dark:text-white/70">Total dibayar: {formatIDR(data.grossAmount)}</div> <div className="mt-1 text-sm text-black/70">Total dibayar: {formatIDR(data.grossAmount)}</div>
) : null} ) : null}
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Terima kasih! Pesanan Anda sedang diproses.</div> <div className="mt-1 text-xs text-black/60">Terima kasih! Pesanan Anda sedang diproses.</div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button className="w-full sm:w-auto" onClick={() => nav.toHistory()}>Lihat riwayat pembayaran</Button> <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.toCheckout()}>Kembali ke checkout</Button>
@ -68,7 +66,7 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
</span> </span>
<div className="text-base font-semibold">Pembayaran belum berhasil</div> <div className="text-base font-semibold">Pembayaran belum berhasil</div>
</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-1 text-xs text-black/60">Silakan coba lagi atau pilih metode lain.</div>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button className="w-full sm:w-auto" onClick={() => nav.toCheckout()}>Coba lagi</Button> <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> <Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toStatus(orderId, method)}>Lihat detail status</Button>
@ -77,18 +75,18 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
) : ( ) : (
<div className="mt-2"> <div className="mt-2">
<div className="flex items-center gap-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"> <span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500/15 text-yellow-700">
{/* hourglass/spinner icon */} {/* 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 className="h-4 w-4 animate-spin rounded-full border-2 border-yellow-600/50 border-t-transparent" aria-hidden />
</span> </span>
<div className="text-base font-semibold">Menunggu pembayaran</div> <div className="text-base font-semibold">Menunggu pembayaran</div>
</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-1 text-[11px] text-black/60">Kami memeriksa otomatis setiap 3 detik sampai selesai.</div>
<div className="mt-3 flex flex-wrap gap-2"> <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}> <Button variant="outline" className="w-full sm:w-auto" onClick={() => refetch()} aria-busy={isRefetching} disabled={isRefetching}>
{isRefetching ? ( {isRefetching ? (
<span className="inline-flex items-center gap-2" role="status" aria-live="polite"> <span className="inline-flex items-center gap-2" role="status" aria-live="polite">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden /> <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
Memuat Memuat
</span> </span>
) : 'Refresh sekarang'} ) : 'Refresh sekarang'}

View File

@ -1,4 +1,3 @@
import React from 'react'
import type { PaymentMethod } from './PaymentMethodList' import type { PaymentMethod } from './PaymentMethodList'
export function PaymentInstructions({ method, title, steps }: { method?: PaymentMethod; title?: string; steps?: string[] }) { export function PaymentInstructions({ method, title, steps }: { method?: PaymentMethod; title?: string; steps?: string[] }) {
@ -6,9 +5,9 @@ export function PaymentInstructions({ method, title, steps }: { method?: Payment
const finalSteps = computed.length ? computed : ['Ikuti instruksi yang muncul pada layar pembayaran.'] const finalSteps = computed.length ? computed : ['Ikuti instruksi yang muncul pada layar pembayaran.']
const finalTitle = title ?? 'Cara bayar' const finalTitle = title ?? 'Cara bayar'
return ( return (
<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="mt-2 rounded-lg border-2 border-gray-300 p-3 bg-white" role="region" aria-label={finalTitle}>
<div className="text-sm font-medium mb-2">{finalTitle}</div> <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"> <ol className="list-decimal list-inside space-y-2 text-sm text-gray-800">
{finalSteps.map((s, i) => ( {finalSteps.map((s, i) => (
<li key={i}>{s}</li> <li key={i}>{s}</li>
))} ))}

View File

@ -1,4 +1,4 @@
import React from 'react' //
export type BankKey = 'bca' | 'bni' | 'bri' | 'cimb' | 'mandiri' | 'permata' export type BankKey = 'bca' | 'bni' | 'bri' | 'cimb' | 'mandiri' | 'permata'
@ -17,7 +17,6 @@ function BrandImg({ src, alt, compact = false, size, fallbackSrc }: { src: strin
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={(e) => { onError={(e) => {
const el = e.currentTarget const el = e.currentTarget
const current = el.src
const proxyUsed = el.dataset.proxy === 'used' const proxyUsed = el.dataset.proxy === 'used'
const fbUsed = el.dataset.fb === 'used' const fbUsed = el.dataset.fb === 'used'
if (fallbackSrc && !fbUsed) { if (fallbackSrc && !fbUsed) {
@ -104,7 +103,7 @@ export function CStoreLogosRow({ compact = false, size }: { compact?: boolean; s
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LogoAlfamart compact={compact} size={size} /> <LogoAlfamart compact={compact} size={size} />
<LogoIndomaret compact={compact} size={size} /> {/* <LogoIndomaret compact={compact} size={size} /> */}
</div> </div>
) )
} }

View File

@ -13,7 +13,7 @@ export interface PaymentMethodListProps {
} }
const baseItems: Array<{ key: PaymentMethod; title: string; subtitle: string; icon?: React.ReactNode }> = [ const baseItems: Array<{ key: PaymentMethod; title: string; subtitle: string; icon?: React.ReactNode }> = [
{ key: 'bank_transfer', title: 'Transfer bank', subtitle: 'BCA • BNI • BRI • CIMB • Mandiri • Permata', icon: <img src="/logos/logo-semua-bank.PNG" alt="Semua bank yang didukung" className="h-6 sm:h-8 object-contain" /> }, { 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: '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: '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: 'cstore', title: 'Convenience Store', subtitle: '', icon: <CStoreLogosRow compact size="xs" /> },
@ -32,26 +32,26 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-sm font-medium">Metode pembayaran</div> <div className="text-sm font-medium">Metode pembayaran</div>
<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"> <div className="rounded-lg border-2 border-black/30 divide-y-[2px] divide-black/20 bg-white">
{items.map((it) => ( {items.map((it) => (
<div key={it.key}> <div key={it.key}>
<button <button
onClick={() => !disabled && onSelect(it.key)} onClick={() => !disabled && onSelect(it.key)}
disabled={disabled} disabled={disabled}
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`} 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'} ${selected === it.key ? 'bg-black/10' : ''} focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white`}
aria-pressed={selected === it.key} aria-pressed={selected === it.key}
aria-expanded={selected === it.key} aria-expanded={selected === it.key}
aria-controls={`panel-${it.key}`} aria-controls={`panel-${it.key}`}
> >
<div className="flex-1"> <div className="flex-1">
<div className="text-base font-semibold text-black dark:text-white">{it.title}</div> <div className="text-base font-semibold text-black">{it.title}</div>
{it.key === 'bank_transfer' && it.subtitle && ( {it.key === 'bank_transfer' && it.subtitle && (
<div className="mt-1 text-xs text-black/60 dark:text-white/60"> <div className="mt-1 text-xs text-black/60">
{it.subtitle} {it.subtitle}
</div> </div>
)} )}
{it.key === 'cpay' && it.subtitle && ( {it.key === 'cpay' && it.subtitle && (
<div className="mt-1 text-xs text-black/60 dark:text-white/60"> <div className="mt-1 text-xs text-black/60">
{it.subtitle} {it.subtitle}
</div> </div>
)} )}
@ -62,11 +62,11 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
{it.icon} {it.icon}
</span> </span>
)} )}
<span aria-hidden className={`text-black/60 dark:text-white/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}></span> <span aria-hidden className={`text-black/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}></span>
</div> </div>
</button> </button>
{selected === it.key && renderPanel && ( {selected === it.key && renderPanel && (
<div id={`panel-${it.key}`} className="p-3 bg-white dark:bg-black/20"> <div id={`panel-${it.key}`} className="p-3 bg-white">
{renderPanel(it.key)} {renderPanel(it.key)}
</div> </div>
)} )}

View File

@ -14,9 +14,10 @@ function useCountdown(expireAt: number) {
}, []) }, [])
const remainMs = Math.max(0, expireAt - now) const remainMs = Math.max(0, expireAt - now)
const totalSec = Math.floor(remainMs / 1000) const totalSec = Math.floor(remainMs / 1000)
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0') const hh = String(Math.floor(totalSec / 3600)).padStart(2, '0')
const mm = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0')
const ss = String(totalSec % 60).padStart(2, '0') const ss = String(totalSec % 60).padStart(2, '0')
return `${mm}:${ss}` return `${hh}:${mm}:${ss}`
} }
export interface PaymentSheetProps { export interface PaymentSheetProps {
@ -28,7 +29,7 @@ export interface PaymentSheetProps {
showStatusCTA?: boolean showStatusCTA?: boolean
} }
export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) { export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) {
const countdown = useCountdown(expireAt) const countdown = useCountdown(expireAt)
const [expanded, setExpanded] = React.useState(true) const [expanded, setExpanded] = React.useState(true)
return ( return (
@ -38,7 +39,7 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
<div className="bg-[#0c1f3f] text-white p-3 sm:p-4 flex items-center justify-between"> <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="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> <div className="rounded bg-white text-black px-2 py-1 text-[11px] sm:text-xs font-bold" aria-hidden>
ZARA SIMAYA
</div> </div>
<div className="font-semibold text-sm sm:text-base">{merchantName}</div> <div className="font-semibold text-sm sm:text-base">{merchantName}</div>
</div> </div>
@ -57,32 +58,33 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
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`} 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`}
> >
˅ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M7 14L12 9L17 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button> </button>
</div> </div>
</div> </div>
{/* Amount panel */}
{expanded && ( {expanded && (
<div className="p-4 border-b border-black/10 dark:border-white/10 flex items-start justify-between"> <div className="p-4 border-b border-black/10 flex items-start justify-between">
<div> <div>
<div className="text-xs text-black/60 dark:text-white/60">Total</div> <div className="text-xs text-black">Total</div>
<div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div> <div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
<div className="text-xs text-black/60 dark:text-white/60">Order ID #{orderId}</div> <div className="text-xs text-black/60">Order ID #{orderId}</div>
</div> </div>
</div> </div>
)} )}
{/* Body */}
<div className="p-4"> <div className="p-4">
{children} {children}
<TrustStrip location="sheet" /> <TrustStrip location="sheet" />
</div> </div>
{/* Sticky CTA (mobile-friendly) */}
{showStatusCTA && ( {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)]"> <div className="sticky bottom-0 bg-white/95 backdrop-blur border-t border-black/10 p-3 pb-[env(safe-area-inset-bottom)]">
<Link <Link
to={`/payments/${orderId}/status`} to={`/payments/${orderId}/status`}
aria-label="Buka halaman status pembayaran" 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]" className="w-full block text-center rounded bg-[#0c1f3f] !text-white py-3 text-base font-semibold hover:bg-[#0a1a35] hover:!text-white focus:outline-none focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:ring-[#0c1f3f] visited:!text-white active:!text-white"
> >
Cek status pembayaran Cek status pembayaran
</Link> </Link>

View File

@ -1,15 +1,15 @@
import React from 'react' //
export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) { export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) {
return ( return (
<div className={`text-[10px] ${location === 'sheet' ? 'mt-2' : ''} text-black/50 dark:text-white/50 flex items-center gap-2`}> <div className={`text-[10px] ${location === 'sheet' ? 'mt-2' : ''} text-black/50 flex items-center gap-2`}>
<span aria-hidden>🔒</span> <span aria-hidden>🔒</span>
<span>Secure payments by Midtrans</span> <span>Secure payments by Midtrans</span>
<span aria-hidden className="ml-auto flex items-center gap-1"> <span aria-hidden className="ml-auto flex items-center gap-1">
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">Visa</span> <span className="inline-block rounded bg-black/10 px-1">Visa</span>
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">Mastercard</span> <span className="inline-block rounded bg-black/10 px-1">Mastercard</span>
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">JCB</span> <span className="inline-block rounded bg-black/10 px-1">JCB</span>
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">Amex</span> <span className="inline-block rounded bg-black/10 px-1">Amex</span>
</span> </span>
</div> </div>
) )

View File

@ -4,7 +4,7 @@ export function usePaymentNavigation() {
const navigate = useNavigate() const navigate = useNavigate()
return { return {
toCheckout() { toCheckout() {
navigate('/checkout') window.location.assign('https://erpskrip.id/pembayaran-pelanggan')
}, },
toStatus(orderId: string, method?: string) { toStatus(orderId: string, method?: string) {
const qs = method ? `?m=${encodeURIComponent(method)}` : '' const qs = method ? `?m=${encodeURIComponent(method)}` : ''

View File

@ -1,6 +1,19 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './styles/globals.css' import './styles/globals.css'
(() => {
const html = document.documentElement
try {
if (html.classList.contains('dark')) {
html.classList.remove('dark')
}
document.body.classList.remove('dark')
html.style.colorScheme = 'light'
html.setAttribute('data-theme', 'light')
} catch {
}
})()
import { AppRouter } from './app/router' import { AppRouter } from './app/router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

View File

@ -72,13 +72,13 @@ export function CheckoutPage() {
</Alert> </Alert>
)} )}
<PaymentSheet merchantName="Zara" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}> <PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}>
{/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */} {/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */}
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-sm font-medium">Konfirmasi data checkout</div> <div className="text-sm font-medium">Konfirmasi data checkout</div>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Nama</div> <div className="text-xs text-gray-600">Nama</div>
<input <input
type="text" type="text"
className="w-full rounded border px-3 py-2" className="w-full rounded border px-3 py-2"
@ -87,7 +87,7 @@ export function CheckoutPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Email atau HP</div> <div className="text-xs text-gray-600">Email atau HP</div>
<input <input
type="text" type="text"
className="w-full rounded border px-3 py-2" className="w-full rounded border px-3 py-2"
@ -96,7 +96,7 @@ export function CheckoutPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Alamat</div> <div className="text-xs text-gray-600">Alamat</div>
<input <input
type="text" type="text"
className="w-full rounded border px-3 py-2" className="w-full rounded border px-3 py-2"
@ -105,7 +105,7 @@ export function CheckoutPage() {
/> />
</label> </label>
<label className="block"> <label className="block">
<div className="text-xs text-black/60 dark:text-white/60">Catatan</div> <div className="text-xs text-gray-600">Catatan</div>
<textarea <textarea
className="w-full rounded border px-3 py-2" className="w-full rounded border px-3 py-2"
value={form.notes} value={form.notes}
@ -157,7 +157,15 @@ export function CheckoutPage() {
} }
}} }}
disabled={locked} disabled={locked}
enabled={runtimeCfg?.paymentToggles} enabled={runtimeCfg?.paymentToggles
? {
bank_transfer: runtimeCfg.paymentToggles.bank_transfer,
credit_card: runtimeCfg.paymentToggles.credit_card,
gopay: runtimeCfg.paymentToggles.gopay,
cstore: runtimeCfg.paymentToggles.cstore,
cpay: !!runtimeCfg.paymentToggles.cpay,
}
: undefined}
renderPanel={(m) => { renderPanel={(m) => {
const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled() const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled()
if (!methodEnabled[m]) { if (!methodEnabled[m]) {
@ -170,14 +178,7 @@ export function CheckoutPage() {
if (m === 'bank_transfer') { if (m === 'bank_transfer') {
return ( return (
<div className="space-y-2" aria-live="polite"> <div className="space-y-2" aria-live="polite">
<div className="flex justify-center"> <div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
<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' : ''}`}> <div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => ( {(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
<button <button
@ -188,7 +189,7 @@ export function CheckoutPage() {
setIsBusy(true) setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300) setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}} }}
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10" className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
aria-label={`Pilih bank ${bk.toUpperCase()}`} aria-label={`Pilih bank ${bk.toUpperCase()}`}
> >
<BankLogo bank={bk} /> <BankLogo bank={bk} />
@ -196,8 +197,8 @@ export function CheckoutPage() {
))} ))}
</div> </div>
{isBusy && ( {isBusy && (
<div className="text-xs text-black/60 dark:text-white/60 inline-flex items-center gap-2"> <div className="text-xs text-gray-600 inline-flex items-center gap-2">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden /> <span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
Menyiapkan VA Menyiapkan VA
</div> </div>
)} )}
@ -207,7 +208,7 @@ export function CheckoutPage() {
if (m === 'cstore') { if (m === 'cstore') {
return ( return (
<div className="space-y-2" aria-live="polite"> <div className="space-y-2" aria-live="polite">
<div className="text-xs text-black/60 dark:text-white/60">Pilih toko untuk membuat kode pembayaran</div> <div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}> <div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['alfamart','indomaret'] as const).map((st) => ( {(['alfamart','indomaret'] as const).map((st) => (
<button <button
@ -218,7 +219,7 @@ export function CheckoutPage() {
setIsBusy(true) setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300) setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}} }}
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10" className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
aria-label={`Pilih toko ${st.toUpperCase()}`} aria-label={`Pilih toko ${st.toUpperCase()}`}
> >
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />} {st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
@ -258,7 +259,7 @@ export function CheckoutPage() {
)} )}
</PaymentSheet> </PaymentSheet>
<div className="text-xs text-black/60 dark:text-white/60"> <div className="text-xs text-gray-600">
API Base: {apiBase ?? '—'} | Client Key: {clientKey ? 'OK' : '—'} API Base: {apiBase ?? '—'} | Client Key: {clientKey ? 'OK' : '—'}
</div> </div>
</div> </div>

View File

@ -12,10 +12,10 @@ export function DemoStorePage() {
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="text-sm font-medium">Produk: T-Shirt Hitam</div> <div className="text-sm font-medium">Produk: T-Shirt Hitam</div>
<div className="text-xs text-black/60 dark:text-white/60">Ukuran: M Bahan: Cotton</div> <div className="text-xs text-black/60">Ukuran: M Bahan: Cotton</div>
<div className="mt-2 text-lg font-semibold">Rp 3.500.000</div> <div className="mt-2 text-lg font-semibold">Rp 3.500.000</div>
</div> </div>
<div className="w-24 h-24 rounded bg-black/10 dark:bg-white/10" aria-hidden /> <div className="w-24 h-24 rounded bg-black/10" aria-hidden />
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Button <Button

13
src/pages/InitialPage.tsx Normal file
View File

@ -0,0 +1,13 @@
export function InitPage() {
return (
<div className="fixed inset-0 bg-white overflow-hidden flex items-center justify-center p-6">
<div className="flex items-center gap-3">
<img src="/simaya.png" alt="Simaya" className="h-12 w-12 md:h-16 md:w-16" />
<div className="text-black">
<div className="text-xl font-semibold leading-none">Simaya Midtrans</div>
<div className="text-sm leading-none">Payment Service</div>
</div>
</div>
</div>
)
}

View File

@ -4,8 +4,9 @@ export function NotFoundPage() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<h1 className="text-xl font-semibold">Halaman tidak ditemukan</h1> <h1 className="text-xl font-semibold">Halaman tidak ditemukan</h1>
<p className="text-sm text-black/70 dark:text-white/70">Periksa URL atau kembali ke checkout.</p> <p className="text-sm text-black/70">Periksa URL atau kembali ke checkout.</p>
<Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link> {/* <Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link> */}
<Link to="/" className="text-brand-600 underline">Kembali</Link>
</div> </div>
) )
} }

249
src/pages/PayPage.tsx Normal file
View File

@ -0,0 +1,249 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
import { BankTransferPanel } from '../features/payments/components/BankTransferPanel'
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, LogoAlfamart, LogoIndomaret } from '../features/payments/components/PaymentLogos'
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
import { Alert } from '../components/alert/Alert'
import { Button } from '../components/ui/button'
import { getPaymentLinkPayload } from '../services/api'
type Method = PaymentMethod | null
export function PayPage() {
const { token } = useParams()
const [orderId, setOrderId] = useState<string>('')
const [amount, setAmount] = useState<number>(0)
const [expireAt, setExpireAt] = useState<number>(Date.now() + 24 * 60 * 60 * 1000)
const [selectedMethod, setSelectedMethod] = useState<Method>(null)
const [locked, setLocked] = useState<boolean>(false)
const [selectedBank, setSelectedBank] = useState<BankKey | null>(null)
const [selectedStore, setSelectedStore] = useState<'alfamart' | 'indomaret' | null>(null)
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
const { data: runtimeCfg } = usePaymentConfig()
const [currentStep, setCurrentStep] = useState<2 | 3>(2)
const [isBusy, setIsBusy] = useState(false)
useEffect(() => {
let cancelled = false
async function resolve() {
if (!token) return
try {
const payload = await getPaymentLinkPayload(token)
if (cancelled) return
setOrderId(payload.order_id)
setAmount(payload.nominal)
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
setAllowedMethods(payload.allowed_methods)
setError(null)
} catch (err) {
if (cancelled) return
setError({ code: 'TOKEN_RESOLVE_ERROR' })
}
}
resolve()
return () => {
cancelled = true
}
}, [token])
const merchantName = useMemo(() => '', [])
const isExpired = expireAt ? Date.now() > expireAt : false
const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => {
const base = runtimeCfg?.paymentToggles
const allow = allowedMethods
const all: Record<PaymentMethod, boolean> = {
bank_transfer: base?.bank_transfer ?? true,
credit_card: base?.credit_card ?? true,
gopay: base?.gopay ?? true,
cstore: base?.cstore ?? true,
cpay: base?.cpay ?? false,
}
if (allow && Array.isArray(allow)) {
for (const k of (Object.keys(all) as PaymentMethod[])) {
if (k === 'cpay') continue
all[k] = allow.includes(k) && all[k]
}
}
return all
}, [runtimeCfg, allowedMethods])
if (error || isExpired) {
const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid'
const msg = isExpired ? 'Silakan minta link baru dari admin atau ERP.' : 'Token tidak dapat diverifikasi. Hubungi admin untuk bantuan.'
return (
<PaymentSheet
merchantName={merchantName}
orderId={orderId || (token ?? '')}
amount={amount}
expireAt={expireAt}
showStatusCTA={false}
>
<div className="space-y-4 px-4 py-6">
<Alert title={title}>{msg}</Alert>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="secondary"
onClick={() => { try { window.location.reload() } catch { } }}
className="w-full sm:w-auto"
>
Muat ulang
</Button>
<a
href="mailto:retailimaya@gmail.com?subject=Permintaan%20Link%20Pembayaran&body=Order%20ID:%20"
className="inline-flex items-center px-3 py-2 rounded border bg-gray-800 !text-white hover:!text-white focus:!text-white visited:!text-white active:!text-white w-full sm:w-auto justify-center"
>
Hubungi Admin
</a>
</div>
</div>
</PaymentSheet>
)
}
return (
<PaymentSheet
merchantName={merchantName}
orderId={orderId}
amount={amount}
expireAt={expireAt}
showStatusCTA={currentStep === 3}
>
<div className="space-y-4 px-4 py-6">
{currentStep === 2 && (
<div className="space-y-3">
<PaymentMethodList
selected={selectedMethod ?? undefined}
onSelect={(m) => {
setSelectedMethod(m as Method)
if (m === 'bank_transfer' || m === 'cstore') {
} else if (m === 'cpay') {
try {
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
} catch { }
} else {
setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}
}}
disabled={locked}
enabled={enabledMap}
renderPanel={(m) => {
const enabled = enabledMap[m]
if (!enabled) {
return (
<div className="p-2">
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
</div>
)
}
if (m === 'bank_transfer') {
return (
<div className="space-y-2" aria-live="polite">
<div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
<div className={`grid grid-cols-2 md:grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata'] as BankKey[]).map((bk) => (
<button
key={bk}
type="button"
onClick={() => {
setSelectedBank(bk)
setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}}
className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center overflow-hidden hover:bg-gray-100"
aria-label={`Pilih bank ${bk.toUpperCase()}`}
>
<BankLogo bank={bk} />
</button>
))}
</div>
{isBusy && (
<div className="text-xs text-gray-600 inline-flex items-center gap-2">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
Menyiapkan VA
</div>
)}
</div>
)
}
if (m === 'cstore') {
return (
<div className="space-y-2" aria-live="polite">
<div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{/* {(['alfamart', 'indomaret'] as const).map((st) => ( */}
{(['alfamart'] as const).map((st) => (
<button
key={st}
type="button"
onClick={() => {
setSelectedStore(st)
setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}}
className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center hover:bg-gray-100"
aria-label={`Pilih toko ${st.toUpperCase()}`}
>
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
</button>
))}
</div>
</div>
)
}
return null
}}
/>
</div>
)}
{currentStep === 3 && (
<div className="space-y-3" aria-live="polite">
{selectedMethod === 'bank_transfer' && (
<BankTransferPanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
defaultBank={(selectedBank ?? 'bca')}
/>
)}
{selectedMethod === 'credit_card' && (
<CardPanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
/>
)}
{selectedMethod === 'gopay' && (
<GoPayPanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
/>
)}
{selectedMethod === 'cstore' && (
<CStorePanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
defaultStore={selectedStore ?? undefined}
/>
)}
</div>
)}
</div>
</PaymentSheet>
)
}

View File

@ -32,18 +32,18 @@ export function PaymentStatusPage() {
function statusBadgeClass(s: PaymentStatusResponse['status']) { function statusBadgeClass(s: PaymentStatusResponse['status']) {
switch (s) { switch (s) {
case 'pending': case 'pending':
return 'inline-block rounded px-2 py-0.5 text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200' return 'inline-block rounded px-2 py-0.5 text-xs bg-yellow-100 text-yellow-800'
case 'settlement': case 'settlement':
case 'capture': case 'capture':
return 'inline-block rounded px-2 py-0.5 text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' return 'inline-block rounded px-2 py-0.5 text-xs bg-green-100 text-green-800'
case 'deny': case 'deny':
case 'cancel': case 'cancel':
case 'expire': case 'expire':
case 'refund': case 'refund':
case 'chargeback': case 'chargeback':
return 'inline-block rounded px-2 py-0.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' return 'inline-block rounded px-2 py-0.5 text-xs bg-red-100 text-red-800'
default: default:
return 'inline-block rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-200' return 'inline-block rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-800'
} }
} }
@ -53,7 +53,7 @@ export function PaymentStatusPage() {
<div className="card p-4"> <div className="card p-4">
<div className="text-sm">Order ID: {orderId}</div> <div className="text-sm">Order ID: {orderId}</div>
{method || data?.method ? ( {method || data?.method ? (
<div className="text-xs text-black/60 dark:text-white/60">Metode: {data?.method ?? method}</div> <div className="text-xs text-gray-600">Metode: {data?.method ?? method}</div>
) : null} ) : null}
<div className="mt-2">Status: {isLoading ? ( <div className="mt-2">Status: {isLoading ? (
<span className="font-medium">memuat</span> <span className="font-medium">memuat</span>
@ -62,7 +62,7 @@ export function PaymentStatusPage() {
) : ( ) : (
<span className={statusBadgeClass(statusText)}>{statusText}</span> <span className={statusBadgeClass(statusText)}>{statusText}</span>
)}</div> )}</div>
<div className="mt-1 text-xs text-black/60 dark:text-white/60"> <div className="mt-1 text-xs text-gray-600">
{isFinal ? 'Status final — polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'} {isFinal ? 'Status final — polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'}
</div> </div>
{/* Method-specific details */} {/* Method-specific details */}
@ -70,18 +70,18 @@ export function PaymentStatusPage() {
<div className="mt-3 space-y-2 text-sm"> <div className="mt-3 space-y-2 text-sm">
{/* Bank Transfer / VA */} {/* Bank Transfer / VA */}
{data.vaNumber ? ( {data.vaNumber ? (
<div className="rounded border border-black/10 dark:border-white/10 p-2"> <div className="rounded border border-gray-200 p-2">
<div className="font-medium">Virtual Account</div> <div className="font-medium">Virtual Account</div>
<div>VA Number: <span className="font-mono">{data.vaNumber}</span></div> <div>VA Number: <span className="font-mono">{data.vaNumber}</span></div>
{data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null} {data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null}
{data.billKey && data.billerCode ? ( {data.billKey && data.billerCode ? (
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Mandiri E-Channel Bill Key: {data.billKey}, Biller: {data.billerCode}</div> <div className="mt-1 text-xs text-gray-600">Mandiri E-Channel Bill Key: {data.billKey}, Biller: {data.billerCode}</div>
) : null} ) : null}
</div> </div>
) : null} ) : null}
{/* C-store */} {/* C-store */}
{data.store || data.paymentCode ? ( {data.store || data.paymentCode ? (
<div className="rounded border border-black/10 dark:border-white/10 p-2"> <div className="rounded border border-gray-200 p-2">
<div className="font-medium">Convenience Store</div> <div className="font-medium">Convenience Store</div>
{data.store ? <div>Store: {data.store}</div> : null} {data.store ? <div>Store: {data.store}</div> : null}
{data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null} {data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null}
@ -89,9 +89,9 @@ export function PaymentStatusPage() {
) : null} ) : null}
{/* E-money (GoPay/QRIS) */} {/* E-money (GoPay/QRIS) */}
{data.actions && data.actions.length > 0 ? ( {data.actions && data.actions.length > 0 ? (
<div className="rounded border border-black/10 dark:border-white/10 p-2"> <div className="rounded border border-gray-200 p-2">
<div className="font-medium">QR / Deeplink</div> <div className="font-medium">QR / Deeplink</div>
<div className="text-xs text-black/60 dark:text-white/60">Gunakan link berikut untuk membuka aplikasi pembayaran.</div> <div className="text-xs text-gray-600">Gunakan link berikut untuk membuka aplikasi pembayaran.</div>
<div className="mt-1 flex flex-wrap gap-2"> <div className="mt-1 flex flex-wrap gap-2">
{data.actions.map((a, i) => ( {data.actions.map((a, i) => (
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600"> <a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600">
@ -103,7 +103,7 @@ export function PaymentStatusPage() {
) : null} ) : null}
{/* Card */} {/* Card */}
{data.maskedCard ? ( {data.maskedCard ? (
<div className="rounded border border-black/10 dark:border-white/10 p-2"> <div className="rounded border border-gray-200 p-2">
<div className="font-medium">Kartu</div> <div className="font-medium">Kartu</div>
<div>Masked Card: <span className="font-mono">{data.maskedCard}</span></div> <div>Masked Card: <span className="font-mono">{data.maskedCard}</span></div>
</div> </div>
@ -112,7 +112,7 @@ export function PaymentStatusPage() {
) : null} ) : null}
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<Button onClick={() => nav.toHistory()}>Lihat Riwayat</Button> <Button onClick={() => nav.toHistory()}>Lihat Riwayat</Button>
<Button variant="secondary" onClick={() => nav.toCheckout()}>Kembali ke Checkout</Button> <Button variant="secondary" onClick={() => nav.toCheckout()}>Kembali</Button>
</div> </div>
</div> </div>
{!Env.API_BASE_URL && ( {!Env.API_BASE_URL && (

View File

@ -49,9 +49,11 @@ api.interceptors.response.use(
return response return response
}, },
(error) => { (error) => {
const baseURL = error.config?.baseURL || ''
const url = error.config?.url || '' const url = error.config?.url || ''
const status = error.response?.status const status = error.response?.status
Logger.error('api.error', { url, status, message: error.message }) const fullUrl = `${baseURL}${url}`
Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message })
throw error throw error
} }
) )
@ -112,3 +114,37 @@ export async function getRuntimeConfig(): Promise<RuntimeConfigResponse> {
clientKey: Env.MIDTRANS_CLIENT_KEY, clientKey: Env.MIDTRANS_CLIENT_KEY,
} }
} }
export type PaymentLinkPayload = {
order_id: string
nominal: number
customer?: { name?: string; phone?: string; email?: string }
expire_at?: number
allowed_methods?: string[]
}
export async function getPaymentLinkPayload(token: string): Promise<PaymentLinkPayload> {
if (apiBase) {
const { data } = await api.get(`/payment-links/${encodeURIComponent(token)}`)
Logger.info('paymentlink.resolve', { tokenLen: token.length })
return data as PaymentLinkPayload
}
try {
const json = JSON.parse(atob(token))
return {
order_id: json.order_id || token,
nominal: Number(json.nominal) || 150000,
customer: json.customer || {},
expire_at: json.expire_at || Date.now() + 24 * 60 * 60 * 1000
,
allowed_methods: json.allowed_methods || undefined,
}
} catch {
return {
order_id: token,
nominal: 150000,
expire_at: Date.now() + 24 * 60 * 60 * 1000
,
}
}
}

View File

@ -1,18 +1,6 @@
@import 'tailwindcss'; @import 'tailwindcss';
/* Define brand palette for Tailwind v4 utility tokens */ /* Brand colors are defined in tailwind.config.ts under theme.extend.colors.brand */
@theme {
--color-brand-50: #fef2f2;
--color-brand-100: #fee2e2;
--color-brand-200: #fecaca;
--color-brand-300: #fca5a5;
--color-brand-400: #f87171;
--color-brand-500: #ef4444;
--color-brand-600: #dc2626;
--color-brand-700: #b91c1c;
--color-brand-800: #991b1b;
--color-brand-900: #7f1d1d;
}
:root { :root {
--radius: 8px; --radius: 8px;
@ -25,12 +13,11 @@ body {
background-color: #ffffff; background-color: #ffffff;
color: #000000; color: #000000;
} }
.dark body { background-color: #000000; color: #ffffff; }
a { color: #dc2626; }
a:hover { color: #b91c1c; }
.focus-ring { outline: none; box-shadow: 0 0 0 2px rgba(220,38,38,1), 0 0 0 4px var(--focus-offset, #ffffff); } a { color: #0c1f3f; }
.dark .focus-ring { box-shadow: 0 0 0 2px rgba(220,38,38,1), 0 0 0 4px #000000; } a:hover { color: #0a1a35; }
.focus-ring { outline: none; box-shadow: 0 0 0 2px rgba(37,99,235,1), 0 0 0 4px var(--focus-offset, #ffffff); }
.card { .card {
border-radius: var(--radius); border-radius: var(--radius);
@ -38,7 +25,3 @@ a:hover { color: #b91c1c; }
background-color: #ffffff; background-color: #ffffff;
box-shadow: 0 1px 2px rgba(0,0,0,0.05); box-shadow: 0 1px 2px rgba(0,0,0,0.05);
} }
.dark .card {
background-color: #000000;
border-color: rgba(255,255,255,0.2);
}

View File

@ -7,20 +7,20 @@ export default {
extend: { extend: {
colors: { colors: {
brand: { brand: {
50: '#fef2f2', 50: '#f1f5fb',
100: '#fee2e2', 100: '#e3e9f5',
200: '#fecaca', 200: '#c7d3ea',
300: '#fca5a5', 300: '#a6b9dd',
400: '#f87171', 400: '#6f8bc8',
500: '#ef4444', 500: '#3a5da7',
600: '#dc2626', 600: '#0c1f3f',
700: '#b91c1c', 700: '#0a1a35',
800: '#991b1b', 800: '#08152a',
900: '#7f1d1d', 900: '#050f20',
}, },
}, },
boxShadow: { boxShadow: {
focus: '0 0 0 3px rgba(220,38,38,0.45)', focus: '0 0 0 3px rgba(37,99,235,0.45)',
}, },
}, },
}, },

12
tmp-createtransaksi.json Normal file
View File

@ -0,0 +1,12 @@
{
"mercant_id": "REFNO-001",
"timestamp": 1731300000000,
"deskripsi": "Bayar Internet",
"nominal": 200000,
"nama": "Demo User",
"no_telepon": "081234567890",
"email": "demo@example.com",
"item": [
{ "item_id": "TKG-2511131", "nama": "Internet", "harga": 200000, "qty": 1 }
]
}

1
tmp-sig.txt Normal file
View File

@ -0,0 +1 @@
417582e9fb7105b479e3e7aee99a285dbee0f2ec3238869f8f6fc36b6a098dbee411cf0d3e7637b69f41803518e640a6c9ae71a66b414b29e2182f5aed2ea55a

BIN
tmp-sig2.txt Normal file

Binary file not shown.

1
tmp-sig3.txt Normal file
View File

@ -0,0 +1 @@
e781ba511b1675c05974b45db5f9ddc108d6d2d0acd62ba47fa4125094000512baf9b147689254ac88c406aade53921c9e7e3ae35c154809bdd7723014264667