{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upsell-quantity-unlock",
  "type": "registry:block",
  "title": "Quantity unlock meter",
  "description": "A stepper drives a threshold meter and one line says what the next unit unlocks.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "upsell",
    "supplements",
    "beverage",
    "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/stepper.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "upsell/upsell-quantity-unlock.tsx",
      "content": "\"use client\"\n\nimport { useEffect, 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 { Stepper } from \"@/components/storekit/ui/stepper\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Progressive quantity unlock. A stepper drives a meter with a marker at\n   each discount threshold, and one line says exactly what the next unit\n   does: \"Add 1 more to get 15% off\". Prices are integer cents. */\n\nconst EASE = [0.23, 1, 0.32, 1] as const\n\ntype Tier = { qty: number; discount: number }\n\nconst DEFAULT_TIERS: Tier[] = [\n  { qty: 2, discount: 0.1 },\n  { qty: 3, discount: 0.15 },\n  { qty: 5, discount: 0.2 },\n]\n\nexport function UpsellQuantityUnlock({\n  unitPrice = 5900,\n  tiers = DEFAULT_TIERS,\n  max = 20,\n  defaultQty = 1,\n  onAddToCart,\n  className,\n}: {\n  unitPrice?: number\n  /** Ascending by qty. A tier applies to every unit once qty reaches it. */\n  tiers?: Tier[]\n  max?: number\n  defaultQty?: number\n  onAddToCart?: (v: { quantity: number; discount: number; total: number }) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"upsell\", \"upsell-quantity-unlock\")\n  const [qty, setQty] = useState(defaultQty)\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 sorted = [...tiers].sort((a, b) => a.qty - b.qty)\n  const active = [...sorted].reverse().find((t) => qty >= t.qty)\n  const next = sorted.find((t) => qty < t.qty)\n  const discount = active?.discount ?? 0\n  const each = Math.round(unitPrice * (1 - discount))\n  const total = each * qty\n  const full = unitPrice * qty\n  const top = sorted[sorted.length - 1]?.qty ?? 1\n  const span = Math.max(1, top - 1)\n  const pos = (q: number) => Math.min(1, Math.max(0, (q - 1) / span))\n\n  const change = (n: number) => {\n    sk.emit(\"interact\", \"quantity\", n)\n    setQty(n)\n  }\n\n  const add = () => {\n    if (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        onAddToCart?.({ quantity: qty, discount, total })\n        timers.current.push(window.setTimeout(() => setStatus(\"idle\"), 1200))\n      }, 700)\n    )\n  }\n\n  const message = next\n    ? `Add ${next.qty - qty} more to get ${Math.round(next.discount * 100)}% off every unit`\n    : `${Math.round(discount * 100)}% off every unit, the best rate`\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"flex w-full flex-col gap-5 rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6\", className)}>\n      <div className=\"flex flex-col gap-3\">\n        <div className=\"relative grid min-h-5 overflow-hidden\" aria-live=\"polite\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.p\n              key={message}\n              initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduce ? 0 : -8 }}\n              transition={{ duration: reduce ? 0 : 0.3, ease: EASE }}\n              className={cn(\"col-start-1 row-start-1 text-sm font-medium tabular-nums [text-wrap:pretty]\", next ? \"text-sk-ink\" : \"text-sk-savings\")}\n            >\n              {message}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n\n        <div className=\"relative mx-5 pb-9 pt-1\">\n          <div className=\"relative h-2 rounded-full bg-sk-surface\">\n            <motion.div\n              initial={false}\n              animate={{ scaleX: pos(qty) }}\n              transition={{ duration: reduce ? 0 : 0.45, ease: EASE }}\n              className=\"absolute inset-0 origin-left rounded-full bg-sk-accent\"\n            />\n          </div>\n          {sorted.map((t) => {\n            const reached = qty >= t.qty\n            return (\n              <div key={t.qty} className=\"absolute top-0 flex -translate-x-1/2 flex-col items-center gap-1.5\" style={{ left: `${pos(t.qty) * 100}%` }}>\n                <span\n                  className={cn(\n                    \"mt-[2px] h-3 w-3 rounded-full border-2 transition-colors duration-300\",\n                    reached ? \"border-sk-accent bg-sk-accent\" : \"border-sk-line bg-sk-card\"\n                  )}\n                />\n                <span className={cn(\"flex flex-col items-center whitespace-nowrap text-[11px] font-medium leading-tight tabular-nums transition-colors duration-300\", reached ? \"text-sk-ink\" : \"text-sk-muted\")}>\n                  <span>{t.qty}+</span>\n                  <span className={reached ? \"text-sk-savings\" : undefined}>-{Math.round(t.discount * 100)}%</span>\n                </span>\n              </div>\n            )\n          })}\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between gap-4\">\n        <Stepper value={qty} onChange={change} max={max} />\n        <div className=\"flex flex-col items-end tabular-nums\">\n          <span className=\"flex items-baseline gap-2\">\n            {discount > 0 ? <s className=\"text-sm text-sk-muted\">{money(full)}</s> : null}\n            <span className=\"text-xl font-semibold text-sk-price\">{money(total)}</span>\n          </span>\n          <span className=\"text-[13px] text-sk-muted\">{money(each)} each</span>\n        </div>\n      </div>\n\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\" : \"add\"}\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 {qty} to cart · {money(total)}\n              </>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </Button>\n    </div>\n  )\n}\n",
      "type": "registry:block",
      "target": "components/storekit/upsell/upsell-quantity-unlock.tsx"
    }
  ],
  "meta": {
    "section": "upsell",
    "variant": "quantity-unlock",
    "niche": [
      "supplements",
      "beverage",
      "any"
    ],
    "commerce": [
      "quantity",
      "atc"
    ],
    "previewHeight": 380
  }
}