agency-web/app/components/Testimonials.tsx

260 lines
8.8 KiB
TypeScript

"use client";
/**
* TESTIMONIALS — an interactive, auto-advancing carousel that feels substantial.
* - 6 sample testimonials, one large featured quote at a time, with a
* monogram avatar, name, role, company and a result chip.
* - Auto-advances every ~6s; pauses on hover/focus-within and when offscreen
* or the tab is hidden. Manual prev/next buttons + clickable dots.
* - A thumbnail rail of all clients (monogram avatars) doubles as navigation;
* the active one is highlighted.
* - Slides crossfade/slide with Motion (AnimatePresence) using the page's
* EASE curves; direction-aware.
* - Fully keyboard accessible: real <button>s, arrow-key support, aria-live
* announces the active slide, aria-roledescription="carousel".
* - prefers-reduced-motion: no auto-advance, instant slide swaps.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import Reveal from "./Reveal";
import { testimonials } from "../content";
import { EASE_OUT } from "./motion";
const AUTOPLAY_MS = 6000;
export default function Testimonials() {
const reduce = useReducedMotion();
const [[index, dir], setState] = useState<[number, number]>([0, 0]);
const [paused, setPaused] = useState(false);
const rootRef = useRef<HTMLElement>(null);
const inView = useRef(true);
const count = testimonials.length;
const go = useCallback(
(next: number, direction: number) => {
setState([(next + count) % count, direction]);
},
[count]
);
const next = useCallback(() => go(index + 1, 1), [go, index]);
const prev = useCallback(() => go(index - 1, -1), [go, index]);
// autoplay — paused on hover/focus, offscreen, hidden tab, reduced-motion
useEffect(() => {
if (reduce) return;
const el = rootRef.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => {
inView.current = e.isIntersecting;
},
{ threshold: 0.25 }
);
io.observe(el);
const id = window.setInterval(() => {
if (paused || !inView.current || document.hidden) return;
setState(([i]) => [(i + 1) % count, 1]);
}, AUTOPLAY_MS);
return () => {
io.disconnect();
window.clearInterval(id);
};
}, [reduce, paused, count]);
// keyboard arrows when the carousel has focus
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
prev();
}
};
const t = testimonials[index];
const variants = {
enter: (d: number) =>
reduce
? { opacity: 0 }
: { opacity: 0, x: d > 0 ? 60 : -60, filter: "blur(6px)" },
center: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: (d: number) =>
reduce
? { opacity: 0 }
: { opacity: 0, x: d > 0 ? -60 : 60, filter: "blur(6px)" },
};
return (
<section
ref={rootRef}
className="quotes frame"
aria-labelledby="quotes-h"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={() => setPaused(false)}
>
<div className="wrap">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
In their words sample
</p>
<Reveal as="h2" variant="clip">
<span id="quotes-h" className="display sec-head__title">
The number is the point
</span>
</Reveal>
</header>
<div
className="carousel"
role="group"
aria-roledescription="carousel"
aria-label="Client testimonials"
tabIndex={0}
onKeyDown={onKeyDown}
>
<div className="carousel__stage">
{/* decorative oversized quote mark */}
<span className="carousel__mark display" aria-hidden="true">
&ldquo;
</span>
<AnimatePresence mode="wait" custom={dir} initial={false}>
<motion.figure
key={index}
className="carousel__slide"
custom={dir}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: reduce ? 0.2 : 0.55, ease: EASE_OUT }}
aria-roledescription="slide"
aria-label={`${index + 1} of ${count}`}
>
<blockquote className="carousel__quote display">
{t.quote}
</blockquote>
<figcaption className="carousel__by">
<span className="carousel__avatar">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={t.avatar}
alt=""
width={64}
height={64}
loading="lazy"
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%", display: "block" }}
/>
</span>
<span className="carousel__id">
<span className="carousel__name">{t.name}</span>
<span className="carousel__role">
{t.role}, {t.company}
</span>
</span>
<span className="carousel__chip">{t.metric}</span>
</figcaption>
</motion.figure>
</AnimatePresence>
</div>
{/* controls */}
<div className="carousel__controls">
<button
type="button"
className="carousel__arrow"
onClick={prev}
aria-label="Previous testimonial"
data-cursor="Prev"
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
d="M15 5l-7 7 7 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<ul className="carousel__dots" role="tablist" aria-label="Choose testimonial">
{testimonials.map((tt, i) => (
<li key={tt.name} role="presentation">
<button
type="button"
role="tab"
aria-selected={i === index}
aria-label={`${tt.name}, ${tt.company}`}
className={`carousel__dot ${i === index ? "is-active" : ""}`}
onClick={() => go(i, i > index ? 1 : -1)}
/>
</li>
))}
</ul>
<button
type="button"
className="carousel__arrow"
onClick={next}
aria-label="Next testimonial"
data-cursor="Next"
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
d="M9 5l7 7-7 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
{/* avatar rail — all clients; doubles as quick nav */}
<ul className="carousel__rail" aria-hidden="true">
{testimonials.map((tt, i) => (
<li key={tt.name}>
<button
type="button"
tabIndex={-1}
className={`carousel__railitem ${i === index ? "is-active" : ""}`}
onClick={() => go(i, i > index ? 1 : -1)}
>
<span className="carousel__railmono">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={tt.avatar}
alt=""
width={44}
height={44}
loading="lazy"
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%", display: "block" }}
/>
</span>
<span className="carousel__railname">{tt.company}</span>
</button>
</li>
))}
</ul>
{/* live region for screen readers */}
<p className="sr-only" aria-live="polite">
Testimonial {index + 1} of {count}: {t.name}, {t.role} at {t.company}.
</p>
</div>
</section>
);
}