diff --git a/web/modules/custom/riverside_pt/css/app.css b/web/modules/custom/riverside_pt/css/app.css index 12fdf13..518dbe8 100644 --- a/web/modules/custom/riverside_pt/css/app.css +++ b/web/modules/custom/riverside_pt/css/app.css @@ -558,6 +558,14 @@ video { html { 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 */ @@ -639,6 +647,10 @@ html { pointer-events: none; } +.visible{ + visibility: visible; +} + .static{ position: static; } @@ -689,6 +701,10 @@ html { margin-bottom: 0.125rem; } +.mb-1{ + margin-bottom: 0.25rem; +} + .mb-10{ margin-bottom: 2.5rem; } @@ -713,6 +729,10 @@ html { margin-bottom: 1.25rem; } +.mb-6{ + margin-bottom: 1.5rem; +} + .mb-\[1vw\]{ margin-bottom: 1vw; } @@ -733,28 +753,12 @@ html { margin-top: 0.5rem; } -.mt-\[2vw\]{ - margin-top: 2vw; -} - -.mb-1{ - margin-bottom: 0.25rem; -} - -.mb-6{ - margin-bottom: 1.5rem; -} - .mt-8{ margin-top: 2rem; } -.mt-1{ - margin-top: 0.25rem; -} - -.mt-3{ - margin-top: 0.75rem; +.mt-\[2vw\]{ + margin-top: 2vw; } .block{ @@ -1073,6 +1077,11 @@ html { 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{ --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-green-200{ - --tw-border-opacity: 1; - border-color: rgb(187 247 208 / var(--tw-border-opacity, 1)); -} - .bg-current{ 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{ --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); } -.bg-green-50{ - --tw-bg-opacity: 1; - background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1)); -} - .bg-gradient-to-b{ background-image: linear-gradient(to bottom, var(--tw-gradient-stops)); } @@ -1227,6 +1231,15 @@ html { padding: 1.5rem; } +.p-8{ + padding: 2rem; +} + +.px-3{ + padding-left: 0.75rem; + padding-right: 0.75rem; +} + .px-4{ padding-left: 1rem; padding-right: 1rem; @@ -1282,11 +1295,6 @@ html { padding-bottom: 1em; } -.px-3{ - padding-left: 0.75rem; - padding-right: 0.75rem; -} - .pb-1{ padding-bottom: 0.25rem; } @@ -1303,20 +1311,20 @@ html { padding-top: 0px; } -.pt-4{ - padding-top: 1rem; +.pt-20{ + padding-top: 5rem; } -.pt-px{ - padding-top: 1px; +.pt-4{ + padding-top: 1rem; } .pt-8{ padding-top: 2rem; } -.pt-20{ - padding-top: 5rem; +.pt-px{ + padding-top: 1px; } .text-left{ @@ -1495,11 +1503,26 @@ html { 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{ --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{ --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); } -.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{ text-decoration-line: none; } @@ -1637,11 +1641,6 @@ html { 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{ --tw-border-opacity: 1; border-color: rgb(48 111 142 / var(--tw-border-opacity, 1)); diff --git a/web/modules/custom/riverside_pt/css/tailwind.css b/web/modules/custom/riverside_pt/css/tailwind.css index bec02b5..499c702 100644 --- a/web/modules/custom/riverside_pt/css/tailwind.css +++ b/web/modules/custom/riverside_pt/css/tailwind.css @@ -7,6 +7,12 @@ @layer base { html { 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 */ .page-wrapper { diff --git a/web/modules/custom/riverside_pt/js/components/rpt-booking.js b/web/modules/custom/riverside_pt/js/components/rpt-booking.js index 8462eb0..3cd8042 100644 --- a/web/modules/custom/riverside_pt/js/components/rpt-booking.js +++ b/web/modules/custom/riverside_pt/js/components/rpt-booking.js @@ -18,7 +18,6 @@ const EMPTY_FORM = { firstName: "", lastName: "", phone: "", comments: "" }; function formatPhone(raw) { let d = String(raw || "").replace(/\D/g, ""); if (d.length === 11 && d[0] === "1") { - // NANP with leading 1: show "1 (xxx) xxx-xxxx" const rest = d.slice(1); 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", // ── Calendar overlay ────────────────────────────────────────────────── - calWrapper: "relative", - noSlotsOverlay: "absolute inset-0 z-10 flex items-center justify-center pt-20 pointer-events-none", + calWrapper: "relative", + noSlotsOverlay: "absolute inset-0 z-10 flex items-center justify-center pt-20 pointer-events-none", // ── Success state ────────────────────────────────────────────────────── successSection: "mt-8 pt-8 border-t border-pt-blue-200", - successBox: "p-6 bg-green-50 border border-green-200 text-green-800", - successTitle: "font-medium", - successBody: "text-sm mt-1", - successLink: "mt-3 text-sm text-green-700 underline hover:text-green-800", + successBox: "p-8 bg-green-50 border border-green-200 text-green-800", + successTitle: "text-2xl font-semibold mb-4", + successSummary: "flex flex-col gap-1 text-base mb-4", + successNote: "text-sm text-green-700", }; +// Module-level vars accessible from FullCalendar callbacks without stale closures. var selectedDate = null; var selectedDateSlots = []; +var currentEvents = []; function localDateStr(d) { return d.getFullYear() + "-" + @@ -87,47 +88,48 @@ function nextBusinessDay() { return localDateStr(d); } -function slotLabel(date) { - var h = date.getHours(); +function slotLabel(startStr) { + var d = new Date(startStr); + var h = d.getHours(); 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 }) { 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 [selectedSlotId, setSelectedSlotId] = useState(null); const [formData, setFormData] = useState(EMPTY_FORM); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [success, setSuccess] = useState(false); + const [confirmedAppointment, setConfirmedAppointment] = useState(null); const [noSlotsInMonth, setNoSlotsInMonth] = useState(false); const calEl = useRef(null); const calRef = useRef(null); const initializedRef = useRef(false); - const prevServiceRef = useRef(null); const autoAdvanceRef = useRef(0); - const fetchedRef = useRef(false); + const isFirstServiceRender = useRef(true); const initDate = useMemo(nextBusinessDay, []); function buildEventsUrl(svc) { return settings.eventsUrl + "?service=" + svc; } + // ── Initialize FullCalendar once ─────────────────────────────────────── useEffect(function () { 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, { initialView: "dayGridMonth", initialDate: initDate, @@ -146,17 +148,13 @@ function Booking({ settings }) { eventDisplay: "none", dayMaxEvents: false, - loading: function (isLoading) { - if (!isLoading) fetchedRef.current = true; - }, - - datesSet: function () { + // Visible range changed. Sets dateRange state → triggers fetch effect. + datesSet: function (info) { calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) { d.classList.remove("is-selected"); }); setSelectedSlotId(null); setNoSlotsInMonth(false); - fetchedRef.current = false; if (selectedDate) { var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + selectedDate + "\"]"); if (dayEl) { @@ -168,38 +166,20 @@ function Booking({ settings }) { } else { 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) { - markDays(events); - if (!initializedRef.current && fetchedRef.current) { - fetchedRef.current = false; - var dates = [...new Set(events.map(function (e) { return e.startStr.substring(0, 10); }))].sort(); - var firstDate = dates[0]; - if (firstDate) { - initializedRef.current = true; - 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); - } + 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"); + }); }, dayCellClassNames: function (arg) { @@ -213,9 +193,9 @@ function Booking({ settings }) { d.classList.remove("is-selected"); }); arg.dayEl.classList.add("is-selected"); - var daySlots = cal.getEvents() - .filter(function (e) { return e.startStr.startsWith(arg.dateStr); }) - .sort(function (a, b) { return a.start - b.start; }); + var daySlots = currentEvents + .filter(function (e) { return e.start.startsWith(arg.dateStr); }) + .sort(function (a, b) { return a.start < b.start ? -1 : 1; }); selectedDate = arg.dateStr; selectedDateSlots = daySlots; setSelectedSlotId(null); @@ -230,29 +210,87 @@ function Booking({ settings }) { 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 () { var cal = calRef.current; if (!cal) return; - var isInitial = prevServiceRef.current === null; - prevServiceRef.current = service; - if (!isInitial) { - 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); + if (isFirstServiceRender.current) { + isFirstServiceRender.current = false; + return; } - cal.removeAllEventSources(); - cal.addEventSource(buildEventsUrl(service)); + selectedDate = null; + 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]); function handleSlotClick(slot) { @@ -275,8 +313,8 @@ function Booking({ settings }) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - start: slot.startStr, - end: slot.endStr, + start: slot.start, + end: slot.end, service: service, firstName: formData.firstName, lastName: formData.lastName, @@ -287,16 +325,20 @@ function Booking({ settings }) { if (res.ok) { setSubmitting(false); setSubmitError(null); + setConfirmedAppointment({ + start: slot.start, + service: service, + firstName: formData.firstName, + lastName: formData.lastName, + }); setSuccess(true); setSelectedSlotId(null); setFormData(EMPTY_FORM); } else { setSubmitting(false); - if (res.status === 422) { - setSubmitError("That slot was just booked. Please choose another time."); - } else { - setSubmitError("Something went wrong. Please try again."); - } + setSubmitError(res.status === 422 + ? "That slot was just booked. Please choose another time." + : "Something went wrong. Please try again."); } }).catch(function () { setSubmitting(false); @@ -308,6 +350,7 @@ function Booking({ settings }) { return html`
Select Appointment Type
Request received!
-Thank you. We'll contact you shortly to confirm your appointment.
- -Request received!
+${confirmedAppointment.firstName} ${confirmedAppointment.lastName}
+${TYPES.find(function (t) { return t.id === confirmedAppointment.service; }).label}
+${formatAppointmentDate(confirmedAppointment.start)}
+We'll contact you shortly to confirm your appointment.
+Bringing Relief