T-HUB/apps/web/src/app/join/[token]/page.tsx
hacklab c423f172d0 style: light theme and hand-inked logo matching acampada26s.net
The previous pass kept the dark base and the red icons, which did not
read as the acampada. Now:

- Light theme: white page, black ink and borders, black buttons with a
  hard neon offset shadow, neon for focus and the active nav item;
  h1/h2 in Anton with the stepped neon shadow of the site headings.
- Nav, empty-state, spinner and pattern assets in black; platform icons
  keep their dark disc with a neon stroke.
- New mark: three dome tents wired from their doors into a switch with
  an antenna, drawn in the inked style of the acampada logo (curved
  poles, ball ends, hatching) over a neon offset copy. LogoMark renders
  it inline; visible product name is T-HUB.
- docs/brand: t-hub.svg and a lockup with the Anton wordmark converted
  to outlines so it renders without the font.
- Public submission page moved to the light palette.

Checked with headless Chromium screenshots (offline sample data) on
desktop and mobile.
2026-10-15 14:40:02 +02:00

356 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState, type FormEvent } from "react";
import Link from "next/link";
import { Badge, Button, Card, Input } from "../../../components/ui/primitives";
import { LoadingPanel } from "../../../components/brand/loading";
import { apiFetch, ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import type { InviteInfo } from "../../../lib/api-types";
import type { ManualLanguage } from "@tactical-media-broadcast-hub/core";
import { useAuth } from "../../../lib/auth-store";
const MIN_PASSWORD = 10;
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9_.-]{1,31}$/;
const USERNAME_TITLE = "2-32 characters: lowercase letters, digits, dot, underscore or hyphen";
const API_UNREACHABLE = "API unreachable — check that the API server is running.";
type Phase = "loading" | "preview" | "dead" | "unreachable";
/** Why the link no longer works — drives the copy on the dead-token card. */
type DeadReason = "unknown" | "expired" | "exhausted";
/** 404 (unknown/revoked) vs 410 (expired or exhausted, told apart by the message). Anything else reads as unknown. */
function deadReasonFrom(err: unknown): DeadReason {
if (err instanceof ApiError && err.status === 410) {
const message = err.message.toLowerCase();
if (message.includes("expired")) return "expired";
if (message.includes("used up") || message.includes("no uses left")) return "exhausted";
}
return "unknown";
}
const isAlreadyMember = (err: unknown): boolean => err instanceof ApiError && err.status === 409;
/**
* One-line collective labelling for the claim card ("Collectives: X · Y").
* Null when unknown or empty — the card must read fine without it.
*/
function collectiveLabel(collectives: Array<{ id: string; name: string }> | null, lang: ManualLanguage): string | null {
if (collectives === null || collectives.length === 0) return null;
const names = collectives.map((c) => c.name).join(" · ");
return lang === "es" ? `Colectivos: ${names}` : `Collectives: ${names}`;
}
function usesLeftLine(info: InviteInfo): string {
const left = info.maxUses === null ? "unlimited uses" : `${Math.max(info.maxUses - info.usedCount, 0)} use${info.maxUses - info.usedCount === 1 ? "" : "s"} left`;
return `${left} · expires ${new Date(info.expiresAt).toLocaleDateString()}`;
}
/** Sign in with an existing account, then claim: login installs the account session, claimInvite swaps it for the workspace one. */
function JoinSignInForm({
token,
onSwitch,
onAlreadyMember,
}: {
token: string;
onSwitch: () => void;
onAlreadyMember: () => void;
}): React.ReactElement {
const { login, claimInvite } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(username.trim(), password);
await claimInvite(token);
window.location.assign("/streams");
} catch (err) {
// Login worked but the workspace already holds this user: that is a success state.
if (isAlreadyMember(err)) {
setBusy(false);
onAlreadyMember();
return;
}
setError(errorMessage(err));
setBusy(false);
}
}
return (
<form onSubmit={(e) => void submit(e)}>
<h2 className="mb-1 text-base font-semibold">Sign in</h2>
<p className="mb-4 text-xs text-text-faint">Use the username and password you registered with.</p>
<div className="space-y-2">
<Input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" autoComplete="username" autoFocus />
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" autoComplete="current-password" />
</div>
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-6 flex items-center justify-between">
<Button type="button" variant="ghost" onClick={onSwitch}>New here? Create an account</Button>
<Button type="submit" variant="primary" disabled={busy || username.trim() === "" || password === ""}>
{busy ? "Joining…" : "Sign in & join"}
</Button>
</div>
</form>
);
}
/**
* Create-or-authenticate inline, then claim. Registering normally spins up a
* new workspace, so the claim endpoint takes the credentials itself and hands
* back a session issued in the invited workspace.
*/
function RegisterToJoinForm({
token,
onSwitch,
onAlreadyMember,
}: {
token: string;
onSwitch: () => void;
onAlreadyMember: () => void;
}): React.ReactElement {
const { claimInviteWithCredentials } = useAuth();
const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
setBusy(true);
setError(null);
try {
await claimInviteWithCredentials(token, {
username: username.trim(),
password,
...(displayName.trim() === "" ? {} : { displayName: displayName.trim() }),
...(email.trim() === "" ? {} : { email: email.trim() }),
});
window.location.assign("/streams");
} catch (err) {
if (isAlreadyMember(err)) {
setBusy(false);
onAlreadyMember();
return;
}
setError(errorMessage(err));
setBusy(false);
}
}
const ready = USERNAME_PATTERN.test(username.trim()) && password.length >= MIN_PASSWORD;
return (
<form onSubmit={(e) => void submit(e)}>
<h2 className="mb-1 text-base font-semibold">Create your account</h2>
<p className="mb-4 text-xs text-text-faint">
Registering accepts the invite in one step — no workspace of your own is created.
</p>
<div className="space-y-2">
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your name (optional)" autoComplete="name" />
<Input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())}
placeholder="Username — this is how you sign in"
autoComplete="username"
pattern="[a-z0-9][a-z0-9_.-]{1,31}"
title={USERNAME_TITLE}
/>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={`Password (min ${MIN_PASSWORD} characters)`}
autoComplete="new-password"
/>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email (optional — password recovery)" autoComplete="email" />
</div>
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-6 flex items-center justify-between">
<Button type="button" variant="ghost" onClick={onSwitch}>I already have an account</Button>
<Button type="submit" variant="primary" disabled={busy || !ready}>
{busy ? "Joining…" : "Join workspace"}
</Button>
</div>
</form>
);
}
export default function JoinWorkspacePage({ params }: { params: { token: string } }): React.ReactElement {
const { status, session, isLoading, claimInvite } = useAuth();
const [phase, setPhase] = useState<Phase>("loading");
const [info, setInfo] = useState<InviteInfo | null>(null);
const [deadReason, setDeadReason] = useState<DeadReason>("unknown");
const [mode, setMode] = useState<"register" | "signin">("register");
const [claiming, setClaiming] = useState(false);
const [claimError, setClaimError] = useState<string | null>(null);
const [alreadyMember, setAlreadyMember] = useState(false);
const [attempt, setAttempt] = useState(0);
// Collective names for the claim card (names-only public read). Purely
// cosmetic labelling: a failure renders the card without it, never blocks.
const [collectives, setCollectives] = useState<Array<{ id: string; name: string }> | null>(null);
// The preview is public: fetch it immediately rather than waiting for the session to settle.
useEffect(() => {
let cancelled = false;
setPhase("loading");
apiFetch<InviteInfo>(`/invites/${encodeURIComponent(params.token)}/info`, { redirectOn401: false })
.then((preview) => {
if (cancelled) return;
setInfo(preview);
setPhase("preview");
// Best-effort collective labelling: the token is already proven valid.
apiFetch<{ collectives: Array<{ id: string; name: string }> }>(`/collectives-public/for-invite/${encodeURIComponent(params.token)}`, {
redirectOn401: false,
})
.then((result) => {
if (!cancelled) setCollectives(result.collectives);
})
.catch(() => undefined);
})
.catch((err: unknown) => {
if (cancelled) return;
if (err instanceof ApiUnreachableError) setPhase("unreachable");
else {
setDeadReason(deadReasonFrom(err));
setPhase("dead");
}
});
return () => {
cancelled = true;
};
}, [params.token, attempt]);
const retry = (): void => setAttempt((n) => n + 1);
async function claim(): Promise<void> {
setClaiming(true);
setClaimError(null);
try {
await claimInvite(params.token);
// Full navigation so the (main) shell mounts with the freshly installed session.
window.location.assign("/streams");
} catch (err) {
if (isAlreadyMember(err)) {
setAlreadyMember(true);
setClaiming(false);
return;
}
setClaimError(errorMessage(err));
setClaiming(false);
}
}
if (phase === "loading" || isLoading) return <LoadingPanel label="Checking invite…" />;
if (phase === "unreachable") {
return (
<Card className="w-[520px] max-w-full p-6 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-warn/15 text-lg text-warn">!</div>
<h1 className="text-base font-semibold">Can&apos;t reach the server</h1>
<p className="mt-1 text-xs text-text-faint">
{API_UNREACHABLE} Your invite link is still fine — try again.
</p>
<Button variant="outline" className="mt-6" onClick={retry}>Retry</Button>
</Card>
);
}
if (phase === "dead") {
return (
<Card className="w-[520px] max-w-full p-6 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-danger/15 text-lg text-danger">✕</div>
<h1 className="text-base font-semibold">This invite link isn&apos;t valid</h1>
<p className="mt-1 text-xs text-text-faint">
{deadReason === "expired"
? "It expired."
: deadReason === "exhausted"
? "It has already been used up."
: "It was revoked or never existed."}{" "}
Ask a workspace owner for a fresh invite.
</p>
<Link href="/onboarding" className="mt-6 inline-block rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
Start or sign in to your own workspace
</Link>
</Card>
);
}
if (info === null) return <LoadingPanel label="Checking invite…" />;
const memberCta = alreadyMember ? (
<div className="mt-6 flex items-center justify-between">
<span className="text-xs text-text-faint">You&apos;re already in this workspace.</span>
<Link href="/streams" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">Go to Streams</Link>
</div>
) : (
<div className="mt-6 flex items-center justify-between">
<span className="text-2xs text-text-faint">Signed in as @{session?.user.username}</span>
<Button variant="primary" disabled={claiming} onClick={() => void claim()}>
{claiming ? "Joining…" : "Join workspace"}
</Button>
</div>
);
// Signed in already: one click claims the invite with the current session.
if (status === "authenticated" && session !== null) {
return (
<Card className="w-[520px] max-w-full p-6">
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
<p className="mt-1 text-xs text-text-faint">
You&apos;ve been invited to join <span className="text-text">{info.workspaceName}</span> as{" "}
<Badge tone="accent">{info.role}</Badge>.
</p>
<p className="mt-2 text-2xs text-text-faint">{usesLeftLine(info)}</p>
{collectiveLabel(collectives, "en") !== null && (
<p className="mt-1 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
)}
{claimError !== null && <p className="mt-2 text-xs text-danger">{claimError}</p>}
{memberCta}
</Card>
);
}
// Anonymous (or the API dropped between mount and now): hero + register/sign-in forms.
return (
<Card className="w-[520px] max-w-full p-6">
<div className="mb-5 text-center">
<img
src="/brand/t-hub.svg"
alt="T-HUB: three tents wired to a switch with an antenna"
width={400}
height={400}
className="mx-auto mb-3 h-20 w-20"
/>
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
<p className="mt-1 text-2xs text-text-faint">
You&apos;ve been invited as <Badge tone="accent">{info.role}</Badge> · {usesLeftLine(info)}
</p>
{collectiveLabel(collectives, "en") !== null && (
<p className="mt-0.5 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
)}
</div>
{mode === "signin" ? (
<JoinSignInForm token={params.token} onSwitch={() => setMode("register")} onAlreadyMember={() => setAlreadyMember(true)} />
) : (
<RegisterToJoinForm token={params.token} onSwitch={() => setMode("signin")} onAlreadyMember={() => setAlreadyMember(true)} />
)}
{alreadyMember && (
<div className="mt-5 border-t border-border-subtle pt-4 text-center">
<p className="text-xs text-text-faint">You&apos;re already in this workspace.</p>
<Link href="/streams" className="mt-2 inline-block rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
Go to Streams
</Link>
</div>
)}
</Card>
);
}