first commit
This commit is contained in:
commit
b46af7a927
|
|
@ -0,0 +1,8 @@
|
|||
# Server
|
||||
PORT=3000
|
||||
|
||||
# Payment API — điền thông tin API thật của bạn vào đây
|
||||
# URL endpoint để tạo payment link
|
||||
PAYMENT_API_URL=https://api.example.com/payment-links
|
||||
# Header Authorization (nếu API dùng Bearer token / API key)
|
||||
PAYMENT_API_KEY=your_api_key_here
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
# data/*.db
|
||||
# data/*.db-*
|
||||
.env
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Quick Payment
|
||||
|
||||
Server HTML đơn giản để tạo payment link và lưu lịch sử bằng SQLite.
|
||||
|
||||
## Chạy
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # hoặc: npm run dev (auto-reload)
|
||||
```
|
||||
|
||||
Mở http://localhost:3000
|
||||
|
||||
## Cấu hình API tạo payment link
|
||||
|
||||
1. Copy `.env.example` thành `.env`.
|
||||
2. Điền `PAYMENT_API_URL` và `PAYMENT_API_KEY`.
|
||||
3. Mở `payment-api.js` và chỉnh 2 chỗ có đánh dấu `TODO`:
|
||||
- **Request body** — map dữ liệu form sang đúng field API mong đợi.
|
||||
- **Response** — lấy đúng field chứa payment link trả về.
|
||||
|
||||
> Nếu chưa cấu hình `PAYMENT_API_URL`, server chạy **chế độ mock**: trả về link demo
|
||||
> để test luồng UI + lưu lịch sử. Ghép API thật sau bằng cách sửa `payment-api.js`.
|
||||
|
||||
## Cấu trúc
|
||||
|
||||
| File | Vai trò |
|
||||
|------|---------|
|
||||
| `server.js` | Express server + REST endpoints |
|
||||
| `payment-api.js` | **Adapter gọi API payment — nơi duy nhất cần sửa khi ghép API thật** |
|
||||
| `db.js` | SQLite (better-sqlite3), bảng `payments` |
|
||||
| `public/` | Frontend (form + bảng items + lịch sử) |
|
||||
| `data/payments.db` | File database (tự tạo) |
|
||||
|
||||
## API nội bộ
|
||||
|
||||
- `POST /api/payments` — tạo payment link, lưu lịch sử, trả `{ link, payment }`.
|
||||
- `GET /api/payments` — danh sách lịch sử (mới nhất trước).
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,60 @@
|
|||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
// npm run reset-db
|
||||
|
||||
const db = new Database(path.join(__dirname, 'data', 'payments.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
address TEXT,
|
||||
items_json TEXT NOT NULL,
|
||||
total REAL NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'AUD',
|
||||
payment_link TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'created',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Migrate existing databases that predate the currency column.
|
||||
const hasCurrency = db
|
||||
.prepare(`PRAGMA table_info(payments)`)
|
||||
.all()
|
||||
.some((col) => col.name === 'currency');
|
||||
if (!hasCurrency) {
|
||||
db.exec(`ALTER TABLE payments ADD COLUMN currency TEXT NOT NULL DEFAULT 'AUD'`);
|
||||
}
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO payments
|
||||
(customer_name, email, phone, address, items_json, total, currency, payment_link, status)
|
||||
VALUES
|
||||
(@customer_name, @email, @phone, @address, @items_json, @total, @currency, @payment_link, @status)
|
||||
`);
|
||||
|
||||
function savePayment(record) {
|
||||
const info = insertStmt.run(record);
|
||||
return getPayment(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
function getPayment(id) {
|
||||
return db.prepare('SELECT * FROM payments WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function listPayments(limit = 100) {
|
||||
return db
|
||||
.prepare('SELECT * FROM payments ORDER BY id DESC LIMIT ?')
|
||||
.all(limit);
|
||||
}
|
||||
|
||||
function countPayments() {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM payments').get().n;
|
||||
}
|
||||
|
||||
module.exports = { savePayment, getPayment, listPayments, countPayments };
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "quick-payment",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple server to create payment links with history stored in SQLite",
|
||||
"type": "commonjs",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"reset-db": "node scripts/reset-db.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
// =============================================================================
|
||||
// Payment API adapter
|
||||
// -----------------------------------------------------------------------------
|
||||
// This is the ONLY file you need to edit when integrating a real payment-link API.
|
||||
// Just make sure createPaymentLink(payload) returns:
|
||||
// { link: '<payment url>', raw: <the full original response for reference> }
|
||||
//
|
||||
// The payload it receives has the shape:
|
||||
// {
|
||||
// customerName, email, phone, address, currency,
|
||||
// items: [ { sku, name, price, quantity, condition } ],
|
||||
// total
|
||||
// }
|
||||
// =============================================================================
|
||||
|
||||
const PAYMENT_API_URL = process.env.PAYMENT_API_URL;
|
||||
const PAYMENT_API_KEY = process.env.PAYMENT_API_KEY;
|
||||
|
||||
async function createPaymentLink(payload) {
|
||||
// --- Map the form data to the body the API expects ------------------------
|
||||
// TODO: adjust the body to match your real API.
|
||||
const body = {
|
||||
fullname: payload.customerName,
|
||||
customer_email: payload.email,
|
||||
phone: payload.phone,
|
||||
address: payload.address,
|
||||
items: payload.items.map((it) => ({
|
||||
sku: it.sku,
|
||||
name: it.name ?? it.sku,
|
||||
price: it.price,
|
||||
quantity: it.quantity,
|
||||
condition: it.condition,
|
||||
})),
|
||||
total_price: payload.total,
|
||||
currency: payload.currency,
|
||||
quote_url: 'https://your-website.com/quote',
|
||||
// API expects PHP format Y-m-d\TH:i:s.u\Z (microseconds = 6 digits).
|
||||
// toISOString() only gives 3 (ms), so pad the fraction to 6 digits.
|
||||
expired_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.replace('Z', '000Z'), // 7 days from now, e.g. 2026-07-09T12:34:56.789000Z
|
||||
quoted_by: {
|
||||
id: "1",
|
||||
fullname: "Kay",
|
||||
email: "kay@prology.net",
|
||||
phone: "AU: +61 2 8061 6886 ext 8007, US: +1 714 689 3332 ext 8007",
|
||||
},
|
||||
quote_number: payload.quoteNumber,
|
||||
};
|
||||
|
||||
const res = await fetch(PAYMENT_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `${PAYMENT_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Payment API returned ${res.status}: ${typeof data === 'string' ? data : JSON.stringify(data)}`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Extract the link from the response ----------------------------------
|
||||
// TODO: adjust the field path to match your real API.
|
||||
const link =
|
||||
data.url ||
|
||||
data.link ||
|
||||
data.payment_link ||
|
||||
data.paymentUrl ||
|
||||
(data.data && (data.data.url || data.data.link));
|
||||
|
||||
if (!link) {
|
||||
throw new Error('No payment link found in the response: ' + JSON.stringify(data));
|
||||
}
|
||||
|
||||
return { link, raw: data };
|
||||
}
|
||||
|
||||
module.exports = { createPaymentLink };
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||
|
||||
const itemsBody = $('#items-body');
|
||||
const rowTemplate = $('#row-template');
|
||||
const form = $('#payment-form');
|
||||
const resultBox = $('#result');
|
||||
const submitBtn = $('#submit-btn');
|
||||
|
||||
const currencySelect = $('#currency');
|
||||
|
||||
const money = (n, currency = currencySelect?.value || 'AUD') =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(Number(n) || 0);
|
||||
|
||||
// ---------- Items table ----------
|
||||
function addRow(data = {}) {
|
||||
const frag = rowTemplate.content.cloneNode(true);
|
||||
const row = frag.querySelector('tr');
|
||||
if (data.sku) row.querySelector('.cell-sku').value = data.sku;
|
||||
if (data.name) row.querySelector('.cell-name').value = data.name;
|
||||
if (data.price != null) row.querySelector('.cell-price').value = data.price;
|
||||
if (data.quantity != null) row.querySelector('.cell-qty').value = data.quantity;
|
||||
if (data.condition) row.querySelector('.cell-condition').value = data.condition;
|
||||
|
||||
row.querySelector('.btn-del').addEventListener('click', () => {
|
||||
row.remove();
|
||||
if (itemsBody.children.length === 0) addRow();
|
||||
recalc();
|
||||
});
|
||||
row.addEventListener('input', recalc);
|
||||
itemsBody.appendChild(row);
|
||||
recalc();
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
let total = 0;
|
||||
$$('.item-row', itemsBody).forEach((row) => {
|
||||
const price = parseFloat(row.querySelector('.cell-price').value) || 0;
|
||||
const qty = parseFloat(row.querySelector('.cell-qty').value) || 0;
|
||||
const sub = price * qty;
|
||||
total += sub;
|
||||
row.querySelector('.cell-subtotal').textContent = money(sub);
|
||||
});
|
||||
$('#grand-total').textContent = money(total);
|
||||
}
|
||||
|
||||
function collectItems() {
|
||||
return $$('.item-row', itemsBody)
|
||||
.map((row) => ({
|
||||
sku: row.querySelector('.cell-sku').value.trim(),
|
||||
name: row.querySelector('.cell-name').value.trim(),
|
||||
price: parseFloat(row.querySelector('.cell-price').value) || 0,
|
||||
quantity: parseFloat(row.querySelector('.cell-qty').value) || 0,
|
||||
condition: row.querySelector('.cell-condition').value.trim(),
|
||||
}))
|
||||
.filter((it) => it.sku || it.name || it.price > 0);
|
||||
}
|
||||
|
||||
$('#add-row').addEventListener('click', () => addRow());
|
||||
|
||||
// Re-render totals when the currency changes.
|
||||
currencySelect.addEventListener('change', recalc);
|
||||
|
||||
// ---------- Submit ----------
|
||||
function setLoading(on) {
|
||||
submitBtn.disabled = on;
|
||||
$('.spinner', submitBtn).hidden = !on;
|
||||
$('.btn-label', submitBtn).textContent = on ? 'Creating...' : 'Create payment link';
|
||||
}
|
||||
|
||||
function showResult(type, html) {
|
||||
resultBox.hidden = false;
|
||||
resultBox.className = `result ${type}`;
|
||||
resultBox.innerHTML = html;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customer = {
|
||||
customerName: $('#customerName').value.trim(),
|
||||
email: $('#email').value.trim(),
|
||||
phone: $('#phone').value.trim(),
|
||||
address: $('#address').value.trim(),
|
||||
};
|
||||
|
||||
// All customer fields are required.
|
||||
const CUSTOMER_LABELS = {
|
||||
customerName: 'Customer name',
|
||||
email: 'Email',
|
||||
phone: 'Phone number',
|
||||
address: 'Address',
|
||||
};
|
||||
const missingCustomer = Object.keys(CUSTOMER_LABELS).filter((k) => !customer[k]);
|
||||
if (missingCustomer.length) {
|
||||
showResult('error', `Please fill in: ${missingCustomer.map((k) => CUSTOMER_LABELS[k]).join(', ')}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// At least one item, each with SKU, price and quantity.
|
||||
const items = collectItems();
|
||||
if (items.length === 0) {
|
||||
showResult('error', 'Please add at least one item.');
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
const missing = [];
|
||||
if (!it.sku) missing.push('SKU');
|
||||
if (!(it.price > 0)) missing.push('Price');
|
||||
if (!(it.quantity > 0)) missing.push('Qty');
|
||||
if (!it.condition) missing.push('Condition');
|
||||
if (missing.length) {
|
||||
showResult('error', `Item ${i + 1}: ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...customer,
|
||||
currency: currencySelect.value,
|
||||
items,
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
resultBox.hidden = true;
|
||||
try {
|
||||
const res = await fetch('/api/payments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to create link.');
|
||||
|
||||
// Open in a new tab
|
||||
window.open(data.link, '_blank', 'noopener');
|
||||
|
||||
showResult(
|
||||
'success',
|
||||
`<strong>✓ Link created successfully!</strong>
|
||||
<div class="link-row">
|
||||
<a href="${data.link}" target="_blank" rel="noopener">${data.link}</a>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="copy-link">Copy</button>
|
||||
</div>`
|
||||
);
|
||||
$('#copy-link')?.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(data.link);
|
||||
$('#copy-link').textContent = 'Copied';
|
||||
});
|
||||
|
||||
loadHistory();
|
||||
} catch (err) {
|
||||
showResult('error', `<strong>Error:</strong> ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- History ----------
|
||||
async function loadHistory() {
|
||||
const list = $('#history-list');
|
||||
try {
|
||||
const res = await fetch('/api/payments');
|
||||
const data = await res.json();
|
||||
const payments = data.payments || [];
|
||||
if (payments.length === 0) {
|
||||
list.innerHTML = '<p class="muted empty">No payments yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = payments.map(renderHistItem).join('');
|
||||
} catch {
|
||||
list.innerHTML = '<p class="muted empty">Could not load history.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistItem(p) {
|
||||
let itemCount = 0;
|
||||
try {
|
||||
itemCount = JSON.parse(p.items_json || '[]').length;
|
||||
} catch { }
|
||||
const when = (p.created_at || '').replace('T', ' ').slice(0, 16);
|
||||
const hasLink = !!p.payment_link;
|
||||
const hint = hasLink
|
||||
? `<div class="hist-actions"><span class="hist-link">Open link →</span></div>`
|
||||
: '';
|
||||
return `
|
||||
<div class="hist-item${hasLink ? ' clickable' : ''}"${hasLink ? ` data-link="${escapeHtml(p.payment_link)}" role="link" tabindex="0"` : ''}>
|
||||
<div class="hist-top">
|
||||
<span class="hist-name">${escapeHtml(p.customer_name)}</span>
|
||||
<span class="hist-total">${money(p.total, p.currency || 'AUD')}</span>
|
||||
</div>
|
||||
<div class="hist-meta">${escapeHtml(p.email || p.phone || '')} · ${itemCount} item · ${when}</div>
|
||||
${hint}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Whole history card is clickable → open its payment link in a new tab.
|
||||
function openHistItem(el) {
|
||||
const link = el?.dataset?.link;
|
||||
if (link) window.open(link, '_blank', 'noopener');
|
||||
}
|
||||
$('#history-list').addEventListener('click', (e) => {
|
||||
openHistItem(e.target.closest('.hist-item.clickable'));
|
||||
});
|
||||
$('#history-list').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
const card = e.target.closest('.hist-item.clickable');
|
||||
if (card) {
|
||||
e.preventDefault();
|
||||
openHistItem(card);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])
|
||||
);
|
||||
}
|
||||
|
||||
$('#refresh-history').addEventListener('click', loadHistory);
|
||||
|
||||
// ---------- Init ----------
|
||||
addRow();
|
||||
loadHistory();
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Quick Payment</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="icon" href="mobile-payment.png" type="image/png" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- ============ FORM (left) ============ -->
|
||||
<main class="panel form-panel">
|
||||
<header class="panel-head">
|
||||
<h1>Create Payment Link</h1>
|
||||
<p class="muted">Enter the customer and order details to create a payment link.</p>
|
||||
</header>
|
||||
|
||||
<form id="payment-form" autocomplete="off">
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="customerName">Customer name <span class="req">*</span></label>
|
||||
<input id="customerName" name="customerName" type="text" placeholder="John Smith" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">Email <span class="req">*</span></label>
|
||||
<input id="email" name="email" type="email" placeholder="customer@email.com" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="phone">Phone number <span class="req">*</span></label>
|
||||
<input id="phone" name="phone" type="tel" placeholder="+61 4xx xxx xxx" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="address">Address <span class="req">*</span></label>
|
||||
<input id="address" name="address" type="text" placeholder="Street, suburb, city" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="items-section">
|
||||
<div class="items-head">
|
||||
<h2>Items</h2>
|
||||
<button type="button" id="add-row" class="btn btn-ghost">+ Add row</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center">SKU <span class="req">*</span></th>
|
||||
<th class="center">Name</th>
|
||||
<th class="num">Price <span class="req">*</span></th>
|
||||
<th class="num">Qty <span class="req">*</span></th>
|
||||
<th class="center">Cond<span class="req">*</span></th>
|
||||
<th class="num">Subtotal</th>
|
||||
<th aria-label="Remove"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="items-body"></tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5" class="total-label">Total</td>
|
||||
<td class="num total-value" id="grand-total">0</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="currency-field">
|
||||
<label for="currency">Currency</label>
|
||||
<select id="currency" name="currency">
|
||||
<option value="USD">USD</option>
|
||||
<option value="AUD" selected>AUD</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" id="submit-btn" class="btn btn-primary">
|
||||
<span class="btn-label">Create payment link</span>
|
||||
<span class="spinner" hidden></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="result" class="result" hidden></div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<!-- ============ HISTORY (right) ============ -->
|
||||
<aside class="panel history-panel">
|
||||
<header class="panel-head">
|
||||
<h2>History</h2>
|
||||
<button type="button" id="refresh-history" class="btn btn-ghost btn-sm">↻</button>
|
||||
</header>
|
||||
<div id="history-list" class="history-list">
|
||||
<p class="muted empty">No payments yet.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<template id="row-template">
|
||||
<tr class="item-row">
|
||||
<td data-label="SKU"><input type="text" class="cell-sku" placeholder="SKU" /></td>
|
||||
<td data-label="Name"><input type="text" class="cell-name" placeholder="Product name" /></td>
|
||||
<td data-label="Price"><input type="number" class="cell-price num" min="0" step="1" placeholder="0" /></td>
|
||||
<td data-label="Qty"><input type="number" class="cell-qty num" min="1" step="1" value="1" /></td>
|
||||
<td data-label="Condition"><input type="text" class="cell-condition" placeholder="Condition" /></td>
|
||||
<td data-label="Subtotal" class="num cell-subtotal">0</td>
|
||||
<td><button type="button" class="btn-del" title="Remove row">×</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
|
|
@ -0,0 +1,442 @@
|
|||
:root {
|
||||
--bg: #f4f6fb;
|
||||
--panel: #ffffff;
|
||||
--border: #e5e8f0;
|
||||
--text: #1f2430;
|
||||
--muted: #8a92a6;
|
||||
--primary: #4f6bed;
|
||||
--primary-hover: #3f57d6;
|
||||
--primary-soft: #eef1fe;
|
||||
--success: #16a34a;
|
||||
--danger: #ef4444;
|
||||
--radius: 14px;
|
||||
--shadow: 0 1px 3px rgba(20, 30, 60, 0.06), 0 8px 24px rgba(20, 30, 60, 0.06);
|
||||
font-family:
|
||||
"Inter",
|
||||
"Segoe UI",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 440px;
|
||||
gap: 20px;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
position: sticky;
|
||||
top: 28px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.panel-head h1 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.panel-head h2 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.panel-head p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.req {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ---- Form fields ---- */
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.field label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #4b5266;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-soft);
|
||||
}
|
||||
input::placeholder {
|
||||
color: #b7bdcc;
|
||||
}
|
||||
|
||||
#customerName,
|
||||
#phone {
|
||||
width: 300px;
|
||||
}
|
||||
#email,
|
||||
#address {
|
||||
width: 550px;
|
||||
}
|
||||
|
||||
/* ---- Items table ---- */
|
||||
.items-section {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.items-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.items-head h2 {
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
padding: 10px 10px;
|
||||
background: #fafbfe;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th.num,
|
||||
td.num {
|
||||
text-align: right;
|
||||
}
|
||||
th.center,
|
||||
td.center {
|
||||
text-align: center;
|
||||
}
|
||||
tbody td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #f1f3f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody input {
|
||||
padding: 7px 9px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
tbody input.num {
|
||||
text-align: right;
|
||||
}
|
||||
.cell-subtotal {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
color: #4b5266;
|
||||
}
|
||||
|
||||
td:has(.cell-sku) {
|
||||
width: 25%;
|
||||
}
|
||||
td:has(.cell-price) {
|
||||
width: 90px;
|
||||
}
|
||||
td:has(.cell-qty) {
|
||||
width: 70px;
|
||||
}
|
||||
td:has(.cell-condition) {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.btn-del {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.btn-del:hover {
|
||||
background: #fdeaea;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
padding: 12px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.total-label {
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
}
|
||||
.total-value {
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 10px 16px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
transform 0.05s;
|
||||
}
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: #fff;
|
||||
border-color: var(--border);
|
||||
color: var(--primary);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---- Currency selector ---- */
|
||||
.currency-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.currency-field label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #4b5266;
|
||||
}
|
||||
.currency-field select {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
min-width: 100px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.currency-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-soft);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Result ---- */
|
||||
.result {
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.result.success {
|
||||
background: #ecfdf3;
|
||||
border: 1px solid #b6f0cc;
|
||||
color: #0f7a3d;
|
||||
}
|
||||
.result.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fbcaca;
|
||||
color: #b42323;
|
||||
}
|
||||
.result a {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
.result .link-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ---- History ---- */
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 72vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.history-list .empty {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
.hist-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.hist-item:hover {
|
||||
border-color: #cdd5f5;
|
||||
box-shadow: 0 2px 8px rgba(20, 30, 60, 0.05);
|
||||
}
|
||||
.hist-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.hist-item.clickable:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.hist-item.clickable:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-soft);
|
||||
}
|
||||
.hist-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.hist-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.hist-total {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hist-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.hist-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.hist-link {
|
||||
font-size: 12.5px;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.hist-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.history-panel {
|
||||
position: static;
|
||||
}
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Clears the local SQLite database (all payments.db* files).
|
||||
// Usage: npm run reset-db
|
||||
// Stop the server first, then run this — db.js recreates an empty table on next start.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
let removed = 0;
|
||||
|
||||
if (fs.existsSync(dataDir)) {
|
||||
for (const file of fs.readdirSync(dataDir)) {
|
||||
if (file.startsWith('payments.db')) {
|
||||
fs.rmSync(path.join(dataDir, file), { force: true });
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(removed ? `Cleared ${removed} database file(s).` : 'No database files to clear.');
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
require('dotenv').config();
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const { savePayment, listPayments, countPayments } = require('./db');
|
||||
const { createPaymentLink } = require('./payment-api');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
function toNumber(v) {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
const ALLOWED_CURRENCIES = ['USD', 'AUD'];
|
||||
|
||||
// --- Create payment link ---------------------------------------------------
|
||||
app.post('/api/payments', async (req, res) => {
|
||||
const { customerName, email, phone, address, currency, items } = req.body || {};
|
||||
|
||||
// All customer fields are required.
|
||||
const customerFields = { customerName, email, phone, address };
|
||||
const CUSTOMER_LABELS = {
|
||||
customerName: 'Customer name',
|
||||
email: 'Email',
|
||||
phone: 'Phone number',
|
||||
address: 'Address',
|
||||
};
|
||||
const missingCustomer = Object.keys(CUSTOMER_LABELS).filter(
|
||||
(k) => !customerFields[k] || !String(customerFields[k]).trim()
|
||||
);
|
||||
if (missingCustomer.length) {
|
||||
return res.status(400).json({
|
||||
error: `Missing required field(s): ${missingCustomer.map((k) => CUSTOMER_LABELS[k]).join(', ')}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const cleanItems = Array.isArray(items) ? items : [];
|
||||
if (cleanItems.length === 0) {
|
||||
return res.status(400).json({ error: 'At least one item is required.' });
|
||||
}
|
||||
const cleanCurrency = ALLOWED_CURRENCIES.includes(currency) ? currency : 'AUD';
|
||||
|
||||
const normItems = cleanItems.map((it) => ({
|
||||
sku: (it.sku || '').trim(),
|
||||
name: (it.name || '').trim(),
|
||||
price: toNumber(it.price),
|
||||
quantity: toNumber(it.quantity),
|
||||
condition: (it.condition || '').trim(),
|
||||
}));
|
||||
|
||||
// Each item requires SKU, a positive price, a positive quantity and a condition.
|
||||
for (let i = 0; i < normItems.length; i++) {
|
||||
const it = normItems[i];
|
||||
const missing = [];
|
||||
if (!it.sku) missing.push('SKU');
|
||||
if (!(it.price > 0)) missing.push('price');
|
||||
if (!(it.quantity > 0)) missing.push('quantity');
|
||||
if (!it.condition) missing.push('condition');
|
||||
if (missing.length) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `Item ${i + 1} is missing required field(s): ${missing.join(', ')}.` });
|
||||
}
|
||||
}
|
||||
|
||||
const total = normItems.reduce((sum, it) => sum + it.price * it.quantity, 0);
|
||||
|
||||
// Sequential quote number based on how many payments already exist:
|
||||
// first ever -> QT000001, when history already has 9 -> QT000010.
|
||||
const quoteNumber = `QT${String(countPayments() + 1).padStart(6, '0')}`;
|
||||
|
||||
try {
|
||||
const dataPayment = await createPaymentLink({
|
||||
customerName,
|
||||
email,
|
||||
phone,
|
||||
address,
|
||||
currency: cleanCurrency,
|
||||
quoteNumber,
|
||||
items: normItems,
|
||||
total,
|
||||
});
|
||||
|
||||
const saved = savePayment({
|
||||
customer_name: customerName,
|
||||
email: email || '',
|
||||
phone: phone || '',
|
||||
address: address || '',
|
||||
items_json: JSON.stringify(normItems),
|
||||
total,
|
||||
currency: cleanCurrency,
|
||||
payment_link: dataPayment.link,
|
||||
status: 'created',
|
||||
});
|
||||
|
||||
res.json({ ok: true, payment: saved, link: dataPayment.link, raw: dataPayment.raw });
|
||||
} catch (err) {
|
||||
console.error('createPaymentLink error:', err);
|
||||
res.status(502).json({ error: err.message || 'Failed to create payment link.' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- History ---------------------------------------------------------------
|
||||
app.get('/api/payments', (_req, res) => {
|
||||
res.json({ ok: true, payments: listPayments() });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Quick Payment server running: http://localhost:${PORT}`);
|
||||
});
|
||||
Loading…
Reference in New Issue