Fazekit

Code

"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>
  );
}

More in E-commerce

View all →