+ referendumVote ? (
+ "Referendum quorum (%)"
+ ) : (
+
+ )
}
value={
<>
- {quorumPercent}% / {quorumNeededPercent}%
+ {quorumPercent}% /{" "}
+ {referendumVote
+ ? referendumQuorumRuleLabel
+ : `${quorumNeededPercent}%`}
- {engaged} / {quorumNeeded}
+ {engaged} / {quorumNeeded} {proposal.voterLabel.toLowerCase()}
>
}
@@ -312,7 +331,9 @@ const ProposalChamber: React.FC = () => {
{yesPercentOfQuorum}% / {passingNeededPercent}%
- Yes within quorum
+ {referendumVote
+ ? `${Math.ceil(engaged * 0.666)} yes votes needed at current participation`
+ : "Yes within quorum"}
>
}
diff --git a/src/pages/proposals/ProposalReferendum.tsx b/src/pages/proposals/ProposalReferendum.tsx
new file mode 100644
index 0000000..83d9f6c
--- /dev/null
+++ b/src/pages/proposals/ProposalReferendum.tsx
@@ -0,0 +1,336 @@
+import { useCallback, useEffect, useState } from "react";
+import { useParams } from "react-router";
+
+import { PageHint } from "@/components/PageHint";
+import { ProposalPageHeader } from "@/components/ProposalPageHeader";
+import { VoteButton } from "@/components/VoteButton";
+import {
+ ProposalInvisionInsightCard,
+ ProposalSummaryCard,
+ ProposalTeamMilestonesCard,
+ ProposalTimelineCard,
+} from "@/components/ProposalSections";
+import { StatTile } from "@/components/StatTile";
+import { Surface } from "@/components/Surface";
+import {
+ apiProposalReferendumPage,
+ apiProposalTimeline,
+ apiReferendumVote,
+} from "@/lib/apiClient";
+import { formatLoadError } from "@/lib/errorFormatting";
+import type {
+ ChamberProposalPageDto,
+ ProposalTimelineItemDto,
+} from "@/types/api";
+import {
+ useProposalStageSync,
+ useProposalTransitionNotice,
+} from "./useProposalStageSync";
+
+const ProposalReferendum: 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 [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 apiProposalReferendumPage(id);
+ setProposal(page);
+ setLoadError(null);
+ }, [id]);
+
+ useEffect(() => {
+ if (!id) return;
+ let active = true;
+ (async () => {
+ try {
+ const [pageResult, timelineResult] = await Promise.allSettled([
+ apiProposalReferendumPage(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 referendum",
+ );
+ }
+ 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
+ ? `Referendum unavailable: ${formatLoadError(loadError, "Failed to load referendum.")}`
+ : "Loading referendum…"}
+
+
+ );
+ }
+
+ const yesTotal = proposal.votes.yes;
+ const noTotal = proposal.votes.no;
+ const abstainTotal = proposal.votes.abstain;
+ const totalVotes = yesTotal + noTotal + abstainTotal;
+ const engaged = proposal.engagedVoters ?? proposal.engagedGovernors;
+ const eligibleVoters = Math.max(
+ 1,
+ proposal.eligibleVoters ?? proposal.activeGovernors,
+ );
+ const quorumRuleLabel = "33.3% + 1";
+ const quorumPercent = Math.round((engaged / eligibleVoters) * 100);
+ const yesPercentOfTotal =
+ totalVotes > 0 ? Math.round((yesTotal / totalVotes) * 100) : 0;
+ const noPercentOfTotal =
+ totalVotes > 0 ? Math.round((noTotal / totalVotes) * 100) : 0;
+ const abstainPercentOfTotal =
+ totalVotes > 0 ? Math.round((abstainTotal / totalVotes) * 100) : 0;
+ const yesPercentOfQuorum =
+ engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0;
+ const passingNeededPercent = 66.6;
+
+ const [filledSlots, totalSlots] = proposal.teamSlots
+ .split("/")
+ .map((v) => Number(v.trim()));
+ const openSlots = Math.max(totalSlots - filledSlots, 0);
+
+ const handleVote = async (choice: "yes" | "no" | "abstain") => {
+ if (!id || submitting) return;
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ const result = await apiReferendumVote({
+ proposalId: id,
+ choice,
+ });
+ if (result.systemReset) {
+ window.location.assign("/app");
+ return;
+ }
+ 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}
+
+
+ All active human nodes can vote
+
+
+ handleVote("yes")}
+ />
+ handleVote("no")}
+ />
+ handleVote("abstain")}
+ />
+
+ {submitError ? (
+
+ {formatLoadError(submitError)}
+
+ ) : null}
+
+
+
+ Referendum quorum
+
+
+
+ {quorumPercent}% / {quorumRuleLabel}
+
+
+ {engaged} / {proposal.quorumNeeded} human nodes
+
+ >
+ }
+ 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"
+ />
+
+
+ {yesPercentOfTotal}% /{" "}
+ {noPercentOfTotal}%{" "}
+ / {abstainPercentOfTotal}%
+
+
+ {yesTotal} / {noTotal} / {abstainTotal}
+
+ >
+ }
+ 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"
+ />
+
+
+
+ {yesPercentOfQuorum}% / {passingNeededPercent}%
+
+
+ {Math.ceil(engaged * 0.666)} yes votes needed at current
+ participation
+
+ >
+ }
+ 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 ProposalReferendum;
diff --git a/src/pages/proposals/Proposals.tsx b/src/pages/proposals/Proposals.tsx
index fe05467..d4e35ae 100644
--- a/src/pages/proposals/Proposals.tsx
+++ b/src/pages/proposals/Proposals.tsx
@@ -792,7 +792,8 @@ const Proposals: React.FC = () => {
proposer={proposal.proposer}
proposerId={proposal.proposerId}
primaryHref={
- proposal.stage === "pool"
+ proposal.href ??
+ (proposal.stage === "pool"
? `/app/proposals/${proposal.id}/pp`
: proposal.stage === "vote"
? `/app/proposals/${proposal.id}/chamber`
@@ -804,7 +805,7 @@ const Proposals: React.FC = () => {
? proposal.summaryPill === "Finished"
? `/app/proposals/${proposal.id}/finished`
: `/app/proposals/${proposal.id}/formation`
- : `/app/proposals/${proposal.id}/pp`
+ : `/app/proposals/${proposal.id}/pp`)
}
primaryLabel={proposal.ctaPrimary}
/>
diff --git a/src/pages/proposals/useProposalStageSync.ts b/src/pages/proposals/useProposalStageSync.ts
index 03fc386..76539fb 100644
--- a/src/pages/proposals/useProposalStageSync.ts
+++ b/src/pages/proposals/useProposalStageSync.ts
@@ -49,6 +49,9 @@ export function formatProposalStageTransitionMessage(
? `Milestone M${status.pendingMilestoneIndex} entered chamber vote.`
: "Milestone entered chamber vote.";
}
+ if (status.redirectReason === "referendum_open") {
+ return "Legitimacy referendum opened.";
+ }
if (status.redirectReason === "formation_completed") {
return "Project finished and moved to Finished.";
}
diff --git a/src/types/api.ts b/src/types/api.ts
index f22f626..741c9b6 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -324,6 +324,14 @@ export type TierProgressDto = {
export type GetMyGovernanceResponse = {
eraActivity: MyGovernanceEraActivityDto;
myChamberIds: string[];
+ legitimacy: {
+ percent: number;
+ objecting: boolean;
+ objectingHumanNodes: number;
+ eligibleHumanNodes: number;
+ referendumTriggered: boolean;
+ triggerThresholdPercent: number;
+ };
tier?: TierProgressDto;
rollup?: {
era: number;
@@ -382,6 +390,7 @@ export type ProposalListItemDto = {
date: string;
votes: number;
activityScore: number;
+ href?: string;
ctaPrimary: string;
ctaSecondary: string;
};
@@ -553,7 +562,10 @@ export type ChamberProposalPageDto = {
proposer: string;
proposerId: string;
chamber: string;
- scoreLabel: "CM" | "MM";
+ voteKind: "chamber" | "milestone" | "referendum";
+ voterLabel: "Governors" | "Human nodes";
+ scoreLabel: "CM" | "MM" | null;
+ scoreEnabled: boolean;
milestoneIndex: number | null;
budget: string;
formationEligible: boolean;
@@ -566,6 +578,8 @@ export type ChamberProposalPageDto = {
passingRule: string;
engagedGovernors: number;
activeGovernors: number;
+ engagedVoters: number;
+ eligibleVoters: number;
attachments: { id: string; title: string }[];
teamLocked: { name: string; role: string }[];
openSlotNeeds: { title: string; desc: string }[];