"use client"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { motion, useInView, useReducedMotion } from "motion/react"; type Love = { quote: string; name: string; role: string; initials?: string }; type WallOfLoveProps = { /** On the plate above the note. */ title?: string; /** Who said what. The locks share these out in turn. */ testimonials?: Love[]; /** Ms before the next lock opens by itself. 0 stops that. */ every?: number; /** Set the width here. Everything scales with it. */ className?: string; }; const LOVES: Love[] = [ { quote: "Set it up on a Sunday. By Monday my whole shop was running on it.", name: "Meena R.", role: "Tiffin centre, Madurai" }, { quote: "My customers ask who made the site. That has never happened before.", name: "Arun K.", role: "Bike garage, Coimbatore" }, { quote: "Every time I write in with a doubt, a real person answers the same day.", name: "Fathima S.", role: "Tailor, Chennai" }, { quote: "Bills that took an hour now take five minutes. I go home early.", name: "Joseph D.", role: "Hardware shop, Tuticorin" }, { quote: "Small price, no tricks, and it simply works. I tell everyone.", name: "Lakshmi V.", role: "Flower stall, Salem" }, { quote: "My daughter built our page in one evening. It looks expensive.", name: "Ravi P.", role: "Sweet stall, Trichy" }, ]; const HEART = [".##.##.", "#######", "#######", ".#####.", "..###..", "...#..."]; const CELL_X = 7.05; // cqw const CELL_Y = 8.5; const LOCKS = HEART.flatMap((row, y) => Array.from(row).flatMap((c, x) => (c === "#" ? [{ row: y, col: x, x: 2.6 + x * CELL_X, y: 9 + y * CELL_Y }] : [])), ); const COUNT = LOCKS.length; const FINISH = [ { body: "#c99a3f", edge: "#7b5716", shine: "#f3d78c", ink: "#5e410d" }, // brass { body: "#c8321f", edge: "#6d120c", shine: "#f28a79", ink: "#fbe6d8" }, // red enamel { body: "#a9adb4", edge: "#555a63", shine: "#e7eaee", ink: "#33373d" }, // steel { body: "#16827a", edge: "#0a4641", shine: "#6fd3c8", ink: "#dff5f1" }, // teal enamel { body: "#b56a38", edge: "#663417", shine: "#eab58a", ink: "#4d260f" }, // copper { body: "#e37a9a", edge: "#8b2f4f", shine: "#fbc5d6", ink: "#55162e" }, // pink enamel ]; const initials = (love: Love) => love.initials ?? love.name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase(); function Lock({ index, text, open }: { index: number; text: string; open: boolean }) { const f = FINISH[(index * 7 + Math.floor(index / 4)) % FINISH.length]; const shape = (index * 7) % 3; return ( {shape === 0 && } {shape === 1 && } {shape === 2 && } {text} ); } /* WallOfLove: testimonials as love locks on a chain-link fence at dusk, hung in the shape of a heart. They swing as the pointer passes. Each one opens with a click and its note hangs out on a red thread, so the wall works as both the whole crowd and one voice at a time. */ export function WallOfLove({ title = "Wall of love", testimonials = LOVES, every = 5400, className = "w-full max-w-3xl" }: WallOfLoveProps) { const reduce = useReducedMotion(); const id = useId().replace(/[^a-zA-Z0-9]/g, ""); const box = useRef(null); const stage = useRef(null); const seen = useInView(box, { once: true, amount: 0.35 }); const n = testimonials.length; const [active, setActive] = useState(3); const [chosen, setChosen] = useState(false); const [paused, setPaused] = useState(false); const swings = useRef(LOCKS.map(() => ({ a: 0, w: 0 }))); const raf = useRef(0); const last = useRef(0); const prev = useRef<{ x: number; t: number } | null>(null); const runSwing = () => { if (raf.current) return; last.current = performance.now(); const tick = (now: number) => { const dt = Math.min(0.033, (now - last.current) / 1000); last.current = now; const root = stage.current; if (!root) return; let moving = false; swings.current.forEach((s, i) => { s.w += (-90 * s.a - 2.6 * s.w) * dt; s.a += s.w * dt; if (Math.abs(s.a) > 0.05 || Math.abs(s.w) > 0.4) moving = true; else { s.a = 0; s.w = 0; } const el = root.querySelector(`[data-swing="${i}"]`); if (el) el.style.transform = `rotate(${s.a.toFixed(2)}deg)`; }); raf.current = moving ? requestAnimationFrame(tick) : 0; }; raf.current = requestAnimationFrame(tick); }; useEffect( () => () => { cancelAnimationFrame(raf.current); raf.current = 0; }, [], ); const pull = (i: number, push: number) => { if (reduce) return; const s = swings.current[i]; if (!s) return; s.w = Math.max(-150, Math.min(150, s.w + push)); runSwing(); }; useEffect(() => { if (!seen || !every || chosen || paused || reduce) return; const t = setTimeout(() => { setActive((a) => (a + 11) % COUNT); }, every); return () => clearTimeout(t); }, [seen, every, chosen, paused, reduce, active]); useEffect(() => { pull(active, 62); // eslint-disable-next-line react-hooks/exhaustive-deps }, [active]); const brush = (e: PointerEvent) => { const root = stage.current; if (!root || reduce) return; const r = root.getBoundingClientRect(); const scale = 100 / r.width; const x = (e.clientX - r.left) * scale; const y = (e.clientY - r.top) * scale; const before = prev.current; prev.current = { x, t: e.timeStamp }; if (!before || e.timeStamp - before.t > 80) return; const speed = (x - before.x) * 8; LOCKS.forEach((lock, i) => { const dx = lock.x + 3.5 - x; const dy = lock.y + 4 - y; if (dx * dx + dy * dy < 42) pull(i, speed); }); }; const choose = (i: number) => { setChosen(true); setActive(i); }; const onKey = (e: KeyboardEvent) => { const here = LOCKS[active]; if (!here) return; let to = -1; if (e.key === "ArrowRight") to = (active + 1) % COUNT; else if (e.key === "ArrowLeft") to = (active + COUNT - 1) % COUNT; else if (e.key === "Home") to = 0; else if (e.key === "End") to = COUNT - 1; else if (e.key === "ArrowUp" || e.key === "ArrowDown") { const row = here.row + (e.key === "ArrowUp" ? -1 : 1); let far = Infinity; LOCKS.forEach((l, i) => { const d = Math.abs(l.col - here.col); if (l.row === row && d < far) { far = d; to = i; } }); } if (to < 0) return; e.preventDefault(); choose(to); e.currentTarget.querySelector(`[data-lock="${to}"]`)?.focus(); }; const love = testimonials[active % n]; const lock = LOCKS[active]; const hookX = 60.5; const hookY = 15.4; const startX = (lock?.x ?? 0) + 3.5; const startY = (lock?.y ?? 0) + 7; return (
setPaused(true)} onPointerLeave={() => { setPaused(false); prev.current = null; }} onFocusCapture={() => setPaused(true)} onBlurCapture={() => setPaused(false)} className="relative overflow-hidden rounded-[1.6cqw] select-none" style={{ paddingTop: "66cqw", background: "linear-gradient(180deg, #16193a, #2c2752 46%, #7a3f63 78%, #c96d5f)" }} > {/* Lights far off across the water. */}
{/* Chain link. */}
{LOCKS.map((l, i) => { const who = testimonials[i % n]; const on = i === active; return ( ); })}
{/* Red thread from the open lock to the note. */}
{title}
{love?.quote}
{love?.name} {love?.role}
    {testimonials.map((t) => (
  • {t.quote} {t.name}, {t.role}.
  • ))}
); }