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
|
||||
- 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"
|
||||
},
|
||||
"variable": [
|
||||
{ "key": "baseUrl", "value": "http://localhost:8000" },
|
||||
{ "key": "paymentLinkBase", "value": "http://localhost:5174/pay" },
|
||||
{ "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": "" }
|
||||
|
|
@ -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}"
|
||||
}
|
||||
},
|
||||
"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": []
|
||||
},
|
||||
{
|
||||
|
|
@ -34,6 +59,32 @@
|
|||
"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 = {}; }",
|
||||
"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": []
|
||||
},
|
||||
{
|
||||
|
|
@ -69,6 +120,18 @@
|
|||
"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": []
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -257,26 +257,42 @@ app.post('/createtransaksi', (req, res) => {
|
|||
logWarn('createtransaksi.unauthorized', { id: req.id })
|
||||
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 customer = req?.body?.customer
|
||||
const allowed_methods = req?.body?.allowed_methods
|
||||
if (!itemId || typeof nominalRaw === 'undefined') {
|
||||
logWarn('createtransaksi.bad_request', { id: req.id })
|
||||
return res.status(400).json({ error: 'BAD_REQUEST', message: 'item_id and nominal are required' })
|
||||
const items = Array.isArray(req?.body?.item) ? req.body.item : []
|
||||
const primaryItemId = items?.[0]?.item_id
|
||||
const order_id = String(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
|
||||
|
||||
// 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 now = Date.now()
|
||||
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 30
|
||||
const expire_at = now + ttlMin * 60 * 1000
|
||||
|
||||
// Block if already completed
|
||||
// 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 if active link exists and not expired yet
|
||||
// Block jika ada link aktif belum expired
|
||||
const existing = activeOrders.get(order_id)
|
||||
if (existing && existing > now) {
|
||||
logWarn('createtransaksi.active_exists', { order_id })
|
||||
|
|
@ -287,7 +303,9 @@ app.post('/createtransaksi', (req, res) => {
|
|||
const url = `${PAYMENT_LINK_BASE}/${token}`
|
||||
activeOrders.set(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) {
|
||||
logError('createtransaksi.error', { id: req.id, message: e?.message })
|
||||
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 { createRoot } from 'react-dom/client'
|
||||
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
|
|
|
|||
|
|
@ -178,13 +178,6 @@ export function CheckoutPage() {
|
|||
if (m === 'bank_transfer') {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src="/logos/logo-semua-bank.PNG"
|
||||
alt="Logo semua bank yang didukung"
|
||||
className="max-h-20 sm:max-h-24 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Pilih bank untuk membuat Virtual Account</div>
|
||||
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { BankTransferPanel } from '../features/payments/components/BankTransferP
|
|||
import { CardPanel } from '../features/payments/components/CardPanel'
|
||||
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
||||
import { CStorePanel } from '../features/payments/components/CStorePanel'
|
||||
import { BankLogo, type BankKey, 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'
|
||||
|
|
@ -21,11 +22,13 @@ export function PayPage() {
|
|||
const [expireAt, setExpireAt] = useState<number>(Date.now() + 30 * 60 * 1000)
|
||||
const [selectedMethod, setSelectedMethod] = useState<Method>(null)
|
||||
const [locked, setLocked] = useState<boolean>(false)
|
||||
const [selectedBank, setSelectedBank] = useState<string>('bca')
|
||||
const [selectedStore, setSelectedStore] = useState<string>('indomaret')
|
||||
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
|
||||
|
|
@ -110,63 +113,108 @@ export function PayPage() {
|
|||
orderId={orderId}
|
||||
amount={amount}
|
||||
expireAt={expireAt}
|
||||
showStatusCTA={false}
|
||||
showStatusCTA={currentStep === 3}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Pilih metode pembayaran</h2>
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-3">
|
||||
<PaymentMethodList
|
||||
selected={selectedMethod ?? undefined}
|
||||
onSelect={(m) => setSelectedMethod(m as Method)}
|
||||
onSelect={(m) => {
|
||||
setSelectedMethod(m as Method)
|
||||
if (m === 'bank_transfer' || m === 'cstore') {
|
||||
// Panel pemilihan bank/toko akan muncul di bawah item, dan lanjut ke step 3 setelah memilih
|
||||
} 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) => {
|
||||
if (m === 'bank_transfer') return (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-slate-600">Pilih bank</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{['bca', 'bni', 'bri', 'permata'].map((bank) => (
|
||||
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-black/60 dark:text-white/60">Pilih bank untuk membuat Virtual Account</div>
|
||||
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
|
||||
<button
|
||||
key={bank}
|
||||
className={`px-3 py-2 rounded border ${selectedBank === bank ? 'border-blue-600' : 'border-slate-300'}`}
|
||||
onClick={() => setSelectedBank(bank)}
|
||||
key={bk}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedBank(bk)
|
||||
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 bank ${bk.toUpperCase()}`}
|
||||
>
|
||||
{bank.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
if (m === 'cstore') return (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-slate-600">Pilih gerai</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{['indomaret', 'alfamart'].map((store) => (
|
||||
<button
|
||||
key={store}
|
||||
className={`px-3 py-2 rounded border ${selectedStore === store ? 'border-blue-600' : 'border-slate-300'}`}
|
||||
onClick={() => setSelectedStore(store)}
|
||||
>
|
||||
{store}
|
||||
<BankLogo bank={bk} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isBusy && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
{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 as any}
|
||||
defaultBank={(selectedBank ?? 'bca')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedMethod === 'credit_card' && (
|
||||
<CardPanel
|
||||
locked={locked}
|
||||
|
|
@ -175,7 +223,6 @@ export function PayPage() {
|
|||
amount={amount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedMethod === 'gopay' && (
|
||||
<GoPayPanel
|
||||
locked={locked}
|
||||
|
|
@ -184,17 +231,17 @@ export function PayPage() {
|
|||
amount={amount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedMethod === 'cstore' && (
|
||||
<CStorePanel
|
||||
locked={locked}
|
||||
onChargeInitiated={() => setLocked(true)}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
defaultStore={selectedStore as any}
|
||||
defaultStore={selectedStore ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PaymentSheet>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,9 +49,11 @@ api.interceptors.response.use(
|
|||
return response
|
||||
},
|
||||
(error) => {
|
||||
const baseURL = error.config?.baseURL || ''
|
||||
const url = error.config?.url || ''
|
||||
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
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ body {
|
|||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
.light, html[data-theme="light"] body { background-color: #ffffff; color: #000000; }
|
||||
.dark body { background-color: #000000; color: #ffffff; }
|
||||
a { color: #dc2626; }
|
||||
a:hover { color: #b91c1c; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue