Dental — Appointment Booking
A trust-first, four-step dental booking flow: pick a treatment, choose a dentist, land on a clean calendar with live slot-availability states, enter patient details, and reach a reassuring confirmation screen — all wrapped in a soft clinical-light theme with a progress tracker, running summary, and smooth micro-interactions.
MCP
Code
:root {
--blue: #2563eb;
--blue-d: #1d4ed8;
--blue-50: #eff5ff;
--mint: #34d399;
--mint-50: #e8fbf3;
--ink: #0f2740;
--ink-2: #3a5169;
--muted: #6b7c90;
--bg: #f3f8ff;
--white: #fff;
--line: rgba(15, 39, 64, 0.10);
--line-2: rgba(15, 39, 64, 0.18);
--ok: #22b07d;
--warn: #d98a2b;
--danger: #e05252;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--sh-sm: 0 1px 2px rgba(15, 39, 64, 0.06), 0 1px 3px rgba(15, 39, 64, 0.05);
--sh-md: 0 6px 18px rgba(15, 39, 64, 0.08), 0 2px 6px rgba(15, 39, 64, 0.05);
--sh-lg: 0 18px 48px rgba(29, 78, 216, 0.14);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1200px 600px at 85% -8%, #dcecff 0%, transparent 55%),
radial-gradient(900px 500px at -5% 5%, #e8fbf3 0%, transparent 50%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.page { max-width: 1120px; margin: 0 auto; padding: 22px 20px 64px; }
/* Topbar */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap; margin-bottom: 26px;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 42px; height: 42px; border-radius: 13px;
display: grid; place-items: center;
background: linear-gradient(150deg, var(--blue), var(--blue-d));
color: #fff; box-shadow: var(--sh-md);
}
.brand-txt { display: flex; flex-direction: column; line-height: 1.25; }
.brand-txt strong { font-size: 16px; font-weight: 800; letter-spacing: -.2px; }
.brand-txt span { font-size: 12.5px; color: var(--muted); }
.topbar-meta { display: flex; align-items: center; gap: 14px; }
.pill {
display: inline-flex; align-items: center; gap: 7px;
font-size: 12.5px; font-weight: 600; padding: 6px 12px;
border-radius: 999px; background: var(--white); box-shadow: var(--sh-sm);
border: 1px solid var(--line);
}
.pill-ok { color: var(--ok); }
.pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--mint); box-shadow: 0 0 0 3px rgba(52,211,153,.22); }
.phone { font-size: 14px; font-weight: 700; color: var(--blue-d); text-decoration: none; }
.phone:hover { text-decoration: underline; }
/* Shell layout */
.shell { display: grid; grid-template-columns: 1fr 320px; gap: 22px; align-items: start; }
.main {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
padding: 26px 26px 22px;
}
.head h1 { margin: 0 0 4px; font-size: 24px; font-weight: 800; letter-spacing: -.5px; }
.head p { margin: 0; color: var(--muted); font-size: 14px; }
/* Steps */
.steps {
list-style: none; display: flex; gap: 8px; margin: 22px 0 24px; padding: 0;
}
.step {
flex: 1; display: flex; align-items: center; gap: 9px;
padding: 10px 12px; border-radius: var(--r-md);
background: var(--blue-50); border: 1px solid transparent;
transition: background .2s, border-color .2s, box-shadow .2s;
min-width: 0;
}
.step .s-num {
width: 24px; height: 24px; flex: 0 0 24px; border-radius: 50%;
display: grid; place-items: center; font-size: 12.5px; font-weight: 700;
background: var(--white); color: var(--muted); border: 1.5px solid var(--line-2);
transition: all .2s;
}
.step .s-lbl { font-size: 13px; font-weight: 600; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.step.is-active { background: #fff; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.12); }
.step.is-active .s-num { background: var(--blue); color: #fff; border-color: var(--blue); }
.step.is-done .s-num { background: var(--mint); color: #063d2b; border-color: var(--mint); }
.step.is-done .s-lbl { color: var(--ink); }
/* Panels */
.panel { animation: fade .28s ease; }
.panel[hidden] { display: none; }
.panel-title { margin: 0 0 16px; font-size: 17px; font-weight: 700; letter-spacing: -.2px; }
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* Cards (treatment + dentist) */
.cards { display: grid; gap: 12px; }
.treat-grid { grid-template-columns: repeat(2, 1fr); }
.dentist-grid { grid-template-columns: repeat(2, 1fr); }
.card {
position: relative; display: flex; align-items: center; gap: 12px;
padding: 14px; border-radius: var(--r-md); background: var(--white);
border: 1.5px solid var(--line); cursor: pointer;
transition: border-color .18s, box-shadow .18s, transform .12s, background .18s;
}
.card input { position: absolute; opacity: 0; pointer-events: none; }
.card:hover { border-color: var(--line-2); box-shadow: var(--sh-sm); transform: translateY(-1px); }
.card:focus-visible { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.18); }
.card.is-selected { border-color: var(--blue); background: linear-gradient(180deg, #fff, var(--blue-50)); box-shadow: 0 0 0 3px rgba(37,99,235,.12); }
.card.is-selected::after {
content: "✓"; position: absolute; top: 10px; right: 12px;
width: 20px; height: 20px; border-radius: 50%; background: var(--blue); color: #fff;
font-size: 12px; font-weight: 800; display: grid; place-items: center;
}
.card-ico {
width: 42px; height: 42px; flex: 0 0 42px; border-radius: 12px;
display: grid; place-items: center; font-size: 20px;
background: var(--blue-50);
}
.card-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.card-t { font-weight: 700; font-size: 14.5px; letter-spacing: -.1px; }
.card-d { font-size: 12.5px; color: var(--muted); }
.card-meta { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; text-align: right; }
.chip {
font-size: 11px; font-weight: 600; color: var(--ink-2);
background: var(--blue-50); padding: 3px 8px; border-radius: 999px;
}
.price { font-size: 12.5px; font-weight: 700; color: var(--blue-d); }
/* Dentist specifics */
.avatar {
width: 46px; height: 46px; flex: 0 0 46px; border-radius: 50%;
display: grid; place-items: center; font-weight: 800; font-size: 14px;
background: var(--a, var(--blue-50)); color: var(--b, var(--blue-d));
}
.rating { font-size: 12px; color: var(--muted); margin-top: 2px; }
.stars { color: var(--warn); letter-spacing: 1px; }
.stars.muted { color: var(--line-2); }
.avail {
margin-left: auto; align-self: flex-start; font-size: 11.5px; font-weight: 600;
color: var(--ink-2); background: #f1f5fb; padding: 5px 10px; border-radius: 999px; white-space: nowrap;
}
.avail-soon { color: var(--ok); background: var(--mint-50); }
/* Calendar */
.cal { border: 1px solid var(--line); border-radius: var(--r-md); padding: 12px; margin-bottom: 14px; }
.cal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.cal-month { font-weight: 700; font-size: 14.5px; }
.cal-nav {
width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--line);
background: var(--white); color: var(--ink-2); font-size: 18px; cursor: pointer;
transition: background .15s, border-color .15s;
}
.cal-nav:hover { background: var(--blue-50); border-color: var(--blue); color: var(--blue-d); }
.cal-nav:disabled { opacity: .4; cursor: not-allowed; }
.week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 7px; }
.day {
display: flex; flex-direction: column; align-items: center; gap: 2px;
padding: 9px 4px; border-radius: var(--r-sm); border: 1.5px solid var(--line);
background: var(--white); cursor: pointer; font-family: inherit;
transition: all .15s;
}
.day .dow { font-size: 11px; color: var(--muted); font-weight: 600; }
.day .dnum { font-size: 16px; font-weight: 700; }
.day:hover:not(:disabled) { border-color: var(--blue); background: var(--blue-50); }
.day:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37,99,235,.2); border-color: var(--blue); }
.day.is-selected { background: var(--blue); border-color: var(--blue); color: #fff; }
.day.is-selected .dow { color: rgba(255,255,255,.8); }
.day:disabled { opacity: .4; cursor: not-allowed; }
.day.is-full .dnum { text-decoration: line-through; }
/* Legend */
.legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: var(--muted); margin-bottom: 14px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.lg { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
.lg-open { background: var(--mint); }
.lg-few { background: var(--warn); }
.lg-booked { background: var(--line-2); }
/* Slots */
.slots-wrap { display: grid; gap: 16px; }
.slot-group h3 { margin: 0 0 8px; font-size: 13px; font-weight: 700; color: var(--ink-2); text-transform: uppercase; letter-spacing: .4px; }
.slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; }
.slot {
position: relative; font-family: inherit; cursor: pointer;
padding: 10px 8px 9px; border-radius: var(--r-sm); border: 1.5px solid var(--line);
background: var(--white); font-size: 13.5px; font-weight: 600; color: var(--ink);
display: flex; flex-direction: column; align-items: center; gap: 3px;
transition: all .15s;
}
.slot .tag { font-size: 10px; font-weight: 700; letter-spacing: .2px; }
.slot.open { }
.slot.open .tag { color: var(--ok); }
.slot.few { border-color: rgba(217,138,43,.4); }
.slot.few .tag { color: var(--warn); }
.slot.booked { background: #f5f7fa; color: var(--muted); cursor: not-allowed; }
.slot.booked .tag { color: var(--muted); }
.slot:not(.booked):hover { border-color: var(--blue); background: var(--blue-50); transform: translateY(-1px); box-shadow: var(--sh-sm); }
.slot:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37,99,235,.2); border-color: var(--blue); }
.slot.is-selected { background: var(--blue); border-color: var(--blue); color: #fff; box-shadow: var(--sh-md); }
.slot.is-selected .tag { color: rgba(255,255,255,.85); }
/* Fields */
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field-full { grid-column: 1 / -1; }
.field label { font-size: 13px; font-weight: 600; color: var(--ink-2); }
.field .opt { color: var(--muted); font-weight: 500; }
.field input, .field textarea {
font-family: inherit; font-size: 14px; color: var(--ink);
padding: 11px 12px; border-radius: var(--r-sm); border: 1.5px solid var(--line);
background: var(--white); transition: border-color .15s, box-shadow .15s;
}
.field textarea { resize: vertical; }
.field input::placeholder, .field textarea::placeholder { color: #9aa8b8; }
.field input:focus, .field textarea:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.14); }
.field.has-error input { border-color: var(--danger); box-shadow: 0 0 0 3px rgba(224,82,82,.12); }
.err { font-size: 12px; color: var(--danger); min-height: 0; font-weight: 500; }
.checkline { display: flex; align-items: center; gap: 9px; font-size: 13.5px; color: var(--ink-2); cursor: pointer; }
.checkline input { width: 17px; height: 17px; accent-color: var(--blue); }
/* Confirmation */
.confirm { text-align: center; padding: 12px 0 6px; }
.confirm-ic {
width: 68px; height: 68px; margin: 6px auto 14px; border-radius: 50%;
display: grid; place-items: center; color: #fff;
background: linear-gradient(150deg, var(--mint), var(--ok));
box-shadow: 0 12px 28px rgba(34,176,125,.32);
animation: pop .4s cubic-bezier(.2,.9,.3,1.4);
}
@keyframes pop { from { transform: scale(.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.confirm h2 { margin: 0 0 4px; font-size: 22px; font-weight: 800; }
.confirm-sub { margin: 0 auto 18px; color: var(--muted); font-size: 14px; max-width: 380px; }
.ticket {
text-align: left; max-width: 400px; margin: 0 auto 20px;
border: 1px solid var(--line); border-radius: var(--r-md);
background: var(--blue-50); overflow: hidden;
}
.ticket-row {
display: flex; justify-content: space-between; gap: 16px;
padding: 12px 16px; border-bottom: 1px dashed var(--line-2); font-size: 13.5px;
}
.ticket-row:last-child { border-bottom: none; }
.ticket-row span { color: var(--muted); font-weight: 600; }
.ticket-row strong { color: var(--ink); text-align: right; }
/* Nav */
.nav { display: flex; justify-content: space-between; gap: 12px; margin-top: 26px; padding-top: 18px; border-top: 1px solid var(--line); }
.btn {
font-family: inherit; font-size: 14px; font-weight: 700; cursor: pointer;
padding: 12px 22px; border-radius: var(--r-md); border: 1.5px solid transparent;
transition: all .16s; letter-spacing: -.1px;
}
.btn-primary { background: linear-gradient(150deg, var(--blue), var(--blue-d)); color: #fff; box-shadow: var(--sh-md); }
.btn-primary:hover { transform: translateY(-1px); box-shadow: var(--sh-lg); }
.btn-primary:active { transform: translateY(0); }
.btn-ghost { background: var(--white); color: var(--ink-2); border-color: var(--line-2); }
.btn-ghost:hover:not(:disabled) { background: var(--blue-50); border-color: var(--blue); color: var(--blue-d); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(37,99,235,.28); }
/* Summary aside */
.summary {
position: sticky; top: 20px;
background: var(--white); border: 1px solid var(--line);
border-radius: var(--r-lg); box-shadow: var(--sh-md); padding: 20px;
}
.summary h3 { margin: 0 0 14px; font-size: 15px; font-weight: 800; letter-spacing: -.2px; }
.sum-list { list-style: none; margin: 0 0 16px; padding: 0; display: grid; gap: 12px; }
.sum-list li { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; align-items: baseline; }
.sum-k { color: var(--muted); font-weight: 600; flex: 0 0 auto; }
.sum-v { color: var(--ink); font-weight: 700; text-align: right; }
.sum-v.set { color: var(--blue-d); }
.sum-note {
display: flex; gap: 10px; align-items: flex-start;
background: var(--mint-50); border-radius: var(--r-md); padding: 12px 13px;
}
.sum-note p { margin: 0; font-size: 12px; color: var(--ink-2); line-height: 1.45; }
/* Toast */
.toast {
position: fixed; left: 50%; bottom: 26px; transform: translate(-50%, 20px);
background: var(--ink); color: #fff; font-size: 13.5px; font-weight: 600;
padding: 12px 18px; border-radius: 999px; box-shadow: var(--sh-lg);
opacity: 0; pointer-events: none; transition: opacity .25s, transform .25s; z-index: 60;
max-width: 90vw;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* Responsive */
@media (max-width: 900px) {
.shell { grid-template-columns: 1fr; }
.summary { position: static; order: -1; }
}
@media (max-width: 520px) {
.page { padding: 16px 14px 48px; }
.main { padding: 20px 16px 18px; }
.head h1 { font-size: 21px; }
.steps { gap: 6px; }
.step { padding: 9px 8px; justify-content: center; }
.step .s-lbl { display: none; }
.step.is-active .s-lbl { display: inline; }
.treat-grid, .dentist-grid, .fields { grid-template-columns: 1fr; }
.card-meta { flex-direction: row; align-items: center; gap: 8px; }
.week { gap: 5px; }
.day { padding: 8px 2px; }
.day .dnum { font-size: 14px; }
.slots { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); }
.nav { flex-direction: row; }
.btn { flex: 1; padding: 12px 14px; }
.topbar-meta { width: 100%; justify-content: space-between; }
}(function () {
"use strict";
var TOTAL_STEPS = 4;
var state = {
step: 1,
treatment: null, duration: null, price: null,
dentist: null,
day: null, // {label, iso}
slot: null, // "09:30"
period: null
};
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
/* ---------- Toast ---------- */
var toastEl = $("#toast");
var toastT;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastT);
toastT = setTimeout(function () { toastEl.classList.remove("show"); }, 2600);
}
/* ---------- Selectable cards (treatment + dentist) ---------- */
function wireCards(groupSel, onPick) {
$$(groupSel + " .card").forEach(function (card) {
var input = $("input", card);
function select() {
$$(groupSel + " .card").forEach(function (c) {
c.classList.remove("is-selected");
c.setAttribute("aria-checked", "false");
});
card.classList.add("is-selected");
card.setAttribute("aria-checked", "true");
input.checked = true;
onPick(input, card);
}
card.addEventListener("click", select);
card.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); select(); }
});
});
}
wireCards(".treat-grid", function (input) {
state.treatment = input.value;
state.duration = input.getAttribute("data-dur");
state.price = input.getAttribute("data-price");
syncSummary();
autoAdvanceHint();
});
wireCards(".dentist-grid", function (input) {
state.dentist = input.value;
syncSummary();
autoAdvanceHint();
});
function autoAdvanceHint() { validateNav(); }
/* ---------- Calendar ---------- */
var DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
var MONTHS = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
// Fixed reference "today" for the demo so availability is deterministic.
var baseDate = new Date(2026, 6, 1); // 1 Jul 2026
var weekOffset = 0;
// Deterministic pseudo-availability per date -> 'open' | 'few' | 'full'
function dayAvailability(d) {
var dow = d.getDay();
if (dow === 0) return "full"; // Sunday closed
var seed = (d.getDate() * 7 + d.getMonth() * 13 + dow * 3) % 10;
if (seed < 2) return "full";
if (seed < 5) return "few";
return "open";
}
function slotState(d, hourKey) {
var seed = (d.getDate() * 3 + hourKey * 5 + d.getDay()) % 7;
if (seed === 0 || seed === 1) return "booked";
if (seed === 2) return "few";
return "open";
}
var weekEl = $("#week");
var calMonth = $("#calMonth");
var calPrev = $("#calPrev");
var calNext = $("#calNext");
function startOfWeek(offset) {
var d = new Date(baseDate);
d.setDate(d.getDate() + offset * 7);
return d;
}
function renderWeek() {
weekEl.innerHTML = "";
var start = startOfWeek(weekOffset);
calMonth.textContent = MONTHS[start.getMonth()] + " " + start.getFullYear();
calPrev.disabled = weekOffset <= 0;
for (var i = 0; i < 7; i++) {
(function (i) {
var d = new Date(start);
d.setDate(start.getDate() + i);
var avail = dayAvailability(d);
var full = avail === "full";
var iso = d.getFullYear() + "-" + (d.getMonth() + 1) + "-" + d.getDate();
var btn = document.createElement("button");
btn.type = "button";
btn.className = "day" + (full ? " is-full" : "");
btn.disabled = full;
btn.setAttribute("role", "option");
btn.setAttribute("aria-label", DOW[d.getDay()] + " " + d.getDate() + " " +
MONTHS[d.getMonth()] + (full ? ", fully booked" : ", available"));
btn.innerHTML = '<span class="dow">' + DOW[d.getDay()] + '</span>' +
'<span class="dnum">' + d.getDate() + '</span>';
if (state.day && state.day.iso === iso) {
btn.classList.add("is-selected");
btn.setAttribute("aria-selected", "true");
}
btn.addEventListener("click", function () {
$$(".day", weekEl).forEach(function (b) {
b.classList.remove("is-selected");
b.setAttribute("aria-selected", "false");
});
btn.classList.add("is-selected");
btn.setAttribute("aria-selected", "true");
var label = DOW[d.getDay()] + " " + d.getDate() + " " + MONTHS[d.getMonth()].slice(0, 3);
state.day = { label: label, iso: iso, dateObj: new Date(d) };
state.slot = null;
state.period = null;
renderSlots(d);
syncSummary();
validateNav();
});
weekEl.appendChild(btn);
})(i);
}
}
var SLOT_TIMES = {
Morning: [["08:30", 8], ["09:15", 9], ["10:00", 10], ["10:45", 10], ["11:30", 11]],
Afternoon: [["13:00", 13], ["13:45", 13], ["14:30", 14], ["15:15", 15], ["16:00", 16]],
Evening: [["17:00", 17], ["17:45", 17], ["18:30", 18]]
};
var TAGS = { open: "Available", few: "Few left", booked: "Booked" };
function renderSlots(d) {
["Morning", "Afternoon", "Evening"].forEach(function (period) {
var wrap = $("#slots" + period);
wrap.innerHTML = "";
SLOT_TIMES[period].forEach(function (t) {
var time = t[0], hourKey = t[1];
var st = slotState(d, hourKey);
var btn = document.createElement("button");
btn.type = "button";
btn.className = "slot " + st;
btn.disabled = st === "booked";
btn.setAttribute("aria-label", time + ", " + TAGS[st]);
btn.innerHTML = '<span class="time">' + time + '</span>' +
'<span class="tag">' + TAGS[st] + '</span>';
if (st !== "booked") {
btn.addEventListener("click", function () {
$$(".slot").forEach(function (s) { s.classList.remove("is-selected"); s.setAttribute("aria-pressed", "false"); });
btn.classList.add("is-selected");
btn.setAttribute("aria-pressed", "true");
state.slot = time;
state.period = period;
syncSummary();
validateNav();
if (st === "few") toast("Nice — that's one of the last slots that day.");
});
}
wrap.appendChild(btn);
});
});
}
calPrev.addEventListener("click", function () { if (weekOffset > 0) { weekOffset--; renderWeek(); } });
calNext.addEventListener("click", function () { weekOffset++; renderWeek(); });
/* ---------- Summary ---------- */
function setV(id, val) {
var el = $("#" + id);
if (val) { el.textContent = val; el.classList.add("set"); }
else { el.classList.remove("set"); }
}
function syncSummary() {
setV("sTreat", state.treatment); if (!state.treatment) $("#sTreat").textContent = "Not chosen";
$("#sDur").textContent = state.duration || "—";
$("#sPrice").textContent = state.price || "—";
setV("sDentist", state.dentist); if (!state.dentist) $("#sDentist").textContent = "Not chosen";
if (state.day && state.slot) {
setV("sWhen", state.day.label + " · " + state.slot);
} else {
$("#sWhen").textContent = "Not chosen"; $("#sWhen").classList.remove("set");
}
}
/* ---------- Step navigation ---------- */
var backBtn = $("#backBtn");
var nextBtn = $("#nextBtn");
function showStep(n) {
state.step = n;
$$(".panel").forEach(function (p) {
p.hidden = parseInt(p.getAttribute("data-panel"), 10) !== n;
});
$$(".step").forEach(function (s) {
var sn = parseInt(s.getAttribute("data-step"), 10);
s.classList.toggle("is-active", sn === n);
s.classList.toggle("is-done", sn < n);
});
backBtn.disabled = n <= 1;
nextBtn.textContent = n === TOTAL_STEPS ? "Confirm booking" : "Continue";
$("#nav").style.display = n > TOTAL_STEPS ? "none" : "flex";
validateNav();
// scroll panel into view on small screens
if (window.innerWidth < 900) window.scrollTo({ top: 0, behavior: "smooth" });
}
function validateNav() {
var ok = true;
if (state.step === 1) ok = !!state.treatment;
if (state.step === 2) ok = !!state.dentist;
if (state.step === 3) ok = !!(state.day && state.slot);
nextBtn.disabled = !ok;
}
backBtn.addEventListener("click", function () {
if (state.step > 1) showStep(state.step - 1);
});
nextBtn.addEventListener("click", function () {
if (state.step === 1 && !state.treatment) return toast("Please choose a treatment.");
if (state.step === 2 && !state.dentist) return toast("Please pick a dentist.");
if (state.step === 3 && !(state.day && state.slot)) return toast("Please choose a date and time.");
if (state.step === 4) { submitBooking(); return; }
showStep(state.step + 1);
});
/* ---------- Step 4 validation + submit ---------- */
function setFieldError(name, msg) {
var input = $('[name="' + name + '"]');
var field = input.closest(".field");
var err = $('.err[data-for="' + name + '"]');
if (msg) { field.classList.add("has-error"); err.textContent = msg; }
else { field.classList.remove("has-error"); err.textContent = ""; }
}
["name", "phone", "email"].forEach(function (name) {
var input = $('[name="' + name + '"]');
input.addEventListener("input", function () { setFieldError(name, ""); });
});
function validateDetails() {
var name = $('[name="name"]').value.trim();
var phone = $('[name="phone"]').value.trim();
var email = $('[name="email"]').value.trim();
var ok = true;
if (name.length < 2) { setFieldError("name", "Please enter your full name."); ok = false; }
var digits = phone.replace(/[^0-9]/g, "");
if (digits.length < 7) { setFieldError("phone", "Enter a valid contact number."); ok = false; }
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setFieldError("email", "Enter a valid email address."); ok = false; }
return ok ? { name: name, phone: phone, email: email } : null;
}
function makeRef() {
var s = "BSD-";
for (var i = 0; i < 6; i++) s += "ABCDEFGHJKMNPQRSTUVWXYZ23456789".charAt(Math.floor(Math.random() * 30));
return s;
}
function submitBooking() {
var details = validateDetails();
if (!details) { toast("Please fix the highlighted fields."); return; }
$("#cRef").textContent = makeRef();
$("#cTreat").textContent = state.treatment;
$("#cDentist").textContent = state.dentist;
$("#cWhen").textContent = state.day.label + " · " + state.slot + " (" + state.period + ")";
$("#cName").textContent = details.name;
$("#cEmail").textContent = details.email;
// show confirmation panel
$$(".panel").forEach(function (p) { p.hidden = parseInt(p.getAttribute("data-panel"), 10) !== 5; });
$$(".step").forEach(function (s) { s.classList.remove("is-active"); s.classList.add("is-done"); });
$("#nav").style.display = "none";
window.scrollTo({ top: 0, behavior: "smooth" });
toast("Appointment confirmed — see you soon!");
}
$("#againBtn").addEventListener("click", function () {
// reset
state = { step: 1, treatment: null, duration: null, price: null, dentist: null, day: null, slot: null, period: null };
$("#bookingForm").reset();
$$(".card").forEach(function (c) { c.classList.remove("is-selected"); c.setAttribute("aria-checked", "false"); });
["name", "phone", "email"].forEach(function (n) { setFieldError(n, ""); });
$("#slotsMorning").innerHTML = "";
$("#slotsAfternoon").innerHTML = "";
$("#slotsEvening").innerHTML = "";
weekOffset = 0;
renderWeek();
syncSummary();
showStep(1);
});
/* ---------- Init ---------- */
renderWeek();
syncSummary();
showStep(1);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Brightsmile Dental — Book an Appointment</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&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5.5c-1.5-1.5-4-2-6-1-2.2 1.1-2.6 4-1.6 7 .6 1.8.7 4 1.4 5.6.5 1.2 1.8 1 2.2-.2.4-1.3.7-3 1.6-3s1.2 1.7 1.6 3c.4 1.2 1.7 1.4 2.2.2.7-1.6.8-3.8 1.4-5.6 1-3 .6-5.9-1.6-7-2-1-4.5-.5-6 1z"/></svg>
</span>
<div class="brand-txt">
<strong>Brightsmile Dental</strong>
<span>Fenwick Park Clinic</span>
</div>
</div>
<div class="topbar-meta">
<span class="pill pill-ok"><i class="dot"></i> Accepting new patients</span>
<a class="phone" href="#">(0)20 7946 0321</a>
</div>
</header>
<div class="shell">
<main class="main">
<div class="head">
<h1>Book your appointment</h1>
<p>Four quick steps. No account needed — takes about a minute.</p>
</div>
<!-- Progress -->
<ol class="steps" id="steps" aria-label="Booking progress">
<li class="step is-active" data-step="1"><span class="s-num">1</span><span class="s-lbl">Treatment</span></li>
<li class="step" data-step="2"><span class="s-num">2</span><span class="s-lbl">Dentist</span></li>
<li class="step" data-step="3"><span class="s-num">3</span><span class="s-lbl">Date & time</span></li>
<li class="step" data-step="4"><span class="s-num">4</span><span class="s-lbl">Your details</span></li>
</ol>
<form id="bookingForm" novalidate>
<!-- STEP 1: Treatment -->
<section class="panel is-active" data-panel="1" aria-label="Choose a treatment">
<h2 class="panel-title">What do you need?</h2>
<div class="cards treat-grid" role="radiogroup" aria-label="Treatment">
<label class="card treat" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="treatment" value="Check-up & clean" data-dur="30 min" data-price="£45–£70" />
<span class="card-ico">🦷</span>
<span class="card-body">
<span class="card-t">Check-up & clean</span>
<span class="card-d">Exam, scale & polish</span>
</span>
<span class="card-meta"><span class="chip">30 min</span><span class="price">£45–£70</span></span>
</label>
<label class="card treat" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="treatment" value="Teeth whitening" data-dur="60 min" data-price="£220–£320" />
<span class="card-ico">✨</span>
<span class="card-body">
<span class="card-t">Teeth whitening</span>
<span class="card-d">In-chair professional</span>
</span>
<span class="card-meta"><span class="chip">60 min</span><span class="price">£220–£320</span></span>
</label>
<label class="card treat" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="treatment" value="Filling" data-dur="45 min" data-price="£95–£180" />
<span class="card-ico">🩹</span>
<span class="card-body">
<span class="card-t">Filling</span>
<span class="card-d">Composite restoration</span>
</span>
<span class="card-meta"><span class="chip">45 min</span><span class="price">£95–£180</span></span>
</label>
<label class="card treat" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="treatment" value="Emergency visit" data-dur="30 min" data-price="£80" />
<span class="card-ico">⚡</span>
<span class="card-body">
<span class="card-t">Emergency visit</span>
<span class="card-d">Pain or urgent issue</span>
</span>
<span class="card-meta"><span class="chip">30 min</span><span class="price">£80</span></span>
</label>
<label class="card treat" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="treatment" value="Orthodontic consult" data-dur="45 min" data-price="Free" />
<span class="card-ico">📐</span>
<span class="card-body">
<span class="card-t">Orthodontic consult</span>
<span class="card-d">Aligners & braces plan</span>
</span>
<span class="card-meta"><span class="chip">45 min</span><span class="price">Free</span></span>
</label>
<label class="card treat" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="treatment" value="Implant assessment" data-dur="45 min" data-price="£120" />
<span class="card-ico">🔩</span>
<span class="card-body">
<span class="card-t">Implant assessment</span>
<span class="card-d">Scan & treatment map</span>
</span>
<span class="card-meta"><span class="chip">45 min</span><span class="price">£120</span></span>
</label>
</div>
</section>
<!-- STEP 2: Dentist -->
<section class="panel" data-panel="2" aria-label="Choose a dentist" hidden>
<h2 class="panel-title">Pick your dentist</h2>
<div class="cards dentist-grid" role="radiogroup" aria-label="Dentist">
<label class="card dentist" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="dentist" value="Dr. Amara Okafor" data-next="Tomorrow" />
<span class="avatar" style="--a:#dbeafe;--b:#2563eb">AO</span>
<span class="card-body">
<span class="card-t">Dr. Amara Okafor</span>
<span class="card-d">General & cosmetic</span>
<span class="rating"><span class="stars">★★★★★</span> 4.9 · 312 reviews</span>
</span>
<span class="avail avail-soon">Next: Tomorrow</span>
</label>
<label class="card dentist" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="dentist" value="Dr. Leo Marchetti" data-next="Thu 10 Jul" />
<span class="avatar" style="--a:#dcfce7;--b:#16a34a">LM</span>
<span class="card-body">
<span class="card-t">Dr. Leo Marchetti</span>
<span class="card-d">Implants & surgery</span>
<span class="rating"><span class="stars">★★★★★</span> 4.8 · 198 reviews</span>
</span>
<span class="avail">Next: Thu 10 Jul</span>
</label>
<label class="card dentist" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="dentist" value="Dr. Priya Nair" data-next="Today" />
<span class="avatar" style="--a:#fce7f3;--b:#db2777">PN</span>
<span class="card-body">
<span class="card-t">Dr. Priya Nair</span>
<span class="card-d">Orthodontics</span>
<span class="rating"><span class="stars">★★★★★</span> 5.0 · 421 reviews</span>
</span>
<span class="avail avail-soon">Next: Today</span>
</label>
<label class="card dentist" tabindex="0" role="radio" aria-checked="false">
<input type="radio" name="dentist" value="No preference" data-next="Earliest" />
<span class="avatar" style="--a:#eff5ff;--b:#3a5169">Any</span>
<span class="card-body">
<span class="card-t">No preference</span>
<span class="card-d">Whoever is free soonest</span>
<span class="rating"><span class="stars muted">★★★★★</span> Fastest availability</span>
</span>
<span class="avail avail-soon">Next: Today</span>
</label>
</div>
</section>
<!-- STEP 3: Date & Time -->
<section class="panel" data-panel="3" aria-label="Choose date and time" hidden>
<h2 class="panel-title">Choose a date & time</h2>
<div class="cal">
<div class="cal-head">
<button type="button" class="cal-nav" id="calPrev" aria-label="Previous week">‹</button>
<span class="cal-month" id="calMonth">July 2026</span>
<button type="button" class="cal-nav" id="calNext" aria-label="Next week">›</button>
</div>
<div class="week" id="week" role="listbox" aria-label="Available days"></div>
</div>
<div class="legend" aria-hidden="true">
<span><i class="lg lg-open"></i> Available</span>
<span><i class="lg lg-few"></i> Few left</span>
<span><i class="lg lg-booked"></i> Booked</span>
</div>
<div class="slots-wrap">
<div class="slot-group" data-group="Morning">
<h3>Morning</h3>
<div class="slots" id="slotsMorning"></div>
</div>
<div class="slot-group" data-group="Afternoon">
<h3>Afternoon</h3>
<div class="slots" id="slotsAfternoon"></div>
</div>
<div class="slot-group" data-group="Evening">
<h3>Evening</h3>
<div class="slots" id="slotsEvening"></div>
</div>
</div>
</section>
<!-- STEP 4: Details -->
<section class="panel" data-panel="4" aria-label="Your details" hidden>
<h2 class="panel-title">Your details</h2>
<div class="fields">
<div class="field">
<label for="fName">Full name</label>
<input id="fName" name="name" type="text" autocomplete="name" placeholder="e.g. Jordan Ellis" />
<span class="err" data-for="name"></span>
</div>
<div class="field">
<label for="fPhone">Mobile number</label>
<input id="fPhone" name="phone" type="tel" autocomplete="tel" placeholder="07xxx xxxxxx" />
<span class="err" data-for="phone"></span>
</div>
<div class="field">
<label for="fEmail">Email</label>
<input id="fEmail" name="email" type="email" autocomplete="email" placeholder="[email protected]" />
<span class="err" data-for="email"></span>
</div>
<div class="field field-full">
<label for="fNotes">Notes for the clinic <span class="opt">(optional)</span></label>
<textarea id="fNotes" name="notes" rows="3" placeholder="Anything we should know before your visit?"></textarea>
</div>
<label class="checkline field-full">
<input type="checkbox" id="fNew" name="newPatient" />
<span>I'm a new patient at this clinic</span>
</label>
</div>
</section>
<!-- CONFIRMATION -->
<section class="panel confirm" data-panel="5" aria-label="Booking confirmed" hidden>
<div class="confirm-ic" aria-hidden="true">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<h2>You're booked in!</h2>
<p class="confirm-sub">We've sent a confirmation to <strong id="cEmail">you</strong>. See you soon.</p>
<div class="ticket">
<div class="ticket-row"><span>Reference</span><strong id="cRef">—</strong></div>
<div class="ticket-row"><span>Treatment</span><strong id="cTreat">—</strong></div>
<div class="ticket-row"><span>Dentist</span><strong id="cDentist">—</strong></div>
<div class="ticket-row"><span>When</span><strong id="cWhen">—</strong></div>
<div class="ticket-row"><span>Patient</span><strong id="cName">—</strong></div>
</div>
<button type="button" class="btn btn-primary" id="againBtn">Book another appointment</button>
</section>
<!-- NAV -->
<div class="nav" id="nav">
<button type="button" class="btn btn-ghost" id="backBtn" disabled>Back</button>
<button type="button" class="btn btn-primary" id="nextBtn">Continue</button>
</div>
</form>
</main>
<!-- SUMMARY ASIDE -->
<aside class="summary" aria-label="Booking summary">
<h3>Your booking</h3>
<ul class="sum-list">
<li><span class="sum-k">Treatment</span><span class="sum-v" id="sTreat">Not chosen</span></li>
<li><span class="sum-k">Duration</span><span class="sum-v" id="sDur">—</span></li>
<li><span class="sum-k">Est. price</span><span class="sum-v" id="sPrice">—</span></li>
<li><span class="sum-k">Dentist</span><span class="sum-v" id="sDentist">Not chosen</span></li>
<li><span class="sum-k">Date & time</span><span class="sum-v" id="sWhen">Not chosen</span></li>
</ul>
<div class="sum-note">
<span aria-hidden="true">🛡️</span>
<p>Free cancellation up to 24h before. No card needed to hold your slot.</p>
</div>
</aside>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>A complete self-contained appointment-booking flow for a fictional dental clinic, built to feel calm, clinical, and reassuring. A sticky progress tracker walks the patient through four steps — Treatment, Dentist, Date & Time, and Your details — with a persistent summary card that fills in as choices are made. Treatments are shown as selectable cards with durations and price ranges; dentists as profile cards with specialties, ratings, and next-availability hints.
The heart of the flow is a lightweight calendar and time-slot grid. Days render as a scrollable week strip; picking a day reveals morning/afternoon/evening slots, each carrying a distinct availability state — open, few left, or booked — so the patient can read scarcity at a glance. Booked slots are disabled and keyboard-skippable, while open slots animate on hover and lock in with an accent ring. The final step validates patient name, phone, and email inline, then transitions to a confirmation screen showing the assembled booking reference, dentist, and date.
Everything runs on vanilla JS: step navigation, live summary syncing, slot selection, form validation, and a small toast helper for feedback. It is fully responsive down to narrow phones and keyboard-navigable throughout.
Illustrative UI only — not intended for real medical use.