Initial commit

This commit is contained in:
Patrick Elmer 2026-10-17 12:14:37 +00:00
commit 565defbb72

608
index.html Normal file
View 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>