import { h, render } from "https://esm.sh/preact@10"; import { useState, useEffect, useRef, useMemo } from "https://esm.sh/preact@10/hooks"; import { html } from "https://esm.sh/htm@3/preact"; const TYPES = [ { id: "diagnostic", label: "Diagnostic Assessment", duration: "60 MINS" }, { id: "sports", label: "Sports Rehabilitation", duration: "60 MINS" }, { id: "surgical", label: "Surgery Rehabilitation", duration: "60 MINS" }, { id: "neuro", label: "Neurological Therapy", duration: "60 MINS" }, ]; const CHECK = html``; const EMPTY_FORM = { firstName: "", lastName: "", email: "", phone: "", comments: "" }; /** @param {string | number} [raw] */ function formatPhone(raw) { let d = String(raw || "").replace(/\D/g, ""); if (d.length === 11 && d[0] === "1") { const rest = d.slice(1); return "1 (" + rest.slice(0, 3) + ") " + rest.slice(3, 6) + "-" + rest.slice(6); } d = d.slice(0, 10); if (d.length === 0) return ""; if (d.length <= 3) return d; if (d.length <= 6) return "(" + d.slice(0, 3) + ") " + d.slice(3); return "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6); } // All Tailwind class strings live here so the JIT scanner sees complete // literals in one place rather than spread across template expressions. const CX = { // ── Type selector ────────────────────────────────────────────────────── selectorLabel: "text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-5", selectorGrid: "grid grid-cols-1 sm:grid-cols-2 gap-3 mb-10", typeBtn: "flex items-center gap-4 p-4 w-full rounded-xl border transition-colors", typeBtnActive: "bg-pt-blue-500 border-pt-blue-500", typeBtnInactive: "bg-white border-pt-blue-200 hover:border-pt-blue-500", typeCircle: "w-8 h-8 rounded-full shrink-0 flex items-center justify-center border", typeCircleActive: "border-white/60", typeCircleInactive: "border-pt-blue-200", typeLabel: "font-serif text-[1.0625rem] font-normal leading-snug", typeLabelActive: "text-white", typeLabelInactive: "text-gray-900", typeDuration: "text-[0.6875rem] tracking-widest font-semibold mt-0.5", typeDurationActive: "text-white/70", typeDurationInactive: "text-pt-blue-500", // ── Form ─────────────────────────────────────────────────────────────── formSection: "mt-8 pt-8 border-t border-pt-blue-200", formHeading: "text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-6", formGrid: "grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-5 mb-5", formLabel: "block text-sm font-medium text-gray-700 mb-1", formRequired: "text-red-500", formInput: "w-full border border-pt-blue-200 bg-white px-3 py-2 text-gray-900 text-sm focus:outline-none focus:border-pt-blue-500 transition-colors", formTextarea: "resize-none w-full border border-pt-blue-200 bg-white px-3 py-2 text-gray-900 text-sm focus:outline-none focus:border-pt-blue-500 transition-colors", formError: "text-red-500 text-sm mb-4", 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", // ── Success state ────────────────────────────────────────────────────── successSection: "mt-8 pt-8 border-t border-pt-blue-200", 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", }; /** @param {Date} d */ function localDateStr(d) { return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); } function nextBusinessDay() { var d = new Date(); d.setDate(d.getDate() + 1); while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1); return localDateStr(d); } /** @param {string} startStr */ function slotLabel(startStr) { var d = new Date(startStr); var h = d.getHours(); return (h % 12 || 12) + (h < 12 ? "AM" : "PM") + " PST"; } /** @param {string} startStr */ 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); } // ── BookingPanel ────────────────────────────────────────────────────────── // Keyed by service in the parent, so it always mounts fresh for each service. // service is a prop here — it never changes within an instance's lifetime, // which means the fetch effect can depend only on dateRange (no stale-service risk). /** * @param {{ service: string, settings: RiversidePtSettings }} props */ function BookingPanel({ service, settings }) { const [dateRange, setDateRange] = useState(/** @type {string | null} */ (null)); const [fetchedEvents, setFetchedEvents] = useState(/** @type {RiversidePtEvent[] | null} */ (null)); const [fetchLoading, setFetchLoading] = useState(false); const [slots, setSlots] = useState(/** @type {RiversidePtEvent[]} */ ([])); const [selectedSlotId, setSelectedSlotId] = useState(/** @type {number | string | null} */ (null)); const [formData, setFormData] = useState(EMPTY_FORM); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(/** @type {string | null} */ (null)); const [success, setSuccess] = useState(false); const [confirmedAppointment, setConfirmedAppointment] = useState(/** @type {{ start: string, service: string, firstName: string, lastName: string, email: string } | null} */ (null)); const [noSlotsInMonth, setNoSlotsInMonth] = useState(false); const calEl = useRef(/** @type {HTMLDivElement | null} */ (null)); const calRef = useRef(/** @type {any} */ (null)); const initializedRef = useRef(false); const autoAdvanceRef = useRef(0); const fetchAbortRef = useRef(/** @type {AbortController | null} */ (null)); // Instance-scoped vars for FullCalendar callbacks (no stale-closure risk via .current). const selectedDateRef = useRef(/** @type {string | null} */ (null)); const selectedDateSlotsRef = useRef(/** @type {RiversidePtEvent[]} */ ([])); const currentEventsRef = useRef(/** @type {RiversidePtEvent[]} */ ([])); const initDate = useMemo(nextBusinessDay, []); const formRef = useRef(/** @type {HTMLFormElement | null} */ (null)); const prevSlotIdRef = useRef(/** @type {number | string | null} */ (null)); const successRef = useRef(/** @type {HTMLDivElement | null} */ (null)); function buildEventsUrl() { return settings.eventsUrl + "?service=" + service; } // ── Initialize FullCalendar once ───────────────────────────────────── useEffect(function () { var root = calEl.current; if (!root || !window.FullCalendar) return; /** @type {HTMLDivElement} */ var rootEl = root; var FC = window.FullCalendar || FullCalendar; var cal = new FC.Calendar(root, { initialView: "dayGridMonth", initialDate: initDate, headerToolbar: { left: "prev", center: "title", right: "next" }, titleFormat: { year: "numeric", month: "long" }, dayHeaderFormat: { weekday: "narrow" }, validRange: function (/** @type {any} */ now) { return { start: new Date(now.getFullYear(), now.getMonth(), 1), end: new Date(now.getFullYear(), now.getMonth() + 7, 1), }; }, fixedWeekCount: false, showNonCurrentDates: false, height: "auto", eventDisplay: "none", dayMaxEvents: false, datesSet: function (/** @type {any} */ info) { rootEl.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (/** @type {any} */ d) { d.classList.remove("is-selected"); }); setSelectedSlotId(null); setNoSlotsInMonth(false); if (selectedDateRef.current) { var dayEl = rootEl.querySelector(".fc-daygrid-day[data-date=\"" + selectedDateRef.current + "\"]"); if (dayEl) { dayEl.classList.add("is-selected"); setSlots(selectedDateSlotsRef.current); } else { setSlots([]); } } else { setSlots([]); } setDateRange(info.startStr + "/" + info.endStr); }, eventsSet: function (/** @type {any[]} */ events) { rootEl.querySelectorAll(".fc-daygrid-day.has-availability").forEach(function (/** @type {any} */ d) { d.classList.remove("has-availability"); }); events.forEach(function (/** @type {any} */ event) { var dateStr = event.startStr.substring(0, 10); var dayEl = rootEl.querySelector(".fc-daygrid-day[data-date=\"" + dateStr + "\"]"); if (dayEl) dayEl.classList.add("has-availability"); }); }, dayCellClassNames: function (/** @type {any} */ arg) { var date = arg.date.toISOString().substring(0, 10); if (settings.holidays[date]) return ["is-holiday"]; }, dateClick: function (/** @type {any} */ arg) { if (!arg.dayEl.classList.contains("has-availability")) return; rootEl.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (/** @type {any} */ d) { d.classList.remove("is-selected"); }); arg.dayEl.classList.add("is-selected"); var daySlots = currentEventsRef.current .filter(function (/** @type {RiversidePtEvent} */ e) { return e.start.startsWith(arg.dateStr); }) .sort(function (/** @type {RiversidePtEvent} */ a, /** @type {RiversidePtEvent} */ b) { return a.start < b.start ? -1 : 1; }); selectedDateRef.current = arg.dateStr; selectedDateSlotsRef.current = daySlots; setSelectedSlotId(null); setSubmitError(null); setSuccess(false); setSlots(daySlots); }, }); cal.render(); calRef.current = cal; window.rptScrollTo(cal, true); return function () { cal.destroy(); }; }, []); // ── Fetch events when visible range changes ────────────────────────── // service is a prop that never changes within this component instance // (parent keys us by service), so only dateRange needs to be a dep. useEffect(function () { if (!dateRange) return; if (fetchAbortRef.current) fetchAbortRef.current.abort(); var controller = new AbortController(); fetchAbortRef.current = controller; var parts = dateRange.split("/"); var url = buildEventsUrl() + "&start=" + parts[0] + "&end=" + parts[1]; setFetchLoading(true); setFetchedEvents(null); fetch(url, { signal: controller.signal }) .then(function (r) { return r.json(); }) .then(function (/** @type {RiversidePtEvent[]} */ data) { currentEventsRef.current = data; setFetchedEvents(data); setFetchLoading(false); }) .catch(function (/** @type {any} */ err) { if (err.name === "AbortError") return; currentEventsRef.current = []; setFetchedEvents([]); setFetchLoading(false); }); }, [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); if (!initializedRef.current) { var dates = [...new Set(fetchedEvents.map(function (/** @type {RiversidePtEvent} */ e) { return e.start.substring(0, 10); }))].sort(); var firstDate = dates[0]; if (firstDate) { var firstSlots = fetchedEvents .filter(function (/** @type {RiversidePtEvent} */ e) { return e.start.startsWith(firstDate); }) .sort(function (/** @type {RiversidePtEvent} */ a, /** @type {RiversidePtEvent} */ b) { return a.start < b.start ? -1 : 1; }); selectedDateRef.current = firstDate; selectedDateSlotsRef.current = firstSlots; var root = calEl.current; var targetEl = root ? root.querySelector(".fc-daygrid-day[data-date=\"" + firstDate + "\"]") : null; if (targetEl) { initializedRef.current = true; autoAdvanceRef.current = 0; targetEl.classList.add("is-selected"); setSlots(firstSlots); } } } } else if (!initializedRef.current && autoAdvanceRef.current < 12) { autoAdvanceRef.current++; cal.next(); } else if (initializedRef.current) { setNoSlotsInMonth(true); } }, [fetchedEvents]); useEffect(function () { if (selectedSlotId && prevSlotIdRef.current !== selectedSlotId && formRef.current) { window.rptScrollTo(formRef.current, true); } prevSlotIdRef.current = selectedSlotId; }, [selectedSlotId]); useEffect(function () { if (success && successRef.current) { window.rptScrollTo(successRef.current, true); } }, [success]); /** @param {RiversidePtEvent} slot */ function handleSlotClick(slot) { setSelectedSlotId(slot.id); setSubmitError(null); setSuccess(false); } /** @param {string} field @param {string} value */ function handleFormChange(field, value) { setFormData(function (prev) { return Object.assign({}, prev, { [field]: value }); }); } /** @param {Event} e */ function handleSubmit(e) { e.preventDefault(); var slot = slots.find(function (/** @type {RiversidePtEvent} */ s) { return s.id === selectedSlotId; }); if (!slot) return; // Capture values synchronously; the async callbacks close over these, not the find result. var chosenStart = slot.start; var chosenEnd = slot.end; setSubmitting(true); setSubmitError(null); fetch(settings.storeSlotUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ start: chosenStart, end: chosenEnd, service: service, firstName: formData.firstName, lastName: formData.lastName, email: formData.email, phone: formData.phone, comments: formData.comments, }), }).then(function (res) { if (res.ok) { setSubmitting(false); setSubmitError(null); setConfirmedAppointment({ start: chosenStart, service: service, firstName: formData.firstName, lastName: formData.lastName, email: formData.email, }); 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 { res.json().then(function (/** @type {{message?: string}} */ data) { setSubmitError(data.message || "Something went wrong. Please try again."); }).catch(function () { setSubmitError("Something went wrong. Please try again."); }); } } }).catch(function () { setSubmitting(false); setSubmitError("Something went wrong. Please try again."); }); } var selectedSlot = slots.find(function (/** @type {RiversidePtEvent} */ s) { return s.id === selectedSlotId; }); return html`
No availability this month
Select a time on ${(function () { var p = slots[0] ? slots[0].start.split("T")[0].split("-") : ["","",""]; return parseInt(p[1]) + "/" + parseInt(p[2]) + "/" + p[0]; })()}:
Request received!
${confirmedAppointment.firstName} ${confirmedAppointment.lastName}
${confirmedAppointment.email}
${(TYPES.find(function (/** @type {{id:string,label:string}} */ t) { return t.id === confirmedAppointment.service; }) || {}).label}
${formatAppointmentDate(confirmedAppointment.start)}
We'll contact you shortly to confirm your appointment.
Select Appointment Type