Skip to contentVibraUI

Release notes by email

One address on the release-notes list, subscribed through the server — a refusal marked on the field, a confirmation in its place; reads shippedLine().

Preview

Install

npx shadcn@latest add @vibra/widget-support-changelog-subscribe-form

Needs the @vibra registry in your components.json — set it up once.

Source

app/support/changelog/components/subscribe-form.tsx
"use client"

import * as React from "react"

import { type Result } from "@/lib/sample-data"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Widget } from "@/components/ui/widget"

import { subscribe } from "../actions"

const EMAIL_ID = "changelog-subscribe-email"
const MESSAGE_ID = "changelog-subscribe-error"

/**
 * One address on the release-notes list.
 *
 * The action's answer is the only state: `useActionState` holds the pending
 * flag, the refusal and the confirmation together, so there is nothing to keep
 * in step. A refusal renders against the field it names — which is where the
 * reader is already looking — rather than as a banner somewhere above it.
 */
export function SubscribeForm({ shipped }: { shipped: string }) {
  const [email, setEmail] = React.useState("")
  const [result, formAction, pending] = React.useActionState<
    Result<{ email: string }> | null,
    FormData
  >((_previous, formData) => subscribe(formData), null)

  const error = result && !result.ok ? result.error : null

  return (
    <Widget
      data-widget="widget-support-changelog-subscribe-form"
      title="Release notes by email"
      description="One message per release, nothing else"
      footer={shipped}
    >
      {result?.ok ? (
        <p role="status" className="text-sm text-muted-foreground">
          <span className="text-foreground">{result.data.email}</span> is on the list. Every
          release note will arrive there the day it ships; every message carries a link to stop.
        </p>
      ) : (
        <form action={formAction} noValidate className="flex flex-col gap-3 sm:max-w-md">
          <div className="flex flex-col gap-1.5">
            <Label htmlFor={EMAIL_ID}>Email</Label>
            <Input
              id={EMAIL_ID}
              name="email"
              type="email"
              autoComplete="email"
              placeholder="you@example.com"
              required
              value={email}
              onChange={(event) => setEmail(event.target.value)}
              aria-invalid={error ? true : undefined}
              aria-describedby={error ? MESSAGE_ID : undefined}
            />
            {error ? (
              <p id={MESSAGE_ID} role="alert" className="text-sm text-danger">
                {error.message}
              </p>
            ) : null}
          </div>

          <Button type="submit" disabled={pending} className="sm:self-start">
            {pending ? "Subscribing…" : "Subscribe"}
          </Button>
        </form>
      )}
    </Widget>
  )
}
app/support/changelog/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { invalidInput, isForm, type Result } from "@/lib/sample-data"
import { isEmail } from "@/lib/validation"

/**
 * Puts an address on the release-notes list.
 *
 * There is no subscriber entity, and inventing one for a demo would be a
 * fiction with rows behind it; this validates the address and answers, which
 * is the whole of the contract a consumer replaces. Swap the body for a write
 * to your own list and nothing above this file moves.
 */
export async function subscribe(formData: FormData): Promise<Result<{ email: string }>> {
  if (!isForm(formData)) return invalidInput("Send the address as the form sends it.", "email")
  const raw = formData.get("email")
  const email = typeof raw === "string" ? raw.trim() : ""

  if (!isEmail(email)) {
    return {
      ok: false,
      error: { code: "invalid_input", field: "email", message: "Enter a valid email address." },
    }
  }

  return { ok: true, data: { email } }
}

/**
 * The other thing this page changes: signing out. A server action so the page
 * can stay a server component and still hand the shell something to call.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Product changelog page