Sends client_ref (the till's localId) so the offline queue can retry a sale that already landed without recording it twice. Customer search moves to /api/v1/pos/customers and now carries the company name for the delivery-note slip. Co-Authored-By: Claude <noreply@anthropic.com>
600 lines
22 KiB
JavaScript
600 lines
22 KiB
JavaScript
// 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: '',
|
||
cart: new Map(), // id -> {p, qty}
|
||
customer: null,
|
||
paymentMethod: null,
|
||
cashGiven: 0,
|
||
letterhead: null
|
||
};
|
||
|
||
// ---------------- 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 ----------------
|
||
async function loadProducts(){
|
||
// Try IDB first
|
||
try{
|
||
const cached = await dbAll(STORE_PRODUCTS);
|
||
if(cached.length){
|
||
state.products = cached;
|
||
state.productsById = new Map(cached.map(p=>[p.id,p]));
|
||
buildCategories();
|
||
render();
|
||
}
|
||
}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,
|
||
}));
|
||
state.products = all;
|
||
state.productsById = new Map(all.map(p=>[p.id,p]));
|
||
buildCategories();
|
||
render();
|
||
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(){
|
||
// Letterhead comes from the main app in a later task if needed;
|
||
// the receipt already falls back to "WFSALES POS" when absent.
|
||
}
|
||
|
||
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();
|
||
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(){
|
||
const q = state.search.trim().toLowerCase();
|
||
return state.products.filter(p=>{
|
||
if(state.activeCat==='__uncat'){
|
||
if(p.category) return false;
|
||
} else if(state.activeCat!=='all' && p.category!==state.activeCat){
|
||
return false;
|
||
}
|
||
if(!q) return true;
|
||
return (p.sku||'').toLowerCase().includes(q)
|
||
|| (p.sku_normalised||'').toLowerCase().includes(q)
|
||
|| (p.description||'').toLowerCase().includes(q)
|
||
|| (p.brand||'').toLowerCase().includes(q)
|
||
|| (p.search_text||'').toLowerCase().includes(q);
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
cat.innerHTML = list.map(p=>{
|
||
const price = p.selling_price ?? p.cost_price ?? 0;
|
||
const inCart = state.cart.get(p.id)?.qty || 0;
|
||
return `<button class="prod" data-id="${p.id}" data-od-id="prod-${p.id}">
|
||
<div class="ic">${initials(p.category||p.brand||'?')}</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('');
|
||
cat.querySelectorAll('.prod').forEach(b=>{
|
||
b.addEventListener('click', ()=>addToCart(b.dataset.id));
|
||
});
|
||
}
|
||
|
||
function initials(s){
|
||
const parts = s.trim().split(/\s+/).slice(0,2);
|
||
return parts.map(p=>p[0]||'').join('').toUpperCase().slice(0,2);
|
||
}
|
||
|
||
// ---------------- 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, total=0;
|
||
for(const {p,qty} of state.cart.values()){
|
||
count += qty;
|
||
total += (p.selling_price ?? p.cost_price ?? 0) * qty;
|
||
}
|
||
// VAT-inclusive model: selling_price is the shelf price (incl. VAT).
|
||
// Subtotal excluding VAT = total / 1.15, VAT = total - sub
|
||
const sub = total / 1.15;
|
||
const vat = total - sub;
|
||
return {count, sub, vat, total};
|
||
}
|
||
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();
|
||
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('grandTotal').textContent = fmt(0);
|
||
return;
|
||
}
|
||
body.innerHTML = Array.from(state.cart.values()).map(({p,qty})=>{
|
||
const price = p.selling_price ?? p.cost_price ?? 0;
|
||
return `<div class="line" data-od-id="line-${p.id}">
|
||
<div class="nm">${escapeHTML(p.description||p.sku)}<small>${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}</small></div>
|
||
<div class="qty">
|
||
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease">−</button>
|
||
<div class="v">${qty}</div>
|
||
<button onclick="window.__pos.changeQty('${p.id}',1)" aria-label="Increase">+</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);
|
||
}
|
||
|
||
// ---------------- 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();
|
||
box.innerHTML = (j.items||[]).map(c=>`<div 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('');
|
||
box.querySelectorAll('div').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 ----------------
|
||
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();
|
||
|
||
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,
|
||
sub, vat, total,
|
||
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: p.selling_price, 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: p.id, qty, unit_price: p.selling_price ?? p.cost_price ?? 0,
|
||
sku: p.sku, description: p.description,
|
||
})),
|
||
payment_method: state.paymentMethod,
|
||
cash_given: state.paymentMethod==='cash' ? state.cashGiven : undefined,
|
||
customer_id: state.customer ? state.customer.id : undefined,
|
||
client_ref: localId,
|
||
};
|
||
|
||
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;
|
||
} else {
|
||
throw new Error('server returned ' + r.status);
|
||
}
|
||
}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(_){}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
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'){
|
||
const t = document.getElementById('catTrigger');
|
||
if(t && t.getAttribute('aria-expanded')==='true') closeCatMenu();
|
||
}
|
||
});
|
||
|
||
// ---------------- utils ----------------
|
||
function escapeHTML(s){ return String(s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); }
|
||
function escapeAttr(s){ return escapeHTML(s).replace(/`/g,'`'); }
|
||
|
||
// expose for inline handlers
|
||
window.__pos = { changeQty, removeFromCart, openDrawer, closeDrawer, openPayment, closePayment, pickMethod, confirmSale };
|
||
// 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;
|
||
|
||
// ---------------- 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(()=>{});
|
||
}
|
||
})();
|
||
|
||
})();
|