"use client"; import { PauseIcon, PlayIcon, QuoteIcon, StarIcon } from "lucide-react"; import { motion, useAnimationFrame, useMotionValue, useReducedMotion } from "motion/react"; import { type RefObject, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; // Stays false until mounted so the server render and the first client render match. function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export interface WallQuote { quote: string; highlight?: string; name: string; role: string; company: string; } export interface QuoteWallProps { quotes: WallQuote[]; eyebrow?: string; title?: string; rating?: { score: number; count: string }; speed?: number; className?: string; } const avatarTones = [ "bg-info/16 text-info-foreground", "bg-success/16 text-success-foreground", "bg-warning/12 text-warning-foreground", "bg-muted text-foreground", ]; function initials(name: string) { return name .split(/\s+/) .map((part) => part[0]) .slice(0, 2) .join("") .toUpperCase(); } function toneFor(name: string) { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return avatarTones[hash % avatarTones.length]; } function QuoteText({ text, highlight }: { text: string; highlight?: string }) { const at = highlight ? text.indexOf(highlight) : -1; if (!highlight || at < 0) return <>{text}>; return ( <> {text.slice(0, at)} {highlight} {text.slice(at + highlight.length)} > ); } function QuoteCard({ item }: { item: WallQuote }) { return ( {initials(item.name)} {item.name} {item.role}, {item.company} ); } function ScrollColumn({ items, speed, reverse, offset, hovered, paused, }: { items: WallQuote[]; speed: number; reverse: boolean; offset: number; hovered: RefObject; paused: boolean; }) { const reduce = useSafeReducedMotion(); const viewportRef = useRef(null); const copyRef = useRef(null); const [copyHeight, setCopyHeight] = useState(0); const [copies, setCopies] = useState(2); const y = useMotionValue(0); const velocity = useRef(0); useEffect(() => { const viewport = viewportRef.current; const copy = copyRef.current; if (!viewport || !copy) return; const measure = () => { const h = copy.offsetHeight; setCopyHeight(h); if (h > 0) { setCopies(Math.max(2, Math.ceil(viewport.offsetHeight / h) + 1)); y.set(-h * offset); } }; measure(); const observer = new ResizeObserver(measure); observer.observe(copy); return () => observer.disconnect(); }, [offset, y]); useAnimationFrame((_, delta) => { if (!copyHeight || reduce) return; const dt = Math.min(delta, 50) / 1000; const still = hovered.current || paused; const target = still ? 0 : reverse ? speed : -speed; const rate = still ? 3.2 : 1.4; velocity.current += (target - velocity.current) * (1 - Math.exp(-rate * dt)); if (target === 0 && Math.abs(velocity.current) < 0.3) { velocity.current = 0; return; } const next = y.get() + velocity.current * dt; y.set((((next % copyHeight) - copyHeight) % copyHeight) || 0); }); return ( {Array.from({ length: reduce ? 1 : copies }, (_, copy) => ( 0 || undefined} className="flex flex-col gap-4 pb-4" key={copy} ref={copy === 0 ? copyRef : undefined} > {items.map((item) => ( ))} ))} ); } export function QuoteWall({ quotes, eyebrow = "Wall of love", title = "People keep telling us nice things", rating, speed = 28, className, }: QuoteWallProps) { const reduce = useSafeReducedMotion(); const wallRef = useRef(null); const hovered = useRef(false); const [paused, setPaused] = useState(false); const [columnCount, setColumnCount] = useState(3); useEffect(() => { const wall = wallRef.current; if (!wall) return; const observer = new ResizeObserver(([entry]) => { const w = entry.contentRect.width; setColumnCount(w < 560 ? 1 : w < 860 ? 2 : 3); }); observer.observe(wall); return () => observer.disconnect(); }, []); const columns = Array.from({ length: columnCount }, (_, c) => quotes.filter((_, i) => i % columnCount === c)); return ( {eyebrow && {eyebrow}} {title} {rating && ( {Array.from({ length: 5 }, (_, i) => ( ))} {rating.score.toFixed(1)} from {rating.count} reviews )} {!reduce && ( setPaused((p) => !p)} type="button" > {paused ? : } )} { if (e.pointerType === "mouse") hovered.current = true; }} onPointerLeave={() => { hovered.current = false; }} ref={wallRef} style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }} > {columns.map((items, c) => ( ))} ); }
{eyebrow}