WFDASH-WEBAPP/public/pos/pos-app.js
Dave 0377c51602 fix(pos): dead discount toggle + stale discount figure, lift dark palette
Two reported symptoms that turned out to be one bug, plus the dark
mode the user described as "too black".

The `hidden` attribute does nothing on the discount controls

`hidden` is implemented by the UA stylesheet as `[hidden]{display:none}`.
Any author rule that sets `display` outranks it — and `.totals` and
`.discount-body` both set `display:flex`. So `el.hidden = true` was a
no-op on both:

  - the discount tier panel could not be collapsed, making "Add
    discount" look like a dead control;
  - the Discount totals row stayed on screen after the discount was
    cleared, still showing the last figure (R 98.37) under a total
    that no longer contained it. The JS only ever set `hidden` and
    never cleared the text, so the operator had no way to tell stale
    text from a live one.

`.cat-menu` and `.cat-backdrop` already carried per-element
`[hidden]{display:none}` overrides for exactly this reason; the two
discount elements were missed. Replaced with one global rule so the
whole class is covered rather than relying on each element to
remember. The clear path now also blanks the amount.

Verified statically rather than in a browser: jsdom does not model
the UA-vs-author cascade and reported the broken and fixed cases
identically, so the cascade was checked by resolving which rules
apply. Regression test fails without the fix.

Dark palette

The POS canvas was #0f0f0f (L* 4.3 — effectively black) with
surfaces only 3-4 L* above it, mixing a warm #1c1416 against a
neutral #181818 in the same UI. That near-black base plus two
competing surface tones is what read as muddy. Replaced with a
single cool-neutral ramp with four real steps, and menus/modals now
sit on a `--raised` layer instead of reusing the surface colour.

`#717171` was the secondary text colour in 25 places and measured
3.93:1 on the old canvas — below WCAG AA. It now measures 6.49:1.
Both the text greys and the surfaces were hardcoded per component
across seven POS pages; they are now tokens, with the dark block
rebinding them.

The shared `.dark` block in themes.css (drives all 24 themes) sat at
oklch(0.15) ≈ L* 3.1. Lifted +0.04 across the ramp, preserving its
shape and separation; muted-foreground goes 6.32:1 → 5.95:1, still
above AA. Cursor/Supabase, which override it darker, were at L* 1.3
and lifted too.

Service worker cache bumped to pos-v36 — pos.html and pos-app.js are
served cache-first, so without the bump the tills keep serving the
broken files.

3863 tests pass (3 new); build clean.
2026-10-12 23:17:57 +02:00

1122 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// POS app — catalog, cart, payment, offline queue, slip generation.
// All server access goes through /api/v1/pos/* via window.posFetch
// (see pos-config.js) — this app never talks to PocketBase directly.
(function(){
'use strict';
const CACHE = 'pos-v1';
const LS_CART = 'pos.cart';
const LS_QUEUE = 'pos.queue';
// ---------------- state ----------------
const state = {
products: [],
productsById: new Map(),
categories: [],
activeCat: 'all',
search: '',
// id -> {hay, squish, words}. Built by PosSearch.buildIndex on every
// catalog load (both the IndexedDB and the network path) so the
// per-keystroke filter only does `indexOf`, never normalisation.
searchIndex: new Map(),
cart: new Map(), // id -> {p, qty}
customer: null,
paymentMethod: null,
cashGiven: 0,
// Discount: { kind: "amount" | "percent" | "none", input: number }
// The "input" is the raw number the cashier typed (e.g. 10 for
// "10% off" or 50 for "R50 off"). The server resolves it to a
// rand value and applies it to the gross total post-VAT.
discount: { kind: 'none', input: 0 },
letterhead: null,
// How many rows of the current filter are rendered. The full catalog
// is several hundred items; painting them all makes the till sluggish
// and buries the stock staff actually reach for.
limit: 0
};
// Rows rendered per chunk. The catalog arrives newest-first, so the
// default view is effectively "recently added".
const PAGE = 50;
// ---------------- IndexedDB ----------------
const DB_NAME='pos', DB_VER=1, STORE_PRODUCTS='products', STORE_QUEUE='queue';
let dbPromise = null;
function openDB(){
if(dbPromise) return dbPromise;
dbPromise = new Promise((res,rej)=>{
const req = indexedDB.open(DB_NAME, DB_VER);
req.onupgradeneeded = e => {
const db = e.target.result;
if(!db.objectStoreNames.contains(STORE_PRODUCTS)) db.createObjectStore(STORE_PRODUCTS,{keyPath:'id'});
if(!db.objectStoreNames.contains(STORE_QUEUE)) db.createObjectStore(STORE_QUEUE,{keyPath:'localId'});
};
req.onsuccess = e => res(e.target.result);
req.onerror = e => rej(e.target.error);
});
return dbPromise;
}
async function dbPut(store, val){
const db = await openDB();
return new Promise((res,rej)=>{
const tx = db.transaction(store,'readwrite');
tx.objectStore(store).put(val);
tx.oncomplete = ()=>res();
tx.onerror = e=>rej(e.target.error);
});
}
async function dbAll(store){
const db = await openDB();
return new Promise((res,rej)=>{
const tx = db.transaction(store,'readonly');
const req = tx.objectStore(store).getAll();
req.onsuccess = ()=>res(req.result||[]);
req.onerror = e=>rej(e.target.error);
});
}
async function dbDel(store, key){
const db = await openDB();
return new Promise((res,rej)=>{
const tx = db.transaction(store,'readwrite');
tx.objectStore(store).delete(key);
tx.oncomplete = ()=>res();
tx.onerror = e=>rej(e.target.error);
});
}
// ---------------- formatting ----------------
const fmt = n => 'R ' + (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
const fmtN = n => (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
const uid = ()=> 'loc_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2,8);
// ---------------- catalog loading ----------------
/**
* Adopt a catalog array: wire up the id map, rebuild the search index,
* then repaint. Called from BOTH load paths — the IndexedDB cache and the
* network refresh — because a till that booted offline has no index yet
* and would otherwise filter against an empty one.
*/
function adoptCatalog(list){
state.products = list;
state.productsById = new Map(list.map(p=>[p.id,p]));
// See filteredProducts() — an empty index is inert there via fallbackSearch.
state.searchIndex = window.PosSearch ? window.PosSearch.buildIndex(list) : new Map();
buildCategories();
render();
}
async function loadProducts(){
// Try IDB first
try{
const cached = await dbAll(STORE_PRODUCTS);
if(cached.length){
adoptCatalog(cached);
}
}catch{}
// Then network refresh — single call, the API route returns the
// full active catalog (no pagination needed at this catalog size).
try{
const r = await window.posFetch('/catalog');
if(!r.ok) throw new Error('catalog returned ' + r.status);
const j = await r.json();
const all = (j.items || []).map(p => ({
...p,
description: p.description || p.name,
}));
adoptCatalog(all);
for(const p of all) await dbPut(STORE_PRODUCTS, p);
}catch(e){
console.warn('Network load failed', e);
if(!state.products.length){
const el = document.getElementById('empty');
el.innerHTML = '<h3>Offline</h3><p>No cached catalog. Reconnect and reload.</p>';
}
}
}
async function loadLetterhead(){
// Fetch + cache the active letterhead via letterhead-config.js.
// On a fresh till, this auto-picks Westcott Group (the only
// letterhead the POS uses per the 2026-09-04 operator decision).
// The fetched row drives:
// - state.letterhead.* → shown on the on-screen receipt
// - state.letterhead.vat_percent → cart VAT math (replaces the
// hardcoded 15% so a 0% letterhead shows no VAT line)
// - sale.letterhead.* → attached to the saved sale, consumed by
// receipt.html, epos-print.js (buildEposXml), and the
// history-reprint redirect.
try{
const item = await window.PosLetterhead.ensureActive();
state.letterhead = item;
}catch(e){
console.warn('Letterhead load failed; receipt will fall back to "WFSALES POS"', e);
state.letterhead = null;
}
}
function buildCategories(){
const set = new Map();
for(const p of state.products){
if(!p.category) continue;
set.set(p.category, (set.get(p.category)||0)+1);
}
state.categories = Array.from(set.entries()).sort((a,b)=>b[1]-a[1]);
// Label on the trigger
const lbl = document.getElementById('catLabel');
const cnt = document.getElementById('catCount');
lbl.textContent = state.activeCat === 'all' ? 'All categories' : state.activeCat;
cnt.textContent = state.products.length + ' products';
// Menu items
const head = document.getElementById('catMenuItems');
const total = state.products.length;
const totalUncategorized = state.products.filter(p => !p.category).length;
head.innerHTML =
`<button class="cat-item ${state.activeCat==='all'?'active':''}" data-cat="all" role="option">
<span class="nm">All categories</span><span class="ct">${total}</span>
</button>` +
(totalUncategorized ? `<button class="cat-item ${state.activeCat==='__uncat'?'active':''}" data-cat="__uncat" role="option">
<span class="nm">Uncategorised</span><span class="ct">${totalUncategorized}</span>
</button>` : '') +
state.categories.map(([c,n])=>`<button class="cat-item ${state.activeCat===c?'active':''}" data-cat="${escapeAttr(c)}" role="option">
<span class="nm">${escapeHTML(c)}</span><span class="ct">${n}</span>
</button>`).join('');
head.querySelectorAll('.cat-item').forEach(b=>{
b.addEventListener('click', ()=>{
const v = b.dataset.cat;
state.activeCat = v === '__uncat' ? '__uncat' : v;
closeCatMenu();
buildCategories();
resetPaging();
render();
});
});
}
function openCatMenu(){
const menu = document.getElementById('catMenu');
const back = document.getElementById('catBackdrop');
const trigger = document.getElementById('catTrigger');
// Position menu below the trigger
const r = trigger.getBoundingClientRect();
menu.style.top = (r.bottom + 6) + 'px';
menu.hidden = false;
back.hidden = false;
trigger.setAttribute('aria-expanded','true');
}
function closeCatMenu(){
const menu = document.getElementById('catMenu');
const back = document.getElementById('catBackdrop');
const trigger = document.getElementById('catTrigger');
menu.hidden = true;
back.hidden = true;
trigger.setAttribute('aria-expanded','false');
}
function filteredProducts(){
// Category first — it's a plain equality test and prunes most of the
// catalog before any string work happens.
let list = state.products;
if(state.activeCat==='__uncat'){
list = list.filter(p=>!p.category);
} else if(state.activeCat!=='all'){
list = list.filter(p=>p.category===state.activeCat);
}
// …then rank by text. PosSearch owns the tiers; an empty query returns
// the list untouched, which keeps the default "recently added" view.
//
// The fallback is for a half-updated till: pos.html loads pos-search.js
// ahead of this file, but a service worker can still be serving a stale
// mix of the two. A blank catalog is a shop that cannot sell anything, so
// degrade to the old substring filter rather than throwing. Only reachable
// on a skewed cache — both files ship together.
if(!window.PosSearch) return fallbackSearch(list);
return window.PosSearch.search(list, state.searchIndex, state.search);
}
/** The pre-2026-10-06 filter: case-insensitive substring over sku + description. */
function fallbackSearch(list){
const q = state.search.trim().toLowerCase();
if(!q) return list;
return list.filter(p=>
(p.sku||'').toLowerCase().includes(q)
|| (p.description||'').toLowerCase().includes(q));
}
/** Call whenever the filter changes, so a new result set starts at the top. */
function resetPaging(){
state.limit = PAGE;
}
function render(){
const list = filteredProducts();
const cat = document.getElementById('catalog');
if(!list.length){
cat.innerHTML = '<div class="empty"><h3>No matches</h3><p>Try a different search or category.</p></div>';
return;
}
if(!state.limit) state.limit = PAGE;
const shown = list.slice(0, state.limit);
const more = list.length - shown.length;
// Keep the scroll position across re-renders (adding to the cart calls
// render()), otherwise the till jumps back to the top on every tap.
const scrollTop = cat.scrollTop;
const browsing = !state.search.trim() && state.activeCat === 'all';
const heading = browsing ? 'Recently added' : 'Results';
cat.innerHTML =
`<div class="meta"><span>${heading}</span><span>${shown.length} of ${list.length}</span></div>` +
shown.map(p=>{
const price = p.selling_price ?? p.cost_price ?? 0;
const inCart = state.cart.get(p.id)?.qty || 0;
// FA icon when the category row carries one; fall back to initials
// so an unconfigured category still renders something recognisable.
const icon = p.category_icon
? `<i class="ic-fa ${escapeAttr(p.category_icon)}" aria-hidden="true"></i>`
: initials(p.category||p.brand||'?');
return `<button class="prod" data-id="${p.id}" data-od-id="prod-${p.id}">
<div class="ic">${icon}</div>
<div class="body">
<div class="name">${escapeHTML(p.description||p.sku||'—')}</div>
<div class="sub">${escapeHTML(p.sku||'')}${p.brand?' · '+escapeHTML(p.brand):''}${p.unit?' · '+escapeHTML(p.unit):''}${inCart?' · '+inCart+' in cart':''}</div>
</div>
<div class="price">${fmt(price)}<small>/${escapeHTML(p.unit||'ea')}</small></div>
</button>`;
}).join('') +
(more > 0
? `<button class="more" id="showMore">Show ${Math.min(more, PAGE)} more <small>${more} remaining</small></button>`
: '');
cat.querySelectorAll('.prod').forEach(b=>{
b.addEventListener('click', ()=>addToCart(b.dataset.id));
});
cat.scrollTop = scrollTop;
const moreBtn = document.getElementById('showMore');
if(moreBtn){
const loadMore = ()=>{ state.limit += PAGE; render(); };
moreBtn.addEventListener('click', loadMore);
// Auto-load as it scrolls into view; the button stays as the fallback
// for browsers without IntersectionObserver.
if('IntersectionObserver' in window){
const io = new IntersectionObserver(entries=>{
if(entries.some(e=>e.isIntersecting)){ io.disconnect(); loadMore(); }
}, {root: cat, rootMargin: '200px'});
io.observe(moreBtn);
}
}
}
function initials(s){
const parts = s.trim().split(/\s+/).slice(0,2);
return parts.map(p=>p[0]||'').join('').toUpperCase().slice(0,2);
}
// ---------------- custom (ad-hoc) lines ----------------
//
// A line the cashier types by hand — "Bolt cutting" at R20 — that
// belongs on this slip and nowhere else. It is NOT a catalogue
// item: no `stock_items` row, no stock decrement, no ledger entry.
// It carries a synthetic `custom:` id purely so it can live in the
// same cart Map and flow through the same totals, slip and checkout
// path as everything else.
//
// The prefix matters: it's how the checkout payload marks the line
// `is_custom`, and it's how the cart drawer badges it so the
// cashier can see at a glance which lines are not real stock.
const CUSTOM_ID_PREFIX = 'custom:';
function addCustomItem(description, price){
const desc = (description||'').trim();
const amount = Number(price);
if(!desc){ alert('Give the item a name.'); return null; }
if(!Number.isFinite(amount) || amount <= 0){ alert('Enter a price greater than zero.'); return null; }
const p = {
id: CUSTOM_ID_PREFIX + uid(),
name: desc,
description: desc,
sku: '',
unit: '',
// EX-VAT, same basis as the catalogue — VAT is added on top in
// cartTotals(). A service the shop performs has no cost of goods.
selling_price: amount,
cost_price: 0,
is_custom: true,
};
state.cart.set(p.id, {p, qty: 1});
persistCart();
render();
paintCart();
renderCartDrawer();
return p;
}
function isCustomLine(p){ return !!(p && p.is_custom); }
function openCustomItem(){
document.getElementById('customDesc').value = '';
document.getElementById('customPrice').value = '';
document.getElementById('customSheet').classList.add('show');
document.getElementById('customBackdrop').classList.add('show');
setTimeout(()=>document.getElementById('customDesc').focus(), 60);
}
function closeCustomItem(){
document.getElementById('customSheet').classList.remove('show');
document.getElementById('customBackdrop').classList.remove('show');
}
function saveCustomItem(){
const desc = document.getElementById('customDesc').value;
const price = document.getElementById('customPrice').value.replace(/[^0-9.]/g,'');
const p = addCustomItem(desc, Number(price));
if(p) closeCustomItem();
}
// ---------------- cart ----------------
function addToCart(id){
const p = state.productsById.get(id);
if(!p) return;
const cur = state.cart.get(id);
if(cur) cur.qty++;
else state.cart.set(id, {p, qty:1});
persistCart();
render();
paintCart();
// tactile on the bar
const bar = document.getElementById('cartBar');
bar.animate([{transform:'translateY(0) scale(1)'},{transform:'translateY(-2px) scale(1.01)'},{transform:'translateY(0) scale(1)'}],{duration:200});
}
function changeQty(id, delta){
const cur = state.cart.get(id);
if(!cur) return;
cur.qty += delta;
if(cur.qty<=0) state.cart.delete(id);
persistCart();
render();
paintCart();
if(document.getElementById('cartDrawer').classList.contains('show')) renderCartDrawer();
}
function removeFromCart(id){
state.cart.delete(id);
persistCart();
render();
paintCart();
renderCartDrawer();
}
function cartTotals(){
let count=0, gross=0;
for(const {p,qty} of state.cart.values()){
count += qty;
// Catalogue shelf price — the till never edits a line's price.
// Prices are EX-VAT (see pos-service.ts): derived as
// cost × markup from the supplier's ex-VAT cost, with no VAT
// uplift. VAT is added on top below.
const unit = lineUnitPrice({p,qty});
gross += unit * qty;
}
// Discount (off the ex-VAT gross, so it's a true % off both the
// net and the VAT on it). Percentage-only and capped at the
// gross so the total can never go negative.
const d = state.discount || { kind: 'none', input: 0 };
let discountAmount = 0;
if (d.kind === 'percent' && d.input > 0) {
discountAmount = Math.min((gross * d.input) / 100, gross);
}
// The VAT rate comes from the active letterhead — each shop may
// sit at a different rate. Falls back to the SA standard rate
// when no letterhead has loaded yet (a fresh till on a flaky
// network still has to be able to take money).
const vatPct = Number(state.letterhead && state.letterhead.vat_percent);
const pct = Number.isFinite(vatPct) && vatPct >= 0 ? vatPct : 15;
const net = Math.max(0, gross - discountAmount);
const vat = (net * pct) / 100;
const total = net + vat;
return {count, gross, discountAmount, sub: net, vat, total, vatPct: pct};
}
/**
* Effective unit price for a cart line — always the catalogue's selling
* price. It used to prefer a per-line override set by the long-press edit
* sheet, but that sheet is quantity-only now, so no override can exist.
* Kept as a named helper because the price feeds four places (line render,
* cart totals, the sale record and the checkout payload) and they must not
* drift apart. Returns 0 when neither price is set.
*/
function lineUnitPrice(line){
return Number(line.p.selling_price ?? line.p.cost_price ?? 0) || 0;
}
function persistCart(){
const arr = Array.from(state.cart.entries()).map(([id,{p,qty}])=>({id, qty, p}));
try{ localStorage.setItem(LS_CART, JSON.stringify(arr)); }catch{}
}
function loadCart(){
try{
const arr = JSON.parse(localStorage.getItem(LS_CART)||'[]');
for(const {id,p,qty} of arr) state.cart.set(id,{p,qty});
}catch{}
}
function paintCart(){
const t = cartTotals();
document.getElementById('cartCount').textContent = t.count;
document.getElementById('cartTotal').textContent = fmt(t.total);
document.getElementById('cartBar').classList.toggle('show', t.count>0);
}
function openDrawer(){
renderCartDrawer();
paintDiscount();
document.getElementById('cartDrawer').classList.add('show');
}
function closeDrawer(){
document.getElementById('cartDrawer').classList.remove('show');
}
function renderCartDrawer(){
const body = document.getElementById('cartLines');
if(state.cart.size===0){
body.innerHTML = '<div class="empty"><h3>Empty cart</h3><p>Add products from the catalog.</p></div>';
document.getElementById('subTotal').textContent = fmt(0);
document.getElementById('vatTotal').textContent = fmt(0);
document.getElementById('grandTotal').textContent = fmt(0);
document.getElementById('discountLine').hidden = true;
return;
}
body.innerHTML = Array.from(state.cart.values()).map(({p,qty})=>{
const price = lineUnitPrice({p,qty});
const custom = isCustomLine(p);
// Custom lines carry no SKU and no unit — badge them so the
// cashier can see at a glance that this isn't stock, and it
// won't show up on a stocktake or reorder list.
const sub = custom
? `${fmt(price)} each · custom item`
: `${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}`;
return `<div class="line${custom?' custom-line':''}" data-od-id="line-${p.id}" data-id="${p.id}">
<div class="nm">${escapeHTML(p.description||p.name||p.sku)}<small>${sub}</small></div>
<div class="qty">
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease quantity">−</button>
<button class="v" data-od-id="qty-set" onclick="window.__pos.openEditLine('${p.id}')" aria-label="Set quantity for ${escapeAttr(p.description||p.sku||'item')}">${qty}</button>
<button onclick="window.__pos.changeQty('${p.id}',1)" aria-label="Increase quantity">+</button>
</div>
<div class="pr">${fmt(price*qty)}</div>
<button class="rm" onclick="window.__pos.removeFromCart('${p.id}')" aria-label="Remove">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>`;
}).join('');
const t = cartTotals();
document.getElementById('subTotal').textContent = fmt(t.sub);
document.getElementById('vatTotal').textContent = fmt(t.vat);
document.getElementById('grandTotal').textContent = fmt(t.total);
// The VAT line label follows the letterhead's rate rather than
// hardcoding 15%, so a shop at a different rate doesn't print a
// wrong label over the right number.
const vatLabel = document.getElementById('vatLabel');
if (vatLabel) vatLabel.textContent = t.vatPct > 0 ? `VAT ${t.vatPct}%` : 'VAT';
// Discount line: shown only when a discount is actually
// applied. The server re-derives the resolved rand value at
// commit time, so this number is a preview only.
const dl = document.getElementById('discountLine');
if (t.discountAmount > 0) {
dl.hidden = false;
const d = state.discount || {};
const pctLabel = d.kind === 'percent' && d.input ? ` (${d.input}%)` : '';
document.getElementById('discountLineAmount').textContent =
`− ${fmt(t.discountAmount)}${pctLabel} ex VAT`;
} else {
dl.hidden = true;
// Blank the figure as well as hiding the row. A discount that has
// been cleared must not leave its old amount sitting on screen —
// an operator seeing "− R 98.37" under a total that no longer
// contains it has no way to tell stale text from a live one.
document.getElementById('discountLineAmount').textContent = '';
}
}
// ---------------- discount ----------------
// Two fixed tiers, 20% and 40%. Deliberately not a free-typed
// field: the whole point is that every discount on every slip is
// visibly one of two known-good choices, so nobody can ring up a
// hand-typed 90% (or a random rand amount) and nobody has to guess
// afterwards whether it was deliberate.
const DISCOUNT_TIERS = [20, 40];
function paintDiscount(){
const d = state.discount || { kind: 'none', input: 0 };
const trig = document.getElementById('discountTrigger');
const lbl = document.getElementById('discountTriggerLabel');
const body = document.getElementById('discountBody');
const hint = document.getElementById('discHint');
const active = d.kind === 'percent' && DISCOUNT_TIERS.includes(Number(d.input))
? Number(d.input) : 0;
for (const tier of DISCOUNT_TIERS){
const btn = document.getElementById('discTier'+tier);
if (btn) btn.classList.toggle('active', tier === active);
}
if (active){
trig.classList.add('has-value');
lbl.textContent = `Discount: ${active}%`;
body.hidden = false;
trig.setAttribute('aria-expanded','true');
} else {
trig.classList.remove('has-value');
lbl.textContent = 'Add discount';
body.hidden = true;
trig.setAttribute('aria-expanded','false');
}
hint.textContent = 'Applied off the ex-VAT total — VAT is charged on the discounted amount.';
}
function toggleDiscount(){
// The trigger only opens/closes the tier buttons — it never clears
// the discount. Clearing is tapping the active tier again, so
// there's no way to lose a discount by mis-tapping the chevron.
const body = document.getElementById('discountBody');
const trig = document.getElementById('discountTrigger');
const open = body.hidden;
// Don't let the operator dismiss the panel out from under an
// applied discount — that would hide the reason for the total.
if (!open && state.discount && state.discount.kind === 'percent') return;
body.hidden = !open;
trig.setAttribute('aria-expanded', String(open));
}
function setDiscountTier(pct){
const tier = Number(pct);
if(!DISCOUNT_TIERS.includes(tier)) return;
// Tapping the active tier clears it — one control, no separate
// "remove" affordance to forget.
if(state.discount && state.discount.kind==='percent' && Number(state.discount.input)===tier){
clearDiscount();
return;
}
state.discount = { kind:'percent', input: tier };
paintDiscount();
renderCartDrawer();
}
function clearDiscount(){
state.discount = { kind:'none', input:0 };
paintDiscount();
renderCartDrawer();
}
// Discounts are two fixed buttons now, so there's no free-text
// input to live-listen to.
// ---------------- long-press edit line ----------------
//
// Hold a cart line for 600ms → modal opens with qty + unit price.
// Tapping the quantity number does the same thing and is the discoverable
// route; the long-press is kept because it is the faster gesture for a
// cashier who already knows it's there.
// Common on mobile POS apps; the cashier scans/selects, then holds
// the row to fix the qty before ringing it up. Quantity only — this
// sheet has no price field. We use a 600ms threshold so a normal "I'm
// thinking about it" tap doesn't accidentally open the modal — but it's
// well under the long-press duration most users naturally apply (≈800ms).
//
// Haptic feedback (navigator.vibrate) on the trigger where the
// device supports it; visual hint via the .line-holding class so
// the operator sees something happen during the press.
const LONG_PRESS_MS = 600;
let longPressTimer = null;
let longPressTarget = null;
function clearLongPress(){
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
if (longPressTarget) {
longPressTarget.classList.remove('line-holding');
longPressTarget = null;
}
}
function startLongPress(el){
clearLongPress();
longPressTarget = el;
el.classList.add('line-holding');
// Best-effort haptic on the long-press fire. iOS Safari only
// honors navigator.vibrate inside a user-gesture handler; this
// is called from a pointerdown so the chain is intact. Android
// Chrome respects it always.
longPressTimer = setTimeout(()=>{
const id = el.dataset.id;
try { navigator.vibrate?.(12); } catch(_) {}
if (id) openEditLine(id);
clearLongPress();
}, LONG_PRESS_MS);
}
document.addEventListener('pointerdown', e=>{
const line = e.target.closest('.line[data-id]');
if (!line) return;
// The steppers and the quantity number are their own controls now. A
// long-press on them used to pop the edit sheet open mid-tap, which made
// holding "+" to ring up a bulk quantity fight the sheet. Each of them
// does its own thing on a normal tap.
if (e.target.closest('.qty, .rm')) return;
startLongPress(line);
}, { passive: true });
document.addEventListener('pointerup', clearLongPress);
document.addEventListener('pointercancel', clearLongPress);
document.addEventListener('pointerleave', e=>{
if (longPressTarget && e.target === longPressTarget) clearLongPress();
});
let editingLineId = null;
function openEditLine(id){
const line = state.cart.get(id);
if (!line) return;
editingLineId = id;
document.getElementById('editSku').textContent = line.p.sku || line.p.description || '';
// Quantity only. This popup deliberately has no price field: the till
// sells at the catalogue shelf price and a mis-keyed price is expensive
// and hard to spot. Per-line price overrides were removed on request —
// the server reads only `unit_price`, so nothing else depended on them.
document.getElementById('editQty').value = String(line.qty);
document.getElementById('editBackdrop').classList.add('show');
document.getElementById('editSheet').classList.add('show');
setTimeout(()=>document.getElementById('editQty').focus(), 60);
}
function closeEditLine(){
editingLineId = null;
document.getElementById('editBackdrop').classList.remove('show');
document.getElementById('editSheet').classList.remove('show');
}
function editSave(){
if (!editingLineId) return;
const line = state.cart.get(editingLineId);
if (!line) { closeEditLine(); return; }
const qty = Number((document.getElementById('editQty').value || '').replace(/[^0-9.]/g, ''));
const hint = document.getElementById('editHint');
// Bad qty — show an error in the hint, keep the modal open so the
// operator can fix it. (Previously the modal stayed open without
// any feedback, so a cleared/0 qty field left the screen grey and
// the operator had to long-press another line to escape.)
if (!Number.isFinite(qty) || qty <= 0) {
if (hint) {
hint.textContent = 'Enter a quantity of 1 or more.';
hint.style.color = '#c92a2a';
}
const qIn = document.getElementById('editQty');
if (qIn) { qIn.focus(); qIn.select(); }
return;
}
line.qty = Math.floor(qty);
// Reset the hint to its neutral default before closing so the next
// openEditLine() doesn't show a stale error from this round.
if (hint) { hint.textContent = 'Type a quantity like 800, then tap Save.'; hint.style.color = ''; }
persistCart();
render();
renderCartDrawer();
closeEditLine();
}
function editRemove(){
if (!editingLineId) return;
const id = editingLineId;
closeEditLine();
removeFromCart(id);
}
// ---------------- payment ----------------
function openPayment(){
if(state.cart.size===0) return;
const t = cartTotals();
state.paymentMethod = null;
state.cashGiven = 0;
state.customer = null;
document.getElementById('payTotal').textContent = fmt(t.total);
document.getElementById('paySub').textContent = fmt(t.sub);
document.getElementById('payVat').textContent = fmt(t.vat);
document.getElementById('dueIn').value = fmt(t.total);
document.getElementById('givenIn').value = '';
document.getElementById('changeOut').classList.remove('show','short');
document.getElementById('cashBlock').classList.remove('show');
document.getElementById('custBlock').classList.remove('show');
document.getElementById('custPicked').classList.remove('show');
document.getElementById('custResults').innerHTML = '';
document.getElementById('custQ').value = '';
document.querySelectorAll('.pay-tile').forEach(t=>t.classList.remove('selected'));
document.getElementById('confirmBtn').disabled = true;
buildQuickAmounts(t.total);
document.getElementById('payDrawer').classList.add('show');
}
function closePayment(){
document.getElementById('payDrawer').classList.remove('show');
}
function buildQuickAmounts(total){
// Rounded suggestions above the total
const base = Math.ceil(total/10)*10;
const suggestions = [];
if(base>total) suggestions.push(base);
for(const m of [50,100,200,500,1000]){
const v = Math.ceil(total/m)*m;
if(v>total && !suggestions.includes(v)) suggestions.push(v);
}
suggestions.sort((a,b)=>a-b);
document.getElementById('quick').innerHTML = suggestions.slice(0,5).map(v=>`<button data-v="${v}">${fmt(v)}</button>`).join('');
document.getElementById('quick').querySelectorAll('button').forEach(b=>{
b.addEventListener('click',()=>{
document.getElementById('givenIn').value = fmtN(b.dataset.v);
state.cashGiven = Number(b.dataset.v);
paintChange();
});
});
}
function pickMethod(m){
state.paymentMethod = m;
document.querySelectorAll('.pay-tile').forEach(t=>t.classList.toggle('selected', t.dataset.meth===m));
document.getElementById('cashBlock').classList.toggle('show', m==='cash');
document.getElementById('custBlock').classList.toggle('show', m==='account');
if(m!=='cash') document.getElementById('changeOut').classList.remove('show');
if(m!=='account') document.getElementById('custPicked').classList.remove('show');
validateConfirm();
if(m==='cash'){
setTimeout(()=>document.getElementById('givenIn').focus(), 250);
} else if(m==='account'){
setTimeout(()=>document.getElementById('custQ').focus(), 250);
}
}
function paintChange(){
const total = cartTotals().total;
const given = state.cashGiven || 0;
const change = given - total;
const out = document.getElementById('changeOut');
if(given<=0){ out.classList.remove('show','short'); return; }
out.classList.add('show');
out.classList.toggle('short', change<-0.005);
document.getElementById('changeAmt').textContent = (change>=0?'+ ':'− ') + fmt(Math.abs(change));
validateConfirm();
}
function validateConfirm(){
const m = state.paymentMethod;
const btn = document.getElementById('confirmBtn');
if(!m){ btn.disabled = true; return; }
if(m==='cash'){
btn.disabled = !(state.cashGiven >= cartTotals().total - 0.005);
} else if(m==='account'){
btn.disabled = !state.customer;
} else {
btn.disabled = false;
}
}
// ---------------- customer search ----------------
let custDebounce;
document.addEventListener('input', e=>{
if(e.target.id!=='custQ') return;
clearTimeout(custDebounce);
const q = e.target.value.trim();
custDebounce = setTimeout(()=>searchCustomers(q), 200);
});
document.addEventListener('input', e=>{
if(e.target.id!=='givenIn') return;
const raw = e.target.value.replace(/[^0-9.]/g,'');
e.target.value = raw;
state.cashGiven = Number(raw)||0;
paintChange();
});
async function searchCustomers(q){
const box = document.getElementById('custResults');
if(!q){ box.innerHTML=''; return; }
try{
const r = await window.posFetch(`/customers?q=${encodeURIComponent(q)}`);
const j = await r.json();
const items = j.items || [];
// `total` is every match before the server's display cap. Without it a
// 52-person company would render 8 rows and read as the whole team.
const total = Number.isFinite(j.total) ? j.total : items.length;
if(!items.length){
box.innerHTML = `<div class="cust-none">No match for “${escapeHTML(q)}”</div>`;
return;
}
const head = total > items.length
? `<div class="cust-count">Showing ${items.length} of ${total} — keep typing to narrow</div>`
: `<div class="cust-count">${total} ${total===1?'match':'matches'}</div>`;
box.innerHTML = head + items.map(c=>`<div class="cust-row" data-id="${c.id}" data-name="${escapeAttr(c.name)}" data-company="${escapeAttr(c.company||'')}">
<span>${escapeHTML(c.name)}</span>
<small>${escapeHTML(c.company || '')}</small>
</div>`).join('');
// Scoped to .cust-row on purpose: binding every div would make the
// count header above selectable, picking a customer with an empty id.
box.querySelectorAll('.cust-row').forEach(d=>{
d.addEventListener('click',()=>{
state.customer = {id:d.dataset.id, name:d.dataset.name, company:d.dataset.company};
const pk = document.getElementById('custPicked');
pk.classList.add('show');
pk.querySelector('b').textContent = d.dataset.name;
document.getElementById('custQ').value = '';
box.innerHTML = '';
validateConfirm();
});
});
}catch{}
}
function clearCust(){
state.customer = null;
document.getElementById('custPicked').classList.remove('show');
validateConfirm();
}
// ---------------- save sale ----------------
// Minimal, self-contained error toast. `toast.error(...)` below used
// to reference a `toast` global that was never defined anywhere in
// this app (no library included, no local implementation) — every
// checkout rejection (e.g. insufficient stock) threw a
// ReferenceError instead of showing the operator anything, so the
// cart just sat there with no feedback.
function showToast(message){
let el = document.getElementById('posToast');
if(!el){
el = document.createElement('div');
el.id = 'posToast';
el.style.cssText = 'position:fixed;left:16px;right:16px;bottom:calc(96px + env(safe-area-inset-bottom));max-width:448px;margin:0 auto;background:#c92a2a;color:#fff;padding:14px 16px;border-radius:12px;font-size:14px;font-weight:600;line-height:1.4;box-shadow:0 8px 24px rgba(0,0,0,.25);z-index:999;opacity:0;transition:opacity .15s ease';
document.body.appendChild(el);
}
el.textContent = message;
el.style.opacity = '1';
clearTimeout(el._hideTimer);
el._hideTimer = setTimeout(()=>{ el.style.opacity = '0'; }, 5000);
}
async function confirmSale(){
const btn = document.getElementById('confirmBtn');
if(btn.disabled) return;
btn.disabled = true; btn.innerHTML = 'Saving…';
const totals = cartTotals();
const sub = totals.sub, vat = totals.vat, total = totals.total;
// localId doubles as the server-side idempotency key (client_ref), so
// a queued sale that actually landed can be retried without being
// recorded — and stock decremented — a second time.
const localId = uid();
// Stash the resolved discount + the gross (pre-discount) totals
// so the receipt and the offline-queue replay both render the
// same numbers the cashier saw on the till. The server will
// re-derive its own gross_* / discount_* when the sale lands
// (it can't trust what the till says), but the till-rendered
// receipt uses these.
const d = state.discount || { kind: 'none', input: 0 };
const sale = {
localId,
createdAt: new Date().toISOString(),
payment: state.paymentMethod,
cashGiven: state.cashGiven || null,
change: state.paymentMethod==='cash' ? Math.max(0, state.cashGiven - total) : 0,
gross: totals.gross,
discount: { kind: d.kind, input: d.input, amount: totals.discountAmount },
sub, vat, total,
// Which VAT rate produced those numbers — the receipt labels
// its VAT line from this rather than hardcoding 15%.
vatPct: totals.vatPct,
// Attach the active letterhead so the on-screen receipt AND
// the printed slip can show the right shop name, address,
// phone, VAT number, footer, and logo. receipt.html's
// renderReceipt + epos-print.js's buildEposXml both read
// sale.letterhead.* (and fall back to "WFSALES POS" / blank
// if it's null, so a missing letterhead is a non-fatal
// degradation, not a crash).
letterhead: state.letterhead || null,
items: Array.from(state.cart.values()).map(({p,qty})=>({
sku: p.sku, description: p.description, unit: p.unit,
cost_price: p.cost_price,
selling_price: lineUnitPrice({p,qty}),
qty, product_id: p.id,
})),
customer: state.customer || null,
synced: false,
};
const checkoutBody = {
items: Array.from(state.cart.values()).map(({p,qty})=>({
stock_item_id: isCustomLine(p) ? undefined : p.id,
is_custom: isCustomLine(p),
qty, unit_price: lineUnitPrice({p,qty}),
sku: p.sku, description: isCustomLine(p) ? p.description : (p.description || p.sku),
})),
payment_method: state.paymentMethod,
cash_given: state.paymentMethod==='cash' ? state.cashGiven : undefined,
customer_id: state.customer ? state.customer.id : undefined,
client_ref: localId,
// The server reads the VAT rate off this letterhead rather than
// trusting a rate off the wire.
letterhead_id: state.letterhead ? state.letterhead.id : undefined,
discount_kind: state.discount && state.discount.kind === 'percent' ? 'percent' : undefined,
discount_input: state.discount && state.discount.kind === 'percent' ? state.discount.input : undefined,
};
// Distinguish a permanent server-side rejection (insufficient
// stock, bad input) from a transient failure (server down, network
// blip). The former must NOT advance to the receipt page or get
// queued for offline retry — it's a real, human-fixable problem
// the operator needs to see. The latter is what the offline queue
// is for.
let serverError = null;
try{
const r = await window.posFetch('/checkout', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(checkoutBody),
});
if(r.ok){
const j = await r.json();
sale.synced = true;
sale.serverRef = j.sale && j.sale.id;
// The server raises the delivery note for an `account` sale and
// hands it back here. Carried onto the local sale so
// receipt.html can print the A4 original instead of the old
// 80mm thermal slip. Absent for cash / card / EFT, and absent
// for an account sale whose DN creation failed — in which case
// the operator is told to raise one by hand.
if (j.deliveryNote) sale.deliveryNote = j.deliveryNote;
if (j.deliveryNoteError) sale.deliveryNoteError = j.deliveryNoteError;
} else {
// The route returns JSON on every non-2xx path, so this should
// always parse. Overselling is allowed on purpose (no stock
// check blocks the sale) — every rejection here is a real
// validation/server failure, not a stock shortage.
let body = null;
try { body = await r.json(); } catch (_) { /* not json */ }
const msg = body && body.message ? body.message : ('server returned ' + r.status);
serverError = { message: msg };
throw new Error(msg);
}
}catch(e){
console.warn('Sale queued for sync', e);
try{
await dbPut(STORE_QUEUE, {...sale, checkoutBody});
}catch(qErr){
console.error('IndexedDB queue failed too — saving to localStorage as fallback', qErr);
try{
const arr = JSON.parse(localStorage.getItem('wfsales.queue') || '[]');
arr.push({...sale, checkoutBody});
localStorage.setItem('wfsales.queue', JSON.stringify(arr));
}catch(_){}
}
}
// Permanent server-side rejection — stop here, do NOT navigate to
// the receipt page, do NOT queue for retry. Show a clear error,
// re-enable the Complete button so the operator can fix the
// cart (remove a line, lower the qty) and try again. The cart
// stays open.
if (serverError) {
showToast('Sale rejected: ' + serverError.message);
if (btn) {
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12l5 5 9-11"/></svg>Complete sale';
}
return;
}
// ALWAYS navigate to receipt — never leave the user stuck on "Saving…"
try{ sessionStorage.setItem('pos.lastSale', JSON.stringify(sale)); }catch(_){}
state.cart.clear(); persistCart(); paintCart(); render();
closePayment();
// Use a small delay so the drawer close animation starts before navigation
setTimeout(()=>{ location.href = 'receipt.html?id=' + encodeURIComponent(sale.localId); }, 60);
}
// ---------------- network pill + boot ----------------
function paintNet(){
const p = document.getElementById('netpill');
p.textContent = navigator.onLine ? 'Online' : 'Offline';
p.classList.toggle('off', !navigator.onLine);
}
window.addEventListener('online', ()=>{ paintNet(); flushQueue(); });
window.addEventListener('offline', paintNet);
async function flushQueue(){
if(!navigator.onLine) return;
const q = await dbAll(STORE_QUEUE);
for(const s of q){
try{
const r = await window.posFetch('/checkout', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s.checkoutBody),
});
if(r.ok){
// Safe even if this sale already landed before — the server
// matches on client_ref and replays the original instead of
// decrementing stock again.
await dbDel(STORE_QUEUE, s.localId);
} else {
break; // stop on first failure, retry next time we come online
}
}catch(e){ console.warn('queue item failed', e); break; }
}
}
// ---------------- search input + topbar scroll ----------------
document.addEventListener('input', e=>{
if(e.target.id!=='q') return;
state.search = e.target.value;
resetPaging();
render();
});
const topbar = document.getElementById('topbar');
const cat = document.getElementById('catalog');
cat.addEventListener('scroll', ()=>topbar.classList.toggle('scrolled', cat.scrollTop>4));
// ---------------- category dropdown wiring ----------------
document.getElementById('catTrigger').addEventListener('click', e=>{
e.stopPropagation();
const t = e.currentTarget;
if(t.getAttribute('aria-expanded')==='true') closeCatMenu();
else openCatMenu();
});
document.getElementById('catBackdrop').addEventListener('click', closeCatMenu);
document.addEventListener('keydown', e=>{
if(e.key==='Escape'){
// Edit modal takes priority over the category menu.
if (document.getElementById('editSheet').classList.contains('show')) {
closeEditLine();
return;
}
const t = document.getElementById('catTrigger');
if(t && t.getAttribute('aria-expanded')==='true') closeCatMenu();
}
});
// ---------------- utils ----------------
function escapeHTML(s){ return String(s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]); }
function escapeAttr(s){ return escapeHTML(s).replace(/`/g,'&#96;'); }
// expose for inline handlers
window.__pos = { changeQty, removeFromCart, openDrawer, closeDrawer, openPayment, closePayment, pickMethod, confirmSale, openEditLine, addCustomItem, openCustomItem, closeCustomItem, saveCustomItem, setDiscountTier, clearDiscount, toggleDiscount };
// Also expose as bare globals so inline onclick="" in pos.html works
window.openDrawer = openDrawer;
window.closeDrawer = closeDrawer;
window.openPayment = openPayment;
window.closePayment = closePayment;
window.pickMethod = pickMethod;
window.confirmSale = confirmSale;
window.changeQty = changeQty;
window.removeFromCart = removeFromCart;
window.openEditLine = openEditLine;
// The edit-line modal could be opened but not dismissed or committed:
// its Save / Cancel / Remove buttons (and the customer "Remove" chip)
// call these from inline onclick, so they need to be global too.
window.closeEditLine = closeEditLine;
window.editSave = editSave;
window.editRemove = editRemove;
window.clearCust = clearCust;
// Discount tiers + the ad-hoc line composer are driven from inline
// onclick in pos.html, so they have to be globals too.
window.setDiscountTier = setDiscountTier;
window.toggleDiscount = toggleDiscount;
window.clearDiscount = clearDiscount;
window.addCustomItem = addCustomItem;
window.openCustomItem = openCustomItem;
window.closeCustomItem = closeCustomItem;
window.saveCustomItem = saveCustomItem;
// ---------------- boot ----------------
(async function boot(){
paintNet();
loadCart();
paintCart();
await Promise.all([loadLetterhead(), loadProducts()]);
flushQueue();
// register SW
if('serviceWorker' in navigator){
navigator.serviceWorker.register('sw.js').catch(()=>{});
}
})();
})();