114 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
}
|