feat(pos): enhance POS configuration to handle missing API key prompts gracefully and add slip preview functionality in settings

This commit is contained in:
Dave 2026-09-04 11:55:38 +02:00
parent 5c9f30dbd1
commit 2f3007d978
3 changed files with 655 additions and 6 deletions

View file

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

View file

@ -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}}
</style>
</head>
@ -153,6 +230,18 @@ body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin
<div class="section-label">Switch letterhead</div>
<div class="lh-list" id="lhList"></div>
<div class="section-label">Slip preview</div>
<p class="lede" style="margin:0 4px 10px">A preview of what each slip type will print with the active letterhead. Sample data — not from a real sale.</p>
<div class="preview-tabs" role="tablist">
<button class="preview-tab active" data-type="sale" onclick="switchPreview('sale')" type="button">Sale slip</button>
<button class="preview-tab" data-type="delivery" onclick="switchPreview('delivery')" type="button">Delivery note</button>
<button class="preview-tab" data-type="cashup" onclick="switchPreview('cashup')" type="button">Cash-up</button>
</div>
<div class="receipt-preview" id="receiptPreview" aria-live="polite">
<div class="skeleton" style="padding:24px 8px">Loading preview…</div>
</div>
<p class="preview-note" id="previewNote"></p>
<div id="banner" hidden></div>
<div class="actions">
@ -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 = '<div class="skeleton">Letterhead list unavailable offline. Reconnect to set up.</div>';
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 => '<span>' + esc(line) + '</span>').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 => '<span>' + esc(line) + '</span>').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 => '<span>' + esc(line) + '</span>').join('');
}
boot();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>

View file

@ -0,0 +1,305 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>POS — long-press test harness</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%23ff385c'/></svg>">
<style>
:root{
--bg:#fff;--fg:#222;--muted:#ddd;--border:#ffe3e8;--accent:#ff385c;
--ok:#0f8a3f;--warn:#b14a00;--danger:#c92a2a;--surface:#fff7f8;
--font:'Inter',system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:var(--bg);color:var(--fg);font:14px/1.5 var(--font);-webkit-font-smoothing:antialiased}
body{padding:20px;max-width:640px;margin:0 auto}
h1{font-size:18px;margin-bottom:12px}
p{color:#666;margin-bottom:14px;font-size:13px}
button{font:inherit;padding:9px 14px;border:1px solid var(--muted);border-radius:8px;background:#fff;color:var(--fg);cursor:pointer;margin-right:8px;margin-bottom:8px}
button.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
button:active{transform:translateY(1px)}
.status{margin-top:14px;padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);font-family:'SF Mono','Menlo','Consolas',monospace;font-size:12px;line-height:1.55;white-space:pre-wrap;min-height:60px}
.ok{color:var(--ok);font-weight:600}
.bad{color:var(--danger);font-weight:600}
.dim{color:#666}
.row{margin-top:14px;padding:12px;border:1px solid var(--border);border-radius:8px;background:#fff}
.row h3{font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:#666;margin-bottom:6px;font-weight:700}
.note{color:#b14a00;font-size:12px;margin-top:8px}
iframe{width:100%;height:1px;border:0;visibility:hidden}
</style>
</head>
<body>
<h1>POS long-press test harness</h1>
<p>Loads <code>pos-app.js</code> in a sandbox iframe, pre-populates a cart, and runs the long-press flow. The status box below reports each step.</p>
<button class="primary" id="bBoot" onclick="boot()">1. Boot the POS iframe</button>
<button id="bOpen" onclick="openCart()" disabled>2. Open cart drawer</button>
<button id="bPress" onclick="simulateLongPress()" disabled>3. Simulate long press (700ms)</button>
<button id="bBad" onclick="testInvalidSave()" disabled>4. Test save with empty qty (the bug fix)</button>
<button id="bCancel" onclick="testCancel()" disabled>5. Test Cancel closes the modal</button>
<div class="status" id="status">Ready. Tap "Boot" first.</div>
<div class="row">
<h3>How this works</h3>
<p>The iframe loads <code>pos-config.js</code>, <code>theme.js</code>, <code>letterhead-config.js</code>, and <code>pos-app.js</code> from this server. Before loading, we set <code>localStorage['pos.cart']</code> to a fake cart entry so the boot picks it up. The same-origin iframe can read the cart state via <code>window.__pos.openEditLine(id)</code>, which is what the long-press timer calls internally.</p>
<p>Step 3 dispatches a synthetic <code>pointerdown</code> on the cart line, waits 700ms (timer is 600ms), then checks if the modal opened.</p>
<p class="note">This is a temporary test page. Delete it after the bug fix is verified.</p>
</div>
<iframe id="pos" src="about:blank"></iframe>
<script>
// === pre-populate the cart for pos-app.js's loadCart() ===
const FAKE_CART = [
{
id: 'TEST-1',
qty: 2,
p: {
id: 'TEST-1',
sku: 'TEST-1',
description: 'Test Widget',
unit: 'ea',
cost_price: 30,
selling_price: 45,
},
},
];
localStorage.setItem('pos.cart', JSON.stringify(FAKE_CART));
// Also inject a fake active letterhead so the slip preview on
// settings.html can render without the API. Letterhead-config.js
// looks for these keys in localStorage.
localStorage.setItem('pos.letterhead.activeId', JSON.stringify('5wezvypn6fy47p9'));
localStorage.setItem('pos.letterhead.active', JSON.stringify({
id: '5wezvypn6fy47p9',
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: 'pbc_1010367695',
}));
localStorage.setItem('pos.letterhead.list', JSON.stringify([
{ id: '5wezvypn6fy47p9', display_name: 'WESTSCOTT GROUP OF COMPANIES', company: 'WESTSCOTT GROUP OF COMPANIES', vat_percent: 15, accent_color: '#FFBF00', has_logo: false, is_active: true },
]));
// Pre-set an API key so pos-config.js skips the prompt() (which
// throws in some headless / test browser contexts and breaks every
// subsequent script that depends on window.posFetch being defined).
localStorage.setItem('pos.apiKey', 'test-key-bypasses-prompt');
// === SW + cache cleanup ===
// The fix we're verifying was committed after the most recent SW cache
// bump, but the in-browser SW can still be holding the older copy.
// The cleanup MUST complete before we boot the iframe, otherwise the
// iframe loads the cached pos-app.js (no fix). Boot stays disabled
// until cleanup finishes.
const bBoot = document.getElementById('bBoot');
const bOpen = document.getElementById('bOpen');
bBoot.disabled = true;
bBoot.textContent = '1. Boot the POS iframe (clearing cache…)';
(async function cleanup() {
if ('serviceWorker' in navigator) {
try {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map(r => r.unregister()));
} catch (e) { /* ignore */ }
}
if ('caches' in window) {
try {
const names = await caches.keys();
await Promise.all(names.map(n => caches.delete(n)));
} catch (e) { /* ignore */ }
}
bBoot.disabled = false;
bBoot.textContent = '1. Boot the POS iframe';
})();
const POS_URL = 'pos.html';
const statusEl = document.getElementById('status');
function setStatus(text, kind) {
statusEl.innerHTML = '';
const span = document.createElement('span');
span.className = kind || '';
span.textContent = text;
statusEl.appendChild(span);
}
function setStatusMulti(parts) {
statusEl.innerHTML = '';
parts.forEach(p => {
const span = document.createElement('span');
if (p.kind) span.className = p.kind;
if (p.dim) span.className = (span.className + ' dim').trim();
span.textContent = p.text + '\n';
statusEl.appendChild(span);
});
}
const iframe = document.getElementById('pos');
let booted = false;
function boot() {
if (booted) return;
// Cache-bust the iframe URL so the browser never serves a cached
// copy of pos.html either.
iframe.src = POS_URL + '?cb=' + Date.now();
iframe.addEventListener('load', () => {
booted = true;
bBoot.disabled = true;
bOpen.disabled = false;
setStatusMulti([
{ text: '✓ Iframe loaded with FRESH pos-app.js (cache-busted)', kind: 'ok' },
{ text: 'localStorage["pos.cart"] was set to a fake entry before load', dim: true },
{ text: 'Now tap "Open cart drawer" to render the cart with our test item.', dim: true },
]);
}, { once: true });
}
function openCart() {
if (!booted) return setStatus('Boot the iframe first.', 'bad');
try {
iframe.contentWindow.__pos.openDrawer();
// The drawer renders the cart from state.cart. Our fake item
// should be in the list.
setTimeout(() => {
const line = iframe.contentDocument.querySelector('.line[data-id="TEST-1"]');
if (line) {
setStatusMulti([
{ text: '✓ Cart drawer opened, line "TEST-1" rendered', kind: 'ok' },
{ text: 'Next: tap "Simulate long press" to trigger pointerdown.', dim: true },
]);
document.getElementById('bPress').disabled = false;
document.getElementById('bBad').disabled = false;
document.getElementById('bCancel').disabled = false;
} else {
setStatusMulti([
{ text: '✗ Cart drawer opened but no .line[data-id="TEST-1"] in the DOM', kind: 'bad' },
{ text: 'Possible causes: state.cart was empty (loadCart order), or the renderCartDrawer did not find the item.', dim: true },
]);
}
}, 150);
} catch (e) {
setStatus('openCart failed: ' + e.message, 'bad');
}
}
function simulateLongPress() {
if (!booted) return setStatus('Boot first.', 'bad');
const doc = iframe.contentDocument;
const line = doc.querySelector('.line[data-id="TEST-1"]');
if (!line) return setStatus('No cart line in DOM. Open cart first.', 'bad');
// Synthesize a pointerdown event. The real long-press uses
// pointerType:touch but pointerType:mouse works for the JS path.
const pd = new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
pointerType: 'mouse',
pointerId: 1,
isPrimary: true,
});
line.dispatchEvent(pd);
setStatusMulti([
{ text: '→ Dispatched pointerdown on the cart line' },
{ text: 'Waiting 700ms for the 600ms timer to fire openEditLine...', dim: true },
]);
setTimeout(() => {
const modal = doc.getElementById('editSheet');
const backdrop = doc.getElementById('editBackdrop');
const open = modal && modal.classList.contains('show');
const backdropOpen = backdrop && backdrop.classList.contains('show');
if (open && backdropOpen) {
setStatusMulti([
{ text: '✓ Long press detected. Edit modal opened (backdrop + sheet both have .show).', kind: 'ok' },
{ text: 'Now tap "Test save with empty qty" to verify the original bug is fixed.', dim: true },
]);
} else {
setStatusMulti([
{ text: '✗ Long press did not open the edit modal', kind: 'bad' },
{ text: 'modal.show=' + open + ' backdrop.show=' + backdropOpen, dim: true },
]);
}
}, 700);
}
function testInvalidSave() {
if (!booted) return setStatus('Boot first.', 'bad');
const doc = iframe.contentDocument;
const modal = doc.getElementById('editSheet');
if (!modal || !modal.classList.contains('show')) {
return setStatus('Open the modal first via step 3.', 'bad');
}
// Clear qty (the bug-trigger scenario)
const qtyEl = doc.getElementById('editQty');
qtyEl.value = '';
// Click Save
const saveBtn = doc.querySelector('[data-od-id="edit-save"]');
saveBtn.click();
setTimeout(() => {
const stillOpen = modal.classList.contains('show');
const hintEl = doc.getElementById('editHint');
const hintText = hintEl ? hintEl.textContent : '';
const hintColor = hintEl ? hintEl.style.color : '';
if (stillOpen && /quantity/i.test(hintText) && /red|192, 202/.test(hintColor)) {
setStatusMulti([
{ text: '✓ Edit-line bug FIX verified:', kind: 'ok' },
{ text: ' • Modal stayed open (operator not stuck behind grey backdrop)', kind: 'ok' },
{ text: ' • Inline hint shows "' + hintText + '"', kind: 'ok' },
{ text: ' • Hint is colored red (' + (hintColor || 'unset') + ')', kind: 'ok' },
]);
} else if (stillOpen) {
setStatusMulti([
{ text: '~ Modal stayed open but hint may need adjustment', kind: 'ok' },
{ text: ' • modal still open: ' + stillOpen, kind: 'ok' },
{ text: ' • hint text: "' + hintText + '"', dim: true },
{ text: ' • hint color: "' + (hintColor || 'unset') + '"', dim: true },
]);
} else {
setStatusMulti([
{ text: '✗ Modal CLOSED on invalid input (the original bug)', kind: 'bad' },
{ text: ' The fix is not working as expected. The modal should stay open with an error.', kind: 'bad' },
]);
}
}, 100);
}
function testCancel() {
if (!booted) return setStatus('Boot first.', 'bad');
const doc = iframe.contentDocument;
const modal = doc.getElementById('editSheet');
// Ensure modal is open
if (!modal.classList.contains('show')) {
// Open it first via openEditLine
iframe.contentWindow.__pos.openEditLine('TEST-1');
}
setTimeout(() => {
// Click the Cancel button
const cancelBtn = doc.querySelector('[data-od-id="edit-cancel"]');
cancelBtn.click();
setTimeout(() => {
const stillOpen = modal.classList.contains('show');
if (!stillOpen) {
setStatusMulti([
{ text: '✓ Cancel button closes the modal cleanly', kind: 'ok' },
]);
} else {
setStatusMulti([
{ text: '✗ Cancel did not close the modal', kind: 'bad' },
]);
}
}, 100);
}, 100);
}
</script>
</body>
</html>