Pages Hard
Takeout Pickup Board
Staff-facing takeout order board: three columns (In Prep / Ready / Picked Up), drag-style status advancement, auto-timer per order, and an audio-alert simulation when orders go ready.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
/* ======================================================
Takeout Pickup Board — Phase 27 Restaurant Theme
====================================================== */
:root {
--cream: #FAF7F1;
--ink: #2C1A0E;
--forest: #345F40;
--forest-d: #213D29;
--terracotta: #C4622D;
--gold: #D4A853;
--warm-gray: #8A7D72;
--bone: #F0EBE0;
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'Inter', system-ui, sans-serif;
--shadow-card: 0 2px 8px rgba(44, 26, 14, 0.10), 0 1px 2px rgba(44, 26, 14, 0.06);
--shadow-card-ready: 0 0 0 2px var(--forest), 0 4px 16px rgba(52, 95, 64, 0.30);
--radius-card: 10px;
--radius-col: 10px;
--transition-base: 0.22s ease;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===================== HEADER ===================== */
.board-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: var(--forest-d);
padding: 16px 24px;
flex-wrap: wrap;
}
.header-left {
display: flex;
align-items: baseline;
gap: 16px;
}
.board-title {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 800;
color: var(--bone);
letter-spacing: -0.01em;
}
.board-clock {
font-family: var(--font-body);
font-size: 1.1rem;
font-weight: 600;
color: var(--gold);
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn-new {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--terracotta);
color: var(--bone);
border: none;
border-radius: 8px;
padding: 10px 18px;
font-family: var(--font-body);
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: background var(--transition-base), transform 0.1s;
}
.btn-new:hover {
background: #a84e24;
}
.btn-new:active {
transform: scale(0.97);
}
.btn-demo {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
color: var(--bone);
border: 1.5px solid rgba(240, 235, 224, 0.35);
border-radius: 8px;
padding: 9px 14px;
font-family: var(--font-body);
font-size: 0.813rem;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-base), border-color var(--transition-base);
}
.btn-demo:hover,
.btn-demo.is-active {
background: rgba(240, 235, 224, 0.12);
border-color: var(--gold);
color: var(--gold);
}
.btn-demo.is-active .demo-icon {
animation: spin 1.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===================== BOARD GRID ===================== */
.board-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
padding: 20px;
flex: 1;
align-items: start;
}
@media (max-width: 860px) {
.board-grid {
grid-template-columns: 1fr;
}
}
/* ===================== COLUMNS ===================== */
.board-column {
border-radius: var(--radius-col);
overflow: hidden;
display: flex;
flex-direction: column;
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--forest);
color: var(--bone);
padding: 14px 16px;
border-radius: var(--radius-col) var(--radius-col) 0 0;
}
.column-header--ready {
background: var(--forest-d);
}
.column-header--done {
background: var(--warm-gray);
}
.column-title {
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.column-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(240, 235, 224, 0.22);
color: var(--bone);
border-radius: 999px;
min-width: 26px;
height: 26px;
font-size: 0.8rem;
font-weight: 700;
padding: 0 7px;
transition: background var(--transition-base);
}
.column-body {
background: var(--bone);
border-radius: 0 0 var(--radius-col) var(--radius-col);
padding: 12px;
min-height: 300px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ===================== ORDER CARDS ===================== */
.order-card {
background: var(--cream);
border-radius: var(--radius-card);
padding: 14px;
box-shadow: var(--shadow-card);
transition: box-shadow var(--transition-base), opacity var(--transition-base), transform var(--transition-base);
position: relative;
overflow: hidden;
}
.order-card:hover {
box-shadow: 0 4px 14px rgba(44, 26, 14, 0.14);
transform: translateY(-1px);
}
/* READY state */
.order-card.is-ready {
box-shadow: var(--shadow-card-ready);
animation: pulse-green 1.8s ease-in-out 3;
}
@keyframes pulse-green {
0%, 100% { box-shadow: var(--shadow-card-ready); }
50% { box-shadow: 0 0 0 3px var(--forest), 0 6px 24px rgba(52, 95, 64, 0.45); }
}
/* PICKED UP state */
.order-card.is-done .order-customer {
text-decoration: line-through;
color: var(--warm-gray);
}
.order-card.is-done {
opacity: 0.52;
}
/* COLLAPSING state */
.order-card.is-collapsing {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
opacity: 0;
transform: scaleY(0);
transform-origin: top;
transition: max-height 0.5s ease, opacity 0.4s ease, padding 0.4s ease, transform 0.4s ease;
overflow: hidden;
}
/* Card layout */
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
gap: 8px;
}
.order-number {
background: var(--terracotta);
color: var(--bone);
border-radius: 999px;
padding: 3px 10px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
flex-shrink: 0;
}
.ready-badge {
display: none;
background: var(--forest);
color: var(--bone);
border-radius: 6px;
padding: 3px 8px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.03em;
animation: badge-pop 0.3s ease;
}
@keyframes badge-pop {
0% { transform: scale(0.7); opacity: 0; }
60% { transform: scale(1.12); opacity: 1; }
100% { transform: scale(1); }
}
.order-card.is-ready .ready-badge {
display: inline-block;
}
.order-customer {
font-family: var(--font-body);
font-size: 1rem;
font-weight: 700;
color: var(--ink);
margin-bottom: 6px;
transition: color var(--transition-base), text-decoration var(--transition-base);
}
.order-items {
list-style: none;
margin-bottom: 10px;
}
.order-items li {
font-size: 0.82rem;
color: var(--warm-gray);
padding: 1px 0;
display: flex;
align-items: flex-start;
gap: 5px;
}
.order-items li::before {
content: '·';
color: var(--gold);
font-size: 1.1em;
line-height: 1.2;
flex-shrink: 0;
}
.card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
}
.order-time {
font-size: 0.78rem;
color: var(--warm-gray);
font-variant-numeric: tabular-nums;
}
.order-timer {
font-size: 0.78rem;
color: var(--warm-gray);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.order-timer.is-urgent {
color: var(--terracotta);
}
.card-footer {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.btn-advance {
background: rgba(52, 95, 64, 0.10);
color: var(--forest);
border: 1.5px solid rgba(52, 95, 64, 0.20);
border-radius: 7px;
padding: 6px 14px;
font-family: var(--font-body);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-base), border-color var(--transition-base);
white-space: nowrap;
}
.btn-advance:hover {
background: rgba(52, 95, 64, 0.18);
border-color: var(--forest);
}
/* ===================== ALERT TOAST ===================== */
.alert-toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--forest-d);
color: var(--bone);
border-radius: 12px;
padding: 14px 22px;
font-family: var(--font-body);
font-size: 0.95rem;
font-weight: 600;
box-shadow: 0 8px 24px rgba(33, 61, 41, 0.35);
opacity: 0;
transform: translateY(14px) scale(0.96);
pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease;
z-index: 999;
}
.alert-toast.is-visible {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}/* ======================================================
Takeout Pickup Board — script.js
Phase 27 Restaurant Theme
====================================================== */
// ── Base time for elapsed calculations (board is "live" at 20:46) ──
const BASE_TIME_STR = '20:46';
const BASE_MINUTES = timeToMinutes(BASE_TIME_STR);
function timeToMinutes(t) {
const [h, m] = t.split(':').map(Number);
return h * 60 + m;
}
function minutesToTime(m) {
const h = Math.floor(m / 60) % 24;
const min = m % 60;
return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
}
function elapsedLabel(orderedAt) {
const diff = BASE_MINUTES - timeToMinutes(orderedAt);
if (diff <= 0) return '0 min';
if (diff < 60) return `${diff} min`;
const h = Math.floor(diff / 60);
const m = diff % 60;
return m ? `${h}h ${m}m` : `${h}h`;
}
function isUrgent(orderedAt) {
return (BASE_MINUTES - timeToMinutes(orderedAt)) >= 20;
}
// ── Initial state ──
const INITIAL_ORDERS = {
prep: [
{ id: 'T-048', name: 'Carmen López', items: ['Burrata', 'Ribeye 14oz', 'Sorbete cítrico'], orderedAt: '20:31', addedToDoneAt: null },
{ id: 'T-051', name: 'David Kim', items: ['Pulpo brasa', 'Risotto hongos'], orderedAt: '20:38', addedToDoneAt: null },
],
ready: [
{ id: 'T-044', name: 'Priya Shah', items: ['Pappardelle ragú', 'Tarta de queso'], orderedAt: '20:15', addedToDoneAt: null },
{ id: 'T-046', name: 'Luis Fernández', items: ['Pan masa madre', 'Pollo carbón'], orderedAt: '20:22', addedToDoneAt: null },
],
done: [
{ id: 'T-039', name: 'Emma Walsh', items: ['Ensalada huerta'], orderedAt: '19:58', addedToDoneAt: Date.now() },
],
};
// Deep clone initial state
let state = JSON.parse(JSON.stringify(INITIAL_ORDERS));
// ── Counter for new orders ──
let orderCounter = 52;
// ── Random customer names and items pools ──
const NAMES = [
'Sofia Martín', 'James O\'Brien', 'Yuki Tanaka', 'Ana Torres',
'Marco Rossi', 'Léa Bernard', 'Carlos Vega', 'Nina Petrov',
'Omar Khalil', 'Mei Lin', 'Tomás Reyes', 'Ingrid Holm',
];
const ITEMS_POOL = [
'Croquetas jamón', 'Gazpacho andaluz', 'Patatas bravas',
'Tortilla española', 'Pulpo a la gallega', 'Salmón tartar',
'Chuletón 400g', 'Pasta al nero', 'Arroz meloso', 'Tarta Santiago',
'Crema catalana', 'Helado trufa', 'Pan cristal', 'Burrata stracciatella',
'Gambas al ajillo', 'Merluza a la vasca',
];
function randomName() {
return NAMES[Math.floor(Math.random() * NAMES.length)];
}
function randomItems() {
const count = 2 + Math.floor(Math.random() * 2); // 2 or 3 items
const shuffled = [...ITEMS_POOL].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}
function nextOrderId() {
orderCounter++;
return `T-${String(orderCounter).padStart(3, '0')}`;
}
// ── Render a single order card ──
function renderCard(order, column) {
const card = document.createElement('div');
card.className = 'order-card';
card.dataset.id = order.id;
if (column === 'ready') card.classList.add('is-ready');
if (column === 'done') card.classList.add('is-done');
const elapsed = elapsedLabel(order.orderedAt);
const urgent = isUrgent(order.orderedAt);
const advanceLabel = column === 'prep' ? '→ Ready' :
column === 'ready' ? '→ Picked Up' : null;
const itemsHtml = order.items.slice(0, 3)
.map(i => `<li>${escapeHtml(i)}</li>`)
.join('');
card.innerHTML = `
<div class="card-top">
<span class="order-number">${escapeHtml(order.id)}</span>
<span class="ready-badge">READY 🔔</span>
</div>
<p class="order-customer">${escapeHtml(order.name)}</p>
<ul class="order-items">${itemsHtml}</ul>
<div class="card-meta">
<span class="order-time">Ordered: ${escapeHtml(order.orderedAt)}</span>
<span class="order-timer${urgent ? ' is-urgent' : ''}">⏱ ${elapsed}</span>
</div>
${advanceLabel ? `<div class="card-footer"><button class="btn-advance" data-id="${escapeHtml(order.id)}" data-col="${column}">${advanceLabel}</button></div>` : ''}
`;
return card;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// ── Render all columns ──
function render() {
const lists = {
prep: document.getElementById('list-prep'),
ready: document.getElementById('list-ready'),
done: document.getElementById('list-done'),
};
for (const [col, list] of Object.entries(lists)) {
list.innerHTML = '';
state[col].forEach(order => {
const card = renderCard(order, col);
list.appendChild(card);
});
}
// Update badges
document.getElementById('badge-prep').textContent = state.prep.length;
document.getElementById('badge-ready').textContent = state.ready.length;
document.getElementById('badge-done').textContent = state.done.length;
// Attach advance button listeners
document.querySelectorAll('.btn-advance').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const col = btn.dataset.col;
advanceOrder(id, col);
});
});
}
// ── Advance order from one column to the next ──
function advanceOrder(id, fromCol) {
const toCol = fromCol === 'prep' ? 'ready' : 'done';
const idx = state[fromCol].findIndex(o => o.id === id);
if (idx === -1) return;
const [order] = state[fromCol].splice(idx, 1);
if (toCol === 'done') {
order.addedToDoneAt = Date.now();
}
state[toCol].push(order);
render();
if (toCol === 'ready') {
triggerAlert(order);
// Schedule removal of pulse class after animation
requestAnimationFrame(() => {
const card = document.querySelector(`.order-card[data-id="${order.id}"]`);
if (card) {
// pulse-green runs 3 times = 3 * 1.8s = 5.4s
setTimeout(() => card.classList.remove('is-ready-pulse'), 5400);
}
});
}
if (toCol === 'done') {
scheduleDoneCollapse(order.id);
}
}
// ── Alert toast ──
let alertTimeout = null;
function triggerAlert(order) {
const toast = document.getElementById('alert-toast');
toast.textContent = `🔔 ${order.id} — ${order.name} ready!`;
toast.classList.add('is-visible');
clearTimeout(alertTimeout);
alertTimeout = setTimeout(() => {
toast.classList.remove('is-visible');
}, 3500);
}
// ── Schedule collapse of a done card after 60s ──
function scheduleDoneCollapse(orderId) {
setTimeout(() => {
const card = document.querySelector(`#list-done .order-card[data-id="${orderId}"]`);
if (!card) return;
card.classList.add('is-collapsing');
// Wait for CSS transition to finish, then remove from state + re-render
card.addEventListener('transitionend', () => {
state.done = state.done.filter(o => o.id !== orderId);
render();
}, { once: true });
}, 60_000);
}
// ── Clock update ──
function updateClock() {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
document.getElementById('board-clock').textContent = `${h}:${m}`;
}
// ── Timer update (every 30s) ──
function updateTimers() {
// Re-render is the simplest approach since there's no complex animation state
render();
}
// ── "+ New Order" button ──
document.getElementById('btn-new-order').addEventListener('click', () => {
addNewOrder();
});
function addNewOrder() {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
const newOrder = {
id: nextOrderId(),
name: randomName(),
items: randomItems(),
orderedAt: `${h}:${m}`,
addedToDoneAt: null,
};
state.prep.push(newOrder);
render();
// Flash the new card
requestAnimationFrame(() => {
const card = document.querySelector(`#list-prep .order-card[data-id="${newOrder.id}"]`);
if (card) {
card.style.transition = 'box-shadow 0.2s, transform 0.2s, opacity 0.2s';
card.style.boxShadow = '0 0 0 3px var(--gold)';
card.style.transform = 'scale(1.025)';
setTimeout(() => {
card.style.boxShadow = '';
card.style.transform = '';
}, 600);
}
});
}
// ── Auto-demo toggle ──
let demoInterval = null;
const toggleDemoBtn = document.getElementById('toggle-demo');
toggleDemoBtn.addEventListener('click', () => {
const isActive = toggleDemoBtn.classList.toggle('is-active');
toggleDemoBtn.setAttribute('aria-pressed', String(isActive));
if (isActive) {
startAutoDemo();
} else {
stopAutoDemo();
}
});
function startAutoDemo() {
runDemoStep(); // run immediately
demoInterval = setInterval(runDemoStep, 25_000);
}
function stopAutoDemo() {
clearInterval(demoInterval);
demoInterval = null;
}
function runDemoStep() {
// Advance oldest prep order → ready, or if none add a new prep order
if (state.prep.length > 0) {
const oldest = state.prep[0];
advanceOrder(oldest.id, 'prep');
} else {
addNewOrder();
}
}
// ── Init ──
function init() {
render();
updateClock();
// Schedule existing done orders for collapse
state.done.forEach(order => {
if (order.addedToDoneAt) {
const elapsed = Date.now() - order.addedToDoneAt;
const remaining = Math.max(0, 60_000 - elapsed);
setTimeout(() => {
const card = document.querySelector(`#list-done .order-card[data-id="${order.id}"]`);
if (!card) return;
card.classList.add('is-collapsing');
card.addEventListener('transitionend', () => {
state.done = state.done.filter(o => o.id !== order.id);
render();
}, { once: true });
}, remaining);
}
});
// Clock ticks every 30s
setInterval(updateClock, 30_000);
// Timer labels refresh every 30s
setInterval(updateTimers, 30_000);
}
init();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Takeout Pickup Board</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Inter:wght@400;500;600;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="board-header">
<div class="header-left">
<h1 class="board-title">Takeout Pickup Board</h1>
<span class="board-clock" id="board-clock">20:46</span>
</div>
<div class="header-actions">
<button class="btn-demo" id="toggle-demo" aria-pressed="false">
<span class="demo-icon">⏱</span> Auto Demo
</button>
<button class="btn-new" id="btn-new-order">
<span>+</span> New Order
</button>
</div>
</header>
<main class="board-grid">
<!-- In Prep Column -->
<section class="board-column" id="col-prep">
<div class="column-header">
<span class="column-title">In Prep</span>
<span class="column-badge" id="badge-prep">0</span>
</div>
<div class="column-body" id="list-prep">
<!-- Cards injected by JS -->
</div>
</section>
<!-- Ready Column -->
<section class="board-column" id="col-ready">
<div class="column-header column-header--ready">
<span class="column-title">Ready for Pickup</span>
<span class="column-badge" id="badge-ready">0</span>
</div>
<div class="column-body" id="list-ready">
<!-- Cards injected by JS -->
</div>
</section>
<!-- Picked Up Column -->
<section class="board-column" id="col-done">
<div class="column-header column-header--done">
<span class="column-title">Picked Up</span>
<span class="column-badge" id="badge-done">0</span>
</div>
<div class="column-body" id="list-done">
<!-- Cards injected by JS -->
</div>
</section>
</main>
<!-- Audio alert simulation toast -->
<div class="alert-toast" id="alert-toast" role="alert" aria-live="assertive">
🔔 Order ready for pickup!
</div>
<script src="script.js"></script>
</body>
</html>Takeout Pickup Board
Kitchen-facing board. Three status columns: “In Prep”, “Ready for Pickup”, “Picked Up”. Each order card shows: order number, customer name, items (2-3 lines), order time, and an elapsed timer. “Advance” button moves the card to the next column. When an order enters “Ready”, the card pulses green and shows “READY 🔔”. “Picked Up” column collapses after 60s. A ”+ New order” button adds a mock order to “In Prep”. Auto-demo mode adds a new order every 30 seconds.