feat(pos): enhance POS configuration to handle missing API key prompts gracefully and add slip preview functionality in settings
This commit is contained in:
parent
5c9f30dbd1
commit
2f3007d978
3 changed files with 655 additions and 6 deletions
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
305
public/pos/test-longpress.html
Normal file
305
public/pos/test-longpress.html
Normal 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>
|
||||
Loading…
Reference in a new issue