Smooth scroll, booking refactor, success summary

- Add scroll.js: data-scroll-to attribute drives smooth scrollIntoView;
  scroll-margin-top at md+ accounts for fixed header offset
- Wire Services, FAQ, Book An Appointment, View Our Services nav/hero
  links to on-page anchors; don't close hamburger on scroll-link clicks
- Refactor booking calendar: own the fetch (useEffect + dateRange state)
  instead of handing URL to FullCalendar; removes fetchedRef complexity;
  noSlotsInMonth derived cleanly from fetchLoading + fetchedEvents
- Success state shows appointment summary (name, service, date/time);
  hides calendar/form on success; no "book another" button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-06-03 22:14:39 -07:00
parent 9e1e6a57b7
commit 8962fc5f0e
10 changed files with 245 additions and 175 deletions

View file

@ -558,6 +558,14 @@ video {
html { html {
background-color: #86aab6; background-color: #86aab6;
scroll-behavior: smooth;
}
@media (min-width: 768px) {
[id] {
scroll-margin-top: 110px;
/* fixed header (78px) + breathing room */
}
} }
/* Neutralise any theme container constraints */ /* Neutralise any theme container constraints */
@ -639,6 +647,10 @@ html {
pointer-events: none; pointer-events: none;
} }
.visible{
visibility: visible;
}
.static{ .static{
position: static; position: static;
} }
@ -689,6 +701,10 @@ html {
margin-bottom: 0.125rem; margin-bottom: 0.125rem;
} }
.mb-1{
margin-bottom: 0.25rem;
}
.mb-10{ .mb-10{
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
@ -713,6 +729,10 @@ html {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.mb-6{
margin-bottom: 1.5rem;
}
.mb-\[1vw\]{ .mb-\[1vw\]{
margin-bottom: 1vw; margin-bottom: 1vw;
} }
@ -733,28 +753,12 @@ html {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.mt-\[2vw\]{
margin-top: 2vw;
}
.mb-1{
margin-bottom: 0.25rem;
}
.mb-6{
margin-bottom: 1.5rem;
}
.mt-8{ .mt-8{
margin-top: 2rem; margin-top: 2rem;
} }
.mt-1{ .mt-\[2vw\]{
margin-top: 0.25rem; margin-top: 2vw;
}
.mt-3{
margin-top: 0.75rem;
} }
.block{ .block{
@ -1073,6 +1077,11 @@ html {
border-color: rgb(209 213 219 / var(--tw-border-opacity, 1)); border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
} }
.border-green-200{
--tw-border-opacity: 1;
border-color: rgb(187 247 208 / var(--tw-border-opacity, 1));
}
.border-pt-blue-200{ .border-pt-blue-200{
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(184 212 220 / var(--tw-border-opacity, 1)); border-color: rgb(184 212 220 / var(--tw-border-opacity, 1));
@ -1102,15 +1111,15 @@ html {
border-color: rgb(255 255 255 / 0.6); border-color: rgb(255 255 255 / 0.6);
} }
.border-green-200{
--tw-border-opacity: 1;
border-color: rgb(187 247 208 / var(--tw-border-opacity, 1));
}
.bg-current{ .bg-current{
background-color: currentColor; background-color: currentColor;
} }
.bg-green-50{
--tw-bg-opacity: 1;
background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1));
}
.bg-pt-blue-100{ .bg-pt-blue-100{
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(221 232 240 / var(--tw-bg-opacity, 1)); background-color: rgb(221 232 240 / var(--tw-bg-opacity, 1));
@ -1159,11 +1168,6 @@ html {
background-color: rgb(255 255 255 / 0.9); background-color: rgb(255 255 255 / 0.9);
} }
.bg-green-50{
--tw-bg-opacity: 1;
background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1));
}
.bg-gradient-to-b{ .bg-gradient-to-b{
background-image: linear-gradient(to bottom, var(--tw-gradient-stops)); background-image: linear-gradient(to bottom, var(--tw-gradient-stops));
} }
@ -1227,6 +1231,15 @@ html {
padding: 1.5rem; padding: 1.5rem;
} }
.p-8{
padding: 2rem;
}
.px-3{
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.px-4{ .px-4{
padding-left: 1rem; padding-left: 1rem;
padding-right: 1rem; padding-right: 1rem;
@ -1282,11 +1295,6 @@ html {
padding-bottom: 1em; padding-bottom: 1em;
} }
.px-3{
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.pb-1{ .pb-1{
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
@ -1303,20 +1311,20 @@ html {
padding-top: 0px; padding-top: 0px;
} }
.pt-4{ .pt-20{
padding-top: 1rem; padding-top: 5rem;
} }
.pt-px{ .pt-4{
padding-top: 1px; padding-top: 1rem;
} }
.pt-8{ .pt-8{
padding-top: 2rem; padding-top: 2rem;
} }
.pt-20{ .pt-px{
padding-top: 5rem; padding-top: 1px;
} }
.text-left{ .text-left{
@ -1495,11 +1503,26 @@ html {
color: rgb(17 24 39 / var(--tw-text-opacity, 1)); color: rgb(17 24 39 / var(--tw-text-opacity, 1));
} }
.text-green-700{
--tw-text-opacity: 1;
color: rgb(21 128 61 / var(--tw-text-opacity, 1));
}
.text-green-800{
--tw-text-opacity: 1;
color: rgb(22 101 52 / var(--tw-text-opacity, 1));
}
.text-pt-blue-500{ .text-pt-blue-500{
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(48 111 142 / var(--tw-text-opacity, 1)); color: rgb(48 111 142 / var(--tw-text-opacity, 1));
} }
.text-red-500{
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
}
.text-white{ .text-white{
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1)); color: rgb(255 255 255 / var(--tw-text-opacity, 1));
@ -1513,25 +1536,6 @@ html {
color: rgb(255 255 255 / 0.8); color: rgb(255 255 255 / 0.8);
} }
.text-red-500{
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
}
.text-green-700{
--tw-text-opacity: 1;
color: rgb(21 128 61 / var(--tw-text-opacity, 1));
}
.text-green-800{
--tw-text-opacity: 1;
color: rgb(22 101 52 / var(--tw-text-opacity, 1));
}
.underline{
text-decoration-line: underline;
}
.no-underline{ .no-underline{
text-decoration-line: none; text-decoration-line: none;
} }
@ -1637,11 +1641,6 @@ html {
color: rgb(255 255 255 / var(--tw-text-opacity, 1)); color: rgb(255 255 255 / var(--tw-text-opacity, 1));
} }
.hover\:text-green-800:hover{
--tw-text-opacity: 1;
color: rgb(22 101 52 / var(--tw-text-opacity, 1));
}
.focus\:border-pt-blue-500:focus{ .focus\:border-pt-blue-500:focus{
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(48 111 142 / var(--tw-border-opacity, 1)); border-color: rgb(48 111 142 / var(--tw-border-opacity, 1));

View file

@ -7,6 +7,12 @@
@layer base { @layer base {
html { html {
background-color: theme('colors.pt-blue.400'); background-color: theme('colors.pt-blue.400');
scroll-behavior: smooth;
}
@media (min-width: theme('screens.sm')) {
[id] {
scroll-margin-top: 110px; /* fixed header (78px) + breathing room */
}
} }
/* Neutralise any theme container constraints */ /* Neutralise any theme container constraints */
.page-wrapper { .page-wrapper {

View file

@ -18,7 +18,6 @@ const EMPTY_FORM = { firstName: "", lastName: "", phone: "", comments: "" };
function formatPhone(raw) { function formatPhone(raw) {
let d = String(raw || "").replace(/\D/g, ""); let d = String(raw || "").replace(/\D/g, "");
if (d.length === 11 && d[0] === "1") { if (d.length === 11 && d[0] === "1") {
// NANP with leading 1: show "1 (xxx) xxx-xxxx"
const rest = d.slice(1); const rest = d.slice(1);
return "1 (" + rest.slice(0, 3) + ") " + rest.slice(3, 6) + "-" + rest.slice(6); return "1 (" + rest.slice(0, 3) + ") " + rest.slice(3, 6) + "-" + rest.slice(6);
} }
@ -60,19 +59,21 @@ const CX = {
submitBtn: "px-[4em] py-[1em] bg-pt-blue-500 text-white text-sm font-medium transition-colors border-2 border-pt-blue-500 hover:bg-pt-blue-600 hover:border-pt-blue-600 disabled:opacity-50", submitBtn: "px-[4em] py-[1em] bg-pt-blue-500 text-white text-sm font-medium transition-colors border-2 border-pt-blue-500 hover:bg-pt-blue-600 hover:border-pt-blue-600 disabled:opacity-50",
// ── Calendar overlay ────────────────────────────────────────────────── // ── Calendar overlay ──────────────────────────────────────────────────
calWrapper: "relative", calWrapper: "relative",
noSlotsOverlay: "absolute inset-0 z-10 flex items-center justify-center pt-20 pointer-events-none", noSlotsOverlay: "absolute inset-0 z-10 flex items-center justify-center pt-20 pointer-events-none",
// ── Success state ────────────────────────────────────────────────────── // ── Success state ──────────────────────────────────────────────────────
successSection: "mt-8 pt-8 border-t border-pt-blue-200", successSection: "mt-8 pt-8 border-t border-pt-blue-200",
successBox: "p-6 bg-green-50 border border-green-200 text-green-800", successBox: "p-8 bg-green-50 border border-green-200 text-green-800",
successTitle: "font-medium", successTitle: "text-2xl font-semibold mb-4",
successBody: "text-sm mt-1", successSummary: "flex flex-col gap-1 text-base mb-4",
successLink: "mt-3 text-sm text-green-700 underline hover:text-green-800", successNote: "text-sm text-green-700",
}; };
// Module-level vars accessible from FullCalendar callbacks without stale closures.
var selectedDate = null; var selectedDate = null;
var selectedDateSlots = []; var selectedDateSlots = [];
var currentEvents = [];
function localDateStr(d) { function localDateStr(d) {
return d.getFullYear() + "-" + return d.getFullYear() + "-" +
@ -87,47 +88,48 @@ function nextBusinessDay() {
return localDateStr(d); return localDateStr(d);
} }
function slotLabel(date) { function slotLabel(startStr) {
var h = date.getHours(); var d = new Date(startStr);
var h = d.getHours();
return (h % 12 || 12) + (h < 12 ? "AM" : "PM") + " PST"; return (h % 12 || 12) + (h < 12 ? "AM" : "PM") + " PST";
} }
function formatAppointmentDate(startStr) {
var parts = startStr.split("T")[0].split("-");
var d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
var date = d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
return date + " at " + slotLabel(startStr);
}
function Booking({ settings }) { function Booking({ settings }) {
const [service, setService] = useState("diagnostic"); const [service, setService] = useState("diagnostic");
const [dateRange, setDateRange] = useState(null); // "startStr/endStr", set by datesSet
const [fetchedEvents, setFetchedEvents] = useState(null); // null = not yet fetched
const [fetchLoading, setFetchLoading] = useState(false);
const [slots, setSlots] = useState([]); const [slots, setSlots] = useState([]);
const [selectedSlotId, setSelectedSlotId] = useState(null); const [selectedSlotId, setSelectedSlotId] = useState(null);
const [formData, setFormData] = useState(EMPTY_FORM); const [formData, setFormData] = useState(EMPTY_FORM);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null); const [submitError, setSubmitError] = useState(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [confirmedAppointment, setConfirmedAppointment] = useState(null);
const [noSlotsInMonth, setNoSlotsInMonth] = useState(false); const [noSlotsInMonth, setNoSlotsInMonth] = useState(false);
const calEl = useRef(null); const calEl = useRef(null);
const calRef = useRef(null); const calRef = useRef(null);
const initializedRef = useRef(false); const initializedRef = useRef(false);
const prevServiceRef = useRef(null);
const autoAdvanceRef = useRef(0); const autoAdvanceRef = useRef(0);
const fetchedRef = useRef(false); const isFirstServiceRender = useRef(true);
const initDate = useMemo(nextBusinessDay, []); const initDate = useMemo(nextBusinessDay, []);
function buildEventsUrl(svc) { function buildEventsUrl(svc) {
return settings.eventsUrl + "?service=" + svc; return settings.eventsUrl + "?service=" + svc;
} }
// ── Initialize FullCalendar once ───────────────────────────────────────
useEffect(function () { useEffect(function () {
if (!calEl.current || !window.FullCalendar) return; if (!calEl.current || !window.FullCalendar) return;
function markDays(events) {
calEl.current.querySelectorAll(".fc-daygrid-day.has-availability").forEach(function (d) {
d.classList.remove("has-availability");
});
events.forEach(function (event) {
var dateStr = event.startStr.substring(0, 10);
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + dateStr + "\"]");
if (dayEl) dayEl.classList.add("has-availability");
});
}
var cal = new FullCalendar.Calendar(calEl.current, { var cal = new FullCalendar.Calendar(calEl.current, {
initialView: "dayGridMonth", initialView: "dayGridMonth",
initialDate: initDate, initialDate: initDate,
@ -146,17 +148,13 @@ function Booking({ settings }) {
eventDisplay: "none", eventDisplay: "none",
dayMaxEvents: false, dayMaxEvents: false,
loading: function (isLoading) { // Visible range changed. Sets dateRange state → triggers fetch effect.
if (!isLoading) fetchedRef.current = true; datesSet: function (info) {
},
datesSet: function () {
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) { calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) {
d.classList.remove("is-selected"); d.classList.remove("is-selected");
}); });
setSelectedSlotId(null); setSelectedSlotId(null);
setNoSlotsInMonth(false); setNoSlotsInMonth(false);
fetchedRef.current = false;
if (selectedDate) { if (selectedDate) {
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + selectedDate + "\"]"); var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + selectedDate + "\"]");
if (dayEl) { if (dayEl) {
@ -168,38 +166,20 @@ function Booking({ settings }) {
} else { } else {
setSlots([]); setSlots([]);
} }
setDateRange(info.startStr + "/" + info.endStr);
}, },
// Mark which day cells have availability whenever FullCalendar's
// event list changes (fires after addEventSource / removeAllEventSources).
eventsSet: function (events) { eventsSet: function (events) {
markDays(events); calEl.current.querySelectorAll(".fc-daygrid-day.has-availability").forEach(function (d) {
if (!initializedRef.current && fetchedRef.current) { d.classList.remove("has-availability");
fetchedRef.current = false; });
var dates = [...new Set(events.map(function (e) { return e.startStr.substring(0, 10); }))].sort(); events.forEach(function (event) {
var firstDate = dates[0]; var dateStr = event.startStr.substring(0, 10);
if (firstDate) { var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + dateStr + "\"]");
initializedRef.current = true; if (dayEl) dayEl.classList.add("has-availability");
autoAdvanceRef.current = 0; });
var firstSlots = events
.filter(function (e) { return e.startStr.startsWith(firstDate); })
.sort(function (a, b) { return a.start - b.start; });
selectedDate = firstDate;
selectedDateSlots = firstSlots;
var targetEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + firstDate + "\"]");
if (targetEl) {
targetEl.classList.add("is-selected");
setSlots(firstSlots);
}
} else if (autoAdvanceRef.current < 12) {
autoAdvanceRef.current++;
cal.next();
}
}
if (initializedRef.current && fetchedRef.current) {
var viewStart = cal.view.currentStart;
var viewEnd = cal.view.currentEnd;
var inView = events.filter(function (e) { return e.start >= viewStart && e.start < viewEnd; });
setNoSlotsInMonth(inView.length === 0);
}
}, },
dayCellClassNames: function (arg) { dayCellClassNames: function (arg) {
@ -213,9 +193,9 @@ function Booking({ settings }) {
d.classList.remove("is-selected"); d.classList.remove("is-selected");
}); });
arg.dayEl.classList.add("is-selected"); arg.dayEl.classList.add("is-selected");
var daySlots = cal.getEvents() var daySlots = currentEvents
.filter(function (e) { return e.startStr.startsWith(arg.dateStr); }) .filter(function (e) { return e.start.startsWith(arg.dateStr); })
.sort(function (a, b) { return a.start - b.start; }); .sort(function (a, b) { return a.start < b.start ? -1 : 1; });
selectedDate = arg.dateStr; selectedDate = arg.dateStr;
selectedDateSlots = daySlots; selectedDateSlots = daySlots;
setSelectedSlotId(null); setSelectedSlotId(null);
@ -230,29 +210,87 @@ function Booking({ settings }) {
return function () { cal.destroy(); }; return function () { cal.destroy(); };
}, []); }, []);
// ── Fetch events when service or visible range changes ─────────────────
useEffect(function () {
if (!dateRange) return;
var parts = dateRange.split("/");
var url = buildEventsUrl(service) + "&start=" + parts[0] + "&end=" + parts[1];
setFetchLoading(true);
setFetchedEvents(null);
fetch(url)
.then(function (r) { return r.json(); })
.then(function (data) {
currentEvents = data;
setFetchedEvents(data);
setFetchLoading(false);
})
.catch(function () {
currentEvents = [];
setFetchedEvents([]);
setFetchLoading(false);
});
}, [service, dateRange]);
// ── Push fetched events into FullCalendar; auto-advance or auto-select ─
useEffect(function () {
if (fetchedEvents === null) return;
var cal = calRef.current;
if (!cal) return;
cal.removeAllEventSources();
if (fetchedEvents.length > 0) {
cal.addEventSource(fetchedEvents); // fires eventsSet → marks availability
if (!initializedRef.current) {
var dates = [...new Set(fetchedEvents.map(function (e) { return e.start.substring(0, 10); }))].sort();
var firstDate = dates[0];
if (firstDate) {
initializedRef.current = true;
autoAdvanceRef.current = 0;
var firstSlots = fetchedEvents
.filter(function (e) { return e.start.startsWith(firstDate); })
.sort(function (a, b) { return a.start < b.start ? -1 : 1; });
selectedDate = firstDate;
selectedDateSlots = firstSlots;
var targetEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + firstDate + "\"]");
if (targetEl) {
targetEl.classList.add("is-selected");
setSlots(firstSlots);
}
}
}
} else if (!initializedRef.current && autoAdvanceRef.current < 12) {
autoAdvanceRef.current++;
cal.next(); // fires datesSet → setDateRange → fetch effect re-runs
} else if (initializedRef.current) {
setNoSlotsInMonth(true);
}
}, [fetchedEvents]);
// ── Reset and navigate when service changes ────────────────────────────
useEffect(function () { useEffect(function () {
var cal = calRef.current; var cal = calRef.current;
if (!cal) return; if (!cal) return;
var isInitial = prevServiceRef.current === null; if (isFirstServiceRender.current) {
prevServiceRef.current = service; isFirstServiceRender.current = false;
if (!isInitial) { return;
initializedRef.current = false;
autoAdvanceRef.current = 0;
fetchedRef.current = false;
selectedDate = null;
selectedDateSlots = [];
setSlots([]);
setSelectedSlotId(null);
setFormData(EMPTY_FORM);
setSubmitError(null);
setSuccess(false);
setNoSlotsInMonth(false);
cal.gotoDate(initDate);
} }
cal.removeAllEventSources(); selectedDate = null;
cal.addEventSource(buildEventsUrl(service)); selectedDateSlots = [];
currentEvents = [];
initializedRef.current = false;
autoAdvanceRef.current = 0;
setSlots([]);
setSelectedSlotId(null);
setFormData(EMPTY_FORM);
setSubmitError(null);
setSuccess(false);
setNoSlotsInMonth(false);
setFetchedEvents(null);
cal.gotoDate(initDate); // fires datesSet → setDateRange → fetch effect
}, [service]); }, [service]);
function handleSlotClick(slot) { function handleSlotClick(slot) {
@ -275,8 +313,8 @@ function Booking({ settings }) {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
start: slot.startStr, start: slot.start,
end: slot.endStr, end: slot.end,
service: service, service: service,
firstName: formData.firstName, firstName: formData.firstName,
lastName: formData.lastName, lastName: formData.lastName,
@ -287,16 +325,20 @@ function Booking({ settings }) {
if (res.ok) { if (res.ok) {
setSubmitting(false); setSubmitting(false);
setSubmitError(null); setSubmitError(null);
setConfirmedAppointment({
start: slot.start,
service: service,
firstName: formData.firstName,
lastName: formData.lastName,
});
setSuccess(true); setSuccess(true);
setSelectedSlotId(null); setSelectedSlotId(null);
setFormData(EMPTY_FORM); setFormData(EMPTY_FORM);
} else { } else {
setSubmitting(false); setSubmitting(false);
if (res.status === 422) { setSubmitError(res.status === 422
setSubmitError("That slot was just booked. Please choose another time."); ? "That slot was just booked. Please choose another time."
} else { : "Something went wrong. Please try again.");
setSubmitError("Something went wrong. Please try again.");
}
} }
}).catch(function () { }).catch(function () {
setSubmitting(false); setSubmitting(false);
@ -308,6 +350,7 @@ function Booking({ settings }) {
return html` return html`
<div> <div>
${!success ? html`
<p class=${CX.selectorLabel}>Select Appointment Type</p> <p class=${CX.selectorLabel}>Select Appointment Type</p>
<div class=${CX.selectorGrid}> <div class=${CX.selectorGrid}>
${TYPES.map(function (t) { ${TYPES.map(function (t) {
@ -364,29 +407,7 @@ function Booking({ settings }) {
` : null} ` : null}
</div> </div>
${success ? html` ${!success && selectedSlot ? html`
<div class=${CX.successSection}>
<div class=${CX.successBox}>
<p class=${CX.successTitle}>Request received!</p>
<p class=${CX.successBody}>Thank you. We'll contact you shortly to confirm your appointment.</p>
<button
type="button"
onClick=${function () {
setSuccess(false);
setFormData(EMPTY_FORM);
if (calEl.current) {
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) {
d.classList.remove("is-selected");
});
}
}}
class=${CX.successLink}
>Book another appointment</button>
</div>
</div>
` : null}
${selectedSlot ? html`
<form onSubmit=${handleSubmit} autocomplete="on" class=${CX.formSection}> <form onSubmit=${handleSubmit} autocomplete="on" class=${CX.formSection}>
<p class=${CX.formHeading}>Your Details</p> <p class=${CX.formHeading}>Your Details</p>
@ -456,6 +477,21 @@ function Booking({ settings }) {
</button> </button>
</form> </form>
` : null} ` : null}
` : null}
${success && confirmedAppointment ? html`
<div class=${CX.successSection}>
<div class=${CX.successBox}>
<p class=${CX.successTitle}>Request received!</p>
<div class=${CX.successSummary}>
<p>${confirmedAppointment.firstName} ${confirmedAppointment.lastName}</p>
<p>${TYPES.find(function (t) { return t.id === confirmedAppointment.service; }).label}</p>
<p>${formatAppointmentDate(confirmedAppointment.start)}</p>
</div>
<p class=${CX.successNote}>We'll contact you shortly to confirm your appointment.</p>
</div>
</div>
` : null}
</div> </div>
`; `;
} }

View file

@ -13,6 +13,7 @@
nav.querySelectorAll('a').forEach(function (link) { nav.querySelectorAll('a').forEach(function (link) {
link.addEventListener('click', function () { link.addEventListener('click', function () {
if (link.dataset.scrollTo) return; // page scrolls away — no need to close
nav.classList.remove('is-open'); nav.classList.remove('is-open');
btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-expanded', 'false');
}); });

View file

@ -0,0 +1,13 @@
// Add data-scroll-to="#target-id" to any link to smooth-scroll instead of navigate.
(function () {
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('click', function (e) {
var link = e.target.closest('[data-scroll-to]');
if (!link) return;
var target = document.querySelector(link.dataset.scrollTo);
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth' });
});
});
})();

View file

@ -4,6 +4,7 @@ app:
css/app.css: {} css/app.css: {}
js: js:
js/nav.js: {} js/nav.js: {}
js/scroll.js: {}
js/phone-format.js: {} js/phone-format.js: {}
js/components/rpt-toggle.js: { attributes: { type: module } } js/components/rpt-toggle.js: { attributes: { type: module } }
js/components/rpt-carousel.js: { attributes: { type: module } } js/components/rpt-carousel.js: { attributes: { type: module } }

View file

@ -89,6 +89,10 @@ function riverside_pt_mail(string $key, array &$message, array $params): void {
'Slot: ' . $start->format('l, F j, Y') . ', ' . $start->format('g:i A') . '' . $end->format('g:i A'), 'Slot: ' . $start->format('l, F j, Y') . ', ' . $start->format('g:i A') . '' . $end->format('g:i A'),
]; ];
if (!empty($params['email'])) {
array_splice($lines, 1, 0, 'Email: ' . $params['email']);
}
if (!empty($params['comments'])) { if (!empty($params['comments'])) {
$lines[] = 'Comments: ' . $params['comments']; $lines[] = 'Comments: ' . $params['comments'];
} }

View file

@ -42,6 +42,7 @@ class ScheduleController extends ControllerBase {
$firstName = trim($data['firstName'] ?? $data['first_name'] ?? ''); $firstName = trim($data['firstName'] ?? $data['first_name'] ?? '');
$lastName = trim($data['lastName'] ?? $data['last_name'] ?? ''); $lastName = trim($data['lastName'] ?? $data['last_name'] ?? '');
$email = trim($data['email'] ?? '');
$phone = trim($data['phone'] ?? ''); $phone = trim($data['phone'] ?? '');
$comments = $data['comments'] ?? ''; $comments = $data['comments'] ?? '';
$service = $data['service'] ?? 'diagnostic'; $service = $data['service'] ?? 'diagnostic';
@ -51,7 +52,7 @@ class ScheduleController extends ControllerBase {
// Full contact info present (new embedded booking flow on homepage): // Full contact info present (new embedded booking flow on homepage):
// validate, send the request email immediately, and return success. // validate, send the request email immediately, and return success.
// This replaces the previous /schedule/book form page. // This replaces the previous /schedule/book form page.
if ($firstName && $lastName && $phone) { if ($firstName && $lastName && $email && $phone) {
// Prevent double-booking against existing appointment nodes (same logic as before). // Prevent double-booking against existing appointment nodes (same logic as before).
$conflict = \Drupal::entityQuery('node') $conflict = \Drupal::entityQuery('node')
->condition('type', 'appointment') ->condition('type', 'appointment')
@ -71,6 +72,7 @@ class ScheduleController extends ControllerBase {
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, [ $sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, [
'first_name' => $firstName, 'first_name' => $firstName,
'last_name' => $lastName, 'last_name' => $lastName,
'email' => $email,
'phone' => $phone, 'phone' => $phone,
'comments' => $comments, 'comments' => $comments,
'start' => $start, 'start' => $start,
@ -91,7 +93,9 @@ class ScheduleController extends ControllerBase {
'start' => $start, 'start' => $start,
'end' => $end, 'end' => $end,
'service' => $service, 'service' => $service,
'first_name' => $firstName,
'last_name' => $lastName, 'last_name' => $lastName,
'email' => $email,
'phone' => $phone, 'phone' => $phone,
'comments' => $comments, 'comments' => $comments,
'provider_id' => $providerId, 'provider_id' => $providerId,

View file

@ -20,7 +20,10 @@
text-[blue-900] font-medium text-[blue-900] font-medium
{% endif %} {% endif %}
" "
href="{{ item.url }}">{{ item.title }}</a> href="{{ item.url }}"
{% if item.title == 'Services' %}data-scroll-to="#pt-services"{% endif %}
{% if item.title == 'FAQ' %}data-scroll-to="#pt-faq"{% endif %}
>{{ item.title }}</a>
</li> </li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -39,7 +42,9 @@
href="{{ item.url }}">{{ item.title }}</a> href="{{ item.url }}">{{ item.title }}</a>
{% else %} {% else %}
<a class="inline-block px-5 py-2 rounded-none bg-pt-blue-500 text-white text-sm font-medium no-underline rounded border border-pt-navy whitespace-nowrap transition-colors hover:bg-pt-blue-600 hover:border-pt-blue-600 hover:text-white" <a class="inline-block px-5 py-2 rounded-none bg-pt-blue-500 text-white text-sm font-medium no-underline rounded border border-pt-navy whitespace-nowrap transition-colors hover:bg-pt-blue-600 hover:border-pt-blue-600 hover:text-white"
href="{{ item.url }}">{{ item.title }}</a> href="{{ item.url }}"
{% if item.title == 'Book An Appointment' %}data-scroll-to="#book-an-appointment"{% endif %}
>{{ item.title }}</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -35,6 +35,7 @@
>Book An Appointment</a> >Book An Appointment</a>
<a <a
href="/services" href="/services"
data-scroll-to="#pt-services"
class="hidden sm:inline-block text-[clamp(0.25rem,1vw,1.25vw)] px-[4em] py-[1em] bg-pt-blue-400 text-white font-medium no-underline transition-colors border-2 border-white hover:bg-pt-sage-500" class="hidden sm:inline-block text-[clamp(0.25rem,1vw,1.25vw)] px-[4em] py-[1em] bg-pt-blue-400 text-white font-medium no-underline transition-colors border-2 border-white hover:bg-pt-sage-500"
>View Our Services</a> >View Our Services</a>
</div> </div>
@ -45,7 +46,7 @@
</section> </section>
</div> </div>
<section class="py-16 px-6 bg-white"> <section id="pt-services" class="py-16 px-6 bg-white">
<div class="max-w-[1040px] mx-auto mb-12"> <div class="max-w-[1040px] mx-auto mb-12">
<p class="text-sm tracking-widest uppercase text-pt-blue-500 font-semibold text-center mb-2">Bringing Relief</p> <p class="text-sm tracking-widest uppercase text-pt-blue-500 font-semibold text-center mb-2">Bringing Relief</p>
<h2 class="text-[2.25rem] font-serif font-normal text-gray-900 leading-tight text-center">Our Wide Range of Physical Therapy Services</h2> <h2 class="text-[2.25rem] font-serif font-normal text-gray-900 leading-tight text-center">Our Wide Range of Physical Therapy Services</h2>
@ -122,6 +123,6 @@
</div> </div>
</section> </section>
<rpt-faq class="block"></rpt-faq> <rpt-faq id="pt-faq" class="block"></rpt-faq>
<div class="bg-pt-blue-400 h-[240px]"></div> <div class="bg-pt-blue-400 h-[240px]"></div>