T-HUB/apps/web/src/app/(main)/onboarding/page.tsx
hacklab cfabbf20ef 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:40:02 +02:00

400 lines
19 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 { OfflineBadge } from "../../../components/ui/offline-badge";
import { ArrowGlyph, LogoMark } from "../../../components/brand/logo";
import { PLATFORMS } from "../../../lib/platforms";
import { useApiResource } from "../../../lib/use-api";
import { api, errorMessage } from "../../../lib/api-client";
import type { MemberMutationResult } from "../../../lib/api-types";
import type { ConnectProvider } from "../../../lib/connections-client";
import { useAuth } from "../../../lib/auth-store";
// Step labels for the collective flow; solo accounts skip this wizard entirely.
const STEPS = ["Name your collective", "Invite team", "Connect accounts"];
const MIN_PASSWORD = 10;
const INVITE_ROLE_CHOICES = ["Owner", "Editor", "Viewer"] as const;
const DEFAULT_INVITE_ROLE = "Editor";
const NO_PROVIDERS: ConnectProvider[] = [];
function platformName(id: string): string {
return PLATFORMS[id]?.displayName ?? id;
}
function SignInForm({ onSwitch }: { onSwitch: () => void }): React.ReactElement {
const { login } = 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);
window.location.assign("/streams");
} catch (err) {
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}>Create a workspace instead</Button>
<Button type="submit" variant="primary" disabled={busy || username.trim() === "" || password === ""}>
{busy ? "Signing in…" : "Sign in"}
</Button>
</div>
</form>
);
}
export default function OnboardingPage(): React.ReactElement {
const { status, session, register, adoptToken, logout } = useAuth();
const offline = status === "offline";
// /login deep-links here in sign-in mode; the default entry is create-account.
const [mode, setMode] = useState<"register" | "signin">(
typeof window !== "undefined" && window.location.search.includes("mode=signin") ? "signin" : "register",
);
const [step, setStep] = useState(0);
const [name, setName] = useState("");
// Collectives opt-in (operator reorder #2): default OFF = solo account.
const [creatingCollective, setCreatingCollective] = useState(false);
const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emails, setEmails] = useState("");
const [inviteRole, setInviteRole] = useState<string>(DEFAULT_INVITE_ROLE);
const { data: providers, source: providerSource } = useApiResource<ConnectProvider[], ConnectProvider[]>("/connections/providers", (p) => p, NO_PROVIDERS);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessionExpired, setSessionExpired] = useState(false);
// The API client redirects here with ?reason=session on a 401: default to sign-in.
useEffect(() => {
if (new URLSearchParams(window.location.search).get("reason") === "session") {
setSessionExpired(true);
setMode("signin");
}
}, []);
async function createWorkspace(): Promise<void> {
if (offline) {
if (!creatingCollective) window.location.assign("/streams");
else setStep(1);
return;
}
setBusy(true);
setError(null);
try {
await register({
// Solo default: omitting workspaceName registers a personal workspace.
...(creatingCollective ? { workspaceName: name.trim() } : {}),
username: username.trim(),
password,
...(displayName.trim() === "" ? {} : { displayName: displayName.trim() }),
...(email.trim() === "" ? {} : { email: email.trim() }),
});
// Solo accounts skip the team-invite wizard: there is no collective to
// invite anyone into yet. They land straight in the app (which shows the
// "no collectives" home menu until they create or join one).
if (!creatingCollective) window.location.assign("/streams");
else setStep(1);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function sendInvites(): Promise<void> {
const list = emails.split(/[\s,;]+/).map((e) => e.trim()).filter((e) => e.includes("@"));
if (offline || list.length === 0) {
setStep(2);
return;
}
setBusy(true);
setError(null);
const failures: string[] = [];
// Sequential: each invite bumps the authz epoch and returns the token the next call must use.
for (const invitee of list) {
try {
const result = await api.post<MemberMutationResult>("/team/members/invite", { email: invitee, role: inviteRole });
adoptToken(result.token);
} catch (err) {
failures.push(`${invitee}: ${errorMessage(err)}`);
}
}
setBusy(false);
if (failures.length > 0) setError(failures.join(" · "));
else setStep(2);
}
const usernameValid = /^[a-z0-9][a-z0-9_.-]{1,31}$/.test(username.trim());
const registerReady =
(!creatingCollective || name.trim().length >= 2) && (offline || (usernameValid && password.length >= MIN_PASSWORD));
// Signed-in visitors who land here (e.g. from the sidebar) get a way out rather than a second registration form.
if (status === "authenticated" && session !== null && step === 0) {
return (
<div className="flex min-h-full items-center justify-center p-6">
<Card className="w-[520px] max-w-full p-6">
<h2 className="mb-1 text-base font-semibold">You&apos;re signed in</h2>
<p className="mb-6 text-xs text-text-faint">
@{session.user.username} · {session.role} of <span className="text-text">{session.workspace.name}</span>
</p>
<div className="flex justify-between">
<Button variant="ghost" onClick={() => void logout()}>Sign out</Button>
<Link href="/streams" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
Continue to Streams
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="flex h-full items-center justify-center p-6">
<Card className="w-[520px] max-w-full p-6">
<div className="mb-4 flex items-center justify-end">
<Button
size="sm"
variant="outline"
onClick={() => { setMode(mode === "signin" ? "register" : "signin"); setStep(0); }}
>
{mode === "signin" ? "Create a workspace" : "Existing account? Log in"}
</Button>
</div>
{offline && (
<div className="mb-4 flex justify-end">
<OfflineBadge source="sample" />
</div>
)}
{sessionExpired && step === 0 && (
<p className="mb-4 rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
Your session ended (expired, or your role changed). Sign in again.
</p>
)}
{mode === "signin" && step === 0 && !offline ? (
<SignInForm onSwitch={() => setMode("register")} />
) : (
<>
<div className="mb-6 flex items-center gap-2">
{STEPS.map((s, i) => (
<div key={s} className="flex flex-1 items-center gap-2">
<span
className={`flex h-6 w-6 items-center justify-center rounded-full text-2xs font-semibold ${
i < step ? "bg-live/20 text-live" : i === step ? "bg-accent text-white" : "bg-surface-2 text-text-faint"
}`}
>
{i < step ? "✓" : i + 1}
</span>
<span className={`text-xs ${i === step ? "text-text" : "text-text-faint"}`}>{s}</span>
{i < STEPS.length - 1 && <span className="h-px flex-1 bg-border-subtle" />}
</div>
))}
</div>
{step === 0 && (
<form
onSubmit={(e) => {
e.preventDefault();
void createWorkspace();
}}
>
<div className="mb-5">
<div className="-m-6 mb-5 overflow-hidden bg-black">
<div className="flex items-center gap-5 px-8 py-8">
<LogoMark size={96} className="shrink-0 text-white" />
<div className="min-w-0">
<div className="text-xl font-semibold leading-tight text-white">Horizontal Collaborative Broadcast System</div>
<div className="mt-1 text-xs uppercase tracking-wider text-[#00e5ff]">T-HUB</div>
</div>
</div>
</div>
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">T-HUB</h1>
<p className="mt-1 text-2xs text-text-faint">Your platform. Their feeds. No algorithm in between.</p>
</div>
</div>
{/*
Operator reorder #1/#2: registration makes a USER by default —
a solo account with no collective. Creating a collective is an
explicit opt-in that reveals the name field and the classic
workspace flow (provisioner mints rooms once a collective is
actually created from inside the app).
*/}
<label className="mb-4 flex cursor-pointer items-start gap-2.5 rounded-lg border border-border bg-surface-2 p-3">
<input
type="checkbox"
checked={creatingCollective}
onChange={(e) => setCreatingCollective(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-[#FF1610]"
/>
<span>
<span className="block text-sm font-medium">I am creating a new collective</span>
<span className="block text-xs text-text-faint">Voy a crear un colectivo — with its own rooms and members.</span>
</span>
</label>
{creatingCollective ? (
<>
<h2 className="mb-1 text-base font-semibold">Name your collective</h2>
<p className="mb-4 text-xs text-text-faint">The tenant boundary for accounts, drafts, and audit.</p>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your collective, your studio…" autoFocus />
</>
) : (
<h2 className="mb-4 text-base font-semibold">Create your account</h2>
)}
{!offline && (
<div className="mt-4 space-y-2">
<p className="text-xs text-text-faint">Your owner account</p>
<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="2-32 characters: lowercase letters, digits, dot, underscore or hyphen"
/>
<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)"
autoComplete="email"
/>
</div>
)}
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-6 flex items-center justify-between">
{offline ? <span /> : (
<Button type="button" variant="ghost" onClick={() => setMode("signin")}>I already have an account</Button>
)}
<Button type="submit" variant="primary" disabled={busy || !registerReady}>
{busy ? "Creating…" : "Continue"}
</Button>
</div>
</form>
)}
{step === 1 && (
<div>
<h2 className="mb-1 text-base font-semibold">Invite your team</h2>
<p className="mb-4 text-xs text-text-faint">
Comma-separated emails. Invitees join at the chosen role; adjust roles on the Team page.
</p>
<textarea
value={emails}
onChange={(e) => setEmails(e.target.value)}
rows={3}
placeholder="priya@studio.io, yuki@studio.io"
className="w-full resize-none rounded-md border border-border bg-surface-2 p-2 text-sm focus:border-accent focus:outline-none"
/>
<label className="mt-3 flex items-center gap-2 text-xs text-text-faint">
Invite role
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
>
{INVITE_ROLE_CHOICES.map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
</label>
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-6 flex justify-between">
{/* The workspace already exists once step 0 succeeds; going back would re-register. */}
<Button variant="ghost" onClick={() => setStep(2)}>Skip</Button>
<Button variant="primary" disabled={busy} onClick={() => void sendInvites()}>
{busy ? "Inviting…" : "Continue"}
</Button>
</div>
</div>
)}
{step === 2 && (
<div>
<h2 className="mb-1 text-base font-semibold">Connect your first accounts</h2>
<p className="mb-4 text-xs text-text-faint">
{providerSource === "live" && providers.length > 0
? `Available: ${providers.map((p) => platformName(p.id)).join(", ")}. Follow each linking flow — bot tokens where required, OAuth on your instance otherwise.`
: "Connect at least one account so your workspace can publish."}
</p>
<div className="space-y-2">
{providerSource === "live" && providers.length > 0 ? (
providers.map((p) => (
<div key={p.id} className="flex items-center justify-between rounded-md border border-border-subtle px-3 py-2.5">
<div className="flex items-center gap-3">
<ArrowGlyph size={11} className="text-accent" />
<span className="text-sm font-medium">{platformName(p.id)}</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => window.location.assign(`/connections?start=${p.id}`)}
>
Connect
</Button>
</div>
))
) : (
<p className={`rounded-md border px-3 py-2.5 text-xs ${providerSource === "loading" || providerSource === "live" ? "border-border-subtle text-text-faint" : "border-danger text-danger"}`}>
{providerSource === "loading"
? "Loading connectable platforms…"
: providerSource === "live"
? "No connectable platforms are available yet."
: "Could not load the platform list — check that the API is reachable, or continue and connect later from Connections."}
</p>
)}
</div>
<p className="mt-3 text-2xs text-text-faint">Connecting opens the account linking flow — you can also do this later from Connections.</p>
<div className="mt-6 flex justify-between">
<Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
<Button variant="primary" onClick={() => setStep(3)}>Finish setup</Button>
</div>
</div>
)}
{step === 3 && (
<div className="py-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-live/20 text-lg text-live">✓</div>
<h2 className="text-base font-semibold">{session?.workspace.name ?? (name || "Workspace")} is ready</h2>
<p className="mt-1 text-xs text-text-faint">
Link accounts any time from <Link href="/connections" className="text-accent hover:underline">Connections</Link> — or head to{" "}
<Link href="/streams" className="text-accent hover:underline">Streams</Link> or{" "}
<Link href="/compose" className="text-accent hover:underline">Compose</Link>
</p>
</div>
)}
</>
)}
</Card>
</div>
);
}