2026-06-01 00:47:26 -08:00
import { h , render } from "https://esm.sh/preact@10" ;
2026-06-03 00:54:29 -08:00
import { useState , useEffect , useReducer , useRef } from "https://esm.sh/preact@10/hooks" ;
2026-06-01 00:47:26 -08:00
import { html } from "https://esm.sh/htm@3/preact" ;
const TESTIMONIALS = [
{
name : "Sarah M." , category : "Sports Rehab Patient" , initials : "SM" , color : "#8ab4be" ,
quote : "After my ACL tear I was terrified I'd never run again. The team here built a plan that had me back on the field in four months. Every session felt purposeful." ,
} ,
{
name : "Leon N." , category : "Neurology Patient" , initials : "LN" , color : "#a3bfc8" ,
2026-06-03 00:37:13 -08:00
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." ,
2026-06-01 00:47:26 -08:00
} ,
{
name : "Diana K." , category : "Surgery Rehab Patient" , initials : "DK" , color : "#7aa3af" ,
quote : "Six weeks post-hip replacement and I was walking without a cane -- weeks ahead of what my surgeon expected. The therapists here are genuinely invested in your outcome, not just checking boxes." ,
} ,
{
name : "Marcus T." , category : "Sports Rehab Patient" , initials : "MT" , color : "#6b9dab" ,
quote : "I came in with chronic shoulder pain that three other clinics couldn't resolve. Two months in, I'm lifting overhead for the first time in years. The diagnostic process here is legitimately different." ,
} ,
{
name : "Rachel O." , category : "Surgery Rehab Patient" , initials : "RO" , color : "#93b8c3" ,
quote : "The booking process is seamless and the staff remembers you. I never felt like just another patient. My recovery from rotator cuff surgery exceeded every milestone." ,
} ,
{
name : "James P." , category : "Neurology Patient" , initials : "JP" , color : "#80aab5" ,
quote : "After my stroke the neurological therapy program here gave me my independence back. The team combined manual therapy with targeted exercise in a way that made real, measurable progress every single week." ,
} ,
] ;
const CARD _W = 270 ;
const GAP = 20 ;
2026-06-03 00:37:13 -08:00
const STEP = CARD _W + GAP ;
const TOTAL _W = TESTIMONIALS . length * CARD _W + ( TESTIMONIALS . length - 1 ) * GAP ;
2026-06-01 00:47:26 -08:00
function Testimonials ( ) {
2026-06-03 00:37:13 -08:00
const containerRef = useRef ( null ) ;
const trackRef = useRef ( null ) ;
const [ left , setLeft ] = useState ( 0 ) ;
2026-06-03 00:54:29 -08:00
const [ , forceUpdate ] = useReducer ( function ( n ) { return n + 1 ; } , 0 ) ;
2026-06-01 00:47:26 -08:00
2026-06-03 00:37:13 -08:00
function measureMax ( ) {
if ( ! containerRef . current ) return 0 ;
return Math . max ( 0 , TOTAL _W - containerRef . current . offsetWidth ) ;
}
var prev = function ( ) {
setLeft ( function ( l ) { return Math . min ( 0 , l + STEP ) ; } ) ;
} ;
2026-06-01 00:47:26 -08:00
2026-06-03 00:37:13 -08:00
var next = function ( ) {
var maxL = measureMax ( ) ;
setLeft ( function ( l ) { return Math . max ( - maxL , l - STEP ) ; } ) ;
} ;
2026-06-03 00:40:15 -08:00
useEffect ( function ( ) {
var timer ;
function onResize ( ) {
clearTimeout ( timer ) ;
timer = setTimeout ( function ( ) {
var max = measureMax ( ) ;
2026-06-03 00:54:29 -08:00
setLeft ( function ( l ) { return Math . min ( 0 , Math . max ( - max , l ) ) ; } ) ;
forceUpdate ( ) ;
2026-06-03 00:40:15 -08:00
} , 150 ) ;
}
window . addEventListener ( "resize" , onResize ) ;
return function ( ) {
clearTimeout ( timer ) ;
window . removeEventListener ( "resize" , onResize ) ;
} ;
} , [ ] ) ;
2026-06-03 00:54:29 -08:00
var drag = useRef ( null ) ; // null when idle, {x, left} when dragging
2026-06-03 00:40:45 -08:00
2026-06-03 00:54:29 -08:00
var onPointerDown = function ( e ) {
2026-06-03 00:54:48 -08:00
drag . current = { x : e . clientX , left : left } ;
2026-06-03 00:54:29 -08:00
trackRef . current . style . transition = "none" ;
e . currentTarget . setPointerCapture ( e . pointerId ) ;
2026-06-03 00:40:45 -08:00
} ;
2026-06-03 00:54:29 -08:00
var onPointerMove = function ( e ) {
if ( ! drag . current ) return ;
var delta = e . clientX - drag . current . x ;
var max = measureMax ( ) ;
setLeft ( Math . min ( 0 , Math . max ( - max , drag . current . left + delta ) ) ) ;
} ;
var onPointerUp = function ( e ) {
if ( ! drag . current ) return ;
drag . current = null ;
trackRef . current . style . transition = "left 0.5s ease" ;
var max = measureMax ( ) ;
setLeft ( function ( l ) {
var clamped = Math . min ( 0 , Math . max ( - max , l ) ) ;
var snapped = - Math . round ( - clamped / STEP ) * STEP ;
return Math . max ( - max , snapped ) ;
} ) ;
2026-06-03 00:40:45 -08:00
} ;
2026-06-03 00:37:13 -08:00
var atStart = left >= 0 ;
2026-06-03 00:54:29 -08:00
var atEnd = ! ! containerRef . current && left <= - measureMax ( ) ;
2026-06-01 00:47:26 -08:00
return html `
2026-06-03 00:56:07 -08:00
< div style = "overflow:hidden" >
2026-06-03 00:37:13 -08:00
< div class = "px-6 py-16" >
2026-06-03 00:56:07 -08:00
< div ref = $ { containerRef } style = "max-width:1200px; margin:0 auto" >
2026-06-03 00:37:13 -08:00
< div class = "mb-10" >
2026-06-01 00:47:26 -08:00
< p class = "text-xs tracking-widest uppercase text-[#306f8e] font-semibold mb-4" > Testimonials < / p >
2026-06-03 00:37:13 -08:00
< div class = "flex items-end gap-6" >
2026-06-01 00:47:26 -08:00
< h2 class = "text-[clamp(1.75rem,3vw,2.5rem)] font-serif font-normal text-gray-900 leading-tight max-w-[520px]" >
2026-06-01 01:21:51 -08:00
Don$ { String . fromCharCode ( 8217 ) } t take our word for it . < br / > Hear it from our patients !
2026-06-01 00:47:26 -08:00
< / h 2 >
< div class = "flex gap-3 pb-1 shrink-0" >
< button
onClick = $ { prev }
2026-06-03 00:37:13 -08:00
disabled = $ { atStart }
2026-06-01 00:47:26 -08:00
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"
2026-06-01 01:21:51 -08:00
> $ { String . fromCharCode ( 8592 ) } < / b u t t o n >
2026-06-01 00:47:26 -08:00
< button
onClick = $ { next }
2026-06-03 00:54:29 -08:00
disabled = $ { atEnd }
2026-06-01 00:47:26 -08:00
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"
2026-06-01 01:21:51 -08:00
> $ { String . fromCharCode ( 8594 ) } < / b u t t o n >
2026-06-01 00:47:26 -08:00
< / d i v >
< / d i v >
< / d i v >
2026-06-03 00:37:13 -08:00
< div
ref = $ { trackRef }
2026-06-03 00:54:29 -08:00
onPointerDown = $ { onPointerDown }
onPointerMove = $ { onPointerMove }
onPointerUp = $ { onPointerUp }
style = $ { { position : "relative" , top : 0 , left : left + "px" , transition : "left 0.5s ease" , display : "flex" , gap : GAP + "px" , paddingBottom : "2px" , width : TOTAL _W + "px" } }
2026-06-03 00:37:13 -08:00
>
$ { TESTIMONIALS . map ( function ( t , i ) { return html `
2026-06-01 00:47:26 -08:00
< 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"
style = $ { { backgroundColor : t . color } }
> $ { t . initials } < / d i v >
< p class = "text-[15px] text-gray-700 leading-relaxed flex-1" > $ { t . quote } < / p >
< div >
< p class = "text-xl font-serif text-gray-900 mb-0.5" > $ { t . name } < / p >
< p class = "text-xs tracking-widest uppercase text-[#306f8e] font-semibold" > $ { t . category } < / p >
< / d i v >
< / d i v >
2026-06-03 00:37:13 -08:00
` ; })}
< / d i v >
2026-06-01 00:47:26 -08:00
< / d i v >
< / d i v >
< / d i v >
` ;
}
class RptTestimonials extends HTMLElement {
connectedCallback ( ) {
render ( html ` < ${ Testimonials } /> ` , this ) ;
}
disconnectedCallback ( ) {
render ( null , this ) ;
}
}
customElements . define ( "rpt-testimonials" , RptTestimonials ) ;