"use client"; import { CheckIcon, HeartIcon, MinusIcon, PlusIcon, StarIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type CSSProperties, type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from "react"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; export interface Swatch { id: string; name: string; color: string; accent?: string; soldOut?: string[]; } export interface SizeOption { id: string; label: string; price: number; scale?: number; } export interface SwatchCardProps { name: string; tagline?: string; rating?: number; reviews?: number; swatches: Swatch[]; sizes: SizeOption[]; defaultSwatch?: string; defaultSize?: string; currency?: string; locale?: string; renderProduct?: (swatch: Swatch, size: SizeOption) => ReactNode; onAdd?: (selection: { swatch: Swatch; size: SizeOption; qty: number }) => void | Promise; className?: string; } type BagState = { phase: "idle" } | { phase: "adding" } | { phase: "in"; qty: number }; export function SwatchCard({ name, tagline, rating, reviews, swatches, sizes, defaultSwatch, defaultSize, currency = "USD", locale = "en-US", renderProduct, onAdd, className, }: SwatchCardProps) { const reduce = useReducedMotion(); const [swatchId, setSwatchId] = useState(defaultSwatch ?? swatches[0].id); const [preview, setPreview] = useState(null); const [sizeId, setSizeId] = useState(defaultSize ?? sizes[0].id); const [bag, setBag] = useState({ phase: "idle" }); const [liked, setLiked] = useState(false); const ringId = useId(); const swatchRefs = useRef<(HTMLButtonElement | null)[]>([]); const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const shownSwatch = swatches.find((s) => s.id === (preview ?? swatchId)) ?? swatches[0]; const swatch = swatches.find((s) => s.id === swatchId) ?? swatches[0]; const size = sizes.find((s) => s.id === sizeId) ?? sizes[0]; const soldOut = (sz: SizeOption, sw = swatch) => sw.soldOut?.includes(sz.id) ?? false; const fmt = (n: number) => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: n % 1 ? 2 : 0 }).format(n); const choose = (id: string) => { setSwatchId(id); const next = swatches.find((s) => s.id === id); if (next?.soldOut?.includes(sizeId)) { const fallback = sizes.find((sz) => !next.soldOut?.includes(sz.id)); if (fallback) setSizeId(fallback.id); } setBag({ phase: "idle" }); }; const onSwatchKey = (event: KeyboardEvent) => { const i = swatches.findIndex((s) => s.id === swatchId); let j = -1; if (event.key === "ArrowRight" || event.key === "ArrowDown") j = (i + 1) % swatches.length; if (event.key === "ArrowLeft" || event.key === "ArrowUp") j = (i - 1 + swatches.length) % swatches.length; if (j < 0) return; event.preventDefault(); choose(swatches[j].id); swatchRefs.current[j]?.focus(); }; const add = async () => { if (bag.phase !== "idle" || soldOut(size)) return; setBag({ phase: "adding" }); await onAdd?.({ swatch, size, qty: 1 }); if (mounted.current) setBag({ phase: "in", qty: 1 }); }; const changeQty = (delta: number) => { if (bag.phase !== "in") return; const qty = bag.qty + delta; setBag(qty <= 0 ? { phase: "idle" } : { phase: "in", qty: Math.min(qty, 9) }); }; const ease = [0.23, 1, 0.32, 1] as const; const stageColor = { "--c": shownSwatch.color } as CSSProperties; return (
{renderProduct ? renderProduct(shownSwatch, size) : }

{name}

{tagline &&

{tagline}

}
{rating !== undefined && (

{rating.toFixed(1)} {reviews !== undefined && ({reviews} reviews)}

)}

Colour {shownSwatch.name}

setPreview(null)} role="radiogroup"> {swatches.map((s, i) => { const on = s.id === swatchId; return ( ); })}
Size {sizes.some((sz) => soldOut(sz)) && ( {sizes .filter((sz) => soldOut(sz)) .map((sz) => sz.label) .join(", ")}{" "} sold out in {swatch.name} )}
{sizes.map((sz) => { const on = sz.id === sizeId; const out = soldOut(sz); return ( ); })}
); } function BagButton({ bag, label, onAdd, onQty, reduce, }: { bag: BagState; label: string; onAdd: () => void; onQty: (delta: number) => void; reduce: boolean; }) { const ease = [0.23, 1, 0.32, 1] as const; const swap = { initial: reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(2px)" }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(2px)" }, transition: { duration: 0.2, ease }, }; const inBag = bag.phase === "in"; return (
{inBag ? ( in your bag ) : ( {bag.phase === "adding" ? ( Adding… ) : ( {label} )} )}
); } function Bottle({ swatch, scale }: { swatch: Swatch; scale: number }) { const reduce = useReducedMotion(); const vars = { "--c": swatch.color, "--cap": swatch.accent ?? `color-mix(in oklch, ${swatch.color} 62%, black)`, } as CSSProperties; return (
Fieldwell
); } function RollingNumber({ text, className }: { text: string; className?: string }) { const reduce = useReducedMotion(); const chars = text.split(""); return ( {chars.map((c, i) => { const key = chars.length - i; if (!/\d/.test(c)) { return ( {c} ); } return ( {Array.from({ length: 10 }, (_, d) => ( {d} ))} ); })} ); }