Initial commit
This commit is contained in:
commit
565defbb72
608
index.html
Normal file
608
index.html
Normal file
@ -0,0 +1,608 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Play Shop</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fff8e7;
|
||||
--panel: #ffffff;
|
||||
--ink: #2b2b2b;
|
||||
--accent: #2e9e5b;
|
||||
--accent-dark: #1f7a43;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #d9534f;
|
||||
--muted: #8a8a8a;
|
||||
--line: #f0e2c0;
|
||||
--gap: clamp(8px, 1.6vw, 16px);
|
||||
}
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
padding: var(--gap);
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
button {
|
||||
font: inherit; color: inherit; border: none; cursor: pointer;
|
||||
touch-action: manipulation; /* no double-tap zoom, no delayed/duplicate clicks on touch screens */
|
||||
}
|
||||
|
||||
/* ---------- Products ---------- */
|
||||
#shop {
|
||||
flex: 2;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
}
|
||||
#pages {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 4px; /* room for the tab shadow */
|
||||
}
|
||||
#pages[hidden] { display: none; }
|
||||
#pages button {
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-radius: 999px;
|
||||
padding: clamp(6px, 1.2vw, 10px) clamp(14px, 2.4vw, 22px);
|
||||
font-size: clamp(16px, 2vw, 22px);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 3px 0 var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
#pages button.active { background: var(--accent); color: var(--accent-ink); box-shadow: 0 3px 0 var(--accent-dark); }
|
||||
#products {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(92px, 16vw, 140px), 1fr));
|
||||
gap: var(--gap);
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.product {
|
||||
background: var(--panel);
|
||||
border-radius: clamp(12px, 2vw, 20px);
|
||||
padding: clamp(6px, 1.2vw, 12px) 6px;
|
||||
box-shadow: 0 4px 0 var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
transition: transform .08s;
|
||||
}
|
||||
.product:active { transform: scale(.94); }
|
||||
.product .emoji { font-size: clamp(36px, 6vw, 64px); line-height: 1.1; }
|
||||
.product .name {
|
||||
font-size: clamp(14px, 1.8vw, 20px);
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.product .price { font-size: clamp(16px, 2vw, 22px); font-weight: 800; color: var(--accent); }
|
||||
|
||||
/* ---------- Basket ---------- */
|
||||
#side {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
max-width: 420px;
|
||||
min-height: 0;
|
||||
background: var(--panel);
|
||||
border-radius: clamp(14px, 2.4vw, 24px);
|
||||
padding: clamp(10px, 1.6vw, 16px);
|
||||
padding-top: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 0 var(--line);
|
||||
position: relative;
|
||||
}
|
||||
#basket { flex: 1; min-height: 0; overflow-y: auto; margin: 0; padding: 0; list-style: none; }
|
||||
#basket li button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 4px;
|
||||
background: none;
|
||||
border-bottom: 2px dashed var(--line);
|
||||
font-size: clamp(16px, 2vw, 22px);
|
||||
text-align: left;
|
||||
}
|
||||
#basket li button:active { background: #ffecec; }
|
||||
#basket .b-emoji { font-size: 1.3em; flex: none; }
|
||||
#basket .b-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#basket .qty { font-weight: 800; flex: none; min-width: 2.2em; text-align: right; }
|
||||
#basket .sum { font-weight: 700; flex: none; min-width: 3.2em; text-align: right; }
|
||||
#empty { color: var(--muted); font-size: clamp(16px, 2vw, 22px); text-align: center; margin: 24px 0; }
|
||||
|
||||
#total {
|
||||
font-size: clamp(28px, 5vw, 48px);
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
margin: clamp(6px, 1.2vw, 12px) 0;
|
||||
}
|
||||
.big-btn {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-size: clamp(24px, 4vw, 40px);
|
||||
font-weight: 800;
|
||||
border-radius: 20px;
|
||||
padding: clamp(10px, 2vw, 20px);
|
||||
box-shadow: 0 6px 0 var(--accent-dark);
|
||||
}
|
||||
.big-btn:active { transform: translateY(4px); box-shadow: 0 2px 0 var(--accent-dark); }
|
||||
.big-btn:disabled { opacity: .4; }
|
||||
|
||||
/* ---------- Settings button ---------- */
|
||||
#settings {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
font-size: 20px;
|
||||
background: var(--panel);
|
||||
opacity: .5;
|
||||
}
|
||||
#settings:active { opacity: 1; }
|
||||
|
||||
/* ---------- Overlays ---------- */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 16px;
|
||||
gap: clamp(12px, 2.5vh, 20px);
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
}
|
||||
.overlay.show { display: flex; }
|
||||
.overlay > * { flex: none; }
|
||||
|
||||
/* Thank-you + receipt */
|
||||
#thanks { background: var(--accent); color: var(--accent-ink); }
|
||||
#thanks::before, #thanks::after { content: ""; flex: 1; } /* centre vertically, but scroll if too tall */
|
||||
#thanks .msg { font-size: clamp(32px, 7vw, 56px); font-weight: 900; text-align: center; }
|
||||
#receipt {
|
||||
background: #ffffff;
|
||||
color: var(--ink);
|
||||
width: min(360px, 100%);
|
||||
padding: 20px 20px 28px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: clamp(15px, 2.2vw, 18px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,.2);
|
||||
/* zig-zag torn paper edge */
|
||||
-webkit-mask: conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) 50% / 16px 100%;
|
||||
mask: conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) 50% / 16px 100%;
|
||||
}
|
||||
#receipt h2 { text-align: center; margin: 0 0 4px; font-size: 1.3em; }
|
||||
#receipt .date { text-align: center; color: var(--muted); font-size: .8em; margin-bottom: 12px; }
|
||||
#receipt .row { display: flex; gap: 8px; padding: 2px 0; }
|
||||
#receipt .row .r { margin-left: auto; white-space: nowrap; }
|
||||
#receipt hr { border: none; border-top: 2px dashed var(--muted); margin: 10px 0; }
|
||||
#receipt .tot { font-weight: 900; font-size: 1.3em; }
|
||||
#receipt .bye { text-align: center; margin-top: 12px; }
|
||||
.btn-row { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; }
|
||||
.white-btn {
|
||||
background: #ffffff;
|
||||
color: var(--accent);
|
||||
font-size: clamp(20px, 4vw, 30px);
|
||||
font-weight: 800;
|
||||
border-radius: 20px;
|
||||
padding: clamp(10px, 2vw, 16px) clamp(18px, 4vw, 32px);
|
||||
}
|
||||
.white-btn:active { transform: scale(.96); }
|
||||
|
||||
/* Parent editor */
|
||||
#editor { background: var(--bg); }
|
||||
#editor h1 { margin: 0; font-size: clamp(24px, 5vw, 32px); }
|
||||
#editor .hint { color: var(--muted); margin: -8px 0 0; text-align: center; }
|
||||
#edit-list { width: min(560px, 100%); display: flex; flex-direction: column; gap: 8px; }
|
||||
.edit-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1.4fr) minmax(0, 1fr) 80px 44px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.edit-row input {
|
||||
font: inherit;
|
||||
font-size: 18px;
|
||||
padding: 8px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
.edit-row .e-emoji { text-align: center; font-size: 26px; padding: 4px; }
|
||||
.edit-row .del { background: none; font-size: 22px; color: var(--danger); }
|
||||
.small-btn {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
padding: 12px 18px;
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 3px 0 var(--line);
|
||||
}
|
||||
.small-btn.primary { background: var(--accent); color: var(--accent-ink); box-shadow: 0 3px 0 var(--accent-dark); }
|
||||
|
||||
/* ---------- Narrow screens (phones, portrait tablets): basket below products ---------- */
|
||||
@media (max-width: 760px) and (min-height: 500px) {
|
||||
body { flex-direction: column; }
|
||||
#shop { flex: 1; }
|
||||
#products { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }
|
||||
#side { flex: none; max-width: none; min-width: 0; height: 46dvh; }
|
||||
}
|
||||
/* Editor rows wrap onto two lines when narrow: page goes under the name */
|
||||
@media (max-width: 560px) {
|
||||
.edit-row { grid-template-columns: 48px minmax(0, 1fr) 72px 40px; }
|
||||
.edit-row .e-page { grid-column: 2 / 4; grid-row: 2; }
|
||||
}
|
||||
/* Very small phones */
|
||||
@media (max-width: 380px) {
|
||||
#products { grid-template-columns: repeat(3, 1fr) !important; }
|
||||
.edit-row { grid-template-columns: 44px minmax(0, 1fr) 64px 36px; gap: 6px; padding: 6px; }
|
||||
}
|
||||
/* Short landscape phones: keep side-by-side, tighten everything */
|
||||
@media (max-height: 499px) {
|
||||
#side { min-width: 200px; padding-top: 40px; }
|
||||
.product .emoji { font-size: 32px; }
|
||||
#total { font-size: 26px; margin: 4px 0; }
|
||||
.big-btn { font-size: 22px; padding: 8px; }
|
||||
}
|
||||
|
||||
/* Only the receipt goes to the printer */
|
||||
@media print {
|
||||
body { display: block; height: auto; overflow: visible; }
|
||||
body > *:not(#thanks) { display: none !important; }
|
||||
#thanks { position: static; background: none; padding: 0; }
|
||||
#thanks::before, #thanks::after, #thanks > *:not(#receipt) { display: none !important; }
|
||||
#receipt { box-shadow: none; -webkit-mask: none; mask: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="shop">
|
||||
<nav id="pages" hidden></nav>
|
||||
<div id="products"></div>
|
||||
</div>
|
||||
|
||||
<div id="side">
|
||||
<button id="settings" aria-label="Edit products">⚙️</button>
|
||||
<ul id="basket"></ul>
|
||||
<div id="empty">Tap a product 👆</div>
|
||||
<div id="total">0 €</div>
|
||||
<button id="pay" class="big-btn" disabled>Pay 💰</button>
|
||||
</div>
|
||||
|
||||
<div id="thanks" class="overlay">
|
||||
<div class="msg">🎉 Thank you! 🎉</div>
|
||||
<div id="receipt"></div>
|
||||
<div class="btn-row">
|
||||
<button id="print" class="white-btn">Print 🖨️</button>
|
||||
<button id="next" class="white-btn">New customer 🛒</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editor" class="overlay">
|
||||
<h1>Edit products</h1>
|
||||
<p class="hint">Emoji · name · page · price (e.g. 2 or 1.50). Products with the same page name share a tab. Saved on this device.</p>
|
||||
<div id="edit-list"></div>
|
||||
<datalist id="page-names"></datalist>
|
||||
<div class="btn-row">
|
||||
<button id="add-row" class="small-btn">➕ Add product</button>
|
||||
<button id="reset" class="small-btn">↩️ Defaults</button>
|
||||
<button id="cancel" class="small-btn">✖️ Cancel</button>
|
||||
<button id="done" class="small-btn primary">✅ Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---------- Settings ----------
|
||||
const CURRENCY = "€";
|
||||
const SHOP_NAME = "Play Shop";
|
||||
const STORAGE_KEY = "play-shop-products";
|
||||
const TAP_GUARD_MS = 100; // ignore a till tap that follows the previous one this closely
|
||||
|
||||
// Default products. Parents can change them in the app (tap ⚙️).
|
||||
// Products with the same `page` are shown together on one tab; tabs appear in order of first use.
|
||||
const FRUIT_VEG = "🥕 Fruit & Veg";
|
||||
const DEFAULT_PAGE = "🛒 Other"; // also used for products saved before pages existed, or left blank
|
||||
const DEFAULT_PRODUCTS = [
|
||||
{ emoji: "🍎", name: "Apple", price: 1, page: FRUIT_VEG },
|
||||
{ emoji: "🍌", name: "Banana", price: 1, page: FRUIT_VEG },
|
||||
{ emoji: "🥕", name: "Carrot", price: 1, page: FRUIT_VEG },
|
||||
{ emoji: "🍅", name: "Tomato", price: 1, page: FRUIT_VEG },
|
||||
{ emoji: "🍞", name: "Bread", price: 2, page: DEFAULT_PAGE },
|
||||
{ emoji: "🥛", name: "Milk", price: 2, page: DEFAULT_PAGE },
|
||||
{ emoji: "🥚", name: "Eggs", price: 3, page: DEFAULT_PAGE },
|
||||
{ emoji: "🧀", name: "Cheese", price: 3, page: DEFAULT_PAGE },
|
||||
{ emoji: "🍝", name: "Pasta", price: 2, page: DEFAULT_PAGE },
|
||||
{ emoji: "🍪", name: "Cookies", price: 4, page: DEFAULT_PAGE },
|
||||
{ emoji: "🧃", name: "Juice", price: 2, page: DEFAULT_PAGE },
|
||||
{ emoji: "🍦", name: "Ice cream", price: 5, page: DEFAULT_PAGE },
|
||||
];
|
||||
|
||||
// ---------- Elements ----------
|
||||
const $ = id => document.getElementById(id);
|
||||
const $pages = $("pages");
|
||||
const $products = $("products");
|
||||
const $basket = $("basket");
|
||||
const $empty = $("empty");
|
||||
const $total = $("total");
|
||||
const $pay = $("pay");
|
||||
const $thanks = $("thanks");
|
||||
const $receipt = $("receipt");
|
||||
const $settings = $("settings");
|
||||
const $editor = $("editor");
|
||||
const $editList = $("edit-list");
|
||||
const $pageNames = $("page-names");
|
||||
|
||||
// ---------- Helpers ----------
|
||||
// Builds an element. Keys starting with "on" become event listeners, known DOM properties are
|
||||
// assigned (so user-entered text is never interpreted as HTML), anything else is an attribute.
|
||||
// `list` is a read-only property on inputs, so it has to be set as an attribute.
|
||||
function el(tag, props = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
|
||||
else if (k in node && k !== "list") node[k] = v;
|
||||
else node.setAttribute(k, v);
|
||||
}
|
||||
node.append(...children);
|
||||
return node;
|
||||
}
|
||||
|
||||
// One tap = one action. Touch screens can deliver a second "ghost" click for the same tap,
|
||||
// and after the basket re-renders that click can land on a different line. Ignore any
|
||||
// till tap that follows the previous one too closely to be a real second tap.
|
||||
let lastTap = 0;
|
||||
function tap(fn) {
|
||||
return e => {
|
||||
if (e.timeStamp - lastTap < TAP_GUARD_MS) return;
|
||||
lastTap = e.timeStamp;
|
||||
fn();
|
||||
};
|
||||
}
|
||||
|
||||
const roundCents = n => Math.round(n * 100) / 100;
|
||||
|
||||
// Whole amounts show as "2 €", others with two decimals ("1.50 €", or "1,50 €" depending on device language)
|
||||
function money(n) {
|
||||
n = roundCents(n);
|
||||
const decimals = Number.isInteger(n) ? 0 : 2;
|
||||
return `${n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: 2 })} ${CURRENCY}`;
|
||||
}
|
||||
|
||||
// Accepts "1.5", "1,50", " 2 " … and rounds to cents; invalid or negative becomes 0
|
||||
function parsePrice(text) {
|
||||
const n = Number(String(text).trim().replace(",", "."));
|
||||
return Number.isFinite(n) && n > 0 ? roundCents(n) : 0;
|
||||
}
|
||||
|
||||
const showOverlay = $overlay => { $overlay.classList.add("show"); $overlay.scrollTop = 0; };
|
||||
const hideOverlay = $overlay => $overlay.classList.remove("show");
|
||||
|
||||
// ---------- Storage ----------
|
||||
function loadProducts() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
|
||||
if (Array.isArray(saved) && saved.length) return saved;
|
||||
} catch (e) { /* storage unavailable or corrupt */ }
|
||||
return DEFAULT_PRODUCTS.map(p => ({ ...p }));
|
||||
}
|
||||
|
||||
function saveProducts() {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(products)); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---------- Sound ----------
|
||||
let audio;
|
||||
function beep(freq = 880) {
|
||||
try {
|
||||
audio ||= new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = audio.createOscillator();
|
||||
const gain = audio.createGain();
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(0.2, audio.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + 0.15);
|
||||
osc.connect(gain).connect(audio.destination);
|
||||
osc.start();
|
||||
osc.stop(audio.currentTime + 0.15);
|
||||
} catch (e) { /* no sound available */ }
|
||||
}
|
||||
|
||||
// ---------- State ----------
|
||||
let products = loadProducts();
|
||||
let currentPage = null; // page name of the visible tab
|
||||
const basket = new Map(); // index into `products` -> quantity
|
||||
|
||||
const pageOf = p => p.page || DEFAULT_PAGE;
|
||||
|
||||
// Distinct page names in order of first appearance
|
||||
const pageNames = () => [...new Set(products.map(pageOf))];
|
||||
|
||||
// Basket contents with the product looked up, in the order they were added
|
||||
const lines = () => [...basket].map(([i, qty]) => ({ i, qty, p: products[i], sum: products[i].price * qty }));
|
||||
|
||||
const total = () => lines().reduce((sum, line) => sum + line.sum, 0);
|
||||
|
||||
// ---------- Products ----------
|
||||
function renderProducts() {
|
||||
const pages = pageNames();
|
||||
if (!pages.includes(currentPage)) currentPage = pages[0];
|
||||
|
||||
// Tabs only when there is more than one page
|
||||
$pages.hidden = pages.length < 2;
|
||||
$pages.replaceChildren(...pages.map(name =>
|
||||
el("button", {
|
||||
className: name === currentPage ? "active" : "",
|
||||
textContent: name,
|
||||
"aria-pressed": String(name === currentPage),
|
||||
onclick: tap(() => showPage(name)),
|
||||
})));
|
||||
|
||||
// Keep each product's index into `products`, which is what the basket uses
|
||||
$products.replaceChildren(...products.flatMap((p, i) => pageOf(p) !== currentPage ? [] : [
|
||||
el("button", { className: "product", onclick: tap(() => changeQty(i, +1)) },
|
||||
el("span", { className: "emoji", textContent: p.emoji }),
|
||||
el("span", { className: "name", textContent: p.name }),
|
||||
el("span", { className: "price", textContent: money(p.price) }))]));
|
||||
}
|
||||
|
||||
function showPage(name) {
|
||||
if (name === currentPage) return;
|
||||
currentPage = name;
|
||||
renderProducts();
|
||||
$products.scrollTop = 0;
|
||||
$pages.querySelector(".active")?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}
|
||||
|
||||
// ---------- Basket ----------
|
||||
function renderBasket() {
|
||||
$basket.replaceChildren(...lines().map(({ i, qty, p, sum }) =>
|
||||
el("li", {},
|
||||
el("button", { onclick: tap(() => changeQty(i, -1)), "aria-label": `Remove one ${p.name}` },
|
||||
el("span", { className: "qty", textContent: `${qty} ×` }),
|
||||
el("span", { className: "b-emoji", textContent: p.emoji }),
|
||||
el("span", { className: "b-name", textContent: p.name }),
|
||||
el("span", { className: "sum", textContent: money(sum) })))));
|
||||
$empty.style.display = basket.size ? "none" : "block";
|
||||
$total.textContent = money(total());
|
||||
$pay.disabled = basket.size === 0;
|
||||
$basket.scrollTop = $basket.scrollHeight;
|
||||
}
|
||||
|
||||
// +1 from a product button (high beep), -1 from a basket line (low beep)
|
||||
function changeQty(i, delta) {
|
||||
const qty = (basket.get(i) || 0) + delta;
|
||||
if (qty < 0) return;
|
||||
if (qty > 0) basket.set(i, qty); else basket.delete(i);
|
||||
beep(delta > 0 ? 880 : 330);
|
||||
renderBasket();
|
||||
}
|
||||
|
||||
// ---------- Pay + receipt ----------
|
||||
function renderReceipt() {
|
||||
$receipt.replaceChildren(
|
||||
el("h2", { textContent: SHOP_NAME }),
|
||||
el("div", { className: "date", textContent: new Date().toLocaleString() }),
|
||||
...lines().map(({ qty, p, sum }) =>
|
||||
el("div", { className: "row" },
|
||||
el("span", { textContent: `${qty} × ${p.name}` }),
|
||||
el("span", { className: "r", textContent: money(sum) }))),
|
||||
el("hr"),
|
||||
el("div", { className: "row tot" },
|
||||
el("span", { textContent: "TOTAL" }),
|
||||
el("span", { className: "r", textContent: money(total()) })),
|
||||
el("div", { className: "bye", textContent: "Come again soon!" }));
|
||||
}
|
||||
|
||||
$pay.onclick = () => {
|
||||
renderReceipt();
|
||||
showOverlay($thanks);
|
||||
beep(660); setTimeout(() => beep(990), 150);
|
||||
};
|
||||
|
||||
$("print").onclick = () => window.print();
|
||||
|
||||
$("next").onclick = () => {
|
||||
basket.clear();
|
||||
hideOverlay($thanks);
|
||||
renderBasket();
|
||||
};
|
||||
|
||||
// ---------- Parent editor ----------
|
||||
function editRow(p = { emoji: "🛍️", name: "", price: 1, page: currentPage }) {
|
||||
const row = el("div", { className: "edit-row" },
|
||||
el("input", { className: "e-emoji", value: p.emoji, "aria-label": "Emoji" }),
|
||||
el("input", { className: "e-name", value: p.name, placeholder: "Name", "aria-label": "Name" }),
|
||||
el("input", { className: "e-page", value: pageOf(p), placeholder: DEFAULT_PAGE, list: "page-names", "aria-label": "Page" }),
|
||||
el("input", { className: "e-price", type: "text", inputMode: "decimal", value: p.price, placeholder: "0.00", "aria-label": "Price" }),
|
||||
el("button", { className: "del", textContent: "🗑️", "aria-label": "Delete", onclick: () => row.remove() }));
|
||||
return row;
|
||||
}
|
||||
|
||||
function readRow(row) {
|
||||
const field = cls => row.querySelector(cls).value.trim();
|
||||
return {
|
||||
emoji: field(".e-emoji") || "🛍️",
|
||||
name: field(".e-name"),
|
||||
price: parsePrice(field(".e-price")),
|
||||
page: field(".e-page") || DEFAULT_PAGE,
|
||||
};
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
$pageNames.replaceChildren(...pageNames().map(name => el("option", { value: name })));
|
||||
$editList.replaceChildren(...products.map(p => editRow(p)));
|
||||
showOverlay($editor);
|
||||
}
|
||||
|
||||
$settings.onclick = openEditor;
|
||||
|
||||
$("add-row").onclick = () => {
|
||||
const row = editRow();
|
||||
$editList.append(row);
|
||||
row.querySelector(".e-name").focus();
|
||||
};
|
||||
|
||||
$("reset").onclick = () => {
|
||||
if (!confirm("Reset all products to the defaults?")) return;
|
||||
$editList.replaceChildren(...DEFAULT_PRODUCTS.map(p => editRow(p)));
|
||||
};
|
||||
|
||||
$("cancel").onclick = () => hideOverlay($editor);
|
||||
|
||||
$("done").onclick = () => {
|
||||
const edited = [...$editList.children].map(readRow).filter(p => p.name);
|
||||
if (!edited.length) { alert("Add at least one product with a name."); return; }
|
||||
products = edited;
|
||||
saveProducts();
|
||||
basket.clear(); // product positions may have changed
|
||||
hideOverlay($editor);
|
||||
renderProducts();
|
||||
renderBasket();
|
||||
};
|
||||
|
||||
// ---------- Start ----------
|
||||
renderProducts();
|
||||
renderBasket();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user