232 lines
7.2 KiB
JavaScript
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) =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])
|
|
);
|
|
}
|
|
|
|
$('#refresh-history').addEventListener('click', loadHistory);
|
|
|
|
// ---------- Init ----------
|
|
addRow();
|
|
loadHistory();
|