{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cta-signup",
  "title": "CTA signup",
  "description": "An email capture card: a checklist, one field with validation, a sending state and a confirmation that echoes the address. Sends nothing itself; you pass onSubmit.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/cta-signup/cta-signup.tsx",
      "content": "// An email capture as one quiet Tiles card: a headline, a short \"what you get\" checklist and the form. The form\n// validates on blur and on submit, keeps its width while it sends (the label fades out and a spinner fades in on the\n// same button), and on success is replaced by a confirmation that says the address back in mono. Signature: the\n// success state shows the address the user typed, so they can spot a typo at once. Nothing is sent by the section:\n// `onSubmit` receives the values and returns a promise it awaits.\n// Screen readers get a labelled section with one h2 and a list; the field has a real label, errors are tied to it\n// with aria-invalid and aria-describedby and focus moves to it when it is invalid; the success text is announced\n// through a polite live region. Under reduced motion the spinner stops turning and the success state appears at once.\nimport { Check } from 'lucide-react'\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ComponentProps,\n  type FormEvent,\n  type ReactNode,\n} from 'react'\nimport { cn } from '@/lib/utils'\n\nexport type CtaSignupValues = {\n  /** The address the user typed, trimmed. */\n  email: string\n}\n\nexport type CtaSignupLabels = {\n  /** The visible label of the field. Default \"Email address\". */\n  email?: string\n  /** The submit button. Default \"Subscribe\". */\n  submit?: string\n  /** Hidden text on the button while it sends. Default \"Sending\". */\n  sending?: string\n  /** Shown when the field is empty. Default \"Enter your email address.\" */\n  required?: string\n  /** Shown when the address does not look right. Default \"That does not look like an email address. Check it for typos, for example name@example.com.\" */\n  invalid?: string\n  /** Shown when `onSubmit` rejects. Default \"Something went wrong and nothing was saved. Try again in a moment.\" */\n  failed?: string\n  /** The example text inside the empty field. Default \"name@example.com\". */\n  placeholder?: string\n  /** The heading of the success state. Default \"You are on the list.\" */\n  successTitle?: string\n  /**\n   * The sentence in front of the address in the success state. Default \"Signed up as\". If your `onSubmit` sends a\n   * confirmation mail, say so here, such as \"We sent a confirmation to\".\n   */\n  successText?: string\n  /** The button in the success state that goes back to the form. Default \"Use a different address\". */\n  reset?: string\n  /** The small heading of the checklist, hidden from sight and read by screen readers. Default \"What you get\". */\n  includes?: string\n}\n\nconst DEFAULT_LABELS: Required<CtaSignupLabels> = {\n  email: 'Email address',\n  submit: 'Subscribe',\n  sending: 'Sending',\n  required: 'Enter your email address.',\n  invalid: 'That does not look like an email address. Check it for typos, for example name@example.com.',\n  failed: 'Something went wrong and nothing was saved. Try again in a moment.',\n  placeholder: 'name@example.com',\n  successTitle: 'You are on the list.',\n  successText: 'Signed up as',\n  reset: 'Use a different address',\n  includes: 'What you get',\n}\n\nexport type CtaSignupProps = Omit<ComponentProps<'section'>, 'children' | 'title' | 'onSubmit'> & {\n  /** The headline of the card. */\n  title: ReactNode\n  /** One sentence under the headline. */\n  description?: ReactNode\n  /** What the person gets, 3 to 5 short items. Shown as a list with check marks. */\n  items: ReactNode[]\n  /** The consent line under the form, such as a sentence with a link to the privacy policy. */\n  consent?: ReactNode\n  /**\n   * Called with the values when the form is valid. Return a promise; the button shows its sending state until it\n   * settles. Resolve to show the success state, reject to show an error and keep the typed text. The section itself\n   * never sends anything.\n   */\n  onSubmit?: (values: CtaSignupValues) => Promise<void> | void\n  /** The `autocomplete` token of the field. Default \"email\". */\n  autoComplete?: string\n  /** Text for the interface itself, for translation. */\n  labels?: CtaSignupLabels\n}\n\ntype Status = 'idle' | 'sending' | 'sent'\n\n// A forgiving check: something@something.tld without spaces. The server decides what is really valid.\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/\n\n/** The email capture card: checklist, one field, validation, a sending state and a confirmation that echoes the address. */\nexport function CtaSignup({\n  title,\n  description,\n  items,\n  consent,\n  onSubmit,\n  autoComplete = 'email',\n  labels,\n  className,\n  ...rest\n}: CtaSignupProps) {\n  const l = { ...DEFAULT_LABELS, ...labels }\n  const uid = useId()\n  const headingId = `${uid}-title`\n  const inputId = `${uid}-email`\n  const errorId = `${uid}-error`\n  const consentId = `${uid}-consent`\n  const inputRef = useRef<HTMLInputElement>(null)\n  const resetRef = useRef<HTMLButtonElement>(null)\n  const alive = useRef(false)\n  const [value, setValue] = useState('')\n  const [touched, setTouched] = useState(false)\n  const [status, setStatus] = useState<Status>('idle')\n  const [failed, setFailed] = useState(false)\n  const [sentTo, setSentTo] = useState('')\n\n  useEffect(() => {\n    alive.current = true\n    return () => {\n      alive.current = false\n    }\n  }, [])\n\n  // The submit button goes away with the form, so focus moves to the success state's only control instead of\n  // falling back to the page. The live region still reads the confirmation.\n  useEffect(() => {\n    if (status === 'sent') resetRef.current?.focus()\n  }, [status])\n\n  const trimmed = value.trim()\n  const problem = !trimmed ? l.required : !EMAIL.test(trimmed) ? l.invalid : null\n  const showProblem = touched && problem\n  const sending = status === 'sending'\n\n  const submit = useCallback(\n    async (e: FormEvent<HTMLFormElement>) => {\n      e.preventDefault()\n      if (sending) return\n      setTouched(true)\n      if (problem) {\n        inputRef.current?.focus()\n        return\n      }\n      setFailed(false)\n      setStatus('sending')\n      try {\n        await onSubmit?.({ email: trimmed })\n        if (!alive.current) return\n        setSentTo(trimmed)\n        setStatus('sent')\n      } catch {\n        if (!alive.current) return\n        setFailed(true)\n        setStatus('idle')\n      }\n    },\n    [sending, problem, onSubmit, trimmed],\n  )\n\n  const reset = () => {\n    setValue('')\n    setTouched(false)\n    setFailed(false)\n    setStatus('idle')\n    // Wait for the form to render again before moving focus into it.\n    requestAnimationFrame(() => inputRef.current?.focus())\n  }\n\n  const describedBy = [showProblem || failed ? errorId : null, consent ? consentId : null].filter(Boolean).join(' ') || undefined\n\n  return (\n    <section\n      aria-labelledby={headingId}\n      {...rest}\n      className={cn('@container mx-auto w-full max-w-6xl px-4 py-12 sm:px-6 @3xl:py-20', className)}\n    >\n      <div className=\"grid gap-10 rounded-[calc(var(--radius)*2+2px)] bg-card px-6 py-8 text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)] @min-[40rem]:px-10 @min-[40rem]:py-12 @min-[56rem]:grid-cols-2 @min-[56rem]:gap-16 @min-[64rem]:px-14 @min-[64rem]:py-14\">\n        <div className=\"min-w-0\">\n          <h2\n            id={headingId}\n            className=\"max-w-[20ch] text-[clamp(1.75rem,6cqi,3rem)] leading-[1.04] font-extrabold tracking-[-0.04em] text-balance\"\n            style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '86%' }}\n          >\n            {title}\n          </h2>\n          {description && <p className=\"mt-4 max-w-[46ch] text-base leading-relaxed text-pretty text-muted-foreground\">{description}</p>}\n          <h3 className=\"sr-only\">{l.includes}</h3>\n          <ul className=\"mt-7 grid gap-3\">\n            {items.map((item, i) => (\n              <li key={i} className=\"flex items-start gap-3 text-[15px] leading-snug\">\n                <span aria-hidden className=\"mt-px grid size-5 flex-none place-items-center rounded-full bg-muted text-foreground\">\n                  <Check className=\"size-3\" strokeWidth={3} />\n                </span>\n                <span className=\"min-w-0 text-pretty\">{item}</span>\n              </li>\n            ))}\n          </ul>\n        </div>\n\n        <div className=\"min-w-0 self-center\">\n          <div role=\"status\" aria-live=\"polite\">\n            {status === 'sent' && (\n              <div className=\"rounded-[14px] bg-muted p-6 transition-[opacity,transform] duration-300 ease-out-quint starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none\">\n                <span aria-hidden className=\"grid size-9 place-items-center rounded-full bg-foreground text-background\">\n                  <Check className=\"size-[18px]\" strokeWidth={2.5} />\n                </span>\n                <p className=\"mt-4 text-xl leading-tight font-semibold tracking-[-0.02em]\">{l.successTitle}</p>\n                <p className=\"mt-2 text-[15px] leading-relaxed text-muted-foreground\">{l.successText}</p>\n                <p className=\"mt-1 font-mono text-[13px] leading-relaxed break-all tabular-nums\">{sentTo}</p>\n                <button\n                  ref={resetRef}\n                  type=\"button\"\n                  onClick={reset}\n                  className=\"mt-4 -ml-3 inline-flex min-h-11 items-center rounded-full px-3 text-sm font-medium underline underline-offset-4 transition-[opacity,transform] duration-150 ease-out-quint hover:opacity-70 active:scale-[0.97] motion-reduce:transition-none\"\n                >\n                  {l.reset}\n                </button>\n              </div>\n            )}\n          </div>\n\n          {status !== 'sent' && (\n            <form noValidate onSubmit={submit} aria-busy={sending || undefined}>\n              <label htmlFor={inputId} className=\"block text-sm font-medium\">\n                {l.email}\n              </label>\n              <div className=\"mt-2 flex flex-col gap-3 @min-[32rem]:flex-row\">\n                <input\n                  ref={inputRef}\n                  id={inputId}\n                  name=\"email\"\n                  type=\"email\"\n                  inputMode=\"email\"\n                  autoComplete={autoComplete}\n                  autoCapitalize=\"none\"\n                  spellCheck={false}\n                  required\n                  value={value}\n                  readOnly={sending}\n                  onChange={(e) => setValue(e.target.value)}\n                  onBlur={() => value && setTouched(true)}\n                  aria-invalid={showProblem ? true : undefined}\n                  aria-describedby={describedBy}\n                  className={cn(\n                    'h-12 w-full min-w-0 rounded-[14px] @min-[32rem]:flex-1 bg-background px-4 text-base text-foreground shadow-[inset_0_0_0_1px_var(--border)] outline-offset-2 placeholder:text-muted-foreground/70',\n                    showProblem && 'shadow-[inset_0_0_0_1.5px_var(--destructive)]',\n                  )}\n                  placeholder={l.placeholder}\n                />\n                <button\n                  type=\"submit\"\n                  aria-disabled={sending || undefined}\n                  className=\"relative inline-flex h-12 flex-none items-center justify-center rounded-full bg-primary px-6 text-[15px] font-medium whitespace-nowrap text-primary-foreground transition-[transform,opacity] duration-150 ease-out-quint hover:opacity-90 active:scale-[0.97] aria-disabled:cursor-progress motion-reduce:transition-none motion-reduce:active:scale-100\"\n                >\n                  <span className={cn('transition-[opacity,transform] duration-150 ease-out-quint motion-reduce:transition-none', sending && 'scale-95 opacity-0')}>\n                    {l.submit}\n                  </span>\n                  <span\n                    aria-hidden\n                    className={cn(\n                      'absolute inset-0 grid place-items-center transition-[opacity,transform] duration-150 ease-out-quint motion-reduce:transition-none',\n                      sending ? 'opacity-100' : 'scale-90 opacity-0',\n                    )}\n                  >\n                    <svg viewBox=\"0 0 24 24\" className=\"size-5 animate-spin motion-reduce:animate-none\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\">\n                      <circle cx=\"12\" cy=\"12\" r=\"9\" opacity=\"0.25\" />\n                      <path d=\"M21 12a9 9 0 0 0-9-9\" />\n                    </svg>\n                  </span>\n                  {sending && <span className=\"sr-only\">{l.sending}</span>}\n                </button>\n              </div>\n              <div id={errorId} role={failed ? 'alert' : undefined} className=\"empty:hidden\">\n                {showProblem ? (\n                  <p className=\"mt-2 text-sm leading-snug text-destructive\">{problem}</p>\n                ) : failed ? (\n                  <p className=\"mt-2 text-sm leading-snug text-destructive\">{l.failed}</p>\n                ) : null}\n              </div>\n              {consent && (\n                <p id={consentId} className=\"mt-4 text-[13px] leading-relaxed text-pretty text-muted-foreground [&_a]:underline [&_a]:underline-offset-2 [&_a]:hover:text-foreground\">\n                  {consent}\n                </p>\n              )}\n            </form>\n          )}\n        </div>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "templates"
  ],
  "type": "registry:block"
}