From 97f22e527bbbc1a6b898ab052b39d247b85c6534 Mon Sep 17 00:00:00 2001
From: Cyber-preacher
Date: Wed, 8 Apr 2026 23:39:10 +0400
Subject: [PATCH] phase-83: add veto pages and harden governance UI
---
rsbuild.config.ts | 3 +
src/app/App.tsx | 1 +
src/app/AppRoutes.tsx | 10 +
src/components/AttachmentList.tsx | 1 +
src/components/ProposalSections.tsx | 99 +++--
src/components/ProposalStageBar.tsx | 18 +-
src/components/StageChip.tsx | 3 +
src/lib/apiClient.ts | 129 ++++++
src/pages/MyGovernance.tsx | 99 +++++
src/pages/factions/Faction.tsx | 186 ++++++---
src/pages/factions/Factions.tsx | 14 +-
src/pages/feed/Feed.tsx | 120 +++++-
src/pages/human-nodes/HumanNodes.tsx | 2 +-
src/pages/invision/Invision.tsx | 4 +-
src/pages/proposals/ProposalChamber.tsx | 215 ++++++----
src/pages/proposals/ProposalChamberVeto.tsx | 393 ++++++++++++++++++
src/pages/proposals/ProposalCitizenVeto.tsx | 322 ++++++++++++++
src/pages/proposals/ProposalCreation.tsx | 25 ++
src/pages/proposals/ProposalDraft.tsx | 16 -
src/pages/proposals/ProposalFinished.tsx | 22 +-
src/pages/proposals/ProposalFormation.tsx | 3 -
src/pages/proposals/ProposalPP.tsx | 87 ++--
src/pages/proposals/ProposalReferendum.tsx | 56 +--
src/pages/proposals/Proposals.tsx | 308 +++++++++-----
.../proposals/proposalCreation/toApiForm.ts | 3 +
src/pages/proposals/proposalCreation/types.ts | 2 +
src/pages/proposals/useProposalStageSync.ts | 18 +-
src/types/api.ts | 129 +++++-
src/types/stages.ts | 10 +
tests/unit/dto-parsers.test.ts | 4 -
30 files changed, 1902 insertions(+), 400 deletions(-)
create mode 100644 src/pages/proposals/ProposalChamberVeto.tsx
create mode 100644 src/pages/proposals/ProposalCitizenVeto.tsx
diff --git a/rsbuild.config.ts b/rsbuild.config.ts
index cd3b4a7..69c4d18 100644
--- a/rsbuild.config.ts
+++ b/rsbuild.config.ts
@@ -5,6 +5,9 @@ const isTest = process.env.RSTEST === "1";
export default defineConfig({
plugins: [pluginReact()],
+ html: {
+ title: "Vortex Sim",
+ },
server: {
...(isTest ? { host: "127.0.0.1", port: 0 } : {}),
proxy: {
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 9f8b100..00b5f74 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -7,6 +7,7 @@ const ScrollToTopOnRouteChange: React.FC = () => {
const { pathname, search } = useLocation();
useEffect(() => {
+ document.title = "Vortex Sim";
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, [pathname, search]);
diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx
index e46a4d2..668f4bb 100644
--- a/src/app/AppRoutes.tsx
+++ b/src/app/AppRoutes.tsx
@@ -15,6 +15,8 @@ import FactionInitiativeCreate from "../pages/factions/FactionInitiativeCreate";
import FactionCreate from "../pages/factions/FactionCreate";
import ProposalPP from "../pages/proposals/ProposalPP";
import ProposalChamber from "../pages/proposals/ProposalChamber";
+import ProposalCitizenVeto from "../pages/proposals/ProposalCitizenVeto";
+import ProposalChamberVeto from "../pages/proposals/ProposalChamberVeto";
import ProposalReferendum from "../pages/proposals/ProposalReferendum";
import ProposalFormation from "../pages/proposals/ProposalFormation";
import ProposalFinished from "../pages/proposals/ProposalFinished";
@@ -91,6 +93,14 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ }
+ />
+ }
+ />
}
diff --git a/src/components/AttachmentList.tsx b/src/components/AttachmentList.tsx
index 10d3834..45c4767 100644
--- a/src/components/AttachmentList.tsx
+++ b/src/components/AttachmentList.tsx
@@ -21,6 +21,7 @@ export function AttachmentList({
title = "Attachments",
className,
}: AttachmentListProps) {
+ if (items.length === 0) return null;
return (
;
@@ -58,8 +59,22 @@ type ProposalSummaryCardProps = {
executionPlan: string[];
budgetScope: string;
attachments: AttachmentItem[];
+ showExecutionPlan?: boolean;
+ showBudgetScope?: boolean;
};
+function canonicalizeProposalText(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/\s+/g, " ")
+ .trim()
+ .replace(/^[^a-z0-9]+/i, "")
+ .replace(/\b(the|a|an)\b/gi, " ")
+ .replace(/\s+/g, " ")
+ .replace(/[.!?]+$/g, "")
+ .trim();
+}
+
export function ProposalSummaryCard({
summary,
stats,
@@ -67,14 +82,28 @@ export function ProposalSummaryCard({
executionPlan,
budgetScope,
attachments,
+ showExecutionPlan,
+ showBudgetScope,
}: ProposalSummaryCardProps) {
const normalizedSummary = summary.replace(/\s+/g, " ").trim();
const normalizedOverview = overview.replace(/\s+/g, " ").trim();
- const showSummary = normalizedSummary !== normalizedOverview;
+ const normalizedBudgetScope = budgetScope.replace(/\s+/g, " ").trim();
+ const canonicalSummary = canonicalizeProposalText(normalizedSummary);
+ const canonicalOverview = canonicalizeProposalText(normalizedOverview);
+ const showSummary =
+ canonicalSummary.length > 0 &&
+ canonicalOverview.length > 0 &&
+ canonicalSummary !== canonicalOverview;
+ const showSummaryHeader = showSummary || stats.length > 0;
+ const renderExecutionPlan =
+ showExecutionPlan ?? executionPlan.some((item) => item.trim().length > 0);
+ const renderBudgetScope = showBudgetScope ?? normalizedBudgetScope.length > 0;
return (
- Summary
+ {showSummaryHeader ? (
+ Summary
+ ) : null}
{showSummary && {summary}
}
{stats.length > 0 && (
@@ -92,16 +121,20 @@ export function ProposalSummaryCard({
{overview}
-
-
- {executionPlan.map((item) => (
- {item}
- ))}
-
-
-
- {budgetScope}
-
+ {renderExecutionPlan ? (
+
+
+ {executionPlan.map((item) => (
+ {item}
+ ))}
+
+
+ ) : null}
+ {renderBudgetScope ? (
+
+ {budgetScope}
+
+ ) : null}
@@ -214,26 +247,6 @@ export function ProposalTeamMilestonesCard({
);
}
-type ProposalInvisionInsightCardProps = {
- insight: ProposalInvisionInsight;
-};
-
-export function ProposalInvisionInsightCard({
- insight,
-}: ProposalInvisionInsightCardProps) {
- return (
-
- Invision insight
- Role: {insight.role}
-
- {insight.bullets.map((item) => (
- {item}
- ))}
-
-
- );
-}
-
type ProposalTimelineCardProps = {
items: ProposalTimelineItem[];
proposalId?: string;
@@ -245,12 +258,18 @@ function isLikelyAddress(value: string): boolean {
function snapshotStageHref(
proposalId: string,
- stage: "pool" | "vote" | "build",
+ stage: "pool" | "vote" | "citizen_veto" | "chamber_veto" | "build",
): string | null {
if (stage === "pool")
return `/app/proposals/${proposalId}/pp?snapshotStage=pool`;
if (stage === "vote")
return `/app/proposals/${proposalId}/chamber?snapshotStage=vote`;
+ if (stage === "citizen_veto") {
+ return `/app/proposals/${proposalId}/citizen-veto?snapshotStage=citizen_veto`;
+ }
+ if (stage === "chamber_veto") {
+ return `/app/proposals/${proposalId}/chamber-veto?snapshotStage=chamber_veto`;
+ }
// `build` stage can become unavailable after terminal transition.
return null;
}
diff --git a/src/components/ProposalStageBar.tsx b/src/components/ProposalStageBar.tsx
index 23045ee..16855e0 100644
--- a/src/components/ProposalStageBar.tsx
+++ b/src/components/ProposalStageBar.tsx
@@ -5,6 +5,8 @@ export type ProposalStage =
| "draft"
| "pool"
| "vote"
+ | "citizen_veto"
+ | "chamber_veto"
| "build"
| "passed"
| "failed";
@@ -36,6 +38,8 @@ export const ProposalStageBar: React.FC = ({
label: "Chamber vote",
render: Chamber vote ,
},
+ { key: "citizen_veto", label: "Citizen veto" },
+ { key: "chamber_veto", label: "Chamber veto" },
{
key: "build",
label: "Formation",
@@ -62,11 +66,15 @@ export const ProposalStageBar: React.FC = ({
? "bg-primary text-[var(--primary-foreground)]"
: stage.key === "vote"
? "bg-[var(--accent)] text-[var(--accent-foreground)]"
- : stage.key === "build"
- ? "bg-[var(--accent-warm)] text-[var(--text)]"
- : stage.key === "passed"
- ? "bg-[color:var(--ok)]/20 text-[color:var(--ok)]"
- : "bg-[color:var(--danger)]/12 text-[color:var(--danger)]";
+ : stage.key === "citizen_veto"
+ ? "bg-[color:var(--danger)]/14 text-[color:var(--danger)]"
+ : stage.key === "chamber_veto"
+ ? "bg-[color:var(--danger)]/18 text-[color:var(--danger)] ring-1 ring-[color:var(--danger)]/25"
+ : stage.key === "build"
+ ? "bg-[var(--accent-warm)] text-[var(--text)]"
+ : stage.key === "passed"
+ ? "bg-[color:var(--ok)]/20 text-[color:var(--ok)]"
+ : "bg-[color:var(--danger)]/12 text-[color:var(--danger)]";
return (
= {
proposal_pool: "bg-[color:var(--accent-warm)]/15 text-[var(--accent-warm)]",
chamber_vote: "bg-[color:var(--accent)]/15 text-[var(--accent)]",
+ citizen_veto: "bg-[color:var(--danger)]/12 text-[color:var(--danger)]",
+ chamber_veto:
+ "bg-[color:var(--danger)]/16 text-[color:var(--danger)] ring-1 ring-[color:var(--danger)]/20",
formation: "bg-[color:var(--primary)]/12 text-primary",
passed: "bg-[color:var(--ok)]/20 text-[color:var(--ok)]",
failed: "bg-[color:var(--danger)]/12 text-[color:var(--danger)]",
diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts
index 8209c8a..84a6433 100644
--- a/src/lib/apiClient.ts
+++ b/src/lib/apiClient.ts
@@ -1,5 +1,6 @@
import type {
ChamberProposalPageDto,
+ ChamberVetoProposalPageDto,
ChamberChatPeerDto,
ChamberChatSignalDto,
ChamberThreadDetailDto,
@@ -7,6 +8,7 @@ import type {
ChamberThreadMessageDto,
ChamberChatMessageDto,
ChamberCmDto,
+ CitizenVetoProposalPageDto,
CmSummaryDto,
CourtCaseDetailDto,
FactionDto,
@@ -287,6 +289,35 @@ export async function apiProposalStatus(
return await apiGet
(`/api/proposals/${id}/status`);
}
+export type CitizenVetoVoteChoice = "veto" | "keep";
+
+export async function apiCitizenVetoVote(input: {
+ proposalId: string;
+ choice: CitizenVetoVoteChoice;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "veto.citizen.vote";
+ proposalId: string;
+ choice: CitizenVetoVoteChoice;
+ counts: { veto: number; keep: number };
+}> {
+ return await apiPost(
+ "/api/command",
+ {
+ type: "veto.citizen.vote",
+ payload: {
+ proposalId: input.proposalId,
+ choice: input.choice,
+ },
+ idempotencyKey: input.idempotencyKey,
+ },
+ input.idempotencyKey
+ ? { headers: { "idempotency-key": input.idempotencyKey } }
+ : undefined,
+ );
+}
+
export type PoolVoteDirection = "up" | "down";
export async function apiPoolVote(input: {
@@ -315,6 +346,8 @@ export async function apiPoolVote(input: {
export type ChamberVoteChoice = "yes" | "no" | "abstain";
+export type ChamberVetoVoteChoice = "veto" | "keep" | "abstain";
+
export async function apiChamberVote(input: {
proposalId: string;
choice: ChamberVoteChoice;
@@ -614,6 +647,22 @@ export async function apiProposalChamberPage(
return await apiGet(`/api/proposals/${id}/chamber`);
}
+export async function apiProposalCitizenVetoPage(
+ id: string,
+): Promise {
+ return await apiGet(
+ `/api/proposals/${id}/citizen-veto`,
+ );
+}
+
+export async function apiProposalChamberVetoPage(
+ id: string,
+): Promise {
+ return await apiGet(
+ `/api/proposals/${id}/chamber-veto`,
+ );
+}
+
export async function apiProposalReferendumPage(
id: string,
): Promise {
@@ -647,6 +696,36 @@ export async function apiReferendumVote(input: {
);
}
+export async function apiChamberVetoVote(input: {
+ proposalId: string;
+ chamberId: string;
+ choice: ChamberVetoVoteChoice;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "veto.chamber.vote";
+ proposalId: string;
+ chamberId: string;
+ choice: ChamberVetoVoteChoice;
+ counts: { veto: number; keep: number; abstain: number };
+}> {
+ return await apiPost(
+ "/api/command",
+ {
+ type: "veto.chamber.vote",
+ payload: {
+ proposalId: input.proposalId,
+ chamberId: input.chamberId,
+ choice: input.choice,
+ },
+ idempotencyKey: input.idempotencyKey,
+ },
+ input.idempotencyKey
+ ? { headers: { "idempotency-key": input.idempotencyKey } }
+ : undefined,
+ );
+}
+
export async function apiProposalFormationPage(
id: string,
): Promise {
@@ -868,6 +947,7 @@ export async function apiFactionJoin(input: {
type: "faction.join";
factionId: string;
joined: boolean;
+ pending: boolean;
}> {
return await apiPost(
"/api/command",
@@ -884,6 +964,54 @@ export async function apiFactionJoin(input: {
);
}
+export async function apiFactionJoinRequestApprove(input: {
+ factionId: string;
+ address: string;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "faction.join.request.approve";
+ factionId: string;
+ address: string;
+ accepted: true;
+}> {
+ return await apiPost(
+ "/api/command",
+ {
+ type: "faction.join.request.approve",
+ payload: { factionId: input.factionId, address: input.address },
+ idempotencyKey: input.idempotencyKey,
+ },
+ input.idempotencyKey
+ ? { headers: { "idempotency-key": input.idempotencyKey } }
+ : undefined,
+ );
+}
+
+export async function apiFactionJoinRequestDecline(input: {
+ factionId: string;
+ address: string;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "faction.join.request.decline";
+ factionId: string;
+ address: string;
+ declined: true;
+}> {
+ return await apiPost(
+ "/api/command",
+ {
+ type: "faction.join.request.decline",
+ payload: { factionId: input.factionId, address: input.address },
+ idempotencyKey: input.idempotencyKey,
+ },
+ input.idempotencyKey
+ ? { headers: { "idempotency-key": input.idempotencyKey } }
+ : undefined,
+ );
+}
+
export async function apiFactionLeave(input: {
factionId: string;
idempotencyKey?: string;
@@ -1351,6 +1479,7 @@ export async function apiProposalDraft(
export type ProposalDraftFormPayload = {
templateId?: "project" | "system";
presetId?: string;
+ resubmitsProposalId?: string;
formationEligible?: boolean;
title: string;
chamberId: string;
diff --git a/src/pages/MyGovernance.tsx b/src/pages/MyGovernance.tsx
index 0f571eb..ac86e9c 100644
--- a/src/pages/MyGovernance.tsx
+++ b/src/pages/MyGovernance.tsx
@@ -15,6 +15,7 @@ import { Button } from "@/components/primitives/button";
import { Input } from "@/components/primitives/input";
import { AddressInline } from "@/components/AddressInline";
import { PipelineList } from "@/components/PipelineList";
+import { StageChip } from "@/components/StageChip";
import { StatGrid, makeChamberStats } from "@/components/StatGrid";
import { Surface } from "@/components/Surface";
import { PageHint } from "@/components/PageHint";
@@ -27,6 +28,7 @@ import {
apiLegitimacyObjectSet,
apiCmMe,
apiMyGovernance,
+ apiProposals,
} from "@/lib/apiClient";
import { formatLoadError } from "@/lib/errorFormatting";
import { toTimestampMs } from "@/lib/dateTime";
@@ -35,6 +37,7 @@ import type {
CmSummaryDto,
GetClockResponse,
GetMyGovernanceResponse,
+ ProposalListItemDto,
} from "@/types/api";
import { cn } from "@/lib/utils";
@@ -182,6 +185,7 @@ const MyGovernance: React.FC = () => {
const [delegationErrorByChamber, setDelegationErrorByChamber] = useState<
Record
>({});
+ const [vetoWindows, setVetoWindows] = useState([]);
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
@@ -228,6 +232,39 @@ const MyGovernance: React.FC = () => {
};
}, []);
+ useEffect(() => {
+ if (!gov) {
+ setVetoWindows([]);
+ return;
+ }
+ let active = true;
+ const currentTierLabel = gov.tier?.tier ?? "Nominee";
+ (async () => {
+ try {
+ const [citizenResult, chamberResult] = await Promise.all([
+ currentTierLabel === "Citizen"
+ ? apiProposals({ stage: "citizen_veto" })
+ : Promise.resolve({ items: [] as ProposalListItemDto[] }),
+ currentTierLabel !== "Nominee"
+ ? apiProposals({ stage: "chamber_veto" })
+ : Promise.resolve({ items: [] as ProposalListItemDto[] }),
+ ]);
+ if (!active) return;
+ setVetoWindows(
+ [...citizenResult.items, ...chamberResult.items].sort(
+ (a, b) => toTimestampMs(b.date, -1) - toTimestampMs(a.date, -1),
+ ),
+ );
+ } catch {
+ if (!active) return;
+ setVetoWindows([]);
+ }
+ })();
+ return () => {
+ active = false;
+ };
+ }, [gov]);
+
const eraActivity = gov?.eraActivity;
const timeLeftValue = useMemo(() => {
const targetMs = clock?.nextEraAt
@@ -688,6 +725,68 @@ const MyGovernance: React.FC = () => {
+
+
+ Veto windows
+
+
+
+ Snapshot eligibility is finalized on each veto proposal page. This
+ section highlights currently open Citizen and Chamber Veto windows
+ you may want to inspect.
+
+ {vetoWindows.length === 0 ? (
+
+ No live veto windows right now.
+
+ ) : (
+
+ {vetoWindows.map((proposal) => (
+
+
+
+
+
+
+ {proposal.chamber}
+
+
+
+ {proposal.title}
+
+
{proposal.summary}
+
+
+
+ Open
+
+
+
+
+ ))}
+
+ )}
+
+
+
My chambers
diff --git a/src/pages/factions/Faction.tsx b/src/pages/factions/Faction.tsx
index 54cdc2e..edeaf30 100644
--- a/src/pages/factions/Faction.tsx
+++ b/src/pages/factions/Faction.tsx
@@ -23,6 +23,8 @@ import {
apiFactionCofounderInviteCancel,
apiFactionDelete,
apiFactionJoin,
+ apiFactionJoinRequestApprove,
+ apiFactionJoinRequestDecline,
apiFactionLeave,
apiFactionMemberRoleSet,
apiFactionUpdate,
@@ -89,6 +91,8 @@ const Faction: React.FC = () => {
const threads = faction?.threads ?? [];
const initiatives = faction?.initiativesDetailed ?? [];
const cofounderInvitations = faction?.cofounderInvitations ?? [];
+ const joinRequests = faction?.joinRequests ?? [];
+ const viewerJoinRequest = faction?.viewerJoinRequest ?? null;
const viewerMembership = useMemo(() => {
if (!viewerAddress) return null;
@@ -101,6 +105,8 @@ const Faction: React.FC = () => {
const viewerRole = viewerMembership?.isActive ? viewerMembership.role : null;
const isFounderAdmin = viewerRole === "founder";
+ const canModerateQueues =
+ viewerRole === "founder" || viewerRole === "steward";
const canJoin = !!viewerAddress && !viewerMembership?.isActive;
const canLeave =
!!viewerAddress && !!viewerMembership?.isActive && viewerRole !== "founder";
@@ -194,14 +200,18 @@ const Faction: React.FC = () => {
{canJoin ? (
runAction(async () => {
await apiFactionJoin({ factionId: faction.id });
})
}
>
- Join faction
+ {faction.visibility === "private"
+ ? viewerJoinRequest?.status === "pending"
+ ? "Request pending"
+ : "Request to join"
+ : "Join faction"}
) : null}
{canLeave ? (
@@ -237,6 +247,14 @@ const Faction: React.FC = () => {
) : null}
+ {viewerJoinRequest?.status === "pending" &&
+ !viewerMembership?.isActive ? (
+
+
+ Private faction join request pending
+
+
+ ) : null}
@@ -422,56 +440,124 @@ const Faction: React.FC = () => {
-
-
- Cofounder invitations
-
-
- {cofounderInvitations.length === 0 ? (
-
- ) : (
- cofounderInvitations.map((invite) => (
-
-
-
-
-
Invited by
-
-
· {formatDateTime(invite.invitedAt)}
+ {canModerateQueues ? (
+
+
+ Cofounder invitations
+
+
+ {cofounderInvitations.length === 0 ? (
+
+ ) : (
+ cofounderInvitations.map((invite) => (
+
+
+
+
+
Invited by
+
+
· {formatDateTime(invite.invitedAt)}
+
+
+
+ {invite.status}
+ {isFounderAdmin && invite.status === "pending" ? (
+
+ runAction(async () => {
+ await apiFactionCofounderInviteCancel({
+ factionId: faction.id,
+ address: invite.address,
+ });
+ })
+ }
+ >
+ Cancel
+
+ ) : null}
-
- {invite.status}
- {isFounderAdmin && invite.status === "pending" ? (
-
- runAction(async () => {
- await apiFactionCofounderInviteCancel({
- factionId: faction.id,
- address: invite.address,
- });
- })
- }
- >
- Cancel
-
- ) : null}
-
-
- ))
- )}
-
-
+ ))
+ )}
+
+
+ ) : null}
+
+ {canModerateQueues ? (
+
+
+ Join requests
+
+
+ {joinRequests.filter((request) => request.status === "pending")
+ .length === 0 ? (
+
+ ) : (
+ joinRequests
+ .filter((request) => request.status === "pending")
+ .map((request) => (
+
+
+
+
+ Requested
+ {formatDateTime(request.requestedAt)}
+
+
+
+
+ runAction(async () => {
+ await apiFactionJoinRequestApprove({
+ factionId: faction.id,
+ address: request.address,
+ });
+ })
+ }
+ >
+ Accept
+
+
+ runAction(async () => {
+ await apiFactionJoinRequestDecline({
+ factionId: faction.id,
+ address: request.address,
+ });
+ })
+ }
+ >
+ Decline
+
+
+
+ ))
+ )}
+
+
+ ) : null}
diff --git a/src/pages/factions/Factions.tsx b/src/pages/factions/Factions.tsx
index 7b145a4..51fc446 100644
--- a/src/pages/factions/Factions.tsx
+++ b/src/pages/factions/Factions.tsx
@@ -20,6 +20,12 @@ import type { FactionDto } from "@/types/api";
const Factions: React.FC = () => {
const [factions, setFactions] = useState(null);
+ const [serverTotals, setServerTotals] = useState<{
+ totalFactions: number;
+ totalMemberships: number;
+ uniqueMembers: number;
+ totalAcm: number;
+ } | null>(null);
const [loadError, setLoadError] = useState(null);
const [query, setQuery] = useState("");
const [filters, setFilters] = useState<{
@@ -35,10 +41,12 @@ const Factions: React.FC = () => {
const res = await apiFactions();
if (!active) return;
setFactions(res.items);
+ setServerTotals(res.totals ?? null);
setLoadError(null);
} catch (error) {
if (!active) return;
setFactions([]);
+ setServerTotals(null);
setLoadError((error as Error).message);
}
})();
@@ -49,7 +57,9 @@ const Factions: React.FC = () => {
const totals = useMemo(() => {
const list = factions ?? [];
- const totalMembers = list.reduce((sum, f) => sum + f.members, 0);
+ const totalMembers =
+ serverTotals?.uniqueMembers ??
+ list.reduce((sum, f) => sum + f.members, 0);
const totalAcm = list.reduce(
(sum, f) => sum + parseInt(f.acm.replace(/[,]/g, ""), 10),
0,
@@ -59,7 +69,7 @@ const Factions: React.FC = () => {
totalAcm,
totalFactions: list.length,
};
- }, [factions]);
+ }, [factions, serverTotals]);
const focusOptions = useMemo(
() => Array.from(new Set((factions ?? []).map((f) => f.focus))),
diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx
index 95d1b17..27dc339 100644
--- a/src/pages/feed/Feed.tsx
+++ b/src/pages/feed/Feed.tsx
@@ -22,11 +22,18 @@ import {
apiHuman,
apiMyGovernance,
apiProposalChamberPage,
+ apiProposalChamberVetoPage,
+ apiProposalCitizenVetoPage,
apiProposalFinishedPage,
apiProposalFormationPage,
apiProposalPoolPage,
} from "@/lib/apiClient";
-import type { FeedItemDto, ProposalFinishedPageDto } from "@/types/api";
+import type {
+ ChamberVetoProposalPageDto,
+ CitizenVetoProposalPageDto,
+ FeedItemDto,
+ ProposalFinishedPageDto,
+} from "@/types/api";
type FeedScope = "urgent" | "my" | "chambers" | "all";
@@ -79,7 +86,7 @@ const proposalIdFromHref = (href?: string) => {
? noQuery.slice("/app".length)
: noQuery;
const match = clean.match(
- /^\/proposals\/([^/]+)\/(pp|chamber|referendum|formation|finished)$/,
+ /^\/proposals\/([^/]+)\/(pp|chamber|citizen-veto|chamber-veto|referendum|formation|finished)$/,
);
return match?.[1] ?? null;
};
@@ -225,6 +232,12 @@ const Feed: React.FC = () => {
const [chamberPagesById, setChamberPagesById] = useState<
Record
>({});
+ const [citizenVetoPagesById, setCitizenVetoPagesById] = useState<
+ Record
+ >({});
+ const [chamberVetoPagesById, setChamberVetoPagesById] = useState<
+ Record
+ >({});
const [formationPagesById, setFormationPagesById] = useState<
Record
>({});
@@ -499,6 +512,22 @@ const Feed: React.FC = () => {
setChamberPagesById((curr) => ({ ...curr, [proposalId]: page }));
});
}
+ if (
+ item.stage === "citizen_veto" &&
+ citizenVetoPagesById[proposalId] === undefined
+ ) {
+ void apiProposalCitizenVetoPage(proposalId).then((page) => {
+ setCitizenVetoPagesById((curr) => ({ ...curr, [proposalId]: page }));
+ });
+ }
+ if (
+ item.stage === "chamber_veto" &&
+ chamberVetoPagesById[proposalId] === undefined
+ ) {
+ void apiProposalChamberVetoPage(proposalId).then((page) => {
+ setChamberVetoPagesById((curr) => ({ ...curr, [proposalId]: page }));
+ });
+ }
if (
item.stage === "build" &&
formationPagesById[proposalId] === undefined
@@ -512,6 +541,8 @@ const Feed: React.FC = () => {
feedItems,
poolPagesById,
chamberPagesById,
+ citizenVetoPagesById,
+ chamberVetoPagesById,
formationPagesById,
finishedPagesById,
]);
@@ -581,6 +612,14 @@ const Feed: React.FC = () => {
item.stage === "pool" ? poolPagesById[proposalId] : null;
const chamberPage =
item.stage === "vote" ? chamberPagesById[proposalId] : null;
+ const citizenVetoPage =
+ item.stage === "citizen_veto"
+ ? citizenVetoPagesById[proposalId]
+ : null;
+ const chamberVetoPage =
+ item.stage === "chamber_veto"
+ ? chamberVetoPagesById[proposalId]
+ : null;
const formationPage =
item.stage === "build" ? formationPagesById[proposalId] : null;
@@ -719,6 +758,12 @@ const Feed: React.FC = () => {
};
})()
: null;
+ const keyStats =
+ finishedPage?.stats ??
+ citizenVetoPage?.stats ??
+ chamberVetoPage?.stats ??
+ item.stats ??
+ [];
return (
{
value={`${poolStats.upvoteFloorProgressPercent}% / ${poolStats.upvoteFloorFractionPercent}%`}
tone={poolStats.meetsUpvoteFloor ? "ok" : "warn"}
/>
-
) : item.stage === "vote" && chamberPage && chamberStats ? (
@@ -895,6 +935,68 @@ const Feed: React.FC = () => {
/>
+ ) : item.stage === "citizen_veto" && citizenVetoPage ? (
+
+
+ Citizen veto snapshot
+
+
+
+ Eligible Citizens: {citizenVetoPage.eligibleCitizens}
+
+
+ Attempts used: {citizenVetoPage.attemptsUsed} ·
+ Remaining: {citizenVetoPage.attemptsRemaining}
+
+
+
+ {citizenVetoPage.stageData.map((entry, idx) => (
+
+ ))}
+
+
+ ) : item.stage === "chamber_veto" && chamberVetoPage ? (
+
+
+ Chamber veto snapshot
+
+
+
+ Vetoing chambers: {chamberVetoPage.vetoingChambers} /{" "}
+ {chamberVetoPage.chamberThreshold}
+
+
+ Active chambers: {chamberVetoPage.activeChambers}
+
+
+
+ {chamberVetoPage.stageData.map((entry, idx) => (
+
+ ))}
+
+
) : item.stage === "build" &&
formationPage &&
formationStats ? (
@@ -1042,13 +1144,13 @@ const Feed: React.FC = () => {
) : null}
- {(finishedPage || item.stats) &&
+ {keyStats.length > 0 &&
item.stage !== "courts" &&
item.stage !== "thread" ? (
Key stats
- {(finishedPage?.stats ?? item.stats ?? []).map((stat) => (
+ {keyStats.map((stat) => (
{
{node.formationCapable && (
- Formation
+ Active node
)}
diff --git a/src/pages/invision/Invision.tsx b/src/pages/invision/Invision.tsx
index 3a88e3e..3e4ee17 100644
--- a/src/pages/invision/Invision.tsx
+++ b/src/pages/invision/Invision.tsx
@@ -171,7 +171,7 @@ const Invision: React.FC = () => {
-
Window
+
Timeframe
{decentralization.windowLabel}
@@ -229,7 +229,7 @@ const Invision: React.FC = () => {
-
Window
+
Timeframe
{stability.windowLabel}
diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx
index 8e52cb7..7f13df7 100644
--- a/src/pages/proposals/ProposalChamber.tsx
+++ b/src/pages/proposals/ProposalChamber.tsx
@@ -7,7 +7,6 @@ import { VoteButton } from "@/components/VoteButton";
import { AddressInline } from "@/components/AddressInline";
import { Input } from "@/components/primitives/input";
import {
- ProposalInvisionInsightCard,
ProposalSummaryCard,
ProposalTeamMilestonesCard,
ProposalTimelineCard,
@@ -29,6 +28,7 @@ import {
useProposalStageSync,
useProposalTransitionNotice,
} from "./useProposalStageSync";
+import { useAuth } from "@/app/auth/AuthContext";
const ProposalChamber: React.FC = () => {
const { id } = useParams();
@@ -39,6 +39,7 @@ const ProposalChamber: React.FC = () => {
const [timeline, setTimeline] = useState
([]);
const [timelineError, setTimelineError] = useState(null);
const [yesScore, setYesScore] = useState(5);
+ const auth = useAuth();
const syncProposalStage = useProposalStageSync(id);
const transitionNotice = useProposalTransitionNotice();
const loadPage = useCallback(async () => {
@@ -85,6 +86,15 @@ const ProposalChamber: React.FC = () => {
};
}, [id]);
+ useEffect(() => {
+ if (
+ proposal?.viewerVote?.choice === "yes" &&
+ typeof proposal.viewerVote.score === "number"
+ ) {
+ setYesScore(proposal.viewerVote.score);
+ }
+ }, [proposal?.viewerVote?.choice, proposal?.viewerVote?.score]);
+
if (!proposal) {
return (
@@ -144,6 +154,9 @@ const ProposalChamber: React.FC = () => {
? proposal.milestoneIndex
: null;
const referendumVote = proposal.voteKind === "referendum";
+ const viewerIsProposer =
+ auth.address?.trim().toLowerCase() ===
+ proposal.proposerId.trim().toLowerCase();
const scoreLabel =
proposal.scoreLabel === "MM" || milestoneVoteIndex !== null
? "MM"
@@ -155,17 +168,74 @@ const ProposalChamber: React.FC = () => {
: milestoneVoteIndex !== null
? `${proposal.title} — Milestone vote (M${milestoneVoteIndex})`
: proposal.title;
+ const delegationNote = proposal.delegation?.viewer ? (
+ proposal.delegation.viewer.delegateeAddress ? (
+ proposal.delegation.viewer.hasDirectVote ? (
+ <>
+ Direct vote overrides your delegation to{" "}
+
+ .
+ >
+ ) : (
+ <>
+ You delegate to{" "}
+
+ . Direct vote overrides it here.
+ >
+ )
+ ) : proposal.delegation.viewer.inboundDelegatedWeight > 0 ? (
+ <>Your vote currently carries delegated weight.>
+ ) : null
+ ) : proposal.delegation?.source === "snapshot" ? (
+ <>Uses a frozen delegation snapshot.>
+ ) : proposal.delegation ? (
+ <>Uses current chamber delegations.>
+ ) : null;
- const [filledSlots, totalSlots] = proposal.teamSlots
- .split("/")
- .map((v) => Number(v.trim()));
+ const [filledSlots, totalSlots] = proposal.formationEligible
+ ? proposal.teamSlots.split("/").map((v) => Number(v.trim()))
+ : [0, 0];
const openSlots = Math.max(totalSlots - filledSlots, 0);
+ const formationSummaryStats = proposal.formationEligible
+ ? [
+ { label: "Budget ask", value: proposal.budget },
+ {
+ label: "Formation",
+ value: "Yes",
+ },
+ {
+ label: "Team slots",
+ value: `${proposal.teamSlots} (open: ${openSlots})`,
+ },
+ {
+ label: "Milestones",
+ value: `${proposal.milestones} milestones planned`,
+ },
+ ]
+ : [];
+ const viewerVoteLabel = proposal.viewerVote
+ ? proposal.viewerVote.choice === "yes"
+ ? `Yes${
+ typeof proposal.viewerVote.score === "number"
+ ? ` (score ${proposal.viewerVote.score})`
+ : ""
+ }`
+ : proposal.viewerVote.choice === "no"
+ ? "No"
+ : "Abstain"
+ : null;
const handleVote = async (
choice: "yes" | "no" | "abstain",
score?: number,
) => {
- if (!id || submitting) return;
+ if (!id || submitting || viewerIsProposer) return;
setSubmitting(true);
setSubmitError(null);
try {
@@ -219,7 +289,12 @@ const ProposalChamber: React.FC = () => {
handleVote("yes", yesScore)}
/>
{proposal.scoreEnabled && scoreLabel ? (
@@ -246,16 +321,36 @@ const ProposalChamber: React.FC = () => {
handleVote("no")}
/>
handleVote("abstain")}
/>
+ {viewerIsProposer ? (
+
+ You cannot vote on your own proposal.
+
+ ) : null}
{submitError ? (
{
{formatLoadError(submitError)}
) : null}
+ {proposal.viewerVote && !viewerIsProposer ? (
+
+ Your current vote:{" "}
+ {viewerVoteLabel}
+
+ Recorded {formatDateTime(proposal.viewerVote.updatedAt)}
+
+
+ ) : null}
@@ -414,87 +523,37 @@ const ProposalChamber: React.FC = () => {
valueClassName="flex flex-col items-center gap-1 text-2xl font-semibold"
/>
-
- {proposal.delegation.viewer ? (
- proposal.delegation.viewer.delegateeAddress ? (
- proposal.delegation.viewer.hasDirectVote ? (
- <>
- Your direct vote is overriding your delegation to{" "}
- {" "}
- on this proposal.
- >
- ) : (
- <>
- You currently delegate your vote in this chamber to{" "}
-
- . If you cast a direct vote on this proposal, that
- delegation will be overridden here.
- >
- )
- ) : proposal.delegation.viewer.inboundDelegatedWeight > 0 ? (
- <>
- Other governors currently delegate to you, so your direct vote
- carries additional weight on this proposal.
- >
- ) : (
- <>You are voting with only your base chamber weight here.>
- )
- ) : proposal.delegation.source === "snapshot" ? (
- <>
- This vote is using a proposal-local delegation snapshot, so
- later delegation changes will not rewrite this chamber vote.
- >
- ) : (
- <>
- This vote is currently using the live delegation graph because
- no proposal-local snapshot was found.
- >
- )}
-
+ {delegationNote ? (
+
+ {delegationNote}
+
+ ) : null}
) : null}
-
-
-
+ {proposal.formationEligible ? (
+
+ ) : null}
{timelineError ? (
{
+ const { id } = useParams();
+ const [proposal, setProposal] = useState(
+ null,
+ );
+ const [loadError, setLoadError] = useState(null);
+ const [submitError, setSubmitError] = useState(null);
+ const [submittingKey, setSubmittingKey] = useState(null);
+ const auth = useAuth();
+ const [timeline, setTimeline] = useState([]);
+ const [timelineError, setTimelineError] = useState(null);
+ const syncProposalStage = useProposalStageSync(id);
+ const transitionNotice = useProposalTransitionNotice();
+
+ const loadPage = useCallback(async () => {
+ if (!id) return;
+ const page = await apiProposalChamberVetoPage(id);
+ setProposal(page);
+ setLoadError(null);
+ }, [id]);
+
+ useEffect(() => {
+ if (!id) return;
+ let active = true;
+ (async () => {
+ try {
+ const [pageResult, timelineResult] = await Promise.allSettled([
+ apiProposalChamberVetoPage(id),
+ apiProposalTimeline(id),
+ ]);
+ if (!active) return;
+ if (pageResult.status === "fulfilled") {
+ setProposal(pageResult.value);
+ setLoadError(null);
+ } else {
+ setProposal(null);
+ setLoadError(
+ pageResult.reason?.message ?? "Failed to load chamber veto",
+ );
+ }
+ if (timelineResult.status === "fulfilled") {
+ setTimeline(timelineResult.value.items);
+ setTimelineError(null);
+ } else {
+ setTimeline([]);
+ setTimelineError(
+ timelineResult.reason?.message ?? "Failed to load timeline",
+ );
+ }
+ } catch (error) {
+ if (!active) return;
+ setProposal(null);
+ setLoadError((error as Error).message);
+ }
+ })();
+ return () => {
+ active = false;
+ };
+ }, [id]);
+
+ if (!proposal) {
+ return (
+
+
+ {transitionNotice ? (
+
+ {transitionNotice}
+
+ ) : null}
+
+ {loadError
+ ? `Chamber veto unavailable: ${formatLoadError(loadError, "Failed to load chamber veto.")}`
+ : "Loading chamber veto…"}
+
+
+ );
+ }
+
+ const viewerIsProposer =
+ auth.address?.trim().toLowerCase() ===
+ proposal.proposerId.trim().toLowerCase();
+
+ const handleVote = async (
+ chamberId: string,
+ choice: "veto" | "keep" | "abstain",
+ ) => {
+ if (!id || submittingKey || viewerIsProposer) return;
+ setSubmittingKey(`${chamberId}:${choice}`);
+ setSubmitError(null);
+ try {
+ await apiChamberVetoVote({
+ proposalId: id,
+ chamberId,
+ choice,
+ });
+ const redirected = await syncProposalStage();
+ if (redirected) return;
+ await loadPage();
+ } catch (error) {
+ setSubmitError((error as Error).message);
+ } finally {
+ setSubmittingKey(null);
+ void syncProposalStage();
+ }
+ };
+
+ return (
+
+
+ {transitionNotice ? (
+
+ {transitionNotice}
+
+ ) : null}
+
+
+
+ All snapped active chambers may separately decide whether to veto
+
+ {viewerIsProposer ? (
+
+ You cannot vote on your own proposal.
+
+ ) : null}
+ {submitError ? (
+
+ {formatLoadError(submitError)}
+
+ ) : null}
+
+
+
+ Chamber veto window
+
+
+
+
+
+
+
+
+
+ Per-chamber votes
+
+ {proposal.chambers.map((chamber) => {
+ const totalVotes =
+ chamber.votes.veto + chamber.votes.keep + chamber.votes.abstain;
+ const chamberKey = chamber.chamberId;
+ return (
+
+
+
+
+ {chamber.chamberTitle}
+
+
+ {chamber.countsAsVetoing
+ ? "This chamber currently counts as vetoing."
+ : "This chamber has not yet crossed its internal veto threshold."}
+
+
+
+ {chamber.countsAsVetoing ? "Vetoing" : "Not vetoing"}
+
+
+
+
+
+
+
+
+
+
+
+ {viewerIsProposer
+ ? "You cannot vote on your own proposal."
+ : chamber.viewer.eligible
+ ? chamber.viewer.currentVote
+ ? `Your current vote: ${chamber.viewer.currentVote}`
+ : "You are eligible to vote in this chamber."
+ : "You are not eligible in this chamber’s snapped electorate."}
+
+
+
+ handleVote(chamber.chamberId, "veto")}
+ />
+ handleVote(chamber.chamberId, "keep")}
+ />
+ handleVote(chamber.chamberId, "abstain")}
+ />
+
+
+
+ {totalVotes} votes cast so far in this chamber.
+
+
+ );
+ })}
+
+
+
+
+
+ {timelineError ? (
+
+ Timeline unavailable: {formatLoadError(timelineError)}
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default ProposalChamberVeto;
diff --git a/src/pages/proposals/ProposalCitizenVeto.tsx b/src/pages/proposals/ProposalCitizenVeto.tsx
new file mode 100644
index 0000000..5d6f4bb
--- /dev/null
+++ b/src/pages/proposals/ProposalCitizenVeto.tsx
@@ -0,0 +1,322 @@
+import { useCallback, useEffect, useState } from "react";
+import { useParams } from "react-router";
+
+import { PageHint } from "@/components/PageHint";
+import { ProposalPageHeader } from "@/components/ProposalPageHeader";
+import {
+ ProposalSummaryCard,
+ ProposalTimelineCard,
+} from "@/components/ProposalSections";
+import { StatTile } from "@/components/StatTile";
+import { Surface } from "@/components/Surface";
+import { VoteButton } from "@/components/VoteButton";
+import {
+ apiCitizenVetoVote,
+ apiProposalCitizenVetoPage,
+ apiProposalTimeline,
+} from "@/lib/apiClient";
+import { formatLoadError } from "@/lib/errorFormatting";
+import type {
+ CitizenVetoProposalPageDto,
+ ProposalTimelineItemDto,
+} from "@/types/api";
+import {
+ useProposalStageSync,
+ useProposalTransitionNotice,
+} from "./useProposalStageSync";
+import { useAuth } from "@/app/auth/AuthContext";
+
+const ProposalCitizenVeto: React.FC = () => {
+ const { id } = useParams();
+ const [proposal, setProposal] = useState(
+ null,
+ );
+ const [loadError, setLoadError] = useState(null);
+ const [submitError, setSubmitError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const auth = useAuth();
+ const [timeline, setTimeline] = useState([]);
+ const [timelineError, setTimelineError] = useState(null);
+ const syncProposalStage = useProposalStageSync(id);
+ const transitionNotice = useProposalTransitionNotice();
+
+ const loadPage = useCallback(async () => {
+ if (!id) return;
+ const page = await apiProposalCitizenVetoPage(id);
+ setProposal(page);
+ setLoadError(null);
+ }, [id]);
+
+ useEffect(() => {
+ if (!id) return;
+ let active = true;
+ (async () => {
+ try {
+ const [pageResult, timelineResult] = await Promise.allSettled([
+ apiProposalCitizenVetoPage(id),
+ apiProposalTimeline(id),
+ ]);
+ if (!active) return;
+ if (pageResult.status === "fulfilled") {
+ setProposal(pageResult.value);
+ setLoadError(null);
+ } else {
+ setProposal(null);
+ setLoadError(
+ pageResult.reason?.message ?? "Failed to load citizen veto",
+ );
+ }
+ if (timelineResult.status === "fulfilled") {
+ setTimeline(timelineResult.value.items);
+ setTimelineError(null);
+ } else {
+ setTimeline([]);
+ setTimelineError(
+ timelineResult.reason?.message ?? "Failed to load timeline",
+ );
+ }
+ } catch (error) {
+ if (!active) return;
+ setProposal(null);
+ setLoadError((error as Error).message);
+ }
+ })();
+ return () => {
+ active = false;
+ };
+ }, [id]);
+
+ if (!proposal) {
+ return (
+
+
+ {transitionNotice ? (
+
+ {transitionNotice}
+
+ ) : null}
+
+ {loadError
+ ? `Citizen veto unavailable: ${formatLoadError(loadError, "Failed to load citizen veto.")}`
+ : "Loading citizen veto…"}
+
+
+ );
+ }
+
+ const castVotes = proposal.votes.veto + proposal.votes.keep;
+ const quorumPercent =
+ proposal.eligibleCitizens > 0
+ ? Math.round((castVotes / proposal.eligibleCitizens) * 100)
+ : 0;
+ const vetoPercent =
+ castVotes > 0 ? Math.round((proposal.votes.veto / castVotes) * 100) : 0;
+ const viewerIsProposer =
+ auth.address?.trim().toLowerCase() ===
+ proposal.proposerId.trim().toLowerCase();
+
+ const handleVote = async (choice: "veto" | "keep") => {
+ if (!id || submitting || !proposal.viewer.eligible || viewerIsProposer)
+ return;
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ await apiCitizenVetoVote({ proposalId: id, choice });
+ const redirected = await syncProposalStage();
+ if (redirected) return;
+ await loadPage();
+ } catch (error) {
+ setSubmitError((error as Error).message);
+ } finally {
+ setSubmitting(false);
+ void syncProposalStage();
+ }
+ };
+
+ return (
+
+
+ {transitionNotice ? (
+
+ {transitionNotice}
+
+ ) : null}
+
+
+
+ Only snapped Citizen-tier voters can participate in this window
+
+
+ handleVote("veto")}
+ />
+ handleVote("keep")}
+ />
+
+
+ {viewerIsProposer
+ ? "You cannot vote on your own proposal."
+ : proposal.viewer.eligible
+ ? proposal.viewer.currentVote
+ ? `Your current vote: ${proposal.viewer.currentVote === "veto" ? "Veto" : "Keep"}`
+ : "You are eligible to vote in this Citizen Veto window."
+ : "You are not eligible in the snapped Citizen electorate for this window."}
+
+ {submitError ? (
+
+ {formatLoadError(submitError)}
+
+ ) : null}
+
+
+
+ Citizen veto window
+
+
+
+
+ {castVotes} / {proposal.quorumNeeded}
+
+
+ {quorumPercent}% of snapped Citizens
+
+ >
+ }
+ variant="panel"
+ className="flex min-h-24 flex-col items-center justify-center gap-1 py-4"
+ valueClassName="flex flex-col items-center gap-1 text-2xl font-semibold"
+ />
+
+
+ {proposal.votes.veto} / {proposal.vetoNeeded}
+
+
+ {vetoPercent}% veto among cast votes
+
+ >
+ }
+ variant="panel"
+ className="flex min-h-24 flex-col items-center justify-center gap-1 py-4"
+ valueClassName="flex flex-col items-center gap-1 text-2xl font-semibold"
+ />
+
+
+ {proposal.attemptsUsed} /{" "}
+ {proposal.attemptsUsed + proposal.attemptsRemaining}
+
+
+ {proposal.attemptsRemaining} Citizen veto tries left
+
+ >
+ }
+ variant="panel"
+ className="flex min-h-24 flex-col items-center justify-center gap-1 py-4"
+ valueClassName="flex flex-col items-center gap-1 text-2xl font-semibold"
+ />
+
+
+
+
+
+ {timelineError ? (
+
+ Timeline unavailable: {formatLoadError(timelineError)}
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default ProposalCitizenVeto;
diff --git a/src/pages/proposals/ProposalCreation.tsx b/src/pages/proposals/ProposalCreation.tsx
index 3de8f08..753d773 100644
--- a/src/pages/proposals/ProposalCreation.tsx
+++ b/src/pages/proposals/ProposalCreation.tsx
@@ -116,6 +116,9 @@ const ProposalCreation: React.FC = () => {
null,
);
const requestedDraftId = (searchParams.get("draftId") ?? "").trim();
+ const requestedResubmitsProposalId = (
+ searchParams.get("resubmitsProposalId") ?? ""
+ ).trim();
useEffect(() => {
const preset = PROPOSAL_PRESETS.find((item) => item.id === presetId);
@@ -332,6 +335,18 @@ const ProposalCreation: React.FC = () => {
};
}, [navigate, requestedDraftId]);
+ useEffect(() => {
+ if (requestedDraftId) return;
+ setDraft((prev) => {
+ const nextLineage = requestedResubmitsProposalId || undefined;
+ if (prev.resubmitsProposalId === nextLineage) return prev;
+ return {
+ ...prev,
+ resubmitsProposalId: nextLineage,
+ };
+ });
+ }, [requestedDraftId, requestedResubmitsProposalId]);
+
useEffect(() => {
if (!auth.enabled || !auth.authenticated) {
setTierProgress(null);
@@ -466,6 +481,16 @@ const ProposalCreation: React.FC = () => {
return (
+ {draft.resubmitsProposalId ? (
+
+ This draft is marked as a reconsideration of decision lineage{" "}
+
+ {draft.resubmitsProposalId}
+
+ . Submit it only if you intend this proposal to count as the same
+ decision lineage.
+
+ ) : null}
diff --git a/src/pages/proposals/ProposalDraft.tsx b/src/pages/proposals/ProposalDraft.tsx
index f868be1..470e7b8 100644
--- a/src/pages/proposals/ProposalDraft.tsx
+++ b/src/pages/proposals/ProposalDraft.tsx
@@ -376,22 +376,6 @@ const ProposalDraft: React.FC = () => {
/>
-
-
-
- Invision insight
-
-
-
- {draftDetails.invisionInsight.role}
-
-
- {draftDetails.invisionInsight.bullets.map((bullet) => (
- {bullet}
- ))}
-
-
-
);
};
diff --git a/src/pages/proposals/ProposalFinished.tsx b/src/pages/proposals/ProposalFinished.tsx
index 59404dc..3c599ee 100644
--- a/src/pages/proposals/ProposalFinished.tsx
+++ b/src/pages/proposals/ProposalFinished.tsx
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
-import { useParams } from "react-router";
+import { Link, useParams } from "react-router";
+import { Button } from "@/components/primitives/button";
import { Surface } from "@/components/Surface";
import { PageHint } from "@/components/PageHint";
import { ProposalPageHeader } from "@/components/ProposalPageHeader";
import {
- ProposalInvisionInsightCard,
ProposalSummaryCard,
ProposalTeamMilestonesCard,
ProposalTimelineCard,
@@ -116,7 +116,19 @@ const ProposalFinished: React.FC = () => {
showFormationStage={proposal.formationEligible}
chamber={proposal.chamber}
proposer={proposal.proposer}
- />
+ >
+ {proposal.canReconsider ? (
+
+
+
+ Resubmit for reconsideration
+
+
+
+ ) : null}
+
@@ -158,6 +170,8 @@ const ProposalFinished: React.FC = () => {
executionPlan={proposal.executionPlan}
budgetScope={proposal.budgetScope}
attachments={proposal.attachments}
+ showExecutionPlan={proposal.formationEligible}
+ showBudgetScope={proposal.formationEligible}
/>
{showFormationDetails ? (
@@ -168,8 +182,6 @@ const ProposalFinished: React.FC = () => {
/>
) : null}
-
-
{timelineError ? (
{
milestonesDetail={project.milestonesDetail}
/>
-
-
{timelineError ? (
{
);
}
- const [filledSlots, totalSlots] = proposal.teamSlots
- .split("/")
- .map((v) => Number(v.trim()));
+ const [filledSlots, totalSlots] = proposal.formationEligible
+ ? proposal.teamSlots.split("/").map((v) => Number(v.trim()))
+ : [0, 0];
const openSlots = Math.max(totalSlots - filledSlots, 0);
- const poolWindowOpen = proposal.timeLeft !== "Ended";
+ const viewerIsProposer =
+ auth.address?.trim().toLowerCase() ===
+ proposal.proposerId.trim().toLowerCase();
+ const formationSummaryStats = proposal.formationEligible
+ ? [
+ { label: "Budget ask", value: proposal.budget },
+ {
+ label: "Formation",
+ value: "Yes",
+ },
+ {
+ label: "Team slots",
+ value: `${proposal.teamSlots} (open: ${openSlots})`,
+ },
+ {
+ label: "Milestones",
+ value: `${proposal.milestones} milestones planned`,
+ },
+ ]
+ : [];
const votingAllowed =
- poolWindowOpen &&
+ !viewerIsProposer &&
(!auth.enabled || (auth.authenticated && auth.eligible && !auth.loading));
- const votingDisabledReason = !poolWindowOpen
- ? "Pool window ended."
+ const votingDisabledReason = viewerIsProposer
+ ? "You cannot vote on your own proposal."
: auth.enabled && auth.loading
? "Checking wallet status…"
: auth.enabled && !auth.authenticated
@@ -202,9 +221,9 @@ const ProposalPP: React.FC = () => {
}}
/>
- {!poolWindowOpen ? (
+ {viewerIsProposer ? (
- Pool window ended. This proposal can no longer receive pool votes.
+ You cannot vote on your own proposal.
) : null}
@@ -255,46 +274,28 @@ const ProposalPP: React.FC = () => {
className="flex min-h-24 flex-col items-center justify-center gap-1 py-4"
valueClassName="text-2xl font-semibold"
/>
-
-
+ {proposal.formationEligible ? (
+
+ ) : null}
{
{
engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0;
const passingNeededPercent = 66.6;
- const [filledSlots, totalSlots] = proposal.teamSlots
- .split("/")
- .map((v) => Number(v.trim()));
+ const [filledSlots, totalSlots] = proposal.formationEligible
+ ? proposal.teamSlots.split("/").map((v) => Number(v.trim()))
+ : [0, 0];
const openSlots = Math.max(totalSlots - filledSlots, 0);
+ const formationSummaryStats = proposal.formationEligible
+ ? [
+ { label: "Budget ask", value: proposal.budget },
+ {
+ label: "Formation",
+ value: "Yes",
+ },
+ {
+ label: "Team slots",
+ value: `${proposal.teamSlots} (open: ${openSlots})`,
+ },
+ {
+ label: "Milestones",
+ value: `${proposal.milestones} milestones planned`,
+ },
+ ]
+ : [];
const handleVote = async (choice: "yes" | "no" | "abstain") => {
if (!id || submitting) return;
@@ -288,34 +304,22 @@ const ProposalReferendum: React.FC = () => {
-
-
-
+ {proposal.formationEligible ? (
+
+ ) : null}
{timelineError ? (
{
const [chamberPagesById, setChamberPagesById] = useState<
Record
>({});
+ const [citizenVetoPagesById, setCitizenVetoPagesById] = useState<
+ Record
+ >({});
+ const [chamberVetoPagesById, setChamberVetoPagesById] = useState<
+ Record
+ >({});
const [formationPagesById, setFormationPagesById] = useState<
Record
>({});
@@ -138,6 +148,22 @@ const Proposals: React.FC = () => {
setChamberPagesById((curr) => ({ ...curr, [proposal.id]: page }));
});
}
+ if (
+ proposal.stage === "citizen_veto" &&
+ citizenVetoPagesById[proposal.id] === undefined
+ ) {
+ void apiProposalCitizenVetoPage(proposal.id).then((page) => {
+ setCitizenVetoPagesById((curr) => ({ ...curr, [proposal.id]: page }));
+ });
+ }
+ if (
+ proposal.stage === "chamber_veto" &&
+ chamberVetoPagesById[proposal.id] === undefined
+ ) {
+ void apiProposalChamberVetoPage(proposal.id).then((page) => {
+ setChamberVetoPagesById((curr) => ({ ...curr, [proposal.id]: page }));
+ });
+ }
if (
proposal.stage === "build" &&
formationPagesById[proposal.id] === undefined
@@ -151,6 +177,8 @@ const Proposals: React.FC = () => {
proposalData,
poolPagesById,
chamberPagesById,
+ citizenVetoPagesById,
+ chamberVetoPagesById,
formationPagesById,
finishedPagesById,
]);
@@ -250,6 +278,8 @@ const Proposals: React.FC = () => {
{ value: "any", label: "Any" },
{ value: "pool", label: "Proposal pool" },
{ value: "vote", label: "Chamber vote" },
+ { value: "citizen_veto", label: "Citizen veto" },
+ { value: "chamber_veto", label: "Chamber veto" },
{ value: "build", label: "Formation" },
{ value: "passed", label: "Passed" },
{ value: "failed", label: "Ended (failed)" },
@@ -326,6 +356,14 @@ const Proposals: React.FC = () => {
proposal.stage === "pool" ? poolPagesById[proposal.id] : null;
const chamberPage =
proposal.stage === "vote" ? chamberPagesById[proposal.id] : null;
+ const citizenVetoPage =
+ proposal.stage === "citizen_veto"
+ ? citizenVetoPagesById[proposal.id]
+ : null;
+ const chamberVetoPage =
+ proposal.stage === "chamber_veto"
+ ? chamberVetoPagesById[proposal.id]
+ : null;
const formationPage =
proposal.stage === "build"
? formationPagesById[proposal.id]
@@ -465,6 +503,76 @@ const Proposals: React.FC = () => {
proposal.stage === "build" && formationPage
? getFormationProgress(formationPage)
: null;
+ const keyStats =
+ proposal.stage === "pool" && poolPage && poolStats
+ ? poolPage.formationEligible
+ ? [
+ {
+ label: "Budget ask",
+ value: poolPage.budget,
+ },
+ {
+ label: "Formation",
+ value: "Yes",
+ },
+ {
+ label: "Team slots",
+ value: `${poolPage.teamSlots} (open: ${poolStats.openSlots})`,
+ },
+ {
+ label: "Milestones",
+ value: `${poolStats.milestonesCount} planned`,
+ },
+ ]
+ : []
+ : proposal.stage === "vote" && chamberPage && chamberStats
+ ? chamberPage.formationEligible
+ ? [
+ {
+ label: "Budget ask",
+ value: chamberPage.budget,
+ },
+ {
+ label: "Formation",
+ value: "Yes",
+ },
+ {
+ label: "Team slots",
+ value: `${chamberPage.teamSlots} (open: ${chamberStats.openSlots})`,
+ },
+ {
+ label: "Milestones",
+ value: `${chamberPage.milestones} planned`,
+ },
+ ]
+ : []
+ : proposal.stage === "citizen_veto" && citizenVetoPage
+ ? citizenVetoPage.stats
+ : proposal.stage === "chamber_veto" && chamberVetoPage
+ ? chamberVetoPage.stats
+ : finishedPage
+ ? finishedPage.stats
+ : proposal.stage === "build" && formationPage
+ ? [
+ {
+ label: "Budget ask",
+ value: formationPage.budget,
+ },
+ {
+ label: "Time left",
+ value: formationPage.timeLeft,
+ },
+ {
+ label: "Team slots",
+ value: formationPage.teamSlots,
+ },
+ {
+ label: "Milestones",
+ value: formationPage.milestones,
+ },
+ ...formationPage.stats,
+ ]
+ : proposal.stats;
return (
{
value={`${poolStats.upvoteFloorProgressPercent}% / ${poolStats.upvoteFloorFractionPercent}%`}
tone={poolStats.meetsUpvoteFloor ? "ok" : "warn"}
/>
-
@@ -665,8 +768,67 @@ const Proposals: React.FC = () => {
- If this passes, it moves to Formation for execution.
+ {chamberPage.formationEligible
+ ? "If this passes, it moves to Formation for execution."
+ : "If this passes, it stays a non-formation governance decision."}
+
+
+ ) : proposal.stage === "citizen_veto" && citizenVetoPage ? (
+
+
+ Citizen veto snapshot
+
+
+ Citizen-tier voters can remand this approved decision
+ for reconsideration. Attempts used:{" "}
+ {citizenVetoPage.attemptsUsed} /{" "}
+ {citizenVetoPage.attemptsUsed +
+ citizenVetoPage.attemptsRemaining}
+ .
+
+
+ {citizenVetoPage.stageData.map((item, index) => (
+
+ ))}
+
+
+ ) : proposal.stage === "chamber_veto" && chamberVetoPage ? (
+
+
+ Chamber veto snapshot
+
+ {chamberVetoPage.vetoingChambers} /{" "}
+ {chamberVetoPage.chamberThreshold} chambers currently
+ count as vetoing.
+
+
+ {chamberVetoPage.stageData.map((item, index) => (
+
+ ))}
+
) : proposal.stage === "build" &&
formationPage &&
@@ -736,6 +898,24 @@ const Proposals: React.FC = () => {
>
Loading chamber vote stats…
+ ) : proposal.stage === "citizen_veto" ? (
+
+ Loading citizen veto stats…
+
+ ) : proposal.stage === "chamber_veto" ? (
+
+ Loading chamber veto stats…
+
) : (
@@ -755,98 +935,22 @@ const Proposals: React.FC = () => {
)}
-
-
Key stats
-
- {proposal.stage === "pool" && poolPage && poolStats ? (
- <>
-
-
-
-
-
- >
- ) : proposal.stage === "vote" &&
- chamberPage &&
- chamberStats ? (
- <>
-
-
-
-
- >
- ) : finishedPage ? (
- finishedPage.stats.map((stat) => (
-
- ))
- ) : proposal.stage === "build" && formationPage ? (
- <>
-
-
-
-
- {formationPage.stats.map((stat) => (
-
- ))}
- >
- ) : (
- proposal.stats.map((stat) => (
+ {keyStats.length > 0 ? (
+
+
+ Key stats
+
+
+ {keyStats.map((stat) => (
- ))
- )}
-
-
+ ))}
+
+
+ ) : null}
{proposal.tags.map((tag) => (
@@ -865,13 +969,17 @@ const Proposals: React.FC = () => {
? `/app/proposals/${proposal.id}/pp`
: proposal.stage === "vote"
? `/app/proposals/${proposal.id}/chamber`
- : proposal.stage === "passed"
- ? `/app/proposals/${proposal.id}/finished`
- : proposal.stage === "build"
- ? proposal.summaryPill === "Finished"
+ : proposal.stage === "citizen_veto"
+ ? `/app/proposals/${proposal.id}/citizen-veto`
+ : proposal.stage === "chamber_veto"
+ ? `/app/proposals/${proposal.id}/chamber-veto`
+ : proposal.stage === "passed"
? `/app/proposals/${proposal.id}/finished`
- : `/app/proposals/${proposal.id}/formation`
- : `/app/proposals/${proposal.id}/pp`)
+ : proposal.stage === "build"
+ ? proposal.summaryPill === "Finished"
+ ? `/app/proposals/${proposal.id}/finished`
+ : `/app/proposals/${proposal.id}/formation`
+ : `/app/proposals/${proposal.id}/pp`)
}
primaryLabel={proposal.ctaPrimary}
/>
diff --git a/src/pages/proposals/proposalCreation/toApiForm.ts b/src/pages/proposals/proposalCreation/toApiForm.ts
index 3959f33..bf72ad2 100644
--- a/src/pages/proposals/proposalCreation/toApiForm.ts
+++ b/src/pages/proposals/proposalCreation/toApiForm.ts
@@ -29,6 +29,9 @@ export function draftToApiForm(
return {
...(input?.templateId ? { templateId: input.templateId } : {}),
...(draft.presetId ? { presetId: draft.presetId } : {}),
+ ...(draft.resubmitsProposalId
+ ? { resubmitsProposalId: draft.resubmitsProposalId }
+ : {}),
...(typeof draft.formationEligible === "boolean"
? { formationEligible: draft.formationEligible }
: {}),
diff --git a/src/pages/proposals/proposalCreation/types.ts b/src/pages/proposals/proposalCreation/types.ts
index 9caa34a..79c3a59 100644
--- a/src/pages/proposals/proposalCreation/types.ts
+++ b/src/pages/proposals/proposalCreation/types.ts
@@ -28,6 +28,7 @@ export type OpenSlotNeedItem = {
export type ProposalDraftForm = {
title: string;
chamberId: string;
+ resubmitsProposalId?: string;
summary: string;
what: string;
why: string;
@@ -67,6 +68,7 @@ export type ProposalDraftForm = {
export const DEFAULT_DRAFT: ProposalDraftForm = {
title: "",
chamberId: "",
+ resubmitsProposalId: undefined,
summary: "",
what: "",
why: "",
diff --git a/src/pages/proposals/useProposalStageSync.ts b/src/pages/proposals/useProposalStageSync.ts
index 76539fb..2987206 100644
--- a/src/pages/proposals/useProposalStageSync.ts
+++ b/src/pages/proposals/useProposalStageSync.ts
@@ -34,7 +34,13 @@ function hasSnapshotRouteOverride(search?: string): boolean {
if (!search) return false;
const params = new URLSearchParams(search);
const stage = params.get("snapshotStage");
- return stage === "pool" || stage === "vote" || stage === "build";
+ return (
+ stage === "pool" ||
+ stage === "vote" ||
+ stage === "citizen_veto" ||
+ stage === "chamber_veto" ||
+ stage === "build"
+ );
}
export function formatProposalStageTransitionMessage(
@@ -52,6 +58,12 @@ export function formatProposalStageTransitionMessage(
if (status.redirectReason === "referendum_open") {
return "Legitimacy referendum opened.";
}
+ if (status.redirectReason === "citizen_veto_opened") {
+ return "Citizen veto opened.";
+ }
+ if (status.redirectReason === "chamber_veto_opened") {
+ return "Chamber veto opened.";
+ }
if (status.redirectReason === "formation_completed") {
return "Project finished and moved to Finished.";
}
@@ -60,6 +72,10 @@ export function formatProposalStageTransitionMessage(
}
if (status.canonicalStage === "vote")
return "Proposal moved to Chamber vote.";
+ if (status.canonicalStage === "citizen_veto")
+ return "Proposal moved to Citizen veto.";
+ if (status.canonicalStage === "chamber_veto")
+ return "Proposal moved to Chamber veto.";
if (status.canonicalStage === "build") return "Proposal moved to Formation.";
if (status.canonicalStage === "passed") return "Proposal moved to Passed.";
if (status.canonicalStage === "failed") return "Proposal moved to Failed.";
diff --git a/src/types/api.ts b/src/types/api.ts
index 2bd6ce2..c607e0b 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -3,8 +3,22 @@
import type { FeedStage } from "./stages";
-export type ProposalStageDto = "pool" | "vote" | "build" | "passed" | "failed";
+export type ProposalStageDto =
+ | "pool"
+ | "vote"
+ | "citizen_veto"
+ | "chamber_veto"
+ | "build"
+ | "passed"
+ | "failed";
export type FeedStageDto = FeedStage;
+export type ProposalResolutionKindDto =
+ | "ordinary_failed_pool"
+ | "ordinary_failed_vote"
+ | "citizen_veto_remand"
+ | "chamber_veto_void"
+ | "formation_completed"
+ | "formation_canceled";
export type ToneDto = "ok" | "warn";
@@ -128,8 +142,30 @@ export type FactionDto = {
invitedAt: string;
respondedAt: string | null;
}>;
+ joinRequests?: Array<{
+ address: string;
+ status: "pending" | "accepted" | "declined" | "canceled";
+ requestedAt: string;
+ respondedAt: string | null;
+ respondedBy: string | null;
+ }>;
+ viewerJoinRequest?: {
+ address: string;
+ status: "pending" | "accepted" | "declined" | "canceled";
+ requestedAt: string;
+ respondedAt: string | null;
+ respondedBy: string | null;
+ } | null;
+};
+export type GetFactionsResponse = {
+ items: FactionDto[];
+ totals?: {
+ totalFactions: number;
+ totalMemberships: number;
+ uniqueMembers: number;
+ totalAcm: number;
+ };
};
-export type GetFactionsResponse = { items: FactionDto[] };
export type ChamberProposalStageDto = "upcoming" | "live" | "ended";
export type ChamberProposalDto = {
@@ -434,8 +470,6 @@ export type ProposalListItemDto = {
};
export type GetProposalsResponse = { items: ProposalListItemDto[] };
-export type InvisionInsightDto = { role: string; bullets: string[] };
-
export type ProposalTimelineEventTypeDto = string;
export type ProposalTimelineItemDto = {
@@ -446,8 +480,14 @@ export type ProposalTimelineItemDto = {
actor?: string;
timestamp: string;
snapshot?: {
- fromStage: "pool" | "vote" | "build";
- toStage: "vote" | "build" | "passed" | "failed";
+ fromStage: "pool" | "vote" | "citizen_veto" | "chamber_veto" | "build";
+ toStage:
+ | "vote"
+ | "citizen_veto"
+ | "chamber_veto"
+ | "build"
+ | "passed"
+ | "failed";
reason?: string;
milestoneIndex?: number | null;
metrics: Array<{ label: string; value: string }>;
@@ -484,6 +524,7 @@ export type GetProposalDraftsResponse = { items: ProposalDraftListItemDto[] };
export type ProposalDraftEditableFormDto = {
templateId?: "project" | "system";
presetId?: string;
+ resubmitsProposalId?: string;
formationEligible?: boolean;
title: string;
chamberId: string;
@@ -542,7 +583,6 @@ export type ProposalDraftDetailDto = {
summary: string;
rationale: string;
budgetScope: string;
- invisionInsight: InvisionInsightDto;
checklist: string[];
milestones: string[];
teamLocked: { name: string; role: string }[];
@@ -579,7 +619,6 @@ export type PoolProposalPageDto = {
overview: string;
executionPlan: string[];
budgetScope: string;
- invisionInsight: InvisionInsightDto;
thresholdContext?: {
activityThreshold: {
categories: string[];
@@ -629,7 +668,11 @@ export type ChamberProposalPageDto = {
overview: string;
executionPlan: string[];
budgetScope: string;
- invisionInsight: InvisionInsightDto;
+ viewerVote: null | {
+ choice: "yes" | "no" | "abstain";
+ score: number | null;
+ updatedAt: string;
+ };
delegation: null | {
source: "snapshot" | "live";
snapshotCapturedAt: string | null;
@@ -662,6 +705,69 @@ export type ChamberProposalPageDto = {
};
};
+export type CitizenVetoProposalPageDto = {
+ title: string;
+ proposer: string;
+ proposerId: string;
+ chamber: string;
+ budget: string;
+ timeLeft: string;
+ formationEligible: boolean;
+ summary: string;
+ overview: string;
+ executionPlan: string[];
+ budgetScope: string;
+ attachments: { id: string; title: string; href?: string }[];
+ stageData: ProposalStageDatumDto[];
+ stats: { label: string; value: string }[];
+ attemptsUsed: number;
+ attemptsRemaining: number;
+ eligibleCitizens: number;
+ quorumNeeded: number;
+ vetoNeeded: number;
+ votes: { veto: number; keep: number };
+ viewer: {
+ eligible: boolean;
+ currentVote: "veto" | "keep" | null;
+ };
+};
+
+export type ChamberVetoProposalPageDto = {
+ title: string;
+ proposer: string;
+ proposerId: string;
+ chamber: string;
+ budget: string;
+ timeLeft: string;
+ formationEligible: boolean;
+ summary: string;
+ overview: string;
+ executionPlan: string[];
+ budgetScope: string;
+ attachments: { id: string; title: string; href?: string }[];
+ stageData: ProposalStageDatumDto[];
+ stats: { label: string; value: string }[];
+ activeChambers: number;
+ chamberThreshold: number;
+ vetoingChambers: number;
+ chambers: Array<{
+ chamberId: string;
+ chamberTitle: string;
+ eligibleVoters: number;
+ quorumNeeded: number;
+ votes: { veto: number; keep: number; abstain: number };
+ countsAsVetoing: boolean;
+ delegation: {
+ source: "snapshot" | "live";
+ snapshotCapturedAt: string | null;
+ };
+ viewer: {
+ eligible: boolean;
+ currentVote: "veto" | "keep" | "abstain" | null;
+ };
+ }>;
+};
+
export type FormationProposalPageDto = {
title: string;
chamber: string;
@@ -690,7 +796,6 @@ export type FormationProposalPageDto = {
overview: string;
executionPlan: string[];
budgetScope: string;
- invisionInsight: InvisionInsightDto;
};
export type ProposalFinishedPageDto = {
@@ -699,8 +804,11 @@ export type ProposalFinishedPageDto = {
proposer: string;
proposerId: string;
terminalStage: "passed" | "failed";
+ resolutionKind: ProposalResolutionKindDto | null;
terminalLabel: string;
terminalSummary: string;
+ decisionRootProposalId: string;
+ canReconsider: boolean;
formationEligible: boolean;
budget: string;
timeLeft: string;
@@ -714,7 +822,6 @@ export type ProposalFinishedPageDto = {
overview: string;
executionPlan: string[];
budgetScope: string;
- invisionInsight: InvisionInsightDto;
};
export type CourtCaseStatusDto = "jury" | "live" | "ended";
diff --git a/src/types/stages.ts b/src/types/stages.ts
index 16c94a7..4f74920 100644
--- a/src/types/stages.ts
+++ b/src/types/stages.ts
@@ -1,6 +1,8 @@
export const proposalStages = [
"pool",
"vote",
+ "citizen_veto",
+ "chamber_veto",
"build",
"passed",
"failed",
@@ -11,6 +13,8 @@ export type ProposalStage = (typeof proposalStages)[number];
export const feedStages = [
"pool",
"vote",
+ "citizen_veto",
+ "chamber_veto",
"build",
"passed",
"failed",
@@ -27,6 +31,8 @@ export type Stage = ProposalStage | FeedStage;
export type StageChipKind =
| "proposal_pool"
| "chamber_vote"
+ | "citizen_veto"
+ | "chamber_veto"
| "formation"
| "passed"
| "failed"
@@ -38,6 +44,8 @@ export type StageChipKind =
export const stageToChipKind = {
pool: "proposal_pool",
vote: "chamber_vote",
+ citizen_veto: "citizen_veto",
+ chamber_veto: "chamber_veto",
build: "formation",
passed: "passed",
failed: "failed",
@@ -50,6 +58,8 @@ export const stageToChipKind = {
export const stageLabel = {
pool: "Proposal pool",
vote: "Chamber vote",
+ citizen_veto: "Citizen veto",
+ chamber_veto: "Chamber veto",
build: "Formation",
passed: "Passed",
failed: "Failed",
diff --git a/tests/unit/dto-parsers.test.ts b/tests/unit/dto-parsers.test.ts
index e9417b2..70083fb 100644
--- a/tests/unit/dto-parsers.test.ts
+++ b/tests/unit/dto-parsers.test.ts
@@ -90,10 +90,6 @@ test("formation progress mapping uses ratios", () => {
overview: "",
executionPlan: [],
budgetScope: "",
- invisionInsight: {
- role: "observer",
- bullets: [],
- },
};
const progress = getFormationProgress(formation);