diff --git a/src/styles/globals.css b/src/styles/globals.css index 5c63a3b..c9360d4 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -2904,7 +2904,7 @@ code { .invite-link-cell { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 0.45rem; } @@ -2917,6 +2917,14 @@ code { white-space: nowrap; } +.invite-link-cell__state { + color: var(--accent-lime); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; +} + .admin-helper-note { max-width: 38rem; margin: 0.22rem 0 0; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index c5788b1..f4efcb5 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -2090,6 +2090,7 @@ function InvitesSection({ const [email, setEmail] = useState(""); const [role, setRole] = useState("member"); const [deleteInviteId, setDeleteInviteId] = useState(null); + const [copiedInviteId, setCopiedInviteId] = useState(null); const invites = data.invites.filter((invite) => invite.clientId === clientId); const deletingInvite = invites.find((invite) => invite.id === deleteInviteId) ?? null; const actor = getUser(data, actorUserId); @@ -2102,6 +2103,26 @@ function InvitesSection({ setRole("member"); } + async function handleCopyInvite(invite: Invite) { + const inviteUrl = buildInviteUrl(invite.token); + + try { + await copyToClipboard(inviteUrl); + } catch { + return; + } + + setCopiedInviteId(invite.id); + + if (invite.status === "created") { + onUpdateInvite(invite.id, { status: "sent" }); + } + + window.setTimeout(() => { + setCopiedInviteId((currentInviteId) => (currentInviteId === invite.id ? null : currentInviteId)); + }, 1800); + } + return (
@@ -2149,69 +2170,75 @@ function InvitesSection({ - {invites.map((invite) => ( - - - onUpdateInvite(invite.id, { email: event.target.value })} - aria-label={`Email инвайта ${invite.email}`} - /> - - - onUpdateInvite(invite.id, { role: nextRole })} - /> - - - onUpdateInvite(invite.id, { status })} - /> - - -
- {`/invite/${invite.token}`} + {invites.map((invite) => { + const inviteUrl = buildInviteUrl(invite.token); + const isCopied = copiedInviteId === invite.id; + + return ( + + + onUpdateInvite(invite.id, { email: event.target.value })} + aria-label={`Email инвайта ${invite.email}`} + /> + + + onUpdateInvite(invite.id, { role: nextRole })} + /> + + + onUpdateInvite(invite.id, { status })} + /> + + +
+ {inviteUrl} + {isCopied ? Скопировано : null} + void handleCopyInvite(invite)} + > + + +
+ + + { + if (value) onUpdateInvite(invite.id, { expiresAt: value }); + }} + /> + + void navigator.clipboard?.writeText(`/invite/${invite.token}`)} + onClick={() => setDeleteInviteId(invite.id)} > - + -
- - - { - if (value) onUpdateInvite(invite.id, { expiresAt: value }); - }} - /> - - - setDeleteInviteId(invite.id)} - > - - - - - ))} + + + ); + })}
@@ -2233,6 +2260,38 @@ function InvitesSection({ ); } +function buildInviteUrl(token: string) { + if (typeof window === "undefined") return `/invite/${token}`; + + return new URL(`/invite/${token}`, window.location.origin).toString(); +} + +async function copyToClipboard(value: string) { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + if (typeof document === "undefined") { + throw new Error("Clipboard API is not available"); + } + + const input = document.createElement("textarea"); + input.value = value; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.append(input); + input.select(); + + const copied = document.execCommand("copy"); + input.remove(); + + if (!copied) { + throw new Error("Fallback copy failed"); + } +} + function SyncSection({ data, clientId,