{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upsell-mix-match",
  "type": "registry:block",
  "title": "Mix and match builder",
  "description": "Pick a pack size, spread units across flavors with steppers, watch the meter fill and add when complete.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "upsell",
    "beverage",
    "supplements"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/button.json",
    "https://storekit-eight.vercel.app/r/stepper.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "upsell/upsell-mix-match.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, Shuffle } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { Button } from \"@/components/storekit/ui/button\"\nimport { Stepper } from \"@/components/storekit/ui/stepper\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Mix and match builder. Pick a pack size, then spread that many units\n   across flavors with steppers. A meter tracks progress to the required\n   count, the discount and total update live, and the add button unlocks\n   only when the pack is full. Prices are integer cents. */\n\nconst EASE = [0.23, 1, 0.32, 1] as const\nconst RING = \"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\n\ntype Flavor = { id: string; name: string; note?: string; soldOut?: boolean }\ntype Pack = { size: number; discount: number }\n\nconst DEFAULT_FLAVORS: Flavor[] = [\n  { id: \"citrus\", name: \"Citrus Salt\", note: \"Lemon, lime, sea salt\" },\n  { id: \"berry\", name: \"Wild Berry\", note: \"Blackberry, raspberry\" },\n  { id: \"mango\", name: \"Mango Chili\", note: \"Ripe mango, mild heat\" },\n  { id: \"plain\", name: \"Unflavored\", note: \"No sweetener\" },\n]\n\nconst DEFAULT_PACKS: Pack[] = [\n  { size: 6, discount: 0.1 },\n  { size: 12, discount: 0.15 },\n]\n\nexport function UpsellMixMatch({\n  unitPrice = 450,\n  unitLabel = \"stick\",\n  flavors = DEFAULT_FLAVORS,\n  packs = DEFAULT_PACKS,\n  onAddToCart,\n  className,\n}: {\n  /** Price of one unit in integer cents before the pack discount. */\n  unitPrice?: number\n  unitLabel?: string\n  flavors?: Flavor[]\n  packs?: Pack[]\n  onAddToCart?: (v: { pack: number; picks: Record<string, number>; total: number }) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"upsell\", \"upsell-mix-match\")\n  const [packIdx, setPackIdx] = useState(0)\n  const [picks, setPicks] = useState<Record<string, number>>({})\n  const [status, setStatus] = useState<\"idle\" | \"loading\" | \"added\">(\"idle\")\n  const packRefs = useRef<(HTMLButtonElement | null)[]>([])\n  const timers = useRef<number[]>([])\n  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])\n\n  const pack = packs[packIdx] ?? packs[0]\n  const count = Object.values(picks).reduce((a, b) => a + b, 0)\n  const left = pack.size - count\n  const complete = left === 0\n  const full = unitPrice * pack.size\n  const total = Math.round(full * (1 - pack.discount))\n  const pct = Math.round(pack.discount * 100)\n\n  const choosePack = (i: number, focus = false) => {\n    if (i === packIdx) return\n    sk.emit(\"interact\", `pack_${packs[i].size}`, undefined)\n    setPackIdx(i)\n    // Trim picks that no longer fit, removing from the last flavor first.\n    setPicks((prev) => {\n      const next = { ...prev }\n      let over = Object.values(next).reduce((a, b) => a + b, 0) - packs[i].size\n      for (const f of [...flavors].reverse()) {\n        if (over <= 0) break\n        const take = Math.min(next[f.id] ?? 0, over)\n        next[f.id] = (next[f.id] ?? 0) - take\n        over -= take\n      }\n      return next\n    })\n    if (focus) packRefs.current[i]?.focus()\n  }\n\n  const setFlavor = (id: string, n: number) => {\n    sk.emit(\"interact\", `flavor_${id}`, n)\n    setPicks((prev) => ({ ...prev, [id]: n }))\n  }\n\n  const fillEvenly = () => {\n    const open = flavors.filter((f) => !f.soldOut)\n    if (!open.length) return\n    sk.emit(\"interact\", \"fill_evenly\", undefined)\n    const next: Record<string, number> = {}\n    open.forEach((f, i) => {\n      next[f.id] = Math.floor(pack.size / open.length) + (i < pack.size % open.length ? 1 : 0)\n    })\n    setPicks(next)\n  }\n\n  const add = () => {\n    if (!complete || status !== \"idle\") return\n    setStatus(\"loading\")\n    sk.emit(\"convert\", \"add_to_cart\", total)\n    timers.current.push(\n      window.setTimeout(() => {\n        setStatus(\"added\")\n        onAddToCart?.({ pack: pack.size, picks, total })\n        timers.current.push(window.setTimeout(() => setStatus(\"idle\"), 1200))\n      }, 700)\n    )\n  }\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"flex w-full flex-col gap-5 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6\", className)}>\n      <div className=\"flex flex-col gap-2\">\n        <span className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">Build your pack</span>\n        <div role=\"radiogroup\" aria-label=\"Pack size\" className=\"grid grid-cols-2 gap-2\">\n          {packs.map((p, i) => {\n            const on = i === packIdx\n            return (\n              <button\n                key={p.size}\n                ref={(el) => {\n                  packRefs.current[i] = el\n                }}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={on}\n                tabIndex={on ? 0 : -1}\n                onClick={() => choosePack(i)}\n                onKeyDown={(e) => {\n                  const f = e.key === \"ArrowRight\" || e.key === \"ArrowDown\"\n                  const b = e.key === \"ArrowLeft\" || e.key === \"ArrowUp\"\n                  if (!f && !b) return\n                  e.preventDefault()\n                  choosePack((packIdx + (f ? 1 : -1) + packs.length) % packs.length, true)\n                }}\n                className={cn(\n                  \"flex h-14 flex-col items-center justify-center rounded-sk-btn border tabular-nums transition-[border-color,background-color,transform] duration-200 active:scale-[0.97]\",\n                  on ? \"border-sk-accent bg-sk-accent/[0.08]\" : \"border-sk-line hover:border-sk-ink/30\",\n                  RING\n                )}\n              >\n                <span className=\"text-sm font-medium text-sk-ink\">\n                  {p.size} {unitLabel}s\n                </span>\n                <span className=\"text-[12px] font-medium text-sk-savings\">Save {Math.round(p.discount * 100)}%</span>\n              </button>\n            )\n          })}\n        </div>\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        <div className=\"flex items-baseline justify-between gap-3 text-sm\" aria-live=\"polite\">\n          <span className=\"font-medium text-sk-ink tabular-nums\">\n            {count} of {pack.size} picked\n          </span>\n          <span className=\"text-[13px] text-sk-muted tabular-nums\">{complete ? \"Pack complete\" : `${left} to go`}</span>\n        </div>\n        <div className=\"h-1.5 overflow-hidden rounded-full bg-sk-surface\" role=\"progressbar\" aria-valuemin={0} aria-valuemax={pack.size} aria-valuenow={count} aria-label=\"Pack progress\">\n          <motion.div\n            initial={false}\n            animate={{ scaleX: pack.size ? count / pack.size : 0 }}\n            transition={{ duration: reduce ? 0 : 0.4, ease: EASE }}\n            className={cn(\"h-full w-full origin-left rounded-full transition-colors duration-300\", complete ? \"bg-sk-savings\" : \"bg-sk-accent\")}\n          />\n        </div>\n      </div>\n\n      <ul className=\"flex flex-col divide-y divide-sk-line/70 border-y border-sk-line/70\">\n        {flavors.map((f) => {\n          const n = picks[f.id] ?? 0\n          return (\n            <li key={f.id} className={cn(\"flex flex-wrap items-center gap-x-3 gap-y-2 py-3\", f.soldOut && \"opacity-60\")}>\n              <span aria-hidden className=\"grid h-10 w-10 shrink-0 place-items-center rounded-sk-btn bg-sk-surface font-sk-display text-sm font-semibold text-sk-ink\">\n                {f.name.slice(0, 1)}\n              </span>\n              <span className=\"flex min-w-[8rem] flex-1 basis-0 flex-col\">\n                <span className=\"text-sm font-medium leading-[1.35] text-sk-ink\">{f.name}</span>\n                <span className=\"text-[13px] leading-[1.35] text-sk-muted\">{f.soldOut ? \"Sold out\" : f.note}</span>\n              </span>\n              {f.soldOut ? null : (\n                <Stepper size=\"sm\" label={`${f.name} quantity`} value={n} min={0} max={n + left} onChange={(v) => setFlavor(f.id, v)} className=\"ml-auto\" />\n              )}\n            </li>\n          )\n        })}\n      </ul>\n\n      <div className=\"flex flex-col gap-3\">\n        <div className=\"flex flex-wrap items-end justify-between gap-x-4 gap-y-2\">\n          <div className=\"flex min-w-0 flex-col gap-0.5 tabular-nums\">\n            <span className=\"text-[13px] text-sk-muted\">\n              {pct}% off, {money(Math.round(total / pack.size))} / {unitLabel}\n            </span>\n            <span className=\"flex items-baseline gap-2\">\n              <span className=\"text-xl font-semibold text-sk-price\">{money(total)}</span>\n              <s className=\"text-sm text-sk-muted\">{money(full)}</s>\n            </span>\n          </div>\n          <button\n            type=\"button\"\n            onClick={fillEvenly}\n            className={cn(\"inline-flex h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-sk-btn px-3 text-[13px] font-medium text-sk-muted transition-[color,background-color,transform] duration-200 hover:bg-sk-ink/[0.05] hover:text-sk-ink active:scale-[0.97]\", RING)}\n          >\n            <Shuffle size={14} aria-hidden /> Fill evenly\n          </button>\n        </div>\n        <Button size=\"lg\" className=\"w-full active:scale-[0.97]\" disabled={!complete} loading={status === \"loading\"} onClick={add}>\n          <AnimatePresence mode=\"wait\" initial={false}>\n            <motion.span\n              key={status === \"added\" ? \"added\" : complete ? \"ready\" : \"wait\"}\n              initial={{ opacity: 0, y: reduce ? 0 : 6 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduce ? 0 : -6 }}\n              transition={{ duration: reduce ? 0 : 0.2, ease: EASE }}\n              className=\"inline-flex items-center gap-2 tabular-nums\"\n            >\n              {status === \"added\" ? (\n                <>\n                  <Check size={16} strokeWidth={2.5} /> Added to cart\n                </>\n              ) : complete ? (\n                <>Add pack of {pack.size} · {money(total)}</>\n              ) : (\n                <>\n                  Pick {left} more to add\n                </>\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </Button>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:block",
      "target": "components/storekit/upsell/upsell-mix-match.tsx"
    }
  ],
  "meta": {
    "section": "upsell",
    "variant": "mix-match",
    "niche": [
      "beverage",
      "supplements"
    ],
    "commerce": [
      "bundle",
      "atc"
    ],
    "previewHeight": 640
  }
}