Interior — Consultation Booking
A warm editorial consultation booking flow for an interior design studio: pick a consultation type from tactile cards, toggle in-person or virtual delivery, choose a date and open time slot from a calendar, then fill in project details and confirm. Live pricing, availability badges, running summary rail, form validation, and a graceful confirmation state make the whole scheduling journey feel considered and effortless.
MCP
Code
:root {
--bg: #f6f2ec;
--paper: #fbf9f5;
--ink: #2c2620;
--ink-2: #5a5147;
--muted: #8a8175;
--clay: #b08968;
--clay-d: #8c6a4f;
--walnut: #5c4433;
--sage: #9caf88;
--line: rgba(44, 38, 32, 0.12);
--white: #fff;
--r-sm: 4px;
--r-md: 8px;
--r-lg: 14px;
--shadow: 0 18px 44px -28px rgba(44, 38, 32, 0.5);
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
body {
margin: 0;
font-family: var(--sans);
background:
radial-gradient(1200px 600px at 88% -8%, rgba(176, 137, 104, 0.1), transparent 60%),
var(--bg);
color: var(--ink);
line-height: 1.5;
min-height: 100vh;
}
.page { max-width: 1120px; margin: 0 auto; padding: 30px 24px 80px; }
/* Masthead */
.masthead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-mark {
display: grid;
place-items: center;
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: var(--walnut);
color: var(--paper);
font-family: var(--serif);
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.brand-name { margin: 0; font-family: var(--serif); font-size: 24px; font-weight: 600; line-height: 1; }
.brand-sub { margin: 4px 0 0; font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); }
.steps ol { display: flex; gap: 22px; margin: 0; padding: 0; list-style: none; }
.step { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 500; color: var(--muted); }
.step .dot {
display: grid;
place-items: center;
width: 22px; height: 22px;
border-radius: 50%;
border: 1px solid var(--line);
font-size: 12px; font-weight: 600;
background: var(--paper);
transition: 0.25s ease;
}
.step.is-active { color: var(--ink); }
.step.is-active .dot { background: var(--clay); color: var(--white); border-color: var(--clay); }
.step.is-done .dot { background: var(--walnut); color: var(--white); border-color: var(--walnut); }
/* Layout */
.layout {
display: grid;
grid-template-columns: 1fr 336px;
gap: 34px;
margin-top: 34px;
align-items: start;
}
/* Intro */
.eyebrow { margin: 0 0 8px; font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--clay-d); font-weight: 600; }
.intro h1 { margin: 0; font-family: var(--serif); font-weight: 500; font-size: clamp(30px, 5vw, 44px); line-height: 1.06; letter-spacing: -0.01em; }
.lede { margin: 12px 0 0; max-width: 46ch; color: var(--ink-2); font-size: 15px; }
/* Blocks */
.block { border: 0; margin: 34px 0 0; padding: 0; }
.block legend {
display: flex; align-items: baseline; gap: 10px;
font-family: var(--serif); font-size: 22px; font-weight: 600;
padding: 0; margin-bottom: 16px;
}
.block legend .num { font-family: var(--sans); font-size: 12px; font-weight: 700; color: var(--clay); letter-spacing: 0.1em; }
/* Type cards */
.type-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.type-card {
position: relative;
display: flex; flex-direction: column; gap: 10px;
padding: 18px 16px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.type-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: rgba(140, 106, 79, 0.4); }
.type-card input { position: absolute; opacity: 0; pointer-events: none; }
.type-card.is-selected { border-color: var(--clay); background: linear-gradient(180deg, rgba(176, 137, 104, 0.09), var(--paper)); box-shadow: var(--shadow); }
.type-card.is-selected::after {
content: "✓"; position: absolute; top: 14px; right: 14px;
width: 22px; height: 22px; border-radius: 50%;
background: var(--clay); color: var(--white);
display: grid; place-items: center; font-size: 12px; font-weight: 700;
}
.type-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.type-name { font-family: var(--serif); font-size: 20px; font-weight: 600; }
.type-price { font-size: 14px; font-weight: 700; color: var(--walnut); }
.type-desc { font-size: 13px; color: var(--ink-2); }
.type-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
.chip {
font-size: 11px; font-weight: 600; letter-spacing: 0.02em;
padding: 3px 9px; border-radius: 999px;
background: rgba(92, 68, 51, 0.1); color: var(--walnut);
}
.chip-soft { background: rgba(156, 175, 136, 0.22); color: #556647; }
.type-card:focus-within { outline: 2px solid var(--clay-d); outline-offset: 2px; }
/* Format toggle */
.format { display: flex; align-items: center; gap: 16px; margin-top: 18px; flex-wrap: wrap; }
.format-label { font-size: 14px; font-weight: 500; color: var(--ink-2); }
.segmented { display: inline-flex; padding: 4px; gap: 4px; background: var(--paper); border: 1px solid var(--line); border-radius: 999px; }
.seg {
display: inline-flex; align-items: center; gap: 7px;
border: 0; background: transparent; cursor: pointer;
font-family: var(--sans); font-size: 13px; font-weight: 600; color: var(--ink-2);
padding: 8px 16px; border-radius: 999px;
transition: 0.2s ease;
}
.seg .seg-ico { font-size: 11px; color: var(--clay); }
.seg:hover { color: var(--ink); }
.seg.is-on { background: var(--walnut); color: var(--white); }
.seg.is-on .seg-ico { color: var(--sage); }
.seg:focus-visible { outline: 2px solid var(--clay-d); outline-offset: 2px; }
/* Scheduler */
.scheduler { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 20px; }
.calendar, .slots {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 16px 16px 14px;
}
.cal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.cal-title { margin: 0; font-family: var(--serif); font-size: 19px; font-weight: 600; }
.cal-nav {
width: 30px; height: 30px; border-radius: var(--r-sm);
border: 1px solid var(--line); background: var(--white); color: var(--ink);
cursor: pointer; font-size: 16px; line-height: 1;
transition: 0.2s ease;
}
.cal-nav:hover { background: var(--walnut); color: var(--white); border-color: var(--walnut); }
.cal-nav:focus-visible { outline: 2px solid var(--clay-d); outline-offset: 2px; }
.cal-dow { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.cal-day {
aspect-ratio: 1 / 1;
border: 1px solid transparent;
background: transparent;
border-radius: var(--r-sm);
font-family: var(--sans); font-size: 13px; font-weight: 500;
color: var(--ink-2); cursor: pointer;
display: grid; place-items: center; position: relative;
transition: 0.15s ease;
}
.cal-day.empty { visibility: hidden; }
.cal-day.avail { color: var(--walnut); font-weight: 700; }
.cal-day.avail::after {
content: ""; position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%);
width: 4px; height: 4px; border-radius: 50%; background: var(--clay);
}
.cal-day.avail:hover { background: rgba(176, 137, 104, 0.16); border-color: rgba(176, 137, 104, 0.35); }
.cal-day.disabled { color: rgba(138, 129, 117, 0.55); cursor: not-allowed; }
.cal-day.is-selected { background: var(--walnut); color: var(--white); border-color: var(--walnut); }
.cal-day.is-selected::after { background: var(--sage); }
.cal-day:focus-visible { outline: 2px solid var(--clay-d); outline-offset: 1px; }
.cal-legend { display: flex; align-items: center; gap: 7px; margin: 12px 0 0; font-size: 11px; color: var(--muted); }
.dotmark { width: 6px; height: 6px; border-radius: 50%; background: var(--clay); display: inline-block; }
/* Slots */
.slots-title { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: var(--ink); }
.slot-list { display: flex; flex-direction: column; gap: 8px; max-height: 268px; overflow-y: auto; padding-right: 2px; }
.slots-empty { margin: 6px 0; font-size: 13px; color: var(--muted); }
.slot {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 11px 13px; border: 1px solid var(--line); border-radius: var(--r-md);
background: var(--white); cursor: pointer;
font-family: var(--sans); font-size: 14px; font-weight: 600; color: var(--ink); text-align: left;
transition: 0.18s ease;
}
.slot:hover { border-color: var(--clay); transform: translateX(2px); }
.slot .slot-cap { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: rgba(156, 175, 136, 0.24); color: #556647; }
.slot .slot-cap.few { background: rgba(176, 137, 104, 0.2); color: var(--clay-d); }
.slot.is-selected { background: var(--walnut); color: var(--white); border-color: var(--walnut); }
.slot.is-selected .slot-cap { background: rgba(251, 249, 245, 0.2); color: var(--paper); }
.slot:focus-visible { outline: 2px solid var(--clay-d); outline-offset: 2px; }
/* Details form */
.details { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field-wide { grid-column: 1 / -1; }
.field label { font-size: 13px; font-weight: 600; color: var(--ink-2); }
.field input, .field textarea {
font-family: var(--sans); font-size: 14px; color: var(--ink);
padding: 11px 13px; border: 1px solid var(--line); border-radius: var(--r-md);
background: var(--paper); resize: vertical;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.field input::placeholder, .field textarea::placeholder { color: var(--muted); }
.field input:focus, .field textarea:focus {
outline: none; border-color: var(--clay);
box-shadow: 0 0 0 3px rgba(176, 137, 104, 0.16);
}
.field.invalid input, .field.invalid textarea { border-color: #b5533f; box-shadow: 0 0 0 3px rgba(181, 83, 63, 0.14); }
.err { font-size: 12px; color: #b5533f; min-height: 14px; }
.hint { font-size: 12px; color: var(--muted); }
.check { margin-top: -4px; }
.checkbox { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; font-weight: 500; color: var(--ink-2); cursor: pointer; }
.checkbox input { width: 17px; height: 17px; margin-top: 1px; accent-color: var(--clay); }
/* Summary rail */
.rail { position: sticky; top: 24px; }
.summary, .confirmed {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 22px 20px;
box-shadow: var(--shadow);
}
.rail-eyebrow { margin: 0 0 6px; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--clay-d); font-weight: 600; }
.rail-service { margin: 0; font-family: var(--serif); font-size: 25px; font-weight: 600; line-height: 1.1; }
.rail-desc { margin: 8px 0 16px; font-size: 13px; color: var(--ink-2); }
.rail-lines { margin: 0; padding: 16px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); display: grid; gap: 10px; }
.line { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.line dt { margin: 0; font-size: 13px; color: var(--muted); }
.line dd { margin: 0; font-size: 13px; font-weight: 600; text-align: right; color: var(--ink); }
.rail-total { display: flex; align-items: baseline; justify-content: space-between; padding: 16px 0 4px; }
.rail-total span { font-size: 14px; font-weight: 600; color: var(--ink-2); }
.rail-total strong { font-family: var(--serif); font-size: 30px; font-weight: 600; color: var(--walnut); }
.confirm {
width: 100%; margin-top: 12px;
padding: 14px; border: 0; border-radius: var(--r-md);
background: var(--walnut); color: var(--paper);
font-family: var(--sans); font-size: 15px; font-weight: 600; cursor: pointer;
transition: 0.2s ease;
}
.confirm:hover:not(:disabled) { background: #47341f; transform: translateY(-1px); }
.confirm:active:not(:disabled) { transform: translateY(0); }
.confirm:focus-visible { outline: 2px solid var(--clay-d); outline-offset: 2px; }
.confirm:disabled { background: rgba(92, 68, 51, 0.28); cursor: not-allowed; }
.rail-note { margin: 12px 0 0; font-size: 11px; color: var(--muted); text-align: center; }
/* Confirmed state */
.confirmed { text-align: center; }
.tick {
width: 54px; height: 54px; margin: 0 auto 14px;
border-radius: 50%; background: var(--sage); color: var(--white);
display: grid; place-items: center; font-size: 26px;
animation: pop 0.4s cubic-bezier(0.2, 1.2, 0.4, 1) both;
}
.confirmed h2 { margin: 0; font-family: var(--serif); font-size: 26px; font-weight: 600; }
.conf-line { margin: 10px 0 4px; font-size: 14px; color: var(--ink-2); }
.conf-ref { margin: 4px 0 18px; font-size: 13px; color: var(--muted); }
.conf-ref strong { color: var(--walnut); letter-spacing: 0.05em; }
.ghost {
border: 1px solid var(--clay); background: transparent; color: var(--clay-d);
padding: 11px 20px; border-radius: var(--r-md); cursor: pointer;
font-family: var(--sans); font-size: 14px; font-weight: 600;
transition: 0.2s ease;
}
.ghost:hover { background: var(--clay); color: var(--white); }
.ghost:focus-visible { outline: 2px solid var(--clay-d); outline-offset: 2px; }
@keyframes pop { from { transform: scale(0.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
/* Toast */
.toast-wrap { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; z-index: 50; align-items: center; }
.toast {
background: var(--ink); color: var(--paper);
padding: 11px 18px; border-radius: 999px;
font-size: 13px; font-weight: 500;
box-shadow: var(--shadow);
animation: toast-in 0.3s ease both;
}
.toast.out { animation: toast-out 0.3s ease forwards; }
@keyframes toast-in { from { transform: translateY(14px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes toast-out { to { transform: translateY(14px); opacity: 0; } }
/* Responsive */
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
.rail { position: static; }
.type-grid { grid-template-columns: 1fr; }
.scheduler { grid-template-columns: 1fr; }
}
@media (max-width: 520px) {
.page { padding: 22px 16px 60px; }
.masthead { gap: 14px; }
.steps ol { gap: 14px; }
.step span:not(.dot) { display: none; }
.step { gap: 0; }
.details { grid-template-columns: 1fr; }
.intro h1 { font-size: 30px; }
.format { align-items: flex-start; }
.rail-service { font-size: 22px; }
.slot-list { max-height: none; }
}(function () {
"use strict";
/* ---------- data ---------- */
var SERVICES = {
discovery: { name: "Discovery Call", desc: "A relaxed chat to map your goals and budget.", price: 0, minutes: 30 },
styling: { name: "In-Home Styling", desc: "One room walked with you, styling plan left behind.", price: 180, minutes: 60 },
full: { name: "Full Room Design", desc: "Mood direction, sourcing strategy and a scoped path.", price: 420, minutes: 90 }
};
// Weekday availability pattern + slot templates (deterministic, fictional).
var SLOT_TEMPLATES = ["09:00", "10:30", "12:00", "14:00", "15:30", "17:00"];
var MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
/* ---------- state ---------- */
var state = {
type: null,
format: "inperson",
date: null, // Date object
time: null, // string "HH:MM"
viewYear: 0,
viewMonth: 0
};
var today = new Date();
today.setHours(0, 0, 0, 0);
state.viewYear = today.getFullYear();
state.viewMonth = today.getMonth();
/* ---------- helpers ---------- */
function $(sel, root) { return (root || document).querySelector(sel); }
function $all(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
function toast(msg) {
var wrap = $("#toast-wrap");
var el = document.createElement("div");
el.className = "toast";
el.textContent = msg;
wrap.appendChild(el);
setTimeout(function () {
el.classList.add("out");
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 320);
}, 2400);
}
// A day is "available" if it's in the future (or today) and not a Sunday,
// with a couple of scattered closures for realism.
function isAvailable(d) {
if (d < today) return false;
var dow = d.getDay();
if (dow === 0) return false; // Sundays closed
var dom = d.getDate();
if (dom === 4 || dom === 18 || dom === 25) return false; // studio closures
return true;
}
// Deterministic pseudo-capacity per day so slots feel real but stable.
function slotsForDate(d) {
var seed = d.getFullYear() * 372 + (d.getMonth() + 1) * 31 + d.getDate();
var out = [];
for (var i = 0; i < SLOT_TEMPLATES.length; i++) {
var v = (seed * (i + 7)) % 11;
if (v < 3) continue; // some slots already booked
out.push({ time: SLOT_TEMPLATES[i], left: (v % 4) + 1 });
}
return out;
}
function fmtDate(d) {
var wk = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()];
return wk + " " + d.getDate() + " " + MONTHS[d.getMonth()].slice(0, 3);
}
function fmtTime(t) {
var parts = t.split(":");
var h = parseInt(parts[0], 10);
var ampm = h >= 12 ? "PM" : "AM";
var h12 = h % 12; if (h12 === 0) h12 = 12;
return h12 + ":" + parts[1] + " " + ampm;
}
/* ---------- step indicator ---------- */
function updateSteps() {
var s1 = !!state.type;
var s2 = !!(state.date && state.time);
var f = $("#details-form");
var s3 = f && f.name.value.trim() && /.+@.+\..+/.test(f.email.value) && f.scope.value.trim();
setStep(1, s1, false);
setStep(2, s2, s1 && !s2);
setStep(3, !!s3, s2 && !s3);
if (!s1) setStep(1, false, true);
else if (!s2) setStep(2, false, true);
else if (!s3) setStep(3, false, true);
}
function setStep(n, done, active) {
var el = $('.step[data-step="' + n + '"]');
if (!el) return;
el.classList.toggle("is-done", done);
el.classList.toggle("is-active", active);
}
/* ---------- calendar ---------- */
function renderCalendar() {
var grid = $("#cal-grid");
grid.innerHTML = "";
$("#cal-title").textContent = MONTHS[state.viewMonth] + " " + state.viewYear;
var first = new Date(state.viewYear, state.viewMonth, 1);
var startDow = first.getDay();
var daysInMonth = new Date(state.viewYear, state.viewMonth + 1, 0).getDate();
for (var b = 0; b < startDow; b++) {
var blank = document.createElement("div");
blank.className = "cal-day empty";
grid.appendChild(blank);
}
for (var day = 1; day <= daysInMonth; day++) {
var d = new Date(state.viewYear, state.viewMonth, day);
d.setHours(0, 0, 0, 0);
var btn = document.createElement("button");
btn.type = "button";
btn.className = "cal-day";
btn.textContent = day;
btn.setAttribute("role", "gridcell");
var avail = isAvailable(d);
if (avail) {
btn.classList.add("avail");
btn.setAttribute("aria-label", fmtDate(d) + ", available");
(function (dateObj) {
btn.addEventListener("click", function () { selectDate(dateObj, btn); });
})(d);
} else {
btn.classList.add("disabled");
btn.disabled = true;
btn.setAttribute("aria-label", fmtDate(d) + ", unavailable");
}
if (state.date && d.getTime() === state.date.getTime()) {
btn.classList.add("is-selected");
}
grid.appendChild(btn);
}
}
function selectDate(d, btn) {
state.date = d;
state.time = null;
$all(".cal-day.is-selected").forEach(function (el) { el.classList.remove("is-selected"); });
if (btn) btn.classList.add("is-selected");
renderSlots();
updateSummary();
updateSteps();
toast("Date set — " + fmtDate(d));
}
/* ---------- slots ---------- */
function renderSlots() {
var list = $("#slot-list");
var title = $("#slots-title");
list.innerHTML = "";
if (!state.date) {
title.textContent = "Select a date to see times";
list.innerHTML = '<p class="slots-empty">No date selected yet.</p>';
return;
}
title.textContent = "Times for " + fmtDate(state.date);
var slots = slotsForDate(state.date);
if (!slots.length) {
list.innerHTML = '<p class="slots-empty">Fully booked — try another day.</p>';
return;
}
var mins = state.type ? SERVICES[state.type].minutes : 60;
slots.forEach(function (s) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "slot";
if (state.time === s.time) btn.classList.add("is-selected");
var capClass = s.left <= 1 ? "slot-cap few" : "slot-cap";
var capText = s.left <= 1 ? "1 left" : s.left + " open";
btn.innerHTML =
'<span>' + fmtTime(s.time) + ' <span style="font-weight:400;opacity:.7">· ' + mins + ' min</span></span>' +
'<span class="' + capClass + '">' + capText + '</span>';
btn.setAttribute("aria-label", fmtTime(s.time) + ", " + capText);
btn.addEventListener("click", function () { selectTime(s.time, btn); });
list.appendChild(btn);
});
}
function selectTime(t, btn) {
state.time = t;
$all(".slot.is-selected").forEach(function (el) { el.classList.remove("is-selected"); });
btn.classList.add("is-selected");
updateSummary();
updateSteps();
toast("Time held — " + fmtTime(t));
}
/* ---------- type + format ---------- */
function bindTypes() {
$all(".type-card").forEach(function (card) {
var input = card.querySelector("input");
card.addEventListener("click", function () { input.checked = true; onTypeChange(); });
input.addEventListener("change", onTypeChange);
});
}
function onTypeChange() {
var checked = document.querySelector('input[name="ctype"]:checked');
state.type = checked ? checked.value : null;
$all(".type-card").forEach(function (card) {
card.classList.toggle("is-selected", card.querySelector("input").checked);
});
renderSlots(); // duration label depends on type
updateSummary();
updateSteps();
if (state.type) toast(SERVICES[state.type].name + " selected");
}
function bindFormat() {
$all(".seg").forEach(function (seg) {
seg.addEventListener("click", function () {
state.format = seg.getAttribute("data-format");
$all(".seg").forEach(function (s) {
var on = s === seg;
s.classList.toggle("is-on", on);
s.setAttribute("aria-pressed", on ? "true" : "false");
});
updateLocationField();
updateSummary();
toast(state.format === "virtual" ? "Switched to virtual" : "Switched to in-person");
});
});
}
function updateLocationField() {
var label = $("#location-label");
var input = $("#f-location");
var hint = $("#location-hint");
if (state.format === "virtual") {
label.textContent = "Video link preference";
input.placeholder = "Zoom, Google Meet, or leave blank";
hint.textContent = "We'll email a video link before the call.";
} else {
label.textContent = "Project address";
input.placeholder = "Street, suburb, postcode";
hint.textContent = "Where should the designer visit?";
}
}
/* ---------- summary ---------- */
function updateSummary() {
var svc = state.type ? SERVICES[state.type] : null;
$("#s-service").textContent = svc ? svc.name : "No service selected";
$("#s-desc").textContent = svc ? svc.desc : "Pick a consultation type to begin.";
$("#s-format").textContent = state.format === "virtual" ? "Virtual call" : "In-person visit";
$("#s-date").textContent = state.date ? fmtDate(state.date) : "—";
$("#s-time").textContent = state.time ? fmtTime(state.time) : "—";
$("#s-duration").textContent = svc ? svc.minutes + " minutes" : "—";
$("#s-price").textContent = svc ? (svc.price === 0 ? "Free" : "$" + svc.price) : "$0";
var ready = !!(state.type && state.date && state.time);
$("#confirm-btn").disabled = !ready;
}
/* ---------- validation ---------- */
function validateForm(report) {
var f = $("#details-form");
var ok = true;
var checks = [
{ key: "name", el: f.name, test: function (v) { return v.trim().length >= 2; }, msg: "Please enter your name." },
{ key: "email", el: f.email, test: function (v) { return /.+@.+\..+/.test(v.trim()); }, msg: "Enter a valid email." },
{ key: "scope", el: f.scope, test: function (v) { return v.trim().length >= 8; }, msg: "Tell us a little about the space." }
];
checks.forEach(function (c) {
var field = c.el.closest(".field");
var errEl = document.querySelector('.err[data-for="' + c.key + '"]');
var pass = c.test(c.el.value);
if (!pass) ok = false;
if (report) {
field.classList.toggle("invalid", !pass);
if (errEl) errEl.textContent = pass ? "" : c.msg;
}
});
return ok;
}
function bindForm() {
var f = $("#details-form");
$all("input, textarea", f).forEach(function (el) {
el.addEventListener("input", function () {
var field = el.closest(".field");
if (field && field.classList.contains("invalid")) validateForm(true);
updateSteps();
});
});
}
/* ---------- confirm / reset ---------- */
function confirmBooking() {
if (state.type == null || !state.date || !state.time) {
toast("Pick a service, date and time first");
return;
}
if (!validateForm(true)) {
toast("Please complete your details");
var firstBad = document.querySelector(".field.invalid input, .field.invalid textarea");
if (firstBad) firstBad.focus();
return;
}
var ref = "ML-" + state.date.getFullYear().toString().slice(2) +
String(state.date.getMonth() + 1).padStart(2, "0") +
String(state.date.getDate()).padStart(2, "0") + "-" +
Math.floor(1000 + Math.random() * 9000);
var svc = SERVICES[state.type];
var fmt = state.format === "virtual" ? "a virtual call" : "an in-home visit";
$("#conf-line").textContent = svc.name + " · " + fmtDate(state.date) + " at " + fmtTime(state.time) + " — " + fmt + ".";
$("#conf-ref").textContent = ref;
$("#summary").hidden = true;
$("#confirmed").hidden = false;
setStep(3, true, false);
toast("Booked! Reference " + ref);
}
function resetAll() {
state.type = null; state.format = "inperson"; state.date = null; state.time = null;
var checked = document.querySelector('input[name="ctype"]:checked');
if (checked) checked.checked = false;
$all(".type-card").forEach(function (c) { c.classList.remove("is-selected"); });
$all(".seg").forEach(function (s, i) {
var on = i === 0;
s.classList.toggle("is-on", on);
s.setAttribute("aria-pressed", on ? "true" : "false");
});
var f = $("#details-form"); f.reset();
$all(".field").forEach(function (fl) { fl.classList.remove("invalid"); });
$all(".err").forEach(function (e) { e.textContent = ""; });
updateLocationField();
renderSlots();
renderCalendar();
updateSummary();
updateSteps();
$("#confirmed").hidden = true;
$("#summary").hidden = false;
toast("Started a new booking");
}
/* ---------- init ---------- */
bindTypes();
bindFormat();
bindForm();
renderCalendar();
updateLocationField();
updateSummary();
updateSteps();
$("#prev-month").addEventListener("click", function () {
var view = new Date(state.viewYear, state.viewMonth, 1);
var min = new Date(today.getFullYear(), today.getMonth(), 1);
if (view <= min) { toast("That's as far back as we go"); return; }
state.viewMonth--; if (state.viewMonth < 0) { state.viewMonth = 11; state.viewYear--; }
renderCalendar();
});
$("#next-month").addEventListener("click", function () {
state.viewMonth++; if (state.viewMonth > 11) { state.viewMonth = 0; state.viewYear++; }
renderCalendar();
});
$("#confirm-btn").addEventListener("click", confirmBooking);
$("#reset-btn").addEventListener("click", resetAll);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Book a Consultation — Marrow & Loom Interiors</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@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="masthead">
<div class="brand">
<span class="brand-mark" aria-hidden="true">M&L</span>
<div class="brand-text">
<p class="brand-name">Marrow & Loom</p>
<p class="brand-sub">Interiors Studio · est. 2014</p>
</div>
</div>
<nav class="steps" aria-label="Booking progress">
<ol>
<li class="step" data-step="1"><span class="dot">1</span>Service</li>
<li class="step" data-step="2"><span class="dot">2</span>Date & time</li>
<li class="step" data-step="3"><span class="dot">3</span>Details</li>
</ol>
</nav>
</header>
<main class="layout">
<section class="flow" aria-label="Consultation booking">
<div class="intro">
<p class="eyebrow">Book a consultation</p>
<h1>Let's shape the rooms you live in.</h1>
<p class="lede">A short conversation with one of our designers to understand your space, budget and taste — the first thread of the whole project.</p>
</div>
<!-- STEP 1 — service type -->
<fieldset class="block" id="block-type">
<legend><span class="num">01</span> Choose a consultation</legend>
<div class="type-grid" role="radiogroup" aria-label="Consultation type">
<label class="type-card" data-type="discovery">
<input type="radio" name="ctype" value="discovery" />
<span class="type-head">
<span class="type-name">Discovery Call</span>
<span class="type-price">Free</span>
</span>
<span class="type-desc">A relaxed 30-minute chat to map your goals, timeline and rough budget.</span>
<span class="type-meta"><span class="chip">30 min</span><span class="chip chip-soft">Best for starting out</span></span>
</label>
<label class="type-card" data-type="styling">
<input type="radio" name="ctype" value="styling" />
<span class="type-head">
<span class="type-name">In-Home Styling</span>
<span class="type-price">$180</span>
</span>
<span class="type-desc">A designer walks one room with you and leaves a styling plan for layout, light and texture.</span>
<span class="type-meta"><span class="chip">60 min</span><span class="chip chip-soft">Single room</span></span>
</label>
<label class="type-card" data-type="full">
<input type="radio" name="ctype" value="full" />
<span class="type-head">
<span class="type-name">Full Room Design</span>
<span class="type-price">$420</span>
</span>
<span class="type-desc">A deep session with mood direction, sourcing strategy and a scoped path to a finished room.</span>
<span class="type-meta"><span class="chip">90 min</span><span class="chip chip-soft">End-to-end</span></span>
</label>
</div>
<div class="format" role="group" aria-label="Consultation format">
<span class="format-label">How would you like to meet?</span>
<div class="segmented" id="format-toggle">
<button type="button" class="seg is-on" data-format="inperson" aria-pressed="true">
<span class="seg-ico" aria-hidden="true">◉</span> In-person
</button>
<button type="button" class="seg" data-format="virtual" aria-pressed="false">
<span class="seg-ico" aria-hidden="true">▷</span> Virtual
</button>
</div>
</div>
</fieldset>
<!-- STEP 2 — date & time -->
<fieldset class="block" id="block-when">
<legend><span class="num">02</span> Pick a date & time</legend>
<div class="scheduler">
<div class="calendar" aria-label="Choose a date">
<div class="cal-head">
<button type="button" class="cal-nav" id="prev-month" aria-label="Previous month">‹</button>
<p class="cal-title" id="cal-title" aria-live="polite">Month</p>
<button type="button" class="cal-nav" id="next-month" aria-label="Next month">›</button>
</div>
<div class="cal-dow" aria-hidden="true">
<span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span>
</div>
<div class="cal-grid" id="cal-grid" role="grid"></div>
<p class="cal-legend"><span class="dotmark"></span> Available days shown in walnut</p>
</div>
<div class="slots" aria-label="Available times">
<p class="slots-title" id="slots-title">Select a date to see times</p>
<div class="slot-list" id="slot-list">
<p class="slots-empty">No date selected yet.</p>
</div>
</div>
</div>
</fieldset>
<!-- STEP 3 — details -->
<fieldset class="block" id="block-details">
<legend><span class="num">03</span> Your details</legend>
<form class="details" id="details-form" novalidate>
<div class="field">
<label for="f-name">Full name</label>
<input id="f-name" name="name" type="text" autocomplete="name" placeholder="e.g. Rowan Alvarez" required />
<span class="err" data-for="name"></span>
</div>
<div class="field">
<label for="f-email">Email</label>
<input id="f-email" name="email" type="email" autocomplete="email" placeholder="[email protected]" required />
<span class="err" data-for="email"></span>
</div>
<div class="field" id="location-field">
<label for="f-location" id="location-label">Project address</label>
<input id="f-location" name="location" type="text" autocomplete="street-address" placeholder="Street, suburb, postcode" />
<span class="hint" id="location-hint">Where should the designer visit?</span>
</div>
<div class="field field-wide">
<label for="f-scope">Tell us about the space</label>
<textarea id="f-scope" name="scope" rows="3" placeholder="Rooms, current pain points, the mood you're chasing…" required></textarea>
<span class="err" data-for="scope"></span>
</div>
<div class="field field-wide check">
<label class="checkbox">
<input type="checkbox" id="f-consent" />
<span>Email me a calendar invite and a short prep questionnaire.</span>
</label>
</div>
</form>
</fieldset>
</section>
<!-- SUMMARY RAIL -->
<aside class="rail" aria-label="Booking summary">
<div class="summary" id="summary">
<p class="rail-eyebrow">Your consultation</p>
<h2 class="rail-service" id="s-service">No service selected</h2>
<p class="rail-desc" id="s-desc">Pick a consultation type to begin.</p>
<dl class="rail-lines">
<div class="line"><dt>Format</dt><dd id="s-format">—</dd></div>
<div class="line"><dt>Date</dt><dd id="s-date">—</dd></div>
<div class="line"><dt>Time</dt><dd id="s-time">—</dd></div>
<div class="line"><dt>Duration</dt><dd id="s-duration">—</dd></div>
</dl>
<div class="rail-total">
<span>Total</span>
<strong id="s-price">$0</strong>
</div>
<button type="button" class="confirm" id="confirm-btn" disabled>Confirm booking</button>
<p class="rail-note">No payment taken now — you'll settle after the session.</p>
</div>
<div class="confirmed" id="confirmed" hidden>
<div class="tick" aria-hidden="true">✓</div>
<h2>You're booked in.</h2>
<p class="conf-line" id="conf-line">We've reserved your spot.</p>
<p class="conf-ref">Reference <strong id="conf-ref">—</strong></p>
<button type="button" class="ghost" id="reset-btn">Book another</button>
</div>
</aside>
</main>
</div>
<div class="toast-wrap" id="toast-wrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>A four-step booking page for a fictional interior studio, Marrow & Loom. The left column carries the flow — consultation type cards (Discovery Call, In-Home Styling, Full Room Design), an in-person / virtual segmented toggle, a month calendar with disabled and available days, a scrollable list of time slots, and a details form. The right rail holds a sticky summary that updates live with the chosen service, format, date, time, duration and price, plus a confirm button that stays disabled until every required piece is in place.
Interactions are pure vanilla JS: selecting a type recolours its card and refreshes the summary and slot durations; the format toggle swaps the location field between an address and a video-link note; navigating months regenerates the calendar grid; picking a day reveals its available slots with capacity badges. The form validates name, email and project scope inline, and confirming animates the summary into a tidy confirmation card with a booking reference. A small toast() helper acknowledges each meaningful action.
The layout is built for calm: generous whitespace, thin rules, a muted natural palette and Cormorant Garamond headings over Inter body text. It collapses to a single column under 520px, keeps focus states visible, and labels controls with ARIA so the whole flow stays keyboard-usable.