first commit

This commit is contained in:
andrew.ng 2026-07-02 10:31:13 +07:00
commit b46af7a927
16 changed files with 2402 additions and 0 deletions

8
.env.example Normal file
View File

@ -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

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
# data/*.db
# data/*.db-*
.env

38
README.md Normal file
View File

@ -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``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).

BIN
data/payments.db Normal file

Binary file not shown.

BIN
data/payments.db-shm Normal file

Binary file not shown.

BIN
data/payments.db-wal Normal file

Binary file not shown.

60
db.js Normal file
View File

@ -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 };

1265
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@ -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"
}
}

91
payment-api.js Normal file
View File

@ -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 };

231
public/app.js Normal file
View File

@ -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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])
);
}
$('#refresh-history').addEventListener('click', loadHistory);
// ---------- Init ----------
addRow();
loadHistory();

113
public/index.html Normal file
View File

@ -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>

BIN
public/mobile-payment.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

442
public/styles.css Normal file
View File

@ -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;
}
}

19
scripts/reset-db.js Normal file
View File

@ -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.');

114
server.js Normal file
View File

@ -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}`);
});