From 9f35b7d7383efecc5a124af9d6d1e7a017b7b880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Graci=C3=A1=20Carpio?= Date: Tue, 17 Oct 2023 02:34:20 +0200 Subject: [PATCH] Adds teia polls page (#335) --- src/atoms/input/TransferFields.jsx | 8 +- src/components/header/main_menu/MainMenu.jsx | 2 + src/constants.ts | 2 + src/context/pollsStore.ts | 111 ++++++++ src/data/swr.js | 49 ++++ src/index.jsx | 7 + src/pages/dao/index.module.scss | 1 - src/pages/dao/tabs/Proposals.jsx | 10 +- src/pages/dao/tabs/Submit.jsx | 3 +- src/pages/dao/tabs/index.module.scss | 10 +- src/pages/polls/index.jsx | 29 +++ src/pages/polls/index.module.scss | 8 + src/pages/polls/tabs/Create.jsx | 178 +++++++++++++ src/pages/polls/tabs/Polls.jsx | 255 +++++++++++++++++++ src/pages/polls/tabs/index.js | 2 + src/pages/polls/tabs/index.module.scss | 88 +++++++ src/styles/main.scss | 2 +- 17 files changed, 744 insertions(+), 21 deletions(-) create mode 100644 src/context/pollsStore.ts create mode 100644 src/pages/polls/index.jsx create mode 100644 src/pages/polls/index.module.scss create mode 100644 src/pages/polls/tabs/Create.jsx create mode 100644 src/pages/polls/tabs/Polls.jsx create mode 100644 src/pages/polls/tabs/index.js create mode 100644 src/pages/polls/tabs/index.module.scss diff --git a/src/atoms/input/TransferFields.jsx b/src/atoms/input/TransferFields.jsx index 34e54cf80..e990469e1 100644 --- a/src/atoms/input/TransferFields.jsx +++ b/src/atoms/input/TransferFields.jsx @@ -7,7 +7,7 @@ export default function TransferFields({ transfers, onChange, className, - round, + step, children, }) { const handleChange = (index, parameter, value) => { @@ -38,11 +38,9 @@ export default function TransferFields({ label={`${labels.amount} (${index + 1})`} placeholder={placeholders?.amount ?? '0'} min="0" - step={round ? 1 : 0.000001} + step={step} value={transfer.amount} - onChange={(value) => - handleChange(index, 'amount', round ? Math.round(value) : value) - } + onChange={(value) => handleChange(index, 'amount', value)} className={className} > {children} diff --git a/src/components/header/main_menu/MainMenu.jsx b/src/components/header/main_menu/MainMenu.jsx index 60b99dadf..fca046782 100644 --- a/src/components/header/main_menu/MainMenu.jsx +++ b/src/components/header/main_menu/MainMenu.jsx @@ -75,6 +75,8 @@ export const MainMenu = () => { label="DAO governance" route="dao" /> + +
{/* */} diff --git a/src/constants.ts b/src/constants.ts index ecb0e3314..3054b9060 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,6 +10,7 @@ export const PATH = { FAQ: '/faq', CLAIM: '/claim', DAO: '/dao', + POLLS: '/polls', SYNC: '/sync', MINT: '/mint', OBJKT: '/objkt', @@ -140,6 +141,7 @@ export const QUAKE_FUNDING_CONTRACT = 'KT1X1jyohFrZyDYWvCPXw9KvWxk2VDwxyg2g' export const MOROCCO_QUAKE_FUNDING_CONTRACT = 'KT1RwXEP8Sj1UQDHPG4oEjRohBdzG2R7FCpA' +export const POLLS_CONTRACT = 'KT1NPELoSdKjKzfSs85hCTPcxWjuyJjoM4C5' export const DAO_GOVERNANCE_CONTRACT = 'KT1VLLPBjSFFHMp9LxoRfA65cynkxeRDfQeX' export const DAO_TOKEN_CONTRACT = 'KT1QrtA753MSv8VGxkDrKKyJniG5JtuHHbtV' export const DAO_TOKEN_CLAIM_CONTRACT = 'KT1NrfV4e2qWqFrnrKyPTJth5wq2KP9VyBei' diff --git a/src/context/pollsStore.ts b/src/context/pollsStore.ts new file mode 100644 index 000000000..d94ae2c02 --- /dev/null +++ b/src/context/pollsStore.ts @@ -0,0 +1,111 @@ +import { create } from 'zustand' +import { + persist, + createJSONStorage, + subscribeWithSelector, +} from 'zustand/middleware' +import { MichelsonMap } from '@taquito/taquito' +import { POLLS_CONTRACT} from '@constants' +import { Tezos, useUserStore } from './userStore' +import { useModalStore } from './modalStore' +import { stringToHex } from '@utils/string' + +type OperationReturn = Promise + +interface PollsState { + /** Votes in an existing poll */ + votePoll: (pollId: string, option: number, maxCheckpoints: number | null, callback?: any) => OperationReturn + /** Creates a new poll */ + createPoll: (question: string, descriptionIpfsPath: string, voteWeightMethod: string, votePeriod: string, options: string[], callback?: any) => OperationReturn +} + +export const usePollsStore = create()( + subscribeWithSelector( + persist( + (set, get) => ({ + votePoll: async (pollId, option, maxCheckpoints, callback) => { + const handleOp = useUserStore.getState().handleOp + const showError = useModalStore.getState().showError + const step = useModalStore.getState().step + + const modalTitle = 'Vote teia poll' + step(modalTitle, 'Waiting for confirmation', true) + + try { + const contract = await Tezos.wallet.at(POLLS_CONTRACT) + + const parameters = { + poll_id: parseInt(pollId), + option: option, + max_checkpoints: maxCheckpoints + } + const batch = contract.methodsObject.vote(parameters) + const opHash = await handleOp(batch, modalTitle) + + callback?.() + + return opHash + } catch (e) { + showError(modalTitle, e) + } + }, + createPoll: async (question, descriptionIpfsPath, voteWeightMethod, votePeriod, options, callback) => { + const handleOp = useUserStore.getState().handleOp + const show = useModalStore.getState().show + const showError = useModalStore.getState().showError + const step = useModalStore.getState().step + + const modalTitle = 'Create teia poll' + + if (!question || question.length < 10) { + show( + modalTitle, + 'The poll question must be at least 10 characters long' + ) + return + } + + if (options.length < 2) { + show( + modalTitle, + 'The poll should have at least 2 options to vote' + ) + return + } + + step(modalTitle, 'Waiting for confirmation', true) + + try { + const contract = await Tezos.wallet.at(POLLS_CONTRACT) + + const parameters = { + question: stringToHex(question), + description: descriptionIpfsPath === '' + ? stringToHex('') + : stringToHex(`ipfs://${descriptionIpfsPath}`), + options: MichelsonMap.fromLiteral( + Object.fromEntries( + options.map((option, index) => [index, stringToHex(option)]) + ) + ), + vote_weight_method: { [voteWeightMethod]: [['unit']] }, + vote_period: votePeriod + } + const batch = contract.methodsObject.create_poll(parameters) + const opHash = await handleOp(batch, modalTitle) + + callback?.() + + return opHash + } catch (e) { + showError(modalTitle, e) + } + }, + }), + { + name: 'polls', + storage: createJSONStorage(() => localStorage), // or sessionStorage? + } + ) + ) +) diff --git a/src/data/swr.js b/src/data/swr.js index 35441e11c..b2c89dcd7 100644 --- a/src/data/swr.js +++ b/src/data/swr.js @@ -194,3 +194,52 @@ export function useDaoMemberCount(minTokens) { return [data ? parseInt(data) : 0, mutate] } + +export function usePolls(pollsStorage) { + const parameters = { + limit: 10000, + active: true, + select: 'key,value', + } + const { data, mutate } = useSWR( + pollsStorage?.polls + ? [`/v1/bigmaps/${pollsStorage.polls}/keys`, parameters] + : null, + getTzktData + ) + + return [reorderBigmapData(data), mutate] +} + +export function useUserPollVotes(address, pollsStorage) { + const parameters = { + 'key.address': address, + limit: 10000, + active: true, + select: 'key,value', + } + const { data, mutate } = useSWR( + address && pollsStorage?.votes + ? [`/v1/bigmaps/${pollsStorage.votes}/keys`, parameters] + : null, + getTzktData + ) + + return [reorderBigmapData(data, 'nat'), mutate] +} + +export function usePollsUsersAliases(userAddress, polls) { + const addresses = new Set() + + if (userAddress) { + addresses.add(userAddress) + } + + if (polls) { + Object.values(polls).forEach((poll) => { + addresses.add(poll.issuer) + }) + } + + return useAliases(Array.from(addresses)) +} diff --git a/src/index.jsx b/src/index.jsx index 0aef01eed..ed709dc5b 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -15,6 +15,8 @@ import { DaoProposals, SubmitDaoProposals, } from '@pages/dao/tabs' +import { TeiaPolls } from '@pages/polls' +import { Polls, CreatePolls } from '@pages/polls/tabs' import { FAQ } from '@pages/faq' import { Home } from '@pages/home' import FriendsFeed from '@pages/home/feeds/friends-feed' @@ -148,6 +150,11 @@ const router = createBrowserRouter( } /> } /> + }> + } /> + } /> + } /> + } /> }> {display_routes} diff --git a/src/pages/dao/index.module.scss b/src/pages/dao/index.module.scss index a7deae323..e8910cf2c 100644 --- a/src/pages/dao/index.module.scss +++ b/src/pages/dao/index.module.scss @@ -7,7 +7,6 @@ .headline { text-align: center; - margin-bottom: 1em 0; > p { margin: 1em 0; diff --git a/src/pages/dao/tabs/Proposals.jsx b/src/pages/dao/tabs/Proposals.jsx index 2f3e372cc..f75d5ed21 100644 --- a/src/pages/dao/tabs/Proposals.jsx +++ b/src/pages/dao/tabs/Proposals.jsx @@ -238,7 +238,7 @@ function ProposalGroup({ status, proposals }) { function ProposalList({ proposals, ...actions }) { if (proposals.length !== 0) { return ( -
    +
      {proposals.map((proposal, index) => (
    • @@ -278,12 +278,10 @@ function ProposalDescription({ proposal }) { return (
      -

      +

      #{proposal.id} - - {hexToString(proposal.title)} - -

      + {hexToString(proposal.title)} +

      Proposed by{' '} diff --git a/src/pages/dao/tabs/Submit.jsx b/src/pages/dao/tabs/Submit.jsx index f25a5130c..07c7ebcfe 100644 --- a/src/pages/dao/tabs/Submit.jsx +++ b/src/pages/dao/tabs/Submit.jsx @@ -263,6 +263,7 @@ function TransferTezProposalForm({ callback }) { }} transfers={transfers} onChange={setTransfers} + step="0.000001" className={styles.proposal_form_field} > @@ -349,8 +350,8 @@ function TransferTokenProposalForm({ callback }) { }} transfers={transfers} onChange={setTransfers} + step="1" className={styles.proposal_form_field} - round > diff --git a/src/pages/dao/tabs/index.module.scss b/src/pages/dao/tabs/index.module.scss index ef03f3021..7f54f5e91 100644 --- a/src/pages/dao/tabs/index.module.scss +++ b/src/pages/dao/tabs/index.module.scss @@ -27,12 +27,8 @@ } } -.proposal_list { - margin-top: 2em; -} - .proposal { - margin: 1em 0; + margin: 2em 0; p { margin: 0.5em 0; @@ -51,14 +47,14 @@ } .proposal_id { - margin-right: 0.5em; + margin-right: 1em; padding: 0 1em; text-align: center; background-color: var(--gray-15); } .proposal_title { - font-weight: bold; + margin-bottom: 0.5em; } .user_vote { diff --git a/src/pages/polls/index.jsx b/src/pages/polls/index.jsx new file mode 100644 index 000000000..5197464be --- /dev/null +++ b/src/pages/polls/index.jsx @@ -0,0 +1,29 @@ +import { Outlet } from 'react-router-dom' +import { Page } from '@atoms/layout' +import { Tabs } from '@atoms/tab' +import styles from '@style' + +const TABS = [ + { + title: 'Polls', + to: '', + }, + { + title: 'Create', + to: 'create', + }, +] + +export function TeiaPolls() { + return ( + +

      +

      Teia polls

      + + + + +
      + + ) +} diff --git a/src/pages/polls/index.module.scss b/src/pages/polls/index.module.scss new file mode 100644 index 000000000..88c687cba --- /dev/null +++ b/src/pages/polls/index.module.scss @@ -0,0 +1,8 @@ +.container { + padding-bottom: 60px; + width: 100%; +} + +.headline { + text-align: center; +} diff --git a/src/pages/polls/tabs/Create.jsx b/src/pages/polls/tabs/Create.jsx new file mode 100644 index 000000000..de3087c2d --- /dev/null +++ b/src/pages/polls/tabs/Create.jsx @@ -0,0 +1,178 @@ +import { useState } from 'react' +import { POLLS_CONTRACT } from '@constants' +import { useUserStore } from '@context/userStore' +import { usePollsStore } from '@context/pollsStore' +import { Button } from '@atoms/button' +import { Line } from '@atoms/line' +import { SimpleInput } from '@atoms/input' +import { Select } from '@atoms/select' +import { IpfsUploader } from '@components/upload' +import { useStorage, useDaoTokenBalance, usePolls } from '@data/swr' +import styles from '@style' + +export default function CreatePolls() { + // Get all the required user information + const userAddress = useUserStore((st) => st.address) + const [userTokenBalance] = useDaoTokenBalance(userAddress) + + return ( +
      +

      Create a new poll

      + + {userTokenBalance === 0 ? ( +

      Only DAO members can create new polls.

      + ) : ( + <> +

      Use this form to create new Teia polls.

      + + + )} +
      + ) +} + +function PollForm() { + // Set the component state + const [question, setQuestion] = useState('') + const [descriptionIpfsCid, setDescriptionIpfsCid] = useState('') + const [voteWeightMethod, setVoteWeightMethod] = useState('linear') + const [votePeriod, setVotePeriod] = useState('') + const [options, setOptions] = useState(['', '', '']) + + // Get all the required polls information + const [pollsStorage] = useStorage(POLLS_CONTRACT) + const [, updatePolls] = usePolls(pollsStorage) + + // Get the create poll method from the polls store + const createPoll = usePollsStore((st) => st.createPoll) + + // Define the differnt vote weight methods + const voteWeightMethods = { + linear: 'Linear: proportional to the amount of TEIA tokens', + quadratic: + 'Quadratic: proportional to the square root of the amount of TEIA tokens', + equal: 'Equal: one wallet, one vote', + } + + // Define the on change handler + const handleChange = (index, value) => { + const newOptions = options.slice() + newOptions[index] = value + setOptions(newOptions) + } + + // Define the on click handler + const handleClick = (e, increase) => { + e.preventDefault() + const newOptions = options.slice() + + if (increase) { + newOptions.push('') + } else if (newOptions.length > 1) { + newOptions.pop() + } + + setOptions(newOptions) + } + + // Define the on submit handler + const handleSubmit = (e) => { + e.preventDefault() + const cleanOptions = options + .map((option) => option.trim()) + .filter((option) => option !== '') + + createPoll( + question, + descriptionIpfsCid, + voteWeightMethod, + votePeriod, + cleanOptions, + updatePolls + ) + } + + return ( +
      +
      + + + + + + + + + + + + +
      + {options.map((option, index) => ( + handleChange(index, value)} + className={styles.poll_form_field} + > + + + ))} + + +
      +
      + + +
      + ) +} diff --git a/src/pages/polls/tabs/Polls.jsx b/src/pages/polls/tabs/Polls.jsx new file mode 100644 index 000000000..90850ba52 --- /dev/null +++ b/src/pages/polls/tabs/Polls.jsx @@ -0,0 +1,255 @@ +import { useState } from 'react' +import { POLLS_CONTRACT, DAO_TOKEN_DECIMALS } from '@constants' +import { useUserStore } from '@context/userStore' +import { usePollsStore } from '@context/pollsStore' +import { Button } from '@atoms/button' +import { Line } from '@atoms/line' +import { Select } from '@atoms/select' +import { TeiaUserLink, IpfsLink } from '@atoms/link' +import { + useStorage, + usePolls, + useUserPollVotes, + useDaoTokenBalance, + usePollsUsersAliases, +} from '@data/swr' +import { hexToString } from '@utils/string' +import { getWordDate } from '@utils/time' +import styles from '@style' + +const POLL_STATUS_OPTIONS = { + active: 'Active polls', + finished: 'Finished polls', +} + +export default function Polls() { + // Set the component state + const [selectedStatus, setSelectedStatus] = useState('active') + + // Get all the required polls information + const [pollsStorage] = useStorage(POLLS_CONTRACT) + const [polls] = usePolls(pollsStorage) + + // Separate the polls depending of their current status + const pollsByStatus = Object.fromEntries( + Object.keys(POLL_STATUS_OPTIONS).map((status) => [status, []]) + ) + + if (polls) { + const now = new Date() + + for (const pollId of Object.keys(polls).reverse()) { + // Calculate the poll expiration time + const poll = polls[pollId] + const votePeriod = parseInt(poll.vote_period) + const voteExpirationTime = new Date(poll.timestamp) + voteExpirationTime.setDate(voteExpirationTime.getDate() + votePeriod) + + // Save all the information inside the poll + poll.id = pollId + poll.voteExpirationTime = voteExpirationTime + poll.voteFinished = now > voteExpirationTime + + // Classify the poll according to their status + if (poll.voteFinished) { + pollsByStatus.finished.push(poll) + } else { + pollsByStatus.active.push(poll) + } + } + } + + return ( +
      +

      Teia polls

      + + + + +
      + ) +} + +function PollGroup({ status, polls }) { + switch (status) { + case 'active': + return ( + <> + {polls.length > 0 ? ( + <> +

      These polls are still active.

      +

      You can modify your previous vote if you want.

      + + ) : ( +

      There are no active polls to vote at the moment.

      + )} + + + ) + case 'finished': + return ( + <> +

      These polls are closed and cannot be voted anymore.

      + + + ) + default: + return + } +} + +function PollList({ polls, active }) { + if (polls.length !== 0) { + return ( +
        + {polls.map((poll, index) => ( +
      • + + {index !== polls.length - 1 && } +
      • + ))} +
      + ) + } +} + +function Poll({ poll, active }) { + // Get all the required polls information + const [pollsStorage] = useStorage(POLLS_CONTRACT) + const [polls, updatePolls] = usePolls(pollsStorage) + + // Get all the required user information + const userAddress = useUserStore((st) => st.address) + const [userVotes, updateUserVotes] = useUserPollVotes( + userAddress, + pollsStorage + ) + const [userTokenBalance] = useDaoTokenBalance(userAddress) + + // Get all the relevant users aliases + const [usersAliases] = usePollsUsersAliases(userAddress, polls) + + // Define the callback function to be triggered when the user interacts + const callback = () => { + updatePolls() + updateUserVotes() + } + + // Try to extract an ipfs cid from the poll description + const description = + poll.description !== '' ? hexToString(poll.description) : '' + const cid = description.split('//')[1] + + return ( +
      +

      + #{poll.id} + {hexToString(poll.question)} +

      + +

      + Created by{' '} + {' '} + on {getWordDate(poll.timestamp)}. +

      + + {!poll.voteFinished && ( +

      Voting period ends on {getWordDate(poll.voteExpirationTime)}.

      + )} + + {description !== '' && ( +

      + Description:{' '} + {cid ? Open file in ipfs : description} +

      + )} + +

      Vote weight method: {Object.keys(poll.vote_weight_method)[0]}

      + +

      Options to vote:

      + 0} + callback={callback} + /> +
      + ) +} + +function PollOptions({ poll, userVotedOption, active, callback }) { + // Set the component state + const [showPercents, setShowPercents] = useState(false) + + // Get the vote poll method from the polls store + const votePoll = usePollsStore((st) => st.votePoll) + + // Calculate the total and maximum number of votes + const votes = Object.values(poll.votes_count).map((value) => parseInt(value)) + const totalVotes = votes.reduce((acc, value) => acc + value, 0) + const maxVotes = Math.max(...votes) + + // Calculate the vote scaling factor + const voteScaling = poll.vote_weight_method.linear + ? DAO_TOKEN_DECIMALS + : poll.vote_weight_method.quadratic + ? Math.pow(DAO_TOKEN_DECIMALS, 0.5) + : 1 + + return ( +
        + {Object.entries(poll.votes_count).map(([option, optionVotes]) => ( +
      • + + {active && ( + + )} +
      • + ))} +
      + ) +} diff --git a/src/pages/polls/tabs/index.js b/src/pages/polls/tabs/index.js new file mode 100644 index 000000000..82445df49 --- /dev/null +++ b/src/pages/polls/tabs/index.js @@ -0,0 +1,2 @@ +export { default as Polls } from './Polls' +export { default as CreatePolls } from './Create' diff --git a/src/pages/polls/tabs/index.module.scss b/src/pages/polls/tabs/index.module.scss new file mode 100644 index 000000000..dfa226be9 --- /dev/null +++ b/src/pages/polls/tabs/index.module.scss @@ -0,0 +1,88 @@ +@import '@styles/mixins.scss'; +@import '@styles/variables.scss'; + +.section { + margin: 1em 0; + + a { + text-decoration: underline; + } + + > p { + margin: 1em 0; + } + + @include respond-to('desktop') { + margin-bottom: 30px; + } +} + +.section_title { + margin-bottom: 1em; +} + +.poll { + margin: 2em 0; + + p { + margin: 0.5em 0; + } +} + +.poll_id { + margin-right: 1em; + padding: 0 1em; + text-align: center; + background-color: var(--gray-15); +} + +.poll_question { + margin-bottom: 0.5em; +} + +.poll_options { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + gap: 1em; + margin-top: 1em; + text-align: center; + + @include respond-to('tablet') { + justify-content: left; + } +} + +.poll_option { + display: flex; + flex-direction: column; + gap: 0.5em; + padding: 0.75em 1em; + border: 1px solid var(--gray-20); + + &.winner { + border: 2px solid var(--yes-vote-color); + } + + > button { + padding: 0.5em; + cursor: pointer; + } +} + +.poll_form { + margin-top: 2em; +} + +.poll_form_fields { + margin-bottom: 1em; +} + +.poll_form_field { + margin: 1em 0; + + > p { + margin-bottom: 0.3em; + } +} diff --git a/src/styles/main.scss b/src/styles/main.scss index 281aefc3f..c4a179810 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -62,7 +62,7 @@ h1 { h2 { font-size: 22px; line-height: 125%; - @include respond-to('dekstop') { + @include respond-to('desktop') { font-size: 26px; } }