WFDASH-WEBAPP/public/pos/pos.html
Dave 4447c4e773 feat(pos): customer picker matches the company name, and stops hiding truncation
The account-sale picker filtered on three customer columns — name,
number, email. `company` on a customer row is a RELATION id, not a
name, so it could never be text-matched; the company name was only ever
resolved afterwards by `expand` for display. Typing "Sibanye" returned
nothing even though 52 people work there.

  - the filter now traverses the relation: `company.company ~ q`.
    Verified live: "Sibanye - Rustenburg" 0 → 52, "Adal" 0 → 2,
    "Alano" 0 → 1, while "Botha" and "Smit" are unchanged at 6 and 4.
  - each row carries `viaCompany`, and name matches sort above
    company-only matches. PB's own sort is by name across the whole
    result set, which would interleave the two bands.
  - /customers returns { items, total, shown } and the till renders a
    count header: "52 matches", or "Showing 100 of 122 — keep typing to
    narrow". The old `slice(0, 8)` showed 8 of 52 people with nothing
    to suggest 44 were missing; 15 companies have more staff than that.

The picker click handler bound to every `div` in the results box, so
adding a count header would have made it selectable and picked a
customer with an empty id. Now scoped to `.cust-row`.

Two bugs the tests caught rather than the code:

  - the display cap was first set to 50 while its own comment claimed
    it sat above the largest company — which has 52 staff. That
    silently re-created the truncation this change removes. Now 100,
    with a test asserting a 52-person company comes back whole.
  - PocketBase has no escape for a literal backslash: `\'` is
    accepted, but one, two or three backslashes all fail to parse and
    500 the request, so typing `\` in the search box errored. No
    escaping survives, so backslashes are stripped before the filter
    (`M20\Nut` searches `M20Nut`) and a backslash-only query returns
    nothing rather than matching every customer.

13 new tests cover the company clause, the 52-staff case, the cap,
ranking order, backslash stripping and the empty-after-strip case.
Verified live against PocketBase across 10 queries — the ranking
guarantee held on every one.

sw.js v33 -> v34: pos-app.js and pos.html are cache-first in the shell.
2026-10-07 15:53:23 +02:00

535 lines
38 KiB
HTML
Raw 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.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#ff385c">
<title>New sale</title>
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="fa/all.min.css">
<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'/><text x='16' y='22' font-family='-apple-system,sans-serif' font-size='17' font-weight='700' text-anchor='middle' fill='white'>W</text></svg>">
<style>
:root{
--bg:#ffffff;--surface:#fff7f8;--fg:#222222;--muted:#dddddd;--border:#ffe3e8;--accent:#ff385c;
--ok:#0f8a3f;--warn:#b14a00;--danger:#c92a2a;
--shadow-up:0 -1px 0 var(--muted), 0 -8px 24px rgba(34,34,34,.06);
--font:'Inter',system-ui,-apple-system,Segoe UI,Helvetica Neue,Arial,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%;background:var(--bg);color:var(--fg);font:15px/1.45 var(--font);-webkit-font-smoothing:antialiased;overscroll-behavior:none;overflow-x:hidden}
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;background:var(--bg);padding-bottom:env(safe-area-inset-bottom)}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shadow:0 0 0 3px rgba(255,56,92,.30);border-radius:10px}
.topbar .back:focus-visible,.topbar .theme-btn:focus-visible{box-shadow:0 0 0 3px rgba(255,56,92,.30);border-radius:50%}
input{font:inherit;color:inherit}
/* ---- Topbar ---- */
.topbar{display:flex;align-items:center;gap:10px;padding:14px 16px 10px;background:var(--bg);position:sticky;top:0;z-index:10;border-bottom:1px solid transparent;transition:border-color .15s}
.topbar.scrolled{border-bottom-color:var(--muted)}
.topbar .back{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg)}
.topbar .back:active{background:var(--surface)}
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1}
.topbar .netpill{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:600;color:#717171;padding:4px 8px;border:1px solid var(--muted);border-radius:999px}
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:#fff5f5}
/* ---- Search ---- */
.search{padding:6px 16px 10px;background:var(--bg);position:sticky;top:60px;z-index:9}
.search-box{display:flex;align-items:center;gap:10px;padding:11px 14px;border:1px solid var(--muted);border-radius:12px;background:#fff;box-shadow:0 1px 2px rgba(34,34,34,.02)}
.search-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
.search-box svg{width:18px;height:18px;color:#717171;flex:0 0 auto;stroke-width:1.7}
.search-box input{flex:1;border:0;outline:0;background:transparent;font-size:15px;min-width:0}
.search-box input::placeholder{color:#9a9a9a}
/* ---- Category dropdown ---- */
.cat-row{display:flex;align-items:center;gap:10px;padding:4px 16px 12px;background:var(--bg);position:sticky;top:118px;z-index:8}
.cat-trigger{display:flex;align-items:center;gap:8px;padding:10px 14px;border:1px solid var(--muted);border-radius:10px;background:#fff;font-size:14px;font-weight:500;color:var(--fg);min-height:42px;max-width:65%;letter-spacing:-.005em}
.cat-trigger:active{background:var(--surface)}
.cat-trigger .cat-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cat-trigger .cat-chev{width:16px;height:16px;flex:0 0 auto;color:#717171;transition:transform .18s}
.cat-trigger[aria-expanded="true"] .cat-chev{transform:rotate(180deg)}
.cat-count{font-size:12px;color:#717171;letter-spacing:.01em;font-variant-numeric:tabular-nums}
.cat-menu{position:fixed;left:16px;right:16px;top:auto;background:#fff;border:1px solid var(--muted);border-radius:14px;box-shadow:0 12px 40px rgba(34,34,34,.18);z-index:60;max-width:448px;margin:0 auto;overflow:hidden;transform-origin:top center;animation:catIn .15s ease}
@keyframes catIn{from{opacity:0;transform:translateY(-4px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
.cat-menu[hidden]{display:none}
.cat-menu-head{max-height:60dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:4px 0}
.cat-item{display:flex;align-items:center;gap:12px;padding:12px 16px;width:100%;text-align:left;font-size:14px;color:var(--fg);font-weight:500;letter-spacing:-.005em;border-bottom:1px solid var(--muted);background:none;min-height:44px}
.cat-item:last-child{border-bottom:0}
.cat-item:active{background:var(--surface)}
.cat-item .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cat-item .ct{font-size:12px;color:#717171;font-variant-numeric:tabular-nums;letter-spacing:.02em;flex:0 0 auto}
.cat-item.active{background:var(--surface);color:var(--accent)}
.cat-item.active .ct{color:var(--accent)}
.cat-item.active::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--accent);flex:0 0 auto}
.cat-backdrop{position:fixed;inset:0;background:transparent;z-index:55}
.cat-backdrop[hidden]{display:none}
/* ---- Catalog list ---- */
.catalog{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 16px 220px}
.catalog .meta{padding:12px 0 6px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;display:flex;justify-content:space-between}
.catalog .empty{padding:60px 20px;text-align:center;color:#9a9a9a}
.catalog .more{display:flex;flex-direction:column;align-items:center;gap:2px;width:100%;margin:14px 0 8px;padding:14px;border:1px solid var(--muted);border-radius:12px;background:var(--surface-card);color:var(--fg);font-size:14px;font-weight:600;letter-spacing:-.005em}
.catalog .more small{font-size:11px;font-weight:500;color:#717171;letter-spacing:.02em}
.catalog .more:active{background:var(--surface)}
.catalog .empty h3{font-size:16px;font-weight:600;color:var(--fg);margin-bottom:6px;letter-spacing:-.01em}
.catalog .empty p{font-size:13px;line-height:1.5}
.prod{display:flex;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--muted);min-height:60px;background:none;border-left:0;border-right:0;border-top:0;width:100%;text-align:left}
.prod:active{background:var(--surface)}
.prod .ic{width:40px;height:40px;border-radius:10px;background:var(--surface);border:1px solid var(--border);display:grid;place-items:center;color:var(--accent);font-size:12px;font-weight:700;letter-spacing:.04em;flex:0 0 auto}
.prod .ic .ic-fa{font-size:18px;line-height:1;color:var(--accent)}
.prod .body{flex:1;min-width:0;padding-right:8px}
.prod .name{font-size:14px;font-weight:500;line-height:1.3;letter-spacing:-.005em;color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.prod .sub{font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.prod .price{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap}
.prod .price small{font-size:11px;color:#717171;font-weight:500;margin-left:2px}
/* ---- Cart bottom sheet ---- */
.cart-bar{position:fixed;left:0;right:0;bottom:0;background:#fff;border-top:1px solid var(--muted);padding:12px 16px calc(12px + env(safe-area-inset-bottom));z-index:20;display:flex;align-items:center;gap:12px;box-shadow:var(--shadow-up);max-width:480px;margin:0 auto;transform:translateY(100%);transition:transform .22s cubic-bezier(.2,.8,.2,1)}
.cart-bar.show{transform:translateY(0)}
.cart-bar .count{display:flex;flex-direction:column;line-height:1.15}
.cart-bar .count b{font-size:20px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.cart-bar .count span{font-size:11px;color:#717171;letter-spacing:.04em;text-transform:uppercase}
.cart-bar .total{margin-left:auto;font-size:20px;font-weight:700;letter-spacing:-.02em;color:var(--fg);font-variant-numeric:tabular-nums}
.cart-bar .checkout{padding:14px 22px;background:var(--accent);color:#fff;border-radius:12px;font-weight:600;font-size:15px;letter-spacing:-.005em;min-height:48px;display:flex;align-items:center;gap:6px}
.cart-bar .checkout:active{background:#e5324f}
.cart-bar .checkout svg{width:18px;height:18px;stroke-width:2}
/* ---- Cart drawer ---- */
.drawer{position:fixed;inset:0;background:rgba(34,34,34,.5);z-index:50;opacity:0;pointer-events:none;transition:opacity .2s}
.drawer.show{opacity:1;pointer-events:auto}
.sheet{position:absolute;left:0;right:0;bottom:0;background:#fff;border-radius:18px 18px 0 0;max-height:88dvh;display:flex;flex-direction:column;transform:translateY(100%);transition:transform .25s cubic-bezier(.2,.8,.2,1);max-width:480px;margin:0 auto;padding-bottom:env(safe-area-inset-bottom)}
.drawer.show .sheet{transform:translateY(0)}
.sheet-head{display:flex;align-items:center;padding:18px 20px 8px;border-bottom:1px solid var(--muted)}
.sheet-head h2{font-size:18px;font-weight:700;letter-spacing:-.01em;flex:1}
.sheet-head .x{width:32px;height:32px;border-radius:50%;background:var(--surface);display:grid;place-items:center;color:var(--fg)}
.sheet-body{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:4px 0}
.line{display:flex;align-items:center;gap:12px;padding:14px 20px;border-bottom:1px solid var(--muted)}
.line .nm{flex:1;min-width:0;font-size:14px;font-weight:500;line-height:1.3;letter-spacing:-.005em}
.line .nm small{display:block;font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em}
.qty{display:flex;align-items:center;border:1px solid var(--muted);border-radius:10px;overflow:hidden}
.qty button{width:34px;height:34px;display:grid;place-items:center;color:var(--fg);font-size:16px;font-weight:600}
.qty button:active{background:var(--surface)}
.qty .v{min-width:44px;padding:6px 2px;text-align:center;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--fg);border-radius:6px}
.qty .v:active{background:var(--surface)}
.line .pr{width:78px;text-align:right;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--fg)}
.line .rm{width:32px;height:32px;border-radius:50%;color:#9a9a9a;display:grid;place-items:center}
.line .rm:active{background:#fff0f0;color:var(--danger)}
.sheet-foot{padding:14px 20px 18px;border-top:1px solid var(--muted);background:#fff}
.totals{display:flex;justify-content:space-between;padding:6px 0;font-size:14px;color:#717171;letter-spacing:.01em}
.totals b{color:var(--fg);font-variant-numeric:tabular-nums}
.totals.grand{padding-top:12px;margin-top:8px;border-top:1px solid var(--muted);font-size:17px;color:var(--fg);font-weight:700;letter-spacing:-.01em}
/* ---- Discount block ---- */
/* The trigger rides INSIDE the Subtotal row (see the markup below), so it
adds no vertical height at all — the footer keeps every pixel it had
before. It stays visible rather than collapsing away, because a discount
control that appears from nowhere is one nobody finds under pressure. */
.discount-trigger{display:inline-flex;align-items:center;gap:3px;margin-left:8px;padding:3px 6px;border:0;border-radius:6px;background:none;color:#717171;font-size:12px;font-weight:600;letter-spacing:.01em;vertical-align:middle}
.discount-trigger:active{background:var(--surface);color:var(--fg)}
.discount-trigger svg{width:13px;height:13px;color:inherit;transition:transform .18s}
.discount-trigger[aria-expanded="true"] svg{transform:rotate(180deg)}
.discount-trigger.has-value{color:var(--accent)}
.discount-trigger.has-value svg{color:var(--accent)}
.discount-body{padding:12px 0 4px;display:flex;flex-direction:column;gap:10px}
.discount-pills{display:flex;gap:6px;padding:3px;background:var(--surface);border:1px solid var(--muted);border-radius:999px;width:fit-content}
.discount-pill{padding:7px 16px;border-radius:999px;font-size:13px;font-weight:600;letter-spacing:-.005em;color:#717171;min-height:34px}
.discount-pill.active{background:#fff;color:var(--accent);box-shadow:0 1px 2px rgba(34,34,34,.06)}
.discount-input-row{display:flex;align-items:center;gap:8px}
.discount-input-row input{flex:1 1 0;min-width:0;padding:11px 12px;border:1px solid var(--muted);border-radius:10px;font-size:15px;font-weight:600;text-align:right;font-variant-numeric:tabular-nums;outline:0;background:#fff}
.discount-input-row input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
.discount-suffix{font-size:13px;font-weight:600;color:#717171;width:18px;text-align:left;letter-spacing:-.005em}
.discount-apply{padding:11px 16px;background:var(--accent);color:#fff;border-radius:10px;font-size:13px;font-weight:600;letter-spacing:-.005em;min-height:42px;min-width:78px}
.discount-apply:active{background:#e5324f}
.discount-hint{font-size:11px;color:#717171;margin:0;letter-spacing:.01em}
.totals.discount-line{color:var(--danger)}
.totals.discount-line b{color:var(--danger)}
/* ---- Edit line modal (long-press) ---- */
.edit-backdrop{position:fixed;inset:0;background:rgba(34,34,34,.5);z-index:70;opacity:0;pointer-events:none;transition:opacity .15s}
.edit-backdrop.show{opacity:1;pointer-events:auto}
/* The sheet is a SIBLING of the backdrop, not a child, so it cannot be
styled via `.edit-backdrop.show .edit-sheet` — that selector never
matched and the sheet stayed at opacity:0 forever, which showed the
operator a grey screen with no dialog on it. It needs its own stacking
above the backdrop (z-index 70) and its own .show rule. Keep them
siblings: nesting the sheet inside the backdrop would make every tap on
the sheet bubble to the backdrop's onclick and close it. */
.edit-sheet{position:fixed;left:50%;bottom:24px;z-index:71;transform:translate(-50%, 24px);background:#fff;border-radius:18px;width:calc(100% - 32px);max-width:448px;padding:18px 18px 14px;opacity:0;transition:opacity .15s, transform .22s cubic-bezier(.2,.8,.2,1);box-shadow:0 20px 60px rgba(34,34,34,.30);pointer-events:none;display:flex;flex-direction:column;gap:12px;max-height:80dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding-bottom:calc(14px + env(safe-area-inset-bottom))}
.edit-sheet.show{opacity:1;transform:translate(-50%, 0);pointer-events:auto}
.edit-sheet h3{font-size:15px;font-weight:600;letter-spacing:-.01em;margin:0;display:flex;align-items:center;gap:8px}
.edit-sheet h3 small{font-size:11px;font-weight:500;color:#717171;letter-spacing:.02em;margin-left:auto}
.edit-row{display:flex;flex-direction:column;gap:6px}
.edit-row label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#717171}
.edit-row input{width:100%;box-sizing:border-box;padding:12px 12px;border:1px solid var(--muted);border-radius:10px;font-size:16px;font-weight:600;text-align:right;font-variant-numeric:tabular-nums;outline:0;background:#fff}
.edit-row input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
.edit-actions{display:flex;gap:8px;margin-top:4px}
.edit-actions button{flex:1;padding:13px;border-radius:12px;font-size:14px;font-weight:600;letter-spacing:-.005em;min-height:46px}
.edit-actions .primary{background:var(--accent);color:#fff}
.edit-actions .primary:active{background:#e5324f}
.edit-actions .secondary{background:#fff;border:1px solid var(--muted);color:var(--fg)}
.edit-actions .secondary:active{background:var(--surface)}
.edit-actions .danger{background:#fff;border:1px solid #f0c0c0;color:var(--danger)}
.edit-actions .danger:active{background:#fff0f0}
/* Long-press hint chip — shown briefly when a line is held long
enough to count down to the modal opening, so the operator
knows the long-press did something. */
.line-holding{background:var(--surface);transition:background .15s}
/* ---- Payment sheet ---- */
.pay-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:8px 20px 16px}
.pay-tile{padding:18px 14px;border:1.5px solid var(--muted);border-radius:14px;background:#fff;text-align:left;min-height:92px;display:flex;flex-direction:column;justify-content:space-between}
.pay-tile:active{background:var(--surface)}
.pay-tile .lab{font-size:14px;font-weight:600;letter-spacing:-.01em}
.pay-tile .sub{font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em}
.pay-tile .ic{width:32px;height:32px;border-radius:8px;background:var(--surface);color:var(--accent);display:grid;place-items:center;margin-bottom:8px}
.pay-tile .ic svg{width:18px;height:18px;stroke-width:1.7}
.pay-tile.selected{border-color:var(--accent);background:#fff8fa}
.pay-tile.selected .ic{background:var(--accent);color:#fff}
.cash-block{padding:0 16px 20px;display:none}
.cash-block.show{display:block}
.cash-row{display:flex;gap:8px;margin-bottom:10px;min-width:0}
.cash-row .field{flex:1 1 0;min-width:0;display:flex;flex-direction:column}
.cash-row label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#717171;margin-bottom:4px}
.cash-row input{width:100%;box-sizing:border-box;padding:12px 10px;border:1px solid var(--muted);border-radius:10px;font-size:16px;font-weight:600;text-align:right;font-variant-numeric:tabular-nums;outline:0;background:#fff;min-width:0}
.cash-row input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
.quick{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
.quick button{padding:9px 13px;border:1px solid var(--muted);border-radius:999px;font-size:13px;font-weight:500;background:#fff;color:var(--fg);min-height:36px}
.quick button:active{background:var(--surface);border-color:#bbb}
.change-out{padding:14px;border-radius:12px;background:var(--surface);border:1px solid var(--border);text-align:center;min-height:64px;display:flex;flex-direction:column;justify-content:center;opacity:0;max-height:0;padding-top:0;padding-bottom:0;overflow:hidden;transition:opacity .15s, max-height .2s, padding .2s, border-width .2s;border-width:0}
.change-out.show{opacity:1;max-height:120px;padding:14px;border-width:1px}
.change-out .lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171}
.change-out .amt{font-size:32px;font-weight:700;letter-spacing:-.02em;color:var(--ok);font-variant-numeric:tabular-nums;margin-top:4px}
.change-out.short .amt{color:var(--danger)}
.cust-block{padding:0 16px 16px;display:none}
.cust-block.show{display:block}
.cust-block .search-row{display:flex;gap:8px;margin-bottom:10px;min-width:0}
.cust-block input{flex:1 1 0;width:100%;box-sizing:border-box;min-width:0;padding:12px 10px;border:1px solid var(--muted);border-radius:10px;font-size:14px;outline:0;background:#fff}
.cust-block input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
.cust-results{border:1px solid var(--muted);border-radius:10px;max-height:160px;overflow-y:auto;background:#fff}
.cust-results:empty{display:none}
.cust-results div{padding:10px 14px;border-bottom:1px solid var(--muted);font-size:14px;display:flex;align-items:center;gap:8px;cursor:pointer}
.cust-results div:last-child{border-bottom:0}
.cust-results div:active{background:var(--surface)}
.cust-results small{color:#717171;font-size:11px;margin-left:auto;letter-spacing:.02em}
/* Count header + empty state. Both are divs inside .cust-results, so they
need the row cursor/padding neutralised explicitly. */
.cust-results .cust-count{padding:7px 14px;background:var(--surface);border-bottom:1px solid var(--muted);font-size:11px;font-weight:600;letter-spacing:.02em;color:#717171;cursor:default}
.cust-results .cust-none{padding:11px 14px;font-size:13px;color:#717171;cursor:default}
.cust-results .cust-count:active,.cust-results .cust-none:active{background:none}
.cust-picked{padding:10px 12px;background:var(--surface);border:1px solid var(--border);border-radius:10px;font-size:13px;display:none;align-items:center;gap:8px;margin-bottom:8px}
.cust-picked.show{display:flex}
.cust-picked b{flex:1;font-weight:600}
.cust-picked button{color:var(--danger);font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}
.final-cta{width:calc(100% - 40px);margin:0 20px 18px;padding:16px;background:var(--accent);color:#fff;border-radius:14px;font-size:16px;font-weight:600;letter-spacing:-.01em;min-height:54px;display:flex;align-items:center;justify-content:center;gap:8px}
.final-cta:active{background:#e5324f}
.final-cta:disabled{background:#e0e0e0;color:#999}
.final-cta svg{width:20px;height:20px;stroke-width:2}
/* ---- theme toggle ---- */
.theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);flex:0 0 auto;background:transparent;border:0;cursor:pointer;position:relative}
.theme-btn:active{background:var(--surface)}
.theme-btn svg{width:18px;height:18px}
.theme-btn .i-moon{display:none}
:root[data-theme="dark"] .theme-btn .i-sun{display:none}
:root[data-theme="dark"] .theme-btn .i-moon{display:block}
:root[data-theme="auto"] .theme-btn .i-moon{display:none}
:root[data-theme="auto"] .theme-btn .i-sun{display:block}
/* ---- dark theme (auto follows prefers-color-scheme) ---- */
:root[data-theme="dark"]{
--bg:#0f0f0f; --surface:#1c1416; --fg:#f0f0f0; --muted:#2a2a2a; --border:#3a2429;
--shadow-up:0 -1px 0 var(--muted), 0 -8px 24px rgba(0,0,0,.4);
}
:root[data-theme="dark"] body{background:var(--bg);color:var(--fg)}
:root[data-theme="dark"] .topbar{background:var(--bg);border-bottom-color:var(--muted)}
:root[data-theme="dark"] .search-box{background:#181818;border-color:var(--muted)}
:root[data-theme="dark"] .search-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
:root[data-theme="dark"] .search-box svg{color:#888}
:root[data-theme="dark"] .search-box input{color:var(--fg)}
:root[data-theme="dark"] .search-box input::placeholder{color:#666}
:root[data-theme="dark"] .chip{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .chip.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
:root[data-theme="dark"] .cat-trigger{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .cat-trigger .cat-chev{color:#888}
:root[data-theme="dark"] .cat-count{color:#888}
:root[data-theme="dark"] .cat-menu{background:#181818;border-color:var(--muted);box-shadow:0 12px 40px rgba(0,0,0,.5)}
:root[data-theme="dark"] .cat-item{color:var(--fg);border-bottom-color:var(--muted)}
:root[data-theme="dark"] .cat-item:active{background:var(--surface)}
:root[data-theme="dark"] .cat-item .ct{color:#888}
:root[data-theme="dark"] .cat-item.active{background:var(--surface);color:var(--accent)}
:root[data-theme="dark"] .prod{border-bottom-color:var(--muted)}
:root[data-theme="dark"] .prod:active{background:var(--surface)}
:root[data-theme="dark"] .prod .ic{background:var(--surface);border-color:var(--border);color:var(--accent)}
:root[data-theme="dark"] .prod .name{color:var(--fg)}
:root[data-theme="dark"] .prod .sub{color:#888}
:root[data-theme="dark"] .prod .price{color:var(--fg)}
:root[data-theme="dark"] .prod .price small{color:#888}
:root[data-theme="dark"] .cart-bar{background:#181818;border-top-color:var(--muted);box-shadow:var(--shadow-up)}
:root[data-theme="dark"] .cart-bar .count span{color:#888}
:root[data-theme="dark"] .cart-bar .total{color:var(--fg)}
:root[data-theme="dark"] .drawer{background:rgba(0,0,0,.7)}
:root[data-theme="dark"] .sheet{background:#181818}
:root[data-theme="dark"] .sheet-head{border-bottom-color:var(--muted)}
:root[data-theme="dark"] .sheet-head .x{background:var(--surface);color:var(--fg)}
:root[data-theme="dark"] .line{border-bottom-color:var(--muted)}
:root[data-theme="dark"] .line .nm{color:var(--fg)}
:root[data-theme="dark"] .line .nm small{color:#888}
:root[data-theme="dark"] .qty{border-color:var(--muted)}
:root[data-theme="dark"] .qty button{color:var(--fg)}
:root[data-theme="dark"] .qty button:active{background:var(--surface)}
:root[data-theme="dark"] .qty .v{color:var(--fg)}
:root[data-theme="dark"] .line .pr{color:var(--fg)}
:root[data-theme="dark"] .line .rm{color:#666}
:root[data-theme="dark"] .line .rm:active{background:#3a2429;color:var(--accent)}
:root[data-theme="dark"] .sheet-foot{background:#181818;border-top-color:var(--muted)}
:root[data-theme="dark"] .totals{color:#888}
:root[data-theme="dark"] .totals b{color:var(--fg)}
:root[data-theme="dark"] .totals.grand{border-top-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .discount-trigger{color:#888}
:root[data-theme="dark"] .discount-trigger:active{color:var(--fg)}
:root[data-theme="dark"] .discount-trigger.has-value{color:#ff6680}
:root[data-theme="dark"] .discount-pills{background:#222;border-color:var(--muted)}
:root[data-theme="dark"] .discount-pill{color:#888}
:root[data-theme="dark"] .discount-pill.active{background:#181818;color:#ff6680;box-shadow:0 1px 2px rgba(0,0,0,.4)}
:root[data-theme="dark"] .discount-input-row input{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .discount-input-row input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
:root[data-theme="dark"] .discount-suffix{color:#888}
:root[data-theme="dark"] .discount-hint{color:#888}
:root[data-theme="dark"] .totals.discount-line{color:#ff6680}
:root[data-theme="dark"] .totals.discount-line b{color:#ff6680}
:root[data-theme="dark"] .empty{color:#666}
:root[data-theme="dark"] .empty h3{color:var(--fg)}
:root[data-theme="dark"] .empty p{color:#888}
:root[data-theme="dark"] .netpill{background:transparent;color:#888;border-color:var(--muted)}
:root[data-theme="dark"] .netpill.off{color:#ff6680;border-color:#ff6680;background:#2a1a1a}
:root[data-theme="dark"] .back:active{background:var(--surface)}
:root[data-theme="dark"] .pay-tile{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .pay-tile:active{background:var(--surface)}
:root[data-theme="dark"] .pay-tile .ic{background:var(--surface);color:var(--accent)}
:root[data-theme="dark"] .pay-tile.selected{border-color:var(--accent);background:#2a1a1a}
:root[data-theme="dark"] .pay-tile.selected .ic{background:var(--accent);color:#fff}
:root[data-theme="dark"] .cash-row input{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .cash-row input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
:root[data-theme="dark"] .cash-row input[readonly]{background:#222;color:#aaa}
:root[data-theme="dark"] .cash-row label{color:#888}
:root[data-theme="dark"] .quick button{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .quick button:active{background:var(--surface);border-color:#444}
:root[data-theme="dark"] .change-out{background:var(--surface);border-color:var(--border);color:var(--fg)}
:root[data-theme="dark"] .change-out .lbl{color:#888}
:root[data-theme="dark"] .cust-block input{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .cust-block input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
:root[data-theme="dark"] .cust-results{background:#181818;border-color:var(--muted)}
:root[data-theme="dark"] .cust-results div{border-bottom-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .cust-results div:active{background:var(--surface)}
:root[data-theme="dark"] .cust-results small{color:#888}
:root[data-theme="dark"] .cust-results .cust-count{background:#222;border-bottom-color:var(--muted);color:#888}
:root[data-theme="dark"] .cust-results .cust-none{color:#888}
:root[data-theme="dark"] .cust-picked{background:var(--surface);border-color:var(--border);color:var(--fg)}
:root[data-theme="dark"] .edit-sheet{background:#181818;box-shadow:0 20px 60px rgba(0,0,0,.6)}
:root[data-theme="dark"] .edit-sheet h3 small{color:#888}
:root[data-theme="dark"] .edit-row label{color:#888}
:root[data-theme="dark"] .edit-row input{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .edit-row input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
:root[data-theme="dark"] .edit-actions .secondary{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .edit-actions .secondary:active{background:var(--surface)}
:root[data-theme="dark"] .edit-actions .danger{background:#181818;border-color:#5a2429;color:#ff6680}
:root[data-theme="dark"] .edit-actions .danger:active{background:#2a1a1a}
:root[data-theme="dark"] .line-holding{background:var(--surface)}
:root[data-theme="dark"] .final-cta:disabled{background:#2a2a2a;color:#666}
@media (prefers-reduced-motion: reduce){*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}}
</style>
</head>
<body>
<header class="topbar" id="topbar">
<button class="back" data-od-id="back-btn" onclick="location.href='index.html'" aria-label="Back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<h1>New sale</h1>
<span class="netpill" id="netpill">…</span>
<button class="theme-btn" data-theme-toggle data-od-id="theme-toggle" aria-label="Toggle theme" title="Toggle theme" onclick="(window.WFTheme&&window.WFTheme.cycle())||(window.__wfCycle&&window.__wfCycle())">
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</header>
<div class="search">
<div class="search-box">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="q" type="search" placeholder="Search SKU or product" autocomplete="off" inputmode="search" enterkeyhint="search">
</div>
</div>
<div class="cat-row">
<button class="cat-trigger" id="catTrigger" data-od-id="cat-trigger" aria-haspopup="listbox" aria-expanded="false">
<span class="cat-label" id="catLabel">All categories</span>
<svg class="cat-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 9l6 6 6-6"/></svg>
</button>
<span class="cat-count" id="catCount">— products</span>
</div>
<div class="cat-menu" id="catMenu" role="listbox" hidden>
<div class="cat-menu-head" id="catMenuItems"></div>
</div>
<div class="cat-backdrop" id="catBackdrop" hidden></div>
<main class="catalog" id="catalog">
<div class="empty" id="empty">
<h3>Loading catalog…</h3>
<p>Fetching products from the register.</p>
</div>
</main>
<div class="cart-bar" id="cartBar" data-od-id="cart-bar">
<button class="back" style="width:44px;height:44px;border-radius:50%;background:var(--surface);border:1px solid var(--border)" onclick="openDrawer()" aria-label="View cart">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M3 6h2l2 12h12l2-9H6"/><circle cx="9" cy="20" r="1.4"/><circle cx="17" cy="20" r="1.4"/></svg>
</button>
<div class="count"><b id="cartCount">0</b><span>items</span></div>
<div class="total" id="cartTotal">R 0.00</div>
<button class="checkout" data-od-id="checkout-btn" onclick="openPayment()">
Charge
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
</button>
</div>
<!-- Cart drawer -->
<div class="drawer" id="cartDrawer">
<div class="sheet">
<div class="sheet-head">
<h2>Cart</h2>
<button class="x" onclick="closeDrawer()" aria-label="Close">
<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>
<div class="sheet-body" id="cartLines"></div>
<div class="sheet-foot">
<!-- The discount trigger rides inside the Subtotal row so the footer
costs no extra height. Tapping it expands the R / % pills, number
input and Apply button below; the discounted amount is then shown
in red on its own totals line, and the server re-derives the
resolved rand value at commit time. -->
<div class="totals">
<span>Subtotal
<button class="discount-trigger" id="discountTrigger" data-od-id="discount-trigger" onclick="toggleDiscount()">
<span id="discountTriggerLabel">Add discount</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 9l6 6 6-6"/></svg>
</button>
</span>
<b id="subTotal">R 0.00</b>
</div>
<div class="totals"><span>VAT 15%</span><b id="vatTotal">R 0.00</b></div>
<div class="discount-body" id="discountBody" hidden>
<div class="discount-pills" role="tablist">
<button class="discount-pill" id="discKindAmount" data-kind="amount" onclick="setDiscountKind('amount')">R off</button>
<button class="discount-pill" id="discKindPercent" data-kind="percent" onclick="setDiscountKind('percent')">% off</button>
</div>
<div class="discount-input-row">
<input id="discInput" type="text" inputmode="decimal" placeholder="0" autocomplete="off" data-od-id="discount-input">
<span class="discount-suffix" id="discSuffix">R</span>
<button class="discount-apply" data-od-id="discount-apply" onclick="applyDiscount()">Apply</button>
</div>
<p class="discount-hint" id="discHint">Enter a value and tap Apply.</p>
</div>
<div class="totals discount-line" id="discountLine" hidden>
<span>Discount</span><b id="discountLineAmount">− R 0.00</b>
</div>
<div class="totals grand"><span>Total (incl. VAT)</span><b id="grandTotal">R 0.00</b></div>
<button class="final-cta" data-od-id="cart-checkout" onclick="closeDrawer();openPayment()">
Take payment
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
</button>
</div>
</div>
</div>
<!-- Payment drawer -->
<div class="drawer" id="payDrawer">
<div class="sheet">
<div class="sheet-head">
<h2>Take payment</h2>
<button class="x" onclick="closePayment()" aria-label="Close">
<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>
<div class="sheet-body">
<div class="totals" style="padding:10px 20px 0"><span>Subtotal</span><b id="paySub">R 0.00</b></div>
<div class="totals" style="padding:0 20px"><span>VAT 15%</span><b id="payVat">R 0.00</b></div>
<div class="totals grand" style="padding:14px 20px"><span>Total due</span><b id="payTotal">R 0.00</b></div>
<div class="pay-grid" id="payGrid">
<button class="pay-tile" data-od-id="pay-cash" data-meth="cash" onclick="pickMethod('cash')">
<div><div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="6" width="18" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/></svg></div><div class="lab">Cash</div><div class="sub">Calculate change</div></div>
</button>
<button class="pay-tile" data-od-id="pay-card" data-meth="card" onclick="pickMethod('card')">
<div><div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M3 10h18"/></svg></div><div class="lab">Card</div><div class="sub">No change</div></div>
</button>
<button class="pay-tile" data-od-id="pay-eft" data-meth="eft" onclick="pickMethod('eft')">
<div><div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg></div><div class="lab">EFT</div><div class="sub">Instant transfer</div></div>
</button>
<button class="pay-tile" data-od-id="pay-account" data-meth="account" onclick="pickMethod('account')">
<div><div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/></svg></div><div class="lab">Account</div><div class="sub">Print delivery note</div></div>
</button>
</div>
<div class="cash-block" id="cashBlock">
<div class="cash-row">
<div class="field"><label>Total due</label><input id="dueIn" type="text" readonly tabindex="-1"></div>
<div class="field"><label>Cash given</label><input id="givenIn" type="text" inputmode="decimal" placeholder="0.00"></div>
</div>
<div class="quick" id="quick"></div>
<div class="change-out" id="changeOut">
<div class="lbl">Change to give</div>
<div class="amt" id="changeAmt">R 0.00</div>
</div>
</div>
<div class="cust-block" id="custBlock">
<div class="cust-picked" id="custPicked"><b></b><button onclick="clearCust()">Remove</button></div>
<div class="search-row">
<input id="custQ" placeholder="Search customer" autocomplete="off">
</div>
<div class="cust-results" id="custResults"></div>
</div>
</div>
<div style="padding:0 20px 18px">
<button class="final-cta" id="confirmBtn" data-od-id="confirm-pay" disabled onclick="confirmSale()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12l5 5 9-11"/></svg>
Complete sale
</button>
</div>
</div>
</div>
<!-- Edit line modal (long-press a cart row to open) -->
<div class="edit-backdrop" id="editBackdrop" onclick="closeEditLine()"></div>
<div class="edit-sheet" id="editSheet" role="dialog" aria-labelledby="editTitle" aria-modal="true">
<h3 id="editTitle">Edit line <small id="editSku"></small></h3>
<div class="edit-row">
<label for="editQty">Quantity</label>
<input id="editQty" type="text" inputmode="decimal" autocomplete="off" data-od-id="edit-qty">
</div>
<p class="discount-hint" id="editHint">Type a quantity like 800, then tap Save.</p>
<div class="edit-actions">
<button class="danger" data-od-id="edit-remove" onclick="editRemove()">Remove</button>
<button class="secondary" data-od-id="edit-cancel" onclick="closeEditLine()">Cancel</button>
<button class="primary" data-od-id="edit-save" onclick="editSave()">Save</button>
</div>
</div>
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="pos-search.js"></script>
<script src="pos-app.js"></script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>
</html>