{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "waitlist-1",
  "title": "Waitlist 1: referral waitlist",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A launch waitlist that grows by referral: an email form with a count of who is waiting, then the person's place in line, their own share link to copy, and a ladder of rewards that fills as friends join.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/copy-button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/number-ticker.json",
    "https://ui.ballmac.com/r/use-resource.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/waitlist-1/waitlist-1.tsx",
      "content": "// Ballmac UI: Waitlist 1. https://ui.ballmac.com/blocks/waitlist-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, CircleAlert, Gift, Loader2, Lock, Mail, Share2 } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CopyButton } from \"@/components/ballmac/copy-button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { NumberTicker } from \"@/components/ballmac/number-ticker\"\nimport { formPhase, type FormStateProps } from \"@/lib/ballmac/use-resource\"\nimport { cn } from \"@/lib/utils\"\n\ntype Waitlist1Joined = { /** Their place in line, 1 is first. */ position: number; /** Their share link. */ link: string; /** How many friends have joined from it. */ referrals: number }\n\ntype Waitlist1Reward = { /** Friends needed. */ at: number; title: string; description?: string }\n\ntype Waitlist1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onSubmit\"> &\n  FormStateProps & {\n    product?: string\n    title?: string\n    description?: string\n    /** How many people are waiting, shown under the form. */\n    waiting?: number\n    rewards?: Waitlist1Reward[]\n    /** Names for the little faces beside the count. */\n    faces?: string[]\n    /** Called with the email. Return where they stand; reject to show a message and keep what they typed. */\n    onJoin?: (email: string) => Promise<Waitlist1Joined> | Waitlist1Joined\n    /** Start already joined, for showing the second screen. */\n    defaultJoined?: Waitlist1Joined\n  }\n\nconst sampleRewards: Waitlist1Reward[] = [\n  { at: 1, title: \"Skip ahead 100 places\", description: \"For your first friend\" },\n  { at: 3, title: \"Early access\", description: \"In the first wave of invites\" },\n  { at: 10, title: \"A year free\", description: \"On any plan\" },\n]\n\nconst names = [\"Mara E\", \"Theo N\", \"Priya S\", \"Noor H\", \"Kofi M\"]\n\n/**\n * A launch waitlist that grows by referral. First the email form and how many are waiting; once joined, the person sees their place in line,\n * their own share link, and a ladder of rewards that fills as friends join. The place counts up, the link copies with one press, and a\n * failed join keeps what was typed.\n */\nfunction Waitlist1({\n  product = \"Acme\",\n  title,\n  description = \"We let people in a few at a time so every one gets our full attention. Join the list, and move up by bringing friends.\",\n  waiting = 2431,\n  rewards = sampleRewards,\n  faces = names,\n  onJoin,\n  defaultJoined,\n  state: stateProp,\n  errorState,\n  className,\n  ...props\n}: Waitlist1Props) {\n  const id = React.useId()\n  const [email, setEmail] = React.useState(\"\")\n  const [invalid, setInvalid] = React.useState<string>()\n  const [internal, setInternal] = React.useState<\"idle\" | \"sending\" | \"done\" | \"error\">(defaultJoined ? \"done\" : \"idle\")\n  const [joined, setJoined] = React.useState<Waitlist1Joined | undefined>(defaultJoined)\n  const [message, setMessage] = React.useState<string>()\n  const state = formPhase(stateProp) ?? internal\n  const headingRef = React.useRef<HTMLHeadingElement>(null)\n  const first = React.useRef(true)\n  const done = state === \"done\"\n  const sending = state === \"sending\"\n  const info = joined ?? { position: 1284, link: \"acme.com/r/mara-7k2\", referrals: 1 }\n\n  React.useEffect(() => {\n    if (first.current) first.current = false\n    else if (done) headingRef.current?.focus()\n  }, [done])\n\n  async function submit(e: React.FormEvent) {\n    e.preventDefault()\n    if (!/^\\S+@\\S+\\.\\S+$/.test(email.trim())) return setInvalid(\"Enter an email address like name@company.com.\")\n    setInvalid(undefined)\n    setMessage(undefined)\n    setInternal(\"sending\")\n    try {\n      if (onJoin) setJoined(await onJoin(email.trim()))\n      else {\n        await new Promise((r) => setTimeout(r, 800))\n        setJoined({ position: waiting + 1, link: \"acme.com/r/you-7k2\", referrals: 0 })\n      }\n      setInternal(\"done\")\n    } catch (err) {\n      setMessage(err instanceof Error ? err.message : undefined)\n      setInternal(\"error\")\n    }\n  }\n\n  const next = rewards.find((r) => r.at > info.referrals)\n  const shareText = encodeURIComponent(`I just joined the ${product} waitlist. Join with my link and we both move up:`)\n\n  return (\n    <section data-slot=\"waitlist-1\" aria-labelledby={`${id}-h`} aria-busy={sending} className={cn(\"relative isolate w-full overflow-hidden\", className)} {...props}>\n      <div aria-hidden=\"true\" className=\"absolute inset-x-0 top-0 -z-10 h-80 opacity-60 [mask-image:linear-gradient(to_bottom,black,transparent)]\">\n        <CoverArt seed={`${product}-waitlist`} variant=\"mesh\" className=\"absolute inset-0\" />\n      </div>\n\n      <div className=\"mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 sm:py-24\">\n      {!done ? (\n        <div className=\"text-center\">\n          <p className=\"bg-background/70 inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium backdrop-blur\">\n            <Lock aria-hidden=\"true\" className=\"size-3.5\" />\n            Invite only for now\n          </p>\n          <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className=\"mx-auto mt-6 max-w-xl text-5xl font-semibold tracking-[-0.045em] text-balance outline-none sm:text-6xl\">\n            {title ?? `Be first in line for ${product}`}\n          </h2>\n          <p className=\"text-muted-foreground mx-auto mt-5 max-w-xl text-lg text-pretty\">{description}</p>\n\n          <form onSubmit={submit} noValidate className=\"mx-auto mt-10 max-w-md\">\n            <div className=\"flex flex-col gap-2.5 sm:flex-row\">\n              <div className=\"flex-1 text-start\">\n                <Input type=\"email\" size=\"lg\" value={email} onChange={(e) => setEmail(e.target.value)} aria-label=\"Email address\" placeholder=\"you@company.com\" autoComplete=\"email\" disabled={sending} aria-invalid={!!invalid} aria-describedby={invalid ? `${id}-err` : undefined} />\n              </div>\n              <Button type=\"submit\" size=\"lg\" disabled={sending}>\n                {sending ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n                {sending ? \"Joining\" : \"Join the waitlist\"}\n                {sending ? null : <ArrowRight aria-hidden=\"true\" className=\"rtl:rotate-180\" />}\n              </Button>\n            </div>\n            {invalid ? (\n              <p id={`${id}-err`} role=\"alert\" className=\"text-destructive mt-3 text-start text-sm\">\n                {invalid}\n              </p>\n            ) : null}\n            {state === \"error\" ? (\n              <p role=\"alert\" className=\"text-destructive mt-3 flex items-start gap-2 text-start text-sm\">\n                <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n                {errorState?.description ?? message ?? \"We could not add you just now. Your email is still here, so try again.\"}\n              </p>\n            ) : null}\n          </form>\n\n          <div className=\"mt-8 flex items-center justify-center gap-3\">\n            <ul aria-hidden=\"true\" className=\"flex -space-x-2\">\n              {faces.map((f) => (\n                <li key={f} className=\"border-background relative flex size-8 items-center justify-center overflow-hidden rounded-full border-2 text-[10px] font-semibold\">\n                  <CoverArt seed={f} variant=\"mesh\" className=\"absolute inset-0\" />\n                  <span className=\"relative\">{f[0]}</span>\n                </li>\n              ))}\n            </ul>\n            <p className=\"text-muted-foreground text-sm\">\n              <span className=\"text-foreground font-semibold tabular-nums\">{new Intl.NumberFormat(\"en\").format(waiting)}</span> people are waiting\n            </p>\n          </div>\n        </div>\n      ) : (\n        <div>\n          <div className=\"text-center\" role=\"status\">\n            <p className=\"text-muted-foreground text-sm font-medium\">You are on the list</p>\n            <h2 id={`${id}-h`} ref={headingRef} tabIndex={-1} className=\"mt-3 text-6xl font-semibold tracking-[-0.05em] outline-none sm:text-8xl\">\n              <span aria-hidden=\"true\">\n                #<NumberTicker value={info.position} />\n              </span>\n              <span className=\"sr-only\">Number {new Intl.NumberFormat(\"en\").format(info.position)}</span>\n            </h2>\n            <p className=\"text-muted-foreground mx-auto mt-3 max-w-sm text-pretty\">\n              in line for {product}. {next ? `Bring ${next.at - info.referrals} more ${next.at - info.referrals === 1 ? \"friend\" : \"friends\"} and you unlock ${next.title.toLowerCase()}.` : \"You have unlocked every reward. Thank you for spreading the word.\"}\n            </p>\n          </div>\n\n          <div className=\"bg-card mt-10 rounded-3xl border p-5 sm:p-7\">\n            <h3 className=\"flex items-center gap-2 font-semibold tracking-tight\">\n              <Share2 aria-hidden=\"true\" className=\"size-4\" />\n              Your link\n            </h3>\n            <div className=\"mt-3 flex items-center gap-2 rounded-xl border p-1.5 ps-4\">\n              <p className=\"min-w-0 flex-1 truncate font-mono text-sm\" dir=\"ltr\">\n                {info.link}\n              </p>\n              <CopyButton value={`https://${info.link}`} label=\"Copy link\" copiedLabel=\"Copied\" variant=\"solid\" />\n            </div>\n            <div className=\"mt-3 flex flex-wrap gap-2\">\n              <a href={`mailto:?subject=${encodeURIComponent(`Join me on the ${product} waitlist`)}&body=${shareText}%20https://${info.link}`} className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex h-9 items-center gap-2 rounded-md border px-3.5 text-sm font-medium outline-none focus-visible:ring-[3px]\">\n                <Mail aria-hidden=\"true\" className=\"size-4\" />\n                Email a friend\n              </a>\n              <a href={`https://twitter.com/intent/tweet?text=${shareText}&url=https://${info.link}`} className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex h-9 items-center gap-2 rounded-md border px-3.5 text-sm font-medium outline-none focus-visible:ring-[3px]\" rel=\"noreferrer\" target=\"_blank\">\n                Post on X<span className=\"sr-only\"> (opens in a new tab)</span>\n              </a>\n            </div>\n          </div>\n\n          <div className=\"bg-card mt-5 rounded-3xl border p-5 sm:p-7\">\n            <h3 className=\"flex items-center gap-2 font-semibold tracking-tight\">\n              <Gift aria-hidden=\"true\" className=\"size-4\" />\n              Rewards <span className=\"text-muted-foreground font-normal tabular-nums\">· {info.referrals} {info.referrals === 1 ? \"friend\" : \"friends\"} joined</span>\n            </h3>\n            <ol className=\"mt-5 grid gap-0\">\n              {rewards.map((r, i) => {\n                const got = info.referrals >= r.at\n                const last = i === rewards.length - 1\n                return (\n                  <li key={r.at} className=\"relative flex gap-4 pb-6 last:pb-0\">\n                    {!last ? <span aria-hidden=\"true\" className={cn(\"absolute start-[15px] top-8 -bottom-0 w-px\", got ? \"bg-foreground\" : \"bg-border\")} /> : null}\n                    <span aria-hidden=\"true\" className={cn(\"relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-semibold tabular-nums transition-colors motion-reduce:transition-none\", got ? \"bg-foreground text-background border-foreground\" : \"bg-background\")}>\n                      {got ? \"✓\" : r.at}\n                    </span>\n                    <div className=\"min-w-0 pt-1\">\n                      <p className=\"text-sm font-medium\">\n                        {r.title}\n                        <span className=\"sr-only\">, {got ? \"unlocked\" : `needs ${r.at - info.referrals} more ${r.at - info.referrals === 1 ? \"friend\" : \"friends\"}`}</span>\n                      </p>\n                      <p className=\"text-muted-foreground text-sm\">\n                        {r.description ? `${r.description} · ` : \"\"}\n                        {r.at} {r.at === 1 ? \"friend\" : \"friends\"}\n                      </p>\n                    </div>\n                  </li>\n                )\n              })}\n            </ol>\n          </div>\n        </div>\n      )}\n      </div>\n    </section>\n  )\n}\n\nexport { Waitlist1, type Waitlist1Props, type Waitlist1Joined, type Waitlist1Reward }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/waitlist-1/waitlist-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "waitlist",
      "launch",
      "referral",
      "early access",
      "signup",
      "invite",
      "viral",
      "coming soon",
      "pre-launch"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A referral waitlist. <Waitlist1 product='Acme' waiting={2431} rewards={[{ at: 3, title, description }]} onJoin={(email) => Promise<{ position, link, referrals }>} defaultJoined state />. After joining it shows #position, the share link with a copy button, and rewards that unlock as referrals reaches each at. Reject onJoin to show its message with the email kept.",
      "whenToUse": [
        "A pre-launch landing page that collects emails",
        "Rolling out access in waves with referral rewards"
      ],
      "whenNotToUse": [
        "A newsletter signup (use newsletter-1)",
        "A full sign-up form (use auth blocks)"
      ],
      "composesWith": [
        "hero-1",
        "faq-1",
        "footer-1",
        "logo-cloud-1"
      ],
      "a11y": [
        {
          "keys": "Submit",
          "action": "An invalid email is announced as an alert and linked to the field; a failed join keeps the email"
        },
        {
          "keys": "After joining",
          "action": "Focus moves to the place in line, which is read as 'Number 1,284' with the message to follow"
        },
        {
          "keys": "Rewards",
          "action": "Each step says in words whether it is unlocked or how many more friends it needs"
        }
      ],
      "customization": [
        "rewards: any number of steps by friends needed",
        "The share link and text are built for email and X; swap in your own channels",
        "The link is shown left to right even in right-to-left layouts, like any address"
      ]
    },
    "states": [
      {
        "id": "submitting",
        "title": "Joining",
        "example": "waitlist-1-sending",
        "trigger": "state=\"submitting\"",
        "note": "The field and button are disabled, the button says Joining with a spinner and the section is marked busy."
      },
      {
        "id": "success",
        "title": "On the list",
        "example": "waitlist-1-joined",
        "trigger": "state=\"success\"",
        "note": "The second screen: place in line, share link and the reward ladder."
      },
      {
        "id": "error",
        "title": "Could not join",
        "example": "waitlist-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying the email is still there; the person can press Join again."
      }
    ],
    "examples": [
      "waitlist-1-demo",
      "waitlist-1-joined",
      "waitlist-1-sending",
      "waitlist-1-error"
    ],
    "url": "https://ui.ballmac.com/components/waitlist-1"
  },
  "categories": [
    "blocks",
    "waitlist"
  ],
  "type": "registry:block"
}