Skip to content

Commit 1b8a6d4

Browse files
authored
feat: Weighted voting UI (#10858)
<!-- Before opening a pull request, please read the [contributing guidelines](https://github.com/pancakeswap/pancake-frontend/blob/develop/CONTRIBUTING.md) first --> <!-- start pr-codex --> --- ## PR-Codex overview This PR focuses on enhancing the voting system by introducing new vote types, updating interfaces, and improving the UI for casting and displaying votes, particularly for single-choice and weighted voting proposals. ### Detailed summary - Added `SingleVoteState` and `WeightedVoteState` interfaces. - Introduced `ProposalTypeName` enum for vote types. - Updated `Proposal` interface to include `type`, `scores`, and `scores_total`. - Modified `CastVoteModal` to handle different vote types. - Created `SingleVote` and `WeightedVote` components for respective voting methods. - Improved `VoteRow` to display vote percentages for weighted votes. - Enhanced UI components for better styling and responsiveness. - Updated localization strings for new voting-related texts. - Refactored voting logic to accommodate new features and improve clarity. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 680d3f1 commit 1b8a6d4

File tree

22 files changed

+789
-196
lines changed

22 files changed

+789
-196
lines changed
Loading

apps/web/src/state/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,11 @@ export enum ProposalState {
391391
CLOSED = 'closed',
392392
}
393393

394+
export enum ProposalTypeName {
395+
SINGLE_CHOICE = 'single-choice',
396+
WEIGHTED = 'weighted',
397+
}
398+
394399
export interface Proposal {
395400
author: string
396401
body: string
@@ -403,6 +408,9 @@ export interface Proposal {
403408
state: ProposalState
404409
title: string
405410
ipfs: string
411+
type: ProposalTypeName
412+
scores: number[]
413+
scores_total: number
406414
}
407415

408416
export interface Vote {

apps/web/src/state/voting/helpers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const getProposal = async (id: string): Promise<Proposal> => {
5050
author
5151
votes
5252
ipfs
53+
type
54+
scores
55+
scores_total
5356
}
5457
}
5558
`,

apps/web/src/views/Voting/CreateProposal/index.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useTranslation } from '@pancakeswap/localization'
12
import {
23
AutoRenewIcon,
34
Box,
@@ -15,19 +16,18 @@ import {
1516
useModal,
1617
useToast,
1718
} from '@pancakeswap/uikit'
18-
import snapshot from '@snapshot-labs/snapshot.js'
19-
import isEmpty from 'lodash/isEmpty'
20-
import times from 'lodash/times'
21-
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from 'react'
22-
import { useInitialBlock } from 'state/block/hooks'
23-
24-
import { useTranslation } from '@pancakeswap/localization'
2519
import truncateHash from '@pancakeswap/utils/truncateHash'
20+
import snapshot from '@snapshot-labs/snapshot.js'
2621
import ConnectWalletButton from 'components/ConnectWalletButton'
2722
import Container from 'components/Layout/Container'
23+
import isEmpty from 'lodash/isEmpty'
24+
import times from 'lodash/times'
2825
import dynamic from 'next/dynamic'
2926
import Link from 'next/link'
3027
import { useRouter } from 'next/router'
28+
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from 'react'
29+
import { useInitialBlock } from 'state/block/hooks'
30+
import { ProposalTypeName } from 'state/types'
3131
import { getBlockExploreLink } from 'utils'
3232
import { DatePicker, DatePickerPortal, TimePicker } from 'views/Voting/components/DatePicker'
3333
import { useAccount, useWalletClient } from 'wagmi'
@@ -96,7 +96,7 @@ const CreateProposal = () => {
9696

9797
const data: any = await client.proposal(web3 as any, account, {
9898
space: PANCAKE_SPACE,
99-
type: 'single-choice',
99+
type: ProposalTypeName.SINGLE_CHOICE, // TODO
100100
title: name,
101101
body,
102102
start: combineDateAndTime(startDate, startTime) || 0,

apps/web/src/views/Voting/Proposal/Details.tsx

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,72 @@
1+
import { useTranslation } from '@pancakeswap/localization'
12
import { Box, Card, CardBody, CardHeader, Flex, Heading, LinkExternal, ScanLink, Text } from '@pancakeswap/uikit'
2-
import { styled } from 'styled-components'
3+
import truncateHash from '@pancakeswap/utils/truncateHash'
34
import dayjs from 'dayjs'
4-
import { Proposal } from 'state/types'
5+
import { Proposal, ProposalTypeName } from 'state/types'
56
import { getBlockExploreLink } from 'utils'
6-
import { useTranslation } from '@pancakeswap/localization'
7-
import truncateHash from '@pancakeswap/utils/truncateHash'
87
import { IPFS_GATEWAY } from '../config'
9-
import { ProposalStateTag } from '../components/Proposals/tags'
108

119
interface DetailsProps {
1210
proposal: Proposal
1311
}
1412

15-
const DetailBox = styled(Box)`
16-
background-color: ${({ theme }) => theme.colors.background};
17-
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
18-
border-radius: 16px;
19-
`
20-
2113
const Details: React.FC<React.PropsWithChildren<DetailsProps>> = ({ proposal }) => {
2214
const { t } = useTranslation()
2315
const startDate = new Date(proposal.start * 1000)
2416
const endDate = new Date(proposal.end * 1000)
2517

2618
return (
2719
<Card mb="16px">
28-
<CardHeader>
20+
<CardHeader style={{ background: 'transparent' }}>
2921
<Heading as="h3" scale="md">
3022
{t('Details')}
3123
</Heading>
3224
</CardHeader>
3325
<CardBody>
3426
<Flex alignItems="center" mb="8px">
35-
<Text color="textSubtle">{t('Identifier')}</Text>
36-
<LinkExternal href={`${IPFS_GATEWAY}/${proposal.ipfs}`} ml="8px">
37-
{proposal.ipfs.slice(0, 8)}
38-
</LinkExternal>
39-
</Flex>
40-
<Flex alignItems="center" mb="8px">
41-
<Text color="textSubtle">{t('Creator')}</Text>
27+
<Text color="textSubtle" mr="auto">
28+
{t('Creator')}
29+
</Text>
4230
<ScanLink useBscCoinFallback href={getBlockExploreLink(proposal.author, 'address')} ml="8px">
4331
{truncateHash(proposal.author)}
4432
</ScanLink>
4533
</Flex>
46-
<Flex alignItems="center" mb="16px">
47-
<Text color="textSubtle">{t('Snapshot')}</Text>
34+
<Flex mb="24px">
35+
<Text color="textSubtle" mr="auto">
36+
{t('Voting system')}
37+
</Text>
38+
<Text ml="8px">{proposal.type === ProposalTypeName.SINGLE_CHOICE ? t('Binary') : t('Weighted')}</Text>
39+
</Flex>
40+
<Flex alignItems="center" mb="8px">
41+
<Text color="textSubtle" mr="auto">
42+
{t('Identifier')}
43+
</Text>
44+
<LinkExternal href={`${IPFS_GATEWAY}/${proposal.ipfs}`} ml="8px">
45+
{proposal.ipfs.slice(0, 8)}
46+
</LinkExternal>
47+
</Flex>
48+
<Flex alignItems="center" mb="24px">
49+
<Text color="textSubtle" mr="auto">
50+
{t('Snapshot')}
51+
</Text>
4852
<ScanLink useBscCoinFallback href={getBlockExploreLink(proposal.snapshot, 'block')} ml="8px">
4953
{proposal.snapshot}
5054
</ScanLink>
5155
</Flex>
52-
<DetailBox p="16px">
53-
<ProposalStateTag proposalState={proposal.state} mb="8px" />
54-
<Flex alignItems="center">
55-
<Text color="textSubtle" fontSize="14px">
56+
<Box>
57+
<Flex>
58+
<Text color="textSubtle" mr="auto">
5659
{t('Start Date')}
5760
</Text>
5861
<Text ml="8px">{dayjs(startDate).format('YYYY-MM-DD HH:mm')}</Text>
5962
</Flex>
60-
<Flex alignItems="center">
61-
<Text color="textSubtle" fontSize="14px">
63+
<Flex>
64+
<Text color="textSubtle" mr="auto">
6265
{t('End Date')}
6366
</Text>
6467
<Text ml="8px">{dayjs(endDate).format('YYYY-MM-DD HH:mm')}</Text>
6568
</Flex>
66-
</DetailBox>
69+
</Box>
6770
</CardBody>
6871
</Card>
6972
)

apps/web/src/views/Voting/Proposal/Overview.tsx

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { useTranslation } from '@pancakeswap/localization'
2-
import { ArrowBackIcon, Box, Button, Flex, Heading, NotFound, ReactMarkdown } from '@pancakeswap/uikit'
2+
import {
3+
ArrowBackIcon,
4+
Box,
5+
Button,
6+
Flex,
7+
Heading,
8+
NotFound,
9+
ReactMarkdown,
10+
useMatchBreakpoints,
11+
} from '@pancakeswap/uikit'
312
import { useQuery } from '@tanstack/react-query'
413
import Container from 'components/Layout/Container'
514
import PageLoader from 'components/Loader/PageLoader'
@@ -23,6 +32,7 @@ const Overview = () => {
2332
const id = query.id as string
2433
const { t } = useTranslation()
2534
const { address: account } = useAccount()
35+
const { isDesktop } = useMatchBreakpoints()
2636

2737
const {
2838
status: proposalLoadingStatus,
@@ -56,8 +66,12 @@ const Overview = () => {
5666
})
5767

5868
const votes = useMemo(() => data || [], [data])
59-
60-
const hasAccountVoted = account && votes && votes.some((vote) => vote.voter.toLowerCase() === account.toLowerCase())
69+
const hasAccountVoted =
70+
account &&
71+
votes &&
72+
proposal &&
73+
proposal.state === ProposalState.ACTIVE &&
74+
votes.some((vote) => vote.voter.toLowerCase() === account.toLowerCase())
6175

6276
const isPageLoading = votesLoadingStatus === 'pending' || proposalLoadingStatus === 'pending'
6377

@@ -96,19 +110,44 @@ const Overview = () => {
96110
<ReactMarkdown>{proposal.body}</ReactMarkdown>
97111
</Box>
98112
</Box>
99-
{!isPageLoading && !hasAccountVoted && proposal.state === ProposalState.ACTIVE && (
100-
<Vote proposal={proposal} onSuccess={refetch} mb="16px" />
113+
{!isPageLoading && (
114+
<Vote
115+
mb="16px"
116+
proposal={proposal}
117+
votes={votes}
118+
hasAccountVoted={Boolean(hasAccountVoted)}
119+
onSuccess={refetch}
120+
/>
121+
)}
122+
{!isDesktop && (
123+
<Box mb="16px">
124+
<Details proposal={proposal} />
125+
<Results
126+
proposal={proposal}
127+
choices={proposal.choices}
128+
votes={votes || []}
129+
votesLoadingStatus={votesLoadingStatus}
130+
/>
131+
</Box>
101132
)}
102133
<Votes
103134
votes={votes || []}
135+
proposal={proposal}
104136
totalVotes={votes?.length ?? proposal.votes}
105137
votesLoadingStatus={votesLoadingStatus}
106138
/>
107139
</Box>
108-
<Box position="sticky" top="60px">
109-
<Details proposal={proposal} />
110-
<Results choices={proposal.choices} votes={votes || []} votesLoadingStatus={votesLoadingStatus} />
111-
</Box>
140+
{isDesktop && (
141+
<Box position="sticky" top="60px">
142+
<Details proposal={proposal} />
143+
<Results
144+
proposal={proposal}
145+
choices={proposal.choices}
146+
votes={votes || []}
147+
votesLoadingStatus={votesLoadingStatus}
148+
/>
149+
</Box>
150+
)}
112151
</Layout>
113152
</Container>
114153
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useTranslation } from '@pancakeswap/localization'
2+
import { Box, Flex, Progress, Text } from '@pancakeswap/uikit'
3+
import { formatNumber } from '@pancakeswap/utils/formatBalance'
4+
import { Vote } from 'state/types'
5+
import TextEllipsis from '../../components/TextEllipsis'
6+
import { calculateVoteResults, getTotalFromVotes } from '../../helpers'
7+
8+
interface SingleVoteResultsProps {
9+
choices: string[]
10+
votes: Vote[]
11+
}
12+
13+
export const SingleVoteResults: React.FC<SingleVoteResultsProps> = ({ votes, choices }) => {
14+
const { t } = useTranslation()
15+
const results = calculateVoteResults(votes)
16+
const totalVotes = getTotalFromVotes(votes)
17+
18+
return (
19+
<>
20+
{choices.map((choice, index) => {
21+
const choiceVotes = results[choice] || []
22+
const totalChoiceVote = getTotalFromVotes(choiceVotes)
23+
const progress = totalVotes === 0 ? 0 : (totalChoiceVote / totalVotes) * 100
24+
25+
return (
26+
<Box key={choice} mt={index > 0 ? '24px' : '0px'}>
27+
<Flex alignItems="center" mb="8px">
28+
<TextEllipsis mb="4px" title={choice}>
29+
{choice}
30+
</TextEllipsis>
31+
</Flex>
32+
<Box mb="4px">
33+
<Progress primaryStep={progress} scale="sm" />
34+
</Box>
35+
<Flex alignItems="center" justifyContent="space-between">
36+
<Text color="textSubtle">{t('%total% Votes', { total: formatNumber(totalChoiceVote, 0, 2) })}</Text>
37+
<Text>{progress.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Text>
38+
</Flex>
39+
</Box>
40+
)
41+
})}
42+
</>
43+
)
44+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useTranslation } from '@pancakeswap/localization'
2+
import { Box, Flex, Progress, Text } from '@pancakeswap/uikit'
3+
import { formatNumber } from '@pancakeswap/utils/formatBalance'
4+
import { useMemo } from 'react'
5+
import { WeightedVoteState } from 'views/Voting/Proposal/VoteType/types'
6+
import TextEllipsis from '../../components/TextEllipsis'
7+
8+
interface WeightedVoteResultsProps {
9+
choices: string[]
10+
sortData?: boolean
11+
choicesVotes: WeightedVoteState[]
12+
}
13+
14+
export const WeightedVoteResults: React.FC<WeightedVoteResultsProps> = ({ choices, sortData, choicesVotes }) => {
15+
const { t } = useTranslation()
16+
17+
const totalSum = useMemo(
18+
() => choicesVotes.reduce((sum, item) => sum + Object.values(item).reduce((a, b) => a + b, 0), 0),
19+
[choicesVotes],
20+
)
21+
22+
const percentageResults = useMemo(
23+
() =>
24+
choicesVotes.reduce((acc, item) => {
25+
Object.entries(item).forEach(([key, value]) => {
26+
// eslint-disable-next-line no-param-reassign
27+
acc[key] = (acc[key] || 0) + value
28+
})
29+
return acc
30+
}, {}),
31+
[choicesVotes],
32+
)
33+
34+
const sortedChoices = useMemo(() => {
35+
const list = choices.map((choice, index) => {
36+
const totalChoiceVote = percentageResults[index + 1] ?? 0
37+
const progress = (totalChoiceVote / totalSum) * 100
38+
return { choice, totalChoiceVote, progress }
39+
})
40+
41+
return sortData ? list.sort((a, b) => b.progress - a.progress) : list
42+
}, [choices, percentageResults, sortData, totalSum])
43+
44+
return (
45+
<>
46+
{sortedChoices.map(({ choice, totalChoiceVote, progress }, index) => (
47+
<Box key={choice} mt={index > 0 ? '24px' : '0px'}>
48+
<Flex alignItems="center" mb="8px">
49+
<TextEllipsis mb="4px" title={choice}>
50+
{choice}
51+
</TextEllipsis>
52+
</Flex>
53+
<Box mb="4px">
54+
<Progress primaryStep={progress} scale="sm" />
55+
</Box>
56+
<Flex alignItems="center" justifyContent="space-between">
57+
<Text color="textSubtle">{t('%total% Votes', { total: formatNumber(totalChoiceVote, 0, 2) })}</Text>
58+
<Text>
59+
{totalChoiceVote === 0 && totalSum === 0
60+
? '0.00%'
61+
: `${progress.toLocaleString(undefined, {
62+
minimumFractionDigits: 2,
63+
maximumFractionDigits: 2,
64+
})}
65+
%`}
66+
</Text>
67+
</Flex>
68+
</Box>
69+
))}
70+
</>
71+
)
72+
}

0 commit comments

Comments
 (0)