Files
gerbeur/src/components/profile/ProfileOwnActions.tsx
2026-10-08 22:22:15 +00:00

114 lines
3.2 KiB
TypeScript

/**
* Renders the action of the signed-in user's own profile header, which invites
* someone.
*
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { ErrorCard } from "../shared/ErrorCard.tsx";
/**
* Renders the "Invite someone" button, which opens a panel with an invite link.
* Each opening of the panel creates a new single-use link, for `/register`.
*/
export function ProfileOwnActions(): React.JSX.Element {
const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const ref = useHeightTransition<HTMLDivElement>(
(inviteUrl ?? "") + (error ?? ""),
);
async function toggle() {
setError(null);
setCopied(false);
if (inviteUrl) {
setInviteUrl(null);
return;
}
setPending(true);
try {
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
const body = await res.json();
if (body.success) {
setInviteUrl(
`${globalThis.location.origin}/register?token=${body.data.token}`,
);
} else {
setError(t`Failed to generate invite`);
}
} catch {
setError(t`Failed to generate invite`);
} finally {
setPending(false);
}
}
async function copy() {
if (!inviteUrl) return;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// The clipboard is denied outside a secure context. The link can still
// be selected by hand.
}
}
return (
<div className="profile-own-actions" ref={ref}>
<button
type="button"
className="invite-btn"
onClick={toggle}
disabled={pending}
aria-expanded={inviteUrl !== null}
>
<Trans>+ Invite someone</Trans>
</button>
{inviteUrl && (
<div
className="invite-panel animate-in"
role="group"
aria-label={t`Invite link`}
>
<input
className="invite-url"
type="text"
readOnly
value={inviteUrl}
aria-label={t`Invite link`}
onFocus={(e) => e.currentTarget.select()}
/>
<button
type="button"
className={`invite-copy-btn${
copied ? " invite-copy-btn--done" : ""
}`}
onClick={copy}
>
{copied ? t`Copied!` : t`Copy`}
</button>
<span className="invite-panel-hint">
<Trans>Single use, valid for 7 days.</Trans>
</span>
</div>
)}
{error && (
<div className="invite-panel invite-panel--error animate-in">
<ErrorCard title={t`Failed to generate invite`} message={error} />
</div>
)}
</div>
);
}