{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cart-qty-undo",
  "type": "registry:component",
  "title": "Typed quantity with undo",
  "description": "Validated typed quantity input that explains bad values, and removal with a 5-second Undo that restores the line in place.",
  "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-qty-undo.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Trash2, Undo2 } 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/* Typed quantity with validation, and removal you can take back. The input\n   accepts whole numbers within stock, commits on blur or Enter, and explains\n   a bad value instead of silently clamping it. Removing a line shows an Undo\n   bar for 5 seconds that puts the line back where it was. */\n\nexport type UndoLine = { id: string; title: string; variant?: string; price: number; image: string; quantity: number; stock?: number }\n\nconst SAMPLE: UndoLine[] = [\n  { id: \"daily-greens\", title: \"Daily Greens\", variant: \"30 servings\", price: 4900, image: \"/brands/morrow/greens.webp\", quantity: 2, stock: 12 },\n  { id: \"electrolytes\", title: \"Electrolytes\", variant: \"30 sticks\", price: 3200, image: \"/brands/morrow/electrolytes.webp\", quantity: 1, stock: 4 },\n  { id: \"tablets\", title: \"Magnesium Night\", variant: \"60 gummies\", price: 2900, image: \"/brands/morrow/magnesium.webp\", quantity: 1, stock: 20 },\n]\n\nfunction QtyInput({ line, onCommit }: { line: UndoLine; onCommit: (q: number) => void }) {\n  const max = line.stock ?? 99\n  const [draft, setDraft] = React.useState(String(line.quantity))\n  const [error, setError] = React.useState<string>()\n  const id = React.useId()\n  React.useEffect(() => setDraft(String(line.quantity)), [line.quantity])\n\n  const commit = () => {\n    const v = draft.trim()\n    if (!/^\\d+$/.test(v)) return setError(\"Enter a whole number.\")\n    const n = Number(v)\n    if (n < 1) return setError(\"Use Remove to take it out.\")\n    if (n > max) return setError(`Only ${max} in stock.`)\n    setError(undefined)\n    if (n !== line.quantity) onCommit(n)\n  }\n\n  return (\n    <div>\n      <label htmlFor={id} className=\"sr-only\">\n        Quantity for {line.title}\n      </label>\n      <input\n        id={id}\n        inputMode=\"numeric\"\n        autoComplete=\"off\"\n        value={draft}\n        aria-invalid={!!error}\n        aria-describedby={error ? `${id}-err` : undefined}\n        onChange={(e) => {\n          setDraft(e.target.value)\n          if (error) setError(undefined)\n        }}\n        onBlur={commit}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault()\n            commit()\n          }\n          if (e.key === \"Escape\") {\n            setDraft(String(line.quantity))\n            setError(undefined)\n          }\n        }}\n        className={cn(\n          \"h-10 w-16 rounded-sk-btn border bg-sk-card px-2 text-center text-sm tabular-nums text-sk-ink transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2\",\n          error ? \"border-sk-sale focus-visible:ring-sk-sale/30\" : \"border-sk-line focus-visible:border-sk-accent focus-visible:ring-sk-accent/30\"\n        )}\n      />\n      {error ? (\n        <p id={`${id}-err`} role=\"alert\" className=\"mt-1 text-xs text-sk-sale\">\n          {error}\n        </p>\n      ) : null}\n    </div>\n  )\n}\n\nexport function CartQtyUndo({\n  items = SAMPLE,\n  undoMs = 5000,\n  onChange,\n  contained = true,\n  className,\n}: {\n  items?: UndoLine[]\n  undoMs?: number\n  onChange?: (lines: UndoLine[]) => void\n  /** Keep the Undo bar inside this block. Set false to pin it to the viewport. */\n  contained?: boolean\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"cart\", \"cart-qty-undo\")\n  const [lines, setLines] = React.useState(items)\n  const [removed, setRemoved] = React.useState<{ line: UndoLine; index: number } | null>(null)\n  const subtotal = lines.reduce((n, l) => n + l.price * l.quantity, 0)\n\n  React.useEffect(() => {\n    if (!removed) return\n    const t = window.setTimeout(() => setRemoved(null), undoMs)\n    return () => window.clearTimeout(t)\n  }, [removed, undoMs])\n\n  const commit = (next: UndoLine[]) => {\n    setLines(next)\n    onChange?.(next)\n  }\n  const remove = (l: UndoLine) => {\n    const index = lines.findIndex((x) => x.id === l.id)\n    commit(lines.filter((x) => x.id !== l.id))\n    setRemoved({ line: l, index })\n    sk.emit(\"interact\", \"remove\", l.price * l.quantity)\n  }\n  const undo = () => {\n    if (!removed) return\n    const next = [...lines]\n    next.splice(Math.min(removed.index, next.length), 0, removed.line)\n    commit(next)\n    setRemoved(null)\n    sk.emit(\"interact\", \"undo_remove\", removed.line.price * removed.line.quantity)\n  }\n\n  return (\n    <div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn(\"relative w-full pb-16\", className)}>\n      <ul className=\"border-y border-sk-line\">\n        <AnimatePresence initial={false}>\n          {lines.map((l) => (\n            <motion.li\n              key={l.id}\n              layout={!reduce}\n              initial={{ opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={{ opacity: 0, height: 0 }}\n              transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}\n              className=\"overflow-hidden border-b border-sk-line last:border-b-0\"\n            >\n              <div className=\"flex gap-3 py-4\">\n                <ProductImage src={l.image} alt={l.title} className=\"h-16 w-16 shrink-0 rounded-sk-card border border-sk-line\" fit=\"cover\" zoomOnHover={false} />\n                <div className=\"min-w-0 flex-1\">\n                  <div className=\"flex items-start justify-between gap-3\">\n                    <div className=\"min-w-0\">\n                      <p className=\"truncate text-sm font-medium\">{l.title}</p>\n                      <p className=\"text-[13px] text-sk-muted\">\n                        {l.variant ? `${l.variant} / ` : null}\n                        <span className=\"tabular-nums\">{money(l.price)}</span> each\n                      </p>\n                    </div>\n                    <p className=\"shrink-0 text-sm font-semibold tabular-nums text-sk-price\">{money(l.price * l.quantity)}</p>\n                  </div>\n                  <div className=\"mt-2 flex items-start justify-between gap-3\">\n                    <QtyInput line={l} onCommit={(q) => commit(lines.map((x) => (x.id === l.id ? { ...x, quantity: q } : x)))} />\n                    <button\n                      type=\"button\"\n                      onClick={() => remove(l)}\n                      className=\"-mr-2 inline-flex h-10 items-center gap-1.5 rounded-sk-btn px-2 text-[13px] text-sk-muted transition-colors duration-200 hover:text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]\"\n                    >\n                      <Trash2 size={14} />\n                      Remove\n                    </button>\n                  </div>\n                </div>\n              </div>\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </ul>\n      <div className=\"flex justify-between pt-3 text-sm\">\n        <span className=\"text-sk-muted\">Subtotal</span>\n        <span className=\"font-semibold tabular-nums\">{money(subtotal)}</span>\n      </div>\n\n      <div aria-live=\"polite\" className={cn(contained ? \"absolute\" : \"fixed\", \"inset-x-0 bottom-0 z-20 flex justify-center\")}>\n        <AnimatePresence>\n          {removed ? (\n            <motion.div\n              key={removed.line.id}\n              role=\"status\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}\n              transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}\n              className=\"flex w-full max-w-sm items-center gap-3 overflow-hidden rounded-sk-card bg-sk-ink py-1.5 pl-4 pr-1.5 text-sk-paper shadow-[0_24px_60px_-24px_rgb(0_0_0/0.4)]\"\n            >\n              <p className=\"min-w-0 flex-1 truncate text-[13px]\">Removed {removed.line.title}</p>\n              <button\n                type=\"button\"\n                onClick={undo}\n                className=\"inline-flex h-10 shrink-0 items-center gap-1.5 rounded-sk-btn px-3 text-[13px] font-semibold text-sk-paper transition-colors duration-200 hover:bg-sk-paper/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-paper active:scale-[0.97]\"\n              >\n                <Undo2 size={14} />\n                Undo\n              </button>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/cart/cart-qty-undo.tsx"
    }
  ],
  "meta": {
    "section": "cart",
    "variant": "row",
    "niche": [
      "any"
    ],
    "commerce": [
      "cart",
      "quantity"
    ],
    "previewHeight": 480
  }
}