- {[
- { label: "Members", value: faction.members.toString() },
- { label: "Votes", value: faction.votes },
- { label: "ACM", value: faction.acm },
- { label: "Creator", value: roster[0]?.name ?? "" },
- ].map((stat) => (
-
-
-
- {stat.label === "ACM" ? (
- {stat.label}
- ) : (
- stat.label
- )}
-
- {stat.value}
-
-
- ))}
+
+ {canJoin ? (
+
+ ) : null}
+ {canLeave ? (
+
+ ) : null}
+ {viewerRole === "founder" ? (
+ Founder leave disabled until transfer
+ ) : null}
+ {isFounderAdmin ? (
+
+ ) : null}
+ {isFounderAdmin && editOpen ? (
+
+
+ Edit faction
+
+
+ setEditName(event.target.value)}
+ placeholder="Faction name"
+ />
+
+
+ ) : null}
+
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+
- Goals
+ Members
-
- {faction.goals.map((goal) => (
-
- {goal}
-
- ))}
+
+ {memberships.length === 0 ? (
+
+ ) : (
+ memberships
+ .filter((membership) => membership.isActive)
+ .map((membership) => {
+ const isSelf =
+ viewerAddress !== null &&
+ normalizeAddress(viewerAddress) ===
+ normalizeAddress(membership.address);
+ return (
+
+
+
+ {membership.address}
+
+
+ Joined {new Date(membership.joinedAt).toLocaleString()}
+
+
+
+ {canManageMembers ? (
+
+ ) : (
+ {membership.role}
+ )}
+ {isSelf ? You : null}
+
+
+ );
+ })
+ )}
-
+
- Active initiatives
+ Channels
-
- {initiatives.map((item) => (
-
-
- {item.title}
-
-
- {item.location}
-
-
- {item.stage}
+
+ {channels.length === 0 ? (
+
+ ) : (
+ channels.map((channel) => (
+
+
+
+
{channel.title}
+
+ #{channel.slug} · {channel.writeScope} · threads{" "}
+ {channel.threadCount}
+
+
+ {isFounderAdmin ? (
+
+ ) : null}
+
+
+ ))
+ )}
+ {isFounderAdmin ? (
+
+
+ Create channel
-
- ))}
+
setChannelTitle(event.target.value)}
+ placeholder="Channel title"
+ />
+
+
+
+ ) : null}
- Resources
+ Initiatives
-
-
+
+ {initiatives.length === 0 ? (
+
+ ) : (
+ initiatives.map((initiative) => (
+
+
+
+ {initiative.title}
+
+
{initiative.status}
+
+
{initiative.intent}
+ {isFounderAdmin ? (
+
+
+
+ ) : null}
+
+ ))
+ )}
+ {canPost ? (
+
+ ) : null}
- Roster highlights
+ Threads
-
- {roster.map((member) => (
-
- {member.name}
- {member.role}
- {member.tag}
-
- ))}
-
-
-
-
-
- Recent activity
-
-
-
+
+ {threads.length === 0 ? (
+
+ ) : (
+ threads.map((thread) => (
+
+
+
+
+ {thread.title}
+
+
+ {thread.channelTitle} · {thread.status} · replies{" "}
+ {thread.replies}
+
+
+
{thread.status}
+
+
{thread.body}
+ {isFounderAdmin ? (
+
+
+
+ ) : null}
+ {canPost ? (
+
+
+ setReplyByThread((prev) => ({
+ ...prev,
+ [thread.id]: event.target.value,
+ }))
+ }
+ placeholder="Reply"
+ />
+
+
+ ) : null}
+
+ ))
+ )}
+ {canPost ? (
+
+
Start thread
+
+
setThreadTitle(event.target.value)}
+ placeholder="Thread title"
+ />
+
+ ) : null}
diff --git a/src/pages/factions/FactionCreate.tsx b/src/pages/factions/FactionCreate.tsx
new file mode 100644
index 0000000..1c47570
--- /dev/null
+++ b/src/pages/factions/FactionCreate.tsx
@@ -0,0 +1,325 @@
+import { useMemo, useState } from "react";
+import { Link, useNavigate } from "react-router";
+
+import { Kicker } from "@/components/Kicker";
+import { Button } from "@/components/primitives/button";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/primitives/card";
+import { Input } from "@/components/primitives/input";
+import { Select } from "@/components/primitives/select";
+import { apiFactionCreate, getApiErrorPayload } from "@/lib/apiClient";
+
+type FormState = {
+ name: string;
+ description: string;
+ focus: string;
+ visibility: "public" | "private";
+ goalsText: string;
+ tagsText: string;
+ cofoundersText: string;
+};
+
+const FactionCreate: React.FC = () => {
+ const navigate = useNavigate();
+ const [step, setStep] = useState<1 | 2 | 3>(1);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState
(null);
+ const [form, setForm] = useState({
+ name: "",
+ description: "",
+ focus: "General",
+ visibility: "public",
+ goalsText: "",
+ tagsText: "",
+ cofoundersText: "",
+ });
+
+ const goals = useMemo(
+ () =>
+ form.goalsText
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean),
+ [form.goalsText],
+ );
+
+ const tags = useMemo(
+ () =>
+ form.tagsText
+ .split(",")
+ .map((line) => line.trim())
+ .filter(Boolean),
+ [form.tagsText],
+ );
+
+ const cofounders = useMemo(
+ () =>
+ form.cofoundersText
+ .split(/[\n,]+/)
+ .map((line) => line.trim())
+ .filter(Boolean),
+ [form.cofoundersText],
+ );
+
+ const canGoNextStep1 =
+ form.name.trim().length >= 2 &&
+ form.description.trim().length >= 10 &&
+ form.focus.trim().length > 0;
+ const canSubmit = canGoNextStep1;
+
+ const onCreate = async () => {
+ if (!canSubmit || saving) return;
+ setSaving(true);
+ setError(null);
+ try {
+ const response = await apiFactionCreate({
+ name: form.name.trim(),
+ description: form.description.trim(),
+ focus: form.focus.trim(),
+ visibility: form.visibility,
+ goals,
+ tags,
+ cofounders,
+ });
+ navigate(`/app/factions/${response.faction.id}`);
+ } catch (e) {
+ const payload = getApiErrorPayload(e);
+ const message =
+ payload?.error?.message ??
+ (e instanceof Error ? e.message : "Failed to create faction");
+ setError(message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
Phase 67
+
Create faction
+
+ Factions coordinate members, initiatives, and discussion before
+ formal governance actions.
+
+
+
+
+
+
+ {[
+ { n: 1, label: "Identity" },
+ { n: 2, label: "Access & goals" },
+ { n: 3, label: "Review" },
+ ].map((item) => (
+
+ {item.n}. {item.label}
+
+ ))}
+
+
+ {step === 1 ? (
+
+ ) : null}
+
+ {step === 2 ? (
+
+
+
+
+
+
+ ) : null}
+
+ {step === 3 ? (
+
+
+
Name
+
{form.name || "—"}
+
+
+
Description
+
{form.description || "—"}
+
+
+
Focus / Visibility
+
+ {form.focus || "General"} / {form.visibility}
+
+
+
+
Goals
+
+ {goals.length ? goals.join(" · ") : "None"}
+
+
+
+
Tags
+
+ {tags.length ? tags.join(", ") : "None"}
+
+
+
+
Cofounders
+
+ {cofounders.length ? cofounders.join(", ") : "None"}
+
+
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+ {step < 3 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default FactionCreate;
diff --git a/src/pages/factions/Factions.tsx b/src/pages/factions/Factions.tsx
index dfa3766..140b200 100644
--- a/src/pages/factions/Factions.tsx
+++ b/src/pages/factions/Factions.tsx
@@ -96,6 +96,11 @@ const Factions: React.FC = () => {
return (
+
+
+
{factions === null ? (
Loading factions…
@@ -153,41 +158,49 @@ const Factions: React.FC = () => {
className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
aria-live="polite"
>
- {(factions ?? []).map((faction) => (
-
-
- {faction.name}
-
- {faction.description}
-
-
-
-
-
-
- }
- value={faction.acm}
- className="px-2 py-2"
- valueClassName="text-lg"
- />
-
-
-
+ {filtered.length === 0 ? (
+
+ No factions match this filter set.
- ))}
+ ) : (
+ filtered.map((faction) => (
+
+
+ {faction.name}
+
+ {faction.description}
+
+
+
+
+
+
+ }
+ value={faction.acm}
+ className="px-2 py-2"
+ valueClassName="text-lg"
+ />
+
+
+
+
+ ))
+ )}
>
)}
diff --git a/src/pages/invision/Invision.tsx b/src/pages/invision/Invision.tsx
index 4e9cbe1..fc0bef6 100644
--- a/src/pages/invision/Invision.tsx
+++ b/src/pages/invision/Invision.tsx
@@ -69,185 +69,194 @@ const Invision: React.FC = () => {
}, [factions, search, factionSort]);
return (
-
-
- {invision === null ? (
-
- Loading Invision…
-
- ) : null}
- {loadError ? (
-
- Invision unavailable: {loadError}
-
- ) : null}
- {invision !== null &&
- factions !== null &&
- factions.length === 0 &&
- !loadError ? (
-
- ) : null}
-
-
- Governance model
-
- {invision?.governanceState.label ?? "—"}
-
-
- {(invision?.governanceState.metrics ?? []).map((metric) => (
+
+
+
+ {invision === null ? (
+
+ Loading Invision…
+
+ ) : null}
+ {loadError ? (
+
+ Invision unavailable: {loadError}
+
+ ) : null}
+ {invision !== null &&
+ factions !== null &&
+ factions.length === 0 &&
+ !loadError ? (
+
+ ) : null}
+
- {metric.label}
- {metric.value}
+ Governance model
+
+ {invision?.governanceState.label ?? "—"}
+
- ))}
-
+ {(invision?.governanceState.metrics ?? []).map((metric) => (
+
+ {metric.label}
+ {metric.value}
+
+ ))}
+
+
+
setSearch(e.target.value)}
+ placeholder="Search factions, blocs, proposals…"
+ ariaLabel="Search invision"
+ filtersConfig={[
+ {
+ key: "factionSort",
+ label: "Sort factions",
+ options: [
+ { value: "members", label: "Members (desc)" },
+ { value: "votes", label: "Votes (desc)" },
+ { value: "acm", label: "ACM (desc)" },
+ ],
+ },
+ ]}
+ filtersState={filters}
+ onFiltersChange={setFilters}
+ />
- setSearch(e.target.value)}
- placeholder="Search factions, blocs, proposals…"
- ariaLabel="Search invision"
- filtersConfig={[
- {
- key: "factionSort",
- label: "Sort factions",
- options: [
- { value: "members", label: "Members (desc)" },
- { value: "votes", label: "Votes (desc)" },
- { value: "acm", label: "ACM (desc)" },
- ],
- },
- ]}
- filtersState={filters}
- onFiltersChange={setFilters}
- />
+
+
+
+ Largest factions
+
+
+ {filteredFactions.map((faction) => (
+
+
+
+ {faction.name}
+
+
+ {faction.description}
+
+
+
+
+ Members
+
+
+ {faction.members}
+
+
+
+
+ Votes, %
+
+ {faction.votes}
+
+
+
+ ACM
+
+
+ {faction.acm}
+
+
+
+
+
+ ))}
+
+
-
-
-
- Largest factions
-
-
- {filteredFactions.map((faction) => (
-
-
+
+
+ Treasury & economy
+
+
+ {(invision?.economicIndicators ?? []).map((indicator) => (
+
+
{indicator.label}
- {faction.name}
+ {indicator.value}
-
- {faction.description}
-
-
-
-
- Members
-
- {faction.members}
-
-
-
- Votes, %
-
- {faction.votes}
-
-
-
- ACM
-
-
- {faction.acm}
-
-
-
-
-
- ))}
-
-
-
-
-
- Treasury & economy
-
-
- {(invision?.economicIndicators ?? []).map((indicator) => (
-
-
{indicator.label}
-
- {indicator.value}
-
-
{indicator.detail}
-
- ))}
-
-
-
+ {indicator.detail}
+
+ ))}
+
+
+
-
-
-
- Risk dashboard
-
-
- {(invision?.riskSignals ?? []).map((signal) => (
-
-
{signal.title}
-
- {signal.status}
-
-
{signal.detail}
-
- ))}
-
-
+
+
+
+ Risk dashboard
+
+
+ {(invision?.riskSignals ?? []).map((signal) => (
+
+
{signal.title}
+
+ {signal.status}
+
+
{signal.detail}
+
+ ))}
+
+
-
-
- General chamber proposals
-
-
- {(invision?.chamberProposals ?? []).map((proposal) => (
-
-
- {proposal.title}
-
-
{proposal.effect}
-
{proposal.sponsors}
-
- ))}
-
-
+
+
+ General chamber proposals
+
+
+ {(invision?.chamberProposals ?? []).map((proposal) => (
+
+
+ {proposal.title}
+
+
{proposal.effect}
+
{proposal.sponsors}
+
+ ))}
+
+
+
+
+
+
+ coming sooner than you think...
+
);
diff --git a/src/types/api.ts b/src/types/api.ts
index f79928c..c43a8dd 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -70,13 +70,51 @@ export type FactionDto = {
id: string;
name: string;
description: string;
+ visibility: "public" | "private";
members: number;
votes: string;
acm: string;
focus: string;
goals: string[];
+ tags: string[];
initiatives: string[];
roster: FactionRosterMemberDto[];
+ channels?: Array<{
+ id: string;
+ slug: string;
+ title: string;
+ writeScope: "stewards" | "members";
+ isLocked: boolean;
+ threadCount: number;
+ }>;
+ threads?: Array<{
+ id: string;
+ channelId: string;
+ channelTitle: string;
+ title: string;
+ body: string;
+ status: "open" | "resolved" | "locked";
+ authorAddress: string;
+ replies: number;
+ createdAt: string;
+ updatedAt: string;
+ }>;
+ initiativesDetailed?: Array<{
+ id: string;
+ title: string;
+ intent: string;
+ ownerAddress: string;
+ status: "draft" | "active" | "blocked" | "done" | "archived";
+ checklist: string[];
+ links: string[];
+ updatedAt: string;
+ }>;
+ memberships?: Array<{
+ address: string;
+ role: "founder" | "steward" | "member";
+ isActive: boolean;
+ joinedAt: string;
+ }>;
};
export type GetFactionsResponse = { items: FactionDto[] };
@@ -285,6 +323,9 @@ export type GetMyGovernanceResponse = {
export type GetClockResponse = {
currentEra: number;
+ updatedAt: string;
+ eraSeconds: number;
+ nextEraAt: string;
activeGovernors: number;
currentEraRollup?: {
era: number;