609 lines
21 KiB
HTML
609 lines
21 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">
|
||
<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>
|