{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "newsletter-signup",
  "type": "registry:component",
  "title": "Newsletter Signup",
  "description": "Email-capture form with idle/sending/sent/error state machine, custom validators, and overridable labels.",
  "dependencies": [
    "@vllnt/ui@^0.2.1",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/newsletter-signup/newsletter-signup.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  forwardRef,\n  type ReactNode,\n  type SyntheticEvent,\n  useCallback,\n  useId,\n  useReducer,\n  useRef,\n} from \"react\";\n\nimport { CheckCircle2, Loader2, XCircle } from \"lucide-react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { Button } from \"@vllnt/ui\";\nimport { Input } from \"@vllnt/ui\";\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n/**\n * Localizable strings for {@link NewsletterSignup}.\n *\n * @public\n */\nexport type NewsletterSignupLabels = {\n  /** Caption when validation rejects the email. Defaults to a generic message. */\n  emailInvalid?: string;\n  /** Aria-label / fallback for the email input. Defaults to `\"Email address\"`. */\n  emailLabel?: string;\n  /** Generic error message when `onSubmit` rejects without a usable reason. Defaults to `\"Something went wrong. Try again.\"`. */\n  errorFallback?: string;\n  /** Input placeholder. Defaults to `\"you@example.com\"`. */\n  placeholder?: string;\n  /** Caption while the submit promise is in flight. Defaults to `\"Subscribing…\"`. */\n  sending?: string;\n  /** Submit button label in idle state. Defaults to `\"Subscribe\"`. */\n  submit?: string;\n  /** Success caption shown after a successful submission. Defaults to `\"You're on the list. Check your inbox to confirm.\"`. */\n  success?: string;\n  /** Caption for the retry control after an error. Defaults to `\"Try again\"`. */\n  tryAgain?: string;\n};\n\n/**\n * Visual variant for {@link NewsletterSignup}.\n *\n * - `inline` — input + button on a single row (default).\n * - `stacked` — input above, full-width button below.\n *\n * @public\n */\nexport type NewsletterSignupVariant = \"inline\" | \"stacked\";\n\n/**\n * Status reported to {@link NewsletterSignupProps.onStatusChange}.\n *\n * @public\n */\nexport type NewsletterSignupStatus = \"error\" | \"idle\" | \"sending\" | \"sent\";\n\nconst DEFAULT_LABELS = {\n  emailInvalid: \"Enter a valid email address.\",\n  emailLabel: \"Email address\",\n  errorFallback: \"Something went wrong. Try again.\",\n  placeholder: \"you@example.com\",\n  sending: \"Subscribing…\",\n  submit: \"Subscribe\",\n  success: \"You're on the list. Check your inbox to confirm.\",\n  tryAgain: \"Try again\",\n} as const satisfies Required<NewsletterSignupLabels>;\n\ntype State =\n  | { kind: \"error\"; message: string }\n  | { kind: \"idle\" }\n  | { kind: \"sending\" }\n  | { kind: \"sent\" };\n\ntype Action =\n  | { kind: \"fail\"; message: string }\n  | { kind: \"reset\" }\n  | { kind: \"send\" }\n  | { kind: \"succeed\" };\n\nfunction reducer(state: State, action: Action): State {\n  switch (action.kind) {\n    case \"fail\":\n      return { kind: \"error\", message: action.message };\n\n    case \"reset\":\n      return { kind: \"idle\" };\n\n    case \"send\":\n      return state.kind === \"sending\" ? state : { kind: \"sending\" };\n\n    case \"succeed\":\n      return { kind: \"sent\" };\n  }\n}\n\nfunction actionToStatus(action: Action): NewsletterSignupStatus {\n  switch (action.kind) {\n    case \"fail\":\n      return \"error\";\n\n    case \"reset\":\n      return \"idle\";\n\n    case \"send\":\n      return \"sending\";\n\n    case \"succeed\":\n      return \"sent\";\n  }\n}\n\nfunction defaultValidate(email: string, label: string): string | true {\n  const trimmed = email.trim();\n  if (!trimmed) return label;\n  if (!EMAIL_PATTERN.test(trimmed)) return label;\n  return true;\n}\n\nfunction extractErrorMessage(value: unknown, fallback: string): string {\n  if (value instanceof Error && value.message) return value.message;\n  if (typeof value === \"string\" && value.length > 0) return value;\n  return fallback;\n}\n\n/**\n * Props for {@link NewsletterSignup}.\n *\n * @public\n */\nexport type NewsletterSignupProps = {\n  /** Override the input's autocomplete attribute. Defaults to `\"email\"`. */\n  autoComplete?: string;\n  /** Localizable strings. */\n  labels?: NewsletterSignupLabels;\n  /** Fires whenever the internal status transitions. */\n  onStatusChange?: (status: NewsletterSignupStatus) => void;\n  /**\n   * Submission handler. The component awaits the returned promise. Throw to\n   * surface an error message — `Error` instances use their `message` and\n   * thrown strings render verbatim; everything else falls back to\n   * `labels.errorFallback`.\n   */\n  onSubmit: (email: string) => Promise<void> | void;\n  /**\n   * Optional custom validator. Return a string to render as the validation\n   * error, or `true` for valid. Defaults to a basic email regex.\n   */\n  validate?: (email: string) => string | true;\n  /** Visual variant. Defaults to `\"inline\"`. */\n  variant?: NewsletterSignupVariant;\n} & Omit<ComponentPropsWithoutRef<\"form\">, \"onSubmit\">;\n\ntype SubmitButtonProps = {\n  labels: Required<NewsletterSignupLabels>;\n  stacked: boolean;\n  status: NewsletterSignupStatus;\n};\n\nfunction SubmitButton({\n  labels,\n  stacked,\n  status,\n}: SubmitButtonProps): ReactNode {\n  let content: ReactNode;\n  if (status === \"sending\") {\n    content = (\n      <>\n        <Loader2 aria-hidden=\"true\" className=\"mr-2 size-4 animate-spin\" />\n        {labels.sending}\n      </>\n    );\n  } else if (status === \"error\") {\n    content = (\n      <>\n        <XCircle aria-hidden=\"true\" className=\"mr-2 size-4\" />\n        {labels.tryAgain}\n      </>\n    );\n  } else {\n    content = labels.submit;\n  }\n\n  return (\n    <Button\n      className={stacked ? \"w-full\" : \"\"}\n      disabled={status === \"sending\"}\n      type=\"submit\"\n    >\n      {content}\n    </Button>\n  );\n}\n\ntype SuccessPanelProps = {\n  className?: string;\n  message: string;\n};\n\nfunction SuccessPanel({ className, message }: SuccessPanelProps): ReactNode {\n  return (\n    <div\n      aria-live=\"polite\"\n      className={cn(\n        \"flex items-start gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm text-emerald-900 dark:text-emerald-200\",\n        className,\n      )}\n      role=\"status\"\n    >\n      <CheckCircle2 aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n      <span>{message}</span>\n    </div>\n  );\n}\n\n/**\n * Email-capture compound with a built-in state machine for the universal\n * \"drop your email\" pattern. Composes {@link Input} and {@link Button}.\n *\n * State machine: `idle → sending → sent | error`. `error → sending` when\n * the user retries; `error → idle` when they edit the input. Status\n * changes are also reported via `onStatusChange` so callers can drive\n * external UI off the same machine.\n *\n * @example\n * ```tsx\n * <NewsletterSignup\n *   onSubmit={async (email) => subscribe(email)}\n *   labels={{ submit: \"Join\", success: \"Welcome aboard.\" }}\n * />\n * ```\n *\n * @public\n */\ntype SignupController = {\n  errorMessage: null | string;\n  handleChange: () => void;\n  handleSubmit: (event: SyntheticEvent<HTMLFormElement>) => void;\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  status: NewsletterSignupStatus;\n};\n\ntype ControllerOptions = {\n  labels: Required<NewsletterSignupLabels>;\n  onStatusChange?: (status: NewsletterSignupStatus) => void;\n  onSubmit: (email: string) => Promise<void> | void;\n  validate?: (email: string) => string | true;\n};\n\nfunction useNewsletterSignupController(\n  options: ControllerOptions,\n): SignupController {\n  const { labels, onStatusChange, onSubmit, validate } = options;\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [state, dispatch] = useReducer(reducer, { kind: \"idle\" });\n  const status: NewsletterSignupStatus = state.kind;\n\n  const transition = useCallback(\n    (action: Action) => {\n      dispatch(action);\n      onStatusChange?.(actionToStatus(action));\n    },\n    [onStatusChange],\n  );\n\n  const handleChange = useCallback(() => {\n    if (state.kind === \"error\") transition({ kind: \"reset\" });\n  }, [state.kind, transition]);\n\n  const performSubmit = useCallback(\n    async (value: string) => {\n      const validator =\n        validate ??\n        ((email: string) => defaultValidate(email, labels.emailInvalid));\n      const validation = validator(value);\n      if (validation !== true) {\n        transition({ kind: \"fail\", message: validation });\n        inputRef.current?.focus();\n        return;\n      }\n      transition({ kind: \"send\" });\n      try {\n        await onSubmit(value);\n        transition({ kind: \"succeed\" });\n      } catch (error) {\n        transition({\n          kind: \"fail\",\n          message: extractErrorMessage(error, labels.errorFallback),\n        });\n        inputRef.current?.focus();\n      }\n    },\n    [labels.emailInvalid, labels.errorFallback, onSubmit, transition, validate],\n  );\n\n  const handleSubmit = useCallback(\n    (event: SyntheticEvent<HTMLFormElement>) => {\n      event.preventDefault();\n      if (state.kind === \"sending\") return;\n      const value = inputRef.current?.value.trim() ?? \"\";\n      void performSubmit(value);\n    },\n    [performSubmit, state.kind],\n  );\n\n  return {\n    errorMessage: state.kind === \"error\" ? state.message : null,\n    handleChange,\n    handleSubmit,\n    inputRef,\n    status,\n  };\n}\n\nexport const NewsletterSignup = forwardRef<\n  HTMLFormElement,\n  NewsletterSignupProps\n>((props, ref) => {\n  const {\n    autoComplete = \"email\",\n    className,\n    labels,\n    onStatusChange,\n    onSubmit,\n    validate,\n    variant = \"inline\",\n    ...rest\n  } = props;\n  const resolvedLabels = { ...DEFAULT_LABELS, ...labels };\n  const inputId = useId();\n  const errorId = useId();\n  const controller = useNewsletterSignupController({\n    labels: resolvedLabels,\n    onStatusChange,\n    onSubmit,\n    validate,\n  });\n\n  if (controller.status === \"sent\") {\n    return (\n      <SuccessPanel className={className} message={resolvedLabels.success} />\n    );\n  }\n\n  return (\n    <FormBody\n      autoComplete={autoComplete}\n      className={className}\n      errorId={errorId}\n      formRef={ref}\n      inputId={inputId}\n      inputRef={controller.inputRef}\n      labels={resolvedLabels}\n      message={controller.errorMessage}\n      onChange={controller.handleChange}\n      onSubmit={controller.handleSubmit}\n      rest={rest}\n      stacked={variant === \"stacked\"}\n      status={controller.status}\n    />\n  );\n});\nNewsletterSignup.displayName = \"NewsletterSignup\";\n\ntype FormBodyProps = {\n  autoComplete: string;\n  className?: string;\n  errorId: string;\n  formRef: React.ForwardedRef<HTMLFormElement>;\n  inputId: string;\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  labels: Required<NewsletterSignupLabels>;\n  message: null | string;\n  onChange: () => void;\n  onSubmit: (event: SyntheticEvent<HTMLFormElement>) => void;\n  rest: Omit<ComponentPropsWithoutRef<\"form\">, \"onSubmit\">;\n  stacked: boolean;\n  status: NewsletterSignupStatus;\n};\n\nfunction FormBody({\n  autoComplete,\n  className,\n  errorId,\n  formRef,\n  inputId,\n  inputRef,\n  labels,\n  message,\n  onChange,\n  onSubmit,\n  rest,\n  stacked,\n  status,\n}: FormBodyProps): ReactNode {\n  return (\n    <form\n      aria-busy={status === \"sending\"}\n      className={cn(\n        \"flex w-full\",\n        stacked\n          ? \"flex-col gap-2\"\n          : \"flex-col gap-2 sm:flex-row sm:items-start\",\n        className,\n      )}\n      noValidate\n      onSubmit={onSubmit}\n      ref={formRef}\n      {...rest}\n    >\n      <div className={cn(\"flex flex-col gap-1\", stacked ? \"\" : \"sm:flex-1\")}>\n        <label className=\"sr-only\" htmlFor={inputId}>\n          {labels.emailLabel}\n        </label>\n        <Input\n          aria-describedby={message ? errorId : undefined}\n          aria-invalid={message !== null}\n          autoComplete={autoComplete}\n          disabled={status === \"sending\"}\n          id={inputId}\n          name=\"email\"\n          onChange={onChange}\n          placeholder={labels.placeholder}\n          ref={inputRef}\n          type=\"email\"\n        />\n        <p\n          aria-live=\"polite\"\n          className={cn(\"text-xs\", message ? \"text-destructive\" : \"sr-only\")}\n          id={errorId}\n          role={message ? \"alert\" : undefined}\n        >\n          {message ?? \"\"}\n        </p>\n      </div>\n      <SubmitButton labels={labels} stacked={stacked} status={status} />\n    </form>\n  );\n}\n\nexport { reducer as newsletterSignupReducer };\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
