Museum — Ticket Booking
A refined, gallery-toned timed-entry booking flow for a fictional art museum. Visitors pick a date on a mini calendar that closes Mondays and dims past days, choose an entry time slot showing remaining capacity, then add adult, senior, student, child, free-child, and member tickets with quantity steppers. A live order summary tallies subtotal, per-ticket service fee, and total while guarding slot capacity and the per-order limit, with toasts confirming each step and a validated continue-to-checkout button.
MCP
Code
:root {
--paper: #f6f4ef;
--wall: #ffffff;
--charcoal: #1c1b19;
--ink: #2a2825;
--ink-2: #4a4640;
--muted: #8c857a;
--gold: #a98140;
--gold-d: #876631;
--gold-50: #f3ecdd;
--line: rgba(28, 27, 25, 0.12);
--line-2: rgba(28, 27, 25, 0.2);
--ok: #3f7d56;
--warn: #b8842c;
--danger: #b4493a;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--shadow-sm: 0 1px 2px rgba(28, 27, 25, 0.05);
--shadow-md: 0 10px 30px -16px rgba(28, 27, 25, 0.28);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.wrap { width: min(1120px, 92vw); margin-inline: auto; }
.skip {
position: absolute;
left: -999px;
top: 0;
background: var(--charcoal);
color: #fff;
padding: 10px 16px;
border-radius: 0 0 var(--r-sm) 0;
z-index: 50;
}
.skip:focus { left: 0; }
a { color: inherit; }
/* ---------- Masthead ---------- */
.masthead {
background: var(--wall);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 20;
}
.masthead-in {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 0;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--charcoal);
color: var(--gold-50);
font-family: var(--serif);
font-weight: 700;
font-size: 22px;
line-height: 1;
}
.brand-name {
font-family: var(--serif);
font-weight: 600;
font-size: 21px;
letter-spacing: 0.01em;
color: var(--charcoal);
}
.masthead-nav { display: flex; gap: 26px; }
.masthead-nav a {
text-decoration: none;
color: var(--ink-2);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
padding-bottom: 2px;
border-bottom: 1.5px solid transparent;
}
.masthead-nav a:hover { color: var(--charcoal); }
.masthead-nav a[aria-current="page"] {
color: var(--charcoal);
border-bottom-color: var(--gold);
}
/* ---------- Hero ---------- */
.booking { padding: 48px 0 64px; }
.hero { max-width: 720px; margin-bottom: 36px; }
.eyebrow {
margin: 0 0 10px;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--gold-d);
font-weight: 600;
}
.hero h1 {
font-family: var(--serif);
font-weight: 700;
font-size: clamp(2.4rem, 5vw, 3.5rem);
line-height: 1.05;
margin: 0 0 14px;
color: var(--charcoal);
}
.lede { margin: 0; color: var(--ink-2); font-size: 17px; max-width: 60ch; }
.lede em { font-style: italic; color: var(--ink); }
/* ---------- Layout grid ---------- */
.grid {
display: grid;
grid-template-columns: 1fr 360px;
gap: 28px;
align-items: start;
}
.flow { display: grid; gap: 22px; }
/* ---------- Cards ---------- */
.card {
background: var(--wall);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
}
.step { padding: 26px 26px 28px; }
.step-head {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 20px;
}
.step-num {
flex: none;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--gold);
color: var(--gold-d);
font-weight: 600;
font-size: 14px;
background: var(--gold-50);
}
.step-head h2 {
font-family: var(--serif);
font-weight: 600;
font-size: 1.6rem;
margin: 0;
color: var(--charcoal);
line-height: 1.1;
}
.step-sub { margin: 4px 0 0; font-size: 13.5px; color: var(--muted); }
/* ---------- Calendar ---------- */
.cal-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.cal-title {
font-family: var(--serif);
font-weight: 600;
font-size: 1.25rem;
margin: 0;
color: var(--ink);
}
.cal-nav {
width: 34px;
height: 34px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--wall);
color: var(--ink);
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.cal-nav:hover { background: var(--paper); border-color: var(--gold); }
.cal-nav:disabled { opacity: 0.35; cursor: not-allowed; }
.cal-dow {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin-bottom: 6px;
}
.cal-dow span {
text-align: center;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.cal-cell {
aspect-ratio: 1 / 1;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: transparent;
color: var(--ink);
font-family: var(--sans);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: grid;
place-items: center;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.cal-cell.is-pad { visibility: hidden; }
.cal-cell.is-avail:hover { background: var(--gold-50); border-color: var(--gold); }
.cal-cell.is-disabled {
color: var(--line-2);
cursor: not-allowed;
text-decoration: line-through;
}
.cal-cell.is-selected {
background: var(--charcoal);
color: var(--gold-50);
border-color: var(--charcoal);
}
.cal-cell:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.cal-legend {
display: flex;
align-items: center;
gap: 8px;
margin: 14px 0 0;
font-size: 12px;
color: var(--muted);
}
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.dot-on { background: var(--gold); }
.dot-off { background: var(--line-2); margin-left: 10px; }
/* ---------- Slots ---------- */
.slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.slots .slots-msg {
grid-column: 1 / -1;
color: var(--muted);
font-size: 14px;
padding: 8px 0;
}
.slot {
text-align: left;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
background: var(--wall);
padding: 12px 14px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.slot:hover:not(:disabled) { border-color: var(--gold); }
.slot-time {
display: block;
font-family: var(--serif);
font-size: 1.25rem;
font-weight: 600;
color: var(--charcoal);
line-height: 1.1;
}
.slot-cap { display: block; font-size: 12px; margin-top: 3px; color: var(--ok); }
.slot-cap.low { color: var(--warn); }
.slot.is-full { opacity: 0.55; cursor: not-allowed; }
.slot.is-full .slot-cap { color: var(--danger); }
.slot.is-selected {
border-color: var(--charcoal);
background: var(--gold-50);
box-shadow: inset 0 0 0 1px var(--charcoal);
}
.slot:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
/* ---------- Tickets ---------- */
.tickets { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
.tk {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 4px;
border-bottom: 1px solid var(--line);
}
.tk:last-child { border-bottom: 0; }
.tk-info { flex: 1; min-width: 0; }
.tk-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 15.5px;
color: var(--ink);
}
.tk-badge {
font-size: 10.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 600;
padding: 2px 7px;
border-radius: 999px;
background: var(--gold-50);
color: var(--gold-d);
}
.tk-desc { font-size: 13px; color: var(--muted); margin-top: 2px; }
.tk-price {
width: 64px;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--ink);
font-size: 15px;
}
.tk-price small { display: block; font-weight: 400; font-size: 11px; color: var(--muted); }
.tk-free { color: var(--ok); }
/* Stepper */
.stepper {
display: inline-flex;
align-items: center;
border: 1px solid var(--line-2);
border-radius: 999px;
background: var(--wall);
}
.stepper button {
width: 34px;
height: 34px;
border: 0;
background: transparent;
font-size: 18px;
line-height: 1;
color: var(--ink);
cursor: pointer;
border-radius: 999px;
transition: background 0.15s, color 0.15s;
}
.stepper button:hover:not(:disabled) { background: var(--gold-50); color: var(--gold-d); }
.stepper button:disabled { color: var(--line-2); cursor: not-allowed; }
.stepper button:focus-visible { outline: 2px solid var(--gold); outline-offset: 1px; }
.stepper .qty {
min-width: 30px;
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 15px;
}
.ticket-note { margin: 18px 0 0; font-size: 12.5px; color: var(--muted); }
/* ---------- Summary ---------- */
.summary-col { position: sticky; top: 92px; }
.summary { padding: 24px 24px 26px; }
.summary-title {
font-family: var(--serif);
font-weight: 600;
font-size: 1.5rem;
margin: 0 0 16px;
color: var(--charcoal);
}
.sum-meta {
margin: 0 0 18px;
padding: 0 0 18px;
border-bottom: 1px solid var(--line);
display: grid;
gap: 8px;
}
.sum-meta div { display: flex; justify-content: space-between; gap: 12px; }
.sum-meta dt { margin: 0; color: var(--muted); font-size: 13.5px; }
.sum-meta dd { margin: 0; font-weight: 500; font-size: 14px; text-align: right; color: var(--ink); }
.sum-lines { list-style: none; margin: 0 0 16px; padding: 0; display: grid; gap: 8px; }
.sum-lines li {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 14px;
}
.sum-lines .sl-label { color: var(--ink-2); }
.sum-lines .sl-amt { font-variant-numeric: tabular-nums; font-weight: 500; }
.sum-empty { color: var(--muted); font-style: italic; }
.sum-totals {
margin: 0;
padding-top: 16px;
border-top: 1px solid var(--line);
display: grid;
gap: 9px;
}
.sum-totals div { display: flex; justify-content: space-between; gap: 12px; }
.sum-totals dt { margin: 0; color: var(--ink-2); font-size: 14px; }
.sum-totals dd { margin: 0; font-variant-numeric: tabular-nums; font-weight: 500; }
.fee-note { color: var(--muted); font-size: 12px; }
.sum-grand dt, .sum-grand dd {
font-family: var(--serif);
font-size: 1.5rem;
font-weight: 700;
color: var(--charcoal);
}
.btn-checkout {
width: 100%;
margin-top: 20px;
padding: 15px 18px;
border: 0;
border-radius: var(--r-md);
background: var(--charcoal);
color: var(--gold-50);
font-family: var(--sans);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
transition: background 0.2s, transform 0.05s;
}
.btn-checkout:hover { background: #000; }
.btn-checkout:active { transform: translateY(1px); }
.btn-checkout:focus-visible { outline: 2px solid var(--gold); outline-offset: 3px; }
.btn-checkout:disabled { background: var(--line-2); color: var(--wall); cursor: not-allowed; }
.sum-fine { margin: 12px 0 0; text-align: center; font-size: 12px; color: var(--muted); }
/* ---------- Footer ---------- */
.foot { border-top: 1px solid var(--line); background: var(--wall); margin-top: 24px; }
.foot-in { padding: 24px 0; font-size: 13px; color: var(--ink-2); }
.foot-in p { margin: 0 0 4px; }
.foot-dim { color: var(--muted); }
/* ---------- Toast ---------- */
.toast-wrap {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
display: grid;
gap: 10px;
z-index: 80;
width: max-content;
max-width: 92vw;
}
.toast {
background: var(--charcoal);
color: #f6f4ef;
padding: 13px 18px;
border-radius: var(--r-md);
font-size: 14px;
box-shadow: var(--shadow-md);
border-left: 3px solid var(--gold);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.25s, transform 0.25s;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.ok { border-left-color: var(--ok); }
.toast.warn { border-left-color: var(--warn); }
/* ---------- Responsive ---------- */
@media (max-width: 880px) {
.grid { grid-template-columns: 1fr; }
.summary-col { position: static; }
}
@media (max-width: 520px) {
body { font-size: 15px; }
.masthead-nav { gap: 16px; }
.masthead-nav a { font-size: 13px; }
.brand-name { font-size: 18px; }
.booking { padding: 32px 0 48px; }
.step { padding: 20px 18px 22px; }
.step-head h2 { font-size: 1.4rem; }
.slots { grid-template-columns: repeat(2, 1fr); }
.tk { flex-wrap: wrap; gap: 10px; }
.tk-info { flex: 1 1 60%; }
.tk-price { width: auto; order: 3; }
.summary { padding: 20px 18px 22px; }
}(function () {
"use strict";
// ---------- Config / demo data ----------
var SERVICE_FEE = 2.5; // per paid ticket
var MAX_PAID = 10;
var TICKET_TYPES = [
{ id: "adult", name: "Adult", desc: "Ages 18–64", price: 24, badge: null },
{ id: "senior", name: "Senior", desc: "Ages 65+ · ID at door", price: 18, badge: null },
{ id: "student", name: "Student", desc: "Valid student ID", price: 16, badge: null },
{ id: "child", name: "Child", desc: "Ages 6–17", price: 10, badge: null },
{ id: "free", name: "Child 5 & under", desc: "No charge", price: 0, badge: "Free" },
{ id: "member", name: "Member", desc: "Present card at door", price: 0, badge: "Members" }
];
// Time slots (label, base capacity). Closed days handled separately.
var SLOT_TEMPLATE = [
{ time: "10:00 AM", cap: 60 },
{ time: "11:30 AM", cap: 60 },
{ time: "1:00 PM", cap: 45 },
{ time: "2:30 PM", cap: 45 },
{ time: "4:00 PM", cap: 30 }
];
var MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
// ---------- State ----------
var today = new Date(); today.setHours(0, 0, 0, 0);
var viewYear = today.getFullYear();
var viewMonth = today.getMonth();
var selectedDate = null; // Date
var selectedSlot = null; // { time, remaining }
var qty = {};
TICKET_TYPES.forEach(function (t) { qty[t.id] = 0; });
// ---------- Elements ----------
var $ = function (id) { return document.getElementById(id); };
var calGrid = $("calGrid"), calTitle = $("calTitle");
var slotsEl = $("slots"), slotSub = $("slotSub");
var ticketsEl = $("tickets");
var sumDate = $("sumDate"), sumTime = $("sumTime"), sumQty = $("sumQty");
var sumLines = $("sumLines"), sumSub = $("sumSub"), sumFee = $("sumFee"), sumTotal = $("sumTotal");
var checkoutBtn = $("checkout");
// ---------- Helpers ----------
function money(n) { return "$" + n.toFixed(2); }
function isOpenDay(d) { return d.getDay() !== 1; } // closed Mondays
function sameDate(a, b) {
return a && b && a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function dateKey(d) { return d.getFullYear() + "-" + d.getMonth() + "-" + d.getDate(); }
// Deterministic pseudo-random per date+slot so capacity feels real but stable.
function seededRemaining(d, idx, cap) {
var seed = (d.getFullYear() * 1000 + (d.getMonth() + 1) * 40 + d.getDate()) * 7 + idx * 13;
var r = Math.abs(Math.sin(seed) * 10000) % 1;
var sold = Math.floor(r * (cap + 8));
return Math.max(0, cap - sold);
}
var dateFmt = new Intl.DateTimeFormat("en-US", {
weekday: "short", month: "short", day: "numeric", year: "numeric"
});
// ---------- Toast ----------
var toastWrap = $("toastWrap");
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
toastWrap.appendChild(el);
requestAnimationFrame(function () { el.classList.add("show"); });
setTimeout(function () {
el.classList.remove("show");
setTimeout(function () { el.remove(); }, 300);
}, 2600);
}
// ---------- Calendar ----------
function renderCalendar() {
calTitle.textContent = MONTHS[viewMonth] + " " + viewYear;
calGrid.innerHTML = "";
var first = new Date(viewYear, viewMonth, 1);
var startPad = first.getDay();
var daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
for (var p = 0; p < startPad; p++) {
var pad = document.createElement("div");
pad.className = "cal-cell is-pad";
calGrid.appendChild(pad);
}
for (var day = 1; day <= daysInMonth; day++) {
var d = new Date(viewYear, viewMonth, day);
var btn = document.createElement("button");
btn.type = "button";
btn.className = "cal-cell";
btn.textContent = String(day);
btn.setAttribute("role", "gridcell");
var past = d < today;
var closed = !isOpenDay(d);
var avail = !past && !closed;
if (avail) {
btn.classList.add("is-avail");
btn.setAttribute("aria-label", dateFmt.format(d) + " — available");
btn.addEventListener("click", makeDatePick(d));
} else {
btn.classList.add("is-disabled");
btn.disabled = true;
btn.setAttribute("aria-label", dateFmt.format(d) + (closed ? " — closed" : " — unavailable"));
}
if (sameDate(d, selectedDate)) {
btn.classList.add("is-selected");
btn.setAttribute("aria-current", "date");
}
calGrid.appendChild(btn);
}
// Disable prev nav when at/before current month
var prevBtn = $("calPrev");
var atCurrent = (viewYear === today.getFullYear() && viewMonth === today.getMonth());
prevBtn.disabled = atCurrent;
}
function makeDatePick(d) {
return function () {
selectedDate = d;
selectedSlot = null;
renderCalendar();
renderSlots();
updateSummary();
toast("Date set — " + dateFmt.format(d), "ok");
};
}
$("calPrev").addEventListener("click", function () {
viewMonth--;
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
renderCalendar();
});
$("calNext").addEventListener("click", function () {
viewMonth++;
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
renderCalendar();
});
// ---------- Slots ----------
function renderSlots() {
slotsEl.innerHTML = "";
if (!selectedDate) {
slotSub.textContent = "Select a date to see available times.";
var msg = document.createElement("p");
msg.className = "slots-msg";
msg.textContent = "No date chosen yet.";
slotsEl.appendChild(msg);
return;
}
slotSub.textContent = "Entry times for " + dateFmt.format(selectedDate);
SLOT_TEMPLATE.forEach(function (s, idx) {
var remaining = seededRemaining(selectedDate, idx, s.cap);
var btn = document.createElement("button");
btn.type = "button";
btn.className = "slot";
btn.setAttribute("role", "radio");
btn.setAttribute("aria-checked", "false");
var full = remaining === 0;
var low = !full && remaining <= 10;
btn.innerHTML =
'<span class="slot-time">' + s.time + "</span>" +
'<span class="slot-cap' + (low ? " low" : "") + '">' +
(full ? "Sold out" : remaining + " left") + "</span>";
if (full) {
btn.classList.add("is-full");
btn.disabled = true;
btn.setAttribute("aria-disabled", "true");
} else {
btn.addEventListener("click", function () {
selectedSlot = { time: s.time, remaining: remaining };
Array.prototype.forEach.call(slotsEl.querySelectorAll(".slot"), function (b) {
b.classList.remove("is-selected");
b.setAttribute("aria-checked", "false");
});
btn.classList.add("is-selected");
btn.setAttribute("aria-checked", "true");
updateSummary();
});
}
if (selectedSlot && selectedSlot.time === s.time && !full) {
btn.classList.add("is-selected");
btn.setAttribute("aria-checked", "true");
}
slotsEl.appendChild(btn);
});
}
// ---------- Tickets ----------
function totalPaid() {
return TICKET_TYPES.reduce(function (n, t) {
return n + (t.price > 0 ? qty[t.id] : 0);
}, 0);
}
function totalGuests() {
return TICKET_TYPES.reduce(function (n, t) { return n + qty[t.id]; }, 0);
}
function renderTickets() {
ticketsEl.innerHTML = "";
TICKET_TYPES.forEach(function (t) {
var li = document.createElement("li");
li.className = "tk";
var info = document.createElement("div");
info.className = "tk-info";
var nameHtml = '<span class="tk-name">' + t.name +
(t.badge ? ' <span class="tk-badge">' + t.badge + "</span>" : "") + "</span>" +
'<span class="tk-desc">' + t.desc + "</span>";
info.innerHTML = nameHtml;
var price = document.createElement("div");
price.className = "tk-price";
if (t.price === 0) {
price.innerHTML = '<span class="tk-free">Free</span>';
} else {
price.innerHTML = money(t.price) + "<small>per ticket</small>";
}
var stepper = document.createElement("div");
stepper.className = "stepper";
stepper.setAttribute("role", "group");
stepper.setAttribute("aria-label", t.name + " quantity");
var minus = document.createElement("button");
minus.type = "button";
minus.textContent = "−";
minus.setAttribute("aria-label", "Remove one " + t.name);
var q = document.createElement("span");
q.className = "qty";
q.id = "qty-" + t.id;
q.setAttribute("aria-live", "polite");
q.textContent = "0";
var plus = document.createElement("button");
plus.type = "button";
plus.textContent = "+";
plus.setAttribute("aria-label", "Add one " + t.name);
minus.addEventListener("click", function () { changeQty(t, -1); });
plus.addEventListener("click", function () { changeQty(t, 1); });
stepper.appendChild(minus);
stepper.appendChild(q);
stepper.appendChild(plus);
li.appendChild(info);
li.appendChild(price);
li.appendChild(stepper);
ticketsEl.appendChild(li);
});
}
function changeQty(t, delta) {
var next = qty[t.id] + delta;
if (next < 0) return;
if (delta > 0 && t.price > 0 && totalPaid() >= MAX_PAID) {
toast("Up to " + MAX_PAID + " paid tickets per order.", "warn");
return;
}
// Capacity guard against the chosen slot
if (delta > 0 && selectedSlot && totalGuests() >= selectedSlot.remaining) {
toast("Only " + selectedSlot.remaining + " spaces left in that time slot.", "warn");
return;
}
qty[t.id] = next;
var qEl = $("qty-" + t.id);
qEl.textContent = String(next);
updateSummary();
}
// ---------- Summary ----------
function updateSummary() {
sumDate.textContent = selectedDate ? dateFmt.format(selectedDate) : "—";
sumTime.textContent = selectedSlot ? selectedSlot.time : "—";
sumQty.textContent = String(totalGuests());
sumLines.innerHTML = "";
var subtotal = 0;
var any = false;
TICKET_TYPES.forEach(function (t) {
if (qty[t.id] > 0) {
any = true;
var line = t.price * qty[t.id];
subtotal += line;
var li = document.createElement("li");
li.innerHTML =
'<span class="sl-label">' + qty[t.id] + " × " + t.name + "</span>" +
'<span class="sl-amt">' + (t.price === 0 ? "Free" : money(line)) + "</span>";
sumLines.appendChild(li);
}
});
if (!any) {
var empty = document.createElement("li");
empty.className = "sum-empty";
empty.textContent = "No tickets selected yet.";
sumLines.appendChild(empty);
}
var fee = totalPaid() * SERVICE_FEE;
var total = subtotal + fee;
sumSub.textContent = money(subtotal);
sumFee.textContent = money(fee);
sumTotal.textContent = money(total);
var ready = selectedDate && selectedSlot && totalGuests() > 0;
checkoutBtn.disabled = !ready;
}
// ---------- Checkout ----------
checkoutBtn.addEventListener("click", function () {
if (!selectedDate) { toast("Please choose a date first.", "warn"); return; }
if (!selectedSlot) { toast("Please pick an entry time.", "warn"); return; }
if (totalGuests() === 0) { toast("Add at least one ticket.", "warn"); return; }
toast(
"Reserved " + totalGuests() + " ticket(s) for " +
dateFmt.format(selectedDate) + " at " + selectedSlot.time + ".",
"ok"
);
});
// ---------- Init ----------
renderCalendar();
renderSlots();
renderTickets();
updateSummary();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meridian Museum of Art — Tickets</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip" href="#booking">Skip to booking</a>
<header class="masthead">
<div class="wrap masthead-in">
<div class="brand">
<span class="brand-mark" aria-hidden="true">M</span>
<span class="brand-name">Meridian Museum of Art</span>
</div>
<nav class="masthead-nav" aria-label="Primary">
<a href="#">Exhibitions</a>
<a href="#">Visit</a>
<a href="#" aria-current="page">Tickets</a>
</nav>
</div>
</header>
<main id="booking" class="wrap booking">
<div class="hero">
<p class="eyebrow">Admission · General & Exhibitions</p>
<h1>Book your visit</h1>
<p class="lede">
Reserve timed-entry tickets to the permanent collection and the special exhibition
<em>Luminous Ground: Color Field Painting, 1958–1974</em>. Members enter free — select the
member ticket and present your card at the door.
</p>
</div>
<div class="grid">
<section class="flow" aria-label="Booking steps">
<!-- Step 1 — Date -->
<article class="card step" aria-labelledby="s1-h">
<header class="step-head">
<span class="step-num">1</span>
<div>
<h2 id="s1-h">Choose a date</h2>
<p class="step-sub">Open Tuesday–Sunday · Closed Mondays</p>
</div>
</header>
<div class="cal">
<div class="cal-bar">
<button type="button" class="cal-nav" id="calPrev" aria-label="Previous month">‹</button>
<h3 class="cal-title" id="calTitle" aria-live="polite">—</h3>
<button type="button" class="cal-nav" id="calNext" aria-label="Next month">›</button>
</div>
<div class="cal-dow" aria-hidden="true">
<span>Su</span><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span>
</div>
<div class="cal-grid" id="calGrid" role="grid" aria-label="Select a date"></div>
<p class="cal-legend">
<span class="dot dot-on"></span> Available
<span class="dot dot-off"></span> Closed / past
</p>
</div>
</article>
<!-- Step 2 — Time -->
<article class="card step" aria-labelledby="s2-h">
<header class="step-head">
<span class="step-num">2</span>
<div>
<h2 id="s2-h">Pick an entry time</h2>
<p class="step-sub" id="slotSub">Select a date to see available times.</p>
</div>
</header>
<div class="slots" id="slots" role="radiogroup" aria-label="Entry time"></div>
</article>
<!-- Step 3 — Tickets -->
<article class="card step" aria-labelledby="s3-h">
<header class="step-head">
<span class="step-num">3</span>
<div>
<h2 id="s3-h">Select tickets</h2>
<p class="step-sub">Children 5 & under enter free · Members always free</p>
</div>
</header>
<ul class="tickets" id="tickets"></ul>
<p class="ticket-note">Max 10 paid tickets per order. Concession tickets may require ID at entry.</p>
</article>
</section>
<!-- Summary -->
<aside class="summary-col">
<section class="card summary" aria-labelledby="sum-h">
<h2 id="sum-h" class="summary-title">Order summary</h2>
<dl class="sum-meta">
<div><dt>Date</dt><dd id="sumDate">—</dd></div>
<div><dt>Time</dt><dd id="sumTime">—</dd></div>
<div><dt>Guests</dt><dd id="sumQty">0</dd></div>
</dl>
<ul class="sum-lines" id="sumLines">
<li class="sum-empty">No tickets selected yet.</li>
</ul>
<dl class="sum-totals">
<div><dt>Subtotal</dt><dd id="sumSub">$0.00</dd></div>
<div><dt>Service fee <span class="fee-note">($2.50 / paid ticket)</span></dt><dd id="sumFee">$0.00</dd></div>
<div class="sum-grand"><dt>Total</dt><dd id="sumTotal">$0.00</dd></div>
</dl>
<button type="button" class="btn-checkout" id="checkout">Continue to checkout</button>
<p class="sum-fine">Free cancellation up to 24 hours before your visit.</p>
</section>
</aside>
</div>
</main>
<footer class="foot">
<div class="wrap foot-in">
<p>Meridian Museum of Art · 14 Wexford Plaza · Open Tue–Sun, 10 am – 5 pm</p>
<p class="foot-dim">Illustrative UI only — demo data, no real transaction occurs.</p>
</div>
</footer>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Ticket Booking
A self-contained timed-entry booking page for the fictional Meridian Museum of Art, framing admission to the permanent collection and the special exhibition Luminous Ground: Color Field Painting, 1958–1974. The layout favors generous wall space, a Cormorant Garamond and Inter pairing, and a quiet gold-and-charcoal palette so it reads like a cultural institution rather than a generic checkout. Three numbered steps sit beside a sticky order summary.
Step one is a mini month calendar that disables past days and closed Mondays, supports keyboard focus, and lets visitors page between months. Selecting a date reveals step two — five entry-time slots, each showing deterministic remaining capacity with low-availability and sold-out states. Step three lists six ticket types (adult, senior, student, child, free child, and member) with circular quantity steppers, badges, and per-ticket pricing.
Everything stays reactive: the summary rewrites its date, time, and guest count, lists each selected ticket line, and tallies subtotal, a per-paid-ticket service fee, and the running total. The flow validates as you go, capping paid tickets per order, never overselling the chosen slot’s remaining capacity, and only enabling checkout once a date, time, and at least one ticket are chosen — with a toast confirming each step. It is keyboard-usable with visible focus, meets AA contrast, and reflows cleanly down to 360px.
Illustrative UI only — demo data; not a real museum system.