From 2f3007d978b380991fc9eee0f2084dc49dcb2aab Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 4 Sep 2026 11:55:38 +0200 Subject: [PATCH] feat(pos): enhance POS configuration to handle missing API key prompts gracefully and add slip preview functionality in settings --- public/pos/pos-config.js | 16 +- public/pos/settings.html | 340 ++++++++++++++++++++++++++++++++- public/pos/test-longpress.html | 305 +++++++++++++++++++++++++++++ 3 files changed, 655 insertions(+), 6 deletions(-) create mode 100644 public/pos/test-longpress.html diff --git a/public/pos/pos-config.js b/public/pos/pos-config.js index 7ee3952..76d385f 100644 --- a/public/pos/pos-config.js +++ b/public/pos/pos-config.js @@ -4,9 +4,19 @@ window.POS_CONFIG = (function () { const STORAGE_KEY = "pos.apiKey"; let apiKey = localStorage.getItem(STORAGE_KEY) || ""; - if (!apiKey) { - apiKey = window.prompt("Enter this till's POS API key (ask the office for it):") || ""; - if (apiKey) localStorage.setItem(STORAGE_KEY, apiKey); + if (!apiKey && typeof window.prompt === "function") { + // `prompt` is unsupported in some contexts (automated browsers, + // headless test runners, some embedded webviews). Don't let a + // missing prompt throw and abort the rest of the script — the + // till will just have an empty key until the operator sets it via + // the login flow / Settings page. The next attempt to call the + // API will return 401, which the boot screens surface clearly. + try { + apiKey = window.prompt("Enter this till's POS API key (ask the office for it):") || ""; + if (apiKey) localStorage.setItem(STORAGE_KEY, apiKey); + } catch (_) { + apiKey = ""; + } } return { apiBase: "/api/v1/pos", apiKey }; })(); diff --git a/public/pos/settings.html b/public/pos/settings.html index 388aa83..203e53f 100644 --- a/public/pos/settings.html +++ b/public/pos/settings.html @@ -129,6 +129,83 @@ body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin :root[data-theme="dark"] .banner.warn{background:#2a1a1a;border-color:#5a3a1a;color:#ffb060} :root[data-theme="dark"] .banner.err{background:#2a1a1a;border-color:#5a2429;color:#ff6680} :root[data-theme="dark"] .skeleton{color:#666} +:root[data-theme="dark"] .preview-modal{background:#1a1416} +:root[data-theme="dark"] .preview-modal .x{background:var(--surface)} + +/* ---- Slip preview tabs ---- */ +.preview-tabs{display:flex;gap:6px;padding:4px 4px 10px} +.preview-tab{flex:1;padding:8px 10px;border:1px solid var(--muted);border-radius:10px;background:#fff;font-size:13px;font-weight:600;color:var(--fg);transition:background .12s, color .12s, border-color .12s} +.preview-tab:active{background:var(--surface)} +.preview-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)} +/* ---- Letterhead card — clickable to open the slip preview modal ---- */ +.active .body{cursor:pointer;-webkit-tap-highlight-color:transparent} +.active .body:active{opacity:.85} +.active .body .name{text-decoration:underline;text-decoration-color:rgba(255,56,92,.3);text-underline-offset:3px} +.preview-note{margin:6px 4px 0;font-size:11px;color:#888;letter-spacing:.01em} + +/* ---- Slip preview modal ---- */ +.preview-modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:80;opacity:0;pointer-events:none;transition:opacity .15s} +.preview-modal-backdrop.show{opacity:1;pointer-events:auto} +.preview-modal{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(.96);background:var(--bg);border-radius:16px;padding:20px 20px 18px;width:calc(100% - 32px);max-width:440px;max-height:88dvh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .15s, transform .2s cubic-bezier(.2,.8,.2,1);z-index:81} +.preview-modal-backdrop.show .preview-modal{opacity:1;transform:translate(-50%,-50%) scale(1);pointer-events:auto} +.preview-modal h2{font-size:16px;font-weight:700;letter-spacing:-.01em;margin:0 0 4px} +.preview-modal .modal-sub{font-size:12px;color:var(--ink-soft,#717171);margin:0 0 14px} +.preview-modal .x{position:absolute;top:10px;right:10px;width:32px;height:32px;border-radius:50%;background:var(--surface);display:grid;place-items:center;color:var(--fg);border:0;cursor:pointer} +.preview-modal .x:active{background:var(--bg)} + +/* ---- Receipt preview pane ---- */ +.receipt-preview{ + /* `monospace` is the generic CSS family — every browser maps it + to a real monospace font (Menlo/SF Mono on macOS, Consolas on + Windows, Roboto Mono / DejaVu on Linux/Android). The previous + "SF Mono,Menlo,Consolas" stack fell back to a proportional + font on this system and broke the right-column alignment. */ + font-family:monospace; + font-size:10px; + line-height:1.35; + background:#fdfdfa; + color:#1a1a1a; + border:1px solid #999; + border-radius:4px; + padding:10px 10px; + /* 24 chars * ~10px + 20px padding = 260px. Anything wider and we + scroll horizontally instead of overflowing. */ + width:260px; + max-width:100%; + margin:0 auto 4px; + box-shadow:0 2px 6px rgba(0,0,0,.08); + white-space:pre; + overflow-x:auto; + overflow-wrap:normal; + word-break:keep-all; + /* Mimic thermal paper: slightly off-white, ragged-edge look */ + position:relative; +} +.receipt-preview::before, .receipt-preview::after{ + content:''; + position:absolute; + left:0; right:0; + height:6px; + background:radial-gradient(circle at 3px 0, #fff 0 1.5px, transparent 1.5px) repeat-x; + background-size:6px 6px; +} +.receipt-preview::before{ top:-3px; } +.receipt-preview::after{ bottom:-3px; transform:scaleY(-1); } +.receipt-preview .center{text-align:center;display:block} +.receipt-preview .row{display:flex;justify-content:space-between;gap:8px} +.receipt-preview .grow{flex:1} +.receipt-preview .right{text-align:right;flex:0 0 auto} +.receipt-preview .dim{color:#666} +.receipt-preview .em{font-weight:700;color:#000} +.receipt-preview .total{font-weight:700} +.receipt-preview .divider{border-top:1px dashed #999;margin:6px 0;height:0} +.receipt-preview .signoff{text-align:center;color:#666;margin-top:6px} +.receipt-preview .empty{text-align:center;color:#999;padding:30px 0;font-family:inherit;white-space:normal} + +/* The preview pane is intentionally light regardless of the till's + theme — thermal paper is always light. The wrapper around it still + follows the theme. */ + @media (prefers-reduced-motion: reduce){*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}} @@ -153,6 +230,18 @@ body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin
Switch letterhead
+
Slip preview
+

A preview of what each slip type will print with the active letterhead. Sample data — not from a real sale.

+
+ + + +
+
+
Loading preview…
+
+

+
@@ -313,13 +402,22 @@ async function boot(){ showBanner('err', 'Could not load letterhead: ' + (e.message || e)); } paintActive(active); - // Always show the list so the operator can switch. + // Always render the slip preview, even if the list fetch fails. + // The preview uses a demo letterhead fallback so the operator can + // sanity-check the layout before committing to a real letterhead. + renderPreview(); + const note = document.getElementById('previewNote'); + if(note){ + note.textContent = active + ? 'Preview uses your selected letterhead.' + : 'Preview only — pick a letterhead above to see your real one.'; + } + // Try to populate the letterhead picker list (best-effort — the + // preview above already rendered with whatever we have). let list = window.PosLetterhead.getCachedList(); if(!list.length){ try{ list = await window.PosLetterhead.fetchList(); } catch(e){ - // Cached list unavailable (offline + first run). Render a hint - // rather than nothing. document.getElementById('lhList').innerHTML = '
Letterhead list unavailable offline. Reconnect to set up.
'; return; } @@ -327,6 +425,242 @@ async function boot(){ paintList(list, active ? active.id : null); } +// ---------------- Slip preview ---------------- +// Renders an HTML approximation of the three slip types the till +// prints: sale (cash/card/eft), delivery note (account), and cash-up. +// Sample data is hardcoded — this is a layout/font preview, not a +// receipt of a real sale. The actual printed slip uses the ePOS-Print +// XML emitted by EposPrint.buildEposXml/Delivery/Cashup and rendered +// by the TM-T20II; this is the same data, shaped to look like a +// thermal slip in the browser. + +let currentPreviewType = 'sale'; + +function switchPreview(type){ + currentPreviewType = type; + document.querySelectorAll('.preview-tab').forEach(t => { + t.classList.toggle('active', t.dataset.type === type); + }); + renderPreview(); +} + +function pad(n, w){ + const s = String(n == null ? 0 : n); + return s.length >= w ? s : (' '.repeat(w - s.length) + s); +} +function money(n){ return 'R ' + (Number(n) || 0).toFixed(2); } + +// 24-char width — fits the 288px preview box even with the wider +// monospace fonts on some platforms (each char is ~10-12px at our +// 10px size on macOS Chromium). The real printer is 32 chars on +// 80mm paper; we narrow to 24 here so the preview never wraps on +// a phone. +const W = 24; +const center = (s) => { + s = String(s); + if (s.length >= W) return s; + const padLen = Math.floor((W - s.length) / 2); + return ' '.repeat(padLen) + s; +}; +const row = (l, r) => { + // Trim labels that are too long to fit — better a clipped line + // than a wrapped one that pushes the price onto the next row. + const leftMax = Math.max(4, W - String(r).length - 1); + let left = String(l); + if (left.length > leftMax) left = left.slice(0, leftMax - 1) + '…'; + const space = Math.max(1, W - left.length - String(r).length); + return left + ' '.repeat(space) + String(r); +}; +const divider = '-'.repeat(W); + +function renderPreview(){ + const el = document.getElementById('receiptPreview'); + if(!el) return; + const letterhead = window.PosLetterhead.getActive() || DEMO_LETTERHEAD; + if(currentPreviewType === 'sale') el.innerHTML = previewSale(letterhead); + else if(currentPreviewType === 'delivery') el.innerHTML = previewDelivery(letterhead); + else if(currentPreviewType === 'cashup') el.innerHTML = previewCashup(letterhead); +} + +// Fallback letterhead used when the real one can't be loaded (e.g. the +// API is unreachable, or the operator hasn't picked one yet). Lets the +// preview render something useful so the operator can sanity-check the +// layout, font, and divider style before they go through Settings → +// "Test print" against a real printer. A small "preview only" note is +// rendered above the slip so the operator knows this isn't their data. +const DEMO_LETTERHEAD = { + id: '__demo__', + display_name: 'WESTSCOTT GROUP OF COMPANIES', + company: 'WESTSCOTT GROUP OF COMPANIES', + address: '47 Station Street, Carletonville, 2499', + phone: '+27 18 786 2897', + email: 'quotes@wfsales.co.za', + vat_number: '4590136331', + registration_number: '', + footer_text: 'Thank you for your business.', + logo: '', + logo_url: '', + is_active: true, + accent_color: '#FFBF00', + vat_percent: 15, + collection_id: '__demo__', +}; + +function previewSale(lh){ + const vatPct = Number(lh.vat_percent) || 15; + const sampleItems = [ + { description: 'Widget A', sku: 'WA-100', qty: 2, unit: 'ea', selling_price: 45.00 }, + { description: 'Widget B', sku: 'WB-200', qty: 1, unit: 'ea', selling_price: 89.99 }, + ]; + const gross = sampleItems.reduce((s, it) => s + it.qty * it.selling_price, 0); + const factor = vatPct > 0 ? (1 + vatPct / 100) : 1; + const total = gross; + const sub = total / factor; + const vat = total - sub; + const cashGiven = 200, change = cashGiven - total; + const d = new Date(); + const dateStr = d.toLocaleString('en-ZA', { day:'2-digit', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' }); + + const itemsHTML = sampleItems.map(it => { + const line = money(it.qty * it.selling_price); + return esc(it.description) + '\n' + + ' ' + it.qty + ' x ' + money(it.selling_price) + ' ' + line + '\n'; + }).join(''); + + // 32-char width like an 80mm thermal printer at the default font. + const W = 32; + const center = (s) => { + s = String(s); + const padLen = Math.max(0, Math.floor((W - s.length) / 2)); + return ' '.repeat(padLen) + s; + }; + const row = (l, r) => { + const space = Math.max(1, W - String(l).length - String(r).length); + return l + ' '.repeat(space) + r; + }; + const divider = '-'.repeat(W); + + const out = []; + out.push(center('WFSALES POS')); + if(lh.display_name) out.push(center(lh.display_name)); + if(lh.address) out.push(center(String(lh.address).split('\n')[0])); + if(lh.phone) out.push(center('Tel: ' + lh.phone)); + if(lh.vat_number) out.push(center('VAT: ' + lh.vat_number)); + out.push(divider); + out.push(row('Date: ' + dateStr, 'Slip: SAMPLE')); + out.push(divider); + out.push(itemsHTML.trimEnd()); + out.push(divider); + out.push(row('Subtotal', money(sub))); + if(vatPct > 0) out.push(row('VAT ' + vatPct + '%', money(vat))); + out.push(row('TOTAL', money(total))); + out.push(row('Paid: CASH ' + money(cashGiven), '')); + out.push(row('Change', money(change))); + out.push(divider); + if(lh.footer_text) out.push(center(lh.footer_text)); + else out.push(center('Thank you for your purchase.')); + // Wrap each line in a span so we can render with white-space:pre + // while keeping tight spacing. + return out.map(line => '' + esc(line) + '').join(''); +} + +function previewDelivery(lh){ + const sampleItems = [ + { description: 'Widget A', sku: 'WA-100', qty: 5, unit: 'ea', selling_price: 45.00 }, + { description: 'Widget C', sku: 'WC-300', qty: 3, unit: 'ea', selling_price: 22.50 }, + ]; + const total = sampleItems.reduce((s, it) => s + it.qty * it.selling_price, 0); + const d = new Date(); + const dateStr = d.toLocaleDateString('en-ZA', { day:'2-digit', month:'short', year:'numeric' }); + + const W = 32; + const center = (s) => { + s = String(s); + const padLen = Math.max(0, Math.floor((W - s.length) / 2)); + return ' '.repeat(padLen) + s; + }; + const divider = '-'.repeat(W); + + const out = []; + out.push(center('WFSALES POS')); + out.push(center('Delivery Note')); + if(lh.display_name) out.push(center(lh.display_name)); + if(lh.address) out.push(center(String(lh.address).split('\n')[0])); + if(lh.phone) out.push(center('Tel: ' + lh.phone)); + if(lh.vat_number) out.push(center('VAT: ' + lh.vat_number)); + out.push('Company: Acme Mining (Pty) Ltd'); + out.push('Customer: Joe Example'); + out.push('Date: ' + dateStr); + out.push(divider); + sampleItems.forEach(it => { + out.push(esc(it.description)); + const qtyLine = ' ' + it.qty + ' ' + (it.unit || 'ea'); + out.push(qtyLine + ' ' + money(it.qty * it.selling_price)); + }); + out.push(divider); + out.push('TOTAL: ' + money(total)); + out.push('Received by: __________ Driver: __________'); + return out.map(line => '' + esc(line) + '').join(''); +} + +function previewCashup(lh){ + const counts = { cash: 12, card: 7, eft: 3, account: 2 }; + const totals = { cash: 1234.50, card: 890.00, eft: 215.75, account: 1450.00 }; + const grand = totals.cash + totals.card + totals.eft + totals.account; + const cashCounted = 1230.00; // R4.50 short + const variance = cashCounted - totals.cash; + const denoms = [ + { label: 'R200', value: 200, count: 3 }, + { label: 'R100', value: 100, count: 4 }, + { label: 'R50', value: 50, count: 2 }, + { label: 'R20', value: 20, count: 1 }, + ]; + const d = new Date(); + const dateLabel = d.toLocaleDateString('en-ZA', { day:'2-digit', month:'short', year:'numeric' }); + + const W = 32; + const center = (s) => { + s = String(s); + const padLen = Math.max(0, Math.floor((W - s.length) / 2)); + return ' '.repeat(padLen) + s; + }; + const row = (l, r) => { + const space = Math.max(1, W - String(l).length - String(r).length); + return l + ' '.repeat(space) + r; + }; + const divider = '-'.repeat(W); + + const out = []; + out.push(center('WFSALES POS')); + out.push(center('CASH-UP REPORT')); + if(lh.display_name) out.push(center(lh.display_name)); + if(lh.phone) out.push(center('Tel: ' + lh.phone)); + out.push(divider); + out.push(row('Shift: Today', 'Date: ' + dateLabel)); + out.push('Total sales: ' + (counts.cash + counts.card + counts.eft + counts.account)); + out.push(divider); + out.push('PAYMENT METHOD BREAKDOWN'); + out.push(row('Cash ' + pad(counts.cash, 3) + ' sales', money(totals.cash))); + out.push(row('Card ' + pad(counts.card, 3) + ' sales', money(totals.card))); + out.push(row('EFT ' + pad(counts.eft, 3) + ' sales', money(totals.eft))); + out.push(row('Acct ' + pad(counts.account, 3) + ' sales', money(totals.account))); + out.push(divider); + out.push(row('GRAND TOTAL', money(grand))); + out.push(divider); + out.push('CASH RECONCILIATION'); + out.push(row('Cash sales:', money(totals.cash))); + out.push(row('Cash counted:', money(cashCounted))); + out.push(row('Variance:', money(variance))); + out.push(divider); + out.push('DENOMINATIONS COUNTED'); + denoms.forEach(dn => { + if(dn.count > 0){ + out.push(dn.label + ' x ' + pad(dn.count, 3) + ' = ' + money(dn.value * dn.count)); + } + }); + return out.map(line => '' + esc(line) + '').join(''); +} + boot(); diff --git a/public/pos/test-longpress.html b/public/pos/test-longpress.html new file mode 100644 index 0000000..6596f9b --- /dev/null +++ b/public/pos/test-longpress.html @@ -0,0 +1,305 @@ + + + + + +POS — long-press test harness + + + + + +

POS long-press test harness

+

Loads pos-app.js in a sandbox iframe, pre-populates a cart, and runs the long-press flow. The status box below reports each step.

+ + + + + + + +
Ready. Tap "Boot" first.
+ +
+

How this works

+

The iframe loads pos-config.js, theme.js, letterhead-config.js, and pos-app.js from this server. Before loading, we set localStorage['pos.cart'] to a fake cart entry so the boot picks it up. The same-origin iframe can read the cart state via window.__pos.openEditLine(id), which is what the long-press timer calls internally.

+

Step 3 dispatches a synthetic pointerdown on the cart line, waits 700ms (timer is 600ms), then checks if the modal opened.

+

This is a temporary test page. Delete it after the bug fix is verified.

+
+ + + + + +