Align PayPage flow and update /createtransaksi schema #3
|
|
@ -159,3 +159,152 @@ Kemudian `POST` ke `http://localhost:8000/api/payments/webhook` dengan body beri
|
||||||
|
|
||||||
## Postman Collection
|
## Postman Collection
|
||||||
- Anda dapat mengimpor koleksi: `docs/qa/payment-link.postman_collection.json` untuk mencoba endpoint di atas.
|
- 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.
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
},
|
},
|
||||||
"variable": [
|
"variable": [
|
||||||
{ "key": "baseUrl", "value": "http://localhost:8000" },
|
{ "key": "baseUrl", "value": "https://be-midtrans-cifo.winteraccess.id" },
|
||||||
{ "key": "paymentLinkBase", "value": "http://localhost:5174/pay" },
|
{ "key": "paymentLinkBase", "value": "https://midtrans-cifo.winteraccess.id/pay" },
|
||||||
{ "key": "externalApiKey", "value": "" },
|
{ "key": "externalApiKey", "value": "" },
|
||||||
{ "key": "token", "value": "" },
|
{ "key": "token", "value": "" },
|
||||||
{ "key": "order_id", "value": "" }
|
{ "key": "order_id", "value": "" }
|
||||||
|
|
@ -25,6 +25,31 @@
|
||||||
"raw": "{\n \"item_id\": \"order-demo-1\",\n \"nominal\": 150000,\n \"customer\": { \"name\": \"Demo User\", \"email\": \"demo@example.com\" },\n \"allowed_methods\": [\"bank_transfer\", \"credit_card\", \"gopay\", \"cstore\"]\n}"
|
"raw": "{\n \"item_id\": \"order-demo-1\",\n \"nominal\": 150000,\n \"customer\": { \"name\": \"Demo User\", \"email\": \"demo@example.com\" },\n \"allowed_methods\": [\"bank_transfer\", \"credit_card\", \"gopay\", \"cstore\"]\n}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"type": "text/javascript",
|
||||||
|
"exec": [
|
||||||
|
"let res = {};",
|
||||||
|
"try { res = pm.response.json(); } catch(e) { res = {}; }",
|
||||||
|
"if (res && res.token) {",
|
||||||
|
" pm.collectionVariables.set('token', res.token);",
|
||||||
|
"}",
|
||||||
|
"if (res && res.order_id) {",
|
||||||
|
" pm.collectionVariables.set('order_id', res.order_id);",
|
||||||
|
"}",
|
||||||
|
"if (res && res.url) {",
|
||||||
|
" pm.collectionVariables.set('paymentLinkUrl', res.url);",
|
||||||
|
"}",
|
||||||
|
"pm.test('Create Transaction returns token and order_id', function () {",
|
||||||
|
" pm.expect(res.token, 'token exists').to.be.a('string');",
|
||||||
|
" pm.expect(res.order_id, 'order_id exists').to.be.a('string');",
|
||||||
|
"});"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -34,6 +59,32 @@
|
||||||
"header": [],
|
"header": [],
|
||||||
"url": { "raw": "{{baseUrl}}/api/payment-links/{{token}}", "host": ["{{baseUrl}}"], "path": ["api","payment-links","{{token}}"] }
|
"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 = {}; }",
|
||||||
|
"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": []
|
"response": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -69,6 +120,18 @@
|
||||||
"header": [],
|
"header": [],
|
||||||
"url": { "raw": "{{baseUrl}}/api/payments/{{order_id}}/status", "host": ["{{baseUrl}}"], "path": ["api","payments","{{order_id}}","status"] }
|
"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": []
|
"response": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -257,26 +257,42 @@ app.post('/createtransaksi', (req, res) => {
|
||||||
logWarn('createtransaksi.unauthorized', { id: req.id })
|
logWarn('createtransaksi.unauthorized', { id: req.id })
|
||||||
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'X-API-KEY invalid' })
|
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'X-API-KEY invalid' })
|
||||||
}
|
}
|
||||||
const itemId = req?.body?.item_id || req?.body?.order_id
|
|
||||||
|
// 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 nominalRaw = req?.body?.nominal
|
||||||
const customer = req?.body?.customer
|
const items = Array.isArray(req?.body?.item) ? req.body.item : []
|
||||||
const allowed_methods = req?.body?.allowed_methods
|
const primaryItemId = items?.[0]?.item_id
|
||||||
if (!itemId || typeof nominalRaw === 'undefined') {
|
const order_id = String(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
|
||||||
logWarn('createtransaksi.bad_request', { id: req.id })
|
|
||||||
return res.status(400).json({ error: 'BAD_REQUEST', message: 'item_id and nominal are required' })
|
// 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 order_id = String(itemId)
|
|
||||||
const nominal = Number(nominalRaw)
|
const nominal = Number(nominalRaw)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 30
|
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 30
|
||||||
const expire_at = now + ttlMin * 60 * 1000
|
const expire_at = now + ttlMin * 60 * 1000
|
||||||
|
|
||||||
// Block if already completed
|
// Block jika sudah selesai
|
||||||
if (notifiedOrders.has(order_id)) {
|
if (notifiedOrders.has(order_id)) {
|
||||||
logWarn('createtransaksi.completed', { order_id })
|
logWarn('createtransaksi.completed', { order_id })
|
||||||
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
|
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
|
||||||
}
|
}
|
||||||
// Block if active link exists and not expired yet
|
// Block jika ada link aktif belum expired
|
||||||
const existing = activeOrders.get(order_id)
|
const existing = activeOrders.get(order_id)
|
||||||
if (existing && existing > now) {
|
if (existing && existing > now) {
|
||||||
logWarn('createtransaksi.active_exists', { order_id })
|
logWarn('createtransaksi.active_exists', { order_id })
|
||||||
|
|
@ -287,7 +303,9 @@ app.post('/createtransaksi', (req, res) => {
|
||||||
const url = `${PAYMENT_LINK_BASE}/${token}`
|
const url = `${PAYMENT_LINK_BASE}/${token}`
|
||||||
activeOrders.set(order_id, expire_at)
|
activeOrders.set(order_id, expire_at)
|
||||||
logInfo('createtransaksi.issued', { order_id, expire_at })
|
logInfo('createtransaksi.issued', { order_id, expire_at })
|
||||||
res.json({ url, token, order_id, nominal, expire_at })
|
|
||||||
|
// Respons mengikuti format yang diminta
|
||||||
|
res.json({ status: '200', messages: 'SUCCESS', data: { url } })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError('createtransaksi.error', { id: req.id, message: e?.message })
|
logError('createtransaksi.error', { id: req.id, message: e?.message })
|
||||||
res.status(500).json({ error: 'CREATE_ERROR', message: e?.message || 'Internal error' })
|
res.status(500).json({ error: 'CREATE_ERROR', message: e?.message || 'Internal error' })
|
||||||
|
|
|
||||||
15
src/main.tsx
15
src/main.tsx
|
|
@ -1,6 +1,21 @@
|
||||||
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'
|
||||||
|
// Force light theme in case hosting injects a global 'dark' class
|
||||||
|
(() => {
|
||||||
|
const html = document.documentElement
|
||||||
|
try {
|
||||||
|
if (html.classList.contains('dark')) {
|
||||||
|
html.classList.remove('dark')
|
||||||
|
}
|
||||||
|
document.body.classList.remove('dark')
|
||||||
|
// Hint browsers to prefer light color scheme
|
||||||
|
html.style.colorScheme = 'light'
|
||||||
|
html.setAttribute('data-theme', 'light')
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
})()
|
||||||
import { AppRouter } from './app/router'
|
import { AppRouter } from './app/router'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,13 +178,6 @@ 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">
|
|
||||||
<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="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) => (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { BankTransferPanel } from '../features/payments/components/BankTransferP
|
||||||
import { CardPanel } from '../features/payments/components/CardPanel'
|
import { CardPanel } from '../features/payments/components/CardPanel'
|
||||||
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
||||||
import { CStorePanel } from '../features/payments/components/CStorePanel'
|
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 { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
||||||
import { Alert } from '../components/alert/Alert'
|
import { Alert } from '../components/alert/Alert'
|
||||||
import { Button } from '../components/ui/button'
|
import { Button } from '../components/ui/button'
|
||||||
|
|
@ -21,11 +22,13 @@ export function PayPage() {
|
||||||
const [expireAt, setExpireAt] = useState<number>(Date.now() + 30 * 60 * 1000)
|
const [expireAt, setExpireAt] = useState<number>(Date.now() + 30 * 60 * 1000)
|
||||||
const [selectedMethod, setSelectedMethod] = useState<Method>(null)
|
const [selectedMethod, setSelectedMethod] = useState<Method>(null)
|
||||||
const [locked, setLocked] = useState<boolean>(false)
|
const [locked, setLocked] = useState<boolean>(false)
|
||||||
const [selectedBank, setSelectedBank] = useState<string>('bca')
|
const [selectedBank, setSelectedBank] = useState<BankKey | null>(null)
|
||||||
const [selectedStore, setSelectedStore] = useState<string>('indomaret')
|
const [selectedStore, setSelectedStore] = useState<'alfamart' | 'indomaret' | null>(null)
|
||||||
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
|
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
|
||||||
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
||||||
const { data: runtimeCfg } = usePaymentConfig()
|
const { data: runtimeCfg } = usePaymentConfig()
|
||||||
|
const [currentStep, setCurrentStep] = useState<2 | 3>(2)
|
||||||
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -110,91 +113,135 @@ export function PayPage() {
|
||||||
orderId={orderId}
|
orderId={orderId}
|
||||||
amount={amount}
|
amount={amount}
|
||||||
expireAt={expireAt}
|
expireAt={expireAt}
|
||||||
showStatusCTA={false}
|
showStatusCTA={currentStep === 3}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold">Pilih metode pembayaran</h2>
|
{currentStep === 2 && (
|
||||||
<PaymentMethodList
|
<div className="space-y-3">
|
||||||
selected={selectedMethod ?? undefined}
|
<PaymentMethodList
|
||||||
onSelect={(m) => setSelectedMethod(m as Method)}
|
selected={selectedMethod ?? undefined}
|
||||||
disabled={locked}
|
onSelect={(m) => {
|
||||||
enabled={enabledMap}
|
setSelectedMethod(m as Method)
|
||||||
renderPanel={(m) => {
|
if (m === 'bank_transfer' || m === 'cstore') {
|
||||||
if (m === 'bank_transfer') return (
|
// Panel pemilihan bank/toko akan muncul di bawah item, dan lanjut ke step 3 setelah memilih
|
||||||
<div className="mt-2">
|
} else if (m === 'cpay') {
|
||||||
<p className="text-sm text-slate-600">Pilih bank</p>
|
try {
|
||||||
<div className="mt-2 flex gap-2">
|
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
|
||||||
{['bca', 'bni', 'bri', 'permata'].map((bank) => (
|
} catch {}
|
||||||
<button
|
} else {
|
||||||
key={bank}
|
setIsBusy(true)
|
||||||
className={`px-3 py-2 rounded border ${selectedBank === bank ? 'border-blue-600' : 'border-slate-300'}`}
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
onClick={() => setSelectedBank(bank)}
|
}
|
||||||
>
|
}}
|
||||||
{bank.toUpperCase()}
|
disabled={locked}
|
||||||
</button>
|
enabled={enabledMap}
|
||||||
))}
|
renderPanel={(m) => {
|
||||||
</div>
|
const enabled = enabledMap[m]
|
||||||
</div>
|
if (!enabled) {
|
||||||
)
|
return (
|
||||||
if (m === 'cstore') return (
|
<div className="p-2">
|
||||||
<div className="mt-2">
|
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
||||||
<p className="text-sm text-slate-600">Pilih gerai</p>
|
</div>
|
||||||
<div className="mt-2 flex gap-2">
|
)
|
||||||
{['indomaret', 'alfamart'].map((store) => (
|
}
|
||||||
<button
|
if (m === 'bank_transfer') {
|
||||||
key={store}
|
return (
|
||||||
className={`px-3 py-2 rounded border ${selectedStore === store ? 'border-blue-600' : 'border-slate-300'}`}
|
<div className="space-y-2" aria-live="polite">
|
||||||
onClick={() => setSelectedStore(store)}
|
<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' : ''}`}>
|
||||||
{store}
|
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
|
||||||
</button>
|
<button
|
||||||
))}
|
key={bk}
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
onClick={() => {
|
||||||
)
|
setSelectedBank(bk)
|
||||||
return null
|
setIsBusy(true)
|
||||||
}}
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
/>
|
}}
|
||||||
|
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
<div className="mt-6">
|
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
||||||
{selectedMethod === 'bank_transfer' && (
|
>
|
||||||
<BankTransferPanel
|
<BankLogo bank={bk} />
|
||||||
locked={locked}
|
</button>
|
||||||
onChargeInitiated={() => setLocked(true)}
|
))}
|
||||||
orderId={orderId}
|
</div>
|
||||||
amount={amount}
|
{isBusy && (
|
||||||
defaultBank={selectedBank as any}
|
<div className="text-xs text-black/60 dark:text-white/60 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 />
|
||||||
|
Menyiapkan VA…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (m === 'cstore') {
|
||||||
|
return (
|
||||||
|
<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={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||||
|
{(['alfamart','indomaret'] as const).map((st) => (
|
||||||
|
<button
|
||||||
|
key={st}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStore(st)
|
||||||
|
setIsBusy(true)
|
||||||
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
|
}}
|
||||||
|
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
||||||
|
>
|
||||||
|
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedMethod === 'credit_card' && (
|
{currentStep === 3 && (
|
||||||
<CardPanel
|
<div className="space-y-3" aria-live="polite">
|
||||||
locked={locked}
|
{selectedMethod === 'bank_transfer' && (
|
||||||
onChargeInitiated={() => setLocked(true)}
|
<BankTransferPanel
|
||||||
orderId={orderId}
|
locked={locked}
|
||||||
amount={amount}
|
onChargeInitiated={() => setLocked(true)}
|
||||||
/>
|
orderId={orderId}
|
||||||
)}
|
amount={amount}
|
||||||
|
defaultBank={(selectedBank ?? 'bca')}
|
||||||
{selectedMethod === 'gopay' && (
|
/>
|
||||||
<GoPayPanel
|
)}
|
||||||
locked={locked}
|
{selectedMethod === 'credit_card' && (
|
||||||
onChargeInitiated={() => setLocked(true)}
|
<CardPanel
|
||||||
orderId={orderId}
|
locked={locked}
|
||||||
amount={amount}
|
onChargeInitiated={() => setLocked(true)}
|
||||||
/>
|
orderId={orderId}
|
||||||
)}
|
amount={amount}
|
||||||
|
/>
|
||||||
{selectedMethod === 'cstore' && (
|
)}
|
||||||
<CStorePanel
|
{selectedMethod === 'gopay' && (
|
||||||
locked={locked}
|
<GoPayPanel
|
||||||
onChargeInitiated={() => setLocked(true)}
|
locked={locked}
|
||||||
orderId={orderId}
|
onChargeInitiated={() => setLocked(true)}
|
||||||
amount={amount}
|
orderId={orderId}
|
||||||
defaultStore={selectedStore as any}
|
amount={amount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
{selectedMethod === 'cstore' && (
|
||||||
|
<CStorePanel
|
||||||
|
locked={locked}
|
||||||
|
onChargeInitiated={() => setLocked(true)}
|
||||||
|
orderId={orderId}
|
||||||
|
amount={amount}
|
||||||
|
defaultStore={selectedStore ?? undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PaymentSheet>
|
</PaymentSheet>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ body {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
.light, html[data-theme="light"] body { background-color: #ffffff; color: #000000; }
|
||||||
.dark body { background-color: #000000; color: #ffffff; }
|
.dark body { background-color: #000000; color: #ffffff; }
|
||||||
a { color: #dc2626; }
|
a { color: #dc2626; }
|
||||||
a:hover { color: #b91c1c; }
|
a:hover { color: #b91c1c; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue