{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upsell-gift-options",
  "type": "registry:component",
  "title": "Gift options",
  "description": "This is a gift toggle that opens wrapping, a gift note with a character count and hidden prices.",
  "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-options.tsx",
      "content": "\"use client\"\n\nimport { useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Gift } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* \"This is a gift\" toggle. Switching it on opens wrap options, a gift note\n   with a live character count, and a choice to leave prices off the packing\n   slip. Everything is reported through onChange so the cart can store it as\n   line item properties. Wrap 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 Wrap = { id: string; label: string; detail: string; price: number }\nexport type GiftOptionsValue = { isGift: boolean; wrapId: string; note: string; hidePrices: boolean }\n\nconst DEFAULT_WRAPS: Wrap[] = [\n  { id: \"sleeve\", label: \"Gift sleeve\", detail: \"Recycled card sleeve\", price: 0 },\n  { id: \"box\", label: \"Gift box\", detail: \"Rigid box, ribbon tie\", price: 600 },\n]\n\nexport function UpsellGiftOptions({\n  wraps = DEFAULT_WRAPS,\n  maxNote = 200,\n  defaultOpen = false,\n  onChange,\n  className,\n}: {\n  wraps?: Wrap[]\n  maxNote?: number\n  defaultOpen?: boolean\n  onChange?: (v: GiftOptionsValue) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const uid = useId()\n  const sk = useSkBlock(\"upsell\", \"upsell-gift-options\")\n  const [v, setV] = useState<GiftOptionsValue>({ isGift: defaultOpen, wrapId: wraps[0]?.id ?? \"\", note: \"\", hidePrices: true })\n  const wrapRefs = useRef<(HTMLButtonElement | null)[]>([])\n  const t = { duration: reduce ? 0 : 0.35, ease: EASE }\n\n  const update = (patch: Partial<GiftOptionsValue>) => {\n    const next = { ...v, ...patch }\n    setV(next)\n    onChange?.(next)\n  }\n  const toggle = () => {\n    sk.emit(\"interact\", v.isGift ? \"gift_off\" : \"gift_on\", undefined)\n    update({ isGift: !v.isGift })\n  }\n  const pickWrap = (i: number, focus = false) => {\n    if (wraps[i].id !== v.wrapId) sk.emit(\"interact\", `wrap_${wraps[i].id}`, wraps[i].price)\n    update({ wrapId: wraps[i].id })\n    if (focus) wrapRefs.current[i]?.focus()\n  }\n  const wrapIdx = Math.max(0, wraps.findIndex((w) => w.id === v.wrapId))\n  const left = maxNote - v.note.length\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"w-full overflow-hidden rounded-sk-card border border-sk-line bg-sk-card\", className)}>\n      <button\n        type=\"button\"\n        role=\"switch\"\n        aria-checked={v.isGift}\n        aria-controls={`${uid}-panel`}\n        onClick={toggle}\n        className={cn(\"flex min-h-[60px] w-full items-center gap-3 px-4 py-3 text-left transition-colors duration-200 hover:bg-sk-ink/[0.03] sm:px-5\", RING)}\n      >\n        <Gift size={18} className={cn(\"shrink-0 transition-colors duration-200\", v.isGift ? \"text-sk-accent\" : \"text-sk-muted\")} aria-hidden />\n        <span className=\"flex min-w-0 flex-1 flex-col\">\n          <span className=\"text-sm font-medium text-sk-ink\">This is a gift</span>\n          <span className=\"text-[13px] text-sk-muted\">Add wrapping and a note</span>\n        </span>\n        <span aria-hidden className={cn(\"relative h-6 w-10 shrink-0 rounded-full transition-colors duration-200\", v.isGift ? \"bg-sk-accent\" : \"bg-sk-ink/[0.15]\")}>\n          <motion.span\n            initial={false}\n            animate={{ x: v.isGift ? 16 : 0 }}\n            transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}\n            className=\"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-sk-paper shadow-[0_1px_2px_rgb(var(--sk-ink)/0.2)]\"\n          />\n        </span>\n      </button>\n\n      <AnimatePresence initial={false}>\n        {v.isGift ? (\n          <motion.div id={`${uid}-panel`} key=\"panel\" initial={{ height: 0, opacity: 0 }} animate={{ height: \"auto\", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={t} className=\"overflow-hidden\">\n            <div className=\"flex flex-col gap-5 border-t border-sk-line px-4 py-4 sm:px-5 sm:py-5\">\n              <div className=\"flex flex-col gap-2\">\n                <span className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">Wrapping</span>\n                <div role=\"radiogroup\" aria-label=\"Wrapping\" className=\"grid gap-2 sm:grid-cols-2\">\n                  {wraps.map((w, i) => {\n                    const on = w.id === v.wrapId\n                    return (\n                      <button\n                        key={w.id}\n                        ref={(el) => {\n                          wrapRefs.current[i] = el\n                        }}\n                        type=\"button\"\n                        role=\"radio\"\n                        aria-checked={on}\n                        tabIndex={on ? 0 : -1}\n                        onClick={() => pickWrap(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                          pickWrap((wrapIdx + (f ? 1 : -1) + wraps.length) % wraps.length, true)\n                        }}\n                        className={cn(\n                          \"flex min-h-[56px] items-center gap-3 rounded-sk-btn border px-3 py-2.5 text-left transition-[border-color,background-color,transform] duration-200 active:scale-[0.97]\",\n                          on ? \"border-sk-accent bg-sk-accent/[0.06]\" : \"border-sk-line hover:border-sk-ink/30\",\n                          RING\n                        )}\n                      >\n                        <span className={cn(\"grid h-4 w-4 shrink-0 place-items-center rounded-full border\", on ? \"border-sk-accent\" : \"border-sk-line\")}>\n                          <motion.span initial={false} animate={{ scale: on ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.2, ease: EASE }} className=\"h-2 w-2 rounded-full bg-sk-accent\" />\n                        </span>\n                        <span className=\"flex min-w-0 flex-1 flex-col\">\n                          <span className=\"text-sm font-medium text-sk-ink\">{w.label}</span>\n                          <span className=\"text-[12px] text-sk-muted\">{w.detail}</span>\n                        </span>\n                        <span className=\"shrink-0 text-sm font-medium tabular-nums text-sk-ink\">{w.price === 0 ? \"Free\" : `+${money(w.price)}`}</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\">\n                  <label htmlFor={`${uid}-note`} className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">\n                    Gift note\n                  </label>\n                  <span id={`${uid}-count`} aria-live=\"polite\" className={cn(\"text-[12px] tabular-nums\", left <= 20 ? \"text-sk-sale\" : \"text-sk-muted\")}>\n                    {left} left\n                  </span>\n                </div>\n                <textarea\n                  id={`${uid}-note`}\n                  value={v.note}\n                  maxLength={maxNote}\n                  rows={3}\n                  aria-describedby={`${uid}-count`}\n                  placeholder=\"Printed on a card inside the box\"\n                  onChange={(e) => update({ note: e.target.value.slice(0, maxNote) })}\n                  className={cn(\n                    \"w-full resize-none rounded-sk-card border border-sk-line bg-sk-paper px-3 py-2.5 text-sm leading-[1.5] text-sk-ink placeholder:text-sk-muted/70 transition-[border-color] duration-200 focus:border-sk-accent\",\n                    RING\n                  )}\n                />\n              </div>\n\n              <label className=\"flex min-h-[44px] cursor-pointer items-center gap-3\">\n                <input type=\"checkbox\" checked={v.hidePrices} onChange={(e) => update({ hidePrices: e.target.checked })} className=\"peer sr-only\" />\n                <span\n                  aria-hidden\n                  className=\"grid h-5 w-5 shrink-0 place-items-center rounded-[6px] border border-sk-line bg-sk-card text-sk-accent-ink transition-colors duration-200 peer-checked:border-sk-accent peer-checked:bg-sk-accent peer-focus-visible:ring-2 peer-focus-visible:ring-sk-accent peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-sk-paper\"\n                >\n                  <svg viewBox=\"0 0 16 16\" className={cn(\"h-3.5 w-3.5 transition-opacity duration-200\", v.hidePrices ? \"opacity-100\" : \"opacity-0\")} fill=\"none\">\n                    <path d=\"M3.5 8.5l3 3 6-7\" stroke=\"currentColor\" strokeWidth={2.25} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                  </svg>\n                </span>\n                <span className=\"flex flex-col\">\n                  <span className=\"text-sm text-sk-ink\">Hide prices on the packing slip</span>\n                  <span className=\"text-[12px] text-sk-muted\">Your receipt still goes to your email.</span>\n                </span>\n              </label>\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/upsell/upsell-gift-options.tsx"
    }
  ],
  "meta": {
    "section": "upsell",
    "variant": "gift-options",
    "niche": [
      "beauty",
      "any"
    ],
    "commerce": [
      "gift"
    ],
    "previewHeight": 520
  }
}