{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cookie-consent",
  "type": "registry:component",
  "title": "Cookie consent card",
  "description": "Bottom-left card with equal-weight reject and accept, a manage layer with per-category switches. Persists the choice.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "compliance",
    "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": "compliance/cookie-consent.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Cookie } from \"lucide-react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { Button } from \"@/components/storekit/ui/button\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\ntype Sk = ReturnType<typeof useSkBlock>\n\n/** Mounts with the card so the view event fires when it actually appears. */\nfunction Tracked({ children }: { children: (sk: Sk) => React.ReactNode }) {\n  const sk = useSkBlock(\"compliance\", \"cookie-consent\")\n  return <>{children(sk)}</>\n}\n\n/* Bottom-left cookie card. \"Reject all\" and \"Accept all\" carry equal weight\n   on the first layer, as GDPR guidance expects; \"Manage choices\" reveals\n   per-category switches (necessary is locked on) and a save button.\n   The choice persists in localStorage under `storageKey` and the card stays\n   hidden while one exists. `contained` anchors it to the nearest relative\n   parent instead of the viewport. */\n\nexport type CookieChoice = { necessary: true; analytics: boolean; marketing: boolean; at: number }\n\nconst ring =\n  \"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card\"\n\nfunction Switch({\n  label,\n  hint,\n  checked,\n  locked,\n  onChange,\n}: {\n  label: string\n  hint: string\n  checked: boolean\n  locked?: boolean\n  onChange?: (v: boolean) => void\n}) {\n  const reduce = useReducedMotion()\n  return (\n    <button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={checked}\n      aria-disabled={locked || undefined}\n      onClick={() => !locked && onChange?.(!checked)}\n      className={cn(\"flex min-h-12 w-full items-center gap-3 rounded-sk-btn text-left\", locked ? \"cursor-not-allowed\" : \"cursor-pointer\", ring)}\n    >\n      <span className=\"flex-1\">\n        <span className=\"block text-[13px] font-medium text-sk-ink\">{label}</span>\n        <span className=\"block text-xs text-sk-muted\">{hint}</span>\n      </span>\n      <span\n        aria-hidden\n        className={cn(\n          \"relative flex h-6 w-10 shrink-0 items-center rounded-sk-chip p-0.5 transition-colors duration-200\",\n          checked ? \"bg-sk-accent\" : \"bg-sk-ink/20\",\n          locked && \"opacity-60\"\n        )}\n      >\n        <motion.span\n          initial={false}\n          animate={{ x: checked ? 16 : 0 }}\n          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 34 }}\n          className={cn(\"h-5 w-5 rounded-sk-chip shadow-[0_1px_2px_rgb(0_0_0/0.25)]\", checked ? \"bg-sk-accent-ink\" : \"bg-sk-paper\")}\n        />\n      </span>\n    </button>\n  )\n}\n\nexport function CookieConsent({\n  storageKey = \"sk-cookie-consent\",\n  policyHref = \"/privacy\",\n  contained = false,\n  onChange,\n  className,\n}: {\n  storageKey?: string\n  policyHref?: string\n  contained?: boolean\n  onChange?: (choice: CookieChoice) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const [visible, setVisible] = useState(false)\n  const [manage, setManage] = useState(false)\n  const [analytics, setAnalytics] = useState(true)\n  const [marketing, setMarketing] = useState(false)\n  const titleId = useId()\n\n  useEffect(() => {\n    let stored = false\n    try {\n      stored = !!window.localStorage.getItem(storageKey)\n    } catch {}\n    if (stored) return\n    const t = window.setTimeout(() => setVisible(true), 400)\n    return () => window.clearTimeout(t)\n  }, [storageKey])\n\n  const save = (sk: Sk, a: boolean, m: boolean, label: string) => {\n    sk.emit(label === \"cookie_accept\" ? \"convert\" : \"interact\", label)\n    const choice: CookieChoice = { necessary: true, analytics: a, marketing: m, at: Date.now() }\n    try {\n      window.localStorage.setItem(storageKey, JSON.stringify(choice))\n    } catch {}\n    onChange?.(choice)\n    setVisible(false)\n  }\n\n  const ease = [0.16, 1, 0.3, 1] as const\n\n  return (\n    <AnimatePresence>\n      {visible ? (\n        <Tracked key=\"cookie\">\n          {(sk) => (\n            <motion.section\n              ref={sk.ref}\n              {...sk.attrs}\n              role=\"region\"\n              aria-labelledby={titleId}\n              initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduce ? 0 : 8, transition: { duration: 0.2 } }}\n              transition={{ duration: 0.45, ease }}\n              className={cn(\n                contained ? \"absolute\" : \"fixed\",\n                \"bottom-[max(1rem,env(safe-area-inset-bottom))] left-4 right-4 z-40 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain rounded-sk-card border border-sk-line bg-sk-card p-5 shadow-[0_24px_60px_-24px_rgb(0_0_0/0.35)] sm:right-auto sm:w-[360px]\",\n                className\n          )}\n        >\n          <div className=\"flex items-start gap-3\">\n            <Cookie size={18} aria-hidden className=\"mt-0.5 shrink-0 text-sk-muted\" />\n            <div>\n              <h2 id={titleId} className=\"text-sm font-semibold text-sk-ink text-balance\">Cookies on this site</h2>\n              <p className=\"mt-1 text-[13px] leading-[1.5] text-sk-muted\">\n                We use cookies to keep your cart and measure what works.{\" \"}\n                <a href={policyHref} className={cn(\"rounded-sk-btn text-sk-ink underline underline-offset-4\", ring)}>Policy</a>\n              </p>\n            </div>\n          </div>\n\n          <AnimatePresence initial={false}>\n            {manage ? (\n              <motion.div\n                key=\"manage\"\n                initial={{ opacity: 0, y: reduce ? 0 : 6 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                transition={{ duration: 0.3, ease }}\n                className=\"mt-3 divide-y divide-sk-line border-y border-sk-line\"\n              >\n                <Switch label=\"Necessary\" hint=\"Cart, checkout, security\" checked locked />\n                <Switch label=\"Analytics\" hint=\"Anonymous usage stats\" checked={analytics} onChange={setAnalytics} />\n                <Switch label=\"Marketing\" hint=\"Personalised offers\" checked={marketing} onChange={setMarketing} />\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n\n          <div className=\"mt-4 grid grid-cols-2 gap-2\">\n            {manage ? (\n              <>\n                <Button variant=\"outline\" size=\"sm\" onClick={() => save(sk, false, false, \"cookie_reject\")}>Reject all</Button>\n                <Button size=\"sm\" onClick={() => save(sk, analytics, marketing, \"cookie_save\")}>Save choices</Button>\n              </>\n            ) : (\n              <>\n                <Button variant=\"outline\" size=\"sm\" onClick={() => save(sk, false, false, \"cookie_reject\")}>Reject all</Button>\n                <Button size=\"sm\" onClick={() => save(sk, true, true, \"cookie_accept\")}>Accept all</Button>\n              </>\n            )}\n          </div>\n          {manage ? null : (\n            <button\n              type=\"button\"\n              aria-expanded={false}\n              onClick={() => setManage(true)}\n              className={cn(\"mt-1 flex h-10 w-full items-center justify-center rounded-sk-btn text-[13px] font-medium text-sk-muted transition-colors duration-200 hover:text-sk-ink\", ring)}\n            >\n              Manage choices\n            </button>\n          )}\n        </motion.section>\n          )}\n        </Tracked>\n      ) : null}\n    </AnimatePresence>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/compliance/cookie-consent.tsx"
    }
  ],
  "docs": "Read the stored choice before loading analytics or marketing scripts.",
  "meta": {
    "section": "compliance",
    "variant": "card",
    "niche": [
      "any"
    ],
    "commerce": [
      "consent"
    ],
    "previewHeight": 500
  }
}