{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "invite-members",
  "title": "Invite Members",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-react/blocks/invite-members/InviteMembers.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { RotateCw, Send, UserPlus, X } from 'lucide-react'\nimport type { BadgeVariants } from '@/components/ui/badge'\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { SectionCard } from '@/components/ui/section-card'\n\nexport type InviteRole = 'admin' | 'editor' | 'viewer'\nexport type InviteStatus = 'pending' | 'accepted' | 'expired'\n\nexport interface InviteEntry {\n  id: string\n  email: string\n  role: InviteRole\n  status: InviteStatus\n  sentAt: Date\n}\n\nexport interface InviteMembersProps {\n  title?: string\n  description?: string\n  seatLimit?: number\n  /** Seats taken by existing members (incl. already-accepted invites). */\n  memberSeats?: number\n  initialInvites?: InviteEntry[]\n  /** Pre-fills the compose input; an invalid value surfaces the validation error state. */\n  initialDraft?: string\n  /** Emails staged as queued chips before sending (as after a multi-address paste). */\n  initialQueue?: string[]\n  className?: string\n}\n\n/**\n * Self-contained team invite surface. Stub data lives here — swap\n * `DEFAULT_INVITES` for your data source after installing.\n */\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nconst SEATS_PILL =\n  'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap tabular-nums'\nconst SEATS_PILL_WARN = 'border-warning/30 bg-warning/10 text-warning'\nconst SEATS_PILL_OK = 'border-border bg-muted/40 text-muted-foreground'\nconst CHIP =\n  'border-border bg-muted/40 text-foreground inline-flex max-w-full items-center gap-1 rounded-full border py-0.5 pr-1 pl-2.5 ' +\n  'text-xs font-medium'\nconst CHIP_REMOVE =\n  'hover:bg-background inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full ' +\n  'transition-colors'\n\nconst ROLE_LABEL: Record<InviteRole, string> = { admin: 'Admin', editor: 'Editor', viewer: 'Viewer' }\nconst ROLE_VARIANT: Record<InviteRole, BadgeVariants['variant']> = {\n  admin: 'default',\n  editor: 'info',\n  viewer: 'outline',\n}\nconst STATUS_LABEL: Record<InviteStatus, string> = { pending: 'Pending', accepted: 'Accepted', expired: 'Expired' }\nconst STATUS_VARIANT: Record<InviteStatus, BadgeVariants['variant']> = {\n  pending: 'warning',\n  accepted: 'success',\n  expired: 'secondary',\n}\n\nconst now = Date.now()\nconst minutesAgo = (m: number) => new Date(now - m * 60_000)\n\nconst DEFAULT_INVITES: InviteEntry[] = [\n  { id: 'inv-1', email: 'priya@acme.com', role: 'admin', status: 'pending', sentAt: minutesAgo(120) },\n  { id: 'inv-2', email: 'marcus@acme.com', role: 'editor', status: 'accepted', sentAt: minutesAgo(60 * 24 * 3) },\n  { id: 'inv-3', email: 'devon@acme.com', role: 'viewer', status: 'pending', sentAt: minutesAgo(60 * 26) },\n  { id: 'inv-4', email: 'sarah@acme.com', role: 'editor', status: 'expired', sentAt: minutesAgo(60 * 24 * 12) },\n  { id: 'inv-5', email: 'lena@acme.com', role: 'admin', status: 'accepted', sentAt: minutesAgo(60 * 24 * 7) },\n]\n\nfunction tokenize(text: string): string[] {\n  return text\n    .split(/[\\s,;]+/)\n    .map((token) => token.trim())\n    .filter(Boolean)\n}\n\nfunction mergeQueue(prev: string[], emails: string[]): string[] {\n  const next = [...prev]\n  for (const email of emails) {\n    if (!next.some((queued) => queued.toLowerCase() === email.toLowerCase())) next.push(email)\n  }\n  return next\n}\n\nfunction invalidMessage(invalid: string[]): string {\n  return invalid.length === 1\n    ? `“${invalid[0]}” is not a valid email address.`\n    : `${invalid.length} entries are not valid email addresses: ${invalid.join(', ')}`\n}\n\nexport function InviteMembers({\n  title,\n  description,\n  seatLimit = 20,\n  memberSeats = 16,\n  initialInvites,\n  initialDraft,\n  initialQueue,\n  className,\n}: InviteMembersProps) {\n  const [invites, setInvites] = React.useState<InviteEntry[]>(() =>\n    (initialInvites ?? DEFAULT_INVITES).map((invite) => ({ ...invite })),\n  )\n  const [draft, setDraft] = React.useState(initialDraft ?? '')\n  const [queue, setQueue] = React.useState<string[]>(initialQueue ?? [])\n  const [role, setRole] = React.useState<InviteRole>('editor')\n  const [error, setError] = React.useState<string | null>(\n    initialDraft && !EMAIL_RE.test(initialDraft.trim())\n      ? `“${initialDraft.trim()}” is not a valid email address.`\n      : null,\n  )\n  const [confirmId, setConfirmId] = React.useState<string | null>(null)\n  const confirmTimer = React.useRef<number | undefined>(undefined)\n\n  React.useEffect(() => () => window.clearTimeout(confirmTimer.current), [])\n\n  const seatsUsed = memberSeats + invites.filter((invite) => invite.status === 'pending').length\n  const nearLimit = seatsUsed / seatLimit >= 0.9\n  const canSend = draft.trim().length > 0 || queue.length > 0\n\n  function addInvites(emails: string[]) {\n    const stamp = Date.now()\n    const next: InviteEntry[] = emails.map((email, i) => ({\n      id: `inv-${stamp}-${i}`,\n      email,\n      role,\n      status: 'pending',\n      sentAt: new Date(),\n    }))\n    setInvites((prev) => [...next, ...prev])\n  }\n\n  /** Enter / Send invite: a lone address sends straight away, several queue as chips. */\n  function submit() {\n    const tokens = tokenize(draft)\n    const valid = tokens.filter((token) => EMAIL_RE.test(token))\n    const invalid = tokens.filter((token) => !EMAIL_RE.test(token))\n\n    if (tokens.length === 1 && valid.length === 1) {\n      addInvites(valid)\n      setDraft('')\n      setError(null)\n      return\n    }\n\n    if (invalid.length) {\n      setError(invalidMessage(invalid))\n      setDraft(invalid.join(', '))\n      setQueue((prev) => mergeQueue(prev, valid))\n      return\n    }\n\n    // Nothing invalid: stage everything (new addresses plus queued chips) and flush in one pass.\n    const merged = mergeQueue(queue, valid)\n    if (merged.length) addInvites(merged)\n    setError(null)\n    setDraft('')\n    setQueue([])\n  }\n\n  /** A paste containing separators queues every valid address as a chip. */\n  function onPaste(event: React.ClipboardEvent<HTMLInputElement>) {\n    const text = event.clipboardData.getData('text')\n    if (!text || !/[\\s,;]/.test(text.trim())) return\n    event.preventDefault()\n    const tokens = tokenize(text)\n    setQueue((prev) =>\n      mergeQueue(\n        prev,\n        tokens.filter((token) => EMAIL_RE.test(token)),\n      ),\n    )\n    const invalid = tokens.filter((token) => !EMAIL_RE.test(token))\n    setError(invalid.length ? invalidMessage(invalid) : null)\n  }\n\n  function removeQueued(email: string) {\n    setQueue((prev) => prev.filter((queued) => queued !== email))\n  }\n\n  function resend(invite: InviteEntry) {\n    setInvites((prev) =>\n      prev.map((candidate) =>\n        candidate.id === invite.id ? { ...candidate, sentAt: new Date(), status: 'pending' as const } : candidate,\n      ),\n    )\n    cancelConfirm()\n  }\n\n  function askRevoke(id: string) {\n    setConfirmId(id)\n    window.clearTimeout(confirmTimer.current)\n    confirmTimer.current = window.setTimeout(() => setConfirmId(null), 4000)\n  }\n\n  function revoke(invite: InviteEntry) {\n    setInvites((prev) => prev.filter((candidate) => candidate.id !== invite.id))\n    cancelConfirm()\n  }\n\n  function cancelConfirm() {\n    window.clearTimeout(confirmTimer.current)\n    setConfirmId(null)\n  }\n\n  function initialsFor(email: string): string {\n    const local = email.split('@')[0] ?? ''\n    const parts = local.split(/[._-]+/).filter(Boolean)\n    const first = parts[0] ?? ''\n    const second = parts[1] ?? ''\n    if (first && second) return (first.charAt(0) + second.charAt(0)).toUpperCase()\n    return local.slice(0, 2).toUpperCase()\n  }\n\n  function sentAgo(date: Date): string {\n    const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000))\n    if (seconds < 60) return 'just now'\n    const minutes = Math.floor(seconds / 60)\n    if (minutes < 60) return `${minutes}m ago`\n    const hours = Math.floor(minutes / 60)\n    if (hours < 24) return `${hours}h ago`\n    const days = Math.floor(hours / 24)\n    if (days < 7) return `${days}d ago`\n    return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n  }\n\n  return (\n    <SectionCard\n      title={title ?? 'Invite members'}\n      description={description ?? 'Bring teammates into your workspace. Invites expire after 14 days.'}\n      className={className}\n      headerAction={\n        <span\n          data-slot=\"invite-members-seats\"\n          className={`${SEATS_PILL} ${nearLimit ? SEATS_PILL_WARN : SEATS_PILL_OK}`}\n        >\n          {seatsUsed} of {seatLimit} seats used\n        </span>\n      }\n    >\n      <div data-slot=\"invite-members-compose\" className=\"space-y-2\">\n        <div className=\"flex flex-col gap-2 sm:flex-row sm:items-start\">\n          <div className=\"min-w-0 flex-1 space-y-1.5\">\n            <Input\n              value={draft}\n              onChange={(event) => setDraft(event.target.value)}\n              type=\"text\"\n              placeholder=\"name@company.com — paste a list to queue several at once\"\n              status={error ? 'error' : undefined}\n              aria-describedby={error ? 'invite-members-email-error' : undefined}\n              aria-label=\"Email addresses to invite\"\n              onKeyDown={(event) => {\n                if (event.key === 'Enter') {\n                  event.preventDefault()\n                  submit()\n                }\n              }}\n              onPaste={onPaste}\n            />\n            {error && (\n              <p id=\"invite-members-email-error\" className=\"text-destructive text-xs font-medium\">\n                {error}\n              </p>\n            )}\n          </div>\n          <Select value={role} onValueChange={(value) => setRole(value as InviteRole)}>\n            <SelectTrigger className=\"w-full sm:w-32\" aria-label=\"Invite as\">\n              <SelectValue placeholder=\"Role\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"admin\">Admin</SelectItem>\n              <SelectItem value=\"editor\">Editor</SelectItem>\n              <SelectItem value=\"viewer\">Viewer</SelectItem>\n            </SelectContent>\n          </Select>\n          <Button className=\"shrink-0\" disabled={!canSend} onClick={submit}>\n            <Send />\n            Send invite\n          </Button>\n        </div>\n\n        {queue.length > 0 && (\n          <div className=\"flex flex-wrap items-center gap-1.5\">\n            {queue.map((email) => (\n              <span key={email} className={CHIP}>\n                <span className=\"truncate\">{email}</span>\n                <button\n                  type=\"button\"\n                  className={CHIP_REMOVE}\n                  aria-label={`Remove ${email} from queue`}\n                  onClick={() => removeQueued(email)}\n                >\n                  <X className=\"size-3\" />\n                </button>\n              </span>\n            ))}\n            <span className=\"text-muted-foreground text-xs\">{queue.length} queued — pick a role and send</span>\n          </div>\n        )}\n      </div>\n\n      <div data-slot=\"invite-members-list\" className=\"mt-5\">\n        <div className=\"mb-1 flex items-center justify-between\">\n          <h3 className=\"text-muted-foreground text-xs font-semibold tracking-widest uppercase\">Invites</h3>\n          <span className=\"text-muted-foreground text-xs tabular-nums\">{invites.length} total</span>\n        </div>\n\n        {invites.length > 0 ? (\n          <ul className=\"divide-y\">\n            {invites.map((invite) => (\n              <li key={invite.id} data-slot=\"invite-members-row\" className=\"flex flex-wrap items-center gap-3 py-3\">\n                <Avatar className=\"size-8 shrink-0\">\n                  <AvatarFallback className=\"text-xs\">{initialsFor(invite.email)}</AvatarFallback>\n                </Avatar>\n                <div className=\"min-w-[10rem] flex-1\">\n                  <p className=\"truncate text-sm font-medium\">{invite.email}</p>\n                  <p className=\"text-muted-foreground text-xs\">Sent {sentAgo(invite.sentAt)}</p>\n                </div>\n                <Badge variant={ROLE_VARIANT[invite.role]} className=\"shrink-0\">\n                  {ROLE_LABEL[invite.role]}\n                </Badge>\n                <Badge variant={STATUS_VARIANT[invite.status]} className=\"shrink-0\">\n                  {STATUS_LABEL[invite.status]}\n                </Badge>\n                <div className=\"flex shrink-0 items-center gap-0.5\">\n                  {invite.status !== 'accepted' && (\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon-sm\"\n                      title=\"Resend\"\n                      aria-label={`Resend invite to ${invite.email}`}\n                      onClick={() => resend(invite)}\n                    >\n                      <RotateCw />\n                    </Button>\n                  )}\n                  {confirmId !== invite.id ? (\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon-sm\"\n                      title=\"Revoke\"\n                      aria-label={`Revoke invite for ${invite.email}`}\n                      onClick={() => askRevoke(invite.id)}\n                    >\n                      <X />\n                    </Button>\n                  ) : (\n                    <Button\n                      variant=\"destructive\"\n                      size=\"xs\"\n                      onClick={() => revoke(invite)}\n                      onKeyDown={(event) => event.key === 'Escape' && cancelConfirm()}\n                    >\n                      Confirm?\n                    </Button>\n                  )}\n                </div>\n              </li>\n            ))}\n          </ul>\n        ) : (\n          <div className=\"flex flex-col items-center rounded-lg border border-dashed px-6 py-10 text-center\">\n            <div className=\"bg-muted mb-3 rounded-full p-3\">\n              <UserPlus className=\"text-muted-foreground size-5\" />\n            </div>\n            <p className=\"text-sm font-medium\">No invites yet</p>\n            <p className=\"text-muted-foreground mt-0.5 text-xs\">Send an invite above and it will show up here.</p>\n          </div>\n        )}\n      </div>\n    </SectionCard>\n  )\n}\n",
      "type": "registry:block",
      "target": "~/components/blocks/InviteMembers.tsx"
    }
  ],
  "dependencies": [
    "lucide-react"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/react/avatar.json",
    "https://uipkge.dev/r/react/badge.json",
    "https://uipkge.dev/r/react/button.json",
    "https://uipkge.dev/r/react/input.json",
    "https://uipkge.dev/r/react/select.json",
    "https://uipkge.dev/r/react/section-card.json"
  ],
  "description": "Team invite surface in a SectionCard. Header carries a live seats counter tinting warning at ≥90% capacity. Compose takes one email or a comma/space-separated paste — several addresses queue as removable chips first, plus an Admin/Editor/Viewer role Select and basic validation with a destructive error state. Rows show avatar initials, email, role/status badges (pending/accepted/expired), sent-ago timestamps, Resend (re-activates expired invites) and Revoke behind a two-click inline confirm. Self-contained stateful demo — swap DEFAULT_INVITES for your data source.",
  "categories": [
    "security",
    "dashboard",
    "communication"
  ]
}