"use client"; import { CheckIcon, ChevronDownIcon, CircleAlertIcon, CopyIcon, LinkIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ClipboardEvent, type KeyboardEvent, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { Button } from "@/components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "@/components/ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; export type InviteRole = "admin" | "member" | "viewer"; export interface Invite { email: string; role: InviteRole; } export interface WorkspaceMember { name: string; email: string; role: InviteRole | "owner"; } export interface InviteTeammatesProps { workspace: string; description?: string; members?: WorkspaceMember[]; defaultInvites?: Invite[]; defaultRole?: InviteRole; inviteLink?: string; defaultLinkEnabled?: boolean; onSend?: (invites: Invite[]) => void | Promise; onLinkToggle?: (enabled: boolean) => void; onCancel?: () => void; className?: string; } const roles: { value: InviteRole; label: string; hint: string }[] = [ { value: "admin", label: "Admin", hint: "Manage billing and people" }, { value: "member", label: "Member", hint: "Create and edit everything" }, { value: "viewer", label: "Viewer", hint: "Read and comment" }, ]; const roleLabel = (r: InviteRole | "owner") => (r === "owner" ? "Owner" : roles.find((x) => x.value === r)?.label); const press = "transition-[scale,box-shadow,background-color] duration-150 ease-out active:scale-[0.97]"; const EMAIL = /^[^\s@<>()[\],;:"]+@[^\s@<>()[\],;:"]+\.[a-z]{2,}$/i; const avatarTones = [ "bg-info/16 text-info-foreground", "bg-success/16 text-success-foreground", "bg-warning/16 text-amber-800 dark:text-warning-foreground", "bg-destructive/12 text-destructive-foreground", "bg-muted-foreground/16 text-foreground", ]; function toneFor(text: string) { let h = 0; for (const c of text) h = (h * 31 + c.charCodeAt(0)) >>> 0; return avatarTones[h % avatarTones.length]; } function splitEmails(text: string): string[] { return text .split(/[\n,;]+/) .flatMap((segment) => { const angled = segment.match(/<([^>]+)>/); return angled ? [angled[1]] : segment.split(/\s+/); }) .map((part) => part.trim().replace(/^mailto:/i, "")) .filter(Boolean); } export function InviteTeammates({ workspace, description, members = [], defaultInvites = [], defaultRole = "member", inviteLink, defaultLinkEnabled = false, onSend, onLinkToggle, onCancel, className, }: InviteTeammatesProps) { const reduce = useReducedMotion(); const fieldId = useId(); const hintId = useId(); const [invites, setInvites] = useState(defaultInvites); const [draft, setDraft] = useState(""); const [role, setRole] = useState(defaultRole); const [armed, setArmed] = useState(false); const [flash, setFlash] = useState(null); const [sending, setSending] = useState(false); const [sent, setSent] = useState(null); const [linkOn, setLinkOn] = useState(defaultLinkEnabled); const [copied, setCopied] = useState(false); const inputRef = useRef(null); const fieldRef = useRef(null); const count = useRef(defaultInvites.length); const timers = useRef[]>([]); useEffect(() => () => timers.current.forEach(clearTimeout), []); const later = useCallback((fn: () => void, ms: number) => { timers.current.push(setTimeout(fn, ms)); }, []); useEffect(() => { if (invites.length > count.current) { fieldRef.current?.scrollTo({ top: fieldRef.current.scrollHeight, behavior: reduce ? "auto" : "smooth" }); } count.current = invites.length; }, [invites.length, reduce]); const memberEmails = useMemo(() => new Set(members.map((m) => m.email.toLowerCase())), [members]); const problem = useCallback( (email: string) => !EMAIL.test(email) ? "isn't a valid email" : memberEmails.has(email.toLowerCase()) ? "is already in this workspace" : null, [memberEmails], ); const add = (raw: string[]) => { if (!raw.length) return; setSent(null); setInvites((prev) => { const seen = new Set(prev.map((i) => i.email.toLowerCase())); const next = [...prev]; for (const email of raw) { const key = email.toLowerCase(); if (seen.has(key)) { setFlash(key); later(() => setFlash(null), 600); continue; } seen.add(key); next.push({ email, role }); } return next; }); }; const commitDraft = () => { add(splitEmails(draft)); setDraft(""); }; const remove = (email: string) => { setInvites((prev) => prev.filter((i) => i.email !== email)); inputRef.current?.focus(); }; const edit = (email: string) => { commitDraft(); setInvites((prev) => prev.filter((i) => i.email !== email)); setDraft(email); requestAnimationFrame(() => inputRef.current?.select()); }; const onKeyDown = (e: KeyboardEvent) => { if (["Enter", ",", ";", "Tab", " "].includes(e.key) && draft.trim()) { e.preventDefault(); commitDraft(); return; } if (e.key === "Enter" && !draft && valid.length && !invalid.length) { e.preventDefault(); void send(); return; } if (e.key === "Backspace" && !draft && invites.length) { e.preventDefault(); if (armed) { setInvites((prev) => prev.slice(0, -1)); setArmed(false); } else setArmed(true); return; } setArmed(false); }; const onPaste = (e: ClipboardEvent) => { const text = e.clipboardData.getData("text"); const parts = splitEmails(text); if (parts.length < 2 && !/[\n,;]/.test(text)) return; e.preventDefault(); add(parts); }; const valid = invites.filter((i) => !problem(i.email)); const invalid = invites.filter((i) => problem(i.email)); const firstInvalid = invalid[0]; const send = async () => { if (!valid.length || sending) return; setSending(true); await Promise.all([onSend?.(valid), new Promise((r) => later(() => r(null), 700))]); setInvites((prev) => prev.filter((i) => problem(i.email))); setSending(false); setSent(valid.length); later(() => setSent(null), 3200); }; const copy = async () => { if (!inviteLink) return; try { await navigator.clipboard.writeText(inviteLink); } catch {} setCopied(true); later(() => setCopied(false), 1600); }; const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 520, damping: 34 }; const height = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 360, damping: 34 }; return (

Invite to {workspace}

{description &&

{description}

}
{onCancel && ( )}
New invites join as Join as
{ if (e.target === e.currentTarget) inputRef.current?.focus(); }} ref={fieldRef} > {invites.map((invite, index) => { const issue = problem(invite.email); const isLast = index === invites.length - 1; return ( {issue ? ( ) : ( {invite.email[0]} )} {!issue && ( {roleLabel(invite.role)} setInvites((prev) => prev.map((i) => (i.email === invite.email ? { ...i, role: v as InviteRole } : i)), ) } value={invite.role} > {roles.map((r) => ( {r.label} {r.hint} ))} )} ); })} 0 || undefined} autoComplete="off" className="h-7 min-w-36 flex-1 bg-transparent px-1.5 text-sm outline-none placeholder:text-muted-foreground/72" id={fieldId} key="input" layout="position" onBlur={() => { if (draft.trim()) commitDraft(); setArmed(false); }} onChange={(e) => { setDraft(e.target.value); setArmed(false); }} onKeyDown={onKeyDown} onPaste={onPaste} placeholder={invites.length ? "Add more…" : "name@company.com, or paste a list"} ref={inputRef} spellCheck={false} transition={spring} type="text" value={draft} />
{firstInvalid ? ( {firstInvalid.email} {problem(firstInvalid.email)} {invalid.length > 1 && ` (+${invalid.length - 1} more)`} ) : sent ? ( {sent} {sent === 1 ? "invite" : "invites"} sent ) : ( Separate with commas, spaces or new lines. )} {invalid.length > 0 && ( )}
{members.length > 0 && (

People with access

{members.map((m) => (
{m.name .split(" ") .map((p) => p[0]) .join("") .slice(0, 2)}

{m.name}

{m.email}

{roleLabel(m.role)}
))}
)}

Anyone with the link joins as a viewer

{ setLinkOn(on); onLinkToggle?.(on); }} />
{linkOn && inviteLink && (
{inviteLink}
)}
{onCancel && ( )}
); } function RollingNumber({ value }: { value: number }) { const reduce = useReducedMotion(); return ( {value} ); }