Quick_Payment/public/app.js

232 lines
7.2 KiB
JavaScript

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();