"use client"; import { useEffect, useId, useRef, useState, type CSSProperties, type ElementType, type KeyboardEvent, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; export type ProductShowcaseFeature = { title: string; text: string; /** Fills the stage. An image with object-cover works well. */ media: ReactNode; /** Where the marker sits on the stage, in percent. Leave out for no marker. */ spot?: { x: number; y: number }; }; type ProductShowcaseProps = { features: ProductShowcaseFeature[]; title?: string; /** Small line above the title. */ eyebrow?: string; subtitle?: string; /** Seconds on each feature. 0 turns the auto tour off. */ interval?: number; /** Colour of the marker and the active feature. */ color?: string; /** Stage colour. */ tile?: string; /** Put the stage on the right. */ reverse?: boolean; /** h2 by default. */ headingAs?: ElementType; /** Set padding and colours here. */ className?: string; }; /* ProductShowcase: one product stage beside a list of features. Each feature changes the view and moves a marker to the part it talks about. The tour runs by itself until someone picks a feature; it waits on hover, on focus and off screen. */ export function ProductShowcase({ features, title, eyebrow, subtitle, interval = 6, color = "#a78bfa", tile = "#15122a", reverse = false, headingAs: Heading = "h2", className = "bg-[#0b0a14] px-5 py-16 text-white", }: ProductShowcaseProps) { const reduce = useReducedMotion(); const id = useId(); const box = useRef(null); const bar = useRef(null); const tabs = useRef<(HTMLButtonElement | null)[]>([]); const timer = useRef(null); const held = useRef({ hover: false, focus: false, away: true }); const [active, setActive] = useState(0); const [touring, setTouring] = useState(true); const tour = touring && interval > 0 && !reduce; const count = features.length; const spot = features[active].spot; const hold = (key: "hover" | "focus" | "away", on: boolean) => { held.current[key] = on; const { hover, focus, away } = held.current; if (hover || focus || away) timer.current?.pause(); else timer.current?.play(); }; useEffect(() => { const line = bar.current; if (!tour || !line) return; const run = line.animate([{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], { duration: interval * 1000, fill: "forwards" }); run.onfinish = () => setActive((now) => (now + 1) % count); timer.current = run; const { hover, focus, away } = held.current; if (hover || focus || away) run.pause(); return () => { run.cancel(); timer.current = null; }; }, [active, count, interval, tour]); useEffect(() => { const section = box.current; if (!section) return; const onScreen = new IntersectionObserver( ([entry]) => { held.current.away = !entry.isIntersecting; const { hover, focus, away } = held.current; if (hover || focus || away) timer.current?.pause(); else timer.current?.play(); }, { threshold: 0.2 }, ); onScreen.observe(section); return () => onScreen.disconnect(); }, []); const pick = (i: number) => { setTouring(false); setActive(i); }; const keys = (e: KeyboardEvent, i: number) => { const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key]; const to = step ? (i + step + count) % count : e.key === "Home" ? 0 : e.key === "End" ? count - 1 : -1; if (to < 0) return; e.preventDefault(); pick(to); tabs.current[to]?.focus(); }; return (
e.pointerType === "mouse" && hold("hover", true)} onPointerLeave={() => hold("hover", false)} onFocus={() => hold("focus", true)} onBlur={() => hold("focus", false)} className={`@container ${className}`} style={{ "--ps-color": color } as CSSProperties} > {title && (
{eyebrow &&

{eyebrow}

} {title} {subtitle &&

{subtitle}

}
)}
{features[active].media} {spot && ( )}
{features.map((feature, i) => { const on = i === active; return ( ); })}
); }