From 87d20eaaa48fe5e67963cfe084d99a2fd4639355 Mon Sep 17 00:00:00 2001 From: Ibrahim Suleiman Date: Wed, 27 May 2026 12:01:10 +0100 Subject: [PATCH 1/6] feat: enhance polling functionality with voting transparency on the governance page - Updated GraphQL queries to include additional fields for votes, such as choiceID, voter, voteStake, and nonVoteStake. - Introduced a new `parsePollText` function to handle proposal parsing from IPFS. - Enhanced the PollVotingWidget component to display a link to view votes and improved the layout for better user experience. - Added a new PollVotingTable component for displaying detailed vote information. - Updated the voting page to support tab navigation between overview and votes, improving accessibility to voting data. These changes aim to provide a more comprehensive voting experience and better data handling for polls. --- apollo/subgraph.ts | 108 ++++++- components/PollVote/DesktopVoteTable.tsx | 186 +++++++++++ components/PollVote/MobileVoteCards.tsx | 54 ++++ components/PollVote/MobileVoteView.tsx | 184 +++++++++++ components/PollVote/PollVoteDetail.tsx | 225 ++++++++++++++ components/PollVote/PollVotePopover.tsx | 218 +++++++++++++ components/PollVote/index.tsx | 195 ++++++++++++ components/PollVotingWidget/index.tsx | 58 +++- lib/api/polls.ts | 7 + lib/api/types/votes.ts | 4 +- pages/voting/[poll].tsx | 376 +++++++++++++++-------- queries/poll.graphql | 6 +- queries/voteEvents.graphql | 21 ++ 13 files changed, 1487 insertions(+), 155 deletions(-) create mode 100644 components/PollVote/DesktopVoteTable.tsx create mode 100644 components/PollVote/MobileVoteCards.tsx create mode 100644 components/PollVote/MobileVoteView.tsx create mode 100644 components/PollVote/PollVoteDetail.tsx create mode 100644 components/PollVote/PollVotePopover.tsx create mode 100644 components/PollVote/index.tsx create mode 100644 queries/voteEvents.graphql diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts index 89083f72..311a52bc 100644 --- a/apollo/subgraph.ts +++ b/apollo/subgraph.ts @@ -9681,7 +9681,7 @@ export type PollQueryVariables = Exact<{ }>; -export type PollQuery = { __typename: 'Query', poll?: { __typename: 'Poll', id: string, proposal: string, endBlock: string, quorum: string, quota: string, tally?: { __typename: 'PollTally', yes: string, no: string } | null, votes: Array<{ __typename: 'Vote', id: string }> } | null }; +export type PollQuery = { __typename: 'Query', poll?: { __typename: 'Poll', id: string, proposal: string, endBlock: string, quorum: string, quota: string, tally?: { __typename: 'PollTally', yes: string, no: string } | null, votes: Array<{ __typename: 'Vote', id: string, choiceID?: PollChoice | null, voter: string, voteStake: string, nonVoteStake: string }> } | null }; export type PollsQueryVariables = Exact<{ [key: string]: never; }>; @@ -9719,6 +9719,16 @@ export type TranscoderActivatedEventsQueryVariables = Exact<{ export type TranscoderActivatedEventsQuery = { __typename: 'Query', transcoderActivatedEvents: Array<{ __typename: 'TranscoderActivatedEvent', activationRound: string, id: string }> }; +export type TranscoderUpdateEventsQueryVariables = Exact<{ + where?: InputMaybe; + first?: InputMaybe; + orderBy?: InputMaybe; + orderDirection?: InputMaybe; +}>; + + +export type TranscoderUpdateEventsQuery = { __typename: 'Query', transcoderUpdateEvents: Array<{ __typename: 'TranscoderUpdateEvent', id: string, rewardCut: string, feeShare: string, timestamp: number, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string } }> }; + export type TreasuryProposalQueryVariables = Exact<{ id: Scalars['ID']; }>; @@ -9757,6 +9767,14 @@ export type VoteQueryVariables = Exact<{ export type VoteQuery = { __typename: 'Query', vote?: { __typename: 'Vote', choiceID?: PollChoice | null, voteStake: string, nonVoteStake: string } | null }; +export type VoteEventsQueryVariables = Exact<{ + first?: InputMaybe; + where?: InputMaybe; +}>; + + +export type VoteEventsQuery = { __typename: 'Query', voteEvents: Array<{ __typename: 'VoteEvent', id: string, choiceID: string, voter: string, timestamp: number, poll: { __typename: 'Poll', id: string, proposal: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> }; + export const AccountDocument = gql` query account($account: ID!) { @@ -10451,6 +10469,10 @@ export const PollDocument = gql` } votes { id + choiceID + voter + voteStake + nonVoteStake } } } @@ -10838,16 +10860,6 @@ export function useTranscoderActivatedEventsLazyQuery(baseOptions?: Apollo.LazyQ export type TranscoderActivatedEventsQueryHookResult = ReturnType; export type TranscoderActivatedEventsLazyQueryHookResult = ReturnType; export type TranscoderActivatedEventsQueryResult = Apollo.QueryResult; - -export type TranscoderUpdateEventsQueryVariables = Exact<{ - where?: InputMaybe; - first?: InputMaybe; - orderBy?: InputMaybe; - orderDirection?: InputMaybe; -}>; - -export type TranscoderUpdateEventsQuery = { __typename: 'Query', transcoderUpdateEvents: Array<{ __typename: 'TranscoderUpdateEvent', id: string, rewardCut: string, feeShare: string, timestamp: number, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string } }> }; - export const TranscoderUpdateEventsDocument = gql` query transcoderUpdateEvents($where: TranscoderUpdateEvent_filter, $first: Int, $orderBy: TranscoderUpdateEvent_orderBy, $orderDirection: OrderDirection) { transcoderUpdateEvents( @@ -10870,6 +10882,25 @@ export const TranscoderUpdateEventsDocument = gql` } `; +/** + * __useTranscoderUpdateEventsQuery__ + * + * To run a query within a React component, call `useTranscoderUpdateEventsQuery` and pass it any options that fit your needs. + * When your component renders, `useTranscoderUpdateEventsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useTranscoderUpdateEventsQuery({ + * variables: { + * where: // value for 'where' + * first: // value for 'first' + * orderBy: // value for 'orderBy' + * orderDirection: // value for 'orderDirection' + * }, + * }); + */ export function useTranscoderUpdateEventsQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} return Apollo.useQuery(TranscoderUpdateEventsDocument, options); @@ -10881,7 +10912,6 @@ export function useTranscoderUpdateEventsLazyQuery(baseOptions?: Apollo.LazyQuer export type TranscoderUpdateEventsQueryHookResult = ReturnType; export type TranscoderUpdateEventsLazyQueryHookResult = ReturnType; export type TranscoderUpdateEventsQueryResult = Apollo.QueryResult; - export const TreasuryProposalDocument = gql` query treasuryProposal($id: ID!) { treasuryProposal(id: $id) { @@ -11123,4 +11153,56 @@ export function useVoteLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions; export type VoteLazyQueryHookResult = ReturnType; -export type VoteQueryResult = Apollo.QueryResult; \ No newline at end of file +export type VoteQueryResult = Apollo.QueryResult; +export const VoteEventsDocument = gql` + query voteEvents($first: Int, $where: VoteEvent_filter) { + voteEvents( + orderBy: timestamp + orderDirection: desc + first: $first + where: $where + ) { + id + choiceID + voter + timestamp + poll { + id + proposal + } + transaction { + id + timestamp + } + } +} + `; + +/** + * __useVoteEventsQuery__ + * + * To run a query within a React component, call `useVoteEventsQuery` and pass it any options that fit your needs. + * When your component renders, `useVoteEventsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useVoteEventsQuery({ + * variables: { + * first: // value for 'first' + * where: // value for 'where' + * }, + * }); + */ +export function useVoteEventsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(VoteEventsDocument, options); + } +export function useVoteEventsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(VoteEventsDocument, options); + } +export type VoteEventsQueryHookResult = ReturnType; +export type VoteEventsLazyQueryHookResult = ReturnType; +export type VoteEventsQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/components/PollVote/DesktopVoteTable.tsx b/components/PollVote/DesktopVoteTable.tsx new file mode 100644 index 00000000..e09fd332 --- /dev/null +++ b/components/PollVote/DesktopVoteTable.tsx @@ -0,0 +1,186 @@ +import EthAddressBadge from "@components/EthAddressBadge"; +import { ExplorerTooltip } from "@components/ExplorerTooltip"; +import DataTable from "@components/Table"; +import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; +import { Badge, Box, Text } from "@livepeer/design-system"; +import React, { useMemo } from "react"; +import { Column } from "react-table"; +import { PollVoteType } from "."; +import TransactionBadge from "@components/TransactionBadge"; +import { CounterClockwiseClockIcon } from "@radix-ui/react-icons"; +import dayjs from "dayjs"; + +export interface PollVoteTableProps { + votes: PollVoteType[]; + onSelect: (voter: { address: string; ensName?: string }) => void; + pageSize?: number; + totalPages?: number; + currentPage?: number; + onPageChange?: (page: number) => void; +} + +export const DesktopVoteTable: React.FC = ({ + votes, + onSelect, + pageSize = 10, +}) => { + const columns = useMemo[]>( + () => [ + { + Header: "Voter", + accessor: "ensName", + id: "voter", + Cell: ({ row }) => ( + + + + ), + }, + { + Header: "Support", + accessor: "choiceID", + id: "support", + Cell: ({ row }) => { + const support = VOTING_SUPPORT_MAP[row.original.choiceID]; + + return ( + + + + {support.text} + + + ); + }, + }, + { + Header: "Transaction", + accessor: "transactionHash", + id: "transaction", + Cell: ({ row }) => ( + + {row.original.transactionHash ? ( + + ) : ( + + N/A + + )} + + ), + }, + { + Header: "Timestamp", + accessor: "timestamp", + id: "timestamp", + Cell: ({ row }) => { + if (row.original.timestamp) { + return ( + + {dayjs + .unix(row.original.timestamp) + .format("MMM D YYYY, h:mm a")} + + ); + } else { + return ( + + N/A + + ); + } + }, + }, + { + Header: "", + id: "history", + Cell: ({ row }) => ( + + + { + e.stopPropagation(); + onSelect({ + address: row.original.voter, + ensName: row.original.ensName, + }); + }} + css={{ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 32, + height: 32, + borderRadius: "50%", + cursor: "pointer", + border: "none", + backgroundColor: "transparent", + color: "$neutral10", + "&:hover": { + color: "$primary11", + backgroundColor: "$primary3", + transform: "rotate(-15deg)", + }, + transition: "color .2s, background-color .2s, transform .2s", + }} + > + + + + + ), + disableSortBy: true, + }, + ], + [onSelect] + ); + + return ( + + + + ); +}; diff --git a/components/PollVote/MobileVoteCards.tsx b/components/PollVote/MobileVoteCards.tsx new file mode 100644 index 00000000..bef94b4b --- /dev/null +++ b/components/PollVote/MobileVoteCards.tsx @@ -0,0 +1,54 @@ +import Pagination from "@components/Table/Pagination"; +import { Box, Text } from "@livepeer/design-system"; +import React from "react"; +import { PollVoteTableProps } from "./DesktopVoteTable"; +import { MobileVoteView } from "./MobileVoteView"; + +export const MobileVoteCards: React.FC = (props) => { + const { + votes, + onSelect, + totalPages = 0, + currentPage = 1, + onPageChange, + } = props; + + return ( + + + View a voter's proposal voting history by clicking the history + icon. + + + {votes.map((vote) => { + return ( + + ); + })} + + {/* Pagination */} + {totalPages > 1 && ( + 1} + canNext={currentPage < totalPages} + onPrevious={() => onPageChange?.(currentPage - 1)} + onNext={() => onPageChange?.(currentPage + 1)} + /> + )} + + ); +}; diff --git a/components/PollVote/MobileVoteView.tsx b/components/PollVote/MobileVoteView.tsx new file mode 100644 index 00000000..5f234614 --- /dev/null +++ b/components/PollVote/MobileVoteView.tsx @@ -0,0 +1,184 @@ +import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; +import dayjs from "@lib/dayjs"; +import { + Badge, + Box, + Card, + Flex, + Heading, + Link, + Text, +} from "@livepeer/design-system"; +import { + ArrowTopRightIcon, + CounterClockwiseClockIcon, +} from "@radix-ui/react-icons"; +import { formatAddress, formatTransactionHash } from "@utils/web3"; +import { PollVoteType } from "."; + +interface MobileVoteViewProps { + vote: PollVoteType; + onSelect: (voter: { address: string; ensName?: string }) => void; +} + +export function MobileVoteView({ vote, onSelect }: MobileVoteViewProps) { + const support = VOTING_SUPPORT_MAP[vote.choiceID] + const voterId = vote.ensName ? vote.ensName : formatAddress(vote.voter) + + return ( + + + {/* Hero: Vote badge */} + + + {support.text} + + + {/* Voter name + History */} + + + + {voterId} + + + + onSelect({ address: vote.voter, ensName: vote.ensName }) + } + > + + History + + + + + + {/* Footer: Transaction + Timestamp */} + + {vote.transactionHash ? ( + *": { + border: "1.5px solid $grass7 !important", + backgroundColor: "$grass3 !important", + color: "$grass11 !important", + }, + "&:focus-visible > *": { + outline: "2px solid $primary11", + outlineOffset: "2px", + }, + }} + > + + {formatTransactionHash(vote.transactionHash)} + + + + ) : ( + + N/A + + )} + {vote.timestamp && ( + <> + + · + + + {dayjs.unix(vote.timestamp).format("MMM D, h:mm a")} + + + )} + + + + ); +} diff --git a/components/PollVote/PollVoteDetail.tsx b/components/PollVote/PollVoteDetail.tsx new file mode 100644 index 00000000..7566a330 --- /dev/null +++ b/components/PollVote/PollVoteDetail.tsx @@ -0,0 +1,225 @@ +import TransactionBadge from "@components/TransactionBadge"; +import { parsePollText } from "@lib/api/polls"; +import { POLL_VOTES, VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; +import dayjs from "@lib/dayjs"; +import { + Badge, + Box, + Card, + Flex, + Heading, + Link, + Text, +} from "@livepeer/design-system"; +import { VoteEvent } from "apollo"; +import React, { useEffect, useState } from "react"; + +interface PollVoteDetailProps { + vote: VoteEvent; +} + +const Index: React.FC = ({ vote }) => { + const [title, setTitle] = useState(""); + const support = POLL_VOTES[vote.choiceID]; + + useEffect(() => { + async function getTitle() { + const pollText = await parsePollText(vote.poll.proposal); + + if (pollText?.title) { + setTitle(pollText.title); + } + } + + getTitle() + }); + + return ( + + {/* Mobile Card Layout */} + + + {/* Hero: Vote badge */} + + + {support.text} + + + {/* Title link */} + + + {title} + + + + {/* Footer: Transaction + Timestamp */} + + {vote.transaction.id ? ( + + ) : ( + + N/A + + )} + + · + + + {dayjs.unix(vote.transaction.timestamp).format("MMM D, h:mm a")} + + + + + + {/* Desktop Timeline Layout */} + + {/* Timeline Dot */} + + + + + {/* Hero: Vote badge */} + + + {support.text} + + + {/* Title link */} + + + {title} + + + + {/* Footer: Transaction + Timestamp */} + + {vote.transaction.id ? ( + + ) : ( + + N/A + + )} + + · + + + {dayjs.unix(vote.transaction.timestamp).format("MMM D, h:mm a")} + + + + + + + ); +}; + +export default Index; diff --git a/components/PollVote/PollVotePopover.tsx b/components/PollVote/PollVotePopover.tsx new file mode 100644 index 00000000..8f802596 --- /dev/null +++ b/components/PollVote/PollVotePopover.tsx @@ -0,0 +1,218 @@ +import Spinner from "@components/Spinner"; +import { POLL_VOTES } from "@lib/api/types/votes"; +import { Badge, Box, Flex, Link, Text } from "@livepeer/design-system"; +import { ArrowTopRightIcon } from "@radix-ui/react-icons"; +import { formatNumber } from "@utils/numberFormatters"; +import TreasuryVoteHistoryModal from "../Treasury/TreasuryVoteTable/TreasuryVoteHistoryModal"; +import PollVoteDetail from "./PollVoteDetail"; +import { useVoteEventsQuery, VoteEvent } from "apollo"; +import React from "react"; + +interface PollVotePopoverProps { + voter: string; + ensName?: string; + onClose: () => void; +} + +const Index: React.FC = ({ voter, ensName, onClose }) => { + const { data: votesData, loading: isLoading } = useVoteEventsQuery({ + variables: { + where: { + voter: voter, + }, + }, + }); + + const votes = React.useMemo(() => { + return votesData?.voteEvents + ? [...votesData.voteEvents].sort( + (a, b) => b.transaction.timestamp - a.transaction.timestamp + ) + : []; + }, [votesData]); + + const stats = React.useMemo(() => { + if (!votes.length) return null; + + return { + total: votes.length, + for: votes.filter((v) => v.choiceID === "0").length, + against: votes.filter((v) => v.choiceID === "1").length, + }; + }, [votes]); + + const summaryHeader = React.useMemo(() => { + return ( + + + + {ensName || voter} + + + {stats && ( + + + Total: + + + {formatNumber(stats.total, { precision: 0 })} + + + )} + + {stats && ( + + + + Total: + + + {formatNumber(stats.total, { precision: 0 })} + + + + + For: {formatNumber(stats.for, { precision: 0 })} + + + + Against: {formatNumber(stats.against, { precision: 0 })} + + + )} + + ); + }, [stats, voter, ensName]); + + return ( + + {isLoading ? ( + + + + ) : votes.length > 0 ? ( + + {votes.map((vote, idx) => ( + + + + ))} + + ) : ( + + No votes found for this voter. + + )} + + ); +}; + +export default Index; diff --git a/components/PollVote/index.tsx b/components/PollVote/index.tsx new file mode 100644 index 00000000..3ab60ead --- /dev/null +++ b/components/PollVote/index.tsx @@ -0,0 +1,195 @@ +import Spinner from "@components/Spinner"; +import { getEnsForVotes } from "@lib/api/ens"; +import { Flex, Text } from "@livepeer/design-system"; +import { formatAddress } from "@utils/web3"; +import { + PollChoice, + usePollQuery, + useVoteEventsQuery +} from "apollo"; +import React, { useEffect, useMemo, useState } from "react"; +import { useWindowSize } from "react-use"; +import { DesktopVoteTable } from "./DesktopVoteTable"; +import { MobileVoteCards } from "./MobileVoteCards"; +import PollVotePopover from "./PollVotePopover" + +interface PollVotingTableProps { + pollId: string; +} + +export type PollVoteType = { + __typename: "Vote"; + id: string; + choiceID: PollChoice; + voter: string; + voteStake: string; + nonVoteStake: string; + ensName?: string; + transactionHash: string; + timestamp: number +} + +const useVotes = (pollId: string) => { + const pollInterval = 10000; + + const { + data: pollVotesData, + loading, + error: pollError, + } = usePollQuery({ + variables: { + id: pollId ?? "", + }, + pollInterval, + }); + + const { data: pollVoteEventsData, error: pollVoteEventsError } = + useVoteEventsQuery({ + variables: { + first: 200, + where: { + poll: pollId, + }, + }, + }); + + const [votes, setVotes] = useState([]); + const [votesLoading, setVotesLoading] = useState(false); + + useEffect(() => { + if (!pollVotesData?.poll?.votes || !pollVoteEventsData?.voteEvents) { + setVotes([]); + } + + const decorateVotes = async () => { + setVotesLoading(true); + + const uniqueVoters = Array.from( + new Set(pollVotesData?.poll?.votes?.map((v) => v.voter) ?? []) + ); + + const localEnsCache: { [address: string]: string } = {}; + + await Promise.all( + uniqueVoters.map(async (address) => { + try { + if (localEnsCache[address]) { + return; + } + const ensAddress = await getEnsForVotes(address); + + if (ensAddress && ensAddress.name) { + localEnsCache[address] = ensAddress.name; + } else { + localEnsCache[address] = formatAddress(address); + } + } catch (e) { + console.warn(`Failed to fetch ENS for ${address}`, e); + } + }) + ); + + const votes = + pollVotesData?.poll?.votes?.map((vote) => { + const events = (pollVoteEventsData?.voteEvents ?? []) + .filter((event) => event.voter === vote.voter) + .sort((a, b) => b.timestamp - a.timestamp); + + const latestEvent = events[0]; + const ensName = localEnsCache[vote.voter] ?? ""; + + return { + ...vote, + ensName, + transactionHash: latestEvent?.transaction.id ?? "", + timestamp: latestEvent?.timestamp, + }; + }) ?? []; + + setVotes(votes as PollVoteType[]); + setVotesLoading(false); + }; + + decorateVotes(); + }, [pollVotesData?.poll?.votes, pollVoteEventsData?.voteEvents]); + + return { + votes, + loading: loading || votesLoading, + error: pollError || pollVoteEventsError, + }; +}; + +const Index: React.FC = ({ pollId }) => { + const { width } = useWindowSize(); + const isDesktop = width >= 900; + + const [selectedVoter, setSelectedVoter] = useState<{ + address: string; + ensName?: string; + } | null>(null); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const { votes, loading } = useVotes(pollId); + + const paginatedVotesForMobile = useMemo(() => { + const sorted = [...votes].sort( + (a, b) => b.timestamp - a.timestamp + ); + const startIndex = (currentPage - 1) * pageSize; + return sorted.slice(startIndex, startIndex + pageSize); + }, [votes, currentPage, pageSize]); + + const totalPages = Math.ceil(votes.length / pageSize); + + if (loading) { + return ( + + + + ); + } + + if (!votes.length) + return ( + + No votes found for this poll. + + ); + + return ( + <> + {isDesktop ? ( + + ) : ( + + )} + {selectedVoter && ( + setSelectedVoter(null)} + /> + )} + + ); +}; + +export default Index; diff --git a/components/PollVotingWidget/index.tsx b/components/PollVotingWidget/index.tsx index 8b90568a..432a7f89 100644 --- a/components/PollVotingWidget/index.tsx +++ b/components/PollVotingWidget/index.tsx @@ -2,7 +2,8 @@ import CliVotingInstructionsDialog from "@components/CliVotingInstructionsDialog import VoteButton from "@components/VoteButton"; import { PollExtended } from "@lib/api/polls"; import dayjs from "@lib/dayjs"; -import { Box, Button, Flex, Heading, Text } from "@livepeer/design-system"; +import { Box, Button, Flex, Heading, Link as A, Text } from "@livepeer/design-system"; +import Link, { LinkProps } from "next/link"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; import { formatPercent, formatVotingPower } from "@utils/numberFormatters"; import { formatAddress } from "@utils/web3"; @@ -10,6 +11,7 @@ import { AccountQuery, PollChoice, TranscoderStatus } from "apollo"; import { useAccountAddress, usePendingFeesAndStakeData } from "hooks"; import { useMemo } from "react"; import { getVotingPower } from "utils/voting"; +import {} from "@components/PollVote" type Props = { poll: PollExtended; @@ -32,6 +34,7 @@ type Props = { | undefined | null; myAccount: AccountQuery; + votesTabHref?: LinkProps["href"] | string; }; const SectionLabel = ({ children }: { children: React.ReactNode }) => ( @@ -216,23 +219,52 @@ const Index = ({ data }: { data: Props }) => { - - {data.poll.votes.length}{" "} - {`${ - data.poll.votes.length > 1 || data.poll.votes.length === 0 - ? "votes" - : "vote" - }`}{" "} - · {formatVotingPower(data.poll.stake.voters)} ·{" "} - {data.poll.status !== "active" - ? "Final Results" - : dayjs + {/* Summary line */} + + + {data.poll.votes.length}{" "} + {`${data.poll.votes.length > 1 || data.poll.votes.length === 0 + ? "votes" + : "vote" + }`}{" "} + · {formatVotingPower(data.poll.stake.voters)} ·{" "} + {data.poll.status !== "active" + ? "Final Results" + : dayjs .duration( dayjs().unix() - data.poll.estimatedEndTime, "seconds" ) .humanize() + " left"} - + + {data.votesTabHref ? ( + + + View votes + + + ) : ( + + View votes + + )} + {/* ========== YOUR VOTE SECTION ========== */} diff --git a/lib/api/polls.ts b/lib/api/polls.ts index 87192da4..e873c0f7 100644 --- a/lib/api/polls.ts +++ b/lib/api/polls.ts @@ -61,6 +61,13 @@ export const parsePollIpfs = (ipfsObject?: IpfsPoll | null): Fm | null => { }; }; +export const parsePollText = async (proposal: string): Promise => { + const ipfsObject = await catIpfsJson(proposal); + let attributes = parsePollIpfs(ipfsObject); + + return attributes +} + export const getPollExtended = async ( poll: | NonNullable["polls"][number] diff --git a/lib/api/types/votes.ts b/lib/api/types/votes.ts index 87bee124..f294d96a 100644 --- a/lib/api/types/votes.ts +++ b/lib/api/types/votes.ts @@ -3,7 +3,7 @@ import { CrossCircledIcon, MinusCircledIcon, } from "@radix-ui/react-icons"; -import { TreasuryVoteSupport } from "apollo"; +import { PollChoice, TreasuryVoteSupport } from "apollo"; // Standardized Poll Votes ("0" is yes/for) export const POLL_VOTES = { @@ -56,6 +56,8 @@ export const VOTING_SUPPORT_MAP = { [TreasuryVoteSupport.Against]: TREASURY_VOTES.against, [TreasuryVoteSupport.For]: TREASURY_VOTES.for, [TreasuryVoteSupport.Abstain]: TREASURY_VOTES.abstain, + [PollChoice.Yes]: POLL_VOTES.Yes, + [PollChoice.No]: POLL_VOTES.No } as const; // Legacy support (to be replaced by POLL_VOTES or TREASURY_VOTES) diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index 5cc80eed..b4269b04 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -3,6 +3,7 @@ import MarkdownRenderer from "@components/MarkdownRenderer"; import PollVotingWidget from "@components/PollVotingWidget"; import Spinner from "@components/Spinner"; import Stat from "@components/Stat"; +import PollVotingTable from "@components/PollVote" import { LAYOUT_MAX_WIDTH } from "@layouts/constants"; import { getLayout } from "@layouts/main"; import { getPollExtended, PollExtended } from "@lib/api/polls"; @@ -16,6 +17,7 @@ import { Flex, Heading, Text, + Link as A, } from "@livepeer/design-system"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; import { formatLPT, formatPercent } from "@utils/numberFormatters"; @@ -30,8 +32,9 @@ import { import { sentenceCase } from "change-case"; import Head from "next/head"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useWindowSize } from "react-use"; +import NextLink from "next/link"; import { useAccountAddress, @@ -39,6 +42,7 @@ import { useExplorerStore, } from "../../hooks"; import FourZeroFour from "../404"; +import HorizontalScrollContainer from "@components/HorizontalScrollContainer"; const Poll = () => { const router = useRouter(); @@ -48,12 +52,21 @@ const Poll = () => { const [pollData, setPollData] = useState(null); const { query } = router; + const view = query?.view?.toString().toLowerCase() || "overview"; const pollId = query?.poll?.toString().toLowerCase(); const pollInterval = 10000; const { setBottomDrawerOpen } = useExplorerStore(); + const votesTabHref = useMemo( + () => ({ + pathname: router.pathname, + query: { ...query, view: "votes" }, + }), + [router.pathname, query], + ); + const { data, error: pollError } = usePollQuery({ variables: { id: pollId ?? "", @@ -101,6 +114,31 @@ const Poll = () => { init(); }, [data, currentRound?.currentL1Block]); + const tabs = useMemo( + () => [ + { + name: "Overview", + href: { + pathname: router.pathname, + query: { ...query, view: "overview" }, + }, + isActive: view === "overview", + }, + { + name: "Votes", + href: votesTabHref, + isActive: view === "votes", + count: data?.poll?.votes?.length, + }, + ], + [router.pathname, query, view, data?.poll?.votes?.length, votesTabHref], + ); + + const voteContent = useCallback(() => { + if (!pollData?.votes?.length) return No votes yet.; + return ; + }, [pollData]); + if (pollError) { return ; } @@ -212,143 +250,225 @@ const Poll = () => { - - - Total Support ( - {formatPercent( - +pollData.quota / PERCENTAGE_PRECISION_MILLION - )}{" "} - needed) - - } - value={{formatPercent(pollData.percent.yes)}} - meta={ - - ( + + - - - - For ({formatPercent(pollData.percent.yes)}) - - - - {formatLPT(pollData.stake.yes, { precision: 4 })} - - - + - - - - Against ({formatPercent(pollData.percent.no)}) - - - - {formatLPT(pollData.stake.no, { precision: 4 })} - - - - } - /> + {tab.name} + {tab.count !== undefined && ( + + {tab.count} + + )} + + + + ))} + - - Total Participation ( - {formatPercent( - +pollData.quorum / PERCENTAGE_PRECISION_MILLION - )}{" "} - needed) - - } - value={{formatPercent(pollData.percent.voters)}} - meta={ - - - - Voters ({formatPercent(pollData.percent.voters)}) + + + + + Total Support ( + {formatPercent( + +pollData.quota / PERCENTAGE_PRECISION_MILLION, + )}{" "} + needed) - - - {formatLPT(pollData.stake.voters, { precision: 4 })} - + } + value={{formatPercent(pollData.percent.yes)}} + meta={ + + + + + + For ({formatPercent(pollData.percent.yes)}) + + + + {formatLPT(pollData.stake.yes, { precision: 4 })} + + + + + + + Against ({formatPercent(pollData.percent.no)}) + + + + {formatLPT(pollData.stake.no, { precision: 4 })} + + - - - - Nonvoters ({formatPercent(pollData.percent.nonVoters)} - ) + } + /> + + + Total Participation ( + {formatPercent( + +pollData.quorum / PERCENTAGE_PRECISION_MILLION, + )}{" "} + needed) - - - {formatLPT(pollData.stake.nonVoters, { - precision: 4, - })} - + } + value={ + {formatPercent(pollData.percent.voters)} + } + meta={ + + + + Voters ({formatPercent(pollData.percent.voters)}) + + + + {formatLPT(pollData.stake.voters, { + precision: 4, + })} + + + + + + Nonvoters ( + {formatPercent(pollData.percent.nonVoters)}) + + + + {formatLPT(pollData.stake.nonVoters, { + precision: 4, + })} + + + - - - } - /> + } + /> + + + + {pollData.attributes?.text ?? ""} + + + + + + + {voteContent()} - - - {pollData.attributes?.text ?? ""} - - @@ -388,6 +508,7 @@ const Poll = () => { | undefined | null, myAccount: myAccountData as AccountQuery, + votesTabHref, }} /> @@ -415,6 +536,7 @@ const Poll = () => { | undefined | null, myAccount: myAccountData as AccountQuery, + votesTabHref, }} /> diff --git a/queries/poll.graphql b/queries/poll.graphql index fc928830..8b2145b0 100644 --- a/queries/poll.graphql +++ b/queries/poll.graphql @@ -11,6 +11,10 @@ query poll($id: ID!) { } votes { id + choiceID + voter + voteStake + nonVoteStake } } -} +} \ No newline at end of file diff --git a/queries/voteEvents.graphql b/queries/voteEvents.graphql new file mode 100644 index 00000000..15b390af --- /dev/null +++ b/queries/voteEvents.graphql @@ -0,0 +1,21 @@ +query voteEvents($first: Int, $where: VoteEvent_filter) { + voteEvents( + orderBy: timestamp + orderDirection: desc + first: $first + where: $where + ) { + id + choiceID + voter + timestamp + poll { + id + proposal + } + transaction { + id + timestamp + } + } +} \ No newline at end of file From 1660abddf34f6d6f82e7119b83283233ae303609 Mon Sep 17 00:00:00 2001 From: Ibrahim Suleiman Date: Wed, 27 May 2026 12:03:00 +0100 Subject: [PATCH 2/6] refactor: clean up imports and improve code consistency across PollVote components - Removed duplicate imports and organized import statements for clarity in DesktopVoteTable, index, MobileVoteCards, and MobileVoteView components. - Ensured consistent formatting and spacing in various files to enhance readability. - Updated GraphQL queries to maintain structure and improve maintainability. - Minor adjustments to the PollVotingWidget and PollVotePopover components for better integration. --- components/PollVote/DesktopVoteTable.tsx | 23 ++++++++-------- components/PollVote/MobileVoteCards.tsx | 1 + components/PollVote/MobileVoteView.tsx | 5 ++-- components/PollVote/PollVoteDetail.tsx | 4 +-- components/PollVote/PollVotePopover.tsx | 5 ++-- components/PollVote/index.tsx | 17 +++++------- components/PollVotingWidget/index.tsx | 28 ++++++++++++------- lib/api/polls.ts | 6 ++--- lib/api/types/votes.ts | 2 +- pages/voting/[poll].tsx | 16 +++++------ queries/poll.graphql | 2 +- queries/voteEvents.graphql | 34 ++++++++++++------------ 12 files changed, 75 insertions(+), 68 deletions(-) diff --git a/components/PollVote/DesktopVoteTable.tsx b/components/PollVote/DesktopVoteTable.tsx index e09fd332..5df5af4e 100644 --- a/components/PollVote/DesktopVoteTable.tsx +++ b/components/PollVote/DesktopVoteTable.tsx @@ -1,14 +1,15 @@ import EthAddressBadge from "@components/EthAddressBadge"; import { ExplorerTooltip } from "@components/ExplorerTooltip"; import DataTable from "@components/Table"; +import TransactionBadge from "@components/TransactionBadge"; import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; import { Badge, Box, Text } from "@livepeer/design-system"; +import { CounterClockwiseClockIcon } from "@radix-ui/react-icons"; +import dayjs from "dayjs"; import React, { useMemo } from "react"; import { Column } from "react-table"; + import { PollVoteType } from "."; -import TransactionBadge from "@components/TransactionBadge"; -import { CounterClockwiseClockIcon } from "@radix-ui/react-icons"; -import dayjs from "dayjs"; export interface PollVoteTableProps { votes: PollVoteType[]; @@ -104,10 +105,10 @@ export const DesktopVoteTable: React.FC = ({ ); } else { return ( - - N/A - - ); + + N/A + + ); } }, }, @@ -175,10 +176,10 @@ export const DesktopVoteTable: React.FC = ({ pageSize, sortBy: [ { - id: 'timestamp', - desc: true - } - ] + id: "timestamp", + desc: true, + }, + ], }} /> diff --git a/components/PollVote/MobileVoteCards.tsx b/components/PollVote/MobileVoteCards.tsx index bef94b4b..9b26a5c6 100644 --- a/components/PollVote/MobileVoteCards.tsx +++ b/components/PollVote/MobileVoteCards.tsx @@ -1,6 +1,7 @@ import Pagination from "@components/Table/Pagination"; import { Box, Text } from "@livepeer/design-system"; import React from "react"; + import { PollVoteTableProps } from "./DesktopVoteTable"; import { MobileVoteView } from "./MobileVoteView"; diff --git a/components/PollVote/MobileVoteView.tsx b/components/PollVote/MobileVoteView.tsx index 5f234614..8e0132f0 100644 --- a/components/PollVote/MobileVoteView.tsx +++ b/components/PollVote/MobileVoteView.tsx @@ -14,6 +14,7 @@ import { CounterClockwiseClockIcon, } from "@radix-ui/react-icons"; import { formatAddress, formatTransactionHash } from "@utils/web3"; + import { PollVoteType } from "."; interface MobileVoteViewProps { @@ -22,8 +23,8 @@ interface MobileVoteViewProps { } export function MobileVoteView({ vote, onSelect }: MobileVoteViewProps) { - const support = VOTING_SUPPORT_MAP[vote.choiceID] - const voterId = vote.ensName ? vote.ensName : formatAddress(vote.voter) + const support = VOTING_SUPPORT_MAP[vote.choiceID]; + const voterId = vote.ensName ? vote.ensName : formatAddress(vote.voter); return ( = ({ vote }) => { } } - getTitle() + getTitle(); }); return ( diff --git a/components/PollVote/PollVotePopover.tsx b/components/PollVote/PollVotePopover.tsx index 8f802596..5783e138 100644 --- a/components/PollVote/PollVotePopover.tsx +++ b/components/PollVote/PollVotePopover.tsx @@ -3,11 +3,12 @@ import { POLL_VOTES } from "@lib/api/types/votes"; import { Badge, Box, Flex, Link, Text } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@radix-ui/react-icons"; import { formatNumber } from "@utils/numberFormatters"; -import TreasuryVoteHistoryModal from "../Treasury/TreasuryVoteTable/TreasuryVoteHistoryModal"; -import PollVoteDetail from "./PollVoteDetail"; import { useVoteEventsQuery, VoteEvent } from "apollo"; import React from "react"; +import TreasuryVoteHistoryModal from "../Treasury/TreasuryVoteTable/TreasuryVoteHistoryModal"; +import PollVoteDetail from "./PollVoteDetail"; + interface PollVotePopoverProps { voter: string; ensName?: string; diff --git a/components/PollVote/index.tsx b/components/PollVote/index.tsx index 3ab60ead..a9acf355 100644 --- a/components/PollVote/index.tsx +++ b/components/PollVote/index.tsx @@ -2,16 +2,13 @@ import Spinner from "@components/Spinner"; import { getEnsForVotes } from "@lib/api/ens"; import { Flex, Text } from "@livepeer/design-system"; import { formatAddress } from "@utils/web3"; -import { - PollChoice, - usePollQuery, - useVoteEventsQuery -} from "apollo"; +import { PollChoice, usePollQuery, useVoteEventsQuery } from "apollo"; import React, { useEffect, useMemo, useState } from "react"; import { useWindowSize } from "react-use"; + import { DesktopVoteTable } from "./DesktopVoteTable"; import { MobileVoteCards } from "./MobileVoteCards"; -import PollVotePopover from "./PollVotePopover" +import PollVotePopover from "./PollVotePopover"; interface PollVotingTableProps { pollId: string; @@ -26,8 +23,8 @@ export type PollVoteType = { nonVoteStake: string; ensName?: string; transactionHash: string; - timestamp: number -} + timestamp: number; +}; const useVotes = (pollId: string) => { const pollInterval = 10000; @@ -134,9 +131,7 @@ const Index: React.FC = ({ pollId }) => { const { votes, loading } = useVotes(pollId); const paginatedVotesForMobile = useMemo(() => { - const sorted = [...votes].sort( - (a, b) => b.timestamp - a.timestamp - ); + const sorted = [...votes].sort((a, b) => b.timestamp - a.timestamp); const startIndex = (currentPage - 1) * pageSize; return sorted.slice(startIndex, startIndex + pageSize); }, [votes, currentPage, pageSize]); diff --git a/components/PollVotingWidget/index.tsx b/components/PollVotingWidget/index.tsx index 432a7f89..0f9b1592 100644 --- a/components/PollVotingWidget/index.tsx +++ b/components/PollVotingWidget/index.tsx @@ -1,17 +1,24 @@ import CliVotingInstructionsDialog from "@components/CliVotingInstructionsDialog"; +import {} from "@components/PollVote"; import VoteButton from "@components/VoteButton"; import { PollExtended } from "@lib/api/polls"; import dayjs from "@lib/dayjs"; -import { Box, Button, Flex, Heading, Link as A, Text } from "@livepeer/design-system"; -import Link, { LinkProps } from "next/link"; +import { + Box, + Button, + Flex, + Heading, + Link as A, + Text, +} from "@livepeer/design-system"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; import { formatPercent, formatVotingPower } from "@utils/numberFormatters"; import { formatAddress } from "@utils/web3"; import { AccountQuery, PollChoice, TranscoderStatus } from "apollo"; import { useAccountAddress, usePendingFeesAndStakeData } from "hooks"; +import Link, { LinkProps } from "next/link"; import { useMemo } from "react"; import { getVotingPower } from "utils/voting"; -import {} from "@components/PollVote" type Props = { poll: PollExtended; @@ -223,19 +230,20 @@ const Index = ({ data }: { data: Props }) => { {data.poll.votes.length}{" "} - {`${data.poll.votes.length > 1 || data.poll.votes.length === 0 + {`${ + data.poll.votes.length > 1 || data.poll.votes.length === 0 ? "votes" : "vote" - }`}{" "} + }`}{" "} · {formatVotingPower(data.poll.stake.voters)} ·{" "} {data.poll.status !== "active" ? "Final Results" : dayjs - .duration( - dayjs().unix() - data.poll.estimatedEndTime, - "seconds" - ) - .humanize() + " left"} + .duration( + dayjs().unix() - data.poll.estimatedEndTime, + "seconds" + ) + .humanize() + " left"} {data.votesTabHref ? ( diff --git a/lib/api/polls.ts b/lib/api/polls.ts index e873c0f7..888cf19f 100644 --- a/lib/api/polls.ts +++ b/lib/api/polls.ts @@ -63,10 +63,10 @@ export const parsePollIpfs = (ipfsObject?: IpfsPoll | null): Fm | null => { export const parsePollText = async (proposal: string): Promise => { const ipfsObject = await catIpfsJson(proposal); - let attributes = parsePollIpfs(ipfsObject); + const attributes = parsePollIpfs(ipfsObject); - return attributes -} + return attributes; +}; export const getPollExtended = async ( poll: diff --git a/lib/api/types/votes.ts b/lib/api/types/votes.ts index f294d96a..1d3b5688 100644 --- a/lib/api/types/votes.ts +++ b/lib/api/types/votes.ts @@ -57,7 +57,7 @@ export const VOTING_SUPPORT_MAP = { [TreasuryVoteSupport.For]: TREASURY_VOTES.for, [TreasuryVoteSupport.Abstain]: TREASURY_VOTES.abstain, [PollChoice.Yes]: POLL_VOTES.Yes, - [PollChoice.No]: POLL_VOTES.No + [PollChoice.No]: POLL_VOTES.No, } as const; // Legacy support (to be replaced by POLL_VOTES or TREASURY_VOTES) diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index b4269b04..85c69858 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -1,9 +1,10 @@ import BottomDrawer from "@components/BottomDrawer"; +import HorizontalScrollContainer from "@components/HorizontalScrollContainer"; import MarkdownRenderer from "@components/MarkdownRenderer"; +import PollVotingTable from "@components/PollVote"; import PollVotingWidget from "@components/PollVotingWidget"; import Spinner from "@components/Spinner"; import Stat from "@components/Stat"; -import PollVotingTable from "@components/PollVote" import { LAYOUT_MAX_WIDTH } from "@layouts/constants"; import { getLayout } from "@layouts/main"; import { getPollExtended, PollExtended } from "@lib/api/polls"; @@ -16,8 +17,8 @@ import { Container, Flex, Heading, - Text, Link as A, + Text, } from "@livepeer/design-system"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; import { formatLPT, formatPercent } from "@utils/numberFormatters"; @@ -31,10 +32,10 @@ import { } from "apollo"; import { sentenceCase } from "change-case"; import Head from "next/head"; +import NextLink from "next/link"; import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useWindowSize } from "react-use"; -import NextLink from "next/link"; import { useAccountAddress, @@ -42,7 +43,6 @@ import { useExplorerStore, } from "../../hooks"; import FourZeroFour from "../404"; -import HorizontalScrollContainer from "@components/HorizontalScrollContainer"; const Poll = () => { const router = useRouter(); @@ -64,7 +64,7 @@ const Poll = () => { pathname: router.pathname, query: { ...query, view: "votes" }, }), - [router.pathname, query], + [router.pathname, query] ); const { data, error: pollError } = usePollQuery({ @@ -131,7 +131,7 @@ const Poll = () => { count: data?.poll?.votes?.length, }, ], - [router.pathname, query, view, data?.poll?.votes?.length, votesTabHref], + [router.pathname, query, view, data?.poll?.votes?.length, votesTabHref] ); const voteContent = useCallback(() => { @@ -331,7 +331,7 @@ const Poll = () => { Total Support ( {formatPercent( - +pollData.quota / PERCENTAGE_PRECISION_MILLION, + +pollData.quota / PERCENTAGE_PRECISION_MILLION )}{" "} needed) @@ -398,7 +398,7 @@ const Poll = () => { Total Participation ( {formatPercent( - +pollData.quorum / PERCENTAGE_PRECISION_MILLION, + +pollData.quorum / PERCENTAGE_PRECISION_MILLION )}{" "} needed) diff --git a/queries/poll.graphql b/queries/poll.graphql index 8b2145b0..ff688e84 100644 --- a/queries/poll.graphql +++ b/queries/poll.graphql @@ -17,4 +17,4 @@ query poll($id: ID!) { nonVoteStake } } -} \ No newline at end of file +} diff --git a/queries/voteEvents.graphql b/queries/voteEvents.graphql index 15b390af..a0f4a124 100644 --- a/queries/voteEvents.graphql +++ b/queries/voteEvents.graphql @@ -1,21 +1,21 @@ query voteEvents($first: Int, $where: VoteEvent_filter) { - voteEvents( - orderBy: timestamp - orderDirection: desc - first: $first - where: $where - ) { + voteEvents( + orderBy: timestamp + orderDirection: desc + first: $first + where: $where + ) { + id + choiceID + voter + timestamp + poll { + id + proposal + } + transaction { id - choiceID - voter timestamp - poll { - id - proposal - } - transaction { - id - timestamp - } } -} \ No newline at end of file + } +} From 9e80ed999a35c5e080d18b6170c82983ed7a5402 Mon Sep 17 00:00:00 2001 From: Ibrahim Suleiman Date: Wed, 27 May 2026 20:49:31 +0100 Subject: [PATCH 3/6] feat: enhance voting components with vote stake formatting - Updated the PollVote components to include vote stake information, allowing for better transparency in voting data. - Modified the `onSelect` function to pass `voteStake` along with the voter's address and ENS name. - Introduced a `formatVoteStake` function to format and display vote stakes consistently across DesktopVoteTable, MobileVoteCards, and MobileVoteView components. - Adjusted GraphQL queries and state management to accommodate the new vote stake data, improving overall functionality and user experience. --- components/PollVote/DesktopVoteTable.tsx | 61 +++++++++++++++--------- components/PollVote/MobileVoteCards.tsx | 2 + components/PollVote/MobileVoteView.tsx | 23 +++++++-- components/PollVote/PollVoteDetail.tsx | 2 +- components/PollVote/PollVotePopover.tsx | 25 +++++----- components/PollVote/index.tsx | 20 ++++++++ 6 files changed, 95 insertions(+), 38 deletions(-) diff --git a/components/PollVote/DesktopVoteTable.tsx b/components/PollVote/DesktopVoteTable.tsx index 5df5af4e..36ea0688 100644 --- a/components/PollVote/DesktopVoteTable.tsx +++ b/components/PollVote/DesktopVoteTable.tsx @@ -13,17 +13,23 @@ import { PollVoteType } from "."; export interface PollVoteTableProps { votes: PollVoteType[]; - onSelect: (voter: { address: string; ensName?: string }) => void; + onSelect: (voter: { + address: string; + voteStake: string; + ensName?: string; + }) => void; pageSize?: number; totalPages?: number; currentPage?: number; onPageChange?: (page: number) => void; + formatVoteStake: (stake: string) => string; } export const DesktopVoteTable: React.FC = ({ votes, onSelect, pageSize = 10, + formatVoteStake, }) => { const columns = useMemo[]>( () => [ @@ -68,26 +74,13 @@ export const DesktopVoteTable: React.FC = ({ }, }, { - Header: "Transaction", - accessor: "transactionHash", - id: "transaction", + Header: "Stake Used", + accessor: "voteStake", + id: "stakeUsed", Cell: ({ row }) => ( - - {row.original.transactionHash ? ( - - ) : ( - - N/A - - )} - + + {formatVoteStake(row.original.voteStake)} + ), }, { @@ -112,6 +105,29 @@ export const DesktopVoteTable: React.FC = ({ } }, }, + { + Header: "Transaction", + accessor: "transactionHash", + id: "transaction", + Cell: ({ row }) => ( + + {row.original.transactionHash ? ( + + ) : ( + + N/A + + )} + + ), + }, { Header: "", id: "history", @@ -132,6 +148,7 @@ export const DesktopVoteTable: React.FC = ({ onSelect({ address: row.original.voter, ensName: row.original.ensName, + voteStake: row.original.voteStake, }); }} css={{ @@ -164,7 +181,7 @@ export const DesktopVoteTable: React.FC = ({ disableSortBy: true, }, ], - [onSelect] + [formatVoteStake, onSelect] ); return ( @@ -176,7 +193,7 @@ export const DesktopVoteTable: React.FC = ({ pageSize, sortBy: [ { - id: "timestamp", + id: "stakeUsed", desc: true, }, ], diff --git a/components/PollVote/MobileVoteCards.tsx b/components/PollVote/MobileVoteCards.tsx index 9b26a5c6..0f27175c 100644 --- a/components/PollVote/MobileVoteCards.tsx +++ b/components/PollVote/MobileVoteCards.tsx @@ -11,6 +11,7 @@ export const MobileVoteCards: React.FC = (props) => { onSelect, totalPages = 0, currentPage = 1, + formatVoteStake, onPageChange, } = props; @@ -34,6 +35,7 @@ export const MobileVoteCards: React.FC = (props) => { ); diff --git a/components/PollVote/MobileVoteView.tsx b/components/PollVote/MobileVoteView.tsx index 8e0132f0..41673531 100644 --- a/components/PollVote/MobileVoteView.tsx +++ b/components/PollVote/MobileVoteView.tsx @@ -19,10 +19,19 @@ import { PollVoteType } from "."; interface MobileVoteViewProps { vote: PollVoteType; - onSelect: (voter: { address: string; ensName?: string }) => void; + formatVoteStake: (stake: string) => string; + onSelect: (voter: { + address: string; + voteStake: string; + ensName?: string; + }) => void; } -export function MobileVoteView({ vote, onSelect }: MobileVoteViewProps) { +export function MobileVoteView({ + vote, + formatVoteStake, + onSelect, +}: MobileVoteViewProps) { const support = VOTING_SUPPORT_MAP[vote.choiceID]; const voterId = vote.ensName ? vote.ensName : formatAddress(vote.voter); @@ -108,7 +117,11 @@ export function MobileVoteView({ vote, onSelect }: MobileVoteViewProps) { }, }} onClick={() => - onSelect({ address: vote.voter, ensName: vote.ensName }) + onSelect({ + address: vote.voter, + voteStake: vote.voteStake, + ensName: vote.ensName, + }) } > @@ -121,6 +134,10 @@ export function MobileVoteView({ vote, onSelect }: MobileVoteViewProps) { + + {formatVoteStake(vote.voteStake)} + + {/* Footer: Transaction + Timestamp */} {vote.transactionHash ? ( diff --git a/components/PollVote/PollVoteDetail.tsx b/components/PollVote/PollVoteDetail.tsx index 97aa8063..278e6347 100644 --- a/components/PollVote/PollVoteDetail.tsx +++ b/components/PollVote/PollVoteDetail.tsx @@ -32,7 +32,7 @@ const Index: React.FC = ({ vote }) => { } getTitle(); - }); + }, [vote.poll.proposal]); return ( void; + formatVoteStake: (weight: string) => string; } const Index: React.FC = ({ voter, ensName, onClose }) => { - const { data: votesData, loading: isLoading } = useVoteEventsQuery({ + const { data: votesEventsData, loading: isLoading } = useVoteEventsQuery({ variables: { where: { voter: voter, @@ -24,23 +25,23 @@ const Index: React.FC = ({ voter, ensName, onClose }) => { }, }); - const votes = React.useMemo(() => { - return votesData?.voteEvents - ? [...votesData.voteEvents].sort( + const voteEvents = React.useMemo(() => { + return votesEventsData?.voteEvents + ? [...votesEventsData.voteEvents].sort( (a, b) => b.transaction.timestamp - a.transaction.timestamp ) : []; - }, [votesData]); + }, [votesEventsData]); const stats = React.useMemo(() => { - if (!votes.length) return null; + if (!voteEvents.length) return null; return { - total: votes.length, - for: votes.filter((v) => v.choiceID === "0").length, - against: votes.filter((v) => v.choiceID === "1").length, + total: voteEvents.length, + for: voteEvents.filter((v) => v.choiceID === "0").length, + against: voteEvents.filter((v) => v.choiceID === "1").length, }; - }, [votes]); + }, [voteEvents]); const summaryHeader = React.useMemo(() => { return ( @@ -175,7 +176,7 @@ const Index: React.FC = ({ voter, ensName, onClose }) => { > - ) : votes.length > 0 ? ( + ) : voteEvents.length > 0 ? ( = ({ voter, ensName, onClose }) => { }, }} > - {votes.map((vote, idx) => ( + {voteEvents.map((vote, idx) => ( = ({ pollId }) => { const [selectedVoter, setSelectedVoter] = useState<{ address: string; + voteStake: string; ensName?: string; } | null>(null); const [currentPage, setCurrentPage] = useState(1); @@ -130,6 +132,21 @@ const Index: React.FC = ({ pollId }) => { const { votes, loading } = useVotes(pollId); + const totalVoteStake = useMemo( + () => votes.reduce((sum, v) => sum + parseFloat(v.voteStake), 0), + [votes] + ); + + const formatVoteStake = useMemo( + () => (stake: string) => + `${formatLPT(parseFloat(stake), { abbreviate: false })} (${ + totalVoteStake > 0 + ? formatPercent(parseFloat(stake) / totalVoteStake) + : formatPercent(0) + })`, + [totalVoteStake] + ); + const paginatedVotesForMobile = useMemo(() => { const sorted = [...votes].sort((a, b) => b.timestamp - a.timestamp); const startIndex = (currentPage - 1) * pageSize; @@ -166,6 +183,7 @@ const Index: React.FC = ({ pollId }) => { votes={votes} onSelect={setSelectedVoter} pageSize={pageSize} + formatVoteStake={formatVoteStake} /> ) : ( = ({ pollId }) => { currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} + formatVoteStake={formatVoteStake} /> )} {selectedVoter && ( setSelectedVoter(null)} /> )} From 488711c59e9cc9562282c885a7dcc1af11a995ed Mon Sep 17 00:00:00 2001 From: Ibrahim Suleiman Date: Wed, 10 Jun 2026 11:33:08 +0100 Subject: [PATCH 4/6] refactor: update DesktopVoteTable to improve vote weight display - Renamed the "Stake Used" header to "Weight" for clarity. - Updated the accessor ID from "stakeUsed" to "weight" --- components/PollVote/DesktopVoteTable.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/components/PollVote/DesktopVoteTable.tsx b/components/PollVote/DesktopVoteTable.tsx index 36ea0688..d17802d8 100644 --- a/components/PollVote/DesktopVoteTable.tsx +++ b/components/PollVote/DesktopVoteTable.tsx @@ -74,13 +74,22 @@ export const DesktopVoteTable: React.FC = ({ }, }, { - Header: "Stake Used", + Header: "Weight", accessor: "voteStake", - id: "stakeUsed", + id: "weight", Cell: ({ row }) => ( - - {formatVoteStake(row.original.voteStake)} - + + + {formatVoteStake(row.original.voteStake)} + + ), }, { @@ -193,7 +202,7 @@ export const DesktopVoteTable: React.FC = ({ pageSize, sortBy: [ { - id: "stakeUsed", + id: "weight", desc: true, }, ], From 5f3a53279d419b3a05f66d03d1d8481b03d79d8a Mon Sep 17 00:00:00 2001 From: Ibrahim Suleiman Date: Wed, 10 Jun 2026 11:48:28 +0100 Subject: [PATCH 5/6] refactor: rename formatVoteStake to formatWeight for consistency across PollVote components --- components/PollVote/DesktopVoteTable.tsx | 8 ++++---- components/PollVote/MobileVoteCards.tsx | 4 ++-- components/PollVote/MobileVoteView.tsx | 6 +++--- components/PollVote/PollVotePopover.tsx | 2 +- components/PollVote/index.tsx | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/components/PollVote/DesktopVoteTable.tsx b/components/PollVote/DesktopVoteTable.tsx index d17802d8..cca78e94 100644 --- a/components/PollVote/DesktopVoteTable.tsx +++ b/components/PollVote/DesktopVoteTable.tsx @@ -22,14 +22,14 @@ export interface PollVoteTableProps { totalPages?: number; currentPage?: number; onPageChange?: (page: number) => void; - formatVoteStake: (stake: string) => string; + formatWeight: (stake: string) => string; } export const DesktopVoteTable: React.FC = ({ votes, onSelect, pageSize = 10, - formatVoteStake, + formatWeight, }) => { const columns = useMemo[]>( () => [ @@ -87,7 +87,7 @@ export const DesktopVoteTable: React.FC = ({ }} size="2" > - {formatVoteStake(row.original.voteStake)} + {formatWeight(row.original.voteStake)} ), @@ -190,7 +190,7 @@ export const DesktopVoteTable: React.FC = ({ disableSortBy: true, }, ], - [formatVoteStake, onSelect] + [formatWeight, onSelect] ); return ( diff --git a/components/PollVote/MobileVoteCards.tsx b/components/PollVote/MobileVoteCards.tsx index 0f27175c..d81ebb19 100644 --- a/components/PollVote/MobileVoteCards.tsx +++ b/components/PollVote/MobileVoteCards.tsx @@ -11,7 +11,7 @@ export const MobileVoteCards: React.FC = (props) => { onSelect, totalPages = 0, currentPage = 1, - formatVoteStake, + formatWeight, onPageChange, } = props; @@ -35,7 +35,7 @@ export const MobileVoteCards: React.FC = (props) => { ); diff --git a/components/PollVote/MobileVoteView.tsx b/components/PollVote/MobileVoteView.tsx index 41673531..e556ec87 100644 --- a/components/PollVote/MobileVoteView.tsx +++ b/components/PollVote/MobileVoteView.tsx @@ -19,7 +19,7 @@ import { PollVoteType } from "."; interface MobileVoteViewProps { vote: PollVoteType; - formatVoteStake: (stake: string) => string; + formatWeight: (stake: string) => string; onSelect: (voter: { address: string; voteStake: string; @@ -29,7 +29,7 @@ interface MobileVoteViewProps { export function MobileVoteView({ vote, - formatVoteStake, + formatWeight, onSelect, }: MobileVoteViewProps) { const support = VOTING_SUPPORT_MAP[vote.choiceID]; @@ -135,7 +135,7 @@ export function MobileVoteView({ - {formatVoteStake(vote.voteStake)} + {formatWeight(vote.voteStake)} {/* Footer: Transaction + Timestamp */} diff --git a/components/PollVote/PollVotePopover.tsx b/components/PollVote/PollVotePopover.tsx index b60465c6..0df9d7e9 100644 --- a/components/PollVote/PollVotePopover.tsx +++ b/components/PollVote/PollVotePopover.tsx @@ -13,7 +13,7 @@ interface PollVotePopoverProps { voter: string; ensName?: string; onClose: () => void; - formatVoteStake: (weight: string) => string; + formatWeight: (weight: string) => string; } const Index: React.FC = ({ voter, ensName, onClose }) => { diff --git a/components/PollVote/index.tsx b/components/PollVote/index.tsx index 687294a7..f472de21 100644 --- a/components/PollVote/index.tsx +++ b/components/PollVote/index.tsx @@ -137,7 +137,7 @@ const Index: React.FC = ({ pollId }) => { [votes] ); - const formatVoteStake = useMemo( + const formatWeight = useMemo( () => (stake: string) => `${formatLPT(parseFloat(stake), { abbreviate: false })} (${ totalVoteStake > 0 @@ -183,7 +183,7 @@ const Index: React.FC = ({ pollId }) => { votes={votes} onSelect={setSelectedVoter} pageSize={pageSize} - formatVoteStake={formatVoteStake} + formatWeight={formatWeight} /> ) : ( = ({ pollId }) => { currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} - formatVoteStake={formatVoteStake} + formatWeight={formatWeight} /> )} {selectedVoter && ( setSelectedVoter(null)} /> )} From b0ad5722f0392526b246c0047b2f45f615700396 Mon Sep 17 00:00:00 2001 From: Ibrahim Suleiman Date: Wed, 10 Jun 2026 16:06:21 +0100 Subject: [PATCH 6/6] feat: enhance PollVote components with additional vote data and formatting - Updated `PollVoteDetail` to fetch and display total vote stakes - Modified GraphQL queries to include poll details and associated votes - Adjusted the rendering of vote stakes in the UI --- apollo/subgraph.ts | 21 ++++++++------- components/PollVote/PollVoteDetail.tsx | 35 +++++++++++++++++++++++-- components/PollVote/PollVotePopover.tsx | 1 - components/PollVote/index.tsx | 1 - queries/vote.graphql | 7 +++++ 5 files changed, 52 insertions(+), 13 deletions(-) diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts index 311a52bc..e948cd27 100644 --- a/apollo/subgraph.ts +++ b/apollo/subgraph.ts @@ -26,14 +26,6 @@ export type Scalars = { Timestamp: any; }; -/** Indicates whether the current, partially filled bucket should be included in the response. Defaults to `exclude` */ -export enum Aggregation_Current { - /** Exclude the current, partially filled bucket from the response */ - Exclude = 'exclude', - /** Include the current, partially filled bucket in the response */ - Include = 'include' -} - export enum Aggregation_Interval { Day = 'day', Hour = 'hour' @@ -7578,8 +7570,10 @@ export type TreasuryProposal_Filter = { and?: InputMaybe>>; calldatas?: InputMaybe>; calldatas_contains?: InputMaybe>; + calldatas_contains_nocase?: InputMaybe>; calldatas_not?: InputMaybe>; calldatas_not_contains?: InputMaybe>; + calldatas_not_contains_nocase?: InputMaybe>; description?: InputMaybe; description_contains?: InputMaybe; description_contains_nocase?: InputMaybe; @@ -7654,8 +7648,10 @@ export type TreasuryProposal_Filter = { totalVotes_not_in?: InputMaybe>; values?: InputMaybe>; values_contains?: InputMaybe>; + values_contains_nocase?: InputMaybe>; values_not?: InputMaybe>; values_not_contains?: InputMaybe>; + values_not_contains_nocase?: InputMaybe>; voteEnd?: InputMaybe; voteEnd_gt?: InputMaybe; voteEnd_gte?: InputMaybe; @@ -9765,7 +9761,7 @@ export type VoteQueryVariables = Exact<{ }>; -export type VoteQuery = { __typename: 'Query', vote?: { __typename: 'Vote', choiceID?: PollChoice | null, voteStake: string, nonVoteStake: string } | null }; +export type VoteQuery = { __typename: 'Query', vote?: { __typename: 'Vote', choiceID?: PollChoice | null, voteStake: string, nonVoteStake: string, poll?: { __typename: 'Poll', id: string, votes: Array<{ __typename: 'Vote', voteStake: string, id: string }> } | null } | null }; export type VoteEventsQueryVariables = Exact<{ first?: InputMaybe; @@ -11123,6 +11119,13 @@ export const VoteDocument = gql` choiceID voteStake nonVoteStake + poll { + id + votes { + voteStake + id + } + } } } `; diff --git a/components/PollVote/PollVoteDetail.tsx b/components/PollVote/PollVoteDetail.tsx index 278e6347..ad3b58af 100644 --- a/components/PollVote/PollVoteDetail.tsx +++ b/components/PollVote/PollVoteDetail.tsx @@ -11,8 +11,9 @@ import { Link, Text, } from "@livepeer/design-system"; -import { VoteEvent } from "apollo"; -import React, { useEffect, useState } from "react"; +import { formatLPT, formatPercent } from "@utils/numberFormatters"; +import { useVoteQuery, VoteEvent } from "apollo"; +import React, { useEffect, useMemo, useState } from "react"; interface PollVoteDetailProps { vote: VoteEvent; @@ -21,6 +22,26 @@ interface PollVoteDetailProps { const Index: React.FC = ({ vote }) => { const [title, setTitle] = useState(""); const support = POLL_VOTES[vote.choiceID]; + const voteId = `${vote.voter}-${vote.poll.id}`; + + const { data: voteData } = useVoteQuery({ variables: { id: voteId } }); + + const getStake = useMemo( + () => (stake: string) => { + const totalVoteStake = + voteData?.vote?.poll?.votes.reduce( + (sum, v) => sum + parseFloat(v.voteStake), + 0 + ) ?? 0; + + return `${formatLPT(parseFloat(stake), { abbreviate: false })} (${ + totalVoteStake > 0 + ? formatPercent(parseFloat(stake) / totalVoteStake) + : formatPercent(0) + })`; + }, + [voteData?.vote?.poll?.votes] + ); useEffect(() => { async function getTitle() { @@ -102,6 +123,11 @@ const Index: React.FC = ({ vote }) => { + {/* Weight */} + + {getStake(voteData?.vote?.voteStake ?? "0")} + + {/* Footer: Transaction + Timestamp */} {vote.transaction.id ? ( @@ -199,6 +225,11 @@ const Index: React.FC = ({ vote }) => { + {/* Weight */} + + {getStake(voteData?.vote?.voteStake ?? "0")} + + {/* Footer: Transaction + Timestamp */} {vote.transaction.id ? ( diff --git a/components/PollVote/PollVotePopover.tsx b/components/PollVote/PollVotePopover.tsx index 0df9d7e9..a78d81c2 100644 --- a/components/PollVote/PollVotePopover.tsx +++ b/components/PollVote/PollVotePopover.tsx @@ -13,7 +13,6 @@ interface PollVotePopoverProps { voter: string; ensName?: string; onClose: () => void; - formatWeight: (weight: string) => string; } const Index: React.FC = ({ voter, ensName, onClose }) => { diff --git a/components/PollVote/index.tsx b/components/PollVote/index.tsx index f472de21..a2820d31 100644 --- a/components/PollVote/index.tsx +++ b/components/PollVote/index.tsx @@ -199,7 +199,6 @@ const Index: React.FC = ({ pollId }) => { setSelectedVoter(null)} /> )} diff --git a/queries/vote.graphql b/queries/vote.graphql index 75eb8fa1..e0a448c0 100644 --- a/queries/vote.graphql +++ b/queries/vote.graphql @@ -3,5 +3,12 @@ query vote($id: ID!) { choiceID voteStake nonVoteStake + poll { + id + votes { + voteStake + id + } + } } }