"use client"; import { Cookie, Lock } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; import { Button } from "@/components/motion/button"; import { Switch } from "@/components/motion/switch"; import { EASE_OUT, SPRING_PANEL } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface CookiePreferences { /** Always true — required for the site to work. */ necessary: true; analytics: boolean; marketing: boolean; } export type CookieConsentPosition = "bottom-left" | "bottom" | "bottom-right"; type OptionalCategory = "analytics" | "marketing"; const CATEGORIES: { key: keyof CookiePreferences; label: string; description: string; }[] = [ { key: "necessary", label: "Necessary", description: "Sign-in, security and saving these choices. Always on.", }, { key: "analytics", label: "Analytics", description: "Anonymous usage stats that help us improve the product.", }, { key: "marketing", label: "Marketing", description: "Personalised ads and measuring campaigns across sites.", }, ]; const POSITION_CLASS: Record = { "bottom-left": "bottom-4 left-4 max-w-sm", bottom: "inset-x-0 bottom-4 mx-auto max-w-xl", "bottom-right": "right-4 bottom-4 max-w-sm", }; export interface CookieConsentProps { /** Called with the final preferences whenever the visitor decides. */ onConsentChange?: (preferences: CookiePreferences) => void; /** * Persist the choice to `localStorage` under this key. When a saved choice * exists the banner stays hidden. Read after mount, so SSR output is stable. */ storageKey?: string; position?: CookieConsentPosition; /** * Position inside the nearest positioned ancestor (`absolute`) instead of the * viewport (`fixed`). Use for previews and contained layouts. */ contained?: boolean; /** Delay before sliding up, in ms. Default 500. */ delay?: number; /** Starting state of the optional toggles in the Customize panel. */ defaultPreferences?: Partial>; title?: ReactNode; description?: ReactNode; className?: string; } function readStored(key: string): CookiePreferences | null { try { const raw = window.localStorage.getItem(key); if (!raw) return null; const parsed = JSON.parse(raw) as Partial | null; if (!parsed || typeof parsed !== "object") return null; return { necessary: true, analytics: Boolean(parsed.analytics), marketing: Boolean(parsed.marketing), }; } catch { return null; } } function writeStored(key: string, preferences: CookiePreferences) { try { window.localStorage.setItem(key, JSON.stringify(preferences)); } catch { // Private mode / blocked storage: the callback still reports the choice. } } /** * Cookie consent card that slides up after mount with Accept all / Reject * non-essential / Customize. Customize grows the card into per-category * toggles (Necessary locked on) with Save. Non-modal: it never traps focus. */ export function CookieConsent({ onConsentChange, storageKey, position = "bottom-right", contained = false, delay = 500, defaultPreferences, title = "We use cookies", description = "Some are essential to run the site. Others help us understand usage and show relevant ads — only with your permission.", className, }: CookieConsentProps) { const reduce = useReducedMotion() ?? false; const [visible, setVisible] = useState(false); const [customizing, setCustomizing] = useState(false); const [draft, setDraft] = useState({ analytics: defaultPreferences?.analytics ?? false, marketing: defaultPreferences?.marketing ?? false, }); const titleId = useId(); const firstToggleRef = useRef(null); useEffect(() => { if (storageKey && readStored(storageKey)) return; const timer = window.setTimeout(() => setVisible(true), delay); return () => window.clearTimeout(timer); }, [storageKey, delay]); // Move focus into the expanded panel so keyboard users land on the toggles. useEffect(() => { if (!customizing) return; const frame = requestAnimationFrame(() => { firstToggleRef.current ?.querySelector("[role='switch']:not([disabled])") ?.focus({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [customizing]); const decide = (preferences: CookiePreferences) => { if (storageKey) writeStored(storageKey, preferences); onConsentChange?.(preferences); setVisible(false); }; const acceptAll = () => decide({ necessary: true, analytics: true, marketing: true }); const rejectAll = () => decide({ necessary: true, analytics: false, marketing: false }); const save = () => decide({ necessary: true, ...draft }); return ( {visible ? (

{title}

{description}

{/* Height is animated on purpose: growing the card into the toggle list is what this disclosure is for. */} {customizing ? (
{CATEGORIES.map((category) => { const locked = category.key === "necessary"; const checked = locked ? true : draft[category.key as OptionalCategory]; return (
{category.label} {locked ? ( {category.description}
{ if (locked) return; setDraft((prev) => ({ ...prev, [category.key]: next })); }} />
); })}
) : null}
{customizing ? ( <> ) : ( <> )}
) : null}
); }