T-HUB/apps/web/src/app/page.tsx
glass beadian 4a7e78b28d Collectives P1: participation landing (ES/EN), group tree, room provisioning APIs
- 030: groups.parent_id tree + kind discriminator + group_rooms mapping
- /collectives module: create/tree/join APIs, MatrixProvisioner (open room
  public+federated, closed room E2EE-at-creation, onboarding room), NullProvisioner
  fail-soft when ARCHIVE_PROVISIONER_* unset
- web root becomes the public participation manual (ES-first + EN toggle,
  three paths: join by invite, start collective, solo account)
- participation manual content in core
2026-10-12 13:53:40 +00:00

190 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
MANUAL_INTRO,
MANUAL_PATHS_HEADING,
MANUAL_TITLE,
PARTICIPATION_MANUAL,
PARTICIPATION_PATHS,
isManualLanguage,
type ManualLanguage,
} from "@tactical-media-broadcast-hub/core";
import { Logo } from "../components/brand/logo";
import { AuthProvider, useAuth } from "../lib/auth-store";
/**
* Public participation-manual landing page (the app root).
*
* Design (docs/design/2026-10-12-collectives-architecture.md §"Participation
* manual"): the root explains the platform in plain language and offers the
* three participation paths — join a collective, start one, work on your own —
* before any auth wall. The copy is the SINGLE source in
* packages/core/src/participation-manual.ts: the provisioner posts the
* byte-identical text into onboarding rooms, so this page and the bot guide can
* never drift apart.
*
* Spanish is the primary language: the page opens in ES and the EN toggle is a
* one-tap switch, mirroring the bot's ES-first posting order.
*/
const STORAGE_KEY = "tmbh.manual.lang";
function readStoredLanguage(): ManualLanguage {
if (typeof window === "undefined") return "es";
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return isManualLanguage(stored) ? stored : "es";
} catch {
return "es";
}
}
/** Renders manual body text: blank-line paragraphs, "- " bullets. */
function ManualBody({ text }: { text: string }): React.ReactElement {
const blocks = text.split("\n\n");
return (
<>
{blocks.map((block, index) => {
const lines = block.split("\n");
return lines.every((line) => line.startsWith("- ")) ? (
<ul key={index} className="mb-3 ml-1 list-disc space-y-1.5 pl-4">
{lines.map((line, i) => (
<li key={i}>{line.slice(2)}</li>
))}
</ul>
) : (
<p key={index} className="mb-3 leading-relaxed">{block}</p>
);
})}
</>
);
}
/**
* The root sits OUTSIDE the (main) route group, so its AuthProvider must be
* local: the (main) layout's provider does not wrap this page, and useAuth
* without a provider throws during prerender.
*/
export default function ManualLandingPage(): React.ReactElement {
return (
<AuthProvider>
<ManualLanding />
</AuthProvider>
);
}
function ManualLanding(): React.ReactElement {
const { status, session } = useAuth();
const router = useRouter();
const [lang, setLang] = useState<ManualLanguage>("es");
useEffect(() => setLang(readStoredLanguage()), []);
function switchLanguage(next: ManualLanguage): void {
setLang(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Private-browsing refusal: the choice still holds for this view.
}
}
// Signed-in visitors go straight to the app; the manual stays reachable at /wiki.
useEffect(() => {
if (status === "authenticated") router.replace("/streams");
}, [status, router]);
const t = <T extends { es: string; en: string }>(text: T): string => text[lang];
return (
<div className="min-h-dvh bg-bg text-text">
<header className="border-b border-border-subtle bg-surface">
<div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-4">
<Logo size={26} />
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">Tactical Media Broadcast Hub</span>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label={t({ es: "Idioma", en: "Language" })}>
{(["es", "en"] as const).map((code) => (
<button
key={code}
type="button"
onClick={() => switchLanguage(code)}
aria-pressed={lang === code}
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{code}
</button>
))}
</div>
<Link
href={status === "authenticated" ? "/streams" : "/onboarding"}
className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-2xs font-medium text-white hover:bg-accent-hover"
>
{status === "authenticated"
? t({ es: "Abrir la app", en: "Open app" })
: t({ es: "Entrar", en: "Sign in" })}
</Link>
</div>
</header>
<main className="mx-auto max-w-2xl px-4 pb-20 pt-8">
<h1 className="mb-2 text-2xl font-bold">{t(MANUAL_TITLE)}</h1>
<p className="mb-8 text-sm leading-relaxed text-text-dim">{t(MANUAL_INTRO)}</p>
{/* The three participation paths — the core of the page. */}
<section aria-labelledby="paths-heading" className="mb-10">
<h2 id="paths-heading" className="mb-3 text-xs font-bold uppercase tracking-wider text-text-faint">
{t(MANUAL_PATHS_HEADING)}
</h2>
<ul className="grid gap-3">
{PARTICIPATION_PATHS.map((path) => {
// All three land on /onboarding: that page holds the sign-in and
// create-account forms. A join link itself arrives from outside
// (the only /join route is /join/[token]); starting/solo need the
// same forms, so one destination is honest for all three.
const href = "/onboarding";
return (
<li key={path.id}>
<Link
href={href}
className="flex min-h-24 flex-col justify-center gap-1.5 rounded-lg border border-border-subtle bg-surface p-4 transition-colors duration-150 hover:border-accent"
>
<span className="text-sm font-semibold">{t(path.title)}</span>
<span className="text-xs leading-relaxed text-text-faint">{t(path.summary)}</span>
<span className="mt-1 text-2xs font-bold uppercase tracking-wider text-accent">{t(path.action)} ›</span>
</Link>
</li>
);
})}
</ul>
</section>
{/* The manual itself. */}
<div className="flex flex-col gap-8">
{PARTICIPATION_MANUAL.map((section, index) => (
<section key={section.id} aria-labelledby={`section-${section.id}`}>
<h2 id={`section-${section.id}`} className="mb-2 flex items-baseline gap-2.5 text-base font-bold">
<span className="font-mono text-xs font-bold text-accent">{String(index + 1).padStart(2, "0")}</span>
{t(section.heading)}
</h2>
<div className="text-sm text-text-dim">
<ManualBody text={section.body[lang]} />
</div>
</section>
))}
</div>
<footer className="mt-12 border-t border-border-subtle pt-6 text-2xs leading-relaxed text-text-faint">
{t({
es: "Sin cuenta no se puede entrar al área de trabajo, pero esta página es pública por diseño: compártela con quien quiera entender cómo funciona un colectivo antes de pedir su invitación.",
en: "The workspace itself needs an account, but this page is public by design: share it with anyone who wants to understand how a collective works before asking for their invite.",
})}
</footer>
</main>
</div>
);
}