{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "invite-1",
  "title": "Invite 1: accept a team invitation",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The page an invitation email opens: workspace tile, who invited you, your role and what it allows, the account being used, accept and decline, with welcome, declined and expired states.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/badge.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/media.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/invite-1/invite-1.tsx",
      "content": "// Ballmac UI: Invite 1. https://ui.ballmac.com/blocks/invite-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, Check, Clock, Users } from \"lucide-react\"\n\nimport { Badge } from \"@/components/ballmac/badge\"\nimport { Button, buttonVariants } from \"@/components/ballmac/button\"\nimport { cn } from \"@/lib/utils\"\nimport { Media, type MediaSource } from \"@/components/ballmac/media\"\n\ntype Invite1Status = \"pending\" | \"expired\"\n\ntype Invite1Props = Omit<React.ComponentProps<\"section\">, \"onSubmit\"> & {\n  /** The workspace's logo instead of its first letter: an image URL, an object with alt text and a dark-mode file, or your own element. A plain URL is described by the workspace name. */\n  logo?: MediaSource\n  /** Whether the invitation can still be accepted. */\n  status?: Invite1Status\n  /** The workspace being joined. */\n  workspace?: { name: string; members?: number; color?: \"chart-1\" | \"chart-2\" | \"chart-3\" | \"chart-4\" | \"chart-5\" }\n  /** Who sent the invitation. */\n  inviter?: { name: string; role?: string }\n  /** The role being offered. */\n  role?: string\n  /** The address the invitation was sent to; shown as the account being used. */\n  email?: string\n  /** What the role can do. Shown as a checked list. */\n  access?: string[]\n  /** Called by \"Accept invitation\". Throw to show an error; resolve to show the welcome screen. */\n  onAccept?: () => void | Promise<void>\n  /** Called by \"Decline\". Pass null to hide the button. */\n  onDecline?: (() => void | Promise<void>) | null\n  /** Called by \"Request a new invitation\" on an expired invitation. */\n  onRequestNew?: () => void | Promise<void>\n  /** Called by \"Not you? Switch account\". Pass null to hide it. */\n  onSwitchAccount?: (() => void) | null\n  /** Where \"Open workspace\" goes after accepting. */\n  continueHref?: string\n}\n\nconst tiles = { \"chart-1\": \"bg-chart-1\", \"chart-2\": \"bg-chart-2\", \"chart-3\": \"bg-chart-3\", \"chart-4\": \"bg-chart-4\", \"chart-5\": \"bg-chart-5\" }\n\nfunction Invite1({\n  status = \"pending\",\n  workspace = { name: \"Northwind Studio\", members: 24, color: \"chart-1\" },\n  inviter = { name: \"Maya Kim\", role: \"Head of Finance\" },\n  role = \"Editor\",\n  email = \"jordan@acme.com\",\n  access = [\"View and edit invoices and reports\", \"Comment and mention teammates\", \"Invite guests to specific items\"],\n  onAccept,\n  onDecline = () => {},\n  onRequestNew,\n  onSwitchAccount = () => {},\n  continueHref = \"#\",\n  className,\n  logo,\n  ...props\n}: Invite1Props) {\n  const [view, setView] = React.useState<\"idle\" | \"accepted\" | \"declined\" | \"requested\">(\"idle\")\n  const [busy, setBusy] = React.useState<\"accept\" | \"decline\" | \"request\" | null>(null)\n  const [failed, setFailed] = React.useState(false)\n  const headingRef = React.useRef<HTMLHeadingElement>(null)\n  const first = React.useRef(true)\n  React.useEffect(() => {\n    if (first.current) {\n      first.current = false\n      return\n    }\n    headingRef.current?.focus()\n  }, [view])\n\n  const tone = tiles[workspace.color ?? \"chart-1\"]\n  const initial = workspace.name[0]?.toUpperCase() ?? \"W\"\n  const expired = status === \"expired\"\n\n  async function run(kind: \"accept\" | \"decline\" | \"request\", fn: (() => void | Promise<void>) | null | undefined, next: typeof view) {\n    setBusy(kind)\n    setFailed(false)\n    try {\n      if (fn) await fn()\n      else await new Promise((r) => setTimeout(r, 700))\n      setView(next)\n    } catch {\n      setFailed(true)\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  const heading = \"text-2xl font-semibold tracking-[-0.03em] text-balance outline-none sm:text-3xl\"\n  return (\n    <section data-slot=\"invite-1\" className={cn(\"flex min-h-[38rem] items-center justify-center px-4 py-14\", className)} {...props}>\n      <div className=\"bg-card relative w-full max-w-md overflow-hidden rounded-3xl border p-7 text-center shadow-[0_30px_80px_-50px_rgb(0_0_0/0.4)] sm:p-9\">\n        <div aria-hidden=\"true\" className={cn(\"absolute -top-20 left-1/2 size-56 -translate-x-1/2 rounded-full opacity-20 blur-[70px]\", tone)} />\n\n        {view === \"accepted\" ? (\n          <div className=\"relative flex flex-col items-center py-4\">\n            <span aria-hidden=\"true\" className=\"bg-chart-2/15 flex size-14 items-center justify-center rounded-full\"><Check className=\"size-7\" /></span>\n            <h1 ref={headingRef} tabIndex={-1} className={cn(heading, \"mt-6\")}>Welcome to {workspace.name}</h1>\n            <p role=\"status\" className=\"text-muted-foreground mt-2 text-pretty\">You joined with the {role} role. {inviter.name} and the team are glad you’re here.</p>\n            <a href={continueHref} className={buttonVariants({ size: \"lg\", shape: \"pill\", className: \"mt-8 w-full\" })}>Open workspace <ArrowRight  className=\"rtl:rotate-180\"/></a>\n          </div>\n        ) : view === \"declined\" ? (\n          <div className=\"relative flex flex-col items-center py-4\">\n            <h1 ref={headingRef} tabIndex={-1} className={heading}>Invitation declined</h1>\n            <p role=\"status\" className=\"text-muted-foreground mt-2 text-pretty\">We’ve let {inviter.name} know. You can close this page.</p>\n            <Button variant=\"ghost\" className=\"mt-6\" onClick={() => setView(\"idle\")}>Changed your mind? Go back</Button>\n          </div>\n        ) : view === \"requested\" ? (\n          <div className=\"relative flex flex-col items-center py-4\">\n            <span aria-hidden=\"true\" className=\"bg-chart-2/15 flex size-14 items-center justify-center rounded-full\"><Check className=\"size-7\" /></span>\n            <h1 ref={headingRef} tabIndex={-1} className={cn(heading, \"mt-6\")}>Request sent</h1>\n            <p role=\"status\" className=\"text-muted-foreground mt-2 text-pretty\">We’ve asked {inviter.name} to send you a new invitation. Check {email} soon.</p>\n          </div>\n        ) : expired ? (\n          <div className=\"relative flex flex-col items-center\">\n            <span aria-hidden=\"true\" className=\"bg-muted flex size-14 items-center justify-center rounded-2xl\"><Clock className=\"size-7\" /></span>\n            <h1 ref={headingRef} tabIndex={-1} className={cn(heading, \"mt-6\")}>This invitation has expired</h1>\n            <p className=\"text-muted-foreground mt-2 text-pretty\">Invitations to {workspace.name} are valid for 7 days. Ask {inviter.name} to send a new one, or request it now.</p>\n            {failed && <p role=\"alert\" className=\"text-destructive mt-4 text-sm\">We couldn’t send the request. Please try again.</p>}\n            <Button size=\"lg\" shape=\"pill\" loading={busy === \"request\"} className=\"mt-8 w-full\" onClick={() => run(\"request\", onRequestNew, \"requested\")}>Request a new invitation</Button>\n          </div>\n        ) : (\n          <div className=\"relative\">\n            <div className=\"flex items-center justify-center\">\n              {logo ? <Media media={logo} alt={workspace.name} aspect=\"square\" fit=\"contain\" priority className=\"size-16 rounded-2xl shadow-lg\" /> : <span aria-hidden=\"true\" className={cn(\"text-background flex size-16 items-center justify-center rounded-2xl text-2xl font-bold shadow-lg\", tone)}>{initial}</span>}\n            </div>\n            <p className=\"text-muted-foreground mt-6 text-sm\"><span className=\"text-foreground font-medium\">{inviter.name}</span>{inviter.role ? ` (${inviter.role})` : \"\"} invited you to join</p>\n            <h1 ref={headingRef} tabIndex={-1} className={cn(heading, \"mt-1\")}>{workspace.name}</h1>\n            <div className=\"mt-4 flex flex-wrap items-center justify-center gap-2\">\n              <Badge variant=\"outline\">Role: {role}</Badge>\n              {workspace.members ? <span className=\"text-muted-foreground inline-flex items-center gap-1.5 text-sm\"><Users className=\"size-4\" aria-hidden=\"true\" />{workspace.members} members</span> : null}\n            </div>\n\n            {access.length > 0 && (\n              <ul className=\"bg-muted/40 mt-7 space-y-2.5 rounded-2xl border p-4 text-start text-sm\">\n                {access.map((a) => (\n                  <li key={a} className=\"flex items-start gap-2.5\">\n                    <Check className=\"text-chart-2 mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />\n                    {a}\n                  </li>\n                ))}\n              </ul>\n            )}\n\n            <p className=\"text-muted-foreground mt-6 text-sm\">\n              Joining as <span className=\"text-foreground font-medium break-all\">{email}</span>\n              {onSwitchAccount && (\n                <>\n                  {\" · \"}\n                  <button type=\"button\" onClick={onSwitchAccount} className=\"text-foreground focus-visible:ring-ring/50 rounded-sm font-medium underline underline-offset-4 outline-none focus-visible:ring-[3px]\">Not you?</button>\n                </>\n              )}\n            </p>\n\n            {failed && <p role=\"alert\" className=\"text-destructive mt-4 text-sm\">We couldn’t complete that. Please try again.</p>}\n            <div className=\"mt-6 grid gap-2\">\n              <Button size=\"lg\" shape=\"pill\" loading={busy === \"accept\"} disabled={busy === \"decline\"} onClick={() => run(\"accept\", onAccept, \"accepted\")}>Accept invitation</Button>\n              {onDecline && (\n                <Button variant=\"ghost\" loading={busy === \"decline\"} disabled={busy === \"accept\"} onClick={() => run(\"decline\", onDecline, \"declined\")}>Decline</Button>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    </section>\n  )\n}\n\nexport { Invite1, type Invite1Props, type Invite1Status }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/invite-1/invite-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "invite",
      "invitation",
      "team",
      "workspace",
      "accept",
      "join"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "Landing page for an emailed invitation. Pass workspace, inviter, role, email, access, onAccept / onDecline / onRequestNew (throw to show an error) and status ('pending' | 'expired').",
      "whenToUse": [
        "Team and workspace invitations",
        "Sharing links that need an explicit accept"
      ],
      "whenNotToUse": [
        "Sending invitations (use invite-members)"
      ],
      "composesWith": [
        "signup-1",
        "login-2",
        "onboarding-1",
        "verify-1"
      ],
      "a11y": [
        {
          "keys": "Accept / Decline",
          "action": "Focus moves to the new heading and the result is announced as a status"
        },
        {
          "keys": "Errors",
          "action": "A failed action is announced as an alert"
        }
      ],
      "customization": [
        "status: 'expired' shows the request-a-new-link flow",
        "onDecline / onSwitchAccount: null hides them",
        "access: [] hides the list"
      ]
    },
    "examples": [
      "invite-1-demo",
      "invite-1-expired"
    ],
    "url": "https://ui.ballmac.com/components/invite-1"
  },
  "categories": [
    "blocks",
    "onboarding"
  ],
  "type": "registry:block"
}