"use client"; import { Star } from "lucide-react"; import { useReducedMotion } from "motion/react"; import type { CSSProperties, ReactNode } from "react"; import { Marquee } from "@/components/motion/marquee"; import { cn } from "@/lib/utils"; export interface Testimonial { id: string; quote: ReactNode; name: string; role?: ReactNode; /** Image URL. Falls back to initials derived from `name`. */ avatar?: string; /** Override the derived initials. */ initials?: string; /** 0–5 stars. Omit to hide the rating row. */ rating?: number; } export interface TestimonialWallProps { testimonials: Testimonial[]; /** "marquee" scrolls columns in alternating directions; "grid" is static. */ variant?: "marquee" | "grid"; /** Maximum number of columns at the widest container size (1–4). Default 3. */ columns?: 1 | 2 | 3 | 4; /** Seconds for one loop of the first column. Others vary slightly. Default 40. */ speed?: number; /** Height of the marquee viewport. Default 32rem. Ignored by `grid`. */ height?: number | string; /** Pause a column while it is hovered or holds focus. Default true. */ pauseOnHover?: boolean; eyebrow?: ReactNode; heading?: ReactNode; description?: ReactNode; className?: string; } // Show/hide classes for the 1/2/3/4-lane layouts. Lane counts step up with // the block's own container width (not the viewport): 2 at @md, 3 at @3xl, // 4 at @5xl. Spelled out in full so Tailwind can see every class. const SHOW_AT = ["flex", "hidden @md:flex", "hidden @3xl:flex", "hidden @5xl:flex"]; const HIDE_AT = ["", "@md:hidden", "@3xl:hidden", "@5xl:hidden"]; const GRID_COLUMNS = [ "columns-1", "columns-1 @md:columns-2", "columns-1 @md:columns-2 @3xl:columns-3", "columns-1 @md:columns-2 @3xl:columns-3 @5xl:columns-4", ]; const FADE_MASK = "[mask-image:linear-gradient(to_bottom,transparent,black_14%,black_86%,transparent)]"; function initialsOf(name: string) { return name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase() ?? "") .join(""); } /** Deterministic palette slot so a person keeps the same avatar tint. */ function tintOf(name: string) { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return (hash % 5) + 1; } function distribute(items: T[], lanes: number): T[][] { const out: T[][] = Array.from({ length: lanes }, () => []); items.forEach((item, index) => { out[index % lanes]?.push(item); }); return out; } /** Repeat a short lane until it has at least `min` cards. */ function fillLane(lane: T[], min = 4) { const out: { item: T; copy: number }[] = lane.map((item) => ({ item, copy: 0 })); if (lane.length === 0) return out; let copy = 1; while (out.length < min) { for (const item of lane) { if (out.length >= min) break; out.push({ item, copy }); } copy += 1; } return out; } export function TestimonialCard({ testimonial, className, }: { testimonial: Testimonial; className?: string; }) { const { quote, name, role, avatar, rating } = testimonial; const tint = tintOf(name); const stars = rating === undefined ? null : Math.max(0, Math.min(5, Math.round(rating))); return (
{stars !== null ? (
{`Rated ${stars} out of 5`} {[0, 1, 2, 3, 4].map((i) => ( ))}
) : null}
{quote}
{avatar ? ( // biome-ignore lint/performance/noImgElement: library component; consumers may not use next/image. ) : ( {testimonial.initials ?? initialsOf(name)} )}
{name}
{role ? (
{role}
) : null}
); } /** * Testimonial wall: masonry-like columns of quote cards. The marquee variant * scrolls each column vertically in alternating directions behind top and * bottom fade masks, pausing a column on hover or focus; reduced motion and * `variant="grid"` render the same cards as a static masonry. Column count * follows the block's own container width. */ export function TestimonialWall({ testimonials, variant = "marquee", columns = 3, speed = 40, height = "32rem", pauseOnHover = true, eyebrow, heading, description, className, }: TestimonialWallProps) { const reduce = useReducedMotion(); const maxLanes = Math.max(1, Math.min(4, columns)); const animated = variant === "marquee" && !reduce; const header = eyebrow || heading || description ? (
{eyebrow ? (

{eyebrow}

) : null} {heading ? (

{heading}

) : null} {description ? (

{description}

) : null}
) : null; const grid = (
{testimonials.map((t) => ( ))}
); return (
{header} {variant === "grid" ? ( grid ) : !animated ? ( // Reduced motion: same viewport, static masonry that scrolls by hand.
{grid}
) : (
{Array.from({ length: maxLanes }, (_, i) => i + 1).map((laneCount) => (
{distribute(testimonials, laneCount).map((lane, laneIndex) => ( div]:w-full [&>div]:items-stretch", pauseOnHover && "[&:focus-within>div]:[animation-play-state:paused]", )} > {fillLane(lane).map(({ item, copy }) => copy ? ( // Padding copies only exist so a short lane still // covers the viewport; hide them from assistive tech.
) : ( ), )}
))}
))}
)}
); }