From 84b00541b5cbaf0ca88564f898a3cb54a98a0b89 Mon Sep 17 00:00:00 2001 From: Vula Builder Date: Sun, 11 Oct 2026 14:20:26 +0000 Subject: [PATCH] Deploy --- src/components/hero/HeroSlideshow.tsx | 185 ++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/components/hero/HeroSlideshow.tsx diff --git a/src/components/hero/HeroSlideshow.tsx b/src/components/hero/HeroSlideshow.tsx new file mode 100644 index 0000000..e811cf2 --- /dev/null +++ b/src/components/hero/HeroSlideshow.tsx @@ -0,0 +1,185 @@ +"use client" + +import Image from "next/image" +import Link from "next/link" +import { useEffect, useRef, useState } from "react" + +export interface HeroSlide { + image: string + alt: string + eyebrow: string + headline: string + subtitle: string + ctaText: string + ctaHref: string + /** The business's own headline: the page's one h1. */ + primary?: boolean +} + +const SLIDE_MS = 6500 + +function splitEmphasis(headline: string): { lead: string; emph: string } { + const words = headline.split(/\s+/) + if (words.length < 3) return { lead: headline, emph: "" } + const n = words.length > 5 ? 2 : 1 + return { lead: words.slice(0, -n).join(" ") + " ", emph: words.slice(-n).join(" ") } +} + +export default function HeroSlideshow({ slides }: { slides: HeroSlide[] }) { + const count = slides.length + const [active, setActive] = useState(0) + const [paused, setPaused] = useState(false) + const [held, setHeld] = useState(false) + const [still, setStill] = useState(false) + const touchX = useRef(null) + + useEffect(() => { + const query = window.matchMedia("(prefers-reduced-motion: reduce)") + const sync = () => setStill(query.matches) + sync() + query.addEventListener("change", sync) + return () => query.removeEventListener("change", sync) + }, []) + + const playing = count > 1 && !paused && !held && !still + + useEffect(() => { + if (!playing) return + const timer = window.setTimeout(() => setActive((a) => (a + 1) % count), SLIDE_MS) + return () => window.clearTimeout(timer) + }, [playing, active, count]) + + const go = (i: number) => setActive(((i % count) + count) % count) + + return ( +
setHeld(true)} + onMouseLeave={() => setHeld(false)} + onFocus={() => setHeld(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHeld(false) + }} + onKeyDown={(e) => { + if (count < 2) return + if (e.key === "ArrowRight") go(active + 1) + if (e.key === "ArrowLeft") go(active - 1) + }} + onTouchStart={(e) => { + touchX.current = e.touches[0]?.clientX ?? null + }} + onTouchEnd={(e) => { + const from = touchX.current + const to = e.changedTouches[0]?.clientX + touchX.current = null + if (count < 2 || from == null || to == null || Math.abs(to - from) < 40) return + go(active + (to < from ? 1 : -1)) + }} + > +
+ {slides.map((slide, i) => { + const shown = i === active + const { lead, emph } = splitEmphasis(slide.headline) + const Heading = slide.primary ? "h1" : "h2" + return ( +
+ {!slide.headline ? ( + <> + {/* A poster: its own words are in the picture, so it is shown + whole over a blurred copy of itself — never cropped, never + darkened, never written over. */} + +
+ {slide.alt} +
+ {slide.ctaText && slide.ctaHref && ( +
+ + {slide.ctaText} + +
+ )} + + ) : ( + <> + {slide.image ? ( +
+ {slide.alt} +
+ ) : ( +
+ )} +
+ + ) + })} +
+ {count > 1 && ( +
+ +
+ {slides.map((slide, i) => ( + + ))} +
+ + {!still && ( + + )} +
+ )} +
+ ) +}