{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upsell-gift-chooser",
  "type": "registry:component",
  "title": "Gift with purchase chooser",
  "description": "Locked gift options with the exact remainder to spend, then a single pick once the threshold is met.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "upsell",
    "beauty",
    "any"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "upsell/upsell-gift-chooser.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, Gift, Lock } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Gift with purchase. Below the threshold the gift options are visible but\n   locked, with the exact remainder to spend. Once the subtotal crosses it\n   the options unlock and the shopper picks one variant. If the subtotal\n   drops back under, the pick is released. Amounts 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 GiftOption = { id: string; name: string; detail?: string; image?: string; soldOut?: boolean }\n\nconst DEFAULT_GIFTS: GiftOption[] = [\n  { id: \"travel-sticks\", name: \"Electrolyte sticks\", detail: \"5 sticks\" },\n  { id: \"pill-case\", name: \"Weekly pill case\", detail: \"7 day\" },\n  { id: \"mixing-kit\", name: \"Mixing kit\", detail: \"Syringes, swabs\" },\n]\n\nexport function UpsellGiftChooser({\n  subtotal = 12800,\n  threshold = 10000,\n  giftValue = 1800,\n  gifts = DEFAULT_GIFTS,\n  onSelect,\n  className,\n}: {\n  /** Current cart subtotal in integer cents. */\n  subtotal?: number\n  threshold?: number\n  /** Retail value of the gift, shown so the offer is concrete. */\n  giftValue?: number\n  gifts?: GiftOption[]\n  onSelect?: (giftId: string | null) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"upsell\", \"upsell-gift-chooser\")\n  const unlocked = subtotal >= threshold\n  const remaining = Math.max(0, threshold - subtotal)\n  const [picked, setPicked] = useState<string | null>(null)\n  const refs = useRef<(HTMLButtonElement | null)[]>([])\n  const wasUnlocked = useRef(unlocked)\n\n  useEffect(() => {\n    if (wasUnlocked.current && !unlocked && picked) {\n      setPicked(null)\n      onSelect?.(null)\n    }\n    if (!wasUnlocked.current && unlocked) sk.emit(\"interact\", \"gift_unlocked\", subtotal)\n    wasUnlocked.current = unlocked\n  }, [unlocked])\n\n  const choose = (i: number, focus = false) => {\n    const g = gifts[i]\n    if (!unlocked || !g || g.soldOut) return\n    if (g.id !== picked) sk.emit(\"convert\", \"gift_selected\", 0)\n    setPicked(g.id)\n    onSelect?.(g.id)\n    if (focus) refs.current[i]?.focus()\n  }\n  const current = Math.max(0, gifts.findIndex((g) => g.id === picked))\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"flex w-full flex-col gap-4 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6\", className)}>\n      <div className=\"flex items-start gap-3\">\n        <span className={cn(\"grid h-10 w-10 shrink-0 place-items-center rounded-sk-btn transition-colors duration-300\", unlocked ? \"bg-sk-accent text-sk-accent-ink\" : \"bg-sk-surface text-sk-muted\")}>\n          <Gift size={18} aria-hidden />\n        </span>\n        <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\" aria-live=\"polite\">\n          <span className=\"text-sm font-medium text-sk-ink tabular-nums\">\n            {unlocked ? `Choose your free gift, a ${money(giftValue)} value` : `Spend ${money(remaining)} more for a free gift`}\n          </span>\n          <span className=\"text-[13px] text-sk-muted tabular-nums\">\n            {unlocked ? (picked ? \"Added to your order at no charge.\" : \"Pick one. It ships with this order.\") : `On orders over ${money(threshold)}. One per order.`}\n          </span>\n        </div>\n      </div>\n\n      <div className=\"h-1.5 overflow-hidden rounded-full bg-sk-surface\" role=\"progressbar\" aria-label=\"Progress to free gift\" aria-valuemin={0} aria-valuemax={threshold} aria-valuenow={Math.min(subtotal, threshold)}>\n        <motion.div\n          initial={false}\n          animate={{ scaleX: Math.min(1, subtotal / threshold) }}\n          transition={{ duration: reduce ? 0 : 0.45, ease: EASE }}\n          className={cn(\"h-full w-full origin-left rounded-full transition-colors duration-300\", unlocked ? \"bg-sk-savings\" : \"bg-sk-accent\")}\n        />\n      </div>\n\n      <div role=\"radiogroup\" aria-label=\"Free gift\" aria-disabled={!unlocked || undefined} className=\"grid grid-cols-3 gap-2\">\n        {gifts.map((g, i) => {\n          const on = g.id === picked\n          const disabled = !unlocked || g.soldOut\n          return (\n            <button\n              key={g.id}\n              ref={(el) => {\n                refs.current[i] = el\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={on}\n              aria-disabled={disabled || undefined}\n              tabIndex={unlocked && i === current ? 0 : -1}\n              onClick={() => choose(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                for (let s = 1; s <= gifts.length; s++) {\n                  const n = (current + (f ? s : -s) + gifts.length * 2) % gifts.length\n                  if (!gifts[n].soldOut) return choose(n, true)\n                }\n              }}\n              className={cn(\n                \"relative flex flex-col items-center gap-2 rounded-sk-card border p-2.5 pb-3 text-center transition-[border-color,background-color,opacity,transform] duration-200\",\n                on ? \"border-sk-accent bg-sk-accent/[0.06]\" : \"border-sk-line\",\n                disabled ? \"cursor-not-allowed opacity-55\" : \"hover:border-sk-ink/30 active:scale-[0.97]\",\n                RING\n              )}\n            >\n              <span className=\"relative grid aspect-square w-full place-items-center overflow-hidden rounded-sk-btn bg-sk-surface\">\n                {g.image ? <img src={g.image} alt=\"\" className=\"h-full w-full object-cover\" /> : <Gift size={22} className=\"text-sk-muted\" aria-hidden />}\n                <AnimatePresence initial={false}>\n                  {!unlocked ? (\n                    <motion.span\n                      key=\"lock\"\n                      initial={{ opacity: 0 }}\n                      animate={{ opacity: 1 }}\n                      exit={{ opacity: 0 }}\n                      transition={{ duration: reduce ? 0 : 0.25 }}\n                      className=\"absolute right-1.5 top-1.5 grid h-6 w-6 place-items-center rounded-full bg-sk-card text-sk-muted\"\n                    >\n                      <Lock size={12} aria-hidden />\n                    </motion.span>\n                  ) : on ? (\n                    <motion.span\n                      key=\"check\"\n                      initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n                      animate={{ opacity: 1, scale: 1 }}\n                      exit={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n                      transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}\n                      className=\"absolute right-1.5 top-1.5 grid h-6 w-6 place-items-center rounded-full bg-sk-accent text-sk-accent-ink\"\n                    >\n                      <Check size={13} strokeWidth={2.75} aria-hidden />\n                    </motion.span>\n                  ) : null}\n                </AnimatePresence>\n              </span>\n              <span className=\"flex flex-col\">\n                <span className=\"text-[13px] font-medium leading-tight text-sk-ink\">{g.name}</span>\n                <span className=\"text-[12px] text-sk-muted\">{g.soldOut ? \"Gone\" : g.detail}</span>\n              </span>\n            </button>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/upsell/upsell-gift-chooser.tsx"
    }
  ],
  "meta": {
    "section": "upsell",
    "variant": "gift-chooser",
    "niche": [
      "beauty",
      "any"
    ],
    "commerce": [
      "gwp"
    ],
    "previewHeight": 400
  }
}