{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "team-invitations",
  "title": "Team Invitations",
  "description": "Send and manage team invitations with email and link options.",
  "registryDependencies": [
    "badge",
    "button",
    "card",
    "checkbox",
    "dialog",
    "dropdown-menu",
    "empty",
    "field",
    "input-group",
    "select",
    "textarea"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/team/team-invitations.tsx",
      "content": "\"use client\";\n\nimport {\n  Check,\n  Copy,\n  Loader2,\n  Mail,\n  Plus,\n  RefreshCw,\n  Trash2,\n  X,\n} from \"lucide-react\";\nimport { useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Badge } from \"@/registry/new-york/ui/badge\";\nimport { Button } from \"@/registry/new-york/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/registry/new-york/ui/card\";\nimport { Checkbox } from \"@/registry/new-york/ui/checkbox\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/registry/new-york/ui/dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/registry/new-york/ui/dropdown-menu\";\nimport {\n  Empty,\n  EmptyDescription,\n  EmptyHeader,\n  EmptyMedia,\n  EmptyTitle,\n} from \"@/registry/new-york/ui/empty\";\nimport {\n  Field,\n  FieldContent,\n  FieldDescription,\n  FieldError,\n  FieldLabel,\n} from \"@/registry/new-york/ui/field\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/registry/new-york/ui/input-group\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/registry/new-york/ui/select\";\nimport { Textarea } from \"@/registry/new-york/ui/textarea\";\n\nexport interface TeamInvitation {\n  id: string;\n  email?: string;\n  link?: string;\n  role: \"admin\" | \"member\" | \"viewer\";\n  status: \"pending\" | \"accepted\" | \"expired\" | \"revoked\";\n  invitedBy: {\n    name: string;\n    email: string;\n  };\n  expiresAt?: Date;\n  createdAt: Date;\n  acceptedAt?: Date;\n}\n\nexport interface TeamInvitationsProps {\n  invitations?: TeamInvitation[];\n  onCreate?: (data: {\n    email?: string;\n    role: \"admin\" | \"member\" | \"viewer\";\n    expiresInDays?: number;\n    message?: string;\n  }) => Promise<TeamInvitation>;\n  onRevoke?: (invitationId: string) => Promise<void>;\n  onResend?: (invitationId: string) => Promise<void>;\n  onCopyLink?: (link: string) => Promise<void>;\n  className?: string;\n  allowDirectLinks?: boolean;\n}\n\nfunction formatDate(date: Date): string {\n  return new Intl.DateTimeFormat(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    year: \"numeric\",\n  }).format(date);\n}\n\nfunction formatTimeUntil(date: Date): string {\n  const now = Date.now();\n  const diff = date.getTime() - now;\n\n  if (diff <= 0) return \"Expired\";\n\n  const days = Math.floor(diff / (1000 * 60 * 60 * 24));\n  const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n\n  if (days > 0) return `${days} day${days !== 1 ? \"s\" : \"\"}`;\n  if (hours > 0) return `${hours} hour${hours !== 1 ? \"s\" : \"\"}`;\n  return \"Less than an hour\";\n}\n\nfunction getStatusBadge(status: TeamInvitation[\"status\"]) {\n  switch (status) {\n    case \"pending\":\n      return <Badge variant=\"secondary\">Pending</Badge>;\n    case \"accepted\":\n      return <Badge variant=\"default\">Accepted</Badge>;\n    case \"expired\":\n      return <Badge variant=\"outline\">Expired</Badge>;\n    case \"revoked\":\n      return <Badge variant=\"destructive\">Revoked</Badge>;\n  }\n}\n\nexport default function TeamInvitations({\n  invitations = [],\n  onCreate,\n  onRevoke,\n  onResend,\n  onCopyLink,\n  className,\n  allowDirectLinks = true,\n}: TeamInvitationsProps) {\n  const [createDialogOpen, setCreateDialogOpen] = useState(false);\n  const [isCreating, setIsCreating] = useState(false);\n  const [actionLoading, setActionLoading] = useState<string | null>(null);\n  const [copiedLink, setCopiedLink] = useState<string | null>(null);\n  const [errors, setErrors] = useState<Record<string, string>>({});\n  const [inviteData, setInviteData] = useState({\n    email: \"\",\n    role: \"member\" as \"admin\" | \"member\" | \"viewer\",\n    expiresInDays: 7,\n    message: \"\",\n    useDirectLink: false,\n  });\n\n  const pendingInvitations = invitations.filter((i) => i.status === \"pending\");\n  const acceptedInvitations = invitations.filter(\n    (i) => i.status === \"accepted\"\n  );\n\n  const handleCreate = async () => {\n    setErrors({});\n\n    if (!(inviteData.useDirectLink || inviteData.email.trim())) {\n      setErrors({ email: \"Email is required\" });\n      return;\n    }\n\n    if (!inviteData.useDirectLink) {\n      const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n      if (!emailRegex.test(inviteData.email)) {\n        setErrors({ email: \"Please enter a valid email address\" });\n        return;\n      }\n    }\n\n    setIsCreating(true);\n    try {\n      await onCreate?.({\n        email: inviteData.useDirectLink ? undefined : inviteData.email,\n        role: inviteData.role,\n        expiresInDays: inviteData.expiresInDays,\n        message: inviteData.message,\n      });\n      setInviteData({\n        email: \"\",\n        role: \"member\",\n        expiresInDays: 7,\n        message: \"\",\n        useDirectLink: false,\n      });\n      setCreateDialogOpen(false);\n    } catch (error) {\n      setErrors({\n        _general:\n          error instanceof Error\n            ? error.message\n            : \"Failed to create invitation\",\n      });\n    } finally {\n      setIsCreating(false);\n    }\n  };\n\n  const handleAction = async (\n    action: () => Promise<void>,\n    invitationId: string\n  ) => {\n    setActionLoading(invitationId);\n    try {\n      await action();\n    } finally {\n      setActionLoading(null);\n    }\n  };\n\n  const handleCopyLink = async (link: string, invitationId: string) => {\n    try {\n      await onCopyLink?.(link);\n      await navigator.clipboard.writeText(link);\n      setCopiedLink(invitationId);\n      setTimeout(() => setCopiedLink(null), 2000);\n    } catch {}\n  };\n\n  return (\n    <Card className={cn(\"w-full shadow-xs\", className)}>\n      <CardHeader>\n        <div className=\"flex flex-col flex-wrap gap-3 md:flex-row md:items-start md:justify-between\">\n          <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n            <CardTitle>Invitations</CardTitle>\n            <CardDescription>\n              {pendingInvitations.length} pending, {acceptedInvitations.length}{\" \"}\n              accepted\n            </CardDescription>\n          </div>\n          <Dialog onOpenChange={setCreateDialogOpen} open={createDialogOpen}>\n            <DialogTrigger asChild>\n              <Button className=\"w-full shrink-0 md:w-auto\" type=\"button\">\n                <Plus className=\"size-4\" />\n                Create Invitation\n              </Button>\n            </DialogTrigger>\n            <DialogContent className=\"md:max-w-md\">\n              <DialogHeader>\n                <DialogTitle>Create Invitation</DialogTitle>\n                <DialogDescription>\n                  Invite someone to join your team\n                </DialogDescription>\n              </DialogHeader>\n              <div className=\"flex flex-col gap-4\">\n                {errors._general && (\n                  <div className=\"rounded-lg border border-destructive/50 bg-destructive/10 p-3\">\n                    <p className=\"text-destructive text-sm\">\n                      {errors._general}\n                    </p>\n                  </div>\n                )}\n                {allowDirectLinks && (\n                  <Field>\n                    <div className=\"flex items-center gap-2\">\n                      <Checkbox\n                        checked={inviteData.useDirectLink}\n                        id=\"use-direct-link\"\n                        onCheckedChange={(checked: boolean) =>\n                          setInviteData((prev) => ({\n                            ...prev,\n                            useDirectLink: checked === true,\n                          }))\n                        }\n                      />\n                      <FieldLabel htmlFor=\"use-direct-link\">\n                        Generate direct link (no email required)\n                      </FieldLabel>\n                    </div>\n                  </Field>\n                )}\n                {!inviteData.useDirectLink && (\n                  <Field>\n                    <FieldLabel htmlFor=\"email\">\n                      Email <span className=\"text-destructive\">*</span>\n                    </FieldLabel>\n                    <FieldContent>\n                      <InputGroup>\n                        <InputGroupAddon>\n                          <Mail className=\"size-4\" />\n                        </InputGroupAddon>\n                        <InputGroupInput\n                          id=\"email\"\n                          onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                            setInviteData((prev) => ({\n                              ...prev,\n                              email: e.target.value,\n                            }))\n                          }\n                          placeholder=\"colleague@example.com\"\n                          type=\"email\"\n                          value={inviteData.email}\n                        />\n                      </InputGroup>\n                    </FieldContent>\n                    {errors.email && <FieldError>{errors.email}</FieldError>}\n                  </Field>\n                )}\n                <Field>\n                  <FieldLabel htmlFor=\"role\">Role</FieldLabel>\n                  <FieldContent>\n                    <Select\n                      onValueChange={(value: \"admin\" | \"member\" | \"viewer\") =>\n                        setInviteData((prev) => ({ ...prev, role: value }))\n                      }\n                      value={inviteData.role}\n                    >\n                      <SelectTrigger id=\"role\">\n                        <SelectValue />\n                      </SelectTrigger>\n                      <SelectContent>\n                        <SelectItem value=\"member\">Member</SelectItem>\n                        <SelectItem value=\"admin\">Admin</SelectItem>\n                        <SelectItem value=\"viewer\">Viewer</SelectItem>\n                      </SelectContent>\n                    </Select>\n                  </FieldContent>\n                  <FieldDescription>\n                    Members can create content, admins can manage the team\n                  </FieldDescription>\n                </Field>\n                <Field>\n                  <FieldLabel htmlFor=\"expires\">Expires in</FieldLabel>\n                  <FieldContent>\n                    <Select\n                      onValueChange={(value: string) =>\n                        setInviteData((prev) => ({\n                          ...prev,\n                          expiresInDays: Number.parseInt(value),\n                        }))\n                      }\n                      value={inviteData.expiresInDays.toString()}\n                    >\n                      <SelectTrigger id=\"expires\">\n                        <SelectValue />\n                      </SelectTrigger>\n                      <SelectContent>\n                        <SelectItem value=\"1\">1 day</SelectItem>\n                        <SelectItem value=\"3\">3 days</SelectItem>\n                        <SelectItem value=\"7\">7 days</SelectItem>\n                        <SelectItem value=\"14\">14 days</SelectItem>\n                        <SelectItem value=\"30\">30 days</SelectItem>\n                      </SelectContent>\n                    </Select>\n                  </FieldContent>\n                </Field>\n                <Field>\n                  <FieldLabel htmlFor=\"message\">Message (optional)</FieldLabel>\n                  <FieldContent>\n                    <Textarea\n                      id=\"message\"\n                      onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>\n                        setInviteData((prev) => ({\n                          ...prev,\n                          message: e.target.value,\n                        }))\n                      }\n                      placeholder=\"Add a personal message…\"\n                      rows={3}\n                      value={inviteData.message}\n                    />\n                  </FieldContent>\n                </Field>\n              </div>\n              <DialogFooter>\n                <Button\n                  onClick={() => setCreateDialogOpen(false)}\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  Cancel\n                </Button>\n                <Button\n                  aria-busy={isCreating}\n                  data-loading={isCreating}\n                  onClick={handleCreate}\n                  type=\"button\"\n                >\n                  {isCreating ? (\n                    <>\n                      <Loader2 className=\"size-4 animate-spin\" />\n                      Creating…\n                    </>\n                  ) : (\n                    <>\n                      <Mail className=\"size-4\" />\n                      Send Invitation\n                    </>\n                  )}\n                </Button>\n              </DialogFooter>\n            </DialogContent>\n          </Dialog>\n        </div>\n      </CardHeader>\n      <CardContent>\n        {invitations.length === 0 ? (\n          <Empty>\n            <EmptyHeader>\n              <EmptyMedia variant=\"icon\">\n                <Mail className=\"size-6\" />\n              </EmptyMedia>\n              <EmptyTitle>No invitations yet</EmptyTitle>\n              <EmptyDescription>Create one to get started.</EmptyDescription>\n            </EmptyHeader>\n          </Empty>\n        ) : (\n          <div className=\"flex flex-col gap-4\">\n            {invitations.map((invitation) => (\n              <div\n                className=\"flex flex-col gap-3 rounded-lg border bg-card p-4\"\n                key={invitation.id}\n              >\n                <div className=\"flex items-start justify-between gap-4\">\n                  <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n                    <div className=\"flex flex-wrap items-center gap-2\">\n                      {invitation.email ? (\n                        <span className=\"font-medium text-sm\">\n                          {invitation.email}\n                        </span>\n                      ) : (\n                        <span className=\"font-medium text-sm\">Direct Link</span>\n                      )}\n                      {getStatusBadge(invitation.status)}\n                      <Badge variant=\"outline\">{invitation.role}</Badge>\n                    </div>\n                    <div className=\"flex flex-wrap items-center gap-2 text-muted-foreground text-xs\">\n                      <span>\n                        Invited by {invitation.invitedBy.name} on{\" \"}\n                        {formatDate(invitation.createdAt)}\n                      </span>\n                      {invitation.expiresAt && (\n                        <>\n                          <span aria-hidden=\"true\">•</span>\n                          <span>\n                            Expires in {formatTimeUntil(invitation.expiresAt)}\n                          </span>\n                        </>\n                      )}\n                      {invitation.acceptedAt && (\n                        <>\n                          <span aria-hidden=\"true\">•</span>\n                          <span>\n                            Accepted {formatDate(invitation.acceptedAt)}\n                          </span>\n                        </>\n                      )}\n                    </div>\n                  </div>\n                  {invitation.status === \"pending\" && (\n                    <DropdownMenu>\n                      <DropdownMenuTrigger asChild>\n                        <Button\n                          aria-label=\"More options\"\n                          size=\"icon\"\n                          type=\"button\"\n                          variant=\"ghost\"\n                        >\n                          {actionLoading === invitation.id ? (\n                            <Loader2 className=\"size-4 animate-spin\" />\n                          ) : (\n                            <X className=\"size-4\" />\n                          )}\n                        </Button>\n                      </DropdownMenuTrigger>\n                      <DropdownMenuContent\n                        align=\"end\"\n                        collisionPadding={8}\n                        sideOffset={4}\n                      >\n                        {onResend && (\n                          <DropdownMenuItem\n                            onSelect={() =>\n                              handleAction(\n                                () => onResend(invitation.id),\n                                invitation.id\n                              )\n                            }\n                          >\n                            <RefreshCw className=\"size-4\" />\n                            Resend\n                          </DropdownMenuItem>\n                        )}\n                        {onRevoke && (\n                          <>\n                            <DropdownMenuSeparator />\n                            <DropdownMenuItem\n                              onSelect={() =>\n                                handleAction(\n                                  () => onRevoke(invitation.id),\n                                  invitation.id\n                                )\n                              }\n                              variant=\"destructive\"\n                            >\n                              <Trash2 className=\"size-4\" />\n                              Revoke\n                            </DropdownMenuItem>\n                          </>\n                        )}\n                      </DropdownMenuContent>\n                    </DropdownMenu>\n                  )}\n                </div>\n                {invitation.link && (\n                  <div className=\"flex items-center gap-2 rounded-md border bg-muted/30 p-2\">\n                    <code className=\"wrap-break-word truncate text-wrap break-all text-xs\">\n                      {invitation.link}\n                    </code>\n                    <Button\n                      aria-label=\"Copy link\"\n                      onClick={() =>\n                        handleCopyLink(invitation.link!, invitation.id)\n                      }\n                      size=\"icon-sm\"\n                      type=\"button\"\n                      variant=\"ghost\"\n                    >\n                      {copiedLink === invitation.id ? (\n                        <Check className=\"size-4\" />\n                      ) : (\n                        <Copy className=\"size-4\" />\n                      )}\n                    </Button>\n                  </div>\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "block",
    "team"
  ],
  "type": "registry:ui"
}