{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cart-page",
  "type": "registry:block",
  "title": "Cart page",
  "description": "Two-column cart page: line items with steppers on the left, a sticky order summary with shipping math and checkout on the right. Stacks on mobile.",
  "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/button.json",
    "https://storekit-eight.vercel.app/r/price.json",
    "https://storekit-eight.vercel.app/r/product-image.json",
    "https://storekit-eight.vercel.app/r/stepper.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "cart/cart-page.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, Lock, ShieldCheck, Trash2, Truck } 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 { ProductImage } from \"@/components/storekit/ui/product-image\"\nimport { Stepper } from \"@/components/storekit/ui/stepper\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Full cart page. Lines on the left, an order summary on the right that\n   sticks while the list scrolls. Stacks on mobile with the summary last.\n   Owns quantity and removal state after mount. */\n\nexport type CartPageLine = {\n  id: string\n  title: string\n  variant?: string\n  price: number\n  compareAt?: number\n  image: string\n  quantity: number\n}\n\nconst SAMPLE: CartPageLine[] = [\n  { id: \"daily-greens\", title: \"Daily Greens\", variant: \"30 servings\", price: 4900, image: \"/brands/morrow/greens.webp\", quantity: 2 },\n  { id: \"electrolytes\", title: \"Electrolytes\", variant: \"30 sticks\", price: 3200, image: \"/brands/morrow/electrolytes.webp\", quantity: 1 },\n  { id: \"magnesium-night\", title: \"Magnesium Night\", variant: \"60 gummies\", price: 2900, image: \"/brands/morrow/magnesium.webp\", quantity: 1 },\n]\n\nconst RING = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\n\nexport function CartPage({\n  items = SAMPLE,\n  freeShippingThreshold = 15000,\n  shippingRate = 895,\n  onCheckout,\n  className,\n}: {\n  items?: CartPageLine[]\n  /** In cents. */\n  freeShippingThreshold?: number\n  /** Flat shipping in cents below the threshold. */\n  shippingRate?: number\n  onCheckout?: (lines: CartPageLine[]) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"cart\", \"cart-page\")\n  const [lines, setLines] = React.useState(items)\n  const [busy, setBusy] = React.useState(false)\n  const titleId = React.useId()\n\n  const count = lines.reduce((n, l) => n + l.quantity, 0)\n  const subtotal = lines.reduce((n, l) => n + l.price * l.quantity, 0)\n  const savings = lines.reduce((n, l) => n + (l.compareAt ? (l.compareAt - l.price) * l.quantity : 0), 0)\n  const free = subtotal >= freeShippingThreshold\n  const shipping = lines.length === 0 ? 0 : free ? 0 : shippingRate\n  const total = subtotal + shipping\n  const d = reduce ? 0 : 1\n\n  const setQty = (id: string, quantity: number) => {\n    sk.emit(\"interact\", \"quantity\", quantity)\n    setLines((ls) => ls.map((l) => (l.id === id ? { ...l, quantity } : l)))\n  }\n  const remove = (id: string) => {\n    sk.emit(\"interact\", \"remove\")\n    setLines((ls) => ls.filter((l) => l.id !== id))\n  }\n  const checkout = () => {\n    sk.emit(\"convert\", \"checkout\", total)\n    setBusy(true)\n    window.setTimeout(() => {\n      setBusy(false)\n      onCheckout?.(lines)\n    }, 900)\n  }\n\n  return (\n    <section ref={sk.ref as React.Ref<HTMLElement>} {...sk.attrs} aria-labelledby={titleId} className={cn(\"w-full bg-sk-paper py-10 sm:py-14\", className)}>\n      <div className=\"mx-auto w-full max-w-6xl px-4 sm:px-8\">\n        <div className=\"flex items-baseline justify-between gap-4 border-b border-sk-line pb-5\">\n          <h1 id={titleId} className=\"font-sk-display text-3xl font-semibold leading-[1.1] tracking-[-0.02em] text-sk-ink\">\n            Cart\n          </h1>\n          <p className=\"text-sm tabular-nums text-sk-muted\">\n            {count} {count === 1 ? \"item\" : \"items\"}\n          </p>\n        </div>\n\n        <div className=\"mt-6 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_360px] lg:gap-12\">\n          <div className=\"min-w-0\">\n            {lines.length === 0 ? (\n              <div className=\"grid place-items-center gap-2 rounded-sk-card border border-dashed border-sk-line px-6 py-16 text-center\">\n                <p className=\"font-sk-display text-lg font-semibold tracking-[-0.02em]\">Your cart is empty.</p>\n                <p className=\"text-sm text-sk-muted\">Items you add will show up here.</p>\n              </div>\n            ) : (\n              <>\n                <div className=\"hidden grid-cols-[minmax(0,1fr)_120px_96px] gap-4 pb-3 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted sm:grid\">\n                  <span>Product</span>\n                  <span>Quantity</span>\n                  <span className=\"text-right\">Total</span>\n                </div>\n                <ul className=\"sm:border-t sm: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: 0.32 * d, ease: [0.16, 1, 0.3, 1] }}\n                        className=\"overflow-hidden border-b border-sk-line\"\n                      >\n                        <div className=\"grid grid-cols-[80px_minmax(0,1fr)] gap-4 py-5 sm:grid-cols-[96px_minmax(0,1fr)_120px_96px] sm:items-center\">\n                          <ProductImage src={l.image} alt={l.title} className=\"w-20 rounded-sk-card border border-sk-line sm:w-24\" fit=\"cover\" zoomOnHover={false} />\n                          <div className=\"min-w-0\">\n                            <p className=\"truncate text-[15px] font-medium text-sk-ink\">{l.title}</p>\n                            {l.variant ? <p className=\"text-sm text-sk-muted\">{l.variant}</p> : null}\n                            <p className=\"mt-1 text-sm tabular-nums text-sk-muted\">{money(l.price)} each</p>\n                            <div className=\"mt-3 flex items-center justify-between gap-3 sm:hidden\">\n                              <Stepper label={`Quantity, ${l.title}`} value={l.quantity} onChange={(q) => setQty(l.id, q)} size=\"sm\" />\n                              <Price amount={l.price * l.quantity} compareAt={l.compareAt ? l.compareAt * l.quantity : undefined} size=\"sm\" />\n                            </div>\n                            <button\n                              type=\"button\"\n                              onClick={() => remove(l.id)}\n                              className={cn(\"-ml-2 mt-2 inline-flex h-11 items-center gap-1.5 rounded-sk-btn px-2 text-[13px] text-sk-muted transition-colors duration-200 hover:text-sk-ink active:scale-[0.97] sm:h-9\", RING)}\n                            >\n                              <Trash2 size={14} />\n                              Remove\n                            </button>\n                          </div>\n                          <div className=\"hidden sm:block\">\n                            <Stepper label={`Quantity, ${l.title}`} value={l.quantity} onChange={(q) => setQty(l.id, q)} size=\"sm\" />\n                          </div>\n                          <div className=\"hidden justify-end sm:flex\">\n                            <Price amount={l.price * l.quantity} compareAt={l.compareAt ? l.compareAt * l.quantity : undefined} size=\"sm\" className=\"justify-end\" />\n                          </div>\n                        </div>\n                      </motion.li>\n                    ))}\n                  </AnimatePresence>\n                </ul>\n              </>\n            )}\n          </div>\n\n          <aside className=\"lg:sticky lg:top-6 lg:self-start\">\n            <div className=\"rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6\">\n              <h2 className=\"font-sk-display text-lg font-semibold tracking-[-0.02em]\">Order summary</h2>\n              <dl className=\"mt-4 grid gap-2.5 text-sm\">\n                <div className=\"flex justify-between gap-4\">\n                  <dt className=\"text-sk-muted\">Subtotal</dt>\n                  <dd className=\"tabular-nums\">{money(subtotal)}</dd>\n                </div>\n                {savings > 0 ? (\n                  <div className=\"flex justify-between gap-4 text-sk-savings\">\n                    <dt>You save</dt>\n                    <dd className=\"tabular-nums\">{money(savings)}</dd>\n                  </div>\n                ) : null}\n                <div className=\"flex justify-between gap-4\">\n                  <dt className=\"text-sk-muted\">Shipping</dt>\n                  <dd className={cn(\"tabular-nums\", free && \"text-sk-savings\")}>{lines.length === 0 ? money(0) : free ? \"Free\" : money(shipping)}</dd>\n                </div>\n                <div className=\"flex justify-between gap-4\">\n                  <dt className=\"text-sk-muted\">Tax</dt>\n                  <dd className=\"text-sk-muted\">At checkout</dd>\n                </div>\n                <div className=\"mt-2 flex items-baseline justify-between gap-4 border-t border-sk-line pt-4\">\n                  <dt className=\"font-medium\">Total</dt>\n                  <dd className=\"font-sk-display text-lg font-semibold tabular-nums text-sk-price\">{money(total)}</dd>\n                </div>\n              </dl>\n              <p className=\"mt-3 flex items-center gap-2 text-[13px]\" aria-live=\"polite\">\n                {free ? <Check size={14} className=\"shrink-0 text-sk-savings\" /> : <Truck size={14} className=\"shrink-0 text-sk-muted\" />}\n                {free ? (\n                  <span className=\"text-sk-savings\">Free shipping applied</span>\n                ) : (\n                  <span className=\"text-sk-muted\">\n                    Add <span className=\"font-medium tabular-nums text-sk-ink\">{money(freeShippingThreshold - subtotal)}</span> for free shipping\n                  </span>\n                )}\n              </p>\n              <Button size=\"lg\" className=\"mt-5 w-full active:scale-[0.97]\" disabled={!lines.length} loading={busy} onClick={checkout}>\n                <Lock size={14} />\n                Checkout\n              </Button>\n              <p className=\"mt-3 flex items-center justify-center gap-1.5 text-xs text-sk-muted\">\n                <ShieldCheck size={13} />\n                Encrypted checkout. 30-day returns.\n              </p>\n            </div>\n          </aside>\n        </div>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:block",
      "target": "components/storekit/cart/cart-page.tsx"
    }
  ],
  "meta": {
    "section": "cart",
    "variant": "page",
    "niche": [
      "any"
    ],
    "commerce": [
      "cart",
      "quantity",
      "compare-at",
      "shipping-threshold"
    ],
    "previewHeight": 820
  }
}