This commit is contained in:
Philip Peterson 2026-06-14 00:38:35 -07:00
parent e7fd102daf
commit 0b3112f81b
13 changed files with 169 additions and 75 deletions

13
node_modules/.package-lock.json generated vendored
View file

@ -952,6 +952,19 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true "dev": true
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

16
package-lock.json generated
View file

@ -8,7 +8,8 @@
"devDependencies": { "devDependencies": {
"htm": "^3.1.1", "htm": "^3.1.1",
"preact": "^10.29.2", "preact": "^10.29.2",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17",
"typescript": "^6.0.3"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@ -960,6 +961,19 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true "dev": true
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View file

@ -8,6 +8,7 @@
"devDependencies": { "devDependencies": {
"htm": "^3.1.1", "htm": "^3.1.1",
"preact": "^10.29.2", "preact": "^10.29.2",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17",
"typescript": "^6.0.3"
} }
} }

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"checkJs": true,
"allowJs": true,
"noEmit": true,
"strict": true,
"lib": ["dom", "es2022"],
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"paths": {
"https://esm.sh/preact@10": ["./node_modules/preact/src/index.d.ts"],
"https://esm.sh/preact@10/hooks": ["./node_modules/preact/hooks/src/index.d.ts"],
"https://esm.sh/htm@3/preact": ["./node_modules/htm/preact/index.d.ts"]
}
},
"include": [
"web/modules/custom/riverside_pt/js/**/*.js",
"web/modules/custom/riverside_pt/js/globals.d.ts"
]
}

View file

@ -16,6 +16,7 @@ const CHECK = html`<svg width="14" height="11" viewBox="0 0 14 11" fill="none" x
function ApptType() { function ApptType() {
const [selected, setSelected] = useState("diagnostic"); const [selected, setSelected] = useState("diagnostic");
/** @param {string} id */
function select(id) { function select(id) {
setSelected(id); setSelected(id);
document.dispatchEvent(new CustomEvent("rpt:appt-type-change", { detail: { type: id } })); document.dispatchEvent(new CustomEvent("rpt:appt-type-change", { detail: { type: id } }));

View file

@ -15,6 +15,7 @@ const CHECK = html`<svg width="14" height="11" viewBox="0 0 14 11" fill="none" x
const EMPTY_FORM = { firstName: "", lastName: "", email: "", phone: "", comments: "" }; const EMPTY_FORM = { firstName: "", lastName: "", email: "", phone: "", comments: "" };
/** @param {string | number} [raw] */
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") {
@ -70,6 +71,7 @@ const CX = {
successNote: "text-sm text-green-700", successNote: "text-sm text-green-700",
}; };
/** @param {Date} d */
function localDateStr(d) { function localDateStr(d) {
return d.getFullYear() + "-" + return d.getFullYear() + "-" +
String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" +
@ -83,12 +85,14 @@ function nextBusinessDay() {
return localDateStr(d); return localDateStr(d);
} }
/** @param {string} startStr */
function slotLabel(startStr) { function slotLabel(startStr) {
var d = new Date(startStr); var d = new Date(startStr);
var h = d.getHours(); var h = d.getHours();
return (h % 12 || 12) + (h < 12 ? "AM" : "PM") + " PST"; return (h % 12 || 12) + (h < 12 ? "AM" : "PM") + " PST";
} }
/** @param {string} startStr */
function formatAppointmentDate(startStr) { function formatAppointmentDate(startStr) {
var parts = startStr.split("T")[0].split("-"); var parts = startStr.split("T")[0].split("-");
var d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); var d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
@ -100,32 +104,35 @@ function formatAppointmentDate(startStr) {
// Keyed by service in the parent, so it always mounts fresh for each service. // 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, // 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). // which means the fetch effect can depend only on dateRange (no stale-service risk).
/**
* @param {{ service: string, settings: RiversidePtSettings }} props
*/
function BookingPanel({ service, settings }) { function BookingPanel({ service, settings }) {
const [dateRange, setDateRange] = useState(null); const [dateRange, setDateRange] = useState(/** @type {string | null} */ (null));
const [fetchedEvents, setFetchedEvents] = useState(null); const [fetchedEvents, setFetchedEvents] = useState(/** @type {RiversidePtEvent[] | null} */ (null));
const [fetchLoading, setFetchLoading] = useState(false); const [fetchLoading, setFetchLoading] = useState(false);
const [slots, setSlots] = useState([]); const [slots, setSlots] = useState(/** @type {RiversidePtEvent[]} */ ([]));
const [selectedSlotId, setSelectedSlotId] = useState(null); const [selectedSlotId, setSelectedSlotId] = useState(/** @type {number | string | null} */ (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(/** @type {string | null} */ (null));
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [confirmedAppointment, setConfirmedAppointment] = useState(null); const [confirmedAppointment, setConfirmedAppointment] = useState(/** @type {{ start: string, service: string, firstName: string, lastName: string, email: string } | null} */ (null));
const [noSlotsInMonth, setNoSlotsInMonth] = useState(false); const [noSlotsInMonth, setNoSlotsInMonth] = useState(false);
const calEl = useRef(null); const calEl = useRef(/** @type {HTMLDivElement | null} */ (null));
const calRef = useRef(null); const calRef = useRef(/** @type {any} */ (null));
const initializedRef = useRef(false); const initializedRef = useRef(false);
const autoAdvanceRef = useRef(0); const autoAdvanceRef = useRef(0);
const fetchAbortRef = useRef(null); const fetchAbortRef = useRef(/** @type {AbortController | null} */ (null));
// Instance-scoped vars for FullCalendar callbacks (no stale-closure risk via .current). // Instance-scoped vars for FullCalendar callbacks (no stale-closure risk via .current).
const selectedDateRef = useRef(null); const selectedDateRef = useRef(/** @type {string | null} */ (null));
const selectedDateSlotsRef = useRef([]); const selectedDateSlotsRef = useRef(/** @type {RiversidePtEvent[]} */ ([]));
const currentEventsRef = useRef([]); const currentEventsRef = useRef(/** @type {RiversidePtEvent[]} */ ([]));
const initDate = useMemo(nextBusinessDay, []); const initDate = useMemo(nextBusinessDay, []);
const formRef = useRef(null); const formRef = useRef(/** @type {HTMLFormElement | null} */ (null));
const prevSlotIdRef = useRef(null); const prevSlotIdRef = useRef(/** @type {number | string | null} */ (null));
const successRef = useRef(null); const successRef = useRef(/** @type {HTMLDivElement | null} */ (null));
function buildEventsUrl() { function buildEventsUrl() {
return settings.eventsUrl + "?service=" + service; return settings.eventsUrl + "?service=" + service;
@ -133,15 +140,19 @@ function BookingPanel({ service, settings }) {
// ── Initialize FullCalendar once ───────────────────────────────────── // ── Initialize FullCalendar once ─────────────────────────────────────
useEffect(function () { useEffect(function () {
if (!calEl.current || !window.FullCalendar) return; var root = calEl.current;
if (!root || !window.FullCalendar) return;
/** @type {HTMLDivElement} */
var rootEl = root;
var cal = new FullCalendar.Calendar(calEl.current, { var FC = window.FullCalendar || FullCalendar;
var cal = new FC.Calendar(root, {
initialView: "dayGridMonth", initialView: "dayGridMonth",
initialDate: initDate, initialDate: initDate,
headerToolbar: { left: "prev", center: "title", right: "next" }, headerToolbar: { left: "prev", center: "title", right: "next" },
titleFormat: { year: "numeric", month: "long" }, titleFormat: { year: "numeric", month: "long" },
dayHeaderFormat: { weekday: "narrow" }, dayHeaderFormat: { weekday: "narrow" },
validRange: function (now) { validRange: function (/** @type {any} */ now) {
return { return {
start: new Date(now.getFullYear(), now.getMonth(), 1), start: new Date(now.getFullYear(), now.getMonth(), 1),
end: new Date(now.getFullYear(), now.getMonth() + 7, 1), end: new Date(now.getFullYear(), now.getMonth() + 7, 1),
@ -153,14 +164,14 @@ function BookingPanel({ service, settings }) {
eventDisplay: "none", eventDisplay: "none",
dayMaxEvents: false, dayMaxEvents: false,
datesSet: function (info) { datesSet: function (/** @type {any} */ info) {
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) { rootEl.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (/** @type {any} */ d) {
d.classList.remove("is-selected"); d.classList.remove("is-selected");
}); });
setSelectedSlotId(null); setSelectedSlotId(null);
setNoSlotsInMonth(false); setNoSlotsInMonth(false);
if (selectedDateRef.current) { if (selectedDateRef.current) {
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + selectedDateRef.current + "\"]"); var dayEl = rootEl.querySelector(".fc-daygrid-day[data-date=\"" + selectedDateRef.current + "\"]");
if (dayEl) { if (dayEl) {
dayEl.classList.add("is-selected"); dayEl.classList.add("is-selected");
setSlots(selectedDateSlotsRef.current); setSlots(selectedDateSlotsRef.current);
@ -173,31 +184,31 @@ function BookingPanel({ service, settings }) {
setDateRange(info.startStr + "/" + info.endStr); setDateRange(info.startStr + "/" + info.endStr);
}, },
eventsSet: function (events) { eventsSet: function (/** @type {any[]} */ events) {
calEl.current.querySelectorAll(".fc-daygrid-day.has-availability").forEach(function (d) { rootEl.querySelectorAll(".fc-daygrid-day.has-availability").forEach(function (/** @type {any} */ d) {
d.classList.remove("has-availability"); d.classList.remove("has-availability");
}); });
events.forEach(function (event) { events.forEach(function (/** @type {any} */ event) {
var dateStr = event.startStr.substring(0, 10); var dateStr = event.startStr.substring(0, 10);
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + dateStr + "\"]"); var dayEl = rootEl.querySelector(".fc-daygrid-day[data-date=\"" + dateStr + "\"]");
if (dayEl) dayEl.classList.add("has-availability"); if (dayEl) dayEl.classList.add("has-availability");
}); });
}, },
dayCellClassNames: function (arg) { dayCellClassNames: function (/** @type {any} */ arg) {
var date = arg.date.toISOString().substring(0, 10); var date = arg.date.toISOString().substring(0, 10);
if (settings.holidays[date]) return ["is-holiday"]; if (settings.holidays[date]) return ["is-holiday"];
}, },
dateClick: function (arg) { dateClick: function (/** @type {any} */ arg) {
if (!arg.dayEl.classList.contains("has-availability")) return; if (!arg.dayEl.classList.contains("has-availability")) return;
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) { rootEl.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (/** @type {any} */ d) {
d.classList.remove("is-selected"); d.classList.remove("is-selected");
}); });
arg.dayEl.classList.add("is-selected"); arg.dayEl.classList.add("is-selected");
var daySlots = currentEventsRef.current var daySlots = currentEventsRef.current
.filter(function (e) { return e.start.startsWith(arg.dateStr); }) .filter(function (/** @type {RiversidePtEvent} */ e) { return e.start.startsWith(arg.dateStr); })
.sort(function (a, b) { return a.start < b.start ? -1 : 1; }); .sort(function (/** @type {RiversidePtEvent} */ a, /** @type {RiversidePtEvent} */ b) { return a.start < b.start ? -1 : 1; });
selectedDateRef.current = arg.dateStr; selectedDateRef.current = arg.dateStr;
selectedDateSlotsRef.current = daySlots; selectedDateSlotsRef.current = daySlots;
setSelectedSlotId(null); setSelectedSlotId(null);
@ -229,12 +240,12 @@ function BookingPanel({ service, settings }) {
setFetchedEvents(null); setFetchedEvents(null);
fetch(url, { signal: controller.signal }) fetch(url, { signal: controller.signal })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { .then(function (/** @type {RiversidePtEvent[]} */ data) {
currentEventsRef.current = data; currentEventsRef.current = data;
setFetchedEvents(data); setFetchedEvents(data);
setFetchLoading(false); setFetchLoading(false);
}) })
.catch(function (err) { .catch(function (/** @type {any} */ err) {
if (err.name === "AbortError") return; if (err.name === "AbortError") return;
currentEventsRef.current = []; currentEventsRef.current = [];
setFetchedEvents([]); setFetchedEvents([]);
@ -254,15 +265,16 @@ function BookingPanel({ service, settings }) {
cal.addEventSource(fetchedEvents); cal.addEventSource(fetchedEvents);
if (!initializedRef.current) { if (!initializedRef.current) {
var dates = [...new Set(fetchedEvents.map(function (e) { return e.start.substring(0, 10); }))].sort(); var dates = [...new Set(fetchedEvents.map(function (/** @type {RiversidePtEvent} */ e) { return e.start.substring(0, 10); }))].sort();
var firstDate = dates[0]; var firstDate = dates[0];
if (firstDate) { if (firstDate) {
var firstSlots = fetchedEvents var firstSlots = fetchedEvents
.filter(function (e) { return e.start.startsWith(firstDate); }) .filter(function (/** @type {RiversidePtEvent} */ e) { return e.start.startsWith(firstDate); })
.sort(function (a, b) { return a.start < b.start ? -1 : 1; }); .sort(function (/** @type {RiversidePtEvent} */ a, /** @type {RiversidePtEvent} */ b) { return a.start < b.start ? -1 : 1; });
selectedDateRef.current = firstDate; selectedDateRef.current = firstDate;
selectedDateSlotsRef.current = firstSlots; selectedDateSlotsRef.current = firstSlots;
var targetEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + firstDate + "\"]"); var root = calEl.current;
var targetEl = root ? root.querySelector(".fc-daygrid-day[data-date=\"" + firstDate + "\"]") : null;
if (targetEl) { if (targetEl) {
initializedRef.current = true; initializedRef.current = true;
autoAdvanceRef.current = 0; autoAdvanceRef.current = 0;
@ -292,28 +304,34 @@ function BookingPanel({ service, settings }) {
} }
}, [success]); }, [success]);
/** @param {RiversidePtEvent} slot */
function handleSlotClick(slot) { function handleSlotClick(slot) {
setSelectedSlotId(slot.id); setSelectedSlotId(slot.id);
setSubmitError(null); setSubmitError(null);
setSuccess(false); setSuccess(false);
} }
/** @param {string} field @param {string} value */
function handleFormChange(field, value) { function handleFormChange(field, value) {
setFormData(function (prev) { return Object.assign({}, prev, { [field]: value }); }); setFormData(function (prev) { return Object.assign({}, prev, { [field]: value }); });
} }
/** @param {Event} e */
function handleSubmit(e) { function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
var slot = slots.find(function (s) { return s.id === selectedSlotId; }); var slot = slots.find(function (/** @type {RiversidePtEvent} */ s) { return s.id === selectedSlotId; });
if (!slot) return; 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); setSubmitting(true);
setSubmitError(null); setSubmitError(null);
fetch(settings.storeSlotUrl, { fetch(settings.storeSlotUrl, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
start: slot.start, start: chosenStart,
end: slot.end, end: chosenEnd,
service: service, service: service,
firstName: formData.firstName, firstName: formData.firstName,
lastName: formData.lastName, lastName: formData.lastName,
@ -326,7 +344,7 @@ function BookingPanel({ service, settings }) {
setSubmitting(false); setSubmitting(false);
setSubmitError(null); setSubmitError(null);
setConfirmedAppointment({ setConfirmedAppointment({
start: slot.start, start: chosenStart,
service: service, service: service,
firstName: formData.firstName, firstName: formData.firstName,
lastName: formData.lastName, lastName: formData.lastName,
@ -340,7 +358,7 @@ function BookingPanel({ service, settings }) {
if (res.status === 422) { if (res.status === 422) {
setSubmitError("That slot was just booked. Please choose another time."); setSubmitError("That slot was just booked. Please choose another time.");
} else { } else {
res.json().then(function (data) { res.json().then(function (/** @type {{message?: string}} */ data) {
setSubmitError(data.message || "Something went wrong. Please try again."); setSubmitError(data.message || "Something went wrong. Please try again.");
}).catch(function () { }).catch(function () {
setSubmitError("Something went wrong. Please try again."); setSubmitError("Something went wrong. Please try again.");
@ -353,7 +371,7 @@ function BookingPanel({ service, settings }) {
}); });
} }
var selectedSlot = slots.find(function (s) { return s.id === selectedSlotId; }); var selectedSlot = slots.find(function (/** @type {RiversidePtEvent} */ s) { return s.id === selectedSlotId; });
return html` return html`
<div> <div>
@ -372,11 +390,11 @@ function BookingPanel({ service, settings }) {
${slots.length > 0 ? html` ${slots.length > 0 ? html`
<div id="riverside-slots-wrap"> <div id="riverside-slots-wrap">
<p class="text-xs text-gray-500 mb-3">Select a time on ${(function () { <p class="text-xs text-gray-500 mb-3">Select a time on ${(function () {
var p = slots[0].start.split("T")[0].split("-"); var p = slots[0] ? slots[0].start.split("T")[0].split("-") : ["","",""];
return parseInt(p[1]) + "/" + parseInt(p[2]) + "/" + p[0]; return parseInt(p[1]) + "/" + parseInt(p[2]) + "/" + p[0];
})()}:</p> })()}:</p>
<div id="riverside-booking-slots"> <div id="riverside-booking-slots">
${slots.map(function (slot) { ${slots.map(function (/** @type {RiversidePtEvent} */ slot) {
return html` return html`
<button <button
key=${slot.id} key=${slot.id}
@ -407,7 +425,7 @@ function BookingPanel({ service, settings }) {
autocomplete="given-name" autocomplete="given-name"
required required
value=${formData.firstName} value=${formData.firstName}
onInput=${function (e) { handleFormChange("firstName", e.target.value); }} onInput=${function (/** @type {any} */ e) { handleFormChange("firstName", e.target.value); }}
class=${CX.formInput} class=${CX.formInput}
/> />
</div> </div>
@ -422,7 +440,7 @@ function BookingPanel({ service, settings }) {
autocomplete="family-name" autocomplete="family-name"
required required
value=${formData.lastName} value=${formData.lastName}
onInput=${function (e) { handleFormChange("lastName", e.target.value); }} onInput=${function (/** @type {any} */ e) { handleFormChange("lastName", e.target.value); }}
class=${CX.formInput} class=${CX.formInput}
/> />
</div> </div>
@ -437,7 +455,7 @@ function BookingPanel({ service, settings }) {
autocomplete="email" autocomplete="email"
required required
value=${formData.email} value=${formData.email}
onInput=${function (e) { handleFormChange("email", e.target.value); }} onInput=${function (/** @type {any} */ e) { handleFormChange("email", e.target.value); }}
class=${CX.formInput} class=${CX.formInput}
/> />
</div> </div>
@ -452,7 +470,7 @@ function BookingPanel({ service, settings }) {
autocomplete="tel" autocomplete="tel"
required required
value=${formatPhone(formData.phone)} value=${formatPhone(formData.phone)}
onInput=${function (e) { onInput=${function (/** @type {any} */ e) {
handleFormChange("phone", formatPhone(e.target.value)); handleFormChange("phone", formatPhone(e.target.value));
}} }}
class=${CX.formInput} class=${CX.formInput}
@ -470,7 +488,7 @@ function BookingPanel({ service, settings }) {
name="comments" name="comments"
autocomplete="off" autocomplete="off"
value=${formData.comments} value=${formData.comments}
onInput=${function (e) { handleFormChange("comments", e.target.value); }} onInput=${function (/** @type {any} */ e) { handleFormChange("comments", e.target.value); }}
class=${CX.formTextarea} class=${CX.formTextarea}
></textarea> ></textarea>
</div> </div>
@ -491,7 +509,7 @@ function BookingPanel({ service, settings }) {
<div class=${CX.successSummary}> <div class=${CX.successSummary}>
<p>${confirmedAppointment.firstName} ${confirmedAppointment.lastName}</p> <p>${confirmedAppointment.firstName} ${confirmedAppointment.lastName}</p>
<p>${confirmedAppointment.email}</p> <p>${confirmedAppointment.email}</p>
<p>${TYPES.find(function (t) { return t.id === confirmedAppointment.service; }).label}</p> <p>${(TYPES.find(function (/** @type {{id:string,label:string}} */ t) { return t.id === confirmedAppointment.service; }) || {}).label}</p>
<p>${formatAppointmentDate(confirmedAppointment.start)}</p> <p>${formatAppointmentDate(confirmedAppointment.start)}</p>
</div> </div>
<p class=${CX.successNote}>We'll contact you shortly to confirm your appointment.</p> <p class=${CX.successNote}>We'll contact you shortly to confirm your appointment.</p>
@ -506,8 +524,11 @@ function BookingPanel({ service, settings }) {
// Owns service selection and the type selector UI. Keys BookingPanel by // Owns service selection and the type selector UI. Keys BookingPanel by
// service so it mounts fresh on every change — no reset effects, no races. // service so it mounts fresh on every change — no reset effects, no races.
// Starts with null so no service is pre-selected. // Starts with null so no service is pre-selected.
/**
* @param {{ settings: RiversidePtSettings }} props
*/
function Booking({ settings }) { function Booking({ settings }) {
const [service, setService] = useState(null); const [service, setService] = useState(/** @type {string | null} */ (null));
return html` return html`
<div style="min-height:460px"> <div style="min-height:460px">
@ -519,7 +540,7 @@ function Booking({ settings }) {
<button <button
key=${t.id} key=${t.id}
onClick=${function () { onClick=${function () {
setService(t.id); setService(/** @type {string} */ (t.id));
}} }}
style="text-align:left; cursor:pointer;" style="text-align:left; cursor:pointer;"
class=${CX.typeBtn + " " + (active ? CX.typeBtnActive : CX.typeBtnInactive)} class=${CX.typeBtn + " " + (active ? CX.typeBtnActive : CX.typeBtnInactive)}
@ -548,7 +569,8 @@ function Booking({ settings }) {
class RptBooking extends HTMLElement { class RptBooking extends HTMLElement {
connectedCallback() { connectedCallback() {
render(html`<${Booking} settings=${window.drupalSettings.riversidePt} />`, this); var settings = (window.drupalSettings && window.drupalSettings.riversidePt) || /** @type {RiversidePtSettings} */ ({ eventsUrl: "", storeSlotUrl: "", holidays: {} });
render(html`<${Booking} settings=${settings} />`, this);
} }
disconnectedCallback() { disconnectedCallback() {
render(null, this); render(null, this);

View file

@ -12,7 +12,7 @@ const IMAGES = [
function Carousel() { function Carousel() {
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [itemWidth, setItemWidth] = useState(0); const [itemWidth, setItemWidth] = useState(0);
const containerRef = useRef(null); const containerRef = useRef(/** @type {HTMLDivElement | null} */ (null));
useLayoutEffect(() => { useLayoutEffect(() => {
const update = () => { const update = () => {

View file

@ -29,6 +29,9 @@ const FAQS = [
}, },
]; ];
/**
* @param {{ item: {q: string, a: string}, open: boolean, onToggle: () => void }} props
*/
function FaqItem({ item, open, onToggle }) { function FaqItem({ item, open, onToggle }) {
return html` return html`
<div class="border-b border-gray-200"> <div class="border-b border-gray-200">
@ -50,9 +53,9 @@ function FaqItem({ item, open, onToggle }) {
} }
function Faq() { function Faq() {
const [openIndex, setOpenIndex] = useState(null); const [openIndex, setOpenIndex] = useState(/** @type {number | null} */ (null));
const toggle = function(i) { const toggle = function(/** @type {number} */ i) {
setOpenIndex(function(prev) { return prev === i ? null : i; }); setOpenIndex(function(prev) { return prev === i ? null : i; });
}; };

View file

@ -35,10 +35,10 @@ const STEP = CARD_W + GAP;
const TOTAL_W = TESTIMONIALS.length * CARD_W + (TESTIMONIALS.length - 1) * GAP; const TOTAL_W = TESTIMONIALS.length * CARD_W + (TESTIMONIALS.length - 1) * GAP;
function Testimonials() { function Testimonials() {
const containerRef = useRef(null); const containerRef = useRef(/** @type {HTMLDivElement | null} */ (null));
const trackRef = useRef(null); const trackRef = useRef(/** @type {HTMLDivElement | null} */ (null));
const [left, setLeft] = useState(0); const [left, setLeft] = useState(0);
const [, forceUpdate] = useReducer(function (n) { return n + 1; }, 0); const [, forceUpdate] = useReducer(/** @type {(n: number, action?: any) => number} */ (function (n) { return n + 1; }), 0);
function measureMax() { function measureMax() {
if (!containerRef.current) return 0; if (!containerRef.current) return 0;
@ -55,13 +55,14 @@ function Testimonials() {
}; };
useEffect(function () { useEffect(function () {
/** @type {number | undefined} */
var timer; var timer;
function onResize() { function onResize() {
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(function () { timer = setTimeout(function () {
var max = measureMax(); var max = measureMax();
setLeft(function (l) { return Math.min(0, Math.max(-max, l)); }); setLeft(function (l) { return Math.min(0, Math.max(-max, l)); });
forceUpdate(); forceUpdate(0);
}, 150); }, 150);
} }
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
@ -71,23 +72,26 @@ function Testimonials() {
}; };
}, []); }, []);
var drag = useRef(null); // null when idle, {x, left} when dragging var drag = useRef(/** @type {{x: number, left: number} | null} */ (null)); // null when idle, {x, left} when dragging
/** @param {PointerEvent} e */
var onPointerDown = function (e) { var onPointerDown = function (e) {
drag.current = { x: e.clientX, left: left }; drag.current = { x: e.clientX, left: left };
trackRef.current.style.transition = "none"; if (trackRef.current) trackRef.current.style.transition = "none";
e.currentTarget.setPointerCapture(e.pointerId); if (e.currentTarget instanceof Element) e.currentTarget.setPointerCapture(e.pointerId);
}; };
/** @param {PointerEvent} e */
var onPointerMove = function (e) { var onPointerMove = function (e) {
if (!drag.current) return; if (!drag.current) return;
setLeft(drag.current.left + (e.clientX - drag.current.x)); setLeft(drag.current.left + (e.clientX - drag.current.x));
}; };
/** @param {PointerEvent} e */
var onPointerUp = function (e) { var onPointerUp = function (e) {
if (!drag.current) return; if (!drag.current) return;
drag.current = null; drag.current = null;
trackRef.current.style.transition = "left 0.5s ease"; if (trackRef.current) trackRef.current.style.transition = "left 0.5s ease";
var max = measureMax(); var max = measureMax();
setLeft(function (l) { setLeft(function (l) {
var clamped = Math.min(0, Math.max(-max, l)); var clamped = Math.min(0, Math.max(-max, l));

View file

@ -1,14 +1,25 @@
interface RiversidePtSettings { interface RiversidePtSettings {
eventsUrl: string; eventsUrl: string;
storeSlotUrl: string; storeSlotUrl: string;
bookingUrl: string; bookingUrl?: string;
holidays: Record<string, boolean>; holidays: Record<string, boolean>;
scrollTo?: string; scrollTo?: string;
} }
interface RiversidePtEvent {
id: number | string;
title?: string;
start: string;
end: string;
startStr?: string;
}
interface Window { interface Window {
FullCalendar?: any;
rptScrollTo: (el: Element, animate?: boolean) => void; rptScrollTo: (el: Element, animate?: boolean) => void;
drupalSettings?: { drupalSettings?: {
riversidePt?: RiversidePtSettings; riversidePt?: RiversidePtSettings;
}; };
} }
declare const FullCalendar: any;

View file

@ -2,8 +2,8 @@
'use strict'; 'use strict';
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var btn = document.querySelector('.rpt-header__hamburger'); const btn = document.querySelector('.rpt-header__hamburger');
var nav = document.getElementById('rpt-main-nav'); const nav = document.getElementById('rpt-main-nav');
if (!btn || !nav) return; if (!btn || !nav) return;
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
@ -20,7 +20,7 @@
}); });
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
if (!e.target.closest('.rpt-header')) { if (!(e.target instanceof Element) || !e.target.closest('.rpt-header')) {
nav.classList.remove('is-open'); nav.classList.remove('is-open');
btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-expanded', 'false');
} }

View file

@ -1,4 +1,5 @@
(function () { (function () {
/** @param {unknown} raw */
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") {
@ -13,6 +14,7 @@
return "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6); return "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6);
} }
/** @param {HTMLInputElement} input */
function enhancePhoneInput(input) { function enhancePhoneInput(input) {
if (input.dataset.phoneEnhanced) return; if (input.dataset.phoneEnhanced) return;
input.dataset.phoneEnhanced = "true"; input.dataset.phoneEnhanced = "true";
@ -55,7 +57,7 @@
} }
function scan() { function scan() {
document.querySelectorAll("input.rpt-phone").forEach(enhancePhoneInput); /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll("input.rpt-phone")).forEach(enhancePhoneInput);
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {

View file

@ -4,7 +4,7 @@ var FIXED_BUFFER = 0; // breathing room below fixed header when menu is closed
// When the hamburger is open, offsetHeight already includes the expanded nav, // When the hamburger is open, offsetHeight already includes the expanded nav,
// so no extra buffer is needed. When closed, add FIXED_BUFFER for breathing room. // so no extra buffer is needed. When closed, add FIXED_BUFFER for breathing room.
function headerOffset() { function headerOffset() {
var header = document.querySelector(".rpt-header"); var header = /** @type {HTMLElement | null} */ (document.querySelector(".rpt-header"));
if (!header) return 0; if (!header) return 0;
var pos = window.getComputedStyle(header).position; var pos = window.getComputedStyle(header).position;
if (pos !== "fixed" && pos !== "sticky") return 0; if (pos !== "fixed" && pos !== "sticky") return 0;
@ -13,6 +13,7 @@ function headerOffset() {
return header.offsetHeight + (menuOpen ? 0 : FIXED_BUFFER); return header.offsetHeight + (menuOpen ? 0 : FIXED_BUFFER);
} }
/** @param {Element} el @param {boolean} [animate] */
function scrollToEl(el, animate) { function scrollToEl(el, animate) {
var top = Math.max(0, el.getBoundingClientRect().top + window.scrollY - headerOffset()); var top = Math.max(0, el.getBoundingClientRect().top + window.scrollY - headerOffset());
window.scrollTo({ top: top, behavior: animate ? "smooth" : "instant" }); window.scrollTo({ top: top, behavior: animate ? "smooth" : "instant" });
@ -26,7 +27,7 @@ document.addEventListener("DOMContentLoaded", function () {
var settings = window.drupalSettings && window.drupalSettings.riversidePt; var settings = window.drupalSettings && window.drupalSettings.riversidePt;
var anchor = window.location.hash || (settings && settings.scrollTo); var anchor = window.location.hash || (settings && settings.scrollTo);
if (!anchor) return; if (!anchor) return;
var target = document.querySelector(anchor); const target = document.querySelector(anchor);
if (!target) return; if (!target) return;
requestAnimationFrame(function () { requestAnimationFrame(function () {
scrollToEl(target, false); scrollToEl(target, false);
@ -34,9 +35,10 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
var link = e.target.closest("[data-scroll-to]"); if (!(e.target instanceof Element)) return;
var link = /** @type {HTMLElement | null} */ (e.target.closest("[data-scroll-to]"));
if (!link) return; if (!link) return;
var target = document.querySelector(link.dataset.scrollTo); var target = document.querySelector(link.dataset.scrollTo || "");
if (!target) return; if (!target) return;
e.preventDefault(); e.preventDefault();
history.pushState({}, "", link.getAttribute("href")); history.pushState({}, "", link.getAttribute("href"));