Service-aware booking: selector drives calendar, inline request form

- Merge rpt-appt-type + calendar into unified rpt-booking Preact component;
  service state drives event source via useEffect, no DOM event bus
- Each service has distinct availability (different slot density, start hours);
  surgical rehab only available 46+ days out
- Slot click reveals inline Last name / Phone / Comments form; submits all
  fields together to storeSlot rather than redirecting immediately
- Fix nextBusinessDay() timezone bug (toISOString is UTC; use local date
  components instead); pre-select first available day >= next business day
- Today always has no availability (backend now starts from tomorrow)
- Replace all raw hex colour values with named palette tokens throughout
  templates and JS components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-06-03 19:39:35 -07:00
parent 2f624f73ba
commit b000b824ed
7 changed files with 468 additions and 132 deletions

View file

@ -729,6 +729,18 @@ html {
margin-top: 2vw; margin-top: 2vw;
} }
.mb-1{
margin-bottom: 0.25rem;
}
.mb-6{
margin-bottom: 1.5rem;
}
.mt-8{
margin-top: 2rem;
}
.block{ .block{
display: block; display: block;
} }
@ -882,6 +894,10 @@ html {
cursor: pointer; cursor: pointer;
} }
.resize-none{
resize: none;
}
.resize{ .resize{
resize: both; resize: both;
} }
@ -971,6 +987,10 @@ html {
row-gap: 0px; row-gap: 0px;
} }
.gap-y-5{
row-gap: 1.25rem;
}
.self-end{ .self-end{
align-self: flex-end; align-self: flex-end;
} }
@ -1027,18 +1047,6 @@ html {
border-top-width: 1px; border-top-width: 1px;
} }
.border-\[pt-blue-200\]{
border-color: pt-blue-200;
}
.border-\[pt-blue-500\]{
border-color: pt-blue-500;
}
.border-\[pt-navy\]{
border-color: pt-navy;
}
.border-gray-200{ .border-gray-200{
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1)); border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
@ -1049,25 +1057,16 @@ html {
border-color: rgb(209 213 219 / var(--tw-border-opacity, 1)); border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
} }
.border-pt-blue-300{
--tw-border-opacity: 1;
border-color: rgb(157 189 203 / var(--tw-border-opacity, 1));
}
.border-white{
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity, 1));
}
.border-white\/60{
border-color: rgb(255 255 255 / 0.6);
}
.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));
} }
.border-pt-blue-300{
--tw-border-opacity: 1;
border-color: rgb(157 189 203 / var(--tw-border-opacity, 1));
}
.border-pt-blue-500{ .border-pt-blue-500{
--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));
@ -1078,44 +1077,29 @@ html {
border-color: rgb(30 58 95 / var(--tw-border-opacity, 1)); border-color: rgb(30 58 95 / var(--tw-border-opacity, 1));
} }
.bg-\[pt-blue-100\]{ .border-white{
background-color: pt-blue-100; --tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity, 1));
} }
.bg-\[pt-blue-400\]{ .border-white\/60{
background-color: pt-blue-400; border-color: rgb(255 255 255 / 0.6);
}
.bg-\[pt-blue-500\]{
background-color: pt-blue-500;
}
.bg-\[pt-navy\]{
background-color: pt-navy;
} }
.bg-current{ .bg-current{
background-color: currentColor; background-color: currentColor;
} }
.bg-transparent{
background-color: transparent;
}
.bg-white{
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
}
.bg-white\/90{
background-color: rgb(255 255 255 / 0.9);
}
.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));
} }
.bg-pt-blue-300{
--tw-bg-opacity: 1;
background-color: rgb(157 189 203 / var(--tw-bg-opacity, 1));
}
.bg-pt-blue-400{ .bg-pt-blue-400{
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(134 170 182 / var(--tw-bg-opacity, 1)); background-color: rgb(134 170 182 / var(--tw-bg-opacity, 1));
@ -1131,11 +1115,6 @@ html {
background-color: rgb(30 58 95 / var(--tw-bg-opacity, 1)); background-color: rgb(30 58 95 / var(--tw-bg-opacity, 1));
} }
.bg-pt-blue-300{
--tw-bg-opacity: 1;
background-color: rgb(157 189 203 / var(--tw-bg-opacity, 1));
}
.bg-pt-sage-400{ .bg-pt-sage-400{
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(131 161 161 / var(--tw-bg-opacity, 1)); background-color: rgb(131 161 161 / var(--tw-bg-opacity, 1));
@ -1146,6 +1125,19 @@ html {
background-color: rgb(111 143 150 / var(--tw-bg-opacity, 1)); background-color: rgb(111 143 150 / var(--tw-bg-opacity, 1));
} }
.bg-transparent{
background-color: transparent;
}
.bg-white{
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
}
.bg-white\/90{
background-color: rgb(255 255 255 / 0.9);
}
.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));
} }
@ -1264,6 +1256,11 @@ 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;
} }
@ -1288,6 +1285,10 @@ html {
padding-top: 1px; padding-top: 1px;
} }
.pt-8{
padding-top: 2rem;
}
.text-left{ .text-left{
text-align: left; text-align: left;
} }
@ -1430,13 +1431,8 @@ html {
letter-spacing: 0.1em; letter-spacing: 0.1em;
} }
.text-\[\#1e3a8a\]{ .text-\[blue-900\]{
--tw-text-opacity: 1; color: blue-900;
color: rgb(30 58 138 / var(--tw-text-opacity, 1));
}
.text-\[pt-blue-500\]{
color: pt-blue-500;
} }
.text-gray-400{ .text-gray-400{
@ -1469,6 +1465,11 @@ html {
color: rgb(17 24 39 / var(--tw-text-opacity, 1)); color: rgb(17 24 39 / var(--tw-text-opacity, 1));
} }
.text-pt-blue-500{
--tw-text-opacity: 1;
color: rgb(48 111 142 / 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));
@ -1482,13 +1483,9 @@ html {
color: rgb(255 255 255 / 0.8); color: rgb(255 255 255 / 0.8);
} }
.text-\[blue-900\]{ .text-red-500{
color: blue-900;
}
.text-pt-blue-500{
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(48 111 142 / var(--tw-text-opacity, 1)); color: rgb(239 68 68 / var(--tw-text-opacity, 1));
} }
.no-underline{ .no-underline{
@ -1507,6 +1504,10 @@ html {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.filter{
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.transition{ .transition{
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
@ -1545,42 +1546,18 @@ html {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
} }
.\[text-shadow\:-56\.21px_2\.55px_10\.22px_\#0000001A\]{
text-shadow: -56.21px 2.55px 10.22px #0000001A;
}
.\[text-shadow\:-56\.21px_2\.55px_10\.22px_rgb\(0_0_0\/10\%\)\]{ .\[text-shadow\:-56\.21px_2\.55px_10\.22px_rgb\(0_0_0\/10\%\)\]{
text-shadow: -56.21px 2.55px 10.22px rgb(0 0 0/10%); text-shadow: -56.21px 2.55px 10.22px rgb(0 0 0/10%);
} }
.hover\:border-\[pt-blue-500\]:hover{
border-color: pt-blue-500;
}
.hover\:border-\[pt-blue-600\]:hover{
border-color: pt-blue-600;
}
.hover\:border-pt-blue-600:hover{
--tw-border-opacity: 1;
border-color: rgb(31 90 110 / var(--tw-border-opacity, 1));
}
.hover\:border-pt-blue-500:hover{ .hover\:border-pt-blue-500:hover{
--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));
} }
.hover\:bg-\[pt-blue-50\]:hover{ .hover\:border-pt-blue-600:hover{
background-color: pt-blue-50; --tw-border-opacity: 1;
} border-color: rgb(31 90 110 / var(--tw-border-opacity, 1));
.hover\:bg-\[pt-blue-600\]:hover{
background-color: pt-blue-600;
}
.hover\:bg-\[pt-sage-500\]:hover{
background-color: pt-sage-500;
} }
.hover\:bg-gray-100:hover{ .hover\:bg-gray-100:hover{
@ -1603,21 +1580,6 @@ html {
background-color: rgb(111 143 150 / var(--tw-bg-opacity, 1)); background-color: rgb(111 143 150 / var(--tw-bg-opacity, 1));
} }
.hover\:text-\[\#1e3a8a\]:hover{
--tw-text-opacity: 1;
color: rgb(30 58 138 / var(--tw-text-opacity, 1));
}
.hover\:text-\[\#285a6e\]:hover{
--tw-text-opacity: 1;
color: rgb(40 90 110 / var(--tw-text-opacity, 1));
}
.hover\:text-white:hover{
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.hover\:text-\[blue-900\]:hover{ .hover\:text-\[blue-900\]:hover{
color: blue-900; color: blue-900;
} }
@ -1626,10 +1588,29 @@ html {
color: pt-blue-600; color: pt-blue-600;
} }
.hover\:text-white:hover{
--tw-text-opacity: 1;
color: rgb(255 255 255 / 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));
}
.focus\:outline-none:focus{
outline: 2px solid transparent;
outline-offset: 2px;
}
.disabled\:opacity-30:disabled{ .disabled\:opacity-30:disabled{
opacity: 0.3; opacity: 0.3;
} }
.disabled\:opacity-50:disabled{
opacity: 0.5;
}
@media not all and (min-width: 768px){ @media not all and (min-width: 768px){
.max-sm\:text-sm{ .max-sm\:text-sm{
font-size: 0.875rem; font-size: 0.875rem;
@ -1844,10 +1825,6 @@ html {
justify-content: center; justify-content: center;
} }
.\32xl\:bg-\[pt-blue-400\]{
background-color: pt-blue-400;
}
.\32xl\:bg-pt-blue-400{ .\32xl\:bg-pt-blue-400{
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(134 170 182 / var(--tw-bg-opacity, 1)); background-color: rgb(134 170 182 / var(--tw-bg-opacity, 1));

View file

@ -9,12 +9,23 @@
var selectedDate = null; var selectedDate = null;
var initialized = false; var initialized = false;
var currentService = 'diagnostic';
function buildEventsUrl(service) {
return drupalSettings.riversidePt.eventsUrl + '?service=' + service;
}
function localDateStr(d) {
return d.getFullYear() + "-" +
String(d.getMonth() + 1).padStart(2, "0") + "-" +
String(d.getDate()).padStart(2, "0");
}
function nextBusinessDay() { function nextBusinessDay() {
var d = new Date(); var d = new Date();
d.setDate(d.getDate() + 1); d.setDate(d.getDate() + 1);
while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1); while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1);
return d.toISOString().substring(0, 10); return localDateStr(d);
} }
var initDate = nextBusinessDay(); var initDate = nextBusinessDay();
@ -45,7 +56,7 @@
fetch(drupalSettings.riversidePt.storeSlotUrl, { fetch(drupalSettings.riversidePt.storeSlotUrl, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start: event.startStr, end: event.endStr }), body: JSON.stringify({ start: event.startStr, end: event.endStr, service: currentService }),
}).then(function (res) { }).then(function (res) {
if (res.ok) { if (res.ok) {
window.location.href = drupalSettings.riversidePt.bookingUrl; window.location.href = drupalSettings.riversidePt.bookingUrl;
@ -85,7 +96,7 @@
}, },
fixedWeekCount: false, fixedWeekCount: false,
height: 'auto', height: 'auto',
events: drupalSettings.riversidePt.eventsUrl, events: buildEventsUrl(currentService),
eventDisplay: 'none', eventDisplay: 'none',
dayMaxEvents: false, dayMaxEvents: false,

View file

@ -16,6 +16,11 @@ 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");
function select(id) {
setSelected(id);
document.dispatchEvent(new CustomEvent("rpt:appt-type-change", { detail: { type: id } }));
}
return html` return html`
<div> <div>
<p class="text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-5">Select Appointment Type</p> <p class="text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-5">Select Appointment Type</p>
@ -25,7 +30,7 @@ function ApptType() {
return html` return html`
<button <button
key=${t.id} key=${t.id}
onClick=${function () { setSelected(t.id); }} onClick=${function () { select(t.id); }}
style="text-align:left; cursor:pointer;" style="text-align:left; cursor:pointer;"
class=${ class=${
"flex items-center gap-4 p-4 w-full rounded-xl border transition-colors " + "flex items-center gap-4 p-4 w-full rounded-xl border transition-colors " +

View file

@ -0,0 +1,330 @@
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`<svg width="14" height="11" viewBox="0 0 14 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="1,5.5 5,9.5 13,1" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
const EMPTY_FORM = { lastName: "", phone: "", comments: "" };
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);
}
function slotLabel(date) {
var h = date.getHours();
return (h % 12 || 12) + (h < 12 ? "AM" : "PM") + " PST";
}
function Booking({ settings }) {
const [service, setService] = useState("diagnostic");
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 calEl = useRef(null);
const calRef = useRef(null);
const initializedRef = useRef(false);
const prevServiceRef = useRef(null);
const initDate = useMemo(nextBusinessDay, []);
function buildEventsUrl(svc) {
return settings.eventsUrl + "?service=" + svc;
}
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,
headerToolbar: { left: "prev", center: "title", right: "next" },
titleFormat: { year: "numeric", month: "long" },
dayHeaderFormat: { weekday: "narrow" },
validRange: function (now) {
return {
start: new Date(now.getFullYear(), now.getMonth(), 1),
end: new Date(now.getFullYear(), now.getMonth() + 7, 1),
};
},
fixedWeekCount: false,
height: "auto",
eventDisplay: "none",
dayMaxEvents: false,
datesSet: function () {
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) {
d.classList.remove("is-selected");
});
setSlots([]);
setSelectedSlotId(null);
},
eventsSet: function (events) {
markDays(events);
if (!initializedRef.current) {
initializedRef.current = true;
var dates = [...new Set(events.map(function (e) { return e.startStr.substring(0, 10); }))]
.filter(function (d) { return d >= initDate; })
.sort();
var firstDate = dates[0];
if (firstDate) {
var targetEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + firstDate + "\"]");
if (targetEl) {
targetEl.classList.add("is-selected");
setSlots(
events
.filter(function (e) { return e.startStr.startsWith(firstDate); })
.sort(function (a, b) { return a.start - b.start; })
);
}
}
}
},
dayCellClassNames: function (arg) {
var date = arg.date.toISOString().substring(0, 10);
if (settings.holidays[date]) return ["is-holiday"];
},
dateClick: function (arg) {
if (!arg.dayEl.classList.contains("has-availability")) return;
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) {
d.classList.remove("is-selected");
});
arg.dayEl.classList.add("is-selected");
setSelectedSlotId(null);
setSubmitError(null);
setSlots(
cal.getEvents()
.filter(function (e) { return e.startStr.startsWith(arg.dateStr); })
.sort(function (a, b) { return a.start - b.start; })
);
},
});
cal.render();
calRef.current = cal;
return function () { cal.destroy(); };
}, []);
useEffect(function () {
var cal = calRef.current;
if (!cal) return;
var isInitial = prevServiceRef.current === null;
prevServiceRef.current = service;
if (!isInitial) {
initializedRef.current = false;
setSlots([]);
setSelectedSlotId(null);
setFormData(EMPTY_FORM);
setSubmitError(null);
cal.gotoDate(initDate);
}
cal.removeAllEventSources();
cal.addEventSource(buildEventsUrl(service));
}, [service]);
function handleSlotClick(slot) {
setSelectedSlotId(slot.id);
setSubmitError(null);
}
function handleFormChange(field, value) {
setFormData(function (prev) { return Object.assign({}, prev, { [field]: value }); });
}
function handleSubmit(e) {
e.preventDefault();
var slot = slots.find(function (s) { return s.id === selectedSlotId; });
if (!slot) return;
setSubmitting(true);
setSubmitError(null);
fetch(settings.storeSlotUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
start: slot.startStr,
end: slot.endStr,
service: service,
lastName: formData.lastName,
phone: formData.phone,
comments: formData.comments,
}),
}).then(function (res) {
if (res.ok) {
window.location.href = settings.bookingUrl;
} else {
setSubmitting(false);
setSubmitError("Something went wrong. Please try again.");
}
}).catch(function () {
setSubmitting(false);
setSubmitError("Something went wrong. Please try again.");
});
}
var selectedSlot = slots.find(function (s) { return s.id === selectedSlotId; });
var inputClass = "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";
var labelClass = "block text-sm font-medium text-gray-700 mb-1";
return html`
<div>
<p class="text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-5">Select Appointment Type</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-10">
${TYPES.map(function (t) {
var active = service === t.id;
return html`
<button
key=${t.id}
onClick=${function () { setService(t.id); }}
style="text-align:left; cursor:pointer;"
class=${
"flex items-center gap-4 p-4 w-full rounded-xl border transition-colors " +
(active ? "bg-pt-blue-500 border-pt-blue-500" : "bg-white border-pt-blue-200 hover:border-pt-blue-500")
}
>
<div class=${
"w-8 h-8 rounded-full shrink-0 flex items-center justify-center border " +
(active ? "border-white/60" : "border-pt-blue-200")
}>
${active ? CHECK : null}
</div>
<div>
<p class=${"font-serif text-[1.0625rem] font-normal leading-snug " + (active ? "text-white" : "text-gray-900")}>
${t.label}
</p>
<p class=${"text-[0.6875rem] tracking-widest font-semibold mt-0.5 " + (active ? "text-white/70" : "text-pt-blue-500")}>
${t.duration}
</p>
</div>
</button>
`;
})}
</div>
<div class="riverside-booking-wrap">
<div ref=${calEl} id="riverside-calendar"></div>
${slots.length > 0 ? html`
<div id="riverside-slots-wrap">
<div id="riverside-booking-slots">
${slots.map(function (slot) {
return html`
<button
key=${slot.id}
type="button"
onClick=${function () { handleSlotClick(slot); }}
class=${"riverside-slot-btn" + (selectedSlotId === slot.id ? " is-selected" : "")}
>${slotLabel(slot.start)}</button>
`;
})}
</div>
</div>
` : null}
</div>
${selectedSlot ? html`
<form
onSubmit=${handleSubmit}
class="mt-8 pt-8 border-t border-pt-blue-200"
>
<p class="text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-6">
Your Details
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-5 mb-5">
<div>
<label class=${labelClass}>
Last name <span class="text-red-500">*</span>
</label>
<input
type="text"
required
value=${formData.lastName}
onInput=${function (e) { handleFormChange("lastName", e.target.value); }}
class=${inputClass}
/>
</div>
<div>
<label class=${labelClass}>
Phone number <span class="text-red-500">*</span>
</label>
<input
type="tel"
required
value=${formData.phone}
onInput=${function (e) { handleFormChange("phone", e.target.value); }}
class=${inputClass}
/>
</div>
</div>
<div class="mb-6">
<label class=${labelClass}>Comments</label>
<textarea
rows="4"
value=${formData.comments}
onInput=${function (e) { handleFormChange("comments", e.target.value); }}
class=${"resize-none " + inputClass}
></textarea>
</div>
${submitError ? html`<p class="text-red-500 text-sm mb-4">${submitError}</p>` : null}
<button
type="submit"
disabled=${submitting}
class="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"
>
${submitting ? "Submitting…" : "Request appointment"}
</button>
</form>
` : null}
</div>
`;
}
class RptBooking extends HTMLElement {
connectedCallback() {
render(html`<${Booking} settings=${window.drupalSettings.riversidePt} />`, this);
}
disconnectedCallback() {
render(null, this);
}
}
customElements.define("rpt-booking", RptBooking);

View file

@ -8,7 +8,6 @@ app:
js/components/rpt-carousel.js: { attributes: { type: module } } js/components/rpt-carousel.js: { attributes: { type: module } }
js/components/rpt-testimonials.js: { attributes: { type: module } } js/components/rpt-testimonials.js: { attributes: { type: module } }
js/components/rpt-faq.js: { attributes: { type: module } } js/components/rpt-faq.js: { attributes: { type: module } }
js/components/rpt-appt-type.js: { attributes: { type: module } }
schedule: schedule:
css: css:
theme: theme:
@ -16,5 +15,6 @@ schedule:
js: js:
js/fullcalendar.min.js: { minified: true } js/fullcalendar.min.js: { minified: true }
js/calendar.js: {} js/calendar.js: {}
js/components/rpt-booking.js: { attributes: { type: module } }
dependencies: dependencies:
- core/drupalSettings - core/drupalSettings

View file

@ -86,6 +86,10 @@ class ScheduleController extends ControllerBase {
$this->tempStore->set('booking_slot', [ $this->tempStore->set('booking_slot', [
'start' => $start, 'start' => $start,
'end' => $data['end'] ?? '', 'end' => $data['end'] ?? '',
'service' => $data['service'] ?? 'diagnostic',
'last_name' => $data['lastName'] ?? '',
'phone' => $data['phone'] ?? '',
'comments' => $data['comments'] ?? '',
'provider_id' => $data['provider_id'] ?? '', 'provider_id' => $data['provider_id'] ?? '',
]); ]);
@ -95,11 +99,26 @@ class ScheduleController extends ControllerBase {
public function events(Request $request): JsonResponse { public function events(Request $request): JsonResponse {
$start = $request->query->get('start'); $start = $request->query->get('start');
$end = $request->query->get('end'); $end = $request->query->get('end');
$service = $request->query->get('service', 'diagnostic');
// Each service gets different slot density and start hours so calendars
// look meaningfully distinct when switching types.
$serviceConfig = [
'diagnostic' => ['seeds' => [5, 7, 11], 'startHour' => 9],
'sports' => ['seeds' => [3, 5, 8], 'startHour' => 7],
'surgical' => ['seeds' => [4, 6, 13], 'startHour' => 10],
'neuro' => ['seeds' => [2, 9, 7], 'startHour' => 11],
];
$cfg = $serviceConfig[$service] ?? $serviceConfig['diagnostic'];
[$s0, $s1, $s2] = $cfg['seeds'];
$current = new \DateTime($start ?? 'now'); $current = new \DateTime($start ?? 'now');
$today = new \DateTime('today'); $earliest = new \DateTime('tomorrow');
if ($current < $today) { if ($service === 'surgical') {
$current = $today; $earliest = new \DateTime('+46 days');
}
if ($current < $earliest) {
$current = $earliest;
} }
$until = new \DateTime($end ?? 'now'); $until = new \DateTime($end ?? 'now');
$events = []; $events = [];
@ -109,10 +128,10 @@ class ScheduleController extends ControllerBase {
$dow = (int) $current->format('N'); // 1=Mon … 7=Sun $dow = (int) $current->format('N'); // 1=Mon … 7=Sun
if ($dow <= 5) { if ($dow <= 5) {
$i = (int) floor($current->getTimestamp() / 86400); $i = (int) floor($current->getTimestamp() / 86400);
$count = ($i % 5 + $i % 7 + $i % 11) % 6; $count = ($i % $s0 + $i % $s1 + $i % $s2) % 6;
for ($n = 0; $n < $count; $n++) { for ($n = 0; $n < $count; $n++) {
$slot = clone $current; $slot = clone $current;
$slot->setTime(9 + $n, 0); $slot->setTime($cfg['startHour'] + $n, 0);
$events[] = [ $events[] = [
'id' => $id++, 'id' => $id++,
'title' => 'Available', 'title' => 'Available',

View file

@ -118,13 +118,7 @@
<section class="py-24 px-6 bg-white"> <section class="py-24 px-6 bg-white">
<div class="max-w-[700px] mx-auto"> <div class="max-w-[700px] mx-auto">
<h2 class="text-[clamp(2.5rem,5vw,4rem)] font-serif font-light text-gray-800 mb-10 text-center">Book An Appointment</h2> <h2 class="text-[clamp(2.5rem,5vw,4rem)] font-serif font-light text-gray-800 mb-10 text-center">Book An Appointment</h2>
<rpt-appt-type class="block mb-10"></rpt-appt-type> <rpt-booking class="block"></rpt-booking>
<div class="riverside-booking-wrap">
<div id="riverside-calendar"></div>
<div id="riverside-slots-wrap" hidden>
<div id="riverside-booking-slots"></div>
</div>
</div>
</div> </div>
</section> </section>