Salon — Service + Stylist + Time Booking
A luxe three-step booking flow for a boutique salon. Guests browse a categorized service menu with durations and starting prices, choose a stylist from rated artist cards or opt for the next available, then lock in a day and slot from a scrollable date strip and live availability grid. A sticky summary rail tracks the running selection, total duration and price, gating each step until it is valid and flipping to an elegant confirmation card with a reference number and toast.
MCP
Kod
:root {
--gold: #b08d57;
--gold-d: #8c6d3f;
--gold-soft: #efe2cf;
--rose: #c9a78f;
--rose-soft: #f3e6dc;
--ink: #1c1814;
--ink-2: #3d362f;
--muted: #8a7d70;
--cream: #f7f1e8;
--bg: #faf6ef;
--white: #ffffff;
--line: rgba(28, 24, 20, 0.1);
--line-2: rgba(28, 24, 20, 0.18);
--ok: #5f8a6b;
--warn: #c08a3e;
--danger: #b3503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--shadow-sm: 0 1px 2px rgba(28, 24, 20, 0.05),
0 4px 14px rgba(28, 24, 20, 0.05);
--shadow-md: 0 10px 30px rgba(28, 24, 20, 0.08),
0 2px 8px rgba(28, 24, 20, 0.05);
--shadow-lg: 0 24px 60px rgba(28, 24, 20, 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--sans);
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3 {
font-family: var(--serif);
font-weight: 600;
margin: 0;
letter-spacing: 0.01em;
line-height: 1.1;
}
button {
font-family: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
border-radius: var(--r-sm);
}
.kicker,
.eyebrow {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.22em;
font-weight: 600;
color: var(--gold-d);
margin: 0;
}
.muted {
color: var(--muted);
}
/* ---------- Layout shell ---------- */
.booking {
max-width: 1120px;
margin: 0 auto;
padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1rem, 4vw, 2.5rem) 4rem;
}
.hero {
display: grid;
gap: 1rem;
padding-bottom: 1.75rem;
border-bottom: 1px solid var(--line);
}
.hero__brand {
display: flex;
align-items: center;
gap: 1rem;
}
.hero__monogram {
display: grid;
place-items: center;
width: 56px;
height: 56px;
flex: none;
border-radius: 50%;
font-family: var(--serif);
font-weight: 700;
font-size: 1.25rem;
color: var(--white);
letter-spacing: 0.04em;
background: radial-gradient(circle at 30% 25%, var(--rose), var(--gold-d));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25), var(--shadow-sm);
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3rem);
margin-top: 0.15rem;
}
.hero__lede {
max-width: 46ch;
color: var(--ink-2);
font-size: 1rem;
margin: 0;
}
/* ---------- Stepper ---------- */
.steps {
margin: 2rem 0 1.75rem;
}
.steps.is-hidden {
display: none;
}
.steps ol {
list-style: none;
display: flex;
gap: 0.5rem;
padding: 0;
margin: 0;
}
.step {
display: flex;
align-items: center;
gap: 0.6rem;
flex: 1;
font-size: 0.82rem;
color: var(--muted);
position: relative;
}
.step:not(:last-child)::after {
content: "";
flex: 1;
height: 1px;
background: var(--line-2);
margin-left: 0.4rem;
}
.step__dot {
display: grid;
place-items: center;
width: 30px;
height: 30px;
flex: none;
border-radius: 50%;
border: 1px solid var(--line-2);
background: var(--white);
font-weight: 600;
font-size: 0.82rem;
color: var(--muted);
transition: all 0.25s ease;
}
.step__label {
font-weight: 500;
letter-spacing: 0.02em;
white-space: nowrap;
}
.step.is-active .step__dot {
background: var(--ink);
border-color: var(--ink);
color: var(--gold-soft);
}
.step.is-active {
color: var(--ink);
}
.step.is-done .step__dot {
background: var(--gold);
border-color: var(--gold);
color: var(--white);
}
.step.is-done .step__label {
color: var(--ink-2);
}
/* ---------- Two-column layout ---------- */
.layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1.75rem;
align-items: start;
}
.panel {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
padding: clamp(1.25rem, 3vw, 2rem);
display: flex;
flex-direction: column;
}
.screen {
display: none;
animation: fade 0.35s ease;
}
.screen.is-active {
display: block;
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.screen__head {
margin-bottom: 1.5rem;
}
.screen__head h2 {
font-size: 1.85rem;
margin: 0.35rem 0 0.4rem;
}
.screen__hint {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
max-width: 52ch;
}
/* ---------- Category chips ---------- */
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.4rem;
}
.chip {
border: 1px solid var(--line-2);
background: var(--white);
color: var(--ink-2);
font-size: 0.82rem;
font-weight: 500;
padding: 0.45rem 0.95rem;
border-radius: 999px;
transition: all 0.2s ease;
}
.chip:hover {
border-color: var(--gold);
color: var(--gold-d);
}
.chip.is-active {
background: var(--ink);
border-color: var(--ink);
color: var(--gold-soft);
}
/* ---------- Service list ---------- */
.service-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.service {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1rem;
width: 100%;
text-align: left;
background: var(--cream);
border: 1px solid transparent;
border-radius: var(--r-md);
padding: 1rem 1.1rem;
transition: all 0.2s ease;
}
.service:hover {
border-color: var(--gold-soft);
background: var(--white);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.service.is-selected {
border-color: var(--gold);
background: var(--white);
box-shadow: 0 0 0 1px var(--gold), var(--shadow-sm);
}
.service__cat {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gold-d);
background: var(--gold-soft);
padding: 0.3rem 0.55rem;
border-radius: var(--r-sm);
align-self: start;
}
.service__body h3 {
font-size: 1.3rem;
font-weight: 600;
}
.service__desc {
margin: 0.15rem 0 0;
font-size: 0.84rem;
color: var(--muted);
}
.service__meta {
text-align: right;
display: grid;
gap: 0.15rem;
}
.service__price {
font-family: var(--serif);
font-size: 1.4rem;
font-weight: 600;
color: var(--ink);
}
.service__dur {
font-size: 0.78rem;
color: var(--muted);
}
/* ---------- Stylist grid ---------- */
.stylist-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.85rem;
}
.stylist {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.5rem;
background: var(--cream);
border: 1px solid transparent;
border-radius: var(--r-md);
padding: 1.4rem 1rem 1.25rem;
transition: all 0.2s ease;
}
.stylist:hover {
border-color: var(--gold-soft);
background: var(--white);
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
}
.stylist.is-selected {
border-color: var(--gold);
background: var(--white);
box-shadow: 0 0 0 1px var(--gold), var(--shadow-sm);
}
.stylist__avatar {
width: 60px;
height: 60px;
border-radius: 50%;
display: grid;
place-items: center;
font-family: var(--serif);
font-weight: 600;
font-size: 1.35rem;
color: var(--white);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
}
.stylist__name {
font-family: var(--serif);
font-size: 1.25rem;
font-weight: 600;
}
.stylist__spec {
font-size: 0.76rem;
color: var(--muted);
letter-spacing: 0.02em;
}
.stylist__rating {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
font-weight: 500;
color: var(--ink-2);
}
.stylist__rating .star {
color: var(--gold);
}
.stylist--any .stylist__avatar {
background: linear-gradient(135deg, var(--gold-soft), var(--rose-soft));
color: var(--gold-d);
font-size: 1.6rem;
}
/* ---------- Day strip ---------- */
.daystrip {
display: flex;
gap: 0.55rem;
overflow-x: auto;
padding-bottom: 0.5rem;
margin-bottom: 1.5rem;
scrollbar-width: thin;
}
.day {
flex: none;
width: 72px;
border: 1px solid var(--line-2);
background: var(--white);
border-radius: var(--r-md);
padding: 0.7rem 0.5rem;
text-align: center;
transition: all 0.2s ease;
}
.day:hover:not(:disabled) {
border-color: var(--gold);
}
.day.is-selected {
background: var(--ink);
border-color: var(--ink);
color: var(--gold-soft);
}
.day:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.day__dow {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
}
.day__num {
font-family: var(--serif);
font-size: 1.5rem;
font-weight: 600;
line-height: 1.1;
}
.day__mon {
font-size: 0.66rem;
color: inherit;
opacity: 0.7;
}
/* ---------- Slots ---------- */
.slots-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.18em;
font-weight: 600;
color: var(--muted);
margin: 0 0 0.85rem;
}
.slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: 0.55rem;
}
.slot {
border: 1px solid var(--line-2);
background: var(--white);
border-radius: var(--r-sm);
padding: 0.6rem 0.5rem;
font-size: 0.88rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--ink-2);
transition: all 0.18s ease;
}
.slot:hover:not(:disabled) {
border-color: var(--gold);
color: var(--gold-d);
}
.slot.is-selected {
background: var(--gold);
border-color: var(--gold);
color: var(--white);
}
.slot:disabled {
opacity: 0.35;
text-decoration: line-through;
cursor: not-allowed;
}
.slots__empty {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.9rem;
padding: 1rem 0;
}
/* ---------- Panel actions ---------- */
.panel__actions {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 1.75rem;
padding-top: 1.5rem;
border-top: 1px solid var(--line);
}
.panel__actions .btn--primary {
margin-left: auto;
}
/* ---------- Buttons ---------- */
.btn {
font-family: var(--sans);
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.01em;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.72rem 1.5rem;
transition: all 0.2s ease;
}
.btn--primary {
background: var(--ink);
color: var(--gold-soft);
}
.btn--primary:hover:not(:disabled) {
background: var(--ink-2);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.btn--ghost {
background: transparent;
border-color: var(--line-2);
color: var(--ink-2);
}
.btn--ghost:hover {
border-color: var(--gold);
color: var(--gold-d);
}
.btn--block {
width: 100%;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ---------- Summary rail ---------- */
.summary {
position: sticky;
top: 1.5rem;
}
.summary__inner {
background: linear-gradient(170deg, var(--ink) 0%, #2a241e 100%);
color: var(--cream);
border-radius: var(--r-lg);
padding: 1.6rem 1.5rem;
box-shadow: var(--shadow-lg);
}
.summary .eyebrow {
color: var(--gold);
}
.summary__list {
list-style: none;
margin: 1.1rem 0 0;
padding: 0;
display: grid;
gap: 0.85rem;
}
.summary__row {
display: grid;
gap: 0.15rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid rgba(239, 226, 207, 0.14);
}
.summary__key {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--rose);
}
.summary__val {
font-size: 0.98rem;
font-weight: 500;
}
.summary__val.muted {
color: rgba(247, 241, 232, 0.45);
font-weight: 400;
font-style: italic;
}
.summary__val small {
display: block;
font-size: 0.76rem;
color: var(--rose);
font-weight: 400;
margin-top: 0.1rem;
}
.summary__totals {
margin: 1.1rem 0 1.25rem;
display: grid;
gap: 0.5rem;
}
.summary__line {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.9rem;
color: rgba(247, 241, 232, 0.78);
}
.summary__line span:last-child {
font-variant-numeric: tabular-nums;
}
.summary__line--price {
font-family: var(--serif);
font-size: 1.05rem;
color: var(--cream);
}
.summary__line--price span:last-child {
font-size: 1.7rem;
font-weight: 600;
color: var(--gold-soft);
}
.summary__note {
margin: 0.9rem 0 0;
font-size: 0.72rem;
color: rgba(247, 241, 232, 0.5);
text-align: center;
line-height: 1.4;
}
/* ---------- Confirmation ---------- */
.confirm {
text-align: center;
padding: 1.5rem 0;
}
.confirm__mark {
width: 72px;
height: 72px;
margin: 0 auto 1.2rem;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--white);
background: radial-gradient(circle at 30% 25%, var(--rose), var(--gold-d));
box-shadow: var(--shadow-md);
animation: pop 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.4);
}
@keyframes pop {
from {
transform: scale(0.6);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.confirm h2 {
font-size: 2.2rem;
margin: 0.3rem 0 0.6rem;
}
.confirm__copy {
max-width: 42ch;
margin: 0 auto 1.5rem;
color: var(--ink-2);
}
.confirm__ref {
display: inline-grid;
grid-auto-flow: column;
gap: 2.5rem;
margin: 0 auto 1.75rem;
padding: 1.1rem 1.75rem;
background: var(--cream);
border: 1px solid var(--gold-soft);
border-radius: var(--r-md);
text-align: left;
}
.confirm__ref dt {
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--gold-d);
font-weight: 600;
}
.confirm__ref dd {
margin: 0.25rem 0 0;
font-family: var(--serif);
font-size: 1.25rem;
font-weight: 600;
color: var(--ink);
letter-spacing: 0.03em;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 1.5rem;
transform: translate(-50%, 1.5rem);
background: var(--ink);
color: var(--gold-soft);
padding: 0.85rem 1.4rem;
border-radius: 999px;
font-size: 0.88rem;
font-weight: 500;
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease;
z-index: 50;
max-width: calc(100vw - 2rem);
}
.toast.is-visible {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 880px) {
.layout {
grid-template-columns: 1fr;
}
.summary {
position: static;
order: -1;
}
}
@media (max-width: 520px) {
.booking {
padding: 1.25rem 1rem 3rem;
}
.hero h1 {
font-size: 2rem;
}
.step__label {
display: none;
}
.step:not(:last-child)::after {
margin-left: 0;
}
.service {
grid-template-columns: 1fr auto;
gap: 0.5rem 0.75rem;
}
.service__cat {
grid-column: 1 / -1;
justify-self: start;
}
.service__body {
grid-column: 1;
}
.service__meta {
grid-column: 2;
align-self: center;
}
.screen__head h2 {
font-size: 1.55rem;
}
.confirm__ref {
grid-auto-flow: row;
gap: 1rem;
text-align: center;
}
.panel__actions {
flex-direction: column-reverse;
}
.panel__actions .btn {
width: 100%;
}
}(() => {
"use strict";
/* ---------------- Data ---------------- */
const SERVICES = [
{ id: "svc-cut", cat: "Hair", name: "Signature Cut & Style", desc: "Consultation, precision cut, blow-dry finish.", dur: 60, price: 85 },
{ id: "svc-blow", cat: "Hair", name: "Lumière Blowout", desc: "Wash, treatment mask and editorial styling.", dur: 45, price: 55 },
{ id: "svc-updo", cat: "Hair", name: "Occasion Updo", desc: "Sculpted styling for events and galleries.", dur: 75, price: 110 },
{ id: "svc-balayage", cat: "Color", name: "Hand-painted Balayage", desc: "Freehand lightening with gloss and toner.", dur: 165, price: 220 },
{ id: "svc-gloss", cat: "Color", name: "Glaze & Gloss", desc: "Shine-restoring demi-permanent refresh.", dur: 50, price: 75 },
{ id: "svc-root", cat: "Color", name: "Root Retouch", desc: "Seamless regrowth coverage, single process.", dur: 90, price: 95 },
{ id: "svc-mani", cat: "Nails", name: "Maison Manicure", desc: "Shaping, cuticle care and chrome finish.", dur: 45, price: 48 },
{ id: "svc-gel", cat: "Nails", name: "Gel Extensions", desc: "Custom-built tips with two-week wear.", dur: 90, price: 78 },
{ id: "svc-pedi", cat: "Spa", name: "Ritual Pedicure", desc: "Warm soak, exfoliation and pressure massage.", dur: 60, price: 65 },
{ id: "svc-facial", cat: "Spa", name: "Glow Facial", desc: "Double cleanse, mask and lymphatic massage.", dur: 75, price: 130 },
{ id: "svc-scalp", cat: "Spa", name: "Scalp Renewal", desc: "Detoxifying scrub and aromatic head massage.", dur: 40, price: 60 },
];
const STYLISTS = [
{ id: "any", name: "Any available", spec: "We pair you with the soonest opening", rating: null, init: "✶", tint: null, cats: ["Hair", "Color", "Nails", "Spa"] },
{ id: "aria", name: "Aria Vance", spec: "Cutting & Editorial Color", rating: 4.9, init: "AV", tint: "linear-gradient(135deg,#c9a78f,#8c6d3f)", cats: ["Hair", "Color"] },
{ id: "noor", name: "Noor Halabi", spec: "Balayage & Blonding", rating: 4.8, init: "NH", tint: "linear-gradient(135deg,#b08d57,#5f4a2e)", cats: ["Color", "Hair"] },
{ id: "lena", name: "Lena Okafor", spec: "Nail Artistry", rating: 5.0, init: "LO", tint: "linear-gradient(135deg,#caa089,#9a6b50)", cats: ["Nails"] },
{ id: "mara", name: "Mara Sølv", spec: "Skin & Spa Rituals", rating: 4.9, init: "MS", tint: "linear-gradient(135deg,#a89178,#6e5a40)", cats: ["Spa"] },
{ id: "theo", name: "Theo Marchetti", spec: "Precision Barbering", rating: 4.7, init: "TM", tint: "linear-gradient(135deg,#9c8463,#5a4730)", cats: ["Hair"] },
];
const SLOT_TIMES = ["9:30", "10:15", "11:00", "11:45", "13:00", "13:45", "14:30", "15:15", "16:00", "16:45", "17:30", "18:15"];
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/* ---------------- State ---------------- */
const state = {
step: 1,
service: null,
stylist: null,
cat: "all",
day: null, // ISO date string
slot: null,
};
/* ---------------- Helpers ---------------- */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const money = (n) => "$" + n;
const fmtDur = (m) => {
const h = Math.floor(m / 60), mm = m % 60;
if (h && mm) return `${h}h ${mm}m`;
if (h) return `${h}h`;
return `${mm}m`;
};
let toastTimer;
function toast(msg) {
const el = $("#toast");
el.textContent = msg;
el.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove("is-visible"), 3200);
}
/* ---------------- Render: services ---------------- */
const serviceList = $("#serviceList");
function renderServices() {
const items = SERVICES.filter((s) => state.cat === "all" || s.cat === state.cat);
serviceList.innerHTML = items
.map(
(s) => `
<li>
<button class="service ${state.service === s.id ? "is-selected" : ""}"
data-svc="${s.id}" aria-pressed="${state.service === s.id}">
<span class="service__cat">${s.cat}</span>
<span class="service__body">
<h3>${s.name}</h3>
<p class="service__desc">${s.desc}</p>
</span>
<span class="service__meta">
<span class="service__price">${money(s.price)}</span>
<span class="service__dur">${fmtDur(s.dur)}</span>
</span>
</button>
</li>`
)
.join("");
}
$("#serviceFilters").addEventListener("click", (e) => {
const btn = e.target.closest("[data-cat]");
if (!btn) return;
state.cat = btn.dataset.cat;
$$("#serviceFilters .chip").forEach((c) => {
const on = c === btn;
c.classList.toggle("is-active", on);
c.setAttribute("aria-selected", on);
});
renderServices();
});
serviceList.addEventListener("click", (e) => {
const btn = e.target.closest("[data-svc]");
if (!btn) return;
state.service = btn.dataset.svc;
// If chosen stylist can't do this category, reset stylist.
const svc = SERVICES.find((s) => s.id === state.service);
if (state.stylist) {
const st = STYLISTS.find((s) => s.id === state.stylist);
if (st && !st.cats.includes(svc.cat)) state.stylist = null;
}
renderServices();
sync();
});
/* ---------------- Render: stylists ---------------- */
const stylistGrid = $("#stylistGrid");
function renderStylists() {
const svc = SERVICES.find((s) => s.id === state.service);
const list = STYLISTS.filter((s) => !svc || s.cats.includes(svc.cat));
stylistGrid.innerHTML = list
.map((s) => {
const sel = state.stylist === s.id;
const avatar = s.tint
? `style="background:${s.tint}"`
: "";
const rating =
s.rating === null
? `<span class="stylist__rating muted">Flexible timing</span>`
: `<span class="stylist__rating"><span class="star">★</span>${s.rating.toFixed(1)}</span>`;
return `
<li>
<button class="stylist ${s.id === "any" ? "stylist--any" : ""} ${sel ? "is-selected" : ""}"
data-stylist="${s.id}" aria-pressed="${sel}">
<span class="stylist__avatar" ${avatar}>${s.init}</span>
<span class="stylist__name">${s.name}</span>
<span class="stylist__spec">${s.spec}</span>
${rating}
</button>
</li>`;
})
.join("");
}
stylistGrid.addEventListener("click", (e) => {
const btn = e.target.closest("[data-stylist]");
if (!btn) return;
state.stylist = btn.dataset.stylist;
renderStylists();
sync();
});
/* ---------------- Render: days & slots ---------------- */
const dayStrip = $("#dayStrip");
const slotGrid = $("#slotGrid");
// Build the next 10 days starting tomorrow.
const DAYS = (() => {
const out = [];
const base = new Date();
base.setHours(0, 0, 0, 0);
for (let i = 1; i <= 10; i++) {
const d = new Date(base);
d.setDate(base.getDate() + i);
out.push(d);
}
return out;
})();
function isoOf(d) {
return d.toISOString().slice(0, 10);
}
// Deterministic pseudo-availability per day+time so it stays stable.
function slotOpen(iso, time) {
let h = 0;
const str = iso + time;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return h % 10 > 2; // ~70% open
}
function renderDays() {
dayStrip.innerHTML = DAYS.map((d) => {
const iso = isoOf(d);
const sun = d.getDay() === 0; // closed Sundays
const sel = state.day === iso;
return `
<button class="day ${sel ? "is-selected" : ""}" data-day="${iso}"
role="option" aria-selected="${sel}" ${sun ? "disabled" : ""}>
<span class="day__dow">${DOW[d.getDay()]}</span>
<span class="day__num">${d.getDate()}</span>
<span class="day__mon">${MON[d.getMonth()]}</span>
</button>`;
}).join("");
}
function renderSlots() {
if (!state.day) {
slotGrid.innerHTML = `<p class="slots__empty">Select a day above to see open times.</p>`;
return;
}
slotGrid.innerHTML = SLOT_TIMES.map((t) => {
const open = slotOpen(state.day, t);
const sel = state.slot === t;
return `
<button class="slot ${sel ? "is-selected" : ""}" data-slot="${t}"
role="option" aria-selected="${sel}" ${open ? "" : "disabled aria-disabled=\"true\""}>
${t}
</button>`;
}).join("");
}
dayStrip.addEventListener("click", (e) => {
const btn = e.target.closest("[data-day]");
if (!btn || btn.disabled) return;
state.day = btn.dataset.day;
state.slot = null; // reset slot when day changes
renderDays();
renderSlots();
sync();
});
slotGrid.addEventListener("click", (e) => {
const btn = e.target.closest("[data-slot]");
if (!btn || btn.disabled) return;
state.slot = btn.dataset.slot;
renderSlots();
sync();
});
/* ---------------- Summary + totals ---------------- */
function prettyDay(iso) {
const d = new Date(iso + "T00:00:00");
return `${DOW[d.getDay()]}, ${MON[d.getMonth()]} ${d.getDate()}`;
}
function setRow(row, valHtml, filled) {
const valEl = $(`.summary__row[data-row="${row}"] .summary__val`);
valEl.innerHTML = valHtml;
valEl.classList.toggle("muted", !filled);
}
function sync() {
const svc = SERVICES.find((s) => s.id === state.service);
const st = STYLISTS.find((s) => s.id === state.stylist);
setRow(
"service",
svc ? `${svc.name}<small>${svc.cat} · ${fmtDur(svc.dur)}</small>` : "Not selected",
!!svc
);
setRow("stylist", st ? st.name : "Not selected", !!st);
setRow(
"when",
state.day && state.slot
? `${prettyDay(state.day)}<small>${state.slot}</small>`
: "Not selected",
!!(state.day && state.slot)
);
$("#totalDuration").textContent = svc ? fmtDur(svc.dur) : "—";
$("#totalPrice").textContent = svc ? money(svc.price) : "$0";
// Footer continue button gating
const nextBtn = $("#nextBtn");
nextBtn.disabled = !stepValid(state.step);
// Confirm button: needs everything
$("#confirmBtn").disabled = !(state.service && state.stylist && state.day && state.slot);
}
function stepValid(step) {
if (step === 1) return !!state.service;
if (step === 2) return !!state.stylist;
if (step === 3) return !!(state.day && state.slot);
return false;
}
/* ---------------- Step navigation ---------------- */
function showStep(step) {
state.step = step;
$$(".screen").forEach((s) => s.classList.remove("is-active"));
$(`.screen[data-screen="${step}"]`).classList.add("is-active");
$$("#steps .step").forEach((el) => {
const n = Number(el.dataset.step);
el.classList.toggle("is-active", n === step);
el.classList.toggle("is-done", n < step);
});
$("#backBtn").hidden = step === 1;
const nextBtn = $("#nextBtn");
nextBtn.textContent = step === 3 ? "Review & confirm" : "Continue";
$("#panelActions").hidden = false;
if (step === 2) renderStylists();
if (step === 3) {
renderDays();
renderSlots();
}
sync();
}
$("#nextBtn").addEventListener("click", () => {
if (!stepValid(state.step)) {
const msg =
state.step === 1
? "Please choose a service first."
: state.step === 2
? "Please choose a stylist."
: "Please pick a date and time.";
toast(msg);
return;
}
if (state.step < 3) {
showStep(state.step + 1);
} else {
$("#confirmBtn").focus();
toast("Looking good — confirm to lock it in.");
}
});
$("#backBtn").addEventListener("click", () => {
if (state.step > 1) showStep(state.step - 1);
});
/* ---------------- Confirm ---------------- */
function makeRef() {
const a = "ML";
const n = Math.floor(1000 + Math.random() * 9000);
const c = String.fromCharCode(65 + Math.floor(Math.random() * 26));
return `${a}-${n}-${c}`;
}
$("#confirmBtn").addEventListener("click", () => {
if (!(state.service && state.stylist && state.day && state.slot)) {
toast("Complete every step before confirming.");
return;
}
const svc = SERVICES.find((s) => s.id === state.service);
const st = STYLISTS.find((s) => s.id === state.stylist);
const ref = makeRef();
$("#confirmRef").textContent = ref;
$("#confirmCopy").innerHTML =
`Your <strong>${svc.name}</strong> with <strong>${st.name}</strong> is reserved for ` +
`<strong>${prettyDay(state.day)} at ${state.slot}</strong>. We can't wait to see you.`;
// Hide stepper + actions, show confirmation.
$("#steps").classList.add("is-hidden");
$$(".screen").forEach((s) => s.classList.remove("is-active"));
$(`.screen[data-screen="done"]`).classList.add("is-active");
$("#panelActions").hidden = true;
$$("#steps .step").forEach((el) => el.classList.add("is-done"));
toast(`Booked! Reference ${ref}`);
});
$("#restartBtn").addEventListener("click", () => {
state.service = null;
state.stylist = null;
state.cat = "all";
state.day = null;
state.slot = null;
$("#steps").classList.remove("is-hidden");
$$("#steps .step").forEach((el) => el.classList.remove("is-done"));
$$("#serviceFilters .chip").forEach((c, i) => {
c.classList.toggle("is-active", i === 0);
c.setAttribute("aria-selected", i === 0);
});
renderServices();
showStep(1);
toast("Let's start a fresh booking.");
});
/* ---------------- Init ---------------- */
renderServices();
showStep(1);
})();<!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=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Book an appointment · Maison Lumière Salon</title>
</head>
<body>
<main class="booking" aria-labelledby="page-title">
<header class="hero">
<div class="hero__brand">
<span class="hero__monogram" aria-hidden="true">ML</span>
<div>
<p class="kicker">Maison Lumière Salon</p>
<h1 id="page-title">Reserve your moment</h1>
</div>
</div>
<p class="hero__lede">
A considered ritual in three steps — choose your service, your artist,
and the hour that suits you.
</p>
</header>
<nav class="steps" id="steps" aria-label="Booking progress">
<ol>
<li class="step is-active" data-step="1">
<span class="step__dot">1</span>
<span class="step__label">Service</span>
</li>
<li class="step" data-step="2">
<span class="step__dot">2</span>
<span class="step__label">Stylist</span>
</li>
<li class="step" data-step="3">
<span class="step__dot">3</span>
<span class="step__label">Date & Time</span>
</li>
</ol>
</nav>
<div class="layout">
<section class="panel" aria-live="polite">
<!-- STEP 1 — SERVICE -->
<div class="screen is-active" data-screen="1">
<div class="screen__head">
<p class="eyebrow">Step one</p>
<h2>Choose a service</h2>
<p class="screen__hint">
Browse by category. Prices are starting from; final quote is set
at consultation.
</p>
</div>
<div class="filters" id="serviceFilters" role="tablist" aria-label="Service categories">
<button class="chip is-active" role="tab" aria-selected="true" data-cat="all">All</button>
<button class="chip" role="tab" aria-selected="false" data-cat="Hair">Hair</button>
<button class="chip" role="tab" aria-selected="false" data-cat="Color">Color</button>
<button class="chip" role="tab" aria-selected="false" data-cat="Nails">Nails</button>
<button class="chip" role="tab" aria-selected="false" data-cat="Spa">Spa</button>
</div>
<ul class="service-list" id="serviceList"></ul>
</div>
<!-- STEP 2 — STYLIST -->
<div class="screen" data-screen="2">
<div class="screen__head">
<p class="eyebrow">Step two</p>
<h2>Pick your artist</h2>
<p class="screen__hint">
Each stylist brings their own signature. Or let us pair you with
the next available.
</p>
</div>
<ul class="stylist-grid" id="stylistGrid"></ul>
</div>
<!-- STEP 3 — DATE & TIME -->
<div class="screen" data-screen="3">
<div class="screen__head">
<p class="eyebrow">Step three</p>
<h2>Find a time</h2>
<p class="screen__hint">Select a day, then an available slot.</p>
</div>
<div class="daystrip" id="dayStrip" role="listbox" aria-label="Choose a day"></div>
<div class="slots-wrap">
<p class="slots-label" id="slotsLabel">Available times</p>
<div class="slots" id="slotGrid" role="listbox" aria-label="Available time slots"></div>
</div>
</div>
<!-- CONFIRMATION -->
<div class="screen confirm" data-screen="done">
<div class="confirm__mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="48" height="48">
<path d="M14 25l7 7 14-15" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<p class="eyebrow">You're booked</p>
<h2 id="confirmTitle">A votre service</h2>
<p class="confirm__copy" id="confirmCopy"></p>
<dl class="confirm__ref">
<div>
<dt>Reference</dt>
<dd id="confirmRef">—</dd>
</div>
<div>
<dt>Added to</dt>
<dd>your inbox & calendar</dd>
</div>
</dl>
<button class="btn btn--ghost" id="restartBtn" type="button">Book another appointment</button>
</div>
<footer class="panel__actions" id="panelActions">
<button class="btn btn--ghost" id="backBtn" type="button" hidden>Back</button>
<button class="btn btn--primary" id="nextBtn" type="button" disabled>Continue</button>
</footer>
</section>
<!-- SUMMARY RAIL -->
<aside class="summary" aria-label="Booking summary">
<div class="summary__inner">
<p class="eyebrow">Your appointment</p>
<ul class="summary__list" id="summaryList">
<li class="summary__row" data-row="service">
<span class="summary__key">Service</span>
<span class="summary__val muted">Not selected</span>
</li>
<li class="summary__row" data-row="stylist">
<span class="summary__key">Stylist</span>
<span class="summary__val muted">Not selected</span>
</li>
<li class="summary__row" data-row="when">
<span class="summary__key">When</span>
<span class="summary__val muted">Not selected</span>
</li>
</ul>
<div class="summary__totals">
<div class="summary__line">
<span>Duration</span>
<span id="totalDuration">—</span>
</div>
<div class="summary__line summary__line--price">
<span>From</span>
<span id="totalPrice">$0</span>
</div>
</div>
<button class="btn btn--primary btn--block" id="confirmBtn" type="button" disabled>
Confirm booking
</button>
<p class="summary__note">No payment due today · Free cancellation up to 24h before.</p>
</div>
</aside>
</div>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Service + Stylist + Time Booking
A guided reservation ritual for Maison Lumière Salon, composed as three quiet steps. Service presents a categorized menu — Hair, Color, Nails and Spa — each row carrying a short description, duration and a starting price; a chip rail filters the list in place. Stylist offers avatar cards with specialty and rating, automatically narrowed to the artists who perform the chosen service, plus an Any available option for flexible timing. Date & Time pairs a horizontal day strip (Sundays closed) with a slot grid whose openings are deterministic, so the screen stays believable on every visit.
A sticky summary rail mirrors every choice in real time — service, stylist, and the selected day and hour — and totals the duration and price as you go. Forward motion is gated: the Continue button only enables once the current step is satisfied, while Back lets guests revise earlier choices without losing context. Changing a service that the current stylist can’t perform gracefully clears the stylist; changing the day resets the time.
Confirming validates the full selection, generates a salon reference number, and flips the panel to a polished confirmation card with a celebratory toast. Book another appointment resets the flow to a clean slate. Everything is vanilla HTML, CSS and JavaScript — no frameworks, no build — with serif display type, gold hairlines and refined micro-interactions throughout.