Compare commits
5 commits
0d3b502126
...
1b6a71060a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b6a71060a | ||
|
|
0b3112f81b | ||
|
|
e7fd102daf | ||
|
|
558863056e | ||
|
|
2d933926fb |
16 changed files with 302 additions and 222 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,3 +5,5 @@ web/sites/default/files/*
|
||||||
|
|
||||||
# Never commit real credentials
|
# Never commit real credentials
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
|
|
||||||
14
jsconfig.json
Normal file
14
jsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"checkJs": true,
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
48
node_modules/.package-lock.json
generated
vendored
48
node_modules/.package-lock.json
generated
vendored
|
|
@ -15,6 +15,25 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fullcalendar/core": {
|
||||||
|
"version": "6.1.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
||||||
|
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "~10.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/core/node_modules/preact": {
|
||||||
|
"version": "10.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||||
|
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|
@ -317,6 +336,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htm": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
|
@ -677,6 +702,16 @@
|
||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
|
||||||
|
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|
@ -936,6 +971,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",
|
||||||
|
|
|
||||||
54
package-lock.json
generated
54
package-lock.json
generated
|
|
@ -6,7 +6,11 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "riverside-therapeutics",
|
"name": "riverside-therapeutics",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tailwindcss": "^3.4.17"
|
"@fullcalendar/core": "^6.1.20",
|
||||||
|
"htm": "^3.1.1",
|
||||||
|
"preact": "^10.29.2",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|
@ -21,6 +25,25 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fullcalendar/core": {
|
||||||
|
"version": "6.1.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
||||||
|
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "~10.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/core/node_modules/preact": {
|
||||||
|
"version": "10.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||||
|
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|
@ -323,6 +346,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htm": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
|
@ -683,6 +712,16 @@
|
||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
|
||||||
|
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|
@ -942,6 +981,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",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,14 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "tailwindcss -i ./web/modules/custom/riverside_pt/css/tailwind.css -o ./web/modules/custom/riverside_pt/css/app.css --watch",
|
"watch": "tailwindcss -i ./web/modules/custom/riverside_pt/css/tailwind.css -o ./web/modules/custom/riverside_pt/css/app.css --watch",
|
||||||
"build": "tailwindcss -i ./web/modules/custom/riverside_pt/css/tailwind.css -o ./web/modules/custom/riverside_pt/css/app.css --minify"
|
"build": "tailwindcss -i ./web/modules/custom/riverside_pt/css/tailwind.css -o ./web/modules/custom/riverside_pt/css/app.css --minify",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tailwindcss": "^3.4.17"
|
"@fullcalendar/core": "^6.1.20",
|
||||||
|
"htm": "^3.1.1",
|
||||||
|
"preact": "^10.29.2",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
(function (drupalSettings) {
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
var el = document.getElementById('riverside-calendar');
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
requestAnimationFrame(function () {
|
|
||||||
var slotsWrap = document.getElementById('riverside-slots-wrap');
|
|
||||||
var slotsGrid = document.getElementById('riverside-booking-slots');
|
|
||||||
|
|
||||||
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 localDateStr(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
var initDate = nextBusinessDay();
|
|
||||||
|
|
||||||
function slotLabel(date) {
|
|
||||||
var h = date.getHours();
|
|
||||||
return (h % 12 || 12) + (h < 12 ? 'AM' : 'PM') + ' PST';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSlots(dateStr, events) {
|
|
||||||
var dayEvents = events
|
|
||||||
.filter(function (e) { return e.startStr.startsWith(dateStr); })
|
|
||||||
.sort(function (a, b) { return a.start - b.start; });
|
|
||||||
|
|
||||||
if (!slotsWrap || !slotsGrid || dayEvents.length === 0) return;
|
|
||||||
|
|
||||||
slotsGrid.innerHTML = '';
|
|
||||||
dayEvents.forEach(function (event, idx) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.textContent = slotLabel(event.start);
|
|
||||||
btn.className = 'riverside-slot-btn' + (idx === 0 ? ' is-selected' : '');
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
slotsGrid.querySelectorAll('.riverside-slot-btn').forEach(function (b) {
|
|
||||||
b.classList.remove('is-selected');
|
|
||||||
});
|
|
||||||
btn.classList.add('is-selected');
|
|
||||||
fetch(drupalSettings.riversidePt.storeSlotUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ start: event.startStr, end: event.endStr, service: currentService }),
|
|
||||||
}).then(function (res) {
|
|
||||||
if (res.ok) {
|
|
||||||
window.location.href = drupalSettings.riversidePt.bookingUrl;
|
|
||||||
} else {
|
|
||||||
btn.textContent += ' (unavailable)';
|
|
||||||
btn.disabled = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
slotsGrid.appendChild(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
slotsWrap.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectDay(dateStr, events) {
|
|
||||||
el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
|
|
||||||
d.classList.remove('is-selected');
|
|
||||||
});
|
|
||||||
var dayEl = el.querySelector('.fc-daygrid-day[data-date="' + dateStr + '"]');
|
|
||||||
if (dayEl) dayEl.classList.add('is-selected');
|
|
||||||
selectedDate = dateStr;
|
|
||||||
renderSlots(dateStr, events);
|
|
||||||
}
|
|
||||||
|
|
||||||
var calendar = new FullCalendar.Calendar(el, {
|
|
||||||
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,
|
|
||||||
showNonCurrentDates: false,
|
|
||||||
height: 'auto',
|
|
||||||
events: buildEventsUrl(currentService),
|
|
||||||
eventDisplay: 'none',
|
|
||||||
dayMaxEvents: false,
|
|
||||||
|
|
||||||
datesSet: function () {
|
|
||||||
el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
|
|
||||||
d.classList.remove('is-selected');
|
|
||||||
});
|
|
||||||
selectedDate = null;
|
|
||||||
if (slotsWrap) slotsWrap.hidden = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
eventsSet: function (events) {
|
|
||||||
el.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 = el.querySelector('.fc-daygrid-day[data-date="' + dateStr + '"]');
|
|
||||||
if (dayEl) dayEl.classList.add('has-availability');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!initialized) {
|
|
||||||
initialized = true;
|
|
||||||
var targetEl = el.querySelector('.fc-daygrid-day[data-date="' + initDate + '"]');
|
|
||||||
if (targetEl && targetEl.classList.contains('has-availability')) {
|
|
||||||
selectDay(initDate, events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
dayCellClassNames: function (arg) {
|
|
||||||
var date = arg.date.toISOString().substring(0, 10);
|
|
||||||
if (drupalSettings.riversidePt.holidays[date]) return ['is-holiday'];
|
|
||||||
},
|
|
||||||
|
|
||||||
dateClick: function (arg) {
|
|
||||||
if (!arg.dayEl.classList.contains('has-availability')) return;
|
|
||||||
selectDay(arg.dateStr, calendar.getEvents());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
calendar.render();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})(drupalSettings);
|
|
||||||
|
|
@ -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 } }));
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
return settings.holidays[date] ? ["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);
|
||||||
|
|
@ -209,6 +220,11 @@ function BookingPanel({ service, settings }) {
|
||||||
|
|
||||||
cal.render();
|
cal.render();
|
||||||
calRef.current = cal;
|
calRef.current = cal;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.rptScrollTo(rootEl, true);
|
||||||
|
})
|
||||||
|
|
||||||
return function () { cal.destroy(); };
|
return function () { cal.destroy(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -226,12 +242,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([]);
|
||||||
|
|
@ -251,15 +267,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;
|
||||||
|
|
@ -277,7 +294,7 @@ function BookingPanel({ service, settings }) {
|
||||||
}, [fetchedEvents]);
|
}, [fetchedEvents]);
|
||||||
|
|
||||||
useEffect(function () {
|
useEffect(function () {
|
||||||
if (selectedSlotId && !prevSlotIdRef.current && formRef.current) {
|
if (selectedSlotId && prevSlotIdRef.current !== selectedSlotId && formRef.current) {
|
||||||
window.rptScrollTo(formRef.current, true);
|
window.rptScrollTo(formRef.current, true);
|
||||||
}
|
}
|
||||||
prevSlotIdRef.current = selectedSlotId;
|
prevSlotIdRef.current = selectedSlotId;
|
||||||
|
|
@ -289,28 +306,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,
|
||||||
|
|
@ -323,7 +346,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,
|
||||||
|
|
@ -337,7 +360,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.");
|
||||||
|
|
@ -350,7 +373,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>
|
||||||
|
|
@ -369,11 +392,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}
|
||||||
|
|
@ -404,7 +427,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>
|
||||||
|
|
@ -419,7 +442,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>
|
||||||
|
|
@ -434,7 +457,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>
|
||||||
|
|
@ -449,7 +472,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}
|
||||||
|
|
@ -467,7 +490,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>
|
||||||
|
|
@ -488,7 +511,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>
|
||||||
|
|
@ -503,8 +526,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">
|
||||||
|
|
@ -515,7 +541,9 @@ function Booking({ settings }) {
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
key=${t.id}
|
key=${t.id}
|
||||||
onClick=${function () { setService(t.id); }}
|
onClick=${function () {
|
||||||
|
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)}
|
||||||
>
|
>
|
||||||
|
|
@ -543,7 +571,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);
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -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; });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
42
web/modules/custom/riverside_pt/js/globals.d.ts
vendored
Normal file
42
web/modules/custom/riverside_pt/js/globals.d.ts
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { Calendar } from "@fullcalendar/core";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface RiversidePtSettings {
|
||||||
|
eventsUrl: string;
|
||||||
|
storeSlotUrl: string;
|
||||||
|
bookingUrl?: string;
|
||||||
|
holidays: Record<string, boolean>;
|
||||||
|
scrollTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RiversidePtEvent {
|
||||||
|
id: number | string;
|
||||||
|
title?: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
startStr?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmedAppointment {
|
||||||
|
start: string;
|
||||||
|
service: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullCalendar is loaded as a UMD global. The Calendar constructor accepts
|
||||||
|
// any options object because plugin-specific options (e.g. dateClick from
|
||||||
|
// @fullcalendar/interaction) are not reflected in @fullcalendar/core types.
|
||||||
|
namespace FullCalendar {
|
||||||
|
const Calendar: new (el: HTMLElement, options?: Record<string, any>) => Calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
FullCalendar?: typeof FullCalendar;
|
||||||
|
rptScrollTo: (el: Element, animate?: boolean) => void;
|
||||||
|
drupalSettings?: {
|
||||||
|
riversidePt?: RiversidePtSettings;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue