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.
418 lines
22 KiB
HTML
418 lines
22 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<meta name="theme-color" content="#ff385c">
|
|
<title>Settings — WFSales POS</title>
|
|
<link rel="manifest" href="manifest.json">
|
|
<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:0 1px 2px rgba(34,34,34,.04), 0 4px 12px 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:16px/1.5 var(--font);-webkit-font-smoothing:antialiased}
|
|
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;padding:0 20px 32px;background:var(--bg)}
|
|
.topbar{display:flex;align-items:center;gap:10px;padding:14px 0 10px;background:var(--bg);position:sticky;top:0;z-index:10}
|
|
.topbar .back{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface);border:1px solid var(--border);flex:0 0 auto}
|
|
.topbar .back:active{background:var(--bg)}
|
|
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.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;flex:0 0 auto;white-space:nowrap}
|
|
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:#fff5f5}
|
|
.topbar .theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:transparent;border:0;cursor:pointer;flex:0 0 auto}
|
|
.topbar .theme-btn:active{background:var(--surface)}
|
|
.topbar .theme-btn svg{width:18px;height:18px}
|
|
.topbar .theme-btn .i-moon{display:none}
|
|
:root[data-theme="dark"] .topbar .theme-btn .i-sun{display:none}
|
|
:root[data-theme="dark"] .topbar .theme-btn .i-moon{display:block}
|
|
|
|
.lede{color:#717171;font-size:14px;line-height:1.55;margin:6px 0 16px;max-width:38ch}
|
|
|
|
/* ---- Active letterhead card ---- */
|
|
.active{padding:16px;border:1.5px solid var(--accent);border-radius:14px;background:#fff8fa;display:flex;align-items:center;gap:14px;box-shadow:var(--shadow)}
|
|
.active .logo{width:64px;height:64px;border-radius:10px;background:#fff;border:1px solid var(--border);display:grid;place-items:center;overflow:hidden;flex:0 0 auto}
|
|
.active .logo img{max-width:100%;max-height:100%;object-fit:contain}
|
|
.active .logo .placeholder{font-weight:700;font-size:22px;color:var(--accent)}
|
|
.active .body{flex:1;min-width:0}
|
|
.active .name{font-size:17px;font-weight:700;letter-spacing:-.01em;color:var(--fg);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.active .sub{font-size:12px;color:var(--ink-soft, #717171);margin-top:4px;letter-spacing:.01em;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
.active .pill{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--accent);color:#fff;padding:3px 8px;border-radius:999px}
|
|
.active .pill.muted{background:var(--surface);color:var(--fg);border:1px solid var(--border)}
|
|
|
|
/* ---- Letterhead list (for switching) ---- */
|
|
.section-label{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;margin:24px 4px 10px}
|
|
.lh-list{display:flex;flex-direction:column;gap:8px}
|
|
.lh-card{display:flex;align-items:center;gap:12px;padding:12px;border:1.5px solid var(--muted);border-radius:12px;background:#fff;text-align:left;width:100%;cursor:pointer;transition:border-color .12s, background .12s}
|
|
.lh-card:active{background:var(--surface)}
|
|
.lh-card.selected{border-color:var(--accent);background:#fff8fa}
|
|
.lh-card .logo{width:48px;height:48px;border-radius:8px;background:#fff;border:1px solid var(--border);display:grid;place-items:center;overflow:hidden;flex:0 0 auto}
|
|
.lh-card .logo img{max-width:100%;max-height:100%;object-fit:contain}
|
|
.lh-card .logo .placeholder{font-weight:700;font-size:16px;color:var(--accent)}
|
|
.lh-card .body{flex:1;min-width:0}
|
|
.lh-card .name{font-size:15px;font-weight:600;letter-spacing:-.01em;color:var(--fg);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.lh-card .meta{font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em}
|
|
.lh-card .check{width:18px;height:18px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center;flex:0 0 auto;font-size:11px;font-weight:700;line-height:1}
|
|
.lh-card:not(.selected) .check{background:transparent;border:1.5px solid var(--muted);color:transparent}
|
|
|
|
/* ---- Actions ---- */
|
|
.actions{padding:0;display:flex;flex-direction:column;gap:10px;margin-top:24px}
|
|
.btn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:54px;padding:0 18px;border-radius:12px;font-size:15px;font-weight:600;letter-spacing:-.005em;border:1.5px solid var(--muted);background:#fff;color:var(--fg)}
|
|
.btn:active{background:var(--surface)}
|
|
.btn.primary{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:0 4px 14px rgba(255,56,92,.25)}
|
|
.btn.primary:active{background:#e5324f;box-shadow:none}
|
|
.btn svg{width:18px;height:18px;stroke-width:1.7}
|
|
.btn:disabled{opacity:.5;pointer-events:none}
|
|
|
|
/* ---- Empty / error states ---- */
|
|
.banner{padding:12px 14px;border-radius:12px;font-size:13px;display:flex;align-items:flex-start;gap:10px;line-height:1.4}
|
|
.banner.warn{background:#fdf2e6;border:1px solid #f5d9b3;color:#8a4a00}
|
|
.banner.err{background:#fff0f0;border:1px solid #f5c2c7;color:#9b1c1c}
|
|
.banner .ic{flex:0 0 auto;width:16px;height:16px;margin-top:1px}
|
|
.banner .t{flex:1;min-width:0}
|
|
.skeleton{padding:24px 16px;text-align:center;color:#999;font-size:13px}
|
|
|
|
/* ---- Dark theme ---- */
|
|
@media (prefers-color-scheme: dark){
|
|
:root:not([data-theme="light"]){
|
|
--bg:#161619;--surface:#1e1e23;--fg:#f2f2f5;--muted:#303039;--border:#3b3b46;
|
|
}
|
|
:root:not([data-theme="light"]) body{background:var(--bg);color:var(--fg)}
|
|
:root:not([data-theme="light"]) .topbar{background:var(--bg)}
|
|
:root:not([data-theme="light"]) .lede{color:#a0a0a0}
|
|
:root:not([data-theme="light"]) .topbar .back{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
|
:root:not([data-theme="light"]) .topbar .netpill{color:#a0a0a0;border-color:var(--muted)}
|
|
:root:not([data-theme="light"]) .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:#2a1a1a}
|
|
:root:not([data-theme="light"]) .topbar .theme-btn{color:var(--fg)}
|
|
:root:not([data-theme="light"]) .section-label{color:#a0a0a0}
|
|
:root:not([data-theme="light"]) .active{background:#2a1a1a;border-color:#ff385c}
|
|
:root:not([data-theme="light"]) .active .logo{background:var(--surface);border-color:var(--muted)}
|
|
:root:not([data-theme="light"]) .active .name{color:var(--fg)}
|
|
:root:not([data-theme="light"]) .lh-card{background:var(--surface);border-color:var(--muted);color:var(--fg)}
|
|
:root:not([data-theme="light"]) .lh-card:active{background:var(--surface)}
|
|
:root:not([data-theme="light"]) .lh-card.selected{background:#2a1a1a;border-color:#ff385c}
|
|
:root:not([data-theme="light"]) .lh-card .logo{background:var(--surface);border-color:var(--muted)}
|
|
:root:not([data-theme="light"]) .lh-card .meta{color:#a0a0a0}
|
|
:root:not([data-theme="light"]) .lh-card:not(.selected) .check{border-color:var(--muted)}
|
|
:root:not([data-theme="light"]) .btn{background:var(--surface);border-color:var(--muted);color:var(--fg)}
|
|
:root:not([data-theme="light"]) .btn:active{background:var(--surface)}
|
|
:root:not([data-theme="light"]) .banner.warn{background:#2a1a1a;border-color:#5a3a1a;color:#ffb060}
|
|
:root:not([data-theme="light"]) .banner.err{background:#2a1a1a;border-color:#5a2429;color:#ff6680}
|
|
:root:not([data-theme="light"]) .skeleton{color:#666}
|
|
}
|
|
:root[data-theme="dark"]{
|
|
--bg:#161619;--surface:#1e1e23;--fg:#f2f2f5;--muted:#303039;--border:#3b3b46;
|
|
background:var(--bg);color:var(--fg);
|
|
}
|
|
:root[data-theme="dark"] body{background:var(--bg);color:var(--fg)}
|
|
:root[data-theme="dark"] .topbar{background:var(--bg)}
|
|
:root[data-theme="dark"] .lede{color:#a0a0a0}
|
|
:root[data-theme="dark"] .topbar .back{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
|
:root[data-theme="dark"] .topbar .netpill{color:#a0a0a0;border-color:var(--muted)}
|
|
:root[data-theme="dark"] .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:#2a1a1a}
|
|
:root[data-theme="dark"] .topbar .theme-btn{color:var(--fg)}
|
|
:root[data-theme="dark"] .section-label{color:#a0a0a0}
|
|
:root[data-theme="dark"] .active{background:#2a1a1a;border-color:#ff385c}
|
|
:root[data-theme="dark"] .active .logo{background:var(--surface);border-color:var(--muted)}
|
|
:root[data-theme="dark"] .active .name{color:var(--fg)}
|
|
:root[data-theme="dark"] .lh-card{background:var(--surface);border-color:var(--muted);color:var(--fg)}
|
|
:root[data-theme="dark"] .lh-card:active{background:var(--surface)}
|
|
:root[data-theme="dark"] .lh-card.selected{background:#2a1a1a;border-color:#ff385c}
|
|
:root[data-theme="dark"] .lh-card .logo{background:var(--surface);border-color:var(--muted)}
|
|
:root[data-theme="dark"] .lh-card .meta{color:#a0a0a0}
|
|
:root[data-theme="dark"] .lh-card:not(.selected) .check{border-color:var(--muted)}
|
|
:root[data-theme="dark"] .btn{background:var(--surface);border-color:var(--muted);color:var(--fg)}
|
|
:root[data-theme="dark"] .btn:active{background:var(--surface)}
|
|
:root[data-theme="dark"] .banner.warn{background:#2a1a1a;border-color:#5a3a1a;color:#ffb060}
|
|
:root[data-theme="dark"] .banner.err{background:#2a1a1a;border-color:#5a2429;color:#ff6680}
|
|
:root[data-theme="dark"] .skeleton{color:#666}
|
|
:root[data-theme="dark"] .preview-modal{background:#1a1416}
|
|
:root[data-theme="dark"] .preview-modal .x{background:var(--surface)}
|
|
|
|
/* ---- Slip preview tabs ---- */
|
|
.preview-tabs{display:flex;gap:6px;padding:4px 4px 10px}
|
|
.preview-tab{flex:1;padding:8px 10px;border:1px solid var(--muted);border-radius:10px;background:#fff;font-size:13px;font-weight:600;color:var(--fg);transition:background .12s, color .12s, border-color .12s}
|
|
.preview-tab:active{background:var(--surface)}
|
|
.preview-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
|
/* ---- Letterhead card — clickable to open the slip preview modal ---- */
|
|
.active .body{cursor:pointer;-webkit-tap-highlight-color:transparent}
|
|
.active .body:active{opacity:.85}
|
|
.active .body .name{text-decoration:underline;text-decoration-color:rgba(255,56,92,.3);text-underline-offset:3px}
|
|
.preview-note{margin:6px 4px 0;font-size:11px;color:#888;letter-spacing:.01em}
|
|
|
|
/* ---- Slip preview modal ---- */
|
|
.preview-modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:80;opacity:0;pointer-events:none;transition:opacity .15s}
|
|
.preview-modal-backdrop.show{opacity:1;pointer-events:auto}
|
|
.preview-modal{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(.96);background:var(--bg);border-radius:16px;padding:20px 20px 18px;width:calc(100% - 32px);max-width:440px;max-height:88dvh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .15s, transform .2s cubic-bezier(.2,.8,.2,1);z-index:81}
|
|
.preview-modal-backdrop.show .preview-modal{opacity:1;transform:translate(-50%,-50%) scale(1);pointer-events:auto}
|
|
.preview-modal h2{font-size:16px;font-weight:700;letter-spacing:-.01em;margin:0 0 4px}
|
|
.preview-modal .modal-sub{font-size:12px;color:var(--ink-soft,#717171);margin:0 0 14px}
|
|
.preview-modal .x{position:absolute;top:10px;right:10px;width:32px;height:32px;border-radius:50%;background:var(--surface);display:grid;place-items:center;color:var(--fg);border:0;cursor:pointer}
|
|
.preview-modal .x:active{background:var(--bg)}
|
|
|
|
/* ---- Receipt preview pane ---- */
|
|
.receipt-preview{
|
|
/* `monospace` is the generic CSS family — every browser maps it
|
|
to a real monospace font (Menlo/SF Mono on macOS, Consolas on
|
|
Windows, Roboto Mono / DejaVu on Linux/Android). The previous
|
|
"SF Mono,Menlo,Consolas" stack fell back to a proportional
|
|
font on this system and broke the right-column alignment. */
|
|
font-family:monospace;
|
|
font-size:10px;
|
|
line-height:1.35;
|
|
background:#fdfdfa;
|
|
color:#1a1a1a;
|
|
border:1px solid #999;
|
|
border-radius:4px;
|
|
padding:10px 10px;
|
|
/* 24 chars * ~10px + 20px padding = 260px. Anything wider and we
|
|
scroll horizontally instead of overflowing. */
|
|
width:260px;
|
|
max-width:100%;
|
|
margin:0 auto 4px;
|
|
box-shadow:0 2px 6px rgba(0,0,0,.08);
|
|
white-space:pre;
|
|
overflow-x:auto;
|
|
overflow-wrap:normal;
|
|
word-break:keep-all;
|
|
/* Mimic thermal paper: slightly off-white, ragged-edge look */
|
|
position:relative;
|
|
}
|
|
.receipt-preview::before, .receipt-preview::after{
|
|
content:'';
|
|
position:absolute;
|
|
left:0; right:0;
|
|
height:6px;
|
|
background:radial-gradient(circle at 3px 0, #fff 0 1.5px, transparent 1.5px) repeat-x;
|
|
background-size:6px 6px;
|
|
}
|
|
.receipt-preview::before{ top:-3px; }
|
|
.receipt-preview::after{ bottom:-3px; transform:scaleY(-1); }
|
|
.receipt-preview .center{text-align:center;display:block}
|
|
.receipt-preview .row{display:flex;justify-content:space-between;gap:8px}
|
|
.receipt-preview .grow{flex:1}
|
|
.receipt-preview .right{text-align:right;flex:0 0 auto}
|
|
.receipt-preview .dim{color:#666}
|
|
.receipt-preview .em{font-weight:700;color:#000}
|
|
.receipt-preview .total{font-weight:700}
|
|
.receipt-preview .divider{border-top:1px dashed #999;margin:6px 0;height:0}
|
|
.receipt-preview .signoff{text-align:center;color:#666;margin-top:6px}
|
|
.receipt-preview .empty{text-align:center;color:#999;padding:30px 0;font-family:inherit;white-space:normal}
|
|
|
|
/* The preview pane is intentionally light regardless of the till's
|
|
theme — thermal paper is always light. The wrapper around it still
|
|
follows the theme. */
|
|
|
|
@media (prefers-reduced-motion: reduce){*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header class="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>Settings</h1>
|
|
<span class="netpill" id="netpill" data-od-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>
|
|
|
|
<p class="lede">This till runs under the selected letterhead. Slips and receipts will use its name, address, VAT rate, and logo.</p>
|
|
|
|
<div id="activeBlock" class="skeleton">Loading…</div>
|
|
|
|
<div class="section-label">Switch letterhead</div>
|
|
<div class="lh-list" id="lhList"></div>
|
|
|
|
<div class="section-label">Slip layout</div>
|
|
<button class="lh-card" style="width:100%" onclick="location.href='slip-layout.html'" type="button">
|
|
<div class="body">
|
|
<div class="name">Edit slip layout</div>
|
|
<div class="meta">Choose what prints on each slip, in what order</div>
|
|
</div>
|
|
</button>
|
|
|
|
<div id="banner" hidden></div>
|
|
|
|
<div class="actions">
|
|
<button class="btn primary" data-od-id="test-print" onclick="testPrint()">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 9V3h12v6M6 18H4v-7h16v7h-2M6 14h12v7H6z"/></svg>
|
|
Test print
|
|
</button>
|
|
<button class="btn" data-od-id="done-btn" onclick="location.href='index.html'">
|
|
Done
|
|
</button>
|
|
</div>
|
|
|
|
<script src="pos-config.js"></script>
|
|
<script src="theme.js"></script>
|
|
<script src="letterhead-config.js"></script>
|
|
<script src="slip-layout.js"></script>
|
|
<script src="epos-print.js"></script>
|
|
<script>
|
|
'use strict';
|
|
|
|
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);}
|
|
function fmtVat(n){return (Number(n)||0).toString().replace(/\.0$/,'') + '% VAT';}
|
|
function initialsOf(name){
|
|
const s = String(name||'').trim();
|
|
if(!s) return '?';
|
|
return s.split(/\s+/).slice(0,2).map(w => w[0]||'').join('').toUpperCase();
|
|
}
|
|
function logoHtml(item, klass){
|
|
if(item && item.has_logo && item.id){
|
|
// We don't have the full logo URL on the list view (it lives on
|
|
// the full record). The list cards have a placeholder; once the
|
|
// till picks one and we refetch the full record, the active card
|
|
// shows the real logo.
|
|
return '<div class="logo '+klass+'"><span class="placeholder">'+esc(initialsOf(item.display_name))+'</span></div>';
|
|
}
|
|
return '<div class="logo '+klass+'"><span class="placeholder">'+esc(initialsOf(item.display_name))+'</span></div>';
|
|
}
|
|
|
|
function paintNet(){
|
|
const p = document.getElementById('netpill');
|
|
if(!p) return;
|
|
p.textContent = navigator.onLine ? 'Online' : 'Offline';
|
|
p.classList.toggle('off', !navigator.onLine);
|
|
}
|
|
window.addEventListener('online', paintNet);
|
|
window.addEventListener('offline', paintNet);
|
|
paintNet();
|
|
|
|
function showBanner(kind, text){
|
|
const el = document.getElementById('banner');
|
|
if(!text){ el.hidden = true; el.innerHTML = ''; return; }
|
|
el.hidden = false;
|
|
el.innerHTML = '<div class="banner '+kind+'"><span class="ic">'+(kind==='err'
|
|
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg>'
|
|
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4M12 17v.5M3 18l9-15 9 15z"/></svg>'
|
|
)+'</span><span class="t">'+esc(text)+'</span></div>';
|
|
}
|
|
|
|
function paintActive(item){
|
|
const el = document.getElementById('activeBlock');
|
|
if(!item){
|
|
el.className = 'banner err';
|
|
el.innerHTML = '<span class="ic">'
|
|
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg>'
|
|
+ '</span><span class="t">No active letterhead. Pick one below to start ringing up sales.</span>';
|
|
return;
|
|
}
|
|
el.className = 'active';
|
|
const logo = item.logo_url
|
|
? '<img src="'+esc(item.logo_url)+'" alt="">'
|
|
: '<span class="placeholder">'+esc(initialsOf(item.display_name))+'</span>';
|
|
const vatPill = item.vat_percent > 0
|
|
? '<span class="pill">'+esc(fmtVat(item.vat_percent))+'</span>'
|
|
: '<span class="pill muted">No VAT</span>';
|
|
const acc = item.accent_color
|
|
? '<span class="pill muted" style="color:'+esc(item.accent_color)+';border-color:'+esc(item.accent_color)+'">Accent '+esc(item.accent_color)+'</span>'
|
|
: '';
|
|
el.innerHTML = ''
|
|
+ '<div class="logo">'+logo+'</div>'
|
|
+ '<div class="body">'
|
|
+ '<div class="name">'+esc(item.display_name)+'</div>'
|
|
+ '<div class="sub">'
|
|
+ '<span>'+esc(item.company || '')+'</span>'
|
|
+ vatPill
|
|
+ acc
|
|
+ '</div>'
|
|
+ '</div>';
|
|
}
|
|
|
|
function paintList(list, activeId){
|
|
const wrap = document.getElementById('lhList');
|
|
if(!list.length){
|
|
wrap.innerHTML = '<div class="skeleton">No active letterheads configured in PocketBase.</div>';
|
|
return;
|
|
}
|
|
wrap.innerHTML = list.map(item => {
|
|
const isSel = item.id === activeId;
|
|
const meta = [
|
|
item.vat_percent > 0 ? fmtVat(item.vat_percent) : 'No VAT',
|
|
item.accent_color ? 'Accent ' + item.accent_color : null,
|
|
].filter(Boolean).join(' · ');
|
|
return ''
|
|
+ '<button type="button" class="lh-card'+(isSel ? ' selected' : '')+'" data-id="'+esc(item.id)+'" onclick="pickLetterhead(\''+esc(item.id)+'\')">'
|
|
+ logoHtml(item, '')
|
|
+ '<div class="body">'
|
|
+ '<div class="name">'+esc(item.display_name)+'</div>'
|
|
+ '<div class="meta">'+esc(meta)+'</div>'
|
|
+ '</div>'
|
|
+ '<div class="check">✓</div>'
|
|
+ '</button>';
|
|
}).join('');
|
|
}
|
|
|
|
async function pickLetterhead(id){
|
|
showBanner(null);
|
|
try{
|
|
const item = await window.PosLetterhead.fetchById(id);
|
|
paintActive(item);
|
|
// Re-render the list so the new "selected" check shows up.
|
|
const list = window.PosLetterhead.getCachedList();
|
|
paintList(list, item.id);
|
|
}catch(e){
|
|
showBanner('err', e.message || 'Could not pick letterhead');
|
|
}
|
|
}
|
|
|
|
async function testPrint(){
|
|
showBanner(null);
|
|
const btn = document.querySelector('[data-od-id="test-print"]');
|
|
if(btn) btn.disabled = true;
|
|
try{
|
|
const letterhead = await window.PosLetterhead.ensureActive();
|
|
if(!letterhead){
|
|
showBanner('warn', 'Pick a letterhead first.');
|
|
return;
|
|
}
|
|
const where = await window.EposPrint.printTest(letterhead);
|
|
showBanner(null); // success — no banner
|
|
if(btn){
|
|
// Briefly flash the success into the button label.
|
|
const orig = btn.textContent;
|
|
btn.textContent = '✓ ' + where;
|
|
setTimeout(() => { btn.textContent = orig; }, 1800);
|
|
}
|
|
}catch(e){
|
|
showBanner('err', e.message || 'Test print failed');
|
|
}finally{
|
|
if(btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function boot(){
|
|
const wrap = document.getElementById('activeBlock');
|
|
wrap.textContent = 'Loading…';
|
|
let active = null;
|
|
try{
|
|
active = await window.PosLetterhead.ensureActive();
|
|
}catch(e){
|
|
showBanner('err', 'Could not load letterhead: ' + (e.message || e));
|
|
}
|
|
paintActive(active);
|
|
// Try to populate the letterhead picker list.
|
|
let list = window.PosLetterhead.getCachedList();
|
|
if(!list.length){
|
|
try{ list = await window.PosLetterhead.fetchList(); }
|
|
catch(e){
|
|
document.getElementById('lhList').innerHTML = '<div class="skeleton">Letterhead list unavailable offline. Reconnect to set up.</div>';
|
|
return;
|
|
}
|
|
}
|
|
paintList(list, active ? active.id : null);
|
|
}
|
|
|
|
boot();
|
|
</script>
|
|
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
|
|
</body>
|
|
</html>
|