Gym — Class Booking Flow
A bold, athletic four-step class booking flow for a performance gym. Members swipe a horizontal date strip, choose from real-feeling sessions with spots-left counters and intensity badges, claim a position on a small floor map of bikes or stations, then confirm and pay with class credits or a saved card. A persistent summary rail tracks every choice, validation gates each step, and confirming reveals a success panel with a generated booking code.
MCP
Code
:root {
--bg: #0d0f12;
--surface: #15181d;
--surface-2: #1d2127;
--elevated: #23282f;
--ink: #f4f6f8;
--ink-2: #c2c8d0;
--muted: #8b929c;
--neon: #c6ff3a;
--neon-d: #a6e016;
--neon-50: rgba(198, 255, 58, 0.12);
--orange: #ff6a2b;
--orange-soft: rgba(255, 106, 43, 0.14);
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow: 0 1px 0 rgba(255,255,255,0.04) inset, 0 10px 30px rgba(0,0,0,0.45);
--shadow-sm: 0 6px 18px rgba(0,0,0,0.35);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
background-image:
radial-gradient(900px 500px at 110% -10%, rgba(198,255,58,0.07), transparent 60%),
radial-gradient(700px 500px at -10% 110%, rgba(255,106,43,0.06), transparent 55%);
}
.app {
max-width: 1040px;
margin: 0 auto;
padding: 22px 18px 110px;
}
button { font-family: inherit; }
:focus-visible {
outline: 3px solid var(--neon);
outline-offset: 2px;
border-radius: 6px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 42px; height: 42px;
display: grid; place-items: center;
font-size: 20px;
background: var(--neon);
color: #0a0c0e;
border-radius: 12px;
box-shadow: 0 6px 20px rgba(198,255,58,0.35);
}
.brand-text strong {
display: block;
font-weight: 900;
letter-spacing: 0.06em;
font-size: 17px;
}
.brand-text strong span { color: var(--neon); }
.brand-text small {
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 10px;
font-weight: 600;
}
.credits {
display: flex; flex-direction: column; align-items: flex-end;
background: var(--surface);
border: 1px solid var(--line);
padding: 8px 14px;
border-radius: var(--r-md);
}
.credits-num { font-weight: 900; font-size: 20px; color: var(--neon); line-height: 1; }
.credits-label {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--muted); font-weight: 600;
}
/* ---------- Stepper ---------- */
.stepper { margin-bottom: 22px; }
.stepper ol {
list-style: none; margin: 0 0 12px; padding: 0;
display: grid; grid-template-columns: repeat(4, 1fr);
}
.step {
display: flex; flex-direction: column; align-items: center; gap: 7px;
text-align: center;
}
.step .dot {
width: 34px; height: 34px; border-radius: 50%;
display: grid; place-items: center;
background: var(--surface-2);
border: 1px solid var(--line-2);
font-weight: 800; font-size: 14px;
color: var(--muted);
transition: all 0.25s ease;
}
.step .lbl {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em;
font-weight: 700; color: var(--muted);
transition: color 0.25s ease;
}
.step.is-current .dot {
background: var(--neon); color: #0a0c0e; border-color: var(--neon);
box-shadow: 0 0 0 4px var(--neon-50);
}
.step.is-current .lbl { color: var(--ink); }
.step.is-done .dot {
background: transparent; color: var(--neon); border-color: var(--neon);
}
.step.is-done .lbl { color: var(--ink-2); }
.stepper-track {
height: 4px; background: var(--surface-2);
border-radius: 99px; overflow: hidden;
}
.stepper-fill {
height: 100%; width: 12.5%;
background: linear-gradient(90deg, var(--neon-d), var(--neon));
border-radius: 99px;
transition: width 0.35s cubic-bezier(.4,0,.2,1);
}
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 18px;
align-items: start;
}
/* ---------- Panels ---------- */
.panels {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 26px;
min-height: 420px;
box-shadow: var(--shadow);
}
.panel { animation: fade 0.3s ease; }
.panel[hidden] { display: none; }
@keyframes fade {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.eyebrow {
margin: 0 0 6px;
text-transform: uppercase; letter-spacing: 0.16em;
font-size: 11px; font-weight: 700; color: var(--neon);
}
.panel-title { margin: 0 0 6px; font-size: 26px; font-weight: 900; letter-spacing: -0.01em; }
.panel-sub { margin: 0 0 22px; color: var(--ink-2); font-size: 14px; }
/* ---------- Date strip ---------- */
.datestrip {
display: flex; gap: 10px;
overflow-x: auto;
padding-bottom: 8px;
scroll-snap-type: x mandatory;
}
.datecard {
flex: 0 0 auto;
width: 78px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 8px;
text-align: center;
cursor: pointer;
scroll-snap-align: start;
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
}
.datecard:hover { transform: translateY(-2px); border-color: var(--line-2); }
.datecard .dc-dow {
text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px;
font-weight: 700; color: var(--muted);
}
.datecard .dc-day { font-size: 24px; font-weight: 900; margin: 2px 0; }
.datecard .dc-mon { font-size: 11px; color: var(--ink-2); font-weight: 600; }
.datecard[aria-checked="true"] {
background: var(--neon-50);
border-color: var(--neon);
}
.datecard[aria-checked="true"] .dc-day { color: var(--neon); }
.datecard .dc-today {
display: inline-block; margin-top: 6px;
font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--orange); font-weight: 800;
}
/* ---------- Class list ---------- */
.classlist { display: flex; flex-direction: column; gap: 14px; }
.classcard {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px;
cursor: pointer;
transition: border-color 0.2s ease, transform 0.15s ease;
}
.classcard:hover { transform: translateY(-2px); border-color: var(--line-2); }
.classcard[aria-checked="true"] { border-color: var(--neon); background: linear-gradient(180deg, var(--neon-50), transparent 70%); }
.cc-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.cc-title { font-weight: 800; font-size: 17px; }
.cc-coach { color: var(--muted); font-size: 12px; margin-top: 2px; }
.cc-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.badge {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
padding: 4px 8px; border-radius: 99px;
background: var(--elevated); color: var(--ink-2);
border: 1px solid var(--line);
}
.badge.dur { color: var(--ink-2); }
.badge.intensity { color: var(--orange); background: var(--orange-soft); border-color: transparent; }
.cc-slots { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
.slot {
border: 1px solid var(--line-2);
background: var(--elevated);
color: var(--ink);
border-radius: var(--r-sm);
padding: 8px 12px;
font-weight: 700; font-size: 13px;
cursor: pointer;
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
min-width: 92px;
transition: all 0.15s ease;
}
.slot:hover:not(:disabled) { border-color: var(--neon); }
.slot .slot-left {
font-size: 10px; font-weight: 600; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.06em;
}
.slot.is-sel { background: var(--neon); color: #0a0c0e; border-color: var(--neon); }
.slot.is-sel .slot-left { color: rgba(10,12,14,0.7); }
.slot.is-low .slot-left { color: var(--warn); }
.slot:disabled { opacity: 0.4; cursor: not-allowed; }
/* ---------- Floor map ---------- */
.floor {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px;
}
.floor-stage {
text-align: center;
background: linear-gradient(180deg, var(--elevated), var(--surface-2));
border: 1px dashed var(--line-2);
border-radius: var(--r-sm);
padding: 10px;
color: var(--muted);
text-transform: uppercase; letter-spacing: 0.14em;
font-size: 11px; font-weight: 700;
margin-bottom: 18px;
}
.spotgrid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
max-width: 380px;
margin: 0 auto;
}
.spot {
aspect-ratio: 1;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--elevated);
color: var(--ink-2);
font-weight: 800; font-size: 13px;
cursor: pointer;
display: grid; place-items: center;
transition: all 0.15s ease;
}
.spot:hover:not(:disabled) { border-color: var(--neon); transform: scale(1.06); }
.spot.is-sel { background: var(--neon); color: #0a0c0e; border-color: var(--neon); box-shadow: 0 0 0 3px var(--neon-50); }
.spot:disabled { background: var(--surface); color: var(--muted); opacity: 0.45; cursor: not-allowed; }
.floor-legend {
display: flex; gap: 18px; justify-content: center; margin-top: 18px;
font-size: 12px; color: var(--ink-2);
}
.floor-legend span { display: inline-flex; align-items: center; gap: 6px; }
.lg { width: 13px; height: 13px; border-radius: 4px; display: inline-block; }
.lg-open { background: var(--elevated); border: 1px solid var(--line-2); }
.lg-sel { background: var(--neon); }
.lg-taken { background: var(--surface); border: 1px solid var(--line); opacity: 0.6; }
/* ---------- Review ---------- */
.review {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px;
margin-bottom: 18px;
}
.rrow { display: flex; justify-content: space-between; padding: 9px 0; border-bottom: 1px solid var(--line); }
.rrow:last-child { border-bottom: 0; }
.rrow .rk { color: var(--muted); font-size: 13px; }
.rrow .rv { font-weight: 700; font-size: 14px; text-align: right; }
/* ---------- Payment ---------- */
.pay { border: 0; margin: 0; padding: 0; }
.pay legend {
text-transform: uppercase; letter-spacing: 0.12em; font-size: 11px;
font-weight: 700; color: var(--muted); margin-bottom: 10px; padding: 0;
}
.paymethod {
display: flex; align-items: center; gap: 12px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 16px;
cursor: pointer;
margin-bottom: 10px;
transition: border-color 0.2s ease;
}
.paymethod:hover { border-color: var(--line-2); }
.paymethod:has(input:checked) { border-color: var(--neon); background: var(--neon-50); }
.paymethod input { accent-color: var(--neon); width: 18px; height: 18px; }
.pm-body { flex: 1; display: flex; flex-direction: column; }
.pm-title { font-weight: 700; font-size: 14px; }
.pm-sub { color: var(--muted); font-size: 12px; }
.pm-tag {
font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--neon); background: var(--neon-50); padding: 4px 8px; border-radius: 99px;
}
.pm-price { font-weight: 900; font-size: 16px; }
/* ---------- Success ---------- */
.panel-success { text-align: center; padding-top: 14px; }
.success-check {
width: 72px; height: 72px; margin: 0 auto 18px;
border-radius: 50%;
background: var(--neon);
color: #0a0c0e;
display: grid; place-items: center;
font-size: 38px; font-weight: 900;
box-shadow: 0 0 0 8px var(--neon-50);
animation: pop 0.4s cubic-bezier(.2,.8,.2,1.2);
}
@keyframes pop { from { transform: scale(0); } to { transform: scale(1); } }
.codebox {
margin: 22px auto;
max-width: 320px;
background: var(--surface-2);
border: 1px dashed var(--line-2);
border-radius: var(--r-md);
padding: 16px;
}
.codebox-label {
display: block;
text-transform: uppercase; letter-spacing: 0.14em; font-size: 10px;
font-weight: 700; color: var(--muted); margin-bottom: 6px;
}
.codebox-code { font-size: 28px; font-weight: 900; letter-spacing: 0.06em; color: var(--neon); }
.success-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
/* ---------- Summary rail ---------- */
.summary {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 22px;
position: sticky; top: 18px;
box-shadow: var(--shadow-sm);
}
.summary-title {
margin: 0 0 16px; font-size: 13px; text-transform: uppercase;
letter-spacing: 0.14em; color: var(--muted); font-weight: 700;
}
.summary-list { list-style: none; margin: 0 0 16px; padding: 0; }
.srow {
display: flex; justify-content: space-between; gap: 10px;
padding: 11px 0; border-bottom: 1px solid var(--line);
}
.srow .sk { color: var(--muted); font-size: 13px; }
.srow .sv { font-weight: 700; font-size: 13px; text-align: right; color: var(--ink); }
.srow.is-filled .sv { color: var(--neon); }
.summary-total {
display: flex; justify-content: space-between; align-items: baseline;
padding-top: 6px;
}
.summary-total span { text-transform: uppercase; letter-spacing: 0.1em; font-size: 11px; font-weight: 700; color: var(--muted); }
.summary-total strong { font-size: 22px; font-weight: 900; }
/* ---------- Buttons ---------- */
.btn {
border: 1px solid transparent;
border-radius: var(--r-md);
padding: 13px 22px;
font-weight: 800; font-size: 14px;
cursor: pointer;
transition: transform 0.12s ease, background 0.2s ease, opacity 0.2s ease;
}
.btn:active:not(:disabled) { transform: translateY(1px) scale(0.99); }
.btn-primary {
background: var(--neon); color: #0a0c0e;
box-shadow: 0 8px 20px rgba(198,255,58,0.25);
}
.btn-primary:hover:not(:disabled) { background: var(--neon-d); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
.btn-ghost {
background: var(--surface-2); color: var(--ink); border-color: var(--line-2);
}
.btn-ghost:hover:not(:disabled) { border-color: var(--neon); }
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
/* ---------- Nav bar ---------- */
.navbar {
position: fixed; left: 0; right: 0; bottom: 0;
background: rgba(13,15,18,0.92);
backdrop-filter: blur(10px);
border-top: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
padding: 14px 18px;
z-index: 20;
}
.navbar .btn { min-width: 120px; }
.navhint { color: var(--muted); font-size: 13px; flex: 1; text-align: center; }
/* ---------- Toast ---------- */
.toast-wrap {
position: fixed; bottom: 84px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px; z-index: 50;
pointer-events: none; width: max-content; max-width: 90vw;
}
.toast {
background: var(--elevated);
border: 1px solid var(--line-2);
color: var(--ink);
padding: 12px 18px;
border-radius: var(--r-md);
font-weight: 600; font-size: 13px;
box-shadow: var(--shadow);
animation: toastin 0.25s ease;
}
.toast.err { border-color: var(--danger); color: #ffd7d7; }
@keyframes toastin { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
/* ---------- Responsive ---------- */
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
.summary { position: static; order: 2; }
.panels { order: 1; }
}
@media (max-width: 520px) {
.app { padding: 16px 12px 110px; }
.panels { padding: 18px; }
.panel-title { font-size: 21px; }
.step .lbl { font-size: 9px; letter-spacing: 0.06em; }
.step .dot { width: 30px; height: 30px; font-size: 13px; }
.spotgrid { grid-template-columns: repeat(5, 1fr); }
.navbar .btn { min-width: 96px; padding: 12px 14px; }
.navhint { display: none; }
.floor-legend { gap: 12px; flex-wrap: wrap; }
}(function () {
"use strict";
// ---------- Data ----------
var TOTAL_CREDITS = 12;
var CARD_PRICE = 22.0;
var CLASSES = [
{
id: "ride",
title: "Power Ride",
coach: "Coach Mara V.",
duration: "45 min",
intensity: "High burn",
spotLabel: "bike",
slots: [
{ time: "06:30", left: 4 },
{ time: "12:15", left: 1 },
{ time: "18:00", left: 9 },
{ time: "19:30", left: 0 }
]
},
{
id: "hiit",
title: "Inferno HIIT",
coach: "Coach Deon R.",
duration: "40 min",
intensity: "All-out",
spotLabel: "station",
slots: [
{ time: "07:00", left: 6 },
{ time: "17:15", left: 2 },
{ time: "20:00", left: 11 }
]
},
{
id: "lift",
title: "Iron Strength",
coach: "Coach Priya N.",
duration: "55 min",
intensity: "Build",
spotLabel: "rack",
slots: [
{ time: "08:30", left: 3 },
{ time: "13:00", left: 0 },
{ time: "18:45", left: 5 }
]
},
{
id: "flow",
title: "Mobility Flow",
coach: "Coach Sam K.",
duration: "50 min",
intensity: "Recover",
spotLabel: "mat",
slots: [
{ time: "09:15", left: 8 },
{ time: "12:30", left: 12 },
{ time: "19:00", left: 4 }
]
}
];
// Deterministically "taken" spots per class+slot so the map feels real.
function takenSet(seedStr, capacity, left) {
var taken = capacity - left;
var set = {};
var h = 0;
for (var i = 0; i < seedStr.length; i++) h = (h * 31 + seedStr.charCodeAt(i)) >>> 0;
var n = 0, idx = h % capacity;
while (n < taken) {
if (!set[idx]) { set[idx] = true; n++; }
idx = (idx * 7 + 5) % capacity;
}
return set;
}
var SPOT_CAPACITY = 12;
// ---------- State ----------
var state = {
step: 1,
date: null, // {dow, day, mon, iso, label}
classId: null,
slot: null, // "06:30"
spot: null // number
};
// ---------- Elements ----------
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var dateStrip = $("#dateStrip");
var classList = $("#classList");
var spotGrid = $("#spotGrid");
var floorStage = $("#floorStage");
var reviewBox = $("#reviewBox");
var stepperFill = $("#stepperFill");
var nextBtn = $("#nextBtn");
var backBtn = $("#backBtn");
var navHint = $("#navHint");
var creditCount = $("#creditCount");
creditCount.textContent = TOTAL_CREDITS;
// ---------- Toast ----------
function toast(msg, isErr) {
var wrap = $("#toastWrap");
var el = document.createElement("div");
el.className = "toast" + (isErr ? " err" : "");
el.textContent = msg;
wrap.appendChild(el);
setTimeout(function () {
el.style.opacity = "0";
el.style.transform = "translateY(8px)";
el.style.transition = "all .25s ease";
setTimeout(function () { el.remove(); }, 260);
}, 2200);
}
// ---------- Helpers ----------
function classById(id) {
for (var i = 0; i < CLASSES.length; i++) if (CLASSES[i].id === id) return CLASSES[i];
return null;
}
function selectedSlot() {
var c = classById(state.classId);
if (!c || !state.slot) return null;
for (var i = 0; i < c.slots.length; i++) if (c.slots[i].time === state.slot) return c.slots[i];
return null;
}
// ---------- Step 1: date strip ----------
function buildDates() {
var DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
var MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
var base = new Date();
var frag = document.createDocumentFragment();
for (var i = 0; i < 10; i++) {
var d = new Date(base.getTime() + i * 86400000);
var info = {
dow: DOW[d.getDay()],
day: d.getDate(),
mon: MON[d.getMonth()],
iso: d.toISOString().slice(0, 10),
label: DOW[d.getDay()] + " " + MON[d.getMonth()] + " " + d.getDate(),
isToday: i === 0
};
var card = document.createElement("button");
card.type = "button";
card.className = "datecard";
card.setAttribute("role", "radio");
card.setAttribute("aria-checked", "false");
card.setAttribute("aria-label", info.label);
card.innerHTML =
'<span class="dc-dow">' + info.dow + "</span>" +
'<span class="dc-day">' + info.day + "</span>" +
'<span class="dc-mon">' + info.mon + "</span>" +
(info.isToday ? '<span class="dc-today">Today</span>' : "");
(function (info, card) {
card.addEventListener("click", function () {
$$(".datecard", dateStrip).forEach(function (c) { c.setAttribute("aria-checked", "false"); });
card.setAttribute("aria-checked", "true");
state.date = info;
updateSummary();
updateNav();
});
})(info, card);
frag.appendChild(card);
}
dateStrip.appendChild(frag);
}
// ---------- Step 2: classes ----------
function buildClasses() {
var frag = document.createDocumentFragment();
CLASSES.forEach(function (c) {
var card = document.createElement("div");
card.className = "classcard";
card.setAttribute("role", "radio");
card.setAttribute("aria-checked", "false");
card.dataset.id = c.id;
var slotsHtml = c.slots.map(function (s) {
var dis = s.left <= 0;
var low = s.left > 0 && s.left <= 2;
var leftTxt = dis ? "Full" : s.left + " left";
return (
'<button type="button" class="slot' + (low ? " is-low" : "") + '" data-time="' +
s.time + '"' + (dis ? " disabled" : "") + ">" +
"<span>" + s.time + "</span>" +
'<span class="slot-left">' + leftTxt + "</span>" +
"</button>"
);
}).join("");
card.innerHTML =
'<div class="cc-head"><div><div class="cc-title">' + c.title + "</div>" +
'<div class="cc-coach">' + c.coach + "</div></div></div>" +
'<div class="cc-tags"><span class="badge dur">' + c.duration + "</span>" +
'<span class="badge intensity">' + c.intensity + "</span></div>" +
'<div class="cc-slots">' + slotsHtml + "</div>";
// slot clicks
$$(".slot", card).forEach(function (btn) {
if (btn.disabled) return;
btn.addEventListener("click", function (e) {
e.stopPropagation();
// select this class
$$(".classcard", classList).forEach(function (cc) { cc.setAttribute("aria-checked", "false"); });
card.setAttribute("aria-checked", "true");
// clear other slot highlights
$$(".slot", classList).forEach(function (s) { s.classList.remove("is-sel"); });
btn.classList.add("is-sel");
// reset spot if class/slot changed
state.classId = c.id;
state.slot = btn.dataset.time;
state.spot = null;
updateSummary();
updateNav();
});
});
frag.appendChild(card);
});
classList.appendChild(frag);
}
// ---------- Step 3: floor map ----------
function buildSpots() {
spotGrid.innerHTML = "";
var c = classById(state.classId);
var slot = selectedSlot();
if (!c || !slot) return;
floorStage.textContent = c.coach.replace("Coach ", "") + " · " + c.title;
var taken = takenSet(c.id + slot.time, SPOT_CAPACITY, Math.min(slot.left, SPOT_CAPACITY));
var frag = document.createDocumentFragment();
for (var i = 1; i <= SPOT_CAPACITY; i++) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "spot";
btn.setAttribute("role", "gridcell");
btn.textContent = i;
var isTaken = !!taken[i - 1];
if (isTaken) {
btn.disabled = true;
btn.setAttribute("aria-label", c.spotLabel + " " + i + " (taken)");
} else {
btn.setAttribute("aria-label", c.spotLabel + " " + i + " (open)");
if (state.spot === i) btn.classList.add("is-sel");
(function (n, b) {
b.addEventListener("click", function () {
$$(".spot", spotGrid).forEach(function (s) { s.classList.remove("is-sel"); });
b.classList.add("is-sel");
state.spot = n;
updateSummary();
updateNav();
});
})(i, btn);
}
frag.appendChild(btn);
}
spotGrid.appendChild(frag);
}
// ---------- Step 4: review + payment ----------
function buildReview() {
var c = classById(state.classId);
var rows = [
["Date", state.date ? state.date.label : "—"],
["Class", c ? c.title : "—"],
["Coach", c ? c.coach : "—"],
["Time", state.slot || "—"],
["Spot", c && state.spot ? (cap(c.spotLabel) + " " + state.spot) : "—"]
];
reviewBox.innerHTML = rows.map(function (r) {
return '<div class="rrow"><span class="rk">' + r[0] + '</span><span class="rv">' + r[1] + "</span></div>";
}).join("");
$("#pmCreditsSub").textContent = "1 credit · " + (TOTAL_CREDITS - 1) + " left after";
$("#pmCardPrice").textContent = "$" + CARD_PRICE.toFixed(2);
}
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
// ---------- Summary rail ----------
function setRow(key, value) {
var row = $('.srow[data-srow="' + key + '"]');
var val = $(".sv", row);
val.textContent = value || "—";
row.classList.toggle("is-filled", !!value);
}
function updateSummary() {
var c = classById(state.classId);
setRow("date", state.date ? state.date.label : "");
setRow("class", c ? c.title : "");
setRow("time", state.slot || "");
setRow("spot", c && state.spot ? (cap(c.spotLabel) + " " + state.spot) : "");
var total = "—";
if (state.classId) {
var pay = payMethod();
total = pay === "card" ? "$" + CARD_PRICE.toFixed(2) : "1 credit";
}
$("#sumTotal").textContent = total;
}
function payMethod() {
var checked = $('input[name="pay"]:checked');
return checked ? checked.value : "credits";
}
// ---------- Validation ----------
function isStepValid(step) {
if (step === 1) return !!state.date;
if (step === 2) return !!(state.classId && state.slot);
if (step === 3) return !!state.spot;
if (step === 4) return true;
return false;
}
var HINTS = {
1: "Select a date to continue",
2: "Pick a class and time slot",
3: "Tap an open spot on the floor map",
4: "Choose a payment method and confirm"
};
// ---------- Navigation ----------
function showStep(step) {
state.step = step;
$$(".panel").forEach(function (p) {
var match = p.dataset.panel === String(step);
p.hidden = !match;
p.classList.toggle("is-active", match);
});
// stepper visuals
$$(".step").forEach(function (li) {
var n = parseInt(li.dataset.step, 10);
li.classList.toggle("is-current", n === step);
li.classList.toggle("is-done", n < step);
});
stepperFill.style.width = (step / 4 * 100) + "%";
if (step === 3) buildSpots();
if (step === 4) { buildReview(); updateSummary(); }
backBtn.disabled = step <= 1;
nextBtn.textContent = step === 4 ? "Confirm booking" : "Continue";
updateNav();
window.scrollTo({ top: 0, behavior: "smooth" });
}
function updateNav() {
var valid = isStepValid(state.step);
nextBtn.disabled = !valid;
navHint.textContent = valid
? (state.step === 4 ? "Ready to confirm" : "Looks good — continue")
: HINTS[state.step];
}
function next() {
if (!isStepValid(state.step)) {
toast(HINTS[state.step], true);
return;
}
if (state.step === 4) { confirmBooking(); return; }
showStep(state.step + 1);
}
function back() {
if (state.step > 1) showStep(state.step - 1);
}
// ---------- Confirm ----------
function genCode() {
var chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var s = "";
for (var i = 0; i < 6; i++) s += chars[Math.floor(Math.random() * chars.length)];
return "PH-" + s;
}
function confirmBooking() {
var pay = payMethod();
var c = classById(state.classId);
if (pay === "credits") {
var remaining = TOTAL_CREDITS - 1;
creditCount.textContent = remaining;
}
var code = genCode();
$("#bookingCode").textContent = code;
$("#successLine").textContent =
c.title + " · " + state.date.label + " at " + state.slot +
" · " + cap(c.spotLabel) + " " + state.spot;
$$(".panel").forEach(function (p) { p.hidden = true; p.classList.remove("is-active"); });
var s = $('.panel[data-panel="success"]');
s.hidden = false; s.classList.add("is-active");
$$(".step").forEach(function (li) { li.classList.add("is-done"); li.classList.remove("is-current"); });
stepperFill.style.width = "100%";
nextBtn.disabled = true;
backBtn.disabled = true;
navHint.textContent = "Confirmation code " + code;
toast("Booking confirmed — " + code);
window.scrollTo({ top: 0, behavior: "smooth" });
}
function reset() {
state = { step: 1, date: null, classId: null, slot: null, spot: null };
$$(".datecard", dateStrip).forEach(function (c) { c.setAttribute("aria-checked", "false"); });
$$(".classcard", classList).forEach(function (c) { c.setAttribute("aria-checked", "false"); });
$$(".slot", classList).forEach(function (s) { s.classList.remove("is-sel"); });
var creditsRadio = $('input[name="pay"][value="credits"]');
if (creditsRadio) creditsRadio.checked = true;
setRow("date", ""); setRow("class", ""); setRow("time", ""); setRow("spot", "");
$("#sumTotal").textContent = "—";
showStep(1);
}
// ---------- Wire up ----------
nextBtn.addEventListener("click", next);
backBtn.addEventListener("click", back);
$("#againBtn").addEventListener("click", reset);
$("#calBtn").addEventListener("click", function () { toast("Added to your calendar"); });
$$('input[name="pay"]').forEach(function (r) {
r.addEventListener("change", function () { updateSummary(); });
});
// keyboard: Enter advances, ArrowLeft goes back
document.addEventListener("keydown", function (e) {
if (e.target && /input|textarea/i.test(e.target.tagName)) return;
if (e.key === "Enter" && !nextBtn.disabled) { next(); }
});
buildDates();
buildClasses();
showStep(1);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gym — Class Booking Flow</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=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">⚡</span>
<div class="brand-text">
<strong>PULSE<span>HAUS</span></strong>
<small>Studio Booking</small>
</div>
</div>
<div class="credits" title="Available class credits">
<span class="credits-num" id="creditCount">12</span>
<span class="credits-label">credits</span>
</div>
</header>
<!-- Step indicator -->
<nav class="stepper" aria-label="Booking progress">
<ol id="stepper">
<li class="step is-current" data-step="1"><span class="dot">1</span><span class="lbl">Date</span></li>
<li class="step" data-step="2"><span class="dot">2</span><span class="lbl">Class</span></li>
<li class="step" data-step="3"><span class="dot">3</span><span class="lbl">Your spot</span></li>
<li class="step" data-step="4"><span class="dot">4</span><span class="lbl">Confirm</span></li>
</ol>
<div class="stepper-track"><div class="stepper-fill" id="stepperFill"></div></div>
</nav>
<div class="layout">
<main class="panels" id="panels">
<!-- STEP 1 — DATE -->
<section class="panel is-active" data-panel="1" aria-labelledby="t1">
<p class="eyebrow">Step 1 / 4</p>
<h1 id="t1" class="panel-title">Pick a day</h1>
<p class="panel-sub">Choose when you want to train. New slots open daily.</p>
<div class="datestrip" id="dateStrip" role="radiogroup" aria-label="Select a date"></div>
</section>
<!-- STEP 2 — CLASS -->
<section class="panel" data-panel="2" aria-labelledby="t2" hidden>
<p class="eyebrow">Step 2 / 4</p>
<h1 id="t2" class="panel-title">Choose your class</h1>
<p class="panel-sub">Pick a session and a time slot. Spots are limited.</p>
<div class="classlist" id="classList" role="radiogroup" aria-label="Select a class"></div>
</section>
<!-- STEP 3 — SPOT -->
<section class="panel" data-panel="3" aria-labelledby="t3" hidden>
<p class="eyebrow">Step 3 / 4</p>
<h1 id="t3" class="panel-title">Grab your position</h1>
<p class="panel-sub">Tap an open spot on the floor map. Greyed-out spots are taken.</p>
<div class="floor">
<div class="floor-stage" id="floorStage">Coach / Stage</div>
<div class="spotgrid" id="spotGrid" role="grid" aria-label="Floor map"></div>
<div class="floor-legend">
<span><i class="lg lg-open"></i>Open</span>
<span><i class="lg lg-sel"></i>Yours</span>
<span><i class="lg lg-taken"></i>Taken</span>
</div>
</div>
</section>
<!-- STEP 4 — CONFIRM -->
<section class="panel" data-panel="4" aria-labelledby="t4" hidden>
<p class="eyebrow">Step 4 / 4</p>
<h1 id="t4" class="panel-title">Confirm & pay</h1>
<p class="panel-sub">Review your booking and choose how to pay.</p>
<div class="review" id="reviewBox"></div>
<fieldset class="pay" id="payChoice">
<legend>Payment method</legend>
<label class="paymethod" data-method="credits">
<input type="radio" name="pay" value="credits" checked />
<span class="pm-body">
<span class="pm-title">Use class credits</span>
<span class="pm-sub" id="pmCreditsSub">1 credit · 11 left after</span>
</span>
<span class="pm-tag">Best value</span>
</label>
<label class="paymethod" data-method="card">
<input type="radio" name="pay" value="card" />
<span class="pm-body">
<span class="pm-title">Pay by card</span>
<span class="pm-sub" id="pmCardSub">Visa ·· 4421</span>
</span>
<span class="pm-price" id="pmCardPrice">$22.00</span>
</label>
</fieldset>
</section>
<!-- SUCCESS -->
<section class="panel panel-success" data-panel="success" aria-labelledby="t5" hidden>
<div class="success-check" aria-hidden="true">✓</div>
<h1 id="t5" class="panel-title">You're booked!</h1>
<p class="panel-sub" id="successLine">See you on the floor.</p>
<div class="codebox">
<span class="codebox-label">Booking code</span>
<strong class="codebox-code" id="bookingCode">PH-000000</strong>
</div>
<div class="success-actions">
<button class="btn btn-ghost" id="calBtn" type="button">Add to calendar</button>
<button class="btn btn-primary" id="againBtn" type="button">Book another</button>
</div>
</section>
</main>
<!-- SUMMARY RAIL -->
<aside class="summary" aria-label="Booking summary">
<h2 class="summary-title">Your booking</h2>
<ul class="summary-list">
<li class="srow" data-srow="date"><span class="sk">Date</span><span class="sv" id="sumDate">—</span></li>
<li class="srow" data-srow="class"><span class="sk">Class</span><span class="sv" id="sumClass">—</span></li>
<li class="srow" data-srow="time"><span class="sk">Time</span><span class="sv" id="sumTime">—</span></li>
<li class="srow" data-srow="spot"><span class="sk">Spot</span><span class="sv" id="sumSpot">—</span></li>
</ul>
<div class="summary-total">
<span>Total</span>
<strong id="sumTotal">—</strong>
</div>
</aside>
</div>
<!-- NAV -->
<footer class="navbar">
<button class="btn btn-ghost" id="backBtn" type="button" disabled>Back</button>
<span class="navhint" id="navHint">Select a date to continue</span>
<button class="btn btn-primary" id="nextBtn" type="button" disabled>Continue</button>
</footer>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Class Booking Flow
A high-energy, dark-themed booking wizard for a fictional performance studio, PulseHaus. A four-part step indicator (Date → Class → Your spot → Confirm) sits above the panels, with completed steps marked done and a neon progress bar that fills as you advance. Step one is a swipeable date strip with a Today badge; step two lists classes such as Power Ride and Inferno HIIT, each with a coach, duration and intensity badges, plus per-time-slot buttons that show spots-left and disable full or low slots. Step three draws a small floor map where you tap an open bike, rack or station — taken positions are deterministically greyed out — and step four reviews everything and lets you pay by credits or card.
A persistent summary rail mirrors every selection in real time and recomputes the total as 1 credit or the card price. Next/Back navigation is validation-gated: Continue stays disabled (with a contextual hint and an error toast) until the current step has a valid choice. Confirming swaps in a success panel with a generated booking code, an add-to-calendar affordance, a decremented credit balance and a Book another reset. Everything is vanilla JS — no frameworks, no build step.
Illustrative UI only — names, classes and trainers are fictional.