Deploy
This commit is contained in:
@@ -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<number | null>(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 (
|
||||||
|
<section
|
||||||
|
data-vula-section="hero"
|
||||||
|
id="hero"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
aria-label="Highlights"
|
||||||
|
className="relative overflow-hidden text-white"
|
||||||
|
onMouseEnter={() => 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))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid" aria-live={playing ? "off" : "polite"}>
|
||||||
|
{slides.map((slide, i) => {
|
||||||
|
const shown = i === active
|
||||||
|
const { lead, emph } = splitEmphasis(slide.headline)
|
||||||
|
const Heading = slide.primary ? "h1" : "h2"
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
aria-label={`${i + 1} of ${count}`}
|
||||||
|
aria-hidden={!shown}
|
||||||
|
className={`relative col-start-1 row-start-1 min-h-svh transition-opacity duration-1000 ease-out ${shown ? "opacity-100 z-10" : "opacity-0 z-0 pointer-events-none"}`}
|
||||||
|
>
|
||||||
|
{!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. */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||||
|
<Image src={slide.image} alt="" fill className="object-cover scale-110 blur-2xl brightness-50" sizes="100vw" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-x-4 top-20 bottom-20 sm:inset-x-10">
|
||||||
|
<Image src={slide.image} alt={slide.alt} fill className="object-contain" sizes="100vw" priority={i === 0} />
|
||||||
|
</div>
|
||||||
|
{slide.ctaText && slide.ctaHref && (
|
||||||
|
<div className="absolute inset-x-0 bottom-24 z-10 flex justify-center px-6">
|
||||||
|
<Link href={slide.ctaHref} tabIndex={shown ? undefined : -1} className="inline-block border border-accent text-accent px-9 py-4 text-[12.5px] uppercase tracking-[0.24em] hover:bg-accent hover:text-accent-foreground transition-all duration-300">
|
||||||
|
{slide.ctaText}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{slide.image ? (
|
||||||
|
<div className={`absolute inset-0 transition-transform duration-[7000ms] ease-out motion-reduce:transition-none ${shown && !still ? "scale-105" : "scale-100"}`}>
|
||||||
|
<Image src={slide.image} alt={slide.alt} fill className="object-cover" sizes="100vw" priority={i === 0} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary to-secondary" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/50 to-black/30" />
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-black/50 to-transparent" />
|
||||||
|
<div className="relative z-10 flex min-h-svh flex-col items-center justify-center px-6 pt-24 pb-28 text-center">
|
||||||
|
{slide.eyebrow && <p className="uppercase tracking-[0.32em] text-[11.5px] text-accent mb-4">{slide.eyebrow}</p>}
|
||||||
|
<Heading className="text-5xl lg:text-6xl font-normal tracking-[0.01em] mb-6 max-w-4xl">
|
||||||
|
{lead}{emph && <em className="italic text-accent">{emph}</em>}
|
||||||
|
</Heading>
|
||||||
|
{slide.subtitle && <p className="text-lg lg:text-xl text-white/80 max-w-2xl mb-9">{slide.subtitle}</p>}
|
||||||
|
{slide.ctaText && slide.ctaHref && (
|
||||||
|
<Link href={slide.ctaHref} tabIndex={shown ? undefined : -1} className="inline-block border border-accent text-accent px-9 py-4 text-[12.5px] uppercase tracking-[0.24em] hover:bg-accent hover:text-accent-foreground transition-all duration-300">
|
||||||
|
{slide.ctaText}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{count > 1 && (
|
||||||
|
<div className="absolute inset-x-0 bottom-6 z-20 flex items-center justify-center gap-3">
|
||||||
|
<button type="button" onClick={() => go(active - 1)} aria-label="Previous slide" className="flex h-11 w-11 items-center justify-center rounded-full bg-black/35 text-white backdrop-blur-sm hover:bg-black/55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white transition-colors">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 18l-6-6 6-6" /></svg>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{slides.map((slide, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => go(i)}
|
||||||
|
aria-label={`Go to slide ${i + 1}: ${slide.headline || slide.alt}`}
|
||||||
|
aria-current={i === active ? "true" : undefined}
|
||||||
|
className="p-2"
|
||||||
|
>
|
||||||
|
<span className={`block h-2 rounded-full transition-all duration-300 ${i === active ? "w-8 bg-white" : "w-2 bg-white/50 hover:bg-white/80"}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => go(active + 1)} aria-label="Next slide" className="flex h-11 w-11 items-center justify-center rounded-full bg-black/35 text-white backdrop-blur-sm hover:bg-black/55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white transition-colors">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||||
|
</button>
|
||||||
|
{!still && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaused((p) => !p)}
|
||||||
|
aria-label={paused ? "Play slideshow" : "Pause slideshow"}
|
||||||
|
className="flex h-11 w-11 items-center justify-center rounded-full bg-black/35 text-white backdrop-blur-sm hover:bg-black/55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white transition-colors"
|
||||||
|
>
|
||||||
|
{paused ? (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
|
||||||
|
) : (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z" /></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user