The Include-prices toggle drives both the on-screen slip and the Bluetooth thermal print, so what prints matches what the operator saw. Co-Authored-By: Claude <noreply@anthropic.com>
624 lines
36 KiB
HTML
624 lines
36 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>Receipt</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{
|
||
/* Brand palette — Airbnb-inspired Rausch system */
|
||
--bg:#ffffff;--surface:#fff7f8;--surface-2:#f7f7f7;--fg:#222222;--muted:#dddddd;--border:#ffe3e8;
|
||
--ink:#222222;--ink-soft:#717171;--ink-faint:#999999;
|
||
--accent:#ff385c;--accent-soft:#fff5f5;--accent-2:#ffd9df;--accent-hover:#e5324f;--accent-soft-hover:#ffeef1;
|
||
--surface-card:#ffffff;
|
||
/* Semantic */
|
||
--ok:#0f8a3f;--ok-soft:#e8f5ec;--warn:#b14a00;--danger:#c92a2a;--danger-soft:#fff0f0;--danger-2:#ffd0d6;
|
||
--shadow:0 1px 2px rgba(34,34,34,.04), 0 8px 24px rgba(34,34,34,.06);
|
||
--focus-ring:0 0 0 3px rgba(255,56,92,.30);
|
||
--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;overflow-x:hidden}
|
||
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;padding-bottom:env(safe-area-inset-bottom);background:var(--bg)}
|
||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||
button:focus-visible{outline:0;box-shadow:var(--focus-ring);border-radius:14px}
|
||
.btn:focus-visible{box-shadow:var(--focus-ring)}
|
||
.topbar .back:focus-visible,.topbar .theme-btn:focus-visible{box-shadow:var(--focus-ring);border-radius:50%}
|
||
input,select{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}
|
||
.topbar .back{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface-card);border:1px solid var(--muted)}
|
||
.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:var(--ink-soft);padding:4px 8px;border:1px solid var(--muted);border-radius:999px;background:var(--surface-card)}
|
||
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:var(--danger-soft)}
|
||
.theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface-card);border:1px solid var(--muted);cursor:pointer;flex:0 0 auto}
|
||
.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}
|
||
|
||
/* ---- Layout ---- */
|
||
.content{flex:1;padding:8px 16px calc(28px + env(safe-area-inset-bottom));display:flex;flex-direction:column;align-items:stretch;gap:14px}
|
||
|
||
/* ---- Success (restrained — white card, not full-bleed green) ---- */
|
||
.success{display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--surface-card);border:1px solid var(--muted);border-radius:14px;box-shadow:var(--shadow)}
|
||
.success .check{width:36px;height:36px;border-radius:50%;background:var(--ok-soft);color:var(--ok);display:grid;place-items:center;flex:0 0 auto}
|
||
.success .check svg{width:20px;height:20px;stroke-width:2.6}
|
||
.success .txt{display:flex;flex-direction:column;line-height:1.2;min-width:0;flex:1}
|
||
.success .h{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--fg)}
|
||
.success .s{font-size:13px;color:var(--ink-soft);margin-top:2px;letter-spacing:.01em}
|
||
.success .synced-flag{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;background:var(--ok-soft);color:var(--ok);padding:3px 8px;border-radius:999px;flex:0 0 auto;align-self:center;display:inline-flex;align-items:center;gap:5px}
|
||
.success .synced-flag::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ok)}
|
||
|
||
/* ---- Card (the receipt itself) ---- */
|
||
.receipt{background:var(--surface-card);border:1px solid var(--muted);border-radius:16px;box-shadow:var(--shadow);overflow:hidden}
|
||
.r-head{display:flex;align-items:center;gap:12px;padding:18px 18px 14px;border-bottom:1px solid var(--muted)}
|
||
.r-head .ic{width:40px;height:40px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center;flex:0 0 auto}
|
||
.r-head .ic svg{width:22px;height:22px;stroke-width:1.8}
|
||
.r-head .nm{flex:1;min-width:0;line-height:1.25;min-width:0}
|
||
.r-head .nm .biz{font-size:17px;font-weight:700;letter-spacing:-.01em;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.r-head .nm .addr{font-size:12px;color:var(--ink-soft);letter-spacing:.01em;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.r-head .nm .vat{font-size:11px;color:var(--ink-soft);letter-spacing:.04em;margin-top:3px}
|
||
|
||
.r-body{padding:6px 18px 18px}
|
||
.r-meta{display:grid;grid-template-columns:1fr 1fr;gap:14px 18px;padding:14px 0;border-bottom:1px solid var(--muted)}
|
||
.r-meta .k{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:4px}
|
||
.r-meta .v{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
|
||
.r-cust{margin-top:14px;padding:12px 14px;background:var(--surface-2);border-radius:10px;display:flex;align-items:center;gap:10px}
|
||
.r-cust .lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);flex:0 0 auto}
|
||
.r-cust .nm{font-size:14px;font-weight:600;letter-spacing:-.005em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
|
||
.r-items{margin-top:14px}
|
||
.r-item{display:flex;align-items:flex-start;gap:10px;padding:12px 0;border-bottom:1px solid var(--muted)}
|
||
.r-item:last-child{border-bottom:0}
|
||
.r-item .qty{font-size:13px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;min-width:34px;flex:0 0 34px;padding-top:1px}
|
||
.r-item .body{flex:1;min-width:0}
|
||
.r-item .name{font-size:14px;font-weight:500;letter-spacing:-.005em;line-height:1.35;color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||
.r-item .meta{font-size:12px;color:var(--ink-soft);margin-top:3px;letter-spacing:.02em;display:flex;gap:6px;flex-wrap:wrap}
|
||
.r-item .meta .sep{color:var(--ink-faint)}
|
||
.r-item .pr{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap;padding-top:1px}
|
||
|
||
.r-totals{margin-top:14px;padding:14px 0 0;border-top:1px solid var(--muted)}
|
||
.r-row{display:flex;justify-content:space-between;align-items:baseline;padding:5px 0;font-size:14px;color:var(--ink-soft);letter-spacing:.01em;gap:8px}
|
||
.r-row .l{display:flex;align-items:baseline;gap:8px}
|
||
.r-row .b{color:var(--fg);font-weight:600;font-variant-numeric:tabular-nums}
|
||
.r-row.grand{padding-top:14px;margin-top:8px;border-top:1px solid var(--muted);color:var(--fg);font-size:18px;font-weight:700;letter-spacing:-.01em}
|
||
.r-row.grand .b{color:var(--fg);font-size:22px;font-weight:700}
|
||
.r-row .pill{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:999px;background:var(--surface);color:var(--fg);border:1px solid var(--border)}
|
||
.r-row.change .b{color:var(--ok)}
|
||
|
||
.r-foot{text-align:center;padding:14px 18px 16px;border-top:1px dashed var(--muted);font-size:12px;color:var(--ink-soft);letter-spacing:.04em;line-height:1.55;white-space:pre-line}
|
||
|
||
/* ---- Delivery note ---- */
|
||
.dn{background:var(--surface-card);border:1.5px dashed var(--accent);border-radius:16px;box-shadow:var(--shadow);padding:18px}
|
||
.dn-head{display:flex;align-items:center;gap:10px;margin-bottom:14px}
|
||
.dn-head .ic{width:36px;height:36px;border-radius:10px;background:var(--accent-soft);border:1px solid var(--accent-2);color:var(--accent);display:grid;place-items:center;flex:0 0 auto}
|
||
.dn-head .ic svg{width:20px;height:20px;stroke-width:1.8}
|
||
.dn-head .nm{flex:1;min-width:0;line-height:1.2}
|
||
.dn-head .nm .t{font-size:11px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);margin-bottom:2px}
|
||
.dn-head .nm .s{font-size:15px;font-weight:600;letter-spacing:-.005em;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.dn-meta{display:grid;grid-template-columns:1fr 1fr;gap:10px 14px;padding:12px;background:var(--surface-2);border-radius:10px;margin-bottom:14px}
|
||
.dn-meta .k{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:3px}
|
||
.dn-meta .v{font-size:13px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.dn-items{margin-bottom:14px}
|
||
.dn-line{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid var(--muted);font-size:13px;letter-spacing:-.005em;color:var(--fg)}
|
||
.dn-line:last-child{border-bottom:0}
|
||
.dn-line .nm{flex:1;min-width:0;font-weight:500;line-height:1.35}
|
||
.dn-line .qt{font-size:12px;color:var(--ink-soft);letter-spacing:.02em;white-space:nowrap;padding-top:2px;font-variant-numeric:tabular-nums}
|
||
.dn-line .pr{font-size:13px;font-weight:600;color:var(--fg);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:76px;text-align:right}
|
||
.dn-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-soft);margin:10px 0;padding:0 2px}
|
||
.dn-toggle input{width:16px;height:16px}
|
||
@media print{.dn-toggle{display:none}}
|
||
.dn-sign{display:grid;grid-template-columns:1fr 1fr;gap:24px;padding-top:18px;margin-top:6px;border-top:1px dashed var(--muted)}
|
||
.dn-sign > div{padding-top:8px;border-top:1.5px solid var(--fg);font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-soft);text-align:center}
|
||
|
||
/* ---- Actions ---- */
|
||
.actions{display:flex;flex-direction:column;gap:10px;padding:18px 0 4px;margin-top:auto}
|
||
.btn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:54px;padding:0 18px;border-radius:14px;font-size:16px;font-weight:600;letter-spacing:-.005em;border:1.5px solid var(--muted);background:var(--surface-card);color:var(--fg);transition:transform .12s ease, background .12s ease}
|
||
.btn:active{transform:translateY(1px);background:var(--surface)}
|
||
.btn:disabled{opacity:.5;pointer-events:none}
|
||
.btn svg{width:20px;height:20px;stroke-width:1.8}
|
||
.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:var(--accent-hover);box-shadow:none}
|
||
.btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:var(--accent)}
|
||
.btn.acct:active{background:var(--accent-soft-hover)}
|
||
|
||
/* ---- Status banner ---- */
|
||
.status{padding:12px 14px;border-radius:12px;background:var(--surface);border:1px solid var(--border);font-size:13px;display:flex;align-items:center;gap:10px;color:var(--fg);line-height:1.4}
|
||
.empty-state{padding:48px 18px;text-align:center;color:var(--ink-soft)}
|
||
.empty-state h3{font-size:16px;font-weight:600;color:var(--fg);margin-bottom:6px;letter-spacing:-.01em}
|
||
.empty-state p{font-size:13px;line-height:1.5}
|
||
.status.err{background:var(--danger-soft);border-color:var(--danger-2);color:var(--danger)}
|
||
.status .ic{flex:0 0 auto}
|
||
.status svg{width:18px;height:18px;stroke-width:1.7}
|
||
.status .t{flex:1;min-width:0}
|
||
|
||
/* ---- Dark theme ---- */
|
||
@media (prefers-color-scheme: dark){
|
||
:root:not([data-theme="light"]){
|
||
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
|
||
--ink:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||
--surface-card:#181818;--accent-soft:#2a1a1a;--accent-2:#3a2429;--accent-soft-hover:#3a2429;
|
||
--ok-soft:#1a2e22;--danger-soft:#2a1a1a;--danger-2:#3a2429;
|
||
--focus-ring:0 0 0 3px rgba(255,102,128,.35);
|
||
}
|
||
:root:not([data-theme="light"]) body{background:var(--bg)}
|
||
:root:not([data-theme="light"]) .topbar{background:var(--bg)}
|
||
:root:not([data-theme="light"]) .topbar .back{background:var(--surface-card);border-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||
:root:not([data-theme="light"]) .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .success{background:var(--surface-card);border-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .success .check{background:var(--ok-soft);color:#5fc77c}
|
||
:root:not([data-theme="light"]) .success .synced-flag{background:var(--ok-soft);color:#5fc77c}
|
||
:root:not([data-theme="light"]) .success .synced-flag::before{background:#5fc77c}
|
||
:root:not([data-theme="light"]) .receipt{background:var(--surface-card);border-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-head{border-bottom-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-head .nm .biz{color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-head .nm .addr,:root:not([data-theme="light"]) .r-head .nm .vat{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-meta{border-bottom-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-meta .k{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-meta .v{color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-cust{background:var(--surface-2)}
|
||
:root:not([data-theme="light"]) .r-cust .lbl{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-item{border-bottom-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-item .name{color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-item .meta{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-totals{border-top-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-row{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-row .b{color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-row .pill{background:var(--surface);color:var(--fg);border-color:var(--border)}
|
||
:root:not([data-theme="light"]) .r-row.grand{border-top-color:var(--muted);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-row.change .b{color:#5fc77c}
|
||
:root:not([data-theme="light"]) .r-foot{border-top-color:var(--muted);color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .dn{background:var(--surface-card);border-color:#ff6680}
|
||
:root:not([data-theme="light"]) .dn-head .ic{background:var(--accent-soft);border-color:var(--accent-2)}
|
||
:root:not([data-theme="light"]) .dn-meta{background:var(--surface-2)}
|
||
:root:not([data-theme="light"]) .dn-meta .k{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .dn-line{border-bottom-color:var(--muted);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .dn-line .qt{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .dn-sign{border-top-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .dn-sign > div{border-top-color:var(--fg);color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .status{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .status.err{background:var(--danger-soft);border-color:var(--danger-2);color:#ff8a9e}
|
||
:root:not([data-theme="light"]) .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .btn:active{background:var(--surface)}
|
||
:root:not([data-theme="light"]) .btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
|
||
:root:not([data-theme="light"]) .btn.acct:active{background:var(--accent-soft-hover)}
|
||
}
|
||
:root[data-theme="dark"]{
|
||
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
|
||
--ink:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||
--surface-card:#181818;--accent-soft:#2a1a1a;--accent-2:#3a2429;--accent-soft-hover:#3a2429;
|
||
--ok-soft:#1a2e22;--danger-soft:#2a1a1a;--danger-2:#3a2429;
|
||
--focus-ring:0 0 0 3px rgba(255,102,128,.35);
|
||
}
|
||
:root[data-theme="dark"] body{background:var(--bg)}
|
||
:root[data-theme="dark"] .topbar{background:var(--bg)}
|
||
:root[data-theme="dark"] .topbar .back{background:var(--surface-card);border-color:var(--muted)}
|
||
:root[data-theme="dark"] .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||
:root[data-theme="dark"] .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||
:root[data-theme="dark"] .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||
:root[data-theme="dark"] .success{background:var(--surface-card);border-color:var(--muted)}
|
||
:root[data-theme="dark"] .success .check{background:var(--ok-soft);color:#5fc77c}
|
||
:root[data-theme="dark"] .success .synced-flag{background:var(--ok-soft);color:#5fc77c}
|
||
:root[data-theme="dark"] .success .synced-flag::before{background:#5fc77c}
|
||
:root[data-theme="dark"] .receipt{background:var(--surface-card);border-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-head{border-bottom-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-head .nm .biz{color:var(--fg)}
|
||
:root[data-theme="dark"] .r-head .nm .addr,:root[data-theme="dark"] .r-head .nm .vat{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-meta{border-bottom-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-meta .k{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-meta .v{color:var(--fg)}
|
||
:root[data-theme="dark"] .r-cust{background:var(--surface-2)}
|
||
:root[data-theme="dark"] .r-cust .lbl{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-item{border-bottom-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-item .name{color:var(--fg)}
|
||
:root[data-theme="dark"] .r-item .meta{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-totals{border-top-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-row{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-row .b{color:var(--fg)}
|
||
:root[data-theme="dark"] .r-row .pill{background:var(--surface);color:var(--fg);border-color:var(--border)}
|
||
:root[data-theme="dark"] .r-row.grand{border-top-color:var(--muted);color:var(--fg)}
|
||
:root[data-theme="dark"] .r-row.change .b{color:#5fc77c}
|
||
:root[data-theme="dark"] .r-foot{border-top-color:var(--muted);color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .dn{background:var(--surface-card);border-color:#ff6680}
|
||
:root[data-theme="dark"] .dn-head .ic{background:var(--accent-soft);border-color:var(--accent-2)}
|
||
:root[data-theme="dark"] .dn-meta{background:var(--surface-2)}
|
||
:root[data-theme="dark"] .dn-meta .k{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .dn-line{border-bottom-color:var(--muted);color:var(--fg)}
|
||
:root[data-theme="dark"] .dn-line .qt{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .dn-sign{border-top-color:var(--muted)}
|
||
:root[data-theme="dark"] .dn-sign > div{border-top-color:var(--fg);color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .status{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||
:root[data-theme="dark"] .status.err{background:var(--danger-soft);border-color:var(--danger-2);color:#ff8a9e}
|
||
:root[data-theme="dark"] .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||
:root[data-theme="dark"] .btn:active{background:var(--surface)}
|
||
:root[data-theme="dark"] .btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
|
||
:root[data-theme="dark"] .btn.acct:active{background:var(--accent-soft-hover)}
|
||
|
||
/* ---- Reduced motion ---- */
|
||
@media (prefers-reduced-motion: reduce){
|
||
*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}
|
||
.btn:active{transform:none}
|
||
}
|
||
|
||
@media print{
|
||
body{background:#fff}
|
||
.topbar,.theme-btn,.actions,.status,.synced-flag,.success .synced-flag{display:none !important}
|
||
.content{padding:0}
|
||
.success{background:#fff !important;color:#000 !important;box-shadow:none;border:1px solid #000;border-radius:0;padding:14px}
|
||
.success .check{background:#000 !important;color:#fff !important}
|
||
.receipt,.dn{box-shadow:none;border:1px solid #999;border-radius:0;page-break-inside:avoid}
|
||
.btn{display:none}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="topbar">
|
||
<button class="back" data-od-id="back-btn" onclick="location.href='pos.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>Receipt</h1>
|
||
<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>
|
||
<span class="netpill" id="netpill">…</span>
|
||
</header>
|
||
|
||
<div class="content">
|
||
|
||
<div class="success" id="success" data-od-id="success-banner">
|
||
<div class="check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12l5 5 9-11"/></svg></div>
|
||
<div class="txt">
|
||
<div class="h" id="successH">Sale complete</div>
|
||
<div class="s" id="successSub">Saved to register</div>
|
||
</div>
|
||
<span class="synced-flag" id="syncedFlag" style="display:none">Synced</span>
|
||
</div>
|
||
|
||
<section class="receipt" id="receiptCard" data-od-id="receipt"></section>
|
||
|
||
<div id="deliveryWrap" style="display:none">
|
||
<section class="dn" id="deliveryCard" data-od-id="delivery-note"></section>
|
||
</div>
|
||
|
||
<div class="status" id="status">
|
||
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg></span>
|
||
<span class="t" id="statusText">Ready to print.</span>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button class="btn primary" data-od-id="print-thermal" id="printBtn" onclick="printThermal()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18v6H3zM7 13v6h10v-6M7 17h10"/></svg>
|
||
Print slip
|
||
</button>
|
||
<button class="btn acct" data-od-id="print-delivery" id="printDnBtn" style="display:none" onclick="printDelivery()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 4h11l3 3v13H5zM9 9h6M9 13h6M9 17h4"/></svg>
|
||
Print delivery note
|
||
</button>
|
||
<button class="btn" data-od-id="new-sale" onclick="location.href='pos.html'">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>
|
||
New sale
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script src="pos-config.js"></script>
|
||
<script src="theme.js"></script>
|
||
<script>
|
||
(function(){
|
||
'use strict';
|
||
const fmt = n => 'R ' + (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||
const esc = s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
|
||
const escAttr = s => String(s).replace(/"/g,'"');
|
||
|
||
function paintNet(){
|
||
const p = document.getElementById('netpill');
|
||
p.textContent = navigator.onLine ? 'Online' : 'Offline';
|
||
p.classList.toggle('off', !navigator.onLine);
|
||
}
|
||
window.addEventListener('online', paintNet);
|
||
window.addEventListener('offline', paintNet);
|
||
|
||
const sale = JSON.parse(sessionStorage.getItem('pos.lastSale')||'null');
|
||
if(!sale){
|
||
document.getElementById('receiptCard').innerHTML = '<div class="empty-state"><h3>No receipt</h3><p>Start a new sale from the catalog.</p></div>';
|
||
document.getElementById('status').style.display='none';
|
||
document.getElementById('printBtn').style.display='none';
|
||
} else {
|
||
renderReceipt(sale);
|
||
if(sale.synced) document.getElementById('syncedFlag').style.display = 'inline-flex';
|
||
else document.getElementById('successSub').textContent = 'Saved locally · will sync when online';
|
||
if(sale.payment==='account'){
|
||
// Stashed so the "Include prices" checkbox can re-render in place.
|
||
window.__lastSale = sale;
|
||
document.getElementById('deliveryWrap').style.display='block';
|
||
document.getElementById('printDnBtn').style.display='flex';
|
||
renderDeliveryNote(sale);
|
||
}
|
||
}
|
||
|
||
function renderReceipt(s){
|
||
const lh = s.letterhead || {};
|
||
const date = new Date(s.createdAt);
|
||
const dateStr = date.toLocaleString('en-ZA',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||
const method = s.payment==='account' ? 'ACCOUNT' : s.payment.toUpperCase();
|
||
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
|
||
const vat = s.vat != null ? s.vat : (s.total * 15/115);
|
||
const paid = s.payment==='cash' ? fmt(s.cashGiven||0) : fmt(s.total);
|
||
|
||
const biz = lh.name || 'WFSALES POS';
|
||
const addr = (lh.address||'').split('\n')[0] || '';
|
||
const tel = lh.phone || '';
|
||
const vatNum = lh.vat_number || '';
|
||
const logoUrl = lh.logo_url || '';
|
||
const ic = logoUrl
|
||
? `<img src="${escAttr(logoUrl)}" alt="" style="max-height:34px;max-width:120px;display:block">`
|
||
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 12h6M9 16h6M9 8h6"/><rect x="5" y="3" width="14" height="18" rx="2"/></svg>`;
|
||
|
||
const itemsHTML = s.items.map(it=>{
|
||
const price = it.selling_price || 0;
|
||
const lineTotal = price * it.qty;
|
||
const parts = [];
|
||
if(it.sku) parts.push(`<span>${esc(it.sku)}</span>`);
|
||
if(price) parts.push(`<span class="sep">·</span><span>${fmt(price)}/${esc(it.unit||'ea')}</span>`);
|
||
return `<div class="r-item">
|
||
<div class="qty">${it.qty}×</div>
|
||
<div class="body">
|
||
<div class="name">${esc(it.description||it.sku||'—')}</div>
|
||
<div class="meta">${parts.join(' ')}</div>
|
||
</div>
|
||
<div class="pr">${fmt(lineTotal)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const customerBlock = s.customer
|
||
? `<div class="r-cust"><span class="lbl">Customer</span><span class="nm">${esc(s.customer.name)}</span></div>`
|
||
: '';
|
||
|
||
const html = `
|
||
<div class="r-head">
|
||
<div class="ic">${ic}</div>
|
||
<div class="nm">
|
||
<div class="biz">${esc(biz)}</div>
|
||
${addr ? `<div class="addr">${esc(addr)}${tel?' · '+esc(tel):''}</div>` : (tel?`<div class="addr">${esc(tel)}</div>`:'')}
|
||
${vatNum ? `<div class="vat">VAT ${esc(vatNum)}</div>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="r-body">
|
||
<div class="r-meta">
|
||
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
|
||
<div><div class="k">Slip</div><div class="v">${(s.localId||'').slice(-8).toUpperCase()}</div></div>
|
||
</div>
|
||
${customerBlock}
|
||
<div class="r-items">${itemsHTML}</div>
|
||
<div class="r-totals">
|
||
<div class="r-row"><span>Subtotal</span><span class="b">${fmt(sub)}</span></div>
|
||
<div class="r-row"><span>VAT 15%</span><span class="b">${fmt(vat)}</span></div>
|
||
<div class="r-row grand"><span>Total</span><span class="b">${fmt(s.total)}</span></div>
|
||
<div class="r-row" style="margin-top:6px"><span><span class="pill">${esc(method)}</span> Paid</span><span class="b">${paid}</span></div>
|
||
${s.payment==='cash' ? `<div class="r-row change" style="margin-top:2px"><span>Change</span><span class="b">${fmt(s.change||0)}</span></div>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="r-foot">${esc(lh.footer_text || 'Thank you for your purchase.')}</div>
|
||
`;
|
||
document.getElementById('receiptCard').innerHTML = html;
|
||
}
|
||
|
||
function renderDeliveryNote(s){
|
||
const date = new Date(s.createdAt);
|
||
const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
|
||
// Some customers must not see prices on the slip the driver carries;
|
||
// others want them. Default to showing, toggle re-renders in place.
|
||
const toggle = document.getElementById('dnShowPrices');
|
||
const showPrices = toggle ? !!toggle.checked : true;
|
||
const lines = s.items.map(it=>{
|
||
const price = showPrices ? `<div class="pr">${fmt((it.selling_price||0)*it.qty)}</div>` : '';
|
||
return `<div class="dn-line">
|
||
<div class="nm">${esc(it.description||it.sku)}</div>
|
||
<div class="qt">${it.qty} ${esc(it.unit||'ea')}</div>
|
||
${price}
|
||
</div>`;
|
||
}).join('');
|
||
const companyLine = s.customer && s.customer.company
|
||
? `<div class="v">${esc(s.customer.company)}</div>` : '';
|
||
const html = `
|
||
<div class="dn-head">
|
||
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h13l3 4h2v6h-2M3 17V7M3 17a2 2 0 1 0 4 0M17 17a2 2 0 1 0 4 0"/></svg></div>
|
||
<div class="nm">
|
||
<div class="t">Delivery note</div>
|
||
<div class="s">${esc((s.customer && s.customer.company) || (s.customer && s.customer.name) || '—')}</div>
|
||
</div>
|
||
</div>
|
||
<div class="dn-meta">
|
||
<div><div class="k">Customer</div><div class="v">${esc((s.customer && s.customer.name)||'—')}</div>${companyLine}</div>
|
||
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
|
||
<div><div class="k">Slip</div><div class="v">${(s.localId||'').slice(-8).toUpperCase()}</div></div>
|
||
${showPrices ? `<div><div class="k">Sale total</div><div class="v">${fmt(s.total)}</div></div>` : ''}
|
||
</div>
|
||
<label class="dn-toggle"><input type="checkbox" id="dnShowPrices" ${showPrices?'checked':''} onchange="renderDeliveryNote(window.__lastSale)"> Include prices</label>
|
||
<div class="dn-items">${lines}</div>
|
||
<div class="dn-sign"><div>Received by</div><div>Driver</div></div>
|
||
`;
|
||
document.getElementById('deliveryCard').innerHTML = html;
|
||
}
|
||
window.renderDeliveryNote = renderDeliveryNote;
|
||
|
||
// ---------------- Bluetooth thermal printing ----------------
|
||
let btDevice = null;
|
||
let btChar = null;
|
||
|
||
async function printThermal(){
|
||
const btn = document.getElementById('printBtn');
|
||
const status = document.getElementById('statusText');
|
||
const errBox = document.getElementById('status');
|
||
errBox.classList.remove('err');
|
||
btn.disabled = true;
|
||
status.textContent = 'Connecting to printer…';
|
||
|
||
if(!('bluetooth' in navigator)){
|
||
status.textContent = 'Web Bluetooth not supported on this device.';
|
||
errBox.classList.add('err');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
try{
|
||
btDevice = await navigator.bluetooth.requestDevice({
|
||
acceptAllDevices: true,
|
||
optionalServices: [0x18f0, 0x1101, 0x0000ff00, '000018f0-0000-1000-8000-00805f9b34fb']
|
||
});
|
||
status.textContent = 'Pairing ' + (btDevice.name||'printer') + '…';
|
||
const server = await btDevice.gatt.connect();
|
||
const services = await server.getPrimaryServices();
|
||
for(const s of services){
|
||
const chars = await s.getCharacteristics();
|
||
for(const c of chars){
|
||
if(c.properties.write || c.properties.writeWithoutResponse){ btChar = c; break; }
|
||
}
|
||
if(btChar) break;
|
||
}
|
||
if(!btChar) throw new Error('No writable characteristic on printer.');
|
||
status.textContent = 'Printing…';
|
||
const payload = buildEscPos(sale);
|
||
const CHUNK = 80;
|
||
for(let i=0;i<payload.length;i+=CHUNK){
|
||
const slice = payload.slice(i, i+CHUNK);
|
||
await btChar.writeValueWithoutResponse(slice);
|
||
await new Promise(r=>setTimeout(r,30));
|
||
}
|
||
status.textContent = 'Printed · ' + (btDevice.name||'printer');
|
||
}catch(e){
|
||
if(e.name==='NotFoundError') status.textContent = 'Cancelled.';
|
||
else status.textContent = 'Print failed: ' + e.message;
|
||
errBox.classList.add('err');
|
||
}finally{
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function buildEscPos(s){
|
||
const E = (n) => String.fromCharCode(n);
|
||
const ESC = E(0x1B), GS = E(0x1D);
|
||
const cmds = [];
|
||
cmds.push(ESC+'@');
|
||
cmds.push(ESC+'a'+E(1));
|
||
cmds.push(ESC+'!'+E(0x30));
|
||
cmds.push('WFSALES POS\n');
|
||
cmds.push(ESC+'!'+E(0x00));
|
||
if(s.letterhead?.name) cmds.push(s.letterhead.name+'\n');
|
||
cmds.push(ESC+'a'+E(0));
|
||
if(s.letterhead){
|
||
if(s.letterhead.address) cmds.push(s.letterhead.address.split('\n')[0]+'\n');
|
||
if(s.letterhead.phone) cmds.push('Tel: '+s.letterhead.phone+'\n');
|
||
if(s.letterhead.vat_number) cmds.push('VAT: '+s.letterhead.vat_number+'\n');
|
||
}
|
||
cmds.push('--------------------------------\n');
|
||
const d = new Date(s.createdAt);
|
||
cmds.push('Date: ' + d.toLocaleString('en-ZA')+'\n');
|
||
cmds.push('Slip: ' + (s.localId||'').slice(-8).toUpperCase()+'\n');
|
||
if(s.customer) cmds.push('Customer: ' + s.customer.name + '\n');
|
||
cmds.push('--------------------------------\n');
|
||
for(const it of s.items){
|
||
cmds.push((it.description||it.sku) + '\n');
|
||
const line = ` ${it.qty} x ${(it.selling_price||0).toFixed(2)} ${(it.selling_price*it.qty).toFixed(2)}`;
|
||
cmds.push(line + '\n');
|
||
}
|
||
cmds.push('--------------------------------\n');
|
||
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
|
||
const vat = s.vat != null ? s.vat : (s.total * 15/115);
|
||
cmds.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}\n`);
|
||
cmds.push(`VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}\n`);
|
||
cmds.push(ESC+'!'+E(0x08));
|
||
cmds.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}\n`);
|
||
cmds.push(ESC+'!'+E(0x00));
|
||
cmds.push('Paid: ' + (s.payment==='cash' ? `CASH R ${(s.cashGiven||0).toFixed(2)}` : s.payment.toUpperCase()) + '\n');
|
||
if(s.payment==='cash') cmds.push(`Change${' '.repeat(24)}R ${(s.change||0).toFixed(2)}\n`);
|
||
cmds.push('--------------------------------\n');
|
||
if(s.letterhead?.footer_text) cmds.push(s.letterhead.footer_text+'\n');
|
||
cmds.push('\n\n');
|
||
cmds.push(GS+'V'+E(0x41)+E(0x10));
|
||
const text = cmds.join('');
|
||
const bytes = new Uint8Array(text.length);
|
||
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
|
||
return bytes;
|
||
}
|
||
|
||
async function printDelivery(){
|
||
if(!sale) return;
|
||
const btn = document.getElementById('printDnBtn');
|
||
const status = document.getElementById('statusText');
|
||
const errBox = document.getElementById('status');
|
||
errBox.classList.remove('err');
|
||
btn.disabled = true;
|
||
status.textContent = 'Printing delivery note…';
|
||
if(!btChar){
|
||
status.textContent = 'Pair a Bluetooth printer first to print.';
|
||
errBox.classList.add('err');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
const E = (n) => String.fromCharCode(n);
|
||
const ESC = E(0x1B);
|
||
// Mirror the on-screen "Include prices" toggle so the printed slip
|
||
// matches what the operator just checked.
|
||
const dnToggle = document.getElementById('dnShowPrices');
|
||
const showPrices = dnToggle ? !!dnToggle.checked : true;
|
||
const lines = [];
|
||
lines.push(ESC+'@');
|
||
lines.push(ESC+'a'+E(1));
|
||
lines.push(ESC+'!'+E(0x10));
|
||
lines.push('WFSALES POS\n');
|
||
lines.push(ESC+'!'+E(0x00));
|
||
lines.push('Delivery Note\n');
|
||
lines.push(ESC+'a'+E(0));
|
||
if(sale.customer?.company) lines.push('Company: ' + sale.customer.company + '\n');
|
||
lines.push('Customer: ' + (sale.customer?.name||'—') + '\n');
|
||
const d = new Date(sale.createdAt);
|
||
lines.push('Date: ' + d.toLocaleDateString('en-ZA')+'\n');
|
||
lines.push('--------------------------------\n');
|
||
for(const it of sale.items){
|
||
lines.push((it.description||it.sku)+'\n');
|
||
const qtyLine = ` ${it.qty} ${it.unit||'ea'}`;
|
||
lines.push(showPrices
|
||
? `${qtyLine} ${fmt((it.selling_price||0)*it.qty)}\n`
|
||
: `${qtyLine}\n`);
|
||
}
|
||
lines.push('--------------------------------\n');
|
||
if(showPrices) lines.push('TOTAL: ' + fmt(sale.total) + '\n');
|
||
lines.push('Received by: __________ Driver: __________\n');
|
||
lines.push('\n\n');
|
||
lines.push(String.fromCharCode(0x1D)+'V'+String.fromCharCode(0x41)+String.fromCharCode(0x10));
|
||
const text = lines.join('');
|
||
const bytes = new Uint8Array(text.length);
|
||
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
|
||
try{
|
||
for(let i=0;i<bytes.length;i+=80){
|
||
await btChar.writeValueWithoutResponse(bytes.slice(i, i+80));
|
||
await new Promise(r=>setTimeout(r,30));
|
||
}
|
||
status.textContent = 'Delivery note printed.';
|
||
}catch(e){
|
||
errBox.classList.add('err');
|
||
status.textContent = 'DN print failed: ' + e.message;
|
||
}finally{
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
paintNet();
|
||
})();
|
||
</script>
|
||
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
|
||
</body>
|
||
</html>
|