"use client"; import { PlusIcon } from "lucide-react"; import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { useEffect, useId, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type EmptyBagHue = "sky" | "emerald" | "amber" | "violet" | "rose" | "orange" | "indigo" | "teal"; const hues: Record = { sky: "[--hue-hi:var(--color-sky-300)] [--hue:var(--color-sky-500)] [--hue-deep:var(--color-sky-700)]", emerald: "[--hue-hi:var(--color-emerald-300)] [--hue:var(--color-emerald-500)] [--hue-deep:var(--color-emerald-700)]", amber: "[--hue-hi:var(--color-amber-300)] [--hue:var(--color-amber-500)] [--hue-deep:var(--color-amber-700)]", violet: "[--hue-hi:var(--color-violet-300)] [--hue:var(--color-violet-500)] [--hue-deep:var(--color-violet-700)]", rose: "[--hue-hi:var(--color-rose-300)] [--hue:var(--color-rose-500)] [--hue-deep:var(--color-rose-700)]", orange: "[--hue-hi:var(--color-orange-300)] [--hue:var(--color-orange-500)] [--hue-deep:var(--color-orange-700)]", indigo: "[--hue-hi:var(--color-indigo-300)] [--hue:var(--color-indigo-500)] [--hue-deep:var(--color-indigo-700)]", teal: "[--hue-hi:var(--color-teal-300)] [--hue:var(--color-teal-500)] [--hue-deep:var(--color-teal-700)]", }; export interface EmptyBagProps { count?: number; onAdd?: () => void; title?: string; description?: string; filledTitle?: string; filledDescription?: string; actionLabel?: string; price?: string; hue?: EmptyBagHue; className?: string; } const W = 400; const H = 175; const TOP = 30; const FLOOR = 196; const TAG_X = 236; type Pt = [number, number]; const lerp = (a: number, b: number, f: number) => a + (b - a) * f; const mix = (a: Pt, b: Pt, f: number): Pt => [lerp(a[0], b[0], f), lerp(a[1], b[1], f)]; const pt = (p: Pt, dy = 0) => `${p[0].toFixed(2)} ${(p[1] + dy).toFixed(2)}`; function topEdge(f: number) { return { tl: mix([146, 90], [142, 78], f), c1: mix([170, 104], [171, 78], f), m: mix([199, 95], [200, 78], f), c2: mix([226, 86], [229, 78], f), tr: mix([254, 92], [258, 78], f), }; } function frontPath(f: number) { const { tl, c1, m, c2, tr } = topEdge(f); const sideR: Pt = [lerp(248, 261, f), 136]; const sideL: Pt = [lerp(152, 139, f), 136]; return `M${pt(tl)} Q${pt(c1)} ${pt(m)} Q${pt(c2)} ${pt(tr)} Q${pt(sideR)} 264 ${FLOOR - 7} Q264 ${FLOOR} 257 ${FLOOR} L143 ${FLOOR} Q136 ${FLOOR} 136 ${FLOOR - 7} Q${pt(sideL)} ${pt(tl)} Z`; } function rimPath(f: number) { const { tl, c1, m, c2, tr } = topEdge(f); return `M${pt(tl)} Q${pt(c1)} ${pt(m)} Q${pt(c2)} ${pt(tr)} L${pt(tr, 12)} Q${pt(c2, 12)} ${pt(m, 12)} Q${pt(c1, 12)} ${pt(tl, 12)} Z`; } function backPath(f: number) { const l = mix([150, 100], [143, 79], f); const m = mix([200, 104], [200, 62], f); const r = mix([250, 98], [257, 79], f); return `M${pt(l)} Q${pt(m)} ${pt(r)} L${r[0]} 150 L${l[0]} 150 Z`; } function anchors(f: number) { const { tl, m, tr } = topEdge(f); return { a: [176, lerp(tl[1], m[1], 0.6) + 15] as Pt, b: [224, lerp(m[1], tr[1], 0.4) + 15] as Pt }; } function frontHandle(f: number) { const { a, b } = anchors(f); const lift = lerp(-34, -58, f); const lean = lerp(-34, 0, f); return `M${pt(a)} C${a[0] - 2 + lean} ${a[1] + lift} ${b[0] + 2 + lean} ${b[1] + lift} ${pt(b)}`; } function backHandle(f: number) { const y = lerp(106, 74, f); const lift = lerp(-24, -66, f); const lean = lerp(24, 0, f); return `M182 ${y} C${180 + lean} ${y + lift} ${220 + lean} ${y + lift} 218 ${y}`; } function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export function EmptyBag({ count: countProp, onAdd, title = "Your bag is empty", description = "Nothing in here yet. Find something you love and it'll wait for you.", filledTitle, filledDescription = "Held for 48 hours. Shipping is free over $50 and returns are on us.", actionLabel = "Add to bag", price = "$48", hue = "violet", className, }: EmptyBagProps) { const uid = useId().replace(/:/g, ""); const reduce = useCalmMotion(); const root = useRef(null); const inView = useInView(root); const [own, setOwn] = useState(0); const count = countProp ?? own; const full = count > 0; const [peek, setPeek] = useState(false); const first = useRef(true); const fill = useMotionValue(full ? 1 : 0); const breath = useMotionValue(0); const f = useTransform(() => fill.get() + breath.get()); const front = useTransform(f, frontPath); const rim = useTransform(f, rimPath); const back = useTransform(f, backPath); const handleFront = useTransform(f, frontHandle); const handleBack = useTransform(f, backHandle); const grommetA = useTransform(f, (v) => anchors(v).a); const grommetB = useTransform(f, (v) => anchors(v).b); const ax = useTransform(grommetA, (p) => p[0]); const ay = useTransform(grommetA, (p) => p[1]); const bx = useTransform(grommetB, (p) => p[0]); const by = useTransform(grommetB, (p) => p[1]); const shadow = useTransform(f, (v) => lerp(58, 74, Math.min(1, Math.max(0, v)))); useEffect(() => { if (first.current || reduce) { fill.set(full ? 1 : 0); first.current = false; return; } const c = full ? animate(fill, 1, { type: "spring", stiffness: 360, damping: 14, mass: 0.8, delay: 0.3 }) : animate(fill, 0, { type: "spring", stiffness: 90, damping: 18 }); return () => c.stop(); }, [full, reduce, fill]); const sighing = !full && !peek && inView && !reduce; useEffect(() => { if (!sighing) { const c = animate(breath, 0, { duration: 0.3, ease: "easeOut" }); return () => c.stop(); } const c = animate(breath, [0, 0.14, -0.05, 0], { delay: 1.2, duration: 2.4, ease: "easeInOut", repeat: Number.POSITIVE_INFINITY, repeatDelay: 2.6, times: [0, 0.4, 0.75, 1], }); return () => c.stop(); }, [sighing, breath]); const add = () => { if (countProp === undefined) setOwn((n) => n + 1); setPeek(false); onAdd?.(); }; const hover = (on: boolean) => setPeek(on && !full); const looking = peek && !full; const heading = filledTitle ?? `${count} ${count === 1 ? "item" : "items"} in your bag`; return (
e.pointerType === "mouse" && hover(true)} onPointerLeave={() => hover(false)} role="img" > {price} {sighing && ( )} {Math.min(count, 99)}
{[ { key: "empty", show: !full, t: title, d: description }, { key: "full", show: full, t: heading, d: filledDescription }, ].map((copy) => (

{copy.t}

{copy.d}

))}
); }