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.