Skip to content
This repository was archived by the owner on Mar 16, 2026. It is now read-only.
Merged
147 changes: 126 additions & 21 deletions src/components/proposals/ProposalCard.tsx
Comment thread
biwasxyz marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { useProposalVote } from "@/hooks/useProposalVote";
import { Button } from "@/components/ui/button";
import { RefreshCw, AlertCircle } from "lucide-react";
import { motion } from "framer-motion";
import { safeNumberFromBigInt } from "@/utils/proposal";
import { cn } from "@/lib/utils";

interface ProposalCardProps {
proposal: Proposal | ProposalWithDAO;
Expand All @@ -31,14 +33,16 @@ export default function ProposalCard({
const router = useRouter();

// Use the unified status system
const { statusConfig, isActive, isPassed } = useProposalStatus(proposal);
const { status, statusConfig, isActive } = useProposalStatus(proposal);

// Use centralized vote hook for consistent data fetching
const {
voteDisplayData,
calculations,
error: hasVoteDataError,
refreshVoteData,
isLoading: isLoadingVotes,
vetoCheck,
} = useProposalVote({
proposal,
contractPrincipal: proposal.contract_principal,
Expand Down Expand Up @@ -90,6 +94,43 @@ export default function ProposalCard({
// const liquidTokens = Number(proposal.liquid_tokens);
const { totalVotes, hasVoteData } = voteSummary;

// Enhanced calculations for quorum and threshold display
const enhancedCalculations = useMemo(() => {
if (!calculations) return null;

const quorumPercentage = safeNumberFromBigInt(proposal.voting_quorum);
const thresholdPercentage = safeNumberFromBigInt(proposal.voting_threshold);

// Calculate if requirements are met
const metQuorum = calculations.participationRate >= quorumPercentage;
const metThreshold =
calculations.totalVotes > 0
? calculations.approvalRate >= thresholdPercentage
: false;

return {
...calculations,
quorumPercentage,
thresholdPercentage,
metQuorum,
metThreshold,
};
}, [calculations, proposal]);

// Helper function for status display
const getStatusText = (met: boolean, percentage?: number) => {
// If voting hasn't started (PENDING, DRAFT), show "Pending"
if (status === "PENDING" || status === "DRAFT") {
return "Pending";
}

if (isActive) {
return percentage !== undefined ? `${percentage.toFixed(1)}%` : "0%";
}

return met ? "Passed" : "Failed";
};

// Memoize DAO info
const daoInfo = useMemo(() => {
const proposalWithDAO = proposal as ProposalWithDAO;
Expand Down Expand Up @@ -130,12 +171,93 @@ export default function ProposalCard({
? `#${proposal.proposal_id}: ${proposal.title}`
: proposal.title}
</h3>
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<div className="flex flex-wrap items-center gap-2 mb-2">
<ProposalStatusBadge
proposal={proposal}
size="sm"
className="flex-shrink-0"
/>

{/* Quorum and Threshold Badges */}
{enhancedCalculations &&
statusConfig.label !== "Pending" &&
statusConfig.label !== "Draft" && (
<>
{/* Quorum Badge */}
<div
className={cn(
"px-2 py-0.5 rounded-sm text-xs font-medium border flex-shrink-0",
isActive
? enhancedCalculations.metQuorum
? "bg-success/10 border-success/20"
: "bg-primary/10 border-primary/20"
: enhancedCalculations.metQuorum
? "bg-success/10 border-success/20"
: "bg-destructive/10 border-destructive/20"
)}
>
<span className="text-muted-foreground">Quorum:</span>{" "}
<span
className={cn(
isActive
? enhancedCalculations.metQuorum
? "text-success"
: "text-primary"
: enhancedCalculations.metQuorum
? "text-success"
: "text-destructive"
)}
>
{getStatusText(
enhancedCalculations.metQuorum,
enhancedCalculations.participationRate
)}
</span>
</div>

{/* Threshold Badge */}
<div
className={cn(
"px-2 py-0.5 rounded-sm text-xs font-medium border flex-shrink-0",
isActive
? enhancedCalculations.metThreshold
? "bg-success/10 border-success/20"
: "bg-primary/10 border-primary/20"
: enhancedCalculations.metThreshold
? "bg-success/10 border-success/20"
: "bg-destructive/10 border-destructive/20"
)}
>
<span className="text-muted-foreground">
Threshold:
</span>{" "}
<span
className={cn(
isActive
? enhancedCalculations.metThreshold
? "text-success"
: "text-primary"
: enhancedCalculations.metThreshold
? "text-success"
: "text-destructive"
)}
>
{getStatusText(
enhancedCalculations.metThreshold,
enhancedCalculations.approvalRate
)}
</span>
</div>
</>
)}

{/* Veto Override Warning Badge */}
{vetoCheck?.vetoExceedsForVote && !isActive && (
<div className="px-2 py-0.5 rounded-sm text-xs font-medium border flex-shrink-0 bg-destructive/10 border-destructive/20">
<span className="text-destructive">⚠️ Vetoed</span>
</div>
)}

<div className="flex items-center gap-1 text-xs text-foreground/75 flex-shrink-0">
<Clock className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">
Expand Down Expand Up @@ -347,28 +469,11 @@ export default function ProposalCard({
</div>
)}

{/* Completed Status */}
{/* {isPassed && (
<div className="text-sm">
<span className="text-foreground/75">Final result: </span>
<span className="font-medium">
<span className="text-success">
<TokenBalance variant="abbreviated" value={votesFor} /> For
</span>
,{" "}
<span className="text-destructive">
<TokenBalance variant="abbreviated" value={votesAgainst} />{" "}
Against
</span>
</span>
</div>
)} */}

{/* Enhanced Chart Section for detailed view - Hide for pending proposals */}
{/* Vote Status Chart - Show for active, veto period, execution window, passed, and failed proposals */}
{(isActive ||
statusConfig.label === "Veto Period" ||
statusConfig.label === "Execution Window" ||
isPassed ||
statusConfig.label === "Passed" ||
statusConfig.label === "Failed") &&
statusConfig.label !== "Pending" && (
<div className="">
Expand Down
75 changes: 47 additions & 28 deletions src/components/proposals/VoteStatusChart.tsx
Comment thread
biwasxyz marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"use client";

import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { TokenBalance } from "@/components/reusables/BalanceDisplay";
import { Button } from "@/components/ui/button";
import { RefreshCw, AlertCircle } from "lucide-react";
import { useProposalVote } from "@/hooks/useProposalVote";
import { safeNumberFromBigInt } from "@/utils/proposal";
import type { Proposal, ProposalWithDAO } from "@/types";

interface VoteStatusChartProps {
Expand All @@ -22,7 +23,7 @@ const VoteStatusChart = ({
initialVotesAgainst,
// refreshing = false,
tokenSymbol = "",
liquidTokens,
// liquidTokens,
proposal,
}: VoteStatusChartProps) => {
const [localRefreshing, setLocalRefreshing] = useState(false);
Expand Down Expand Up @@ -62,6 +63,34 @@ const VoteStatusChart = ({
[refreshVoteData]
);

// Calculate progress bar size based on quorum (must be before early returns)
const progressBarCalculations = useMemo(() => {
if (!proposal || !calculations) return null;

const quorumPercentage = safeNumberFromBigInt(proposal.voting_quorum);
const liquidTokensNum = calculations.liquidTokensNum;

// Calculate quorum amount in tokens
const quorumAmount = (liquidTokensNum * quorumPercentage) / 100;

// Bar width is quorum + 10%, or total votes if exceeded
const barWidth = Math.max(quorumAmount * 1.1, calculations.totalVotes);

// Calculate percentages based on the bar width
const votesForPercentage = (calculations.votesForNum / barWidth) * 100;
const votesAgainstPercentage =
(calculations.votesAgainstNum / barWidth) * 100;
const quorumLinePercentage = (quorumAmount / barWidth) * 100;

return {
votesForPercentage,
votesAgainstPercentage,
quorumLinePercentage,
quorumAmount,
barWidth,
};
}, [proposal, calculations]);

// Show loading state
if (isLoadingVotes && !error) {
return (
Expand Down Expand Up @@ -100,7 +129,7 @@ const VoteStatusChart = ({
);
}

if (!voteDisplayData || !calculations) {
if (!voteDisplayData || !calculations || !progressBarCalculations) {
return (
<div className="flex items-center justify-center p-4">
<span className="text-sm text-muted-foreground">
Expand All @@ -110,8 +139,6 @@ const VoteStatusChart = ({
);
}

// const isRefreshingAny = localRefreshing || refreshing;

// Main vote display
return (
<div className="space-y-2">
Expand All @@ -122,17 +149,29 @@ const VoteStatusChart = ({
<div
className="absolute left-0 top-0 h-full bg-green-500/80 transition-all duration-500 ease-out rounded-l-full"
style={{
width: `${Math.min(calculations.barPercentageFor, 100)}%`,
width: `${Math.min(progressBarCalculations.votesForPercentage, 100)}%`,
}}
/>
{/* Votes against (red) */}
<div
className="absolute top-0 h-full bg-red-500/80 transition-all duration-500 ease-out"
style={{
width: `${Math.min(calculations.barPercentageAgainst, 100)}%`,
left: `${Math.min(calculations.barPercentageFor, 100)}%`,
width: `${Math.min(progressBarCalculations.votesAgainstPercentage, 100)}%`,
left: `${Math.min(progressBarCalculations.votesForPercentage, 100)}%`,
}}
/>
{/* Quorum line indicator */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-primary z-10"
style={{
left: `${Math.min(progressBarCalculations.quorumLinePercentage, 100)}%`,
}}
>
<div
className="absolute -top-1 w-3 h-3 bg-primary rounded-sm border-2 border-background"
style={{ left: "-5px" }}
/>
</div>
</div>
</div>

Expand Down Expand Up @@ -171,26 +210,6 @@ const VoteStatusChart = ({
className="font-medium sm:hidden"
/>
</div>

{/* Liquid Tokens - Right */}
{liquidTokens && Number(liquidTokens) > 0 && (
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Liquid Token:</span>
<TokenBalance
value={liquidTokens}
decimals={8}
variant="abbreviated"
symbol={tokenSymbol}
className="font-medium hidden sm:inline"
/>
<TokenBalance
value={liquidTokens}
decimals={8}
variant="abbreviated"
className="font-medium sm:hidden"
/>
</div>
)}
</div>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/proposals/VotesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,9 @@ const VotesTable = ({ proposalId, limit }: VotesTableProps) => {
</tr>
</thead>
<tbody>
{displayedVotes.map((vote, index) => (
{displayedVotes.map((vote) => (
<tr
key={vote.tx_id || index}
key={vote.id}
className="border-b border-border/50 hover:bg-muted/30 transition-colors"
>
{/* Voter */}
Expand Down
22 changes: 21 additions & 1 deletion src/components/proposals/VotingProgressChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const VotingProgressChart = ({
error: voteDataError,
hasData,
refreshVoteData,
vetoCheck,
} = useProposalVote({
proposal,
contractPrincipal,
Expand Down Expand Up @@ -172,6 +173,17 @@ const VotingProgressChart = ({
const getResultStatus = () => {
const StatusIcon = statusConfig.icon;

// Check if veto amount exceeds For votes - this overrides other status
if (vetoCheck.vetoExceedsForVote && !isActive) {
return {
status: "Failed",
color: "text-destructive",
icon: <XCircle className="h-4 w-4" />,
bgColor: "bg-destructive/10 border-destructive/20",
reason: "Veto amount exceeds For votes",
};
}

switch (status) {
case "DRAFT":
return {
Expand Down Expand Up @@ -742,6 +754,13 @@ const VotingProgressChart = ({
/>
</div>

{/* Veto override message */}
{resultStatus.reason && (
<div className="text-destructive">
⚠️ {resultStatus.reason}
</div>
)}

{/* Additional status-specific information */}
{status === "EXECUTION_WINDOW" && (
<div className="text-accent-foreground">
Expand All @@ -760,7 +779,8 @@ const VotingProgressChart = ({
)}
{status === "FAILED" &&
enhancedCalculations.metQuorum &&
enhancedCalculations.metThreshold && (
enhancedCalculations.metThreshold &&
!resultStatus.reason && (
<div className="text-destructive">
⚠️ Failed despite meeting requirements
</div>
Expand Down
Loading