{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "email-capture",
  "type": "registry:component",
  "title": "Email capture popup",
  "description": "Split modal with a product render, 10% off offer, loading and success states, and every dismiss path.",
  "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/product-image.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "compliance/email-capture.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, X } from \"lucide-react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { Button } from \"@/components/storekit/ui/button\"\nimport { ProductImage } from \"@/components/storekit/ui/product-image\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\ntype Sk = ReturnType<typeof useSkBlock>\n\n/** Mounts with the dialog so the view event fires when it actually opens. */\nfunction Tracked({ children }: { children: (sk: Sk) => React.ReactNode }) {\n  const sk = useSkBlock(\"compliance\", \"email-capture\")\n  return <>{children(sk)}</>\n}\n\n/* Email capture modal with a split layout: product render on the left, form\n   on the right. Controlled via `open` and `onOpenChange`. Escape, the close\n   button, the backdrop, and \"No thanks\" all dismiss. Scroll lock and a focus\n   trap while open; focus returns to whatever opened it. Set `contained` to\n   position inside the nearest relative parent instead of the viewport (it then\n   skips the page scroll lock). `initialFocus={false}` keeps focus where it is\n   on open, for previews that start open. */\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-paper\"\n\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])'\n\nexport function EmailCapture({\n  open,\n  onOpenChange,\n  image = \"/brands/morrow/greens.webp\",\n  imageAlt = \"\",\n  eyebrow = \"First order\",\n  headline = \"10% off your first order\",\n  body = \"New flavors, restocks and member-only bundles. One email a week at most.\",\n  code = \"WELCOME10\",\n  onSubmit = () => new Promise<void>((r) => setTimeout(r, 900)),\n  contained = false,\n  initialFocus = true,\n  className,\n}: {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  image?: string\n  imageAlt?: string\n  eyebrow?: string\n  headline?: string\n  body?: string\n  code?: string\n  onSubmit?: (email: string) => void | Promise<void>\n  contained?: boolean\n  initialFocus?: boolean\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const [email, setEmail] = useState(\"\")\n  const [state, setState] = useState<\"idle\" | \"loading\" | \"done\">(\"idle\")\n  const [error, setError] = useState<string | null>(null)\n  const ref = useRef<HTMLDivElement>(null)\n  const titleId = useId()\n  const inputId = useId()\n\n  useEffect(() => {\n    if (!open) return\n    const opener = document.activeElement as HTMLElement | null\n    const prev = document.body.style.overflow\n    if (!contained) document.body.style.overflow = \"hidden\"\n    const raf = initialFocus ? requestAnimationFrame(() => ref.current?.querySelector<HTMLInputElement>(\"input\")?.focus({ preventScroll: true })) : 0\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault()\n        onOpenChange(false)\n        return\n      }\n      if (e.key !== \"Tab\" || !ref.current) return\n      const items = Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE))\n      if (!items.length) return\n      const first = items[0]\n      const last = items[items.length - 1]\n      if (e.shiftKey && document.activeElement === first) {\n        e.preventDefault()\n        last.focus()\n      } else if (!e.shiftKey && document.activeElement === last) {\n        e.preventDefault()\n        first.focus()\n      }\n    }\n    document.addEventListener(\"keydown\", onKey)\n    return () => {\n      cancelAnimationFrame(raf)\n      if (!contained) document.body.style.overflow = prev\n      document.removeEventListener(\"keydown\", onKey)\n      if (initialFocus) opener?.focus({ preventScroll: true })\n    }\n  }, [open, onOpenChange, contained, initialFocus])\n\n  const submit = async (e: React.FormEvent, sk: Sk) => {\n    e.preventDefault()\n    if (!/^\\S+@\\S+\\.\\S+$/.test(email)) {\n      setError(\"Enter a valid email address.\")\n      return\n    }\n    setError(null)\n    setState(\"loading\")\n    try {\n      await onSubmit(email)\n      setState(\"done\")\n      sk.emit(\"convert\", \"email_submit\")\n    } catch {\n      setState(\"idle\")\n      setError(\"Something went wrong. Try again.\")\n    }\n  }\n\n  const ease = [0.16, 1, 0.3, 1] as const\n\n  return (\n    <AnimatePresence>\n      {open ? (\n        <Tracked key=\"email-capture\">\n          {(sk) => (\n            <div\n              ref={sk.ref as React.Ref<HTMLDivElement>}\n              {...sk.attrs}\n              className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-50 grid place-items-center p-4\", className)}\n            >\n              <motion.div\n                aria-hidden\n                onClick={() => onOpenChange(false)}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.2 }}\n                className=\"absolute inset-0 bg-[rgb(0_0_0/0.5)]\"\n              />\n              <motion.div\n                ref={ref}\n                role=\"dialog\"\n                aria-modal=\"true\"\n                aria-labelledby={titleId}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, transition: { duration: 0.15 } }}\n                transition={{ duration: 0.25, ease }}\n                className=\"relative grid w-full max-w-2xl overflow-hidden rounded-sk-card border border-sk-line bg-sk-paper shadow-[0_24px_60px_-24px_rgb(0_0_0/0.45)] sm:grid-cols-2\"\n              >\n                <ProductImage src={image} alt={imageAlt} zoomOnHover={false} className=\"hidden h-full sm:block sm:aspect-auto\" />\n                <div className=\"relative flex flex-col justify-center p-6 sm:p-8\">\n                  <button\n                    type=\"button\"\n                    aria-label=\"Close\"\n                    onClick={() => onOpenChange(false)}\n                    className={cn(\"absolute right-1 top-1 grid h-11 w-11 place-items-center rounded-sk-btn text-sk-muted transition-colors duration-200 hover:text-sk-ink\", ring)}\n                  >\n                    <X size={18} aria-hidden />\n                  </button>\n                  <AnimatePresence mode=\"wait\" initial={false}>\n                    {state === \"done\" ? (\n                      <motion.div\n                        key=\"done\"\n                        role=\"status\"\n                        initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n                        animate={{ opacity: 1, y: 0 }}\n                        transition={{ duration: 0.4, ease }}\n                      >\n                        <span className=\"grid h-9 w-9 place-items-center rounded-full bg-sk-savings/15 text-sk-savings\">\n                          <Check size={16} aria-hidden />\n                        </span>\n                        <h2 id={titleId} className=\"mt-4 font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em] text-balance\">Your code is ready</h2>\n                        <p className=\"mt-2 text-sm leading-[1.5] text-sk-muted\">Use it at checkout. We also sent it to {email}.</p>\n                        <p className=\"mt-4 inline-flex rounded-sk-chip border border-dashed border-sk-line px-3 py-1.5 font-sk-mono text-sm font-semibold tracking-[0.14em]\">{code}</p>\n                        <Button className=\"mt-6 w-full\" onClick={() => onOpenChange(false)}>Start shopping</Button>\n                      </motion.div>\n                    ) : (\n                      <motion.div key=\"form\" exit={{ opacity: 0, y: reduce ? 0 : -4, transition: { duration: 0.15 } }}>\n                        <p className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">{eyebrow}</p>\n                        <h2 id={titleId} className=\"mt-2 pr-8 font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em] text-balance\">{headline}</h2>\n                        <p className=\"mt-2 text-sm leading-[1.5] text-sk-muted\">{body}</p>\n                        <form onSubmit={(e) => submit(e, sk)} noValidate className=\"mt-6 grid gap-2\">\n                          <label htmlFor={inputId} className=\"sr-only\">Email</label>\n                          <input\n                            id={inputId}\n                            type=\"email\"\n                            autoComplete=\"email\"\n                            value={email}\n                            onChange={(e) => setEmail(e.target.value)}\n                            placeholder=\"you@lab.edu\"\n                            aria-invalid={!!error || undefined}\n                            aria-describedby={error ? `${inputId}-err` : undefined}\n                            className={cn(\n                              \"h-11 w-full rounded-sk-btn border bg-sk-card px-3.5 text-sm text-sk-ink outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-sk-muted/70 focus:border-sk-accent focus:ring-[3px] focus:ring-sk-accent/15\",\n                              error ? \"border-sk-sale\" : \"border-sk-line\"\n                            )}\n                          />\n                          {error ? <p id={`${inputId}-err`} className=\"text-xs text-sk-sale\">{error}</p> : null}\n                          <Button type=\"submit\" loading={state === \"loading\"} className=\"w-full\">Get 10% off</Button>\n                        </form>\n                        <button\n                          type=\"button\"\n                          onClick={() => onOpenChange(false)}\n                          className={cn(\"mt-2 inline-flex min-h-11 w-full items-center justify-center rounded-sk-btn text-[13px] text-sk-muted transition-colors duration-200 hover:text-sk-ink\", ring)}\n                        >\n                          No thanks\n                        </button>\n                      </motion.div>\n                    )}\n                  </AnimatePresence>\n                </div>\n              </motion.div>\n            </div>\n          )}\n        </Tracked>\n      ) : null}\n    </AnimatePresence>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/compliance/email-capture.tsx"
    }
  ],
  "docs": "Controlled: you decide when to open it (timer, exit intent, scroll depth).",
  "meta": {
    "section": "compliance",
    "variant": "modal",
    "niche": [
      "any"
    ],
    "commerce": [
      "newsletter",
      "discount"
    ],
    "previewHeight": 500
  }
}