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}

+
+ +
+
+ ))} +
+ )} +
+
+ 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 ? ( ) : 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" ? ( + + ) : null}
-
- {invite.status} - {isFounderAdmin && invite.status === "pending" ? ( - - ) : 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)} +
+
+
+ + +
+
+ )) + )} +
+
+ ) : 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/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 ? ( +
+ +
+ ) : 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} {