Add testimonials carousel, redesign calendar, fix trusted host

- rpt-testimonials: pixel-offset carousel with DOM-measured max scroll,
  cards overflow the 1200px safe area to the right; red debug border on container
- calendar: circle-per-day design replacing event bars; teal outline for
  available days, filled for selected; dateClick replaces moreLinkClick
- settings.php: normalize BASE_URL before parse_url to fix trusted host
  error when scheme is missing; always include localhost fallback patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-06-03 01:37:13 -07:00
parent 187174caa6
commit b0eaca462f
5 changed files with 297 additions and 139 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,145 @@
#riverside-calendar {
max-width: 480px;
font-size: 0.8rem;
font-size: 0.9rem;
}
/* ── Strip all borders ── */
#riverside-calendar .fc-scrollgrid,
#riverside-calendar .fc-scrollgrid-section > *,
#riverside-calendar td,
#riverside-calendar th {
border: none !important;
}
/* ── Header toolbar ── */
#riverside-calendar .fc-header-toolbar {
justify-content: center;
gap: 0.25rem;
margin-bottom: 1.25rem;
}
#riverside-calendar .fc-toolbar-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.35rem;
font-weight: normal;
color: #111;
}
#riverside-calendar .fc-toolbar-title::after {
content: ' \25BC';
font-size: 0.6em;
vertical-align: middle;
color: #374151;
}
#riverside-calendar .fc-button-group,
#riverside-calendar .fc-button {
background: none !important;
border: none !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 1rem;
padding: 0.2rem 0.4rem;
cursor: pointer;
}
#riverside-calendar .fc-button:hover {
color: #111 !important;
}
#riverside-calendar .fc-button:focus {
outline: none !important;
box-shadow: none !important;
}
/* ── Day-of-week header row (S M T W T F S) ── */
#riverside-calendar .fc-col-header-cell {
padding: 0.4rem 0;
border: none;
}
#riverside-calendar .fc-col-header-cell-cushion {
font-size: 0.8rem;
font-weight: normal;
color: #9ca3af;
text-decoration: none;
padding: 0;
}
/* ── Day cells ── */
#riverside-calendar .fc-daygrid-day-frame {
min-height: 3rem !important;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
#riverside-calendar .fc-daygrid-day-top {
flex-direction: row;
justify-content: center;
padding: 0;
}
#riverside-calendar .fc-daygrid-day-number {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
color: #9ca3af;
text-decoration: none;
padding: 0;
line-height: 1;
transition: background 0.15s, color 0.15s;
}
/* Days with available slots — teal outline circle */
#riverside-calendar .fc-daygrid-day.has-availability .fc-daygrid-day-number {
border: 2px solid #306f8e;
color: #111;
cursor: pointer;
}
#riverside-calendar .fc-daygrid-day.has-availability .fc-daygrid-day-number:hover {
background: #dde8f0;
}
/* Selected day — filled teal circle */
#riverside-calendar .fc-daygrid-day.is-selected .fc-daygrid-day-number {
background: #306f8e;
border: 2px solid #306f8e;
color: #fff !important;
}
/* Days in other months */
#riverside-calendar .fc-day-other .fc-daygrid-day-number {
color: #d1d5db !important;
border-color: transparent !important;
}
/* Today — no special background, let availability/selected styles win */
#riverside-calendar .fc-day-today {
background: none !important;
}
/* ── Hide all event bars, harnesses, and more-links ── */
#riverside-calendar .fc-event,
#riverside-calendar .fc-daygrid-event-harness,
#riverside-calendar .fc-daygrid-more-link,
#riverside-calendar .fc-more-popover,
#riverside-calendar .riverside-holiday-label {
display: none !important;
}
/* ── Daygrid body rows — give them breathing room ── */
#riverside-calendar .fc-daygrid-body tr {
height: 3.5rem;
}
/* ── Booking backdrop & panel ── */
#riverside-booking-backdrop {
position: fixed;
inset: 0;
@ -54,35 +191,13 @@
#riverside-booking-slots li {
padding: 0.5rem 0.75rem;
border: 1px solid #3b82f6;
border: 1px solid #306f8e;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
color: #1d4ed8;
color: #306f8e;
}
#riverside-booking-slots li:hover {
background: #eff6ff;
}
#riverside-calendar .fc-more-popover {
display: none;
}
#riverside-calendar .is-holiday .fc-more-link {
display: none;
}
#riverside-calendar .fc-day-other .riverside-holiday-label,
#riverside-calendar .fc-day-other .fc-more-link {
opacity: 0.4;
}
#riverside-calendar .riverside-holiday-label {
overflow-wrap: break-word;
line-height: 1;
font-size: 0.65rem;
color: #b45309;
text-align: center;
padding: 2px;
background: #dde8f0;
}

View file

@ -1,109 +1,124 @@
(function (drupalSettings) {
document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById('riverside-calendar');
var el = document.getElementById('riverside-calendar');
if (!el) return;
requestAnimationFrame(function () {
var selectedDate = null;
const calendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth',
headerToolbar: { left: 'prev', center: 'title', right: 'next' },
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',
events: drupalSettings.riversidePt.eventsUrl,
eventBackgroundColor: '#3b82f6',
eventBorderColor: '#3b82f6',
dayMaxEvents: 0,
moreLinkContent: function (arg) {
return arg.num + ' slot' + (arg.num !== 1 ? 's' : '');
},
dayCellClassNames: function (arg) {
const date = arg.date.toISOString().substring(0, 10);
if (drupalSettings.riversidePt.holidays[date]) return ['is-holiday'];
},
dayCellDidMount: function (arg) {
const date = arg.date.toISOString().substring(0, 10);
const holiday = drupalSettings.riversidePt.holidays[date];
if (!holiday) return;
const label = document.createElement('div');
label.className = 'riverside-holiday-label';
label.textContent = holiday;
const dayTop = arg.el.querySelector('.fc-daygrid-day-top');
if (dayTop) {
dayTop.insertAdjacentElement('afterend', label);
} else {
arg.el.appendChild(label);
}
},
moreLinkClick: function (arg) {
arg.jsEvent.preventDefault();
arg.jsEvent.stopPropagation();
const date = arg.date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
panelDate.textContent = date;
panelSlots.innerHTML = '';
arg.allSegs.forEach(function (seg) {
const li = document.createElement('li');
const startLabel = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
const endLabel = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
const a = document.createElement('a');
a.href = '#';
a.textContent = startLabel + ' ' + endLabel;
a.addEventListener('click', function (e) {
e.preventDefault();
fetch(drupalSettings.riversidePt.storeSlotUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
start: seg.event.startStr,
end: seg.event.endStr,
}),
}).then(function (res) {
if (res.ok) {
window.location.href = drupalSettings.riversidePt.bookingUrl;
} else {
a.textContent += ' (no longer available)';
a.style.pointerEvents = 'none';
a.style.opacity = '0.5';
}
});
var panel = document.getElementById('riverside-booking-panel');
var backdrop = document.getElementById('riverside-booking-backdrop');
var panelDate = document.getElementById('riverside-booking-date');
var panelSlots = document.getElementById('riverside-booking-slots');
function closePanel() {
panel.hidden = true;
backdrop.hidden = true;
}
function openPanel() {
backdrop.hidden = false;
panel.hidden = false;
}
var calendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth',
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',
events: drupalSettings.riversidePt.eventsUrl,
eventDisplay: 'none',
dayMaxEvents: false,
datesSet: function () {
el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
d.classList.remove('is-selected');
});
li.appendChild(a);
panelSlots.appendChild(li);
});
openPanel();
return false;
},
});
selectedDate = null;
},
const panel = document.getElementById('riverside-booking-panel');
const backdrop = document.getElementById('riverside-booking-backdrop');
const panelDate = document.getElementById('riverside-booking-date');
const panelSlots = document.getElementById('riverside-booking-slots');
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');
});
},
function closePanel() {
panel.hidden = true;
backdrop.hidden = true;
}
dayCellClassNames: function (arg) {
var date = arg.date.toISOString().substring(0, 10);
if (drupalSettings.riversidePt.holidays[date]) return ['is-holiday'];
},
function openPanel() {
backdrop.hidden = false;
panel.hidden = false;
}
dateClick: function (arg) {
if (!arg.dayEl.classList.contains('has-availability')) return;
document.getElementById('riverside-booking-close').addEventListener('click', closePanel);
backdrop.addEventListener('click', closePanel);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePanel();
});
var dateStr = arg.dateStr;
var dayEvents = calendar.getEvents().filter(function (e) {
return e.startStr.startsWith(dateStr);
});
if (dayEvents.length === 0) return;
calendar.render();
// Update selected highlight.
el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
d.classList.remove('is-selected');
});
arg.dayEl.classList.add('is-selected');
selectedDate = dateStr;
// Build slot list.
panelDate.textContent = arg.date.toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
panelSlots.innerHTML = '';
dayEvents.sort(function (a, b) { return a.start - b.start; }).forEach(function (event) {
var startLabel = event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
var endLabel = event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
var li = document.createElement('li');
var a = document.createElement('a');
a.href = '#';
a.textContent = startLabel + ' ' + endLabel;
a.addEventListener('click', function (e) {
e.preventDefault();
fetch(drupalSettings.riversidePt.storeSlotUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start: event.startStr, end: event.endStr }),
}).then(function (res) {
if (res.ok) {
window.location.href = drupalSettings.riversidePt.bookingUrl;
} else {
a.textContent += ' (no longer available)';
a.style.pointerEvents = 'none';
a.style.opacity = '0.5';
}
});
});
li.appendChild(a);
panelSlots.appendChild(li);
});
openPanel();
},
});
document.getElementById('riverside-booking-close').addEventListener('click', closePanel);
backdrop.addEventListener('click', closePanel);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePanel();
});
calendar.render();
}); // end requestAnimationFrame
});
})(drupalSettings);

View file

@ -1,5 +1,5 @@
import { h, render } from "https://esm.sh/preact@10";
import { useState } from "https://esm.sh/preact@10/hooks";
import { useState, useRef } from "https://esm.sh/preact@10/hooks";
import { html } from "https://esm.sh/htm@3/preact";
const TESTIMONIALS = [
@ -9,7 +9,7 @@ const TESTIMONIALS = [
},
{
name: "Leon N.", category: "Neurology Patient", initials: "LN", color: "#a3bfc8",
quote: "Every new patient begins with a comprehensive diagnostic assessment. From there, they create a fully personalized treatment plan tailored to your goals -- whether that means returning to sport, recovering from surgery, or restoring function.",
quote: "Every new patient begins with a comprehensive diagnostic assessment. From there, they create a fully personalized treatment plan -- whether that means returning to sport, recovering from surgery, or restoring function.",
},
{
name: "Diana K.", category: "Surgery Rehab Patient", initials: "DK", color: "#7aa3af",
@ -31,42 +31,62 @@ const TESTIMONIALS = [
const CARD_W = 270;
const GAP = 20;
const STEP = CARD_W + GAP;
const TOTAL_W = TESTIMONIALS.length * CARD_W + (TESTIMONIALS.length - 1) * GAP;
function Testimonials() {
const [index, setIndex] = useState(0);
const wrapRef = useRef(null);
const containerRef = useRef(null);
const trackRef = useRef(null);
const [left, setLeft] = useState(0);
const prev = () => setIndex(function(i) { return Math.max(0, i - 1); });
const next = () => setIndex(function(i) { return Math.min(TESTIMONIALS.length - 1, i + 1); });
function measureMax() {
if (!containerRef.current) return 0;
return Math.max(0, TOTAL_W - containerRef.current.offsetWidth);
}
const leftEdge = "max(1.5rem, calc((100vw - 1248px) / 2 + 1.5rem))";
var prev = function () {
setLeft(function (l) { return Math.min(0, l + STEP); });
};
var next = function () {
var maxL = measureMax();
setLeft(function (l) { return Math.max(-maxL, l - STEP); });
};
var atStart = left >= 0;
return html`
<div style="overflow:hidden">
<div class="py-16" style=${{ paddingLeft: leftEdge }}>
<div class="mb-10 pr-6">
<div ref=${wrapRef} style="overflow:hidden">
<div class="px-6 py-16">
<div ref=${containerRef} style="max-width:1200px; margin:0 auto; border:2px solid red">
<div class="mb-10">
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold mb-4">Testimonials</p>
<div class="">
<div class="flex items-end gap-6">
<h2 class="text-[clamp(1.75rem,3vw,2.5rem)] font-serif font-normal text-gray-900 leading-tight max-w-[520px]">
Don${String.fromCharCode(8217)}t take our word for it.<br />Hear it from our patients!
</h2>
<div class="flex gap-3 pb-1 shrink-0">
<button
onClick=${prev}
disabled=${index === 0}
disabled=${atStart}
aria-label="Previous testimonials"
class="w-10 h-10 rounded-full border border-gray-300 flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors disabled:opacity-30"
>${String.fromCharCode(8592)}</button>
<button
onClick=${next}
disabled=${index === TESTIMONIALS.length - 1}
disabled=${false}
aria-label="Next testimonials"
class="w-10 h-10 rounded-full border border-gray-300 flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors disabled:opacity-30"
>${String.fromCharCode(8594)}</button>
</div>
</div>
</div>
<div style=${{ display: "flex", gap: GAP + "px", transform: "translateX(-" + (index * (CARD_W + GAP)) + "px)", transition: "transform 0.5s ease", paddingBottom: "2px" }}>
${TESTIMONIALS.map((t, i) => html`
<div
ref=${trackRef}
style=${{ position: "relative", top: 0, left: left + "px", transition: "left 0.5s ease", display: "flex", gap: GAP + "px", paddingBottom: "2px" }}
>
${TESTIMONIALS.map(function (t, i) { return html`
<div key=${i} style=${{ width: CARD_W + "px", flexShrink: 0 }} class="border border-gray-200 rounded-lg p-6 flex flex-col gap-5 bg-white">
<div
class="w-14 h-14 rounded-full flex items-center justify-center text-white font-semibold text-base shrink-0"
@ -78,7 +98,8 @@ function Testimonials() {
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold">${t.category}</p>
</div>
</div>
`)}
`; })}
</div>
</div>
</div>
</div>

View file

@ -35,12 +35,19 @@ if (getenv('DEBUG')) {
$settings['cache']['bins']['page'] = 'cache.backend.null';
}
// Always allow localhost variants so missing/malformed BASE_URL never locks out local dev.
$settings['trusted_host_patterns'] = ['^localhost$', '^localhost:\d+$', '^127\.0\.0\.1$', '^127\.0\.0\.1:\d+$'];
if ($base = getenv('BASE_URL')) {
// Ensure scheme is present so parse_url extracts host/port correctly.
if (!preg_match('#^https?://#', $base)) {
$base = 'http://' . $base;
}
$base_url = $base;
$parsed = parse_url($base);
$host = $parsed['host'] ?? 'localhost';
$host = $parsed['host'] ?? '';
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
$settings['trusted_host_patterns'] = ['^' . preg_quote($host . $port, '/') . '$'];
} else {
$settings['trusted_host_patterns'] = ['^localhost$', '^localhost:8080$', '^127\.0\.0\.1$'];
if ($host && $host !== 'localhost' && !preg_match('/^127\./', $host)) {
$settings['trusted_host_patterns'][] = '^' . preg_quote($host . $port, '/') . '$';
}
}