{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upsell-best-value-tabs",
  "type": "registry:block",
  "title": "Single vs best value tabs",
  "description": "A single unit and a multipack as two tabs, with the per-unit price and saving shown on the pack.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "upsell",
    "any"
  ],
  "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/price.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "upsell/upsell-best-value-tabs.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { Button } from \"@/components/storekit/ui/button\"\nimport { Price } from \"@/components/storekit/ui/price\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Single vs best value as two tabs. The best value tab is the multipack:\n   its tab label carries the saving, its panel shows the per-unit price next\n   to the single price so the maths is visible. 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 Offer = { id: string; tab: string; units: number; price: number; points: string[] }\n\nexport function UpsellBestValueTabs({\n  title = \"Daily Greens\",\n  image,\n  unitPrice = 4900,\n  packUnits = 3,\n  packPrice,\n  defaultTab = \"pack\",\n  onAddToCart,\n  className,\n}: {\n  title?: string\n  image?: string\n  /** Price of one unit in integer cents. */\n  unitPrice?: number\n  packUnits?: number\n  /** Multipack price in integer cents. Defaults to 20% off the unit total. */\n  packPrice?: number\n  defaultTab?: \"single\" | \"pack\"\n  onAddToCart?: (v: { offerId: string; units: number; price: number }) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const uid = useId()\n  const sk = useSkBlock(\"upsell\", \"upsell-best-value-tabs\")\n  const pack = packPrice ?? Math.round(unitPrice * packUnits * 0.8)\n  const fullPack = unitPrice * packUnits\n  const pct = Math.round((1 - pack / fullPack) * 100)\n  const offers: Offer[] = [\n    { id: \"single\", tab: \"Single\", units: 1, price: unitPrice, points: [`One ${title}`, `30 servings, ${money(Math.round(unitPrice / 30))} each`, \"Free shipping over $50\"] },\n    { id: \"pack\", tab: \"Best value\", units: packUnits, price: pack, points: [`${packUnits} x ${title}, shipped together`, `${money(fullPack - pack)} less than buying separately`, \"Free shipping on this order\"] },\n  ]\n  const [tab, setTab] = useState(defaultTab === \"single\" ? 0 : 1)\n  const [status, setStatus] = useState<\"idle\" | \"loading\" | \"added\">(\"idle\")\n  const refs = useRef<(HTMLButtonElement | null)[]>([])\n  const timers = useRef<number[]>([])\n  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])\n  const offer = offers[tab]\n\n  const pick = (i: number, focus = false) => {\n    if (i !== tab) sk.emit(\"interact\", offers[i].id, offers[i].price)\n    setTab(i)\n    if (focus) refs.current[i]?.focus()\n  }\n\n  const add = () => {\n    if (status !== \"idle\") return\n    setStatus(\"loading\")\n    sk.emit(\"convert\", \"add_to_cart\", offer.price)\n    timers.current.push(\n      window.setTimeout(() => {\n        setStatus(\"added\")\n        onAddToCart?.({ offerId: offer.id, units: offer.units, price: offer.price })\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-4\", className)}>\n      <div role=\"tablist\" aria-label=\"Pack size\" className=\"grid grid-cols-2 gap-1 rounded-sk-card border border-sk-line bg-sk-surface p-1\">\n        {offers.map((o, i) => {\n          const on = i === tab\n          return (\n            <button\n              key={o.id}\n              ref={(el) => {\n                refs.current[i] = el\n              }}\n              id={`${uid}-tab-${o.id}`}\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={on}\n              aria-controls={`${uid}-panel`}\n              tabIndex={on ? 0 : -1}\n              onClick={() => pick(i)}\n              onKeyDown={(e) => {\n                if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n                  e.preventDefault()\n                  pick(tab === 0 ? 1 : 0, true)\n                }\n              }}\n              className={cn(\"relative flex min-h-[52px] flex-col items-center justify-center rounded-sk-btn px-3 py-2 transition-transform duration-200 active:scale-[0.97]\", RING)}\n            >\n              {on ? (\n                <motion.span layoutId={`${uid}-pill`} transition={{ duration: reduce ? 0 : 0.35, ease: EASE }} className=\"absolute inset-0 rounded-sk-btn border border-sk-line bg-sk-card\" />\n              ) : null}\n              <span className={cn(\"relative text-sm font-medium transition-colors duration-200\", on ? \"text-sk-ink\" : \"text-sk-muted\")}>{o.tab}</span>\n              <span className={cn(\"relative text-[12px] tabular-nums\", o.id === \"pack\" ? \"font-semibold text-sk-savings\" : \"text-sk-muted\")}>\n                {o.id === \"pack\" ? `${o.units} pack, save ${pct}%` : \"1 unit\"}\n              </span>\n            </button>\n          )\n        })}\n      </div>\n\n      <div id={`${uid}-panel`} role=\"tabpanel\" aria-labelledby={`${uid}-tab-${offer.id}`} className=\"flex flex-col gap-5 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          <motion.div\n            key={offer.id}\n            initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: reduce ? 0 : -6 }}\n            transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}\n            className=\"flex gap-4\"\n          >\n            {image ? (\n              <div className=\"relative h-20 w-20 shrink-0 overflow-hidden rounded-sk-btn bg-sk-surface\">\n                <img src={image} alt=\"\" className=\"h-full w-full object-cover\" />\n                {offer.units > 1 ? (\n                  <span className=\"absolute bottom-1 right-1 rounded-sk-chip bg-sk-ink px-1.5 py-0.5 text-[11px] font-semibold text-sk-paper tabular-nums\">x{offer.units}</span>\n                ) : null}\n              </div>\n            ) : null}\n            <div className=\"flex min-w-0 flex-1 flex-col gap-3\">\n              <Price\n                amount={offer.price}\n                compareAt={offer.units > 1 ? fullPack : undefined}\n                size=\"lg\"\n                per={offer.units > 1 ? { amount: Math.round(offer.price / offer.units), unit: \"each\" } : undefined}\n              />\n              <ul className=\"flex flex-col gap-2\">\n                {offer.points.map((p) => (\n                  <li key={p} className=\"flex items-start gap-2 text-sm text-sk-ink tabular-nums\">\n                    <Check size={15} strokeWidth={2.25} className=\"mt-0.5 shrink-0 text-sk-accent\" aria-hidden />\n                    {p}\n                  </li>\n                ))}\n              </ul>\n            </div>\n          </motion.div>\n        </AnimatePresence>\n        <Button size=\"lg\" className=\"w-full active:scale-[0.97]\" loading={status === \"loading\"} onClick={add}>\n          <AnimatePresence mode=\"wait\" initial={false}>\n            <motion.span\n              key={status === \"added\" ? \"added\" : offer.id}\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              ) : (\n                <>\n                  Add {offer.units > 1 ? `${offer.units} pack` : \"to cart\"} · {money(offer.price)}\n                </>\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </Button>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:block",
      "target": "components/storekit/upsell/upsell-best-value-tabs.tsx"
    }
  ],
  "meta": {
    "section": "upsell",
    "variant": "best-value-tabs",
    "niche": [
      "any"
    ],
    "commerce": [
      "bundle",
      "atc"
    ],
    "previewHeight": 400
  }
}