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:
parent
2f624f73ba
commit
b000b824ed
7 changed files with 468 additions and 132 deletions
|
|
@ -729,6 +729,18 @@ html {
|
|||
margin-top: 2vw;
|
||||
}
|
||||
|
||||
.mb-1{
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-6{
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-8{
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.block{
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -882,6 +894,10 @@ html {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resize-none{
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.resize{
|
||||
resize: both;
|
||||
}
|
||||
|
|
@ -971,6 +987,10 @@ html {
|
|||
row-gap: 0px;
|
||||
}
|
||||
|
||||
.gap-y-5{
|
||||
row-gap: 1.25rem;
|
||||
}
|
||||
|
||||
.self-end{
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
|
@ -1027,18 +1047,6 @@ html {
|
|||
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{
|
||||
--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-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{
|
||||
--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{
|
||||
--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));
|
||||
}
|
||||
|
||||
.bg-\[pt-blue-100\]{
|
||||
background-color: pt-blue-100;
|
||||
.border-white{
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(255 255 255 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-\[pt-blue-400\]{
|
||||
background-color: pt-blue-400;
|
||||
}
|
||||
|
||||
.bg-\[pt-blue-500\]{
|
||||
background-color: pt-blue-500;
|
||||
}
|
||||
|
||||
.bg-\[pt-navy\]{
|
||||
background-color: pt-navy;
|
||||
.border-white\/60{
|
||||
border-color: rgb(255 255 255 / 0.6);
|
||||
}
|
||||
|
||||
.bg-current{
|
||||
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{
|
||||
--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{
|
||||
--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));
|
||||
}
|
||||
|
||||
.bg-pt-blue-300{
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(157 189 203 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-pt-sage-400{
|
||||
--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));
|
||||
}
|
||||
|
||||
.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{
|
||||
background-image: linear-gradient(to bottom, var(--tw-gradient-stops));
|
||||
}
|
||||
|
|
@ -1264,6 +1256,11 @@ html {
|
|||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.px-3{
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.pb-1{
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
|
@ -1288,6 +1285,10 @@ html {
|
|||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.pt-8{
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.text-left{
|
||||
text-align: left;
|
||||
}
|
||||
|
|
@ -1430,13 +1431,8 @@ html {
|
|||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.text-\[\#1e3a8a\]{
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(30 58 138 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-\[pt-blue-500\]{
|
||||
color: pt-blue-500;
|
||||
.text-\[blue-900\]{
|
||||
color: blue-900;
|
||||
}
|
||||
|
||||
.text-gray-400{
|
||||
|
|
@ -1469,6 +1465,11 @@ html {
|
|||
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{
|
||||
--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);
|
||||
}
|
||||
|
||||
.text-\[blue-900\]{
|
||||
color: blue-900;
|
||||
}
|
||||
|
||||
.text-pt-blue-500{
|
||||
.text-red-500{
|
||||
--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{
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
.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-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;
|
||||
|
|
@ -1545,42 +1546,18 @@ html {
|
|||
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%);
|
||||
}
|
||||
|
||||
.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{
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(48 111 142 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-\[pt-blue-50\]:hover{
|
||||
background-color: pt-blue-50;
|
||||
}
|
||||
|
||||
.hover\:bg-\[pt-blue-600\]:hover{
|
||||
background-color: pt-blue-600;
|
||||
}
|
||||
|
||||
.hover\:bg-\[pt-sage-500\]:hover{
|
||||
background-color: pt-sage-500;
|
||||
.hover\:border-pt-blue-600:hover{
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(31 90 110 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-100:hover{
|
||||
|
|
@ -1603,21 +1580,6 @@ html {
|
|||
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{
|
||||
color: blue-900;
|
||||
}
|
||||
|
|
@ -1626,10 +1588,29 @@ html {
|
|||
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{
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.disabled\:opacity-50:disabled{
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media not all and (min-width: 768px){
|
||||
.max-sm\:text-sm{
|
||||
font-size: 0.875rem;
|
||||
|
|
@ -1844,10 +1825,6 @@ html {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.\32xl\:bg-\[pt-blue-400\]{
|
||||
background-color: pt-blue-400;
|
||||
}
|
||||
|
||||
.\32xl\:bg-pt-blue-400{
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(134 170 182 / var(--tw-bg-opacity, 1));
|
||||
|
|
|
|||
|
|
@ -9,12 +9,23 @@
|
|||
|
||||
var selectedDate = null;
|
||||
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() {
|
||||
var d = new Date();
|
||||
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();
|
||||
|
|
@ -45,7 +56,7 @@
|
|||
fetch(drupalSettings.riversidePt.storeSlotUrl, {
|
||||
method: 'POST',
|
||||
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) {
|
||||
if (res.ok) {
|
||||
window.location.href = drupalSettings.riversidePt.bookingUrl;
|
||||
|
|
@ -85,7 +96,7 @@
|
|||
},
|
||||
fixedWeekCount: false,
|
||||
height: 'auto',
|
||||
events: drupalSettings.riversidePt.eventsUrl,
|
||||
events: buildEventsUrl(currentService),
|
||||
eventDisplay: 'none',
|
||||
dayMaxEvents: false,
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ const CHECK = html`<svg width="14" height="11" viewBox="0 0 14 11" fill="none" x
|
|||
function ApptType() {
|
||||
const [selected, setSelected] = useState("diagnostic");
|
||||
|
||||
function select(id) {
|
||||
setSelected(id);
|
||||
document.dispatchEvent(new CustomEvent("rpt:appt-type-change", { detail: { type: id } }));
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<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`
|
||||
<button
|
||||
key=${t.id}
|
||||
onClick=${function () { setSelected(t.id); }}
|
||||
onClick=${function () { select(t.id); }}
|
||||
style="text-align:left; cursor:pointer;"
|
||||
class=${
|
||||
"flex items-center gap-4 p-4 w-full rounded-xl border transition-colors " +
|
||||
|
|
|
|||
330
web/modules/custom/riverside_pt/js/components/rpt-booking.js
Normal file
330
web/modules/custom/riverside_pt/js/components/rpt-booking.js
Normal 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);
|
||||
|
|
@ -8,7 +8,6 @@ app:
|
|||
js/components/rpt-carousel.js: { attributes: { type: module } }
|
||||
js/components/rpt-testimonials.js: { attributes: { type: module } }
|
||||
js/components/rpt-faq.js: { attributes: { type: module } }
|
||||
js/components/rpt-appt-type.js: { attributes: { type: module } }
|
||||
schedule:
|
||||
css:
|
||||
theme:
|
||||
|
|
@ -16,5 +15,6 @@ schedule:
|
|||
js:
|
||||
js/fullcalendar.min.js: { minified: true }
|
||||
js/calendar.js: {}
|
||||
js/components/rpt-booking.js: { attributes: { type: module } }
|
||||
dependencies:
|
||||
- core/drupalSettings
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ class ScheduleController extends ControllerBase {
|
|||
$this->tempStore->set('booking_slot', [
|
||||
'start' => $start,
|
||||
'end' => $data['end'] ?? '',
|
||||
'service' => $data['service'] ?? 'diagnostic',
|
||||
'last_name' => $data['lastName'] ?? '',
|
||||
'phone' => $data['phone'] ?? '',
|
||||
'comments' => $data['comments'] ?? '',
|
||||
'provider_id' => $data['provider_id'] ?? '',
|
||||
]);
|
||||
|
||||
|
|
@ -95,11 +99,26 @@ class ScheduleController extends ControllerBase {
|
|||
public function events(Request $request): JsonResponse {
|
||||
$start = $request->query->get('start');
|
||||
$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');
|
||||
$today = new \DateTime('today');
|
||||
if ($current < $today) {
|
||||
$current = $today;
|
||||
$earliest = new \DateTime('tomorrow');
|
||||
if ($service === 'surgical') {
|
||||
$earliest = new \DateTime('+46 days');
|
||||
}
|
||||
if ($current < $earliest) {
|
||||
$current = $earliest;
|
||||
}
|
||||
$until = new \DateTime($end ?? 'now');
|
||||
$events = [];
|
||||
|
|
@ -109,10 +128,10 @@ class ScheduleController extends ControllerBase {
|
|||
$dow = (int) $current->format('N'); // 1=Mon … 7=Sun
|
||||
if ($dow <= 5) {
|
||||
$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++) {
|
||||
$slot = clone $current;
|
||||
$slot->setTime(9 + $n, 0);
|
||||
$slot->setTime($cfg['startHour'] + $n, 0);
|
||||
$events[] = [
|
||||
'id' => $id++,
|
||||
'title' => 'Available',
|
||||
|
|
|
|||
|
|
@ -118,13 +118,7 @@
|
|||
<section class="py-24 px-6 bg-white">
|
||||
<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>
|
||||
<rpt-appt-type class="block mb-10"></rpt-appt-type>
|
||||
<div class="riverside-booking-wrap">
|
||||
<div id="riverside-calendar"></div>
|
||||
<div id="riverside-slots-wrap" hidden>
|
||||
<div id="riverside-booking-slots"></div>
|
||||
</div>
|
||||
</div>
|
||||
<rpt-booking class="block"></rpt-booking>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue