{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cart-upsell-carousel",
  "type": "registry:component",
  "title": "Upsell carousel",
  "description": "Horizontal snap carousel of add-on cards with one-tap add, arrow paging and end-aware arrows.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "cart",
    "any"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/product-image.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "cart/cart-upsell-carousel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, ChevronLeft, ChevronRight, Plus } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { ProductImage } from \"@/components/storekit/ui/product-image\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Horizontal add-on carousel for carts. Native scroll with snap so touch,\n   trackpad and keyboard all work; arrow buttons page by one viewport and\n   disable at the ends. Each card adds in one tap and confirms in place. */\n\nexport type CarouselAddon = { id: string; title: string; detail?: string; price: number; compareAt?: number; image: string }\n\nconst SAMPLE: CarouselAddon[] = [\n  { id: \"travel-pack\", title: \"Electrolytes Travel Pack\", detail: \"10 sticks\", price: 1200, image: \"/brands/morrow/electrolytes.webp\" },\n  { id: \"electrolytes\", title: \"Electrolytes\", detail: \"30 sticks\", price: 3200, image: \"/brands/morrow/electrolytes.webp\" },\n  { id: \"magnesium-night\", title: \"Magnesium Night\", detail: \"60 gummies\", price: 2900, image: \"/brands/morrow/magnesium.webp\" },\n  { id: \"protein\", title: \"Protein\", detail: \"30 servings\", price: 5400, compareAt: 6200, image: \"/brands/morrow/protein.webp\" },\n  { id: \"focus\", title: \"Focus\", detail: \"60 capsules\", price: 3900, image: \"/brands/morrow/focus.webp\" },\n  { id: \"sparkling-recovery\", title: \"Sparkling Recovery\", detail: \"12 cans\", price: 3600, image: \"/brands/morrow/sparkling.webp\" },\n]\n\nexport function CartUpsellCarousel({\n  title = \"Add to your order\",\n  items = SAMPLE,\n  onAdd,\n  className,\n}: {\n  title?: string\n  items?: CarouselAddon[]\n  onAdd?: (item: CarouselAddon) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"cart\", \"cart-upsell-carousel\")\n  const scroller = React.useRef<HTMLDivElement>(null)\n  const [edges, setEdges] = React.useState({ start: true, end: false })\n  const [added, setAdded] = React.useState<Record<string, boolean>>({})\n  const titleId = React.useId()\n\n  const measure = React.useCallback(() => {\n    const el = scroller.current\n    if (!el) return\n    setEdges({ start: el.scrollLeft <= 2, end: el.scrollLeft + el.clientWidth >= el.scrollWidth - 2 })\n  }, [])\n\n  React.useEffect(() => {\n    measure()\n    const el = scroller.current\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const ro = new ResizeObserver(measure)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [measure, items.length])\n\n  const page = (dir: 1 | -1) => {\n    const el = scroller.current\n    if (!el) return\n    el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: reduce ? \"auto\" : \"smooth\" })\n  }\n\n  const add = (it: CarouselAddon) => {\n    setAdded((a) => ({ ...a, [it.id]: true }))\n    sk.emit(\"convert\", \"upsell_add\", it.price)\n    onAdd?.(it)\n  }\n\n  const arrow = \"grid h-10 w-10 place-items-center rounded-sk-btn border border-sk-line bg-sk-card text-sk-ink transition-[background-color,opacity,transform] duration-200 hover:bg-sk-ink/[0.05] disabled:opacity-35 disabled:hover:bg-sk-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]\"\n\n  return (\n    <section ref={sk.ref as React.Ref<HTMLElement>} {...sk.attrs} aria-labelledby={titleId} className={cn(\"w-full min-w-0\", className)}>\n      <div className=\"flex items-center justify-between gap-3\">\n        <h3 id={titleId} className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">\n          {title}\n        </h3>\n        <div className=\"flex gap-1.5\">\n          <button type=\"button\" aria-label=\"Previous add-ons\" className={arrow} disabled={edges.start} onClick={() => page(-1)}>\n            <ChevronLeft size={16} />\n          </button>\n          <button type=\"button\" aria-label=\"Next add-ons\" className={arrow} disabled={edges.end} onClick={() => page(1)}>\n            <ChevronRight size={16} />\n          </button>\n        </div>\n      </div>\n      <div\n        ref={scroller}\n        onScroll={measure}\n        role=\"list\"\n        className=\"-mx-1 mt-3 flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-px-1 px-1 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n      >\n        {items.map((it) => {\n          const on = added[it.id]\n          return (\n            <div key={it.id} role=\"listitem\" className=\"flex w-[148px] shrink-0 snap-start flex-col rounded-sk-card border border-sk-line bg-sk-card p-2.5\">\n              <ProductImage src={it.image} alt={it.title} className=\"w-full rounded-sk-card\" zoomOnHover={false} />\n              <p className=\"mt-2 truncate text-[13px] font-medium text-sk-ink\">{it.title}</p>\n              {it.detail ? <p className=\"truncate text-xs text-sk-muted\">{it.detail}</p> : null}\n              <div className=\"mt-auto flex items-end justify-between gap-2 pt-2\">\n                <p className=\"text-[13px] tabular-nums\">\n                  <span className={cn(\"font-semibold\", it.compareAt ? \"text-sk-sale\" : \"text-sk-price\")}>{money(it.price)}</span>\n                  {it.compareAt ? <s className=\"ml-1 block text-xs text-sk-muted\">{money(it.compareAt)}</s> : null}\n                </p>\n                <button\n                  type=\"button\"\n                  aria-label={on ? `${it.title} added` : `Add ${it.title}`}\n                  onClick={() => add(it)}\n                  className={cn(\n                    \"relative grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-sk-btn transition-[background-color,color,transform] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card active:scale-[0.97]\",\n                    on ? \"bg-sk-savings/15 text-sk-savings\" : \"bg-sk-accent text-sk-accent-ink hover:brightness-[1.06]\"\n                  )}\n                >\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    <motion.span\n                      key={on ? \"on\" : \"off\"}\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                      animate={{ opacity: 1, scale: 1 }}\n                      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                      transition={{ duration: reduce ? 0 : 0.2 }}\n                    >\n                      {on ? <Check size={16} /> : <Plus size={16} />}\n                    </motion.span>\n                  </AnimatePresence>\n                </button>\n              </div>\n            </div>\n          )\n        })}\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/cart/cart-upsell-carousel.tsx"
    }
  ],
  "meta": {
    "section": "cart",
    "variant": "carousel",
    "niche": [
      "any"
    ],
    "commerce": [
      "upsell",
      "atc"
    ],
    "previewHeight": 360
  }
}