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
This commit is contained in:
parent
c31762b82d
commit
4a7e78b28d
23 changed files with 2892 additions and 8 deletions
|
|
@ -6,6 +6,7 @@ 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;
|
||||
|
|
@ -29,6 +30,16 @@ function deadReasonFrom(err: unknown): DeadReason {
|
|||
|
||||
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()}`;
|
||||
|
|
@ -183,6 +194,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
|
|||
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(() => {
|
||||
|
|
@ -193,6 +207,14 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
|
|||
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;
|
||||
|
|
@ -288,6 +310,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
|
|||
<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>
|
||||
|
|
@ -309,6 +334,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
|
|||
<p className="mt-1 text-2xs text-text-faint">
|
||||
You'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)} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue