pos/index.html
2026-10-17 12:14:37 +00:00

609 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>