"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTime,
useTransform,
type MotionValue,
} from "motion/react";
import { Check, Heart, RotateCcw, ShieldCheck, ShoppingBag, Snowflake, Star, Truck } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ProductColor {
name: string;
/** Main body colour (hex). */
base: string;
/** Shade used on the bottle edges (hex). */
shade: string;
/** Colour of the label text / cap accent (hex). */
ink: string;
}
export interface HeroSplitProductProps {
badge?: string;
name?: string;
description?: string;
price?: number;
compareAt?: number;
currency?: string;
rating?: number;
reviews?: number;
colors?: ProductColor[];
sizes?: string[];
/** Short labels that orbit the product. */
features?: string[];
brand?: string;
onAddToCart?: (sel: { color: string; size: string }) => void;
className?: string;
}
const EASE = [0.22, 1, 0.36, 1] as const;
const DEFAULT_COLORS: ProductColor[] = [
{ name: "Glacier", base: "#7cc4e8", shade: "#2f6f96", ink: "#0c3550" },
{ name: "Ember", base: "#f28b5b", shade: "#a8401d", ink: "#4a1706" },
{ name: "Moss", base: "#8fb275", shade: "#46663a", ink: "#1f3317" },
{ name: "Onyx", base: "#3b3b42", shade: "#141418", ink: "#e9e9ee" },
];
export function HeroSplitProduct({
badge = "New · Spring drop",
name = "Lumen Flask Pro",
description = "Double-wall vacuum steel keeps drinks ice-cold for 24 hours and hot for 12. Leak-proof cap, powder-coat grip, and a shape that slips into any bag.",
price = 48,
compareAt = 64,
currency = "USD",
rating = 4.9,
reviews = 2184,
colors = DEFAULT_COLORS,
sizes = ["500 ml", "750 ml", "1 L"],
features = ["24h ice-cold", "BPA-free steel", "Leak-proof cap", "Lifetime warranty"],
brand = "LUMEN",
onAddToCart,
className,
}: HeroSplitProductProps) {
const reduce = useReducedMotion();
const uid = React.useId();
const [colorIdx, setColorIdx] = React.useState(0);
const [size, setSize] = React.useState(sizes[Math.min(1, sizes.length - 1)] ?? "");
const [added, setAdded] = React.useState(false);
const [liked, setLiked] = React.useState(false);
const color = colors[colorIdx] ?? DEFAULT_COLORS[0];
const fmt = React.useMemo(() => new Intl.NumberFormat("en-US", { style: "currency", currency }), [currency]);
React.useEffect(() => {
if (!added) return;
const t = setTimeout(() => setAdded(false), 2200);
return () => clearTimeout(t);
}, [added]);
const fade = (d: number) => ({
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 16 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.7, delay: d, ease: EASE },
});
return (
<section className={cn("relative w-full overflow-hidden bg-background text-foreground", className)}>
<div className="mx-auto grid max-w-6xl items-center gap-10 px-5 py-14 sm:px-8 lg:grid-cols-[1fr_1.1fr] lg:gap-6 lg:py-20">
{/* Copy */}
<div className="order-2 lg:order-1">
<motion.span {...fade(0)} className="inline-flex items-center gap-1.5 rounded-full border bg-card px-3 py-1 text-xs font-medium text-muted-foreground">
<span className="size-1.5 rounded-full" style={{ background: color.base }} />
{badge}
</motion.span>
<motion.h1 {...fade(0.05)} className="mt-5 text-4xl font-semibold tracking-tight sm:text-5xl lg:text-6xl">
{name}
</motion.h1>
<motion.div {...fade(0.1)} className="mt-4 flex items-center gap-2 text-sm">
<div className="flex" aria-hidden>
{[0, 1, 2, 3, 4].map((i) => (
<Star key={i} className={cn("size-4", i < Math.round(rating) ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40")} />
))}
</div>
<span className="font-medium">{rating.toFixed(1)}</span>
<span className="text-muted-foreground">({reviews.toLocaleString("en-US")} reviews)</span>
</motion.div>
<motion.p {...fade(0.15)} className="mt-5 max-w-md text-pretty leading-relaxed text-muted-foreground">
{description}
</motion.p>
<motion.div {...fade(0.2)} className="mt-6 flex items-baseline gap-3">
<span className="text-3xl font-semibold tabular-nums">{fmt.format(price)}</span>
{compareAt && compareAt > price && (
<>
<span className="text-lg tabular-nums text-muted-foreground line-through">{fmt.format(compareAt)}</span>
<span className="rounded-md bg-emerald-500/12 px-2 py-0.5 text-xs font-semibold text-emerald-700 dark:text-emerald-400">
Save {Math.round((1 - price / compareAt) * 100)}%
</span>
</>
)}
</motion.div>
<motion.div {...fade(0.25)} className="mt-7 space-y-5">
<fieldset>
<legend className="text-sm font-medium">
Color <span className="font-normal text-muted-foreground">— {color.name}</span>
</legend>
<div role="radiogroup" aria-label="Color" className="mt-2.5 flex gap-2.5">
{colors.map((c, i) => (
<button
key={c.name}
type="button"
role="radio"
aria-checked={i === colorIdx}
aria-label={c.name}
onClick={() => setColorIdx(i)}
className={cn(
"relative size-9 rounded-full ring-offset-2 ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
i === colorIdx ? "ring-2 ring-foreground" : "ring-1 ring-border hover:ring-foreground/40",
)}
style={{ background: `linear-gradient(135deg, ${c.base}, ${c.shade})` }}
/>
))}
</div>
</fieldset>
{sizes.length > 0 && (
<fieldset>
<legend className="text-sm font-medium">Size</legend>
<div role="radiogroup" aria-label="Size" className="mt-2.5 inline-flex rounded-xl border bg-muted/50 p-1">
{sizes.map((s) => (
<button
key={s}
type="button"
role="radio"
aria-checked={s === size}
onClick={() => setSize(s)}
className="relative rounded-lg px-4 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{s === size && (
<motion.span layoutId={`${uid}-size`} className="absolute inset-0 rounded-lg bg-background shadow-sm" transition={{ type: "spring", bounce: 0.2, duration: 0.45 }} />
)}
<span className={cn("relative", s === size ? "text-foreground" : "text-muted-foreground")}>{s}</span>
</button>
))}
</div>
</fieldset>
)}
</motion.div>
<motion.div {...fade(0.3)} className="mt-8 flex gap-3">
<button
type="button"
onClick={() => {
setAdded(true);
onAddToCart?.({ color: color.name, size });
}}
className="relative inline-flex h-12 flex-1 items-center justify-center overflow-hidden rounded-xl bg-foreground px-6 text-sm font-semibold text-background transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:max-w-xs"
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={added ? "added" : "add"}
initial={{ y: 18, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -18, opacity: 0 }}
transition={{ duration: 0.22 }}
className="inline-flex items-center gap-2"
>
{added ? <Check className="size-4" aria-hidden /> : <ShoppingBag className="size-4" aria-hidden />}
{added ? "Added to cart" : `Add to cart · ${fmt.format(price)}`}
</motion.span>
</AnimatePresence>
<span className="sr-only" aria-live="polite">{added ? `${name} added to cart` : ""}</span>
</button>
<button
type="button"
aria-pressed={liked}
aria-label={liked ? "Remove from wishlist" : "Add to wishlist"}
onClick={() => setLiked((v) => !v)}
className="grid size-12 shrink-0 place-items-center rounded-xl border bg-card transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<motion.span animate={liked && !reduce ? { scale: [1, 1.35, 1] } : { scale: 1 }} transition={{ duration: 0.35 }}>
<Heart className={cn("size-5", liked ? "fill-rose-500 text-rose-500" : "text-muted-foreground")} />
</motion.span>
</button>
</motion.div>
<motion.ul {...fade(0.35)} className="mt-8 flex flex-wrap gap-x-6 gap-y-3 text-sm text-muted-foreground">
{[
{ icon: Truck, t: "Free shipping" },
{ icon: RotateCcw, t: "30-day returns" },
{ icon: ShieldCheck, t: "Lifetime warranty" },
].map(({ icon: Icon, t }) => (
<li key={t} className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-foreground/70" aria-hidden />
{t}
</li>
))}
</motion.ul>
</div>
{/* Visual */}
<ProductStage color={color} features={features} brand={brand} size={size} reduce={!!reduce} />
</div>
</section>
);
}
function ProductStage({ color, features, brand, size, reduce }: { color: ProductColor; features: string[]; brand: string; size: string; reduce: boolean }) {
const ref = React.useRef<HTMLDivElement>(null);
const [rx, setRx] = React.useState(200);
const [compact, setCompact] = React.useState(false);
const shown = compact ? features.slice(0, 3) : features;
const mx = useMotionValue(0);
const my = useMotionValue(0);
const sx = useSpring(mx, { stiffness: 120, damping: 18 });
const sy = useSpring(my, { stiffness: 120, damping: 18 });
const bottleX = useTransform(sx, (v) => v * 26);
const bottleY = useTransform(sy, (v) => v * 18);
const bottleR = useTransform(sx, (v) => v * 7);
const discX = useTransform(sx, (v) => v * -34);
const discY = useTransform(sy, (v) => v * -24);
const ringX = useTransform(sx, (v) => v * 48);
const ringY = useTransform(sy, (v) => v * 30);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => {
const w = e.contentRect.width;
const small = w < 480;
setCompact(small);
setRx(small ? w / 2 - 60 : Math.min(230, w / 2 - 78));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<motion.div
ref={ref}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.9, ease: EASE }}
onPointerMove={(e) => {
if (reduce) return;
const r = e.currentTarget.getBoundingClientRect();
mx.set((e.clientX - r.left) / r.width - 0.5);
my.set((e.clientY - r.top) / r.height - 0.5);
}}
onPointerLeave={() => (mx.set(0), my.set(0))}
className="relative order-1 h-[400px] w-full select-none sm:h-[500px] lg:order-2 lg:h-[560px]"
aria-hidden
>
{/* Backdrop disc */}
<motion.div style={{ x: discX, y: discY }} className="absolute inset-0 grid place-items-center">
<motion.div
animate={{ background: `radial-gradient(circle at 35% 30%, ${color.base}cc, ${color.shade}55 55%, transparent 72%)` }}
transition={{ duration: 0.6 }}
className="size-[300px] rounded-full sm:size-[420px]"
/>
</motion.div>
<div className="absolute inset-0 grid place-items-center">
<div className="size-[340px] rounded-full border border-dashed border-foreground/10 sm:size-[480px]" />
</div>
{/* Orbit: back half */}
<motion.div style={{ x: ringX, y: ringY }} className="absolute inset-0">
{shown.map((f, i) => (
<OrbitChip key={f} label={f} index={i} count={shown.length} rx={rx} ry={compact ? 0.55 : 0.32} compact={compact} layer="back" reduce={reduce} />
))}
</motion.div>
{/* Bottle */}
<motion.div style={{ x: bottleX, y: bottleY, rotate: bottleR }} className="absolute inset-0 grid place-items-center">
<motion.div
animate={reduce ? undefined : { y: [0, -12, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
className="relative"
>
<Bottle color={color} brand={brand} size={size} />
</motion.div>
</motion.div>
{/* Orbit: front half */}
<motion.div style={{ x: ringX, y: ringY }} className="absolute inset-0">
{shown.map((f, i) => (
<OrbitChip key={f} label={f} index={i} count={shown.length} rx={rx} ry={compact ? 0.55 : 0.32} compact={compact} layer="front" reduce={reduce} />
))}
</motion.div>
</motion.div>
);
}
const ORBIT_PERIOD = 16000;
function OrbitChip({
label,
index,
count,
rx,
ry,
compact,
layer,
reduce,
}: {
label: string;
index: number;
count: number;
rx: number;
ry: number;
compact: boolean;
layer: "front" | "back";
reduce: boolean;
}) {
const time = useTime();
const base = (index / count) * Math.PI * 2 + 0.4;
const angle: MotionValue<number> = useTransform(time, (t) => base + (reduce ? 0 : (t / ORBIT_PERIOD) * Math.PI * 2));
const x = useTransform(angle, (a) => Math.cos(a) * rx);
const y = useTransform(angle, (a) => Math.sin(a) * rx * ry);
const depth = useTransform(angle, (a) => Math.sin(a)); // -1 back … 1 front
const scale = useTransform(depth, (d) => 0.86 + (d + 1) * 0.08);
const opacity = useTransform(depth, (d) => {
const inLayer = layer === "front" ? d >= 0 : d < 0;
if (!inLayer) return 0;
return layer === "front" ? 1 : 0.55 + (d + 1) * 0.35;
});
return (
<motion.div style={{ x, y, scale, opacity }} className="absolute left-1/2 top-[52%] -translate-x-1/2 -translate-y-1/2">
<div className={cn("whitespace-nowrap rounded-full border border-foreground/10 bg-background/75 font-medium shadow-lg shadow-black/5 backdrop-blur-md", compact ? "px-2.5 py-1.5 text-[11px]" : "px-3.5 py-2 text-xs")}>
<span className="inline-flex items-center gap-1.5">
<Snowflake className="size-3.5 text-sky-500" />
{label}
</span>
</div>
</motion.div>
);
}
function Bottle({ color, brand, size }: { color: ProductColor; brand: string; size: string }) {
const id = React.useId().replace(/:/g, "");
return (
<svg viewBox="0 0 200 470" className="h-[320px] w-auto drop-shadow-2xl sm:h-[400px] lg:h-[440px]">
<defs>
<linearGradient id={`${id}-body`} x1="0" x2="1">
<motion.stop offset="0%" animate={{ stopColor: color.shade }} transition={{ duration: 0.5 }} />
<motion.stop offset="30%" animate={{ stopColor: color.base }} transition={{ duration: 0.5 }} />
<motion.stop offset="55%" animate={{ stopColor: color.base }} transition={{ duration: 0.5 }} />
<motion.stop offset="100%" animate={{ stopColor: color.shade }} transition={{ duration: 0.5 }} />
</linearGradient>
<linearGradient id={`${id}-cap`} x1="0" x2="1">
<stop offset="0%" stopColor="#1c1c21" />
<stop offset="40%" stopColor="#4a4a52" />
<stop offset="100%" stopColor="#141418" />
</linearGradient>
<linearGradient id={`${id}-steel`} x1="0" x2="1">
<stop offset="0%" stopColor="#8a8d94" />
<stop offset="45%" stopColor="#eef0f3" />
<stop offset="100%" stopColor="#7a7d84" />
</linearGradient>
<radialGradient id={`${id}-shadow`}>
<stop offset="0%" stopColor="#000" stopOpacity="0.35" />
<stop offset="100%" stopColor="#000" stopOpacity="0" />
</radialGradient>
</defs>
<ellipse cx="100" cy="458" rx="70" ry="9" fill={`url(#${id}-shadow)`} />
{/* cap */}
<rect x="66" y="8" width="68" height="62" rx="12" fill={`url(#${id}-cap)`} />
{[20, 30, 40, 50].map((y) => (
<rect key={y} x="70" y={y} width="60" height="2" rx="1" fill="#fff" opacity="0.07" />
))}
<path d="M86 8h28v-2a4 4 0 0 0-4-4H90a4 4 0 0 0-4 4z" fill="#2a2a30" />
{/* steel neck */}
<rect x="74" y="68" width="52" height="20" rx="3" fill={`url(#${id}-steel)`} />
{/* body */}
<path
d="M76 86 C76 108 38 112 38 150 L38 414 Q38 446 72 446 L128 446 Q162 446 162 414 L162 150 C162 112 124 108 124 86 Z"
fill={`url(#${id}-body)`}
/>
{/* highlights */}
<path d="M56 150 C56 128 70 118 80 112 L80 118 C72 124 62 134 62 152 L62 410 Q62 424 56 426 Z" fill="#fff" opacity="0.28" />
<rect x="138" y="150" width="6" height="260" rx="3" fill="#fff" opacity="0.1" />
{/* label */}
<motion.text
x="100"
y="282"
textAnchor="middle"
fontSize="22"
fontWeight="800"
letterSpacing="6"
animate={{ fill: color.ink }}
transition={{ duration: 0.5 }}
style={{ fontFamily: "inherit" }}
>
{brand}
</motion.text>
<motion.text
x="100"
y="302"
textAnchor="middle"
fontSize="8"
letterSpacing="1.6"
animate={{ fill: color.ink }}
transition={{ duration: 0.5 }}
opacity="0.7"
style={{ fontFamily: "inherit" }}
>
INSULATED · {size.toUpperCase()}
</motion.text>
<rect x="38" y="404" width="124" height="3" fill="#000" opacity="0.08" />
</svg>
);
}