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;
|
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));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 " +
|
||||||
|
|
|
||||||
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-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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue