{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upsell-fbt",
  "type": "registry:block",
  "title": "Frequently bought together",
  "description": "Three products with animated checkboxes, a flipping total, set savings and a single add button.",
  "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/sk-analytics.json"
  ],
  "files": [
    {
      "path": "upsell/upsell-fbt.tsx",
      "content": "\"use client\"\n\nimport { Fragment, useEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, Plus } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { Button } from \"@/components/storekit/ui/button\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Frequently bought together. Each product toggles with an animated check;\n   the total flips as the selection changes and a bundle discount applies\n   when every item is in. */\n\ntype Item = { id: string; title: string; price: number; image: string }\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\nconst DEFAULT_ITEMS: Item[] = [\n  { id: \"daily-greens\", title: \"Daily Greens\", price: 4900, image: \"/brands/morrow/greens.webp\" },\n  { id: \"electrolytes\", title: \"Electrolytes\", price: 3200, image: \"/brands/morrow/electrolytes.webp\" },\n  { id: \"magnesium-night\", title: \"Magnesium Night\", price: 2900, image: \"/brands/morrow/magnesium.webp\" },\n]\n\nfunction Checkbox({ checked, reduce }: { checked: boolean; reduce: boolean | null }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"grid h-5 w-5 place-items-center rounded-[6px] border transition-colors duration-200\",\n        checked ? \"border-sk-accent bg-sk-accent text-sk-accent-ink\" : \"border-sk-line bg-sk-card\"\n      )}\n    >\n      <svg viewBox=\"0 0 16 16\" className=\"h-3 w-3\" fill=\"none\">\n        <motion.path\n          d=\"M3.5 8.5l3 3 6-7\"\n          stroke=\"currentColor\"\n          strokeWidth={2.4}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          initial={false}\n          animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}\n          transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}\n        />\n      </svg>\n    </span>\n  )\n}\n\nexport function UpsellFbt({\n  items = DEFAULT_ITEMS,\n  bundleDiscount = 0.1,\n  title = \"Frequently bought together\",\n  onAdd,\n  className,\n}: {\n  items?: Item[]\n  /** Applied when every item is selected. */\n  bundleDiscount?: number\n  title?: string\n  onAdd?: (ids: string[]) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"upsell\", \"upsell-fbt\")\n  const [selected, setSelected] = useState<string[]>(items.map((i) => i.id))\n  const [status, setStatus] = useState<\"idle\" | \"loading\" | \"added\">(\"idle\")\n  const timers = useRef<number[]>([])\n  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])\n\n  const chosen = items.filter((i) => selected.includes(i.id))\n  const subtotal = chosen.reduce((s, i) => s + i.price, 0)\n  const all = chosen.length === items.length && items.length > 1\n  const total = all ? Math.round(subtotal * (1 - bundleDiscount)) : subtotal\n  const saved = subtotal - total\n  const toggle = (id: string) => setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))\n\n  const add = () => {\n    if (!chosen.length || 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        onAdd?.(chosen.map((c) => c.id))\n        timers.current.push(window.setTimeout(() => setStatus(\"idle\"), 1200))\n      }, 700)\n    )\n  }\n\n  return (\n    <section {...sk.attrs} ref={sk.ref as React.RefObject<HTMLElement>} className={cn(\"w-full rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6\", className)} aria-label={title}>\n      <h3 className=\"font-sk-display text-lg font-semibold tracking-[-0.02em] text-sk-ink\">{title}</h3>\n      <div className=\"mt-5 grid gap-6 md:grid-cols-[1fr_220px] md:items-center\">\n        <div className=\"flex items-start gap-2 sm:gap-3\">\n          {items.map((item, i) => {\n            const on = selected.includes(item.id)\n            return (\n              <Fragment key={item.id}>\n                {i > 0 ? (\n                  <span className=\"shrink-0 self-center pb-12 text-sk-muted\" aria-hidden>\n                    <Plus size={16} />\n                  </span>\n                ) : null}\n                <button\n                  type=\"button\"\n                  role=\"checkbox\"\n                  aria-checked={on}\n                  onClick={() => toggle(item.id)}\n                  className={cn(\"group flex min-w-0 flex-1 flex-col gap-2 rounded-sk-btn text-left active:scale-[0.97] transition-transform duration-200\", RING)}\n                >\n                  <span className={cn(\"relative block aspect-square overflow-hidden rounded-sk-btn border bg-sk-surface transition-[border-color,opacity] duration-200\", on ? \"border-sk-line\" : \"border-sk-line opacity-50\")}>\n                    <img src={item.image} alt=\"\" loading=\"lazy\" className=\"absolute inset-0 h-full w-full object-cover\" />\n                    <span className=\"absolute left-2 top-2\">\n                      <Checkbox checked={on} reduce={reduce} />\n                    </span>\n                  </span>\n                  <span className=\"line-clamp-2 min-h-[2.6em] text-[13px] font-medium leading-[1.3] text-sk-ink\">{item.title}</span>\n                  <span className=\"text-[13px] font-semibold tabular-nums text-sk-price\">{money(item.price)}</span>\n                </button>\n              </Fragment>\n            )\n          })}\n        </div>\n\n        <div className=\"flex flex-col gap-3 border-t border-sk-line pt-5 md:border-l md:border-t-0 md:pl-6 md:pt-0\">\n          <span className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">Total</span>\n          <div className=\"relative h-9 overflow-hidden\" aria-live=\"polite\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.span\n                key={total}\n                initial={{ opacity: 0, y: reduce ? 0 : 12 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: reduce ? 0 : -12 }}\n                transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}\n                className=\"absolute inset-x-0 top-0 flex items-baseline gap-2 tabular-nums\"\n              >\n                <span className=\"text-2xl font-semibold text-sk-price\">{money(total)}</span>\n                {saved > 0 ? <s className=\"text-sm text-sk-muted\">{money(subtotal)}</s> : null}\n              </motion.span>\n            </AnimatePresence>\n          </div>\n          <p className={cn(\"text-[13px]\", saved > 0 ? \"text-sk-savings\" : \"text-sk-muted\")}>\n            {saved > 0 ? `You save ${money(saved)} as a set` : `Add all ${items.length} to save ${Math.round(bundleDiscount * 100)}%`}\n          </p>\n          <Button onClick={add} loading={status === \"loading\"} disabled={!chosen.length} className=\"w-full\">\n            {status === \"added\" ? (\n              <>\n                <Check size={16} strokeWidth={2.5} /> Added\n              </>\n            ) : chosen.length ? (\n              `Add ${chosen.length} to cart`\n            ) : (\n              \"Select an item\"\n            )}\n          </Button>\n        </div>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:block",
      "target": "components/storekit/upsell/upsell-fbt.tsx"
    }
  ],
  "meta": {
    "section": "upsell",
    "variant": "fbt",
    "niche": [
      "any"
    ],
    "commerce": [
      "atc",
      "bundle"
    ],
    "previewHeight": 420
  }
}