{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cta-banner",
  "type": "registry:ui",
  "title": "CTA Banner",
  "author": "Rishabh Bose <https://theb0se.vercel.app/>",
  "description": "Full-width call-to-action section with a self-contained flickering canvas grid background masked radially so the grid is denser at the edges and fades toward the center. Includes an email signup form with loading, success, and validation states animated via Framer Motion AnimatePresence.",
  "dependencies": [
    "framer-motion",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/ui/cta-banner.tsx",
      "content": "\"use client\";\n\nimport React, { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ */\n/* Embedded Flickering Grid Canvas (self-contained, no import)        */\n/* ------------------------------------------------------------------ */\n\ninterface InternalGridProps {\n  squareSize?: number;\n  gridGap?: number;\n  flickerChance?: number;\n  color?: string;\n  maxOpacity?: number;\n  className?: string;\n}\n\nfunction parseColor(c: string): string {\n  if (typeof window === \"undefined\") return \"rgba(0,0,0,\";\n  if (c.startsWith(\"#\")) {\n    let h = c.slice(1);\n    if (h.length === 3) h = h.split(\"\").map((x) => x + x).join(\"\");\n    const n = parseInt(h.slice(0, 6), 16);\n    if (!Number.isNaN(n)) return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},`;\n  }\n  const m = c.match(/rgba?\\((\\d+)[,\\s]+(\\d+)[,\\s]+(\\d+)/i);\n  if (m) return `rgba(${m[1]},${m[2]},${m[3]},`;\n  try {\n    const cv = document.createElement(\"canvas\");\n    cv.width = cv.height = 1;\n    const ctx = cv.getContext(\"2d\", { willReadFrequently: true });\n    if (!ctx) return \"rgba(0,0,0,\";\n    ctx.fillStyle = c;\n    ctx.fillRect(0, 0, 1, 1);\n    const d = ctx.getImageData(0, 0, 1, 1).data;\n    return `rgba(${d[0]},${d[1]},${d[2]},`;\n  } catch {\n    return \"rgba(0,0,0,\";\n  }\n}\n\nfunction InternalGridCanvas({\n  squareSize = 4,\n  gridGap = 6,\n  flickerChance = 0.3,\n  color = \"rgb(255,255,255)\",\n  maxOpacity = 0.3,\n  className,\n}: InternalGridProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [isInView, setIsInView] = useState(false);\n  const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });\n\n  const colorPrefix = useMemo(() => parseColor(color), [color]);\n\n  const setupCanvas = useCallback(\n    (canvas: HTMLCanvasElement, w: number, h: number) => {\n      if (w <= 0 || h <= 0) return null;\n      const dpr = typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n      canvas.width = Math.floor(w * dpr);\n      canvas.height = Math.floor(h * dpr);\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      const cell = squareSize + gridGap;\n      const cols = Math.ceil(w / cell);\n      const rows = Math.ceil(h / cell);\n      const squares = new Float32Array(cols * rows);\n      for (let i = 0; i < squares.length; i++) squares[i] = Math.random() * maxOpacity;\n      return { cols, rows, squares, dpr };\n    },\n    [squareSize, gridGap, maxOpacity]\n  );\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const container = containerRef.current;\n    if (!canvas || !container) return;\n    const ctx = canvas.getContext(\"2d\", { alpha: true });\n    if (!ctx) return;\n\n    let rafId: number | null = null;\n    let ro: ResizeObserver | null = null;\n    let io: IntersectionObserver | null = null;\n    let grid: ReturnType<typeof setupCanvas> = null;\n    let lastTime = performance.now();\n\n    const init = () => {\n      const w = container.clientWidth;\n      const h = container.clientHeight;\n      if (w > 0 && h > 0) {\n        setCanvasSize({ width: w, height: h });\n        grid = setupCanvas(canvas, w, h);\n      }\n    };\n\n    const animate = (time: number) => {\n      if (!grid) return;\n      const dt = Math.min((time - lastTime) / 1000, 0.1);\n      lastTime = time;\n      const step = flickerChance * dt;\n      for (let i = 0; i < grid.squares.length; i++) {\n        if (Math.random() < step) grid.squares[i] = Math.random() * maxOpacity;\n      }\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      const cell = (squareSize + gridGap) * grid.dpr;\n      const sq = squareSize * grid.dpr;\n      for (let i = 0; i < grid.cols; i++) {\n        for (let j = 0; j < grid.rows; j++) {\n          const idx = i * grid.rows + j;\n          if (idx >= grid.squares.length) continue;\n          ctx.fillStyle = `${colorPrefix}${grid.squares[idx]})`;\n          ctx.fillRect(Math.floor(i * cell), Math.floor(j * cell), sq, sq);\n        }\n      }\n      rafId = requestAnimationFrame(animate);\n    };\n\n    init();\n\n    ro = new ResizeObserver(init);\n    ro.observe(container);\n\n    io = new IntersectionObserver(([e]) => setIsInView(e.isIntersecting), { threshold: 0 });\n    io.observe(container);\n\n    if (isInView && grid) rafId = requestAnimationFrame(animate);\n\n    return () => {\n      if (rafId !== null) cancelAnimationFrame(rafId);\n      ro?.disconnect();\n      io?.disconnect();\n    };\n  }, [setupCanvas, colorPrefix, flickerChance, maxOpacity, squareSize, gridGap, isInView]);\n\n  return (\n    <div ref={containerRef} className={cn(\"h-full w-full overflow-hidden\", className)}>\n      <canvas\n        ref={canvasRef}\n        className=\"pointer-events-none block\"\n        style={{ width: canvasSize.width, height: canvasSize.height }}\n      />\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Types & Interfaces                                                  */\n/* ------------------------------------------------------------------ */\n\nexport interface CTABannerProps {\n  /** Bold headline text */\n  heading: string;\n  /** Muted supporting copy under the headline */\n  subheading: string;\n  /** Placeholder text for the email input. Default: \"Enter your email\" */\n  placeholder?: string;\n  /** Submit button label. Default: \"Get started\" */\n  buttonLabel?: string;\n  /** Called with the validated email on submit */\n  onSubmit?: (email: string) => Promise<void> | void;\n  /**\n   * Accent color used for the grid squares and the submit button background.\n   * Accepts any valid CSS color. Default: \"#6366f1\" (indigo-500)\n   */\n  accentColor?: string;\n  /** Additional Tailwind CSS classes merged on the outermost container */\n  className?: string;\n}\n\n/* ------------------------------------------------------------------ */\n/* Animation Variants                                                  */\n/* ------------------------------------------------------------------ */\n\nconst containerVariants = {\n  hidden: { opacity: 0, y: 32 },\n  visible: {\n    opacity: 1,\n    y: 0,\n    transition: { duration: 0.65, ease: [0.16, 1, 0.3, 1], staggerChildren: 0.1, delayChildren: 0.15 },\n  },\n};\n\nconst itemVariants = {\n  hidden: { opacity: 0, y: 18 },\n  visible: { opacity: 1, y: 0, transition: { duration: 0.55, ease: [0.16, 1, 0.3, 1] } },\n};\n\n/* ------------------------------------------------------------------ */\n/* Small sub-components for button states                              */\n/* ------------------------------------------------------------------ */\n\n/** Tiny spinner — pure Framer Motion, no external dep */\nfunction Spinner() {\n  return (\n    <motion.span\n      className=\"inline-block h-4 w-4 rounded-full border-2 border-white/30 border-t-white\"\n      animate={{ rotate: 360 }}\n      transition={{ duration: 0.75, repeat: Infinity, ease: \"linear\" }}\n      aria-hidden=\"true\"\n    />\n  );\n}\n\n/** Animated SVG checkmark (pathLength 0 → 1) */\nfunction CheckMark() {\n  return (\n    <motion.svg\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      strokeWidth={2.5}\n      stroke=\"currentColor\"\n      className=\"h-4 w-4 shrink-0\"\n      aria-hidden=\"true\"\n    >\n      <motion.path\n        d=\"M2.5 8.5L6.5 12.5L13.5 4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={{ pathLength: 0, opacity: 0 }}\n        animate={{ pathLength: 1, opacity: 1 }}\n        transition={{ duration: 0.4, ease: \"easeOut\" }}\n      />\n    </motion.svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* CTABanner Component                                                 */\n/* ------------------------------------------------------------------ */\n\ntype ButtonState = \"idle\" | \"loading\" | \"success\";\n\nexport function CTABanner({\n  heading,\n  subheading,\n  placeholder = \"Enter your email\",\n  buttonLabel = \"Get started\",\n  onSubmit,\n  accentColor = \"#6366f1\",\n  className,\n}: CTABannerProps) {\n  const [email, setEmail] = useState(\"\");\n  const [error, setError] = useState<string | null>(null);\n  const [btnState, setBtnState] = useState<ButtonState>(\"idle\");\n  const [shakeKey, setShakeKey] = useState(0);\n  const shouldReduceMotion = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  /** Very lightweight radial mask: edges show grid, center fades out for readability */\n  const gridMaskStyle: React.CSSProperties = {\n    WebkitMaskImage:\n      \"radial-gradient(ellipse 80% 80% at 50% 50%, transparent 35%, rgba(0,0,0,0.55) 65%, rgba(0,0,0,1) 100%)\",\n    maskImage:\n      \"radial-gradient(ellipse 80% 80% at 50% 50%, transparent 35%, rgba(0,0,0,0.55) 65%, rgba(0,0,0,1) 100%)\",\n  };\n\n  const validateEmail = (val: string) =>\n    /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(val.trim());\n\n  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    if (!validateEmail(email)) {\n      setError(\"Please enter a valid email address.\");\n      setShakeKey((k) => k + 1);\n      inputRef.current?.focus();\n      return;\n    }\n    setError(null);\n    setBtnState(\"loading\");\n    try {\n      await onSubmit?.(email.trim());\n      setBtnState(\"success\");\n    } catch {\n      setBtnState(\"idle\");\n      setError(\"Something went wrong. Please try again.\");\n    }\n  };\n\n  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setEmail(e.target.value);\n    if (error) setError(null);\n    if (btnState === \"success\") setBtnState(\"idle\");\n  };\n\n  // Shake keyframes for validation failure\n  const shakeTransition = shouldReduceMotion\n    ? {}\n    : { duration: 0.4, ease: \"easeInOut\" };\n\n  const shakeAnimate = shouldReduceMotion\n    ? {}\n    : { x: [0, -10, 10, -8, 8, -4, 4, 0] };\n\n  return (\n    <motion.div\n      className={cn(\n        \"relative w-full overflow-hidden rounded-2xl border border-white/10 bg-neutral-950 text-white\",\n        className\n      )}\n      variants={shouldReduceMotion ? undefined : containerVariants}\n      initial=\"hidden\"\n      whileInView=\"visible\"\n      viewport={{ once: true, margin: \"-60px\" }}\n    >\n      {/* ── Flickering Grid Layer ── */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 z-0 select-none\"\n        style={gridMaskStyle}\n      >\n        <InternalGridCanvas\n          squareSize={3}\n          gridGap={5}\n          flickerChance={0.35}\n          color={accentColor}\n          maxOpacity={0.55}\n          className=\"h-full w-full\"\n        />\n      </div>\n\n      {/* ── Overlay gradient for text legibility ── */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-neutral-950/30 via-neutral-950/60 to-neutral-950/80\"\n      />\n\n      {/* ── Glow blob (slow infinite float) ── */}\n      {!shouldReduceMotion && (\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute -top-1/4 left-1/2 z-[1] h-72 w-72 -translate-x-1/2 rounded-full opacity-20 blur-3xl\"\n          style={{ background: accentColor }}\n          animate={{ y: [0, -18, 0], scale: [1, 1.08, 1] }}\n          transition={{ duration: 8, repeat: Infinity, ease: \"easeInOut\" }}\n        />\n      )}\n\n      {/* ── Content ── */}\n      <div className=\"relative z-10 mx-auto flex max-w-2xl flex-col items-center px-6 py-14 text-center sm:px-10 sm:py-20\">\n        {/* Pill badge */}\n        <motion.div\n          variants={shouldReduceMotion ? undefined : itemVariants}\n          className=\"mb-5 inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3.5 py-1 text-xs font-medium tracking-wide text-white/70 backdrop-blur-sm\"\n        >\n          <span\n            className=\"inline-block h-1.5 w-1.5 rounded-full\"\n            style={{ background: accentColor }}\n          />\n          Now in public beta\n        </motion.div>\n\n        {/* Heading */}\n        <motion.h2\n          variants={shouldReduceMotion ? undefined : itemVariants}\n          className=\"text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl\"\n        >\n          {heading}\n        </motion.h2>\n\n        {/* Subheading */}\n        <motion.p\n          variants={shouldReduceMotion ? undefined : itemVariants}\n          className=\"mt-4 max-w-lg text-base leading-relaxed text-white/60 sm:text-lg\"\n        >\n          {subheading}\n        </motion.p>\n\n        {/* ── Form ── */}\n        <motion.div\n          variants={shouldReduceMotion ? undefined : itemVariants}\n          className=\"mt-8 w-full max-w-md\"\n        >\n          {/* aria-live region for status announcements */}\n          <div aria-live=\"polite\" className=\"sr-only\">\n            {btnState === \"success\" && \"You're in! Check your inbox.\"}\n            {error && error}\n          </div>\n\n          <form onSubmit={handleSubmit} noValidate>\n            {/* Input + Button row */}\n            <div className=\"flex flex-col gap-3 sm:flex-row sm:gap-2\">\n              {/* Email input with optional shake animation */}\n              <motion.div\n                key={shakeKey}\n                animate={shakeKey > 0 ? shakeAnimate : {}}\n                transition={shakeTransition}\n                className=\"flex-1\"\n              >\n                <label htmlFor=\"cta-email\" className=\"sr-only\">\n                  Email address\n                </label>\n                <input\n                  ref={inputRef}\n                  id=\"cta-email\"\n                  type=\"email\"\n                  autoComplete=\"email\"\n                  value={email}\n                  onChange={handleChange}\n                  disabled={btnState === \"success\"}\n                  placeholder={placeholder}\n                  aria-describedby={error ? \"cta-email-error\" : undefined}\n                  aria-invalid={!!error}\n                  className={cn(\n                    \"w-full rounded-xl border bg-white/5 px-4 py-3 text-sm text-white placeholder-white/35 outline-none backdrop-blur-sm transition-all duration-200\",\n                    \"focus-visible:ring-2 focus-visible:ring-offset-0\",\n                    error\n                      ? \"border-red-500/70 focus-visible:ring-red-500/50\"\n                      : \"border-white/10 focus-visible:border-white/30 focus-visible:ring-white/20\",\n                    \"disabled:cursor-not-allowed disabled:opacity-50\"\n                  )}\n                />\n              </motion.div>\n\n              {/* Submit button */}\n              <motion.button\n                type=\"submit\"\n                disabled={btnState === \"loading\" || btnState === \"success\"}\n                whileHover={\n                  shouldReduceMotion || btnState !== \"idle\"\n                    ? undefined\n                    : { scale: 1.03 }\n                }\n                whileTap={\n                  shouldReduceMotion || btnState !== \"idle\"\n                    ? undefined\n                    : { scale: 0.97 }\n                }\n                transition={{ type: \"spring\", stiffness: 340, damping: 22 }}\n                className={cn(\n                  \"relative flex shrink-0 items-center justify-center gap-2 overflow-hidden rounded-xl px-6 py-3 text-sm font-semibold text-white shadow-lg transition-opacity duration-200\",\n                  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\",\n                  \"disabled:cursor-not-allowed disabled:opacity-70\",\n                  btnState === \"success\" && \"opacity-90\"\n                )}\n                style={{ background: accentColor, minWidth: \"130px\" }}\n                aria-label={\n                  btnState === \"loading\"\n                    ? \"Submitting…\"\n                    : btnState === \"success\"\n                    ? \"You're in!\"\n                    : buttonLabel\n                }\n              >\n                <AnimatePresence mode=\"wait\" initial={false}>\n                  {btnState === \"idle\" && (\n                    <motion.span\n                      key=\"label\"\n                      initial={{ opacity: 0, y: 6 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      exit={{ opacity: 0, y: -6 }}\n                      transition={{ duration: 0.18 }}\n                    >\n                      {buttonLabel}\n                    </motion.span>\n                  )}\n                  {btnState === \"loading\" && (\n                    <motion.span\n                      key=\"spinner\"\n                      initial={{ opacity: 0, y: 6 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      exit={{ opacity: 0, y: -6 }}\n                      transition={{ duration: 0.18 }}\n                      className=\"flex items-center gap-2\"\n                    >\n                      <Spinner />\n                      <span>Sending…</span>\n                    </motion.span>\n                  )}\n                  {btnState === \"success\" && (\n                    <motion.span\n                      key=\"success\"\n                      initial={{ opacity: 0, y: 6 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      exit={{ opacity: 0, y: -6 }}\n                      transition={{ duration: 0.18 }}\n                      className=\"flex items-center gap-2\"\n                    >\n                      <CheckMark />\n                      <span>You&apos;re in!</span>\n                    </motion.span>\n                  )}\n                </AnimatePresence>\n              </motion.button>\n            </div>\n\n            {/* Inline error with AnimatePresence height + opacity */}\n            <AnimatePresence initial={false}>\n              {error && (\n                <motion.p\n                  id=\"cta-email-error\"\n                  role=\"alert\"\n                  key=\"error\"\n                  initial={{ opacity: 0, height: 0, marginTop: 0 }}\n                  animate={{ opacity: 1, height: \"auto\", marginTop: 8 }}\n                  exit={{ opacity: 0, height: 0, marginTop: 0 }}\n                  transition={{ duration: 0.22, ease: \"easeOut\" }}\n                  className=\"overflow-hidden text-left text-xs text-red-400\"\n                >\n                  {error}\n                </motion.p>\n              )}\n            </AnimatePresence>\n          </form>\n\n          {/* Trust line */}\n          <p className=\"mt-4 text-xs text-white/35\">\n            No credit card required · Cancel any time\n          </p>\n        </motion.div>\n      </div>\n    </motion.div>\n  );\n}\n\nexport default CTABanner;\n",
      "type": "registry:ui",
      "target": "components/ui/cta-banner.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport async function copyToClipboard(text: string): Promise<boolean> {\n  if (typeof window === \"undefined\") return false;\n\n  try {\n    if (navigator.clipboard && window.isSecureContext) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n\n    const textArea = document.createElement(\"textarea\");\n    textArea.value = text;\n    textArea.style.position = \"fixed\";\n    textArea.style.left = \"-999999px\";\n    textArea.style.top = \"-999999px\";\n    document.body.appendChild(textArea);\n    textArea.focus();\n    textArea.select();\n    const succeeded = document.execCommand(\"copy\");\n    textArea.remove();\n    return succeeded;\n  } catch {\n    return false;\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/utils.ts"
    }
  ]
}
