Pages Hard
POS — Order Entry Terminal
Full restaurant POS terminal with category sidebar, menu grid, live ticket panel, modifier sheet, course splitter, and Send / Hold / Print / Pay actions.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
:root {
--cream: #f5f0e8;
--cream-2: #ece4d4;
--bone: #faf7f1;
--terracotta: #c1714a;
--terracotta-d: #a05a38;
--forest: #2d4a3e;
--forest-d: #1e3329;
--gold: #c9a84c;
--gold-light: #e6c97a;
--ink: #2c1a0e;
--ink-2: #4a3828;
--warm-gray: #7a6a58;
--success: #4f7a3a;
--danger: #b3432a;
--warning: #d99020;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--shadow-1: 0 1px 2px rgba(44, 26, 14, 0.08), 0 2px 6px rgba(44, 26, 14, 0.06);
--shadow-2: 0 8px 24px rgba(44, 26, 14, 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
-webkit-font-smoothing: antialiased;
user-select: none;
}
.pos {
height: 100vh;
display: grid;
grid-template-columns: 220px 1fr 380px;
}
/* ── Rail (left sidebar) ── */
.rail {
background: var(--forest);
color: var(--bone);
display: flex;
flex-direction: column;
border-right: 1px solid var(--forest-d);
overflow-y: auto;
}
.rail-head {
padding: 18px 18px 12px;
border-bottom: 1px solid rgba(250, 247, 241, 0.1);
}
.brand {
display: block;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.005em;
}
.term {
display: block;
font-size: 0.7rem;
color: var(--gold-light);
letter-spacing: 0.12em;
text-transform: uppercase;
margin-top: 2px;
}
.rail-section {
padding: 14px 14px 10px;
border-bottom: 1px solid rgba(250, 247, 241, 0.08);
}
.rail-label {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--gold-light);
font-weight: 600;
margin-bottom: 8px;
}
.seg {
display: flex;
gap: 4px;
background: rgba(0, 0, 0, 0.18);
padding: 3px;
border-radius: 999px;
}
.seg-btn {
flex: 1;
background: transparent;
border: none;
color: var(--bone);
font-family: inherit;
font-size: 0.74rem;
font-weight: 600;
padding: 7px 8px;
border-radius: 999px;
cursor: pointer;
}
.seg-btn.is-active {
background: var(--bone);
color: var(--forest-d);
}
.tables {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.table-btn {
background: rgba(250, 247, 241, 0.08);
border: 1px solid rgba(250, 247, 241, 0.12);
color: var(--bone);
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
padding: 8px 0;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.table-btn:hover {
background: rgba(250, 247, 241, 0.16);
}
.table-btn.is-active {
background: var(--gold);
color: var(--ink);
border-color: var(--gold);
}
.cats {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.cat-btn {
background: transparent;
border: none;
color: var(--bone);
font-family: inherit;
font-size: 0.88rem;
font-weight: 600;
text-align: left;
padding: 11px 14px;
border-radius: var(--r-md);
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
}
.cat-btn:hover {
background: rgba(250, 247, 241, 0.08);
}
.cat-btn.is-active {
background: var(--bone);
color: var(--forest-d);
}
.cat-icon {
font-size: 1.05rem;
width: 22px;
text-align: center;
}
.rail-foot {
margin-top: auto;
padding: 12px 14px;
border-top: 1px solid rgba(250, 247, 241, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.clock {
font-family: var(--font-mono);
font-weight: 600;
font-size: 0.85rem;
color: var(--gold-light);
}
.util-btn {
background: rgba(250, 247, 241, 0.08);
border: 1px solid rgba(250, 247, 241, 0.16);
color: var(--bone);
font-family: inherit;
font-size: 0.74rem;
font-weight: 600;
padding: 6px 10px;
border-radius: var(--r-sm);
cursor: pointer;
}
/* ── Menu (centre) ── */
.menu {
background: var(--cream);
display: flex;
flex-direction: column;
overflow: hidden;
}
.menu-head {
padding: 18px 22px 14px;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
}
.menu-kicker {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--terracotta);
font-weight: 600;
margin-bottom: 2px;
}
.menu-head h1 {
font-family: var(--font-display);
font-size: 1.55rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.menu-search {
display: flex;
align-items: center;
gap: 8px;
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.1);
padding: 8px 14px;
border-radius: 999px;
color: var(--warm-gray);
}
.menu-search input {
background: transparent;
border: none;
outline: none;
font-family: inherit;
font-size: 0.86rem;
color: var(--ink);
width: 200px;
}
.grid {
flex: 1;
overflow-y: auto;
padding: 18px 22px 28px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
align-content: start;
}
.tile {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: var(--r-md);
padding: 14px 12px 12px;
cursor: pointer;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 100px;
font-family: inherit;
color: var(--ink);
transition: background 0.15s, border-color 0.15s, transform 0.05s;
}
.tile:hover {
border-color: var(--terracotta);
}
.tile:active {
transform: scale(0.97);
background: var(--cream-2);
}
.tile.is-flash {
animation: flash 0.45s ease;
}
@keyframes flash {
0% {
background: var(--gold);
border-color: var(--gold);
}
100% {
background: var(--bone);
border-color: rgba(44, 26, 14, 0.08);
}
}
.tile-name {
font-size: 0.9rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.005em;
}
.tile-desc {
font-size: 0.72rem;
color: var(--warm-gray);
line-height: 1.35;
flex: 1;
}
.tile-price {
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 600;
color: var(--terracotta-d);
margin-top: 4px;
}
.tile-86 {
position: relative;
opacity: 0.55;
cursor: not-allowed;
}
.tile-86::after {
content: "86";
position: absolute;
top: 8px;
right: 8px;
background: var(--danger);
color: var(--bone);
font-family: var(--font-mono);
font-size: 0.66rem;
font-weight: 700;
padding: 2px 6px;
border-radius: 999px;
}
/* ── Ticket (right) ── */
.ticket {
background: var(--bone);
border-left: 1px solid rgba(44, 26, 14, 0.1);
display: flex;
flex-direction: column;
}
.ticket-head {
padding: 18px 18px 14px;
border-bottom: 1px dashed rgba(44, 26, 14, 0.18);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.ticket-table {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.25rem;
}
.ticket-meta {
font-size: 0.74rem;
color: var(--warm-gray);
margin-top: 2px;
}
.course-btn {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.16);
border-radius: 999px;
padding: 6px 12px;
font-family: inherit;
font-size: 0.74rem;
font-weight: 600;
color: var(--ink-2);
cursor: pointer;
}
.course-btn:hover {
border-color: var(--terracotta);
color: var(--terracotta-d);
}
.ticket-body {
flex: 1;
overflow-y: auto;
padding: 8px 18px;
}
.course {
margin-bottom: 6px;
}
.course-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--terracotta);
font-weight: 700;
padding: 14px 0 6px;
border-bottom: 1px solid rgba(193, 113, 74, 0.2);
}
.course-label .dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--terracotta);
}
.course-label.is-sent .dot {
background: var(--success);
}
.course-label.is-sent {
color: var(--success);
}
.course-sent-tag {
margin-left: auto;
font-size: 0.62rem;
background: var(--success);
color: var(--bone);
padding: 2px 7px;
border-radius: 999px;
letter-spacing: 0.08em;
font-weight: 700;
}
.line {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(44, 26, 14, 0.06);
}
.line:last-child {
border-bottom: none;
}
.line-qty {
display: inline-flex;
align-items: center;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
border-radius: 999px;
padding: 2px;
gap: 2px;
}
.line-qty button {
width: 26px;
height: 26px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--ink-2);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
display: grid;
place-items: center;
line-height: 1;
}
.line-qty button:hover {
background: var(--cream-2);
color: var(--terracotta-d);
}
.line-qty span {
min-width: 22px;
text-align: center;
font-weight: 700;
font-size: 0.86rem;
font-variant-numeric: tabular-nums;
}
.line-body {
flex: 1;
min-width: 0;
}
.line-name {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink);
}
.line-mods {
font-size: 0.7rem;
color: var(--warm-gray);
}
.line-price {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.88rem;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.line.is-sent .line-name,
.line.is-sent .line-price {
color: var(--warm-gray);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.line.is-sent .line-qty {
opacity: 0.5;
}
.empty-course {
font-style: italic;
color: var(--warm-gray);
font-size: 0.78rem;
padding: 8px 0 16px;
}
.ticket-foot {
padding: 14px 18px 18px;
border-top: 1px solid rgba(44, 26, 14, 0.1);
display: flex;
flex-direction: column;
gap: 12px;
background: var(--cream);
}
.totals {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 18px;
}
.totals div {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
color: var(--ink-2);
}
.totals dd {
font-family: var(--font-mono);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.total-big {
grid-column: 1 / -1;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(44, 26, 14, 0.16);
font-size: 1rem;
font-weight: 700;
color: var(--ink);
}
.total-big dd {
font-size: 1.15rem;
}
.actions {
display: grid;
grid-template-columns: auto auto 1fr auto;
gap: 8px;
}
.ghost,
.primary,
.gold {
border: none;
border-radius: 999px;
padding: 11px 14px;
font-family: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, transform 0.05s;
}
.ghost {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.18);
color: var(--ink-2);
}
.ghost:hover {
background: var(--cream-2);
color: var(--ink);
}
.primary {
background: var(--forest);
color: var(--bone);
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-left: 16px;
padding-right: 8px;
}
.primary:hover:not(:disabled) {
background: var(--forest-d);
}
.primary:disabled {
background: var(--warm-gray);
cursor: not-allowed;
opacity: 0.7;
}
.total-pill {
font-family: var(--font-mono);
font-weight: 700;
background: rgba(250, 247, 241, 0.18);
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
}
.gold {
background: var(--gold);
color: var(--ink);
}
.gold:hover:not(:disabled) {
background: var(--gold-light);
}
.gold:disabled {
background: rgba(201, 168, 76, 0.4);
cursor: not-allowed;
color: var(--warm-gray);
}
.toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
background: var(--forest-d);
color: var(--bone);
padding: 10px 18px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: var(--shadow-2);
}
/* ── Responsive (tablets) ── */
@media (max-width: 1100px) {
.pos {
grid-template-columns: 200px 1fr 340px;
}
.menu-search input {
width: 140px;
}
}
@media (max-width: 880px) {
.pos {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
height: auto;
}
html,
body {
overflow: auto;
}
.rail {
flex-direction: row;
align-items: stretch;
overflow-x: auto;
}
.rail-foot {
display: none;
}
.ticket {
border-left: none;
border-top: 1px solid rgba(44, 26, 14, 0.1);
}
}const TAX_RATE = 0.0825;
const SERVICE_RATE = 0.1;
const MENU = {
entradas: {
title: "Entradas",
icon: "🥖",
items: [
{ id: "pan", name: "Pan masa madre", desc: "Smoked oil, sea salt", price: 8 },
{ id: "burrata", name: "Burrata huerta", desc: "Heirloom tomato, basil", price: 16 },
{ id: "pulpo", name: "Pulpo brasa", desc: "Smoked paprika potato", price: 19 },
{ id: "croquetas", name: "Croquetas jamón", desc: "6 pcs, lemon aioli", price: 14 },
{
id: "anchoas",
name: "Anchoas Cantábrico",
desc: "Boquerones, butter, baguette",
price: 17,
},
{ id: "ensalada", name: "Ensalada huerta", desc: "Garden greens, vinaigrette", price: 13 },
],
},
principales: {
title: "Principales",
icon: "🥩",
items: [
{ id: "ribeye", name: "Ribeye 14oz", desc: "Marrow butter, chimichurri", price: 48 },
{ id: "branzino", name: "Branzino entero", desc: "Fennel, preserved lemon", price: 38 },
{ id: "risotto", name: "Risotto hongos", desc: "Wild mushroom, parmesan", price: 26 },
{ id: "pollo", name: "Pollo carbón", desc: "Half chicken, garlic confit", price: 28 },
{ id: "pappardelle", name: "Pappardelle ragú", desc: "Slow lamb shoulder", price: 24 },
{ id: "salmon", name: "Salmón plancha", desc: "Citrus glaze", price: 32, soldOut: true },
{ id: "cordero", name: "Costilla cordero", desc: "Honey-mint glaze", price: 42 },
{ id: "huerto", name: "Plato huerto", desc: "Seasonal vegetables", price: 22 },
],
},
postres: {
title: "Postres",
icon: "🍰",
items: [
{ id: "tarta", name: "Tarta de queso", desc: "Basque burnt, salted caramel", price: 11 },
{ id: "olive", name: "Olive oil cake", desc: "Crème fraîche, candied orange", price: 10 },
{ id: "ganache", name: "Chocolate ganache", desc: "Hazelnut praline", price: 12 },
{ id: "sorbete", name: "Sorbete cítrico", desc: "Citrus, mint", price: 9 },
],
},
bebidas: {
title: "Bebidas",
icon: "🍷",
items: [
{ id: "vermut", name: "Vermut casa", price: 9 },
{ id: "negroni", name: "Negroni sbagliato", price: 14 },
{ id: "spritz", name: "Spritz", price: 13 },
{ id: "tinto", name: "Tinto natural (copa)", price: 12 },
{ id: "blanco", name: "Blanco copa", price: 11 },
{ id: "agua", name: "Agua mineral", price: 5 },
{ id: "cafe", name: "Espresso", price: 4 },
{ id: "tonica", name: "Tónica", price: 6 },
],
},
};
const COURSE_NAMES = ["1st course", "2nd course", "3rd course", "4th course"];
const catsEl = document.getElementById("cats");
const tablesEl = document.getElementById("tables");
const tablesSectionEl = document.getElementById("tablesSection");
const tablesLabelEl = document.getElementById("tablesLabel");
const gridEl = document.getElementById("grid");
const catName = document.getElementById("catName");
const catCount = document.getElementById("catCount");
const searchEl = document.getElementById("search");
const ticketBody = document.getElementById("ticketBody");
const ticketTable = document.getElementById("ticketTable");
const ticketMeta = document.getElementById("ticketMeta");
const subtotalEl = document.getElementById("subtotal");
const taxEl = document.getElementById("tax");
const serviceEl = document.getElementById("service");
const totalEl = document.getElementById("total");
const sendTotalEl = document.getElementById("sendTotal");
const sendBtn = document.getElementById("send");
const payBtn = document.getElementById("pay");
const holdBtn = document.getElementById("hold");
const printBtn = document.getElementById("print");
const courseBtn = document.getElementById("course");
const clockEl = document.getElementById("clock");
const toast = document.getElementById("toast");
let activeCat = "entradas";
let activeTable = 7;
let serviceMode = "dine";
let takeCounter = 1;
let courses = [{ id: 1, name: COURSE_NAMES[0], sent: false, lines: [] }];
function money(v) {
return `$${v.toFixed(2)}`;
}
function renderTables() {
if (serviceMode === "bar") {
tablesEl.innerHTML = Array.from({ length: 6 }, (_, i) => i + 1)
.map(
(n) =>
`<button class="table-btn ${n === activeTable ? "is-active" : ""}" data-table="${n}">B${n}</button>`
)
.join("");
} else {
tablesEl.innerHTML = Array.from({ length: 12 }, (_, i) => i + 1)
.map(
(n) =>
`<button class="table-btn ${n === activeTable ? "is-active" : ""}" data-table="${n}">${n}</button>`
)
.join("");
}
}
function applyServiceMode() {
if (serviceMode === "take") {
tablesSectionEl.hidden = true;
} else {
tablesSectionEl.hidden = false;
tablesLabelEl.textContent = serviceMode === "bar" ? "Bar stool" : "Table";
if (serviceMode === "bar") {
// Clamp activeTable to bar stool range (1–6)
if (activeTable > 6) activeTable = 1;
} else {
// Restore a valid dine table if coming back from bar
if (activeTable > 12) activeTable = 1;
}
renderTables();
}
}
function renderCats() {
catsEl.innerHTML = Object.entries(MENU)
.map(
([key, c]) => `
<button class="cat-btn ${key === activeCat ? "is-active" : ""}" data-cat="${key}">
<span class="cat-icon">${c.icon}</span>
<span>${c.title}</span>
</button>`
)
.join("");
}
function renderGrid() {
const cat = MENU[activeCat];
const q = searchEl.value.trim().toLowerCase();
const filtered = cat.items.filter(
(i) => !q || i.name.toLowerCase().includes(q) || (i.desc || "").toLowerCase().includes(q)
);
catName.textContent = cat.title;
catCount.textContent = `${filtered.length} ${filtered.length === 1 ? "item" : "items"}`;
gridEl.innerHTML = filtered
.map(
(item) => `
<button class="tile ${item.soldOut ? "tile-86" : ""}" ${item.soldOut ? "disabled" : ""}
data-item="${item.id}" data-name="${item.name}" data-price="${item.price}">
<span class="tile-name">${item.name}</span>
${item.desc ? `<span class="tile-desc">${item.desc}</span>` : ""}
<span class="tile-price">$${item.price.toFixed(2)}</span>
</button>`
)
.join("");
}
function activeCourse() {
return courses.find((c) => !c.sent) || courses[courses.length - 1];
}
function renderTicket() {
if (serviceMode === "dine") {
ticketTable.textContent = `Table ${activeTable}`;
} else if (serviceMode === "take") {
ticketTable.textContent = `Take-out #${takeCounter}`;
} else {
ticketTable.textContent = `Bar ${activeTable}`;
}
const totalItems = courses.reduce((n, c) => n + c.lines.reduce((m, l) => m + l.qty, 0), 0);
ticketMeta.textContent = `${totalItems} ${totalItems === 1 ? "item" : "items"} · 2 guests`;
ticketBody.innerHTML = courses
.map(
(course) => `
<div class="course" data-course="${course.id}">
<p class="course-label ${course.sent ? "is-sent" : ""}">
<span class="dot"></span>
<span>${course.name}</span>
${course.sent ? `<span class="course-sent-tag">Sent</span>` : ""}
</p>
${
course.lines.length === 0
? `<p class="empty-course">Add an item.</p>`
: course.lines
.map(
(l) => `
<div class="line ${course.sent ? "is-sent" : ""}" data-id="${l.uid}">
${
course.sent
? `<span class="line-qty"><span>${l.qty}</span></span>`
: `<div class="line-qty">
<button data-action="dec" aria-label="Decrease">−</button>
<span>${l.qty}</span>
<button data-action="inc" aria-label="Increase">+</button>
</div>`
}
<div class="line-body">
<p class="line-name">${l.name}</p>
${l.mods ? `<p class="line-mods">${l.mods}</p>` : ""}
</div>
<span class="line-price">${money(l.price * l.qty)}</span>
</div>`
)
.join("")
}
</div>`
)
.join("");
const subtotal = courses.reduce(
(s, c) => s + c.lines.reduce((m, l) => m + l.price * l.qty, 0),
0
);
const tax = subtotal * TAX_RATE;
const service = subtotal * SERVICE_RATE;
const total = subtotal + tax + service;
subtotalEl.textContent = money(subtotal);
taxEl.textContent = money(tax);
serviceEl.textContent = money(service);
totalEl.textContent = money(total);
sendTotalEl.textContent = money(total);
const hasUnsentItems = courses.some((c) => !c.sent && c.lines.length > 0);
const hasAnyItems = courses.some((c) => c.lines.length > 0);
sendBtn.disabled = !hasUnsentItems;
payBtn.disabled = !hasAnyItems;
}
function addItem(id, name, price, tile) {
const course = activeCourse();
let existing = course.lines.find((l) => l.id === id);
if (existing) existing.qty += 1;
else
course.lines.push({
uid: `${id}-${Date.now()}`,
id,
name,
price,
qty: 1,
mods: "",
});
renderTicket();
if (tile) {
tile.classList.remove("is-flash");
void tile.offsetWidth;
tile.classList.add("is-flash");
}
}
function showToast(message) {
toast.textContent = message;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2200);
}
document.querySelectorAll(".seg-btn[data-service]").forEach((btn) => {
btn.addEventListener("click", () => {
serviceMode = btn.dataset.service;
document.querySelectorAll(".seg-btn[data-service]").forEach((b) => {
b.classList.toggle("is-active", b === btn);
});
applyServiceMode();
renderTicket();
});
});
catsEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-cat]");
if (!btn) return;
activeCat = btn.dataset.cat;
renderCats();
renderGrid();
});
tablesEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-table]");
if (!btn) return;
activeTable = Number(btn.dataset.table);
renderTables();
renderTicket();
});
gridEl.addEventListener("click", (e) => {
const tile = e.target.closest("[data-item]");
if (!tile || tile.classList.contains("tile-86")) return;
addItem(tile.dataset.item, tile.dataset.name, Number(tile.dataset.price), tile);
});
ticketBody.addEventListener("click", (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const line = btn.closest("[data-id]");
const courseEl = btn.closest("[data-course]");
if (!line || !courseEl) return;
const course = courses.find((c) => String(c.id) === courseEl.dataset.course);
if (!course || course.sent) return;
const l = course.lines.find((x) => x.uid === line.dataset.id);
if (!l) return;
if (btn.dataset.action === "inc") l.qty += 1;
if (btn.dataset.action === "dec") {
l.qty -= 1;
if (l.qty <= 0) course.lines = course.lines.filter((x) => x.uid !== l.uid);
}
renderTicket();
});
searchEl.addEventListener("input", renderGrid);
courseBtn.addEventListener("click", () => {
if (courses.length >= COURSE_NAMES.length) return;
courses.push({
id: Date.now(),
name: COURSE_NAMES[courses.length],
sent: false,
lines: [],
});
renderTicket();
});
sendBtn.addEventListener("click", () => {
let sentCount = 0;
courses.forEach((c) => {
if (!c.sent && c.lines.length > 0) {
c.sent = true;
sentCount += c.lines.reduce((n, l) => n + l.qty, 0);
}
});
// Auto-open a new course for the next round if all current are sent.
if (courses.every((c) => c.sent) && courses.length < COURSE_NAMES.length) {
courses.push({
id: Date.now(),
name: COURSE_NAMES[courses.length],
sent: false,
lines: [],
});
}
renderTicket();
showToast(`Sent ${sentCount} items to kitchen`);
});
holdBtn.addEventListener("click", () => showToast("Order held"));
printBtn.addEventListener("click", () => showToast("Printing pre-bill…"));
payBtn.addEventListener("click", () => {
if (serviceMode === "dine") {
showToast(`Closing table ${activeTable}`);
} else if (serviceMode === "take") {
showToast(`Take-out #${takeCounter} paid`);
} else {
showToast(`Closing bar stool B${activeTable}`);
}
setTimeout(() => {
courses = [{ id: 1, name: COURSE_NAMES[0], sent: false, lines: [] }];
if (serviceMode === "take") takeCounter += 1;
renderTicket();
}, 800);
});
function tick() {
const d = new Date();
clockEl.textContent = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
tick();
setInterval(tick, 30000);
applyServiceMode();
renderCats();
renderGrid();
renderTicket();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>POS · Order Entry</title>
</head>
<body>
<main class="pos">
<aside class="rail">
<header class="rail-head">
<span class="brand">Casa Olivar</span>
<span class="term">T-02 · Lina</span>
</header>
<div class="rail-section">
<p class="rail-label">Service</p>
<div class="seg" role="tablist">
<button class="seg-btn is-active" data-service="dine">Dine in</button>
<button class="seg-btn" data-service="take">Take out</button>
<button class="seg-btn" data-service="bar">Bar</button>
</div>
</div>
<div class="rail-section" id="tablesSection">
<p class="rail-label" id="tablesLabel">Table</p>
<div class="tables" id="tables"></div>
</div>
<nav class="rail-section cats" id="cats"></nav>
<footer class="rail-foot">
<span class="clock" id="clock">--:--</span>
<button class="util-btn">Sign out</button>
</footer>
</aside>
<section class="menu">
<header class="menu-head">
<div>
<p class="menu-kicker" id="catName">Entradas</p>
<h1 id="catCount">— items</h1>
</div>
<div class="menu-search">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
<path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<input id="search" placeholder="Search dish…" type="search" />
</div>
</header>
<div class="grid" id="grid"></div>
</section>
<aside class="ticket">
<header class="ticket-head">
<div>
<p class="ticket-table" id="ticketTable">Table 7</p>
<p class="ticket-meta" id="ticketMeta">0 items · 0 guests</p>
</div>
<button class="course-btn" id="course">+ Course</button>
</header>
<div class="ticket-body" id="ticketBody"></div>
<footer class="ticket-foot">
<dl class="totals">
<div><dt>Subtotal</dt><dd id="subtotal">$0.00</dd></div>
<div><dt>Tax (8.25%)</dt><dd id="tax">$0.00</dd></div>
<div><dt>Service (10%)</dt><dd id="service">$0.00</dd></div>
<div class="total-big"><dt>Total</dt><dd id="total">$0.00</dd></div>
</dl>
<div class="actions">
<button class="ghost" id="hold" type="button">Hold</button>
<button class="ghost" id="print" type="button">Print</button>
<button class="primary" id="send" type="button" disabled>
<span>Send to kitchen</span>
<span class="total-pill" id="sendTotal">$0.00</span>
</button>
<button class="gold" id="pay" type="button" disabled>Pay</button>
</div>
</footer>
</aside>
</main>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>POS Order Entry Terminal
The full staff-facing POS terminal. Sidebar with categories and table picker, centre menu grid with tap-to-add tiles, live ticket panel on the right with course separators, course-aware send (send only appetizers, hold mains), per-line modifier hint and total breakdown (subtotal · tax · service · total) at the foot.
Builds on the foundation pass: same warm palette, same tile/ticket primitives, expanded to a full-screen terminal layout.