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>
400 lines
19 KiB
TypeScript
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'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>
|
|
);
|
|
}
|