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 {
|
#riverside-calendar {
|
||||||
max-width: 480px;
|
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 {
|
#riverside-booking-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -54,35 +191,13 @@
|
||||||
|
|
||||||
#riverside-booking-slots li {
|
#riverside-booking-slots li {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid #3b82f6;
|
border: 1px solid #306f8e;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #1d4ed8;
|
color: #306f8e;
|
||||||
}
|
}
|
||||||
|
|
||||||
#riverside-booking-slots li:hover {
|
#riverside-booking-slots li:hover {
|
||||||
background: #eff6ff;
|
background: #dde8f0;
|
||||||
}
|
|
||||||
|
|
||||||
#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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,124 @@
|
||||||
(function (drupalSettings) {
|
(function (drupalSettings) {
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const el = document.getElementById('riverside-calendar');
|
var el = document.getElementById('riverside-calendar');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
|
var selectedDate = null;
|
||||||
|
|
||||||
const calendar = new FullCalendar.Calendar(el, {
|
var panel = document.getElementById('riverside-booking-panel');
|
||||||
initialView: 'dayGridMonth',
|
var backdrop = document.getElementById('riverside-booking-backdrop');
|
||||||
headerToolbar: { left: 'prev', center: 'title', right: 'next' },
|
var panelDate = document.getElementById('riverside-booking-date');
|
||||||
validRange: function (now) {
|
var panelSlots = document.getElementById('riverside-booking-slots');
|
||||||
return {
|
|
||||||
start: new Date(now.getFullYear(), now.getMonth(), 1),
|
function closePanel() {
|
||||||
end: new Date(now.getFullYear(), now.getMonth() + 7, 1),
|
panel.hidden = true;
|
||||||
};
|
backdrop.hidden = true;
|
||||||
},
|
}
|
||||||
fixedWeekCount: false,
|
|
||||||
height: 'auto',
|
function openPanel() {
|
||||||
events: drupalSettings.riversidePt.eventsUrl,
|
backdrop.hidden = false;
|
||||||
eventBackgroundColor: '#3b82f6',
|
panel.hidden = false;
|
||||||
eventBorderColor: '#3b82f6',
|
}
|
||||||
dayMaxEvents: 0,
|
|
||||||
moreLinkContent: function (arg) {
|
var calendar = new FullCalendar.Calendar(el, {
|
||||||
return arg.num + ' slot' + (arg.num !== 1 ? 's' : '');
|
initialView: 'dayGridMonth',
|
||||||
},
|
headerToolbar: { left: 'prev', center: 'title', right: 'next' },
|
||||||
dayCellClassNames: function (arg) {
|
titleFormat: { year: 'numeric', month: 'long' },
|
||||||
const date = arg.date.toISOString().substring(0, 10);
|
dayHeaderFormat: { weekday: 'narrow' },
|
||||||
if (drupalSettings.riversidePt.holidays[date]) return ['is-holiday'];
|
validRange: function (now) {
|
||||||
},
|
return {
|
||||||
dayCellDidMount: function (arg) {
|
start: new Date(now.getFullYear(), now.getMonth(), 1),
|
||||||
const date = arg.date.toISOString().substring(0, 10);
|
end: new Date(now.getFullYear(), now.getMonth() + 7, 1),
|
||||||
const holiday = drupalSettings.riversidePt.holidays[date];
|
};
|
||||||
if (!holiday) return;
|
},
|
||||||
const label = document.createElement('div');
|
fixedWeekCount: false,
|
||||||
label.className = 'riverside-holiday-label';
|
height: 'auto',
|
||||||
label.textContent = holiday;
|
events: drupalSettings.riversidePt.eventsUrl,
|
||||||
const dayTop = arg.el.querySelector('.fc-daygrid-day-top');
|
eventDisplay: 'none',
|
||||||
if (dayTop) {
|
dayMaxEvents: false,
|
||||||
dayTop.insertAdjacentElement('afterend', label);
|
|
||||||
} else {
|
datesSet: function () {
|
||||||
arg.el.appendChild(label);
|
el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
|
||||||
}
|
d.classList.remove('is-selected');
|
||||||
},
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
li.appendChild(a);
|
selectedDate = null;
|
||||||
panelSlots.appendChild(li);
|
},
|
||||||
});
|
|
||||||
openPanel();
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const panel = document.getElementById('riverside-booking-panel');
|
eventsSet: function (events) {
|
||||||
const backdrop = document.getElementById('riverside-booking-backdrop');
|
el.querySelectorAll('.fc-daygrid-day.has-availability').forEach(function (d) {
|
||||||
const panelDate = document.getElementById('riverside-booking-date');
|
d.classList.remove('has-availability');
|
||||||
const panelSlots = document.getElementById('riverside-booking-slots');
|
});
|
||||||
|
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() {
|
dayCellClassNames: function (arg) {
|
||||||
panel.hidden = true;
|
var date = arg.date.toISOString().substring(0, 10);
|
||||||
backdrop.hidden = true;
|
if (drupalSettings.riversidePt.holidays[date]) return ['is-holiday'];
|
||||||
}
|
},
|
||||||
|
|
||||||
function openPanel() {
|
dateClick: function (arg) {
|
||||||
backdrop.hidden = false;
|
if (!arg.dayEl.classList.contains('has-availability')) return;
|
||||||
panel.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('riverside-booking-close').addEventListener('click', closePanel);
|
var dateStr = arg.dateStr;
|
||||||
backdrop.addEventListener('click', closePanel);
|
var dayEvents = calendar.getEvents().filter(function (e) {
|
||||||
document.addEventListener('keydown', function (e) {
|
return e.startStr.startsWith(dateStr);
|
||||||
if (e.key === 'Escape') closePanel();
|
});
|
||||||
});
|
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
|
}); // end requestAnimationFrame
|
||||||
});
|
});
|
||||||
})(drupalSettings);
|
})(drupalSettings);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { h, render } from "https://esm.sh/preact@10";
|
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";
|
import { html } from "https://esm.sh/htm@3/preact";
|
||||||
|
|
||||||
const TESTIMONIALS = [
|
const TESTIMONIALS = [
|
||||||
|
|
@ -9,7 +9,7 @@ const TESTIMONIALS = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Leon N.", category: "Neurology Patient", initials: "LN", color: "#a3bfc8",
|
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",
|
name: "Diana K.", category: "Surgery Rehab Patient", initials: "DK", color: "#7aa3af",
|
||||||
|
|
@ -31,42 +31,62 @@ const TESTIMONIALS = [
|
||||||
|
|
||||||
const CARD_W = 270;
|
const CARD_W = 270;
|
||||||
const GAP = 20;
|
const GAP = 20;
|
||||||
|
const STEP = CARD_W + GAP;
|
||||||
|
const TOTAL_W = TESTIMONIALS.length * CARD_W + (TESTIMONIALS.length - 1) * GAP;
|
||||||
|
|
||||||
function Testimonials() {
|
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); });
|
function measureMax() {
|
||||||
const next = () => setIndex(function(i) { return Math.min(TESTIMONIALS.length - 1, i + 1); });
|
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`
|
return html`
|
||||||
<div style="overflow:hidden">
|
<div ref=${wrapRef} style="overflow:hidden">
|
||||||
<div class="py-16" style=${{ paddingLeft: leftEdge }}>
|
<div class="px-6 py-16">
|
||||||
<div class="mb-10 pr-6">
|
<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>
|
<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]">
|
<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!
|
Don${String.fromCharCode(8217)}t take our word for it.<br />Hear it from our patients!
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex gap-3 pb-1 shrink-0">
|
<div class="flex gap-3 pb-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick=${prev}
|
onClick=${prev}
|
||||||
disabled=${index === 0}
|
disabled=${atStart}
|
||||||
aria-label="Previous testimonials"
|
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"
|
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>
|
>${String.fromCharCode(8592)}</button>
|
||||||
<button
|
<button
|
||||||
onClick=${next}
|
onClick=${next}
|
||||||
disabled=${index === TESTIMONIALS.length - 1}
|
disabled=${false}
|
||||||
aria-label="Next testimonials"
|
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"
|
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>
|
>${String.fromCharCode(8594)}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style=${{ display: "flex", gap: GAP + "px", transform: "translateX(-" + (index * (CARD_W + GAP)) + "px)", transition: "transform 0.5s ease", paddingBottom: "2px" }}>
|
<div
|
||||||
${TESTIMONIALS.map((t, i) => html`
|
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 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
|
<div
|
||||||
class="w-14 h-14 rounded-full flex items-center justify-center text-white font-semibold text-base shrink-0"
|
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>
|
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold">${t.category}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`)}
|
`; })}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,19 @@ if (getenv('DEBUG')) {
|
||||||
$settings['cache']['bins']['page'] = 'cache.backend.null';
|
$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')) {
|
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;
|
$base_url = $base;
|
||||||
$parsed = parse_url($base);
|
$parsed = parse_url($base);
|
||||||
$host = $parsed['host'] ?? 'localhost';
|
$host = $parsed['host'] ?? '';
|
||||||
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||||
$settings['trusted_host_patterns'] = ['^' . preg_quote($host . $port, '/') . '$'];
|
if ($host && $host !== 'localhost' && !preg_match('/^127\./', $host)) {
|
||||||
} else {
|
$settings['trusted_host_patterns'][] = '^' . preg_quote($host . $port, '/') . '$';
|
||||||
$settings['trusted_host_patterns'] = ['^localhost$', '^localhost:8080$', '^127\.0\.0\.1$'];
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue