{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "newsletter-signup",
  "title": "Newsletter Signup",
  "description": "An email capture band driven by a real submit state machine: zod validation, a duplicate-submit guard, a role=status confirmation, an idempotent already-subscribed branch, and a failure that keeps the address you typed.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/newsletter-signup.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, LoaderCircle, Mail, MailCheck } from \"lucide-react\"\nimport { z } from \"zod\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The single validation rule, exported so the host can run the identical check\n * server-side. zod owns validation on purpose: `type=\"email\"` is kept only for\n * the mobile keyboard, and the browser's own rule accepts `a@b` (no TLD) —\n * which every mail provider bounces. `noValidate` on the form keeps the two\n * from fighting over the same field.\n */\nexport const newsletterEmailSchema = z.email()\n\nexport type NewsletterSignupStatus = \"idle\" | \"submitting\" | \"success\" | \"already\" | \"error\"\n\nexport interface NewsletterSignupResult {\n  /**\n   * `\"already-subscribed\"` renders the idempotent variant of the confirmation\n   * panel — a second signup is a success, not a failure.\n   */\n  status: \"subscribed\" | \"already-subscribed\"\n  /** Server copy that replaces the built-in confirmation line, verbatim. */\n  message?: string\n}\n\n/** A rejection that is not an Error still has to become a sentence a human can read. */\nfunction rejectionText(error: unknown, fallback: string): string {\n  if (error instanceof Error && error.message) return error.message\n  if (typeof error === \"string\" && error) return error\n  return fallback\n}\n\nexport interface NewsletterSignupProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"onSubmit\" | \"children\"> {\n  /**\n   * Receives the trimmed address and owns the network call — the block never\n   * fetches anything itself. Resolve to confirm, resolve with\n   * `{ status: \"already-subscribed\" }` for the idempotent notice, reject to\n   * show a retryable error (an `Error`'s message is shown verbatim).\n   */\n  onSubmit: (email: string) => Promise<NewsletterSignupResult | void>\n  /** Section heading; also the accessible name of the region. Pass `\"\"` to drop it. */\n  heading?: string\n  /** Optional line under the heading. */\n  description?: string\n  /** Seeds the field (a known address, an `?email=` query param). */\n  defaultEmail?: string\n  /** Accessible name of the input — visually hidden, because a placeholder is not a label. */\n  emailLabel?: string\n  placeholder?: string\n  submitLabel?: string\n  /** Button label while the promise is in flight. Default \"Subscribing…\". */\n  pendingLabel?: string\n  /** Button label after a rejection. Default \"Try again\". */\n  retryLabel?: string\n  /** Shown when the field is blank. */\n  emptyMessage?: string\n  /** Shown when zod rejects the format. */\n  invalidMessage?: string\n  /** Shown when the rejection carries no readable message of its own. */\n  failureMessage?: string\n  successTitle?: string\n  successDescription?: string\n  alreadyTitle?: string\n  alreadyDescription?: string\n  /** Returns the confirmation panel to an empty form. Default \"Use a different email\". */\n  resetLabel?: string\n  /** Fine print under the form — consent line, privacy link, cadence promise. */\n  footnote?: React.ReactNode\n  className?: string\n}\n\n/**\n * An email capture band whose real subject is the submit state machine:\n * `idle → submitting → success | already | error`, plus a field-level invalid\n * flash that never leaves `idle`.\n *\n * Three things make it shippable rather than decorative: a rejection keeps the\n * address the visitor typed, a repeated press cannot fire a second request, and\n * \"you are already subscribed\" reads as success instead of as an error.\n */\nexport const NewsletterSignup = React.forwardRef<HTMLElement, NewsletterSignupProps>(\n  (\n    {\n      onSubmit,\n      heading = \"Subscribe to the newsletter\",\n      description = \"One email a month with what shipped, what broke and what we learned.\",\n      defaultEmail = \"\",\n      emailLabel = \"Email address\",\n      placeholder = \"you@example.com\",\n      submitLabel = \"Subscribe\",\n      pendingLabel = \"Subscribing…\",\n      retryLabel = \"Try again\",\n      emptyMessage = \"Enter your email address.\",\n      invalidMessage = \"That doesn't look like an email address.\",\n      failureMessage = \"We couldn't sign you up just now. Please try again.\",\n      successTitle = \"You're on the list\",\n      successDescription = \"Check your inbox for a confirmation email.\",\n      alreadyTitle = \"You're already subscribed\",\n      alreadyDescription = \"This address is on the list already — nothing else to do.\",\n      resetLabel = \"Use a different email\",\n      footnote,\n      className,\n      ...rest\n    },\n    forwardedRef,\n  ) => {\n    const baseId = React.useId()\n    const headingId = `${baseId}-heading`\n    const emailId = `${baseId}-email`\n    const alertId = `${baseId}-alert`\n\n    const inputRef = React.useRef<HTMLInputElement>(null)\n    const mountedRef = React.useRef(false)\n    // Flipped synchronously at the top of the handler. State cannot do this job:\n    // several presses inside one tick would all read the same stale `status`.\n    const pendingRef = React.useRef(false)\n    // Set by the reset button, so the effect below knows the caret is owed a home.\n    const refocusRef = React.useRef(false)\n\n    const [email, setEmail] = React.useState(defaultEmail)\n    const [submitted, setSubmitted] = React.useState(\"\")\n    const [status, setStatus] = React.useState<NewsletterSignupStatus>(\"idle\")\n    const [message, setMessage] = React.useState<string | null>(null)\n    // Distinguishes \"the field is wrong\" (clears on the next keystroke) from\n    // \"the request failed\" (survives editing, so the retry keeps its reason).\n    const [invalid, setInvalid] = React.useState(false)\n\n    // Latest-ref: consumers pass an inline arrow function, so this must never\n    // sit in a dependency array.\n    const onSubmitRef = React.useRef(onSubmit)\n    React.useEffect(() => {\n      onSubmitRef.current = onSubmit\n    })\n\n    React.useEffect(() => {\n      // Set in the effect BODY, not only cleared in cleanup: StrictMode runs\n      // mount → cleanup → mount in dev, so a ref that is merely cleared would\n      // read false for the live instance and no submit would ever land.\n      mountedRef.current = true\n      return () => {\n        mountedRef.current = false\n      }\n    }, [])\n\n    React.useEffect(() => {\n      if (status !== \"idle\" || !refocusRef.current) return\n      refocusRef.current = false\n      inputRef.current?.focus()\n    }, [status])\n\n    const busy = status === \"submitting\"\n    const done = status === \"success\" || status === \"already\"\n\n    const rejectField = (text: string) => {\n      setInvalid(true)\n      setMessage(text)\n      // A stale network failure is superseded by a field problem: the button\n      // must not keep offering \"Try again\" for something it can no longer send.\n      setStatus(\"idle\")\n      inputRef.current?.focus()\n    }\n\n    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {\n      event.preventDefault()\n      if (pendingRef.current) return\n\n      const candidate = email.trim()\n      if (candidate === \"\") {\n        rejectField(emptyMessage)\n        return\n      }\n      if (!newsletterEmailSchema.safeParse(candidate).success) {\n        rejectField(invalidMessage)\n        return\n      }\n\n      pendingRef.current = true\n      setInvalid(false)\n      setMessage(null)\n      setStatus(\"submitting\")\n\n      let result: NewsletterSignupResult | void\n      try {\n        result = await onSubmitRef.current(candidate)\n      } catch (error) {\n        pendingRef.current = false\n        // Re-check liveness after the await: a late rejection landing on an\n        // unmounted instance would setState on a dead component.\n        if (!mountedRef.current) return\n        setStatus(\"error\")\n        setMessage(rejectionText(error, failureMessage))\n        return\n      }\n      pendingRef.current = false\n      if (!mountedRef.current) return\n      setSubmitted(candidate)\n      setMessage(result?.message ?? null)\n      setStatus(result?.status === \"already-subscribed\" ? \"already\" : \"success\")\n    }\n\n    const startOver = () => {\n      refocusRef.current = true\n      setEmail(\"\")\n      setSubmitted(\"\")\n      setMessage(null)\n      setInvalid(false)\n      setStatus(\"idle\")\n    }\n\n    const buttonLabel = busy ? pendingLabel : status === \"error\" ? retryLabel : submitLabel\n\n    return (\n      <section\n        {...rest}\n        aria-labelledby={heading ? headingId : undefined}\n        className={cn(\"w-full rounded-2xl border bg-card p-6 text-card-foreground sm:p-10\", className)}\n        data-status={status}\n        ref={forwardedRef}\n      >\n        <div className=\"mx-auto flex w-full max-w-xl flex-col items-center gap-6 text-center\">\n          {/* Always mounted, so a transition is announced by a live region that\n              already existed — one inserted at the same moment as its text is\n              unreliable in most screen readers. It carries the pending label\n              only; the confirmation panel below is its own role=\"status\", so\n              nothing gets announced twice. */}\n          <span className=\"sr-only\" role=\"status\">\n            {busy ? pendingLabel : \"\"}\n          </span>\n\n          {heading || description ? (\n            <div className=\"flex flex-col gap-2\">\n              {heading ? (\n                <h2 className=\"text-2xl font-semibold tracking-tight text-balance\" id={headingId}>\n                  {heading}\n                </h2>\n              ) : null}\n              {description ? (\n                <p className=\"text-sm text-pretty text-muted-foreground\">{description}</p>\n              ) : null}\n            </div>\n          ) : null}\n\n          {done ? (\n            <div\n              className=\"flex w-full flex-col items-center gap-3 rounded-xl border bg-muted/40 p-6\"\n              role=\"status\"\n            >\n              <span className=\"inline-flex size-10 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n                {status === \"success\" ? (\n                  <Check aria-hidden=\"true\" className=\"size-5\" />\n                ) : (\n                  <MailCheck aria-hidden=\"true\" className=\"size-5\" />\n                )}\n              </span>\n              <div className=\"flex flex-col gap-1\">\n                <p className=\"text-sm font-medium\">\n                  {status === \"success\" ? successTitle : alreadyTitle}\n                </p>\n                <p className=\"text-sm text-muted-foreground\">\n                  {message ?? (status === \"success\" ? successDescription : alreadyDescription)}\n                </p>\n              </div>\n              {/* The exact address that was accepted: a confirmation that cannot\n                  say which inbox to check is not a confirmation. */}\n              <p className=\"rounded-md border bg-background px-2.5 py-1 font-mono text-xs break-all\">\n                {submitted}\n              </p>\n              <button\n                className=\"cursor-pointer rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n                onClick={startOver}\n                type=\"button\"\n              >\n                {resetLabel}\n              </button>\n            </div>\n          ) : (\n            /* A real <form onSubmit>, not a button with an onClick: that is the\n               only way Enter in the field submits, and it keeps the whole flow\n               working with the mouse unplugged. */\n            <form\n              aria-busy={busy || undefined}\n              className=\"flex w-full flex-col gap-2\"\n              noValidate\n              onSubmit={handleSubmit}\n            >\n              <div className=\"flex w-full flex-col gap-2 sm:flex-row\">\n                <label className=\"sr-only\" htmlFor={emailId}>\n                  {emailLabel}\n                </label>\n                <div className=\"relative flex-1\">\n                  <Mail\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\"\n                  />\n                  <input\n                    aria-describedby={message ? alertId : undefined}\n                    aria-invalid={invalid || undefined}\n                    autoComplete=\"email\"\n                    className={cn(\n                      \"h-11 w-full rounded-lg border border-input bg-background ps-9 pe-3 text-sm outline-none transition-colors\",\n                      \"placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40\",\n                      \"motion-reduce:transition-none\",\n                      invalid && \"border-destructive focus-visible:border-destructive\",\n                    )}\n                    id={emailId}\n                    // RFC 5321 caps an address at 254 characters; anything longer\n                    // is a paste accident, not an address.\n                    maxLength={254}\n                    name=\"email\"\n                    onChange={event => {\n                      setEmail(event.target.value)\n                      if (invalid) {\n                        setInvalid(false)\n                        setMessage(null)\n                      }\n                    }}\n                    placeholder={placeholder}\n                    ref={inputRef}\n                    required\n                    // Kept for the mobile keyboard only — zod is the validator.\n                    type=\"email\"\n                    value={email}\n                  />\n                </div>\n                {/* aria-disabled, never the native `disabled` attribute: the\n                    browser blurs a control the instant it becomes disabled, so a\n                    keyboard user who pressed Enter would be dumped on <body>\n                    while the pending state is announced from nowhere. The\n                    handler's ref guard is what actually stops a second request. */}\n                <button\n                  aria-busy={busy || undefined}\n                  aria-disabled={busy || undefined}\n                  className={cn(\n                    \"inline-flex h-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors\",\n                    \"hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none\",\n                    \"motion-reduce:transition-none\",\n                    busy && \"cursor-progress opacity-80\",\n                  )}\n                  type=\"submit\"\n                >\n                  {busy ? (\n                    <LoaderCircle\n                      aria-hidden=\"true\"\n                      className=\"size-4 animate-spin motion-reduce:animate-none\"\n                    />\n                  ) : null}\n                  {buttonLabel}\n                </button>\n              </div>\n\n              {message ? (\n                <p\n                  className=\"rounded-lg bg-destructive/10 px-3 py-2 text-start text-sm text-destructive\"\n                  id={alertId}\n                  role=\"alert\"\n                >\n                  {message}\n                </p>\n              ) : null}\n            </form>\n          )}\n\n          {footnote ? <p className=\"text-xs text-muted-foreground\">{footnote}</p> : null}\n        </div>\n      </section>\n    )\n  },\n)\n\nNewsletterSignup.displayName = \"NewsletterSignup\"\n\nexport default NewsletterSignup\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}