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:
parent
187174caa6
commit
b0eaca462f
5 changed files with 297 additions and 139 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,31 @@
|
|||
(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, {
|
||||
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),
|
||||
|
|
@ -17,41 +35,58 @@
|
|||
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' : '');
|
||||
eventDisplay: 'none',
|
||||
dayMaxEvents: false,
|
||||
|
||||
datesSet: function () {
|
||||
el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
|
||||
d.classList.remove('is-selected');
|
||||
});
|
||||
selectedDate = null;
|
||||
},
|
||||
|
||||
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');
|
||||
});
|
||||
},
|
||||
|
||||
dayCellClassNames: function (arg) {
|
||||
const date = arg.date.toISOString().substring(0, 10);
|
||||
var 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;
|
||||
|
||||
dateClick: function (arg) {
|
||||
if (!arg.dayEl.classList.contains('has-availability')) return;
|
||||
|
||||
var dateStr = arg.dateStr;
|
||||
var dayEvents = calendar.getEvents().filter(function (e) {
|
||||
return e.startStr.startsWith(dateStr);
|
||||
});
|
||||
if (dayEvents.length === 0) return;
|
||||
|
||||
// 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 = '';
|
||||
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');
|
||||
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) {
|
||||
|
|
@ -59,10 +94,7 @@
|
|||
fetch(drupalSettings.riversidePt.storeSlotUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
start: seg.event.startStr,
|
||||
end: seg.event.endStr,
|
||||
}),
|
||||
body: JSON.stringify({ start: event.startStr, end: event.endStr }),
|
||||
}).then(function (res) {
|
||||
if (res.ok) {
|
||||
window.location.href = drupalSettings.riversidePt.bookingUrl;
|
||||
|
|
@ -77,25 +109,9 @@
|
|||
panelSlots.appendChild(li);
|
||||
});
|
||||
openPanel();
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
function closePanel() {
|
||||
panel.hidden = true;
|
||||
backdrop.hidden = true;
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
backdrop.hidden = false;
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
document.getElementById('riverside-booking-close').addEventListener('click', closePanel);
|
||||
backdrop.addEventListener('click', closePanel);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
|
|
@ -103,7 +119,6 @@
|
|||
});
|
||||
|
||||
calendar.render();
|
||||
|
||||
}); // end requestAnimationFrame
|
||||
});
|
||||
})(drupalSettings);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, '/') . '$';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue