Skip to content
This repository was archived by the owner on Mar 16, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 104 additions & 10 deletions src/components/aidaos/RootDAOPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import type React from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueries } from "@tanstack/react-query";
import { useMemo, useState, useCallback } from "react";
import {
Building2,
Expand Down Expand Up @@ -45,6 +45,8 @@ import { getStacksAddress } from "@/lib/address";
// import { BalanceDisplay } from "@/components/reusables/BalanceDisplay";
import { TwitterCard } from "@/components/twitter/TwitterCard";
import { rewardPerPassedProposal } from "@/config/features";
import { safeNumberFromBigInt } from "@/utils/proposal";
import { fetchProposalVotes } from "@/services/vote.service";

// Network configuration
const isMainnet = process.env.NEXT_PUBLIC_STACKS_NETWORK === "mainnet";
Expand Down Expand Up @@ -323,6 +325,29 @@ export function RootDAOPage({ children, daoName }: RootDAOPageProps) {
staleTime: 600000,
});

const proposalsArray = useMemo(
() => (Array.isArray(proposals) ? proposals : []),
[proposals]
);

const proposalVoteQueries = useQueries({
queries: proposalsArray.map((proposal) => ({
queryKey: ["proposalVotesDB", proposal.id],
queryFn: () => fetchProposalVotes(proposal.id),
enabled: !!proposal.id,
staleTime: 60000,
})),
});

const hasProposals = proposalsArray.length > 0;
const proposalVotesReady =
!hasProposals ||
(proposalVoteQueries.length === proposalsArray.length &&
proposalVoteQueries.every(
(query) =>
Array.isArray(query.data) && !query.isLoading && !query.isFetching
));

const { data: marketStats } = useQuery({
queryKey: ["marketStats", id, dex, token?.max_supply],
queryFn: () => fetchMarketStats(dex!, id!, token!.max_supply || 0),
Expand Down Expand Up @@ -364,19 +389,84 @@ export function RootDAOPage({ children, daoName }: RootDAOPageProps) {
}, [marketStats, tokenPrice, holdersData, token]);

const totalProposals = useMemo(() => {
if (!Array.isArray(proposals)) return 0;
return proposals.filter((proposal) => proposal.status === "DEPLOYED")
return proposalsArray.filter((proposal) => proposal.status === "DEPLOYED")
.length;
}, [proposals]);
}, [proposalsArray]);

const passedProposals = useMemo(() => {
if (!Array.isArray(proposals)) return 0;
return proposals.filter((proposal) => proposal.passed === true).length;
}, [proposals]);
if (!proposalVotesReady) return 0;

const parseVoteValue = (
value: string | number | null | undefined
): number | null => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim().length > 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
return null;
};

return proposalsArray.filter((proposal, index) => {
if (proposal.status !== "DEPLOYED") {
return false;
}

const voteQuery = proposalVoteQueries[index];
const votesFromQuery = Array.isArray(voteQuery?.data)
? voteQuery.data
: null;

const aggregatedFromQuery = votesFromQuery
? votesFromQuery.reduce(
(acc, vote) => {
const amount =
typeof vote.amount === "string" && vote.amount.trim().length > 0
? Number(vote.amount)
: 1;
const safeAmount = Number.isFinite(amount) ? amount : 1;

if (vote.answer === true) {
acc.votesFor += safeAmount;
} else {
acc.votesAgainst += safeAmount;
}
return acc;
},
{ votesFor: 0, votesAgainst: 0 }
)
: null;

const votesFor = aggregatedFromQuery
? aggregatedFromQuery.votesFor
: parseVoteValue(proposal.votes_for);
const votesAgainst = aggregatedFromQuery
? aggregatedFromQuery.votesAgainst
: parseVoteValue(proposal.votes_against);

if (votesFor === null || votesAgainst === null) {
return false;
}

const totalVotes = votesFor + votesAgainst;
const liquidTokens = parseVoteValue(proposal.liquid_tokens) || 0;
const quorumRequired = safeNumberFromBigInt(proposal.voting_quorum);
const thresholdRequired = safeNumberFromBigInt(proposal.voting_threshold);

const participationRate =
liquidTokens > 0 ? (totalVotes / liquidTokens) * 100 : 0;
const approvalRate = totalVotes > 0 ? (votesFor / totalVotes) * 100 : 0;

return (
participationRate >= quorumRequired && approvalRate >= thresholdRequired
);
}).length;
}, [proposalVoteQueries, proposalVotesReady, proposalsArray]);

const totalRewards = useMemo(() => {
if (!proposalVotesReady) return 0;
return passedProposals * rewardPerPassedProposal;
}, [passedProposals]);
}, [passedProposals, proposalVotesReady]);

if (isBasicLoading || !dao) {
return (
Expand Down Expand Up @@ -483,12 +573,16 @@ export function RootDAOPage({ children, daoName }: RootDAOPageProps) {
</div>
<div className="flex items-center gap-2">
<span className="font-bold">Passed:</span>
<span className="font-medium">{passedProposals}</span>
<span className="font-medium">
{proposalVotesReady ? passedProposals : "Loading..."}
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">Total Rewards:</span>
<span className="font-medium">
${totalRewards.toLocaleString()}
{proposalVotesReady
? `$${totalRewards.toLocaleString()}`
: "Loading..."}
</span>
</div>
</div>
Expand Down
30 changes: 25 additions & 5 deletions src/components/proposals/ProposalBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,40 @@
import React from "react";
import React, { useMemo } from "react";
import { useProposalStatus } from "@/hooks/useProposalStatus";
import { CheckCircle2 } from "lucide-react";
import type { Proposal, ProposalWithDAO } from "@/types";

interface ProposalStatusBadgeProps {
proposal: Proposal | ProposalWithDAO;
size?: "sm" | "md" | "lg";
className?: string;
metQuorum?: boolean;
metThreshold?: boolean;
}

export function ProposalStatusBadge({
proposal,
size = "md",
className = "",
metQuorum,
metThreshold,
}: ProposalStatusBadgeProps) {
const { statusConfig } = useProposalStatus(proposal);
const StatusIcon = statusConfig.icon;
const { statusConfig, isEnded } = useProposalStatus(proposal);

// Override status config if voting ended and requirements were met
const finalStatusConfig = useMemo(() => {
if (isEnded && metQuorum && metThreshold) {
return {
icon: CheckCircle2,
color: "text-success",
bg: "bg-success/10",
border: "border-success/20",
label: "Passed",
};
}
return statusConfig;
}, [isEnded, metQuorum, metThreshold, statusConfig]);

const StatusIcon = finalStatusConfig.icon;

const sizeClasses = {
sm: "px-2 py-0.5 text-xs",
Expand All @@ -30,10 +50,10 @@ export function ProposalStatusBadge({

return (
<div
className={`inline-flex items-center gap-1.5 rounded-sm font-medium ${statusConfig.bg} ${statusConfig.border} ${statusConfig.color} border ${sizeClasses[size]} ${className}`}
className={`inline-flex items-center gap-1.5 rounded-sm font-medium ${finalStatusConfig.bg} ${finalStatusConfig.border} ${finalStatusConfig.color} border ${sizeClasses[size]} ${className}`}
>
<StatusIcon className={iconSizes[size]} />
{statusConfig.label}
{finalStatusConfig.label}
</div>
);
}
156 changes: 143 additions & 13 deletions src/components/proposals/ProposalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export default function ProposalCard({
const router = useRouter();

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

// Use centralized vote hook for consistent data fetching
const {
Expand Down Expand Up @@ -88,7 +89,40 @@ export default function ProposalCard({

// Parse liquid_tokens as a number for use in percentage calculations
// const liquidTokens = Number(proposal.liquid_tokens);
const { totalVotes, hasVoteData } = voteSummary;
const { totalVotes, hasVoteData, votesFor } = voteSummary;

// Calculate quorum and threshold percentages similar to VotingProgressChart
const quorumThresholdData = useMemo(() => {
if (!hasVoteData || totalVotes === null) return null;

const liquidTokens = Number(proposal.liquid_tokens || 0);
const quorumPercentage = Number(proposal.voting_quorum || 0);
const thresholdPercentage = Number(proposal.voting_threshold || 0);

// Calculate participation rate (quorum)
const participationRate =
liquidTokens > 0 ? (totalVotes / liquidTokens) * 100 : 0;

// Calculate approval rate (threshold)
const approvalRate =
totalVotes > 0 && votesFor !== null ? (votesFor / totalVotes) * 100 : 0;

return {
quorumPercentage,
thresholdPercentage,
participationRate,
approvalRate,
metQuorum: participationRate >= quorumPercentage,
metThreshold: approvalRate >= thresholdPercentage,
};
}, [
hasVoteData,
totalVotes,
votesFor,
proposal.liquid_tokens,
proposal.voting_quorum,
proposal.voting_threshold,
]);

// Memoize DAO info
const daoInfo = useMemo(() => {
Expand Down Expand Up @@ -125,17 +159,12 @@ export default function ProposalCard({
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-3 sm:mb-4 gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-foreground group-hover:text-primary transition-colors duration-200 line-clamp-2 mb-2">
{proposal.proposal_id
? `#${proposal.proposal_id}: ${proposal.title}`
: proposal.title}
</h3>
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<ProposalStatusBadge
proposal={proposal}
size="sm"
className="flex-shrink-0"
/>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
<h3 className="text-base sm:text-lg font-semibold text-foreground group-hover:text-primary transition-colors duration-200 line-clamp-2">
{proposal.proposal_id
? `#${proposal.proposal_id}: ${proposal.title}`
: proposal.title}
</h3>
<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 All @@ -146,6 +175,107 @@ export default function ProposalCard({
</span>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3 mb-2 flex-wrap">
<ProposalStatusBadge
proposal={proposal}
size="sm"
className="flex-shrink-0"
metQuorum={quorumThresholdData?.metQuorum}
metThreshold={quorumThresholdData?.metThreshold}
/>
{quorumThresholdData && (
<div
className={`flex items-center gap-1 text-xs px-2 py-1 rounded-sm flex-shrink-0 ${
status === "PENDING" || status === "DRAFT"
? quorumThresholdData.metQuorum
? "bg-green-500/10 border border-green-500/30"
: "bg-gray-500/10 border border-gray-500/30"
: isActive
? quorumThresholdData.metQuorum
? "bg-green-500/10 border border-green-500/30"
: "bg-orange-500/10 border border-orange-500/30"
: !isEnded
? "bg-gray-500/10 border border-gray-500/30"
: quorumThresholdData.metQuorum
? "bg-green-500/10 border border-green-500/30"
: "bg-red-500/10 border border-red-500/30"
}`}
>
<span className="text-muted-foreground">Quorum:</span>
<span
className={`font-medium ${
status === "PENDING" || status === "DRAFT"
? quorumThresholdData.metQuorum
? "text-green-400"
: "text-gray-400"
: isActive
? quorumThresholdData.metQuorum
? "text-green-400"
: "text-orange-400"
: !isEnded
? "text-gray-400"
: quorumThresholdData.metQuorum
? "text-green-400"
: "text-red-400"
}`}
>
{quorumThresholdData.metQuorum
? "Passed"
: status === "VETO_PERIOD" ||
status === "EXECUTION_WINDOW" ||
isEnded
? "Failed"
: `${quorumThresholdData.participationRate.toFixed(1)}%`}
</span>
</div>
)}
{quorumThresholdData && (
<div
className={`flex items-center gap-1 text-xs px-2 py-1 rounded-sm flex-shrink-0 ${
status === "PENDING" || status === "DRAFT"
? quorumThresholdData.metThreshold
? "bg-green-500/10 border border-green-500/30"
: "bg-gray-500/10 border border-gray-500/30"
: isActive
? quorumThresholdData.metThreshold
? "bg-green-500/10 border border-green-500/30"
: "bg-orange-500/10 border border-orange-500/30"
: !isEnded
? "bg-gray-500/10 border border-gray-500/30"
: quorumThresholdData.metThreshold
? "bg-green-500/10 border border-green-500/30"
: "bg-red-500/10 border border-red-500/30"
}`}
>
<span className="text-muted-foreground">Threshold:</span>
<span
className={`font-medium ${
status === "PENDING" || status === "DRAFT"
? quorumThresholdData.metThreshold
? "text-green-400"
: "text-gray-400"
: isActive
? quorumThresholdData.metThreshold
? "text-green-400"
: "text-orange-400"
: !isEnded
? "text-gray-400"
: quorumThresholdData.metThreshold
? "text-green-400"
: "text-red-400"
}`}
>
{quorumThresholdData.metThreshold
? "Passed"
: status === "VETO_PERIOD" ||
status === "EXECUTION_WINDOW" ||
isEnded
? "Failed"
: `${quorumThresholdData.approvalRate.toFixed(1)}%`}
</span>
</div>
)}
</div>

{/* Reference Links - Extract from content and display below title */}
{(() => {
Expand Down
Loading