{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "invite-members",
  "title": "Invite Members",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-vue/blocks/invite-members/InviteMembers.vue",
      "content": "<script lang=\"ts\">\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</script>\n\n<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { RotateCw, Send, UserPlus, X } from 'lucide-vue-next'\nimport type { BadgeVariants } from '@/components/ui/badge'\nimport { cn } from '@/lib/utils'\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\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 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'\nconst SEATS_PILL_WARN = 'border-warning/30 bg-warning/10 text-warning'\nconst SEATS_PILL_OK = 'border-border bg-muted/40 text-muted-foreground'\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\nconst props = defineProps<{\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  class?: string\n}>()\n\nconst invites = ref<InviteEntry[]>((props.initialInvites ?? DEFAULT_INVITES).map((invite) => ({ ...invite })))\nconst draft = ref(props.initialDraft ?? '')\nconst queue = ref<string[]>([...(props.initialQueue ?? [])])\nconst role = ref<InviteRole>('editor')\nconst error = ref<string | null>(\n  props.initialDraft && !EMAIL_RE.test(props.initialDraft.trim())\n    ? `“${props.initialDraft.trim()}” is not a valid email address.`\n    : null,\n)\nconst confirmId = ref<string | null>(null)\nlet confirmTimer: number | undefined\n\nconst seatLimit = computed(() => props.seatLimit ?? 20)\nconst seatsUsed = computed(\n  () => (props.memberSeats ?? 16) + invites.value.filter((invite) => invite.status === 'pending').length,\n)\nconst nearLimit = computed(() => seatsUsed.value / seatLimit.value >= 0.9)\nconst seatsPillClass = computed(() => cn(SEATS_PILL, nearLimit.value ? SEATS_PILL_WARN : SEATS_PILL_OK))\nconst canSend = computed(() => draft.value.trim().length > 0 || queue.value.length > 0)\n\nfunction tokenize(text: string): string[] {\n  return text\n    .split(/[\\s,;]+/)\n    .map((token) => token.trim())\n    .filter(Boolean)\n}\n\nfunction enqueue(emails: string[]) {\n  for (const email of emails) {\n    if (!queue.value.some((queued) => queued.toLowerCase() === email.toLowerCase())) queue.value.push(email)\n  }\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\n/** Enter / Send invite: a lone address sends straight away, several queue as chips. */\nfunction submit() {\n  const tokens = tokenize(draft.value)\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    draft.value = ''\n    error.value = null\n    return\n  }\n\n  enqueue(valid)\n  if (invalid.length) {\n    error.value = invalidMessage(invalid)\n    draft.value = invalid.join(', ')\n  } else {\n    error.value = null\n    draft.value = ''\n    sendQueued()\n  }\n}\n\n/** A paste containing separators queues every valid address as a chip. */\nfunction onPaste(event: ClipboardEvent) {\n  const text = event.clipboardData?.getData('text') ?? ''\n  if (!text || !/[\\s,;]/.test(text.trim())) return\n  event.preventDefault()\n  const tokens = tokenize(text)\n  enqueue(tokens.filter((token) => EMAIL_RE.test(token)))\n  const invalid = tokens.filter((token) => !EMAIL_RE.test(token))\n  error.value = invalid.length ? invalidMessage(invalid) : null\n}\n\nfunction sendQueued() {\n  if (!queue.value.length) return\n  addInvites(queue.value)\n  queue.value = []\n}\n\nfunction 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: role.value,\n    status: 'pending',\n    sentAt: new Date(),\n  }))\n  invites.value = [...next, ...invites.value]\n}\n\nfunction removeQueued(email: string) {\n  queue.value = queue.value.filter((queued) => queued !== email)\n}\n\nfunction resend(invite: InviteEntry) {\n  invite.sentAt = new Date()\n  invite.status = 'pending'\n  cancelConfirm()\n}\n\nfunction askRevoke(id: string) {\n  confirmId.value = id\n  window.clearTimeout(confirmTimer)\n  confirmTimer = window.setTimeout(() => (confirmId.value = null), 4000)\n}\n\nfunction revoke(invite: InviteEntry) {\n  invites.value = invites.value.filter((candidate) => candidate.id !== invite.id)\n  cancelConfirm()\n}\n\nfunction cancelConfirm() {\n  window.clearTimeout(confirmTimer)\n  confirmId.value = null\n}\n\nfunction 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\nfunction 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</script>\n\n<template>\n  <SectionCard\n    :title=\"title ?? 'Invite members'\"\n    :description=\"description ?? 'Bring teammates into your workspace. Invites expire after 14 days.'\"\n    :class=\"$props.class\"\n  >\n    <template #header-action>\n      <span data-slot=\"invite-members-seats\" :class=\"seatsPillClass\">\n        {{ seatsUsed }} of {{ seatLimit }} seats used\n      </span>\n    </template>\n\n    <div data-slot=\"invite-members-compose\" class=\"space-y-2\">\n      <div class=\"flex flex-col gap-2 sm:flex-row sm:items-start\">\n        <div class=\"min-w-0 flex-1 space-y-1.5\">\n          <Input\n            v-model=\"draft\"\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            @keydown.enter.prevent=\"submit\"\n            @paste=\"onPaste\"\n          />\n          <p v-if=\"error\" id=\"invite-members-email-error\" class=\"text-destructive text-xs font-medium\">{{ error }}</p>\n        </div>\n        <Select v-model=\"role\">\n          <SelectTrigger class=\"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 class=\"shrink-0\" :disabled=\"!canSend\" @click=\"submit\">\n          <Send />\n          Send invite\n        </Button>\n      </div>\n\n      <div v-if=\"queue.length\" class=\"flex flex-wrap items-center gap-1.5\">\n        <span v-for=\"email in queue\" :key=\"email\" :class=\"CHIP\">\n          <span class=\"truncate\">{{ email }}</span>\n          <button\n            type=\"button\"\n            :class=\"CHIP_REMOVE\"\n            :aria-label=\"`Remove ${email} from queue`\"\n            @click=\"removeQueued(email)\"\n          >\n            <X class=\"size-3\" />\n          </button>\n        </span>\n        <span class=\"text-muted-foreground text-xs\">{{ queue.length }} queued — pick a role and send</span>\n      </div>\n    </div>\n\n    <div data-slot=\"invite-members-list\" class=\"mt-5\">\n      <div class=\"mb-1 flex items-center justify-between\">\n        <h3 class=\"text-muted-foreground text-xs font-semibold tracking-widest uppercase\">Invites</h3>\n        <span class=\"text-muted-foreground text-xs tabular-nums\">{{ invites.length }} total</span>\n      </div>\n\n      <ul v-if=\"invites.length\" class=\"divide-y\">\n        <li\n          v-for=\"invite in invites\"\n          :key=\"invite.id\"\n          data-slot=\"invite-members-row\"\n          class=\"flex flex-wrap items-center gap-3 py-3\"\n        >\n          <Avatar class=\"size-8 shrink-0\">\n            <AvatarFallback class=\"text-xs\">{{ initialsFor(invite.email) }}</AvatarFallback>\n          </Avatar>\n          <div class=\"min-w-[10rem] flex-1\">\n            <p class=\"truncate text-sm font-medium\">{{ invite.email }}</p>\n            <p class=\"text-muted-foreground text-xs\">Sent {{ sentAgo(invite.sentAt) }}</p>\n          </div>\n          <Badge :variant=\"ROLE_VARIANT[invite.role]\" class=\"shrink-0\">{{ ROLE_LABEL[invite.role] }}</Badge>\n          <Badge :variant=\"STATUS_VARIANT[invite.status]\" class=\"shrink-0\">{{ STATUS_LABEL[invite.status] }}</Badge>\n          <div class=\"flex shrink-0 items-center gap-0.5\">\n            <Button\n              v-if=\"invite.status !== 'accepted'\"\n              variant=\"ghost\"\n              size=\"icon-sm\"\n              title=\"Resend\"\n              :aria-label=\"`Resend invite to ${invite.email}`\"\n              @click=\"resend(invite)\"\n            >\n              <RotateCw />\n            </Button>\n            <Button\n              v-if=\"confirmId !== invite.id\"\n              variant=\"ghost\"\n              size=\"icon-sm\"\n              title=\"Revoke\"\n              :aria-label=\"`Revoke invite for ${invite.email}`\"\n              @click=\"askRevoke(invite.id)\"\n            >\n              <X />\n            </Button>\n            <Button v-else variant=\"destructive\" size=\"xs\" @click=\"revoke(invite)\" @keydown.escape=\"cancelConfirm\">\n              Confirm?\n            </Button>\n          </div>\n        </li>\n      </ul>\n\n      <div v-else class=\"flex flex-col items-center rounded-lg border border-dashed px-6 py-10 text-center\">\n        <div class=\"bg-muted mb-3 rounded-full p-3\">\n          <UserPlus class=\"text-muted-foreground size-5\" />\n        </div>\n        <p class=\"text-sm font-medium\">No invites yet</p>\n        <p class=\"text-muted-foreground mt-0.5 text-xs\">Send an invite above and it will show up here.</p>\n      </div>\n    </div>\n  </SectionCard>\n</template>\n",
      "type": "registry:block",
      "target": "~/app/components/blocks/InviteMembers.vue"
    }
  ],
  "dependencies": [
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/avatar.json",
    "https://uipkge.dev/r/vue/badge.json",
    "https://uipkge.dev/r/vue/button.json",
    "https://uipkge.dev/r/vue/input.json",
    "https://uipkge.dev/r/vue/select.json",
    "https://uipkge.dev/r/vue/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"
  ]
}