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:
parent
9e1e6a57b7
commit
8962fc5f0e
10 changed files with 245 additions and 175 deletions
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
13
web/modules/custom/riverside_pt/js/scroll.js
Normal file
13
web/modules/custom/riverside_pt/js/scroll.js
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -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 } }
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in a new issue