{"version":1,"id":"invite-team","framework":"next","kind":"block","files":[{"path":"src/blocks/gfa/invite-team.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { Check, ChevronDown, Copy, Link2, X } from \"lucide-react\";\nimport { SPRING_PANEL, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InviteRole = \"admin\" | \"member\" | \"viewer\";\n\nexport type PendingInvite = {\n  id: string;\n  email: string;\n  role: InviteRole;\n};\n\nexport type InviteTeamProps = {\n  inviteLink?: string;\n  initialPending?: PendingInvite[];\n  className?: string;\n  onInvite?: (emails: string[], role: InviteRole) => void;\n  onClose?: () => void;\n};\n\nconst ROLES: { id: InviteRole; label: string; hint: string }[] = [\n  { id: \"admin\", label: \"Admin\", hint: \"Can manage members and settings\" },\n  { id: \"member\", label: \"Member\", hint: \"Can use the workspace\" },\n  { id: \"viewer\", label: \"Viewer\", hint: \"Can view, not edit\" },\n];\n\nconst DEFAULT_PENDING: PendingInvite[] = [\n  { id: \"1\", email: \"ada@example.com\", role: \"member\" },\n  { id: \"2\", email: \"lin@studio.dev\", role: \"viewer\" },\n];\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nfunction roleMeta(role: InviteRole) {\n  return ROLES.find((r) => r.id === role) ?? ROLES[1]!;\n}\n\nfunction initials(email: string) {\n  const local = email.split(\"@\")[0] ?? \"?\";\n  const parts = local.split(/[._-]+/).filter(Boolean);\n  if (parts.length >= 2) {\n    return `${parts[0]![0] ?? \"\"}${parts[1]![0] ?? \"\"}`.toUpperCase();\n  }\n  return local.slice(0, 2).toUpperCase();\n}\n\nexport function InviteTeam({\n  inviteLink = \"https://gfa.app/invite/team-7k2m\",\n  initialPending = DEFAULT_PENDING,\n  className,\n  onInvite,\n  onClose,\n}: InviteTeamProps) {\n  const reduce = useReducedMotion();\n  const roleListId = useId();\n  const roleRootRef = useRef<HTMLDivElement>(null);\n  const [emails, setEmails] = useState<string[]>([]);\n  const [draft, setDraft] = useState(\"\");\n  const [role, setRole] = useState<InviteRole>(\"member\");\n  const [roleOpen, setRoleOpen] = useState(false);\n  const [pending, setPending] = useState<PendingInvite[]>(initialPending);\n  const [error, setError] = useState<string | null>(null);\n  const [copied, setCopied] = useState(false);\n  const [sentFlash, setSentFlash] = useState(false);\n\n  useEffect(() => {\n    if (!roleOpen) return;\n    const onDoc = (e: MouseEvent) => {\n      if (roleRootRef.current && !roleRootRef.current.contains(e.target as Node)) {\n        setRoleOpen(false);\n      }\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setRoleOpen(false);\n    };\n    document.addEventListener(\"mousedown\", onDoc);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"mousedown\", onDoc);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [roleOpen]);\n\n  const commitDraft = (raw: string) => {\n    const next = raw\n      .split(/[,;\\s]+/)\n      .map((s) => s.trim().toLowerCase())\n      .filter(Boolean);\n    if (!next.length) return;\n\n    const invalid = next.find((e) => !EMAIL_RE.test(e));\n    if (invalid) {\n      setError(`“${invalid}” isn’t a valid email.`);\n      return;\n    }\n\n    setEmails((prev) => {\n      const seen = new Set(prev);\n      const added: string[] = [];\n      for (const e of next) {\n        if (seen.has(e)) continue;\n        seen.add(e);\n        added.push(e);\n      }\n      return [...prev, ...added];\n    });\n    setDraft(\"\");\n    setError(null);\n  };\n\n  const removeEmail = (email: string) => {\n    setEmails((prev) => prev.filter((e) => e !== email));\n  };\n\n  const sendInvites = () => {\n    const batch = [...emails];\n    if (draft.trim()) {\n      if (!EMAIL_RE.test(draft.trim().toLowerCase())) {\n        setError(`“${draft.trim()}” isn’t a valid email.`);\n        return;\n      }\n      batch.push(draft.trim().toLowerCase());\n    }\n    const unique = [...new Set(batch)];\n    if (!unique.length) {\n      setError(\"Add at least one email.\");\n      return;\n    }\n\n    onInvite?.(unique, role);\n    setPending((prev) => [\n      ...unique.map((email, i) => ({\n        id: `${Date.now()}-${i}`,\n        email,\n        role,\n      })),\n      ...prev,\n    ]);\n    setEmails([]);\n    setDraft(\"\");\n    setError(null);\n    setSentFlash(true);\n    window.setTimeout(() => setSentFlash(false), 1600);\n  };\n\n  const copyLink = async () => {\n    try {\n      await navigator.clipboard.writeText(inviteLink);\n    } catch {\n      /* demo */\n    }\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1600);\n  };\n\n  const canSend = emails.length > 0 || draft.trim().length > 0;\n  const activeRole = roleMeta(role);\n\n  return (\n    <motion.section\n      data-slot=\"invite-team\"\n      className={cn(\n        \"relative w-full max-w-[440px] overflow-hidden rounded-2xl\",\n        \"border border-black/[0.08] bg-white\",\n        \"shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_8px_24px_rgba(0,0,0,0.08)]\",\n        \"dark:border-white/[0.1] dark:bg-[#212121] dark:shadow-[0_8px_32px_rgba(0,0,0,0.45)]\",\n        className,\n      )}\n      initial={reduce ? false : { opacity: 0, y: 10, scale: 0.98 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      transition={reduce ? { duration: 0 } : { ...SPRING_PANEL, stiffness: 380, damping: 36 }}\n    >\n      <div className=\"flex items-start justify-between gap-4 px-5 pt-5 pb-1\">\n        <div className=\"min-w-0\">\n          <h2 className=\"text-[18px] font-semibold tracking-tight text-[#0d0d0d] dark:text-[#ececec]\">\n            Invite members\n          </h2>\n          <p className=\"mt-1 text-[14px] leading-snug text-[#6e6e80] dark:text-[#9b9b9b]\">\n            Add people to your workspace by email. They’ll get a link to join.\n          </p>\n        </div>\n        {onClose ? (\n          <button\n            type=\"button\"\n            onClick={onClose}\n            className=\"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg text-[#6e6e80] transition hover:bg-black/[0.04] hover:text-[#0d0d0d] dark:text-[#9b9b9b] dark:hover:bg-white/[0.06] dark:hover:text-[#ececec]\"\n            aria-label=\"Close\"\n          >\n            <X className=\"size-4\" strokeWidth={2} />\n          </button>\n        ) : null}\n      </div>\n\n      <div className=\"space-y-4 px-5 pt-4 pb-5\">\n        <div className=\"space-y-2\">\n          <label className=\"text-[13px] font-medium text-[#0d0d0d] dark:text-[#ececec]\">\n            Email addresses\n          </label>\n          <div\n            className={cn(\n              \"flex min-h-[44px] flex-wrap items-center gap-1.5 rounded-xl border border-black/[0.12] bg-white px-2.5 py-2\",\n              \"transition-[border-color,box-shadow] focus-within:border-black/40 focus-within:shadow-[0_0_0_1px_rgba(0,0,0,0.2)]\",\n              \"dark:border-white/[0.12] dark:bg-[#303030] dark:focus-within:border-white/30 dark:focus-within:shadow-[0_0_0_1px_rgba(255,255,255,0.2)]\",\n              error &&\n                \"border-[#ef4444]/50 focus-within:border-[#ef4444] focus-within:shadow-[0_0_0_1px_rgba(239,68,68,0.35)]\",\n            )}\n          >\n            <LayoutGroup>\n              <AnimatePresence initial={false}>\n                {emails.map((email) => (\n                  <motion.span\n                    key={email}\n                    layout\n                    initial={reduce ? false : { opacity: 0, scale: 0.96 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={reduce ? undefined : { opacity: 0, scale: 0.96 }}\n                    transition={SPRING_SWAP}\n                    className=\"inline-flex max-w-full items-center gap-1 rounded-md bg-[#f4f4f4] py-1 pr-1 pl-2 text-[13px] text-[#0d0d0d] dark:bg-[#424242] dark:text-[#ececec]\"\n                  >\n                    <span className=\"truncate\">{email}</span>\n                    <button\n                      type=\"button\"\n                      aria-label={`Remove ${email}`}\n                      className=\"rounded p-0.5 text-[#6e6e80] transition hover:bg-black/[0.06] hover:text-[#0d0d0d] dark:hover:bg-white/10 dark:hover:text-[#ececec]\"\n                      onClick={() => removeEmail(email)}\n                    >\n                      <X className=\"size-3\" strokeWidth={2.25} />\n                    </button>\n                  </motion.span>\n                ))}\n              </AnimatePresence>\n            </LayoutGroup>\n            <input\n              value={draft}\n              onChange={(e) => {\n                setDraft(e.target.value);\n                if (error) setError(null);\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" || e.key === \",\" || e.key === \"Tab\") {\n                  if (!draft.trim()) return;\n                  e.preventDefault();\n                  commitDraft(draft);\n                }\n                if (e.key === \"Backspace\" && !draft && emails.length) {\n                  removeEmail(emails[emails.length - 1]!);\n                }\n              }}\n              onBlur={() => {\n                if (draft.trim()) commitDraft(draft);\n              }}\n              onPaste={(e) => {\n                const text = e.clipboardData.getData(\"text\");\n                if (/[,;\\s]/.test(text)) {\n                  e.preventDefault();\n                  commitDraft(text);\n                }\n              }}\n              placeholder={emails.length ? \"Add another…\" : \"name@company.com\"}\n              className=\"min-w-[9rem] flex-1 bg-transparent px-1 py-0.5 text-[14px] text-[#0d0d0d] outline-none placeholder:text-[#8e8ea0] dark:text-[#ececec]\"\n              autoComplete=\"off\"\n              spellCheck={false}\n            />\n          </div>\n          {error ? (\n            <p className=\"text-[13px] text-[#ef4444]\" role=\"alert\">\n              {error}\n            </p>\n          ) : (\n            <p className=\"text-[12px] text-[#8e8ea0]\">\n              Separate multiple emails with commas.\n            </p>\n          )}\n        </div>\n\n        <div className=\"space-y-2\">\n          <p className=\"text-[13px] font-medium text-[#0d0d0d] dark:text-[#ececec]\">\n            Role\n          </p>\n          <div className=\"relative\" ref={roleRootRef}>\n            <button\n              type=\"button\"\n              aria-haspopup=\"listbox\"\n              aria-expanded={roleOpen}\n              aria-controls={roleListId}\n              onClick={() => setRoleOpen((o) => !o)}\n              className={cn(\n                \"flex h-11 w-full items-center justify-between gap-2 rounded-xl border border-black/[0.12] bg-white px-3 text-left text-[14px]\",\n                \"transition hover:bg-[#f9f9f9] dark:border-white/[0.12] dark:bg-[#303030] dark:hover:bg-[#383838]\",\n                roleOpen && \"border-black/40 dark:border-white/30\",\n              )}\n            >\n              <span>\n                <span className=\"block font-medium text-[#0d0d0d] dark:text-[#ececec]\">\n                  {activeRole.label}\n                </span>\n                <span className=\"block text-[12px] text-[#8e8ea0]\">\n                  {activeRole.hint}\n                </span>\n              </span>\n              <ChevronDown\n                className={cn(\n                  \"size-4 shrink-0 text-[#6e6e80] transition-transform\",\n                  roleOpen && \"rotate-180\",\n                )}\n                strokeWidth={2}\n              />\n            </button>\n\n            <AnimatePresence>\n              {roleOpen ? (\n                <motion.ul\n                  id={roleListId}\n                  role=\"listbox\"\n                  initial={reduce ? false : { opacity: 0, y: -4 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={reduce ? undefined : { opacity: 0, y: -4 }}\n                  transition={{ duration: 0.14 }}\n                  className={cn(\n                    \"absolute z-20 mt-1.5 w-full overflow-hidden rounded-xl border border-black/[0.08] bg-white py-1\",\n                    \"shadow-[0_8px_30px_rgba(0,0,0,0.12)]\",\n                    \"dark:border-white/[0.1] dark:bg-[#2f2f2f] dark:shadow-[0_8px_30px_rgba(0,0,0,0.5)]\",\n                  )}\n                >\n                  {ROLES.map((r) => {\n                    const selected = r.id === role;\n                    return (\n                      <li key={r.id} role=\"presentation\">\n                        <button\n                          type=\"button\"\n                          role=\"option\"\n                          aria-selected={selected}\n                          onClick={() => {\n                            setRole(r.id);\n                            setRoleOpen(false);\n                          }}\n                          className={cn(\n                            \"flex w-full items-start gap-3 px-3 py-2.5 text-left transition\",\n                            \"hover:bg-black/[0.04] dark:hover:bg-white/[0.06]\",\n                            selected && \"bg-black/[0.03] dark:bg-white/[0.05]\",\n                          )}\n                        >\n                          <span className=\"min-w-0 flex-1\">\n                            <span className=\"block text-[14px] font-medium text-[#0d0d0d] dark:text-[#ececec]\">\n                              {r.label}\n                            </span>\n                            <span className=\"block text-[12px] text-[#8e8ea0]\">\n                              {r.hint}\n                            </span>\n                          </span>\n                          {selected ? (\n                            <Check\n                              className=\"mt-0.5 size-4 shrink-0 text-[#0d0d0d] dark:text-[#ececec]\"\n                              strokeWidth={2.25}\n                            />\n                          ) : null}\n                        </button>\n                      </li>\n                    );\n                  })}\n                </motion.ul>\n              ) : null}\n            </AnimatePresence>\n          </div>\n        </div>\n\n        <button\n          type=\"button\"\n          onClick={sendInvites}\n          disabled={!canSend && !sentFlash}\n          className={cn(\n            \"flex h-11 w-full items-center justify-center rounded-full text-[14px] font-medium transition\",\n            \"bg-[#0d0d0d] text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-40\",\n            \"dark:bg-[#ececec] dark:text-[#0d0d0d] dark:hover:bg-white dark:disabled:opacity-35\",\n          )}\n        >\n          <AnimatePresence mode=\"wait\" initial={false}>\n            {sentFlash ? (\n              <motion.span\n                key=\"sent\"\n                className=\"inline-flex items-center gap-1.5\"\n                initial={reduce ? false : { opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduce ? undefined : { opacity: 0, y: -4 }}\n                transition={{ duration: 0.15 }}\n              >\n                <Check className=\"size-4\" strokeWidth={2.25} />\n                Invites sent\n              </motion.span>\n            ) : (\n              <motion.span\n                key=\"send\"\n                initial={reduce ? false : { opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduce ? undefined : { opacity: 0, y: -4 }}\n                transition={{ duration: 0.15 }}\n              >\n                Send invites\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </button>\n\n        <div className=\"relative py-1\">\n          <div className=\"absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-black/[0.08] dark:bg-white/[0.1]\" />\n          <p className=\"relative mx-auto w-fit bg-white px-3 text-[12px] text-[#8e8ea0] dark:bg-[#212121]\">\n            or\n          </p>\n        </div>\n\n        <button\n          type=\"button\"\n          onClick={copyLink}\n          className={cn(\n            \"flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-black/[0.08] px-3.5 text-left transition\",\n            \"hover:bg-[#f9f9f9] dark:border-white/[0.1] dark:hover:bg-[#2a2a2a]\",\n          )}\n        >\n          <span className=\"flex min-w-0 items-center gap-2.5\">\n            <Link2 className=\"size-4 shrink-0 text-[#6e6e80]\" strokeWidth={2} />\n            <span className=\"min-w-0\">\n              <span className=\"block truncate text-[13px] font-medium text-[#0d0d0d] dark:text-[#ececec]\">\n                Copy invite link\n              </span>\n              <span className=\"block truncate text-[12px] text-[#8e8ea0]\">\n                {inviteLink.replace(/^https?:\\/\\//, \"\")}\n              </span>\n            </span>\n          </span>\n          <span className=\"inline-flex shrink-0 items-center gap-1 text-[13px] font-medium text-[#0d0d0d] dark:text-[#ececec]\">\n            {copied ? (\n              <>\n                <Check className=\"size-3.5\" strokeWidth={2.25} />\n                Copied\n              </>\n            ) : (\n              <>\n                <Copy className=\"size-3.5\" strokeWidth={2} />\n                Copy\n              </>\n            )}\n          </span>\n        </button>\n\n        {pending.length > 0 && (\n          <div className=\"pt-1\">\n            <div className=\"mb-2 flex items-baseline justify-between\">\n              <p className=\"text-[13px] font-medium text-[#0d0d0d] dark:text-[#ececec]\">\n                Pending invites\n              </p>\n              <p className=\"text-[12px] text-[#8e8ea0]\">{pending.length}</p>\n            </div>\n            <ul className=\"divide-y divide-black/[0.06] overflow-hidden rounded-xl border border-black/[0.08] dark:divide-white/[0.08] dark:border-white/[0.1]\">\n              <AnimatePresence initial={false}>\n                {pending.map((item) => (\n                  <motion.li\n                    key={item.id}\n                    layout\n                    initial={reduce ? false : { opacity: 0, height: 0 }}\n                    animate={{ opacity: 1, height: \"auto\" }}\n                    exit={\n                      reduce\n                        ? undefined\n                        : { opacity: 0, height: 0, transition: { duration: 0.15 } }\n                    }\n                    className=\"flex items-center gap-3 bg-white px-3 py-2.5 dark:bg-[#212121]\"\n                  >\n                    <span\n                      className=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#ececec] text-[11px] font-semibold tracking-wide text-[#6e6e80] dark:bg-[#3a3a3a] dark:text-[#b4b4b4]\"\n                      aria-hidden\n                    >\n                      {initials(item.email)}\n                    </span>\n                    <div className=\"min-w-0 flex-1\">\n                      <p className=\"truncate text-[14px] font-medium text-[#0d0d0d] dark:text-[#ececec]\">\n                        {item.email}\n                      </p>\n                      <p className=\"text-[12px] text-[#8e8ea0]\">\n                        {roleMeta(item.role).label}\n                      </p>\n                    </div>\n                    <button\n                      type=\"button\"\n                      className=\"rounded-md px-2 py-1 text-[13px] text-[#6e6e80] transition hover:bg-black/[0.04] hover:text-[#0d0d0d] dark:hover:bg-white/[0.06] dark:hover:text-[#ececec]\"\n                      onClick={() => {\n                        /* demo */\n                      }}\n                    >\n                      Resend\n                    </button>\n                    <button\n                      type=\"button\"\n                      aria-label={`Revoke ${item.email}`}\n                      className=\"rounded-md p-1.5 text-[#8e8ea0] transition hover:bg-black/[0.04] hover:text-[#0d0d0d] dark:hover:bg-white/[0.06] dark:hover:text-[#ececec]\"\n                      onClick={() =>\n                        setPending((prev) => prev.filter((p) => p.id !== item.id))\n                      }\n                    >\n                      <X className=\"size-3.5\" strokeWidth={2} />\n                    </button>\n                  </motion.li>\n                ))}\n              </AnimatePresence>\n            </ul>\n          </div>\n        )}\n      </div>\n    </motion.section>\n  );\n}\n"}],"meta":{"studioName":"GFA Development & Design Studio","siteUrl":"https://ui.gkhn.dev"}}