"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { Gift, PackageOpen, ShoppingBag, Sparkles, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { BoxVisual, type BoxItem } from "./box-visual";
import { PRODUCTS, SIZES, TIERS } from "./data";
import { ProductArt } from "./product-art";
import { ProductShelf } from "./product-shelf";
import { activeTier, TierMeter } from "./tier-meter";
import type { BoxProduct, GiftBox, Tier } from "./types";
export type { BoxProduct, GiftBox, Tier };
export type GiftBoxBuilderProps = {
products?: BoxProduct[];
tiers?: Tier[];
sizes?: number[];
defaultSize?: number;
currency?: string;
locale?: string;
/** Show 18+ products (and an age notice when one is in the box). */
allowAgeRestricted?: boolean;
title?: string;
subtitle?: string;
eyebrow?: string;
/** Printed on the lid. */
brand?: string;
noteLimit?: number;
onAddToCart?: (box: GiftBox) => void;
className?: string;
};
const ring = "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
const round2 = (n: number) => Math.round(n * 100) / 100;
let uidSeq = 0;
type Drag = { product: BoxProduct; startX: number; startY: number; active: boolean; pointerId: number };
function RollingText({ text }: { text: string }) {
const chars = [...text];
return (
<span className="inline-flex leading-none" aria-hidden>
{chars.map((c, i) => {
const k = chars.length - i;
return /\d/.test(c) ? <RollDigit key={`d${k}`} d={Number(c)} /> : <span key={`s${k}-${c}`} className="whitespace-pre">{c}</span>;
})}
</span>
);
}
function RollDigit({ d }: { d: number }) {
return (
<span className="relative inline-block h-[1em] w-[0.6em] overflow-hidden">
<motion.span className="absolute inset-x-0 top-0 flex flex-col items-center" initial={false} animate={{ y: `${-d}em` }} transition={{ type: "spring", stiffness: 260, damping: 26 }}>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-[1em]">
{n}
</span>
))}
</motion.span>
</span>
);
}
export function GiftBoxBuilder({
products = PRODUCTS,
tiers = TIERS,
sizes = SIZES,
defaultSize,
currency = "PLN",
locale = "pl-PL",
allowAgeRestricted = false,
title = "Build your own gift box",
subtitle = "Pick a box, fill it with treats and watch the bundle discount grow. Full boxes are wrapped and ribboned by hand.",
eyebrow = "Holiday gifting",
brand = "Lumen Gift Co.",
noteLimit = 140,
onAddToCart,
className,
}: GiftBoxBuilderProps) {
const reduced = useReducedMotion() ?? false;
const fmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency }), [currency, locale]);
const money = React.useCallback((n: number) => fmt.format(n), [fmt]);
const shelfProducts = React.useMemo(() => products.filter((p) => allowAgeRestricted || !p.ageRestricted), [products, allowAgeRestricted]);
const categories = React.useMemo(() => Array.from(new Set(shelfProducts.map((p) => p.category))), [shelfProducts]);
const [size, setSize] = React.useState(() => defaultSize ?? sizes[Math.min(1, sizes.length - 1)] ?? 6);
const [slots, setSlots] = React.useState<(BoxItem | null)[]>(() => Array(defaultSize ?? sizes[Math.min(1, sizes.length - 1)] ?? 6).fill(null));
const [category, setCategory] = React.useState("All");
const [note, setNote] = React.useState("");
const [peek, setPeek] = React.useState(false);
const [toast, setToast] = React.useState<{ id: number; text: string } | null>(null);
const [live, setLive] = React.useState("");
const [unlocked, setUnlocked] = React.useState<{ key: number; tier: Tier } | null>(null);
const [shake, setShake] = React.useState(0);
const [added, setAdded] = React.useState(0);
const [drag, setDrag] = React.useState<Drag | null>(null);
const [over, setOver] = React.useState(false);
const trayRef = React.useRef<HTMLDivElement>(null);
const suppressClick = React.useRef(false);
const ghostX = useMotionValue(0);
const ghostY = useMotionValue(0);
const tiltTarget = useMotionValue(0);
const tilt = useSpring(tiltTarget, { stiffness: 300, damping: 20 });
const items = slots.filter((s): s is BoxItem => !!s);
const count = items.length;
const full = count === size && size > 0;
const closed = full && !peek;
const subtotal = round2(items.reduce((a, s) => a + s.product.price, 0));
const tier = activeTier(tiers, count);
const discount = round2(tier ? (subtotal * tier.pct) / 100 : 0);
const total = round2(subtotal - discount);
const inBox = React.useMemo(() => {
const m: Record<string, number> = {};
for (const s of slots) if (s) m[s.product.id] = (m[s.product.id] ?? 0) + 1;
return m;
}, [slots]);
const hasAgeRestricted = items.some((s) => s.product.ageRestricted);
const say = (text: string) => {
setToast({ id: ++uidSeq, text });
setLive(text);
};
React.useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2800);
return () => window.clearTimeout(t);
}, [toast]);
React.useEffect(() => {
if (!unlocked) return;
const t = window.setTimeout(() => setUnlocked(null), 2400);
return () => window.clearTimeout(t);
}, [unlocked]);
const add = (p: BoxProduct, at?: number) => {
if (p.stock - (inBox[p.id] ?? 0) <= 0) {
say(`${p.name} is out of stock`);
return;
}
const index = at !== undefined && at < slots.length && !slots[at] ? at : slots.findIndex((s) => !s);
if (index < 0) {
setShake((s) => s + 1);
say("The box is full. Remove an item or choose a bigger box.");
return;
}
const next = slots.slice();
next[index] = { uid: `gb${++uidSeq}`, product: p };
const before = activeTier(tiers, count);
const after = activeTier(tiers, count + 1);
setSlots(next);
setPeek(false);
if (after && after !== before) {
setUnlocked({ key: ++uidSeq, tier: after });
setLive(`Added ${p.name}. ${after.minItems} items: ${after.pct}% off unlocked.`);
} else {
setLive(`Added ${p.name}. ${count + 1} of ${size} slots filled.`);
}
if (count + 1 === size) window.setTimeout(() => setLive(`Box full. Lid closed. Total ${money(round2((subtotal + p.price) * (1 - (after?.pct ?? 0) / 100)))}.`), 900);
};
const remove = (index: number) => {
const it = slots[index];
if (!it) return;
const next = slots.slice();
next[index] = null;
setSlots(next);
setLive(`Removed ${it.product.name}. ${count - 1} of ${size} slots filled.`);
// Keep keyboard focus inside the box.
window.setTimeout(() => {
const btn = trayRef.current?.querySelector<HTMLButtonElement>("button");
btn?.focus();
}, 220);
};
const changeSize = (n: number) => {
if (n === size) return;
const keep: (BoxItem | null)[] = slots.slice(0, n);
while (keep.length < n) keep.push(null);
const overflow = slots.slice(n).filter((s): s is BoxItem => !!s);
const returned: BoxItem[] = [];
for (const it of overflow) {
const free = keep.findIndex((s) => !s);
if (free >= 0) keep[free] = it;
else returned.push(it);
}
setSize(n);
setSlots(keep);
setPeek(false);
if (returned.length) {
const names = returned.map((r) => r.product.name.split(" ").slice(1).join(" "));
say(`Returned ${returned.length} ${returned.length === 1 ? "item" : "items"} to the shelf: ${names.join(", ")}`);
} else setLive(`${n}-slot box selected.`);
};
const addToCart = () => {
if (!count) return;
const box: GiftBox = { items: items.map((s) => s.product), size, note: note.trim(), subtotal, discount, total };
onAddToCart?.(box);
setAdded((a) => a + 1);
say(`Gift box added to cart · ${money(total)}`);
};
/* --------------------------------- drag --------------------------------- */
const slotAt = (x: number, y: number) => {
const tray = trayRef.current;
if (!tray) return { over: false, index: undefined as number | undefined };
const r = tray.getBoundingClientRect();
const pad = 24;
const inside = x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad;
let index: number | undefined;
tray.querySelectorAll<HTMLElement>("[data-gb-slot]").forEach((el) => {
const b = el.getBoundingClientRect();
if (x >= b.left && x <= b.right && y >= b.top && y <= b.bottom) index = Number(el.dataset.gbSlot);
});
return { over: inside, index };
};
const onDragStart = (p: BoxProduct, e: React.PointerEvent<HTMLButtonElement>) => {
setDrag({ product: p, startX: e.clientX, startY: e.clientY, active: false, pointerId: e.pointerId });
};
React.useEffect(() => {
if (!drag) return;
let lastX = drag.startX;
let lastT = -1;
const move = (e: PointerEvent) => {
if (e.pointerId !== drag.pointerId) return;
if (!drag.active) {
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 6) return;
ghostX.set(e.clientX);
ghostY.set(e.clientY);
setDrag({ ...drag, active: true });
return;
}
e.preventDefault();
ghostX.set(e.clientX);
ghostY.set(e.clientY);
const now = e.timeStamp;
const vx = lastT < 0 ? 0 : (e.clientX - lastX) / Math.max(1, now - lastT);
lastX = e.clientX;
lastT = now;
tiltTarget.set(reduced ? 0 : Math.max(-28, Math.min(28, vx * 14)));
setOver(slotAt(e.clientX, e.clientY).over);
};
const up = (e: PointerEvent) => {
if (e.pointerId !== drag.pointerId) return;
if (drag.active) {
suppressClick.current = true;
window.setTimeout(() => (suppressClick.current = false), 50);
const hit = slotAt(e.clientX, e.clientY);
if (hit.over) add(drag.product, hit.index);
}
tiltTarget.set(0);
setOver(false);
setDrag(null);
};
const key = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOver(false);
setDrag(null);
}
};
window.addEventListener("pointermove", move, { passive: false });
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
window.addEventListener("keydown", key);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
window.removeEventListener("keydown", key);
};
});
const sortedSizes = [...sizes].sort((a, b) => a - b);
const boxPanel = (
<div className="rounded-3xl border bg-card/95 p-3 shadow-[0_20px_60px_-30px_rgba(0,0,0,0.35)] backdrop-blur sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[13px] font-semibold">Your box</p>
<p className="text-[12px] text-muted-foreground tabular-nums">
{count} of {size} filled
</p>
</div>
<div role="radiogroup" aria-label="Box size" className="flex rounded-xl bg-muted p-1">
{sortedSizes.map((n) => (
<button
key={n}
type="button"
role="radio"
aria-checked={n === size}
onClick={() => changeSize(n)}
className={cn("relative h-8 rounded-lg px-3 text-[12.5px] font-semibold tabular-nums transition-colors", n === size ? "text-foreground" : "text-muted-foreground hover:text-foreground", ring)}
>
{n === size && <motion.span layoutId="gb-size" className="absolute inset-0 rounded-lg bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
<span className="relative">{n}</span>
<span className="sr-only"> slots</span>
</button>
))}
</div>
</div>
<div className="mt-1 [--gb-slot:50px] sm:[--gb-slot:76px] lg:[--gb-slot:74px]">
<BoxVisual slots={slots} closed={closed} dropActive={over} shake={shake} brand={brand} reduced={reduced} trayRef={trayRef} onRemove={remove} />
</div>
<div className="mt-1 flex h-7 items-center justify-center">
<AnimatePresence mode="wait">
{full && (
<motion.button
key={closed ? "peek" : "close"}
type="button"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
onClick={() => setPeek((v) => !v)}
className={cn("inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted hover:text-foreground", ring)}
>
{closed ? <PackageOpen className="size-3.5" aria-hidden /> : <Gift className="size-3.5" aria-hidden />}
{closed ? "Open lid to swap items" : "Close the lid"}
</motion.button>
)}
</AnimatePresence>
</div>
<div className="mt-3 px-1 pt-1">
<TierMeter tiers={tiers} count={count} size={size} unlocked={unlocked} />
</div>
</div>
);
const summary = (
<div className="rounded-3xl border bg-card p-4 sm:p-5">
<dl className="space-y-1.5 text-[13px]">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">
{count} {count === 1 ? "item" : "items"}
</dt>
<dd className="tabular-nums">{money(subtotal)}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Bundle discount{tier ? ` (${tier.pct}%)` : ""}</dt>
<dd className={cn("tabular-nums", discount > 0 ? "font-medium text-[var(--gb-accent)]" : "text-muted-foreground")}>{discount > 0 ? `−${money(discount)}` : "—"}</dd>
</div>
<div className="flex items-baseline justify-between gap-3 border-t pt-2.5">
<dt className="font-semibold">Total</dt>
<dd className="text-[22px] font-semibold tracking-tight tabular-nums">
<span className="sr-only">{money(total)}</span>
<RollingText text={money(total)} />
</dd>
</div>
</dl>
<AnimatePresence initial={false}>
{hasAgeRestricted && (
<motion.p initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="overflow-hidden text-[12px] text-muted-foreground">
<span className="mt-3 flex items-start gap-2 rounded-xl border border-dashed p-2.5">
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-foreground text-[9px] font-bold text-background">18+</span>
Contains alcohol. The recipient must show ID proving they are 18 or older on delivery.
</span>
</motion.p>
)}
</AnimatePresence>
<div className="mt-4">
<div className="mb-1.5 flex items-baseline justify-between">
<label htmlFor="gb-note" className="text-[12.5px] font-medium">
Gift note <span className="font-normal text-muted-foreground">(optional)</span>
</label>
<span className={cn("text-[11.5px] tabular-nums", note.length >= noteLimit ? "text-[var(--gb-accent)]" : "text-muted-foreground")} aria-live="polite">
{note.length}/{noteLimit}
</span>
</div>
<textarea
id="gb-note"
value={note}
maxLength={noteLimit}
onChange={(e) => setNote(e.target.value.slice(0, noteLimit))}
rows={2}
placeholder="Happy holidays! Save me a caramel…"
className="block w-full resize-none rounded-xl border bg-background px-3 py-2 text-[13px] placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<motion.button
type="button"
onClick={addToCart}
disabled={!count}
animate={full && !reduced ? { scale: [1, 1.035, 1] } : { scale: 1 }}
transition={full && !reduced ? { duration: 1.4, repeat: Infinity, ease: "easeInOut", delay: 1.6 } : { duration: 0.2 }}
className={cn(
"relative mt-4 flex h-12 w-full items-center justify-center gap-2 overflow-hidden rounded-2xl bg-primary text-[14px] font-semibold text-primary-foreground shadow-lg shadow-primary/20 disabled:cursor-not-allowed disabled:opacity-45",
ring,
)}
>
{full && !reduced && (
<motion.span
aria-hidden
className="absolute inset-y-0 w-1/3 -skew-x-12 bg-gradient-to-r from-transparent via-white/30 to-transparent"
initial={{ left: "-40%" }}
animate={{ left: "140%" }}
transition={{ duration: 1.4, repeat: Infinity, repeatDelay: 1.2, ease: "easeInOut" }}
/>
)}
<ShoppingBag className="size-4" aria-hidden />
{count ? `Add box to cart · ${money(total)}` : "Add products to your box"}
</motion.button>
{added > 0 && <p className="mt-2 text-center text-[12px] text-muted-foreground">{added === 1 ? "1 box in your cart" : `${added} boxes in your cart`}</p>}
</div>
);
return (
<MotionConfig reducedMotion="user">
<section
className={cn(
"relative w-full overflow-x-clip bg-background text-foreground [--gb-accent:#be123c] [--gb-gold:#e3a92f] dark:[--gb-accent:#fb7185] dark:[--gb-gold:#f2c14e]",
className,
)}
aria-labelledby="gb-title"
>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[radial-gradient(60%_100%_at_70%_0%,color-mix(in_oklab,var(--gb-gold)_18%,transparent),transparent)]" />
<div className="relative mx-auto max-w-6xl px-4 py-6 sm:px-6 sm:py-7">
<header className="mb-5 max-w-2xl sm:mb-6">
<p className="mb-2 inline-flex items-center gap-1.5 rounded-full border bg-card px-2.5 py-1 text-[11.5px] font-medium text-muted-foreground">
<Sparkles className="size-3.5 text-[var(--gb-gold)]" aria-hidden />
{eyebrow}
</p>
<h2 id="gb-title" className="text-balance text-2xl font-semibold tracking-tight sm:text-3xl">
{title}
</h2>
<p className="mt-1.5 text-pretty text-[13.5px] text-muted-foreground sm:text-[14.5px]">{subtitle}</p>
</header>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_380px] lg:gap-8">
<div className="sticky top-0 z-20 -mx-4 bg-background/85 px-4 pb-2 pt-2 backdrop-blur-md sm:static sm:mx-0 sm:bg-transparent sm:p-0 sm:backdrop-blur-none lg:col-start-2 lg:row-start-1">{boxPanel}</div>
<div className="lg:col-start-1 lg:row-span-2 lg:row-start-1">
<ProductShelf
products={shelfProducts}
categories={categories}
category={category}
inBox={inBox}
boxFull={full}
draggingId={drag?.active ? drag.product.id : null}
money={money}
onCategory={setCategory}
onAdd={(p) => add(p)}
onDragStart={onDragStart}
consumeClick={() => suppressClick.current}
/>
</div>
<div className="lg:col-start-2 lg:row-start-2">{summary}</div>
</div>
</div>
{/* Drag ghost */}
{drag?.active && (
<motion.div aria-hidden className="pointer-events-none fixed left-0 top-0 z-[60]" style={{ x: ghostX, y: ghostY }}>
<motion.div
className="-ml-10 -mt-10 grid size-20 place-items-center rounded-2xl border bg-card/90 shadow-2xl backdrop-blur"
style={{ rotate: tilt }}
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: over ? 1.12 : 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 420, damping: 22 }}
>
<ProductArt shape={drag.product.shape} color={drag.product.color} className="size-16" />
</motion.div>
</motion.div>
)}
<AnimatePresence>
{toast && (
<motion.div
key={toast.id}
role="status"
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10 }}
className="fixed inset-x-4 bottom-4 z-50 mx-auto flex max-w-md items-center gap-2 rounded-2xl bg-foreground px-4 py-3 text-[13px] font-medium text-background shadow-2xl sm:bottom-6"
>
<Gift className="size-4 shrink-0" aria-hidden />
<span className="min-w-0 flex-1">{toast.text}</span>
<button type="button" onClick={() => setToast(null)} aria-label="Dismiss" className="grid size-7 shrink-0 place-items-center rounded-full hover:bg-background/15">
<X className="size-4" aria-hidden />
</button>
</motion.div>
)}
</AnimatePresence>
<p className="sr-only" aria-live="polite">
{live}
</p>
</section>
</MotionConfig>
);
}