diff --git a/apps/web/package.json b/apps/web/package.json index 69d887a83c02f..91d459399457a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,8 +48,8 @@ "@orbs-network/twap-ui-pancake": "0.11.4", "@pancakeswap/achievements": "workspace:*", "@pancakeswap/blog": "workspace:*", - "@pancakeswap/chains": "workspace:*", "@pancakeswap/canonical-bridge": "workspace:*", + "@pancakeswap/chains": "workspace:*", "@pancakeswap/farms": "workspace:*", "@pancakeswap/gauges": "workspace:*", "@pancakeswap/hooks": "workspace:*", @@ -130,6 +130,7 @@ "react-device-detect": "^2.1.2", "react-dom": "^18.2.0", "react-fast-marquee": "^1.6.0", + "react-hook-form": "^7.54.2", "react-redux": "^8.0.5", "react-transition-group": "^4.4.5", "react-window": "^1.8.7", diff --git a/apps/web/src/views/Voting/CreateProposal/index.tsx b/apps/web/src/views/Voting/CreateProposal/index.tsx index b9399cea34449..8568902ed7d20 100644 --- a/apps/web/src/views/Voting/CreateProposal/index.tsx +++ b/apps/web/src/views/Voting/CreateProposal/index.tsx @@ -1,3 +1,6 @@ +import { Suspense, useEffect, useMemo } from 'react' +import { Controller, useFieldArray, useForm } from 'react-hook-form' + import { useTranslation } from '@pancakeswap/localization' import { AutoRenewIcon, @@ -12,164 +15,186 @@ import { Input, ReactMarkdown, ScanLink, + Spinner, Text, + TooltipText, useModal, useToast, + useTooltip, } from '@pancakeswap/uikit' +import { formatNumber } from '@pancakeswap/utils/formatNumber' import truncateHash from '@pancakeswap/utils/truncateHash' import snapshot from '@snapshot-labs/snapshot.js' import ConnectWalletButton from 'components/ConnectWalletButton' import Container from 'components/Layout/Container' -import isEmpty from 'lodash/isEmpty' -import times from 'lodash/times' +import { useAtomValue } from 'jotai' import dynamic from 'next/dynamic' import Link from 'next/link' import { useRouter } from 'next/router' -import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from 'react' import { useInitialBlock } from 'state/block/hooks' import { ProposalTypeName } from 'state/types' -import { getBlockExploreLink } from 'utils' +import styled from 'styled-components' import { DatePicker, DatePickerPortal, TimePicker } from 'views/Voting/components/DatePicker' import { useAccount, useWalletClient } from 'wagmi' +import { spaceAtom } from '../atom/spaceAtom' import Layout from '../components/Layout' import VoteDetailsModal from '../components/VoteDetailsModal' -import { ADMINS, PANCAKE_SPACE, VOTE_THRESHOLD } from '../config' -import Choices, { ChoiceIdValue, MINIMUM_CHOICES, makeChoice } from './Choices' -import { combineDateAndTime, getFormErrors } from './helpers' +import { PANCAKE_SPACE, VOTE_THRESHOLD } from '../config' +import useGetVotingPower from '../hooks/useGetVotingPower' +import Choices, { MINIMUM_CHOICES, makeChoice } from './Choices' +import { combineDateAndTime, getFormErrors } from './helpers' // You can adapt or remove this import { FormErrors, Label, SecondaryLabel } from './styles' -import { FormState } from './types' - -const hub = 'https://hub.snapshot.org' -const client = new snapshot.Client712(hub) +// Dynamically import EasyMde for SSR const EasyMde = dynamic(() => import('components/EasyMde'), { ssr: false, }) +const hub = 'https://hub.snapshot.org' +const client = new snapshot.Client712(hub) + +function getEndTime(startTime: Date | null, period?: number) { + if (!startTime) return null + return period ? new Date(startTime.getTime() + period * 1000) : null +} + const CreateProposal = () => { - const [state, setState] = useState(() => ({ - name: '', - body: '', - choices: times(MINIMUM_CHOICES).map(makeChoice), - startDate: null, - startTime: null, - endDate: null, - endTime: null, - snapshot: 0, - })) - const [isLoading, setIsLoading] = useState(false) - const [fieldsState, setFieldsState] = useState<{ [key: string]: boolean }>({}) const { t } = useTranslation() + const space = useAtomValue(spaceAtom) + const { toastSuccess, toastError } = useToast() + const { address: account } = useAccount() + const { data: signer } = useWalletClient() const initialBlock = useInitialBlock() + const { delay, period } = space?.voting || {} + const created = useMemo(() => Math.floor(Date.now() / 1000), []) // current timestamp in seconds const { push } = useRouter() - const { toastSuccess, toastError } = useToast() - const [onPresentVoteDetailsModal] = useModal() - // eslint-disable-next-line @typescript-eslint/no-shadow - const { name, body, choices, startDate, startTime, endDate, endTime, snapshot } = state - const formErrors = getFormErrors(state, t) - const { data: signer } = useWalletClient() + const { + register, + handleSubmit, + watch, + setValue, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + name: '', + body: '', + // Pre-fill the choices array with the minimum required + choices: Array.from({ length: MINIMUM_CHOICES }).map(() => makeChoice()), + startDate: delay ? new Date((created + delay) * 1000) : null, + startTime: delay ? new Date((created + delay) * 1000) : null, + endDate: period ? getEndTime(delay ? new Date((created + delay) * 1000) : null, period) : null, + endTime: period ? getEndTime(delay ? new Date((created + delay) * 1000) : null, period) : null, + snapshot: 0, + }, + }) + + const { total, isLoading } = useGetVotingPower(watch('snapshot')) + const enoughVotingPower = total >= VOTE_THRESHOLD + + const { + fields: choiceFields, + replace: replaceChoices, + // If you want to let users add more choices, you can do: + // append, remove, etc. + } = useFieldArray({ + control, + name: 'choices', + }) + + const startDate_ = watch('startDate') + const startTime_ = watch('startTime') + + useEffect(() => { + if (period && startDate_ && startTime_) { + const combinedStart = combineDateAndTime(startDate_, startTime_) + if (combinedStart) { + const date = new Date(combinedStart * 1000) + const newEnd = new Date(date.getTime() + period * 1000) + setValue('endDate', newEnd) + setValue('endTime', newEnd) + } + } + }, [startDate_, startTime_, period, setValue]) - const handleSubmit = async (evt: FormEvent) => { - evt.preventDefault() + useEffect(() => { + if (initialBlock > 0) { + setValue('snapshot', Number(initialBlock)) + } + }, [initialBlock, setValue]) + + const [onPresentVoteDetailsModal] = useModal() + + const votingPowerTooltipContent = t( + 'Your voting power is determined by your veCAKE balance at the snapshot block, which represents how much weight your vote carries.', + ) + const { + targetRef: votingPowerTargetRef, + tooltip: votingPowerTooltip, + tooltipVisible: votingPowerTooltipVisible, + } = useTooltip({votingPowerTooltipContent}, { + placement: 'top', + }) + const onSubmit = async (data: any) => { if (!account) return - try { - setIsLoading(true) + const formErrors = getFormErrors(data, t) + if (Object.keys(formErrors).length > 0) { + toastError(t('Error'), t('Please fix form errors.')) + return + } + try { const web3 = { - getSigner: () => { - return { - _signTypedData: (domain, types, message) => - signer?.signTypedData({ - account, - domain, - types, - message, - primaryType: 'Proposal', - }), - } - }, + getSigner: () => ({ + _signTypedData: (domain: any, types: any, message: any) => + signer?.signTypedData({ + account, + domain, + types, + message, + primaryType: 'Proposal', + }), + }), } - const data: any = await client.proposal(web3 as any, account, { + const { name, body, choices, startDate, startTime, endDate, endTime, snapshot: snapshotNum } = data + + // Combine date/time into seconds + const start = combineDateAndTime(startDate, startTime) || 0 + const end = combineDateAndTime(endDate, endTime) || 0 + + const resData: any = await client.proposal(web3 as any, account, { space: PANCAKE_SPACE, - type: ProposalTypeName.SINGLE_CHOICE, // TODO + type: ProposalTypeName.SINGLE_CHOICE, // Keep or adapt to your type title: name, body, - start: combineDateAndTime(startDate, startTime) || 0, - end: combineDateAndTime(endDate, endTime) || 0, - choices: choices - .filter((choice) => choice.value) - .map((choice) => { - return choice.value - }), - snapshot, + timestamp: created, + start, + end, + choices: choices.filter((choice: any) => choice.value).map((choice: any) => choice.value), + snapshot: snapshotNum, discussion: '', plugins: JSON.stringify({}), app: 'snapshot', }) // Redirect user to newly created proposal page - push(`/voting/proposal/${data.id}`) + push(`/voting/proposal/${resData.id}`) toastSuccess(t('Proposal created!')) } catch (error) { toastError(t('Error'), (error as Error)?.message) console.error(error) - setIsLoading(false) } } - const updateValue = (key: string, value: string | ChoiceIdValue[] | Date) => { - setState((prevState) => ({ - ...prevState, - [key]: value, - })) - - // Keep track of what fields the user has attempted to edit - setFieldsState((prevFieldsState) => ({ - ...prevFieldsState, - [key]: true, - })) - } - - const handleChange = (evt: ChangeEvent) => { - const { name: inputName, value } = evt.currentTarget - updateValue(inputName, value) - } - - const handleEasyMdeChange = (value: string) => { - updateValue('body', value) + if (!space) { + return {t('Network unstable. Please refresh to continue.')} } - const handleChoiceChange = (newChoices: ChoiceIdValue[]) => { - updateValue('choices', newChoices) - } - - const handleDateChange = (key: string) => (value: Date) => { - updateValue(key, value) - } - - const options = useMemo(() => { - return { - hideIcons: - account && ADMINS.includes(account.toLowerCase()) - ? [] - : ['guide', 'fullscreen', 'preview', 'side-by-side', 'image'], - } - }, [account]) - - useEffect(() => { - if (initialBlock > 0) { - setState((prevState) => ({ - ...prevState, - snapshot: Number(initialBlock), - })) - } - }, [initialBlock, setState]) - return ( @@ -179,30 +204,43 @@ const CreateProposal = () => { {t('Make a Proposal')} -
+ {/* Use react-hook-form's handleSubmit */} + + {/* Left side */} - - {formErrors.name && fieldsState.name && } + + {errors.name && } {t('Tip: write in Markdown!')} - ( + + )} /> - {formErrors.body && fieldsState.body && } + {errors.body && } - {body && ( + + {/* Markdown Preview */} + {/* You can simply watch('body') to get the field’s current value */} + {watch('body') && ( @@ -211,14 +249,25 @@ const CreateProposal = () => { - {body} + {watch('body')} )} - - {formErrors.choices && fieldsState.choices && } + + {/* Choices (using a field array) */} + ( + replaceChoices(newChoices)} /> + )} + /> + {/* If you want to show any errors for choices: */} + {errors.choices && } + + {/* Right side */} @@ -229,77 +278,124 @@ const CreateProposal = () => { {t('Start Date')} - ( + field.onChange(date)} + /> + )} /> - {formErrors.startDate && fieldsState.startDate && } + {errors.startDate && } + {t('Start Time')} - ( + field.onChange(date)} + /> + )} /> - {formErrors.startTime && fieldsState.startTime && } + {errors.startTime && } + {t('End Date')} - ( + field.onChange(date)} + /> + )} /> - {formErrors.endDate && fieldsState.endDate && } + {errors.endDate && } + {t('End Time')} - ( + field.onChange(date)} + /> + )} /> - {formErrors.endTime && fieldsState.endTime && } + {errors.endTime && } + {account && ( {t('Creator')} - + {truncateHash(account)} )} + + + + {t('Voting Power')} + + + + {isLoading ? '-' : formatNumber(total ?? 0, { maxDecimalDisplayDigits: 2 })} + + {votingPowerTooltipVisible && votingPowerTooltip} + + + + {/* Snapshot */} {t('Snapshot')} - - {snapshot} + + {watch('snapshot')} + {account ? ( <> - - {t('You need at least %count% voting power to publish a proposal.', { count: VOTE_THRESHOLD })}{' '} - + {!enoughVotingPower && ( + + {t('You need at least %count% voting power to publish a proposal.', { + count: VOTE_THRESHOLD, + })} + + )} @@ -317,4 +413,28 @@ const CreateProposal = () => { ) } -export default CreateProposal +const Wrapped = () => { + return ( + }> + + + ) +} + +const FullScreenBox = styled(Box)` + width: 100%; + height: 50vh; + display: flex; + align-items: center; + justify-content: center; +` + +export const SpinnerPage = () => { + return ( + + + + ) +} + +export default Wrapped diff --git a/apps/web/src/views/Voting/atom/spaceAtom.ts b/apps/web/src/views/Voting/atom/spaceAtom.ts new file mode 100644 index 0000000000000..54fb98907b9a9 --- /dev/null +++ b/apps/web/src/views/Voting/atom/spaceAtom.ts @@ -0,0 +1,59 @@ +import { GraphQLClient, gql } from 'graphql-request' +import { atom } from 'jotai' +import { PANCAKE_SPACE } from '../config' + +interface Space { + id: string + name: string + about: string + network: string + symbol: string + members: number + voting: { + delay: number + period: number + } + strategies: any[] +} + +export const spaceAtom = atom(async () => { + const endpoint = 'https://hub.snapshot.org/graphql' + const client = new GraphQLClient(endpoint, { + headers: { + 'Content-Type': 'application/json', + }, + }) + + // 2. Define the Query + const GET_SPACE_DATA = gql` + query { + space(id: "${PANCAKE_SPACE}") { + id + name + about + network + symbol + members + voting { + delay + period + } + strategies { + name + params + } + validation { + name + params + } + } + } + ` + try { + const data = await client.request(GET_SPACE_DATA) + return data.space as Space + } catch (error) { + console.error(error) + return null + } +}) diff --git a/packages/localization/src/config/translations.json b/packages/localization/src/config/translations.json index 852434ca62cf2..be65e30e66f8a 100644 --- a/packages/localization/src/config/translations.json +++ b/packages/localization/src/config/translations.json @@ -3672,5 +3672,11 @@ "No Existing Orders": "No Existing Orders", "Existing Orders": "Existing Orders", "Swap %token% to win a share of": "Swap %token% to win a share of", - "with daily prizes and leaderboard rewards!": "with daily prizes and leaderboard rewards!" + "with daily prizes and leaderboard rewards!": "with daily prizes and leaderboard rewards!", + "Network unstable. Please refresh to continue.": "Network unstable. Please refresh to continue.", + "Your voting power is determined by your veCAKE balance at the snapshot block, which represents how much weight your vote carries.": "Your voting power is determined by your veCAKE balance at the snapshot block, which represents how much weight your vote carries.", + "Please fix form errors.": "Please fix form errors.", + "Title is required": "Title is required", + "Content is required": "Content is required", + "Please provide valid choices": "Please provide valid choices" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06dd3d47901c8..9f64bb2681599 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,16 +410,16 @@ importers: version: 5.52.1(react@18.2.0) '@vanilla-extract/next-plugin': specifier: ^2.3.0 - version: 2.3.2(@types/node@18.0.4)(next@14.2.14(@babel/core@7.23.9)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(terser@5.24.0)(webpack@5.89.0(esbuild@0.17.19)) + version: 2.3.2(@types/node@18.0.4)(next@14.2.14(@babel/core@7.23.3)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(terser@5.24.0)(webpack@5.89.0(esbuild@0.17.19)) next: specifier: 'catalog:' - version: 14.2.14(@babel/core@7.23.9)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 14.2.14(@babel/core@7.23.3)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) next-seo: specifier: ^5.15.0 - version: 5.15.0(next@14.2.14(@babel/core@7.23.9)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 5.15.0(next@14.2.14(@babel/core@7.23.3)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) next-themes: specifier: ^0.2.1 - version: 0.2.1(next@14.2.14(@babel/core@7.23.9)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 0.2.1(next@14.2.14(@babel/core@7.23.3)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) qs: specifier: ^6.0.0 version: 6.11.2 @@ -1230,6 +1230,9 @@ importers: react-fast-marquee: specifier: ^1.6.0 version: 1.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-hook-form: + specifier: ^7.54.2 + version: 7.54.2(react@18.2.0) react-redux: specifier: ^8.0.5 version: 8.1.3(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0(react@18.2.0))(react-native@0.73.6(@babel/core@7.23.9)(@babel/preset-env@7.23.3(@babel/core@7.23.9))(bufferutil@4.0.8)(react@18.2.0)(utf-8-validate@5.0.10))(react@18.2.0)(redux@4.2.1) @@ -2671,7 +2674,7 @@ importers: version: 13.3.8 jest-styled-components: specifier: ^7.0.8 - version: 7.2.0(styled-components@6.0.7(babel-plugin-styled-components@1.13.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)) + version: 7.2.0(styled-components@6.0.7) js-cookie: specifier: '*' version: 3.0.5 @@ -6997,8 +7000,8 @@ packages: '@solidity-parser/parser@0.16.2': resolution: {integrity: sha512-PI9NfoA3P8XK2VBkK5oIfRgKDsicwDZfkVq9ZTBCQYGOP1N2owgY2dyLGyU5/J/hQs8KRk55kdmvTLjy3Mu3vg==} - '@solidity-parser/parser@0.18.0': - resolution: {integrity: sha512-yfORGUIPgLck41qyN7nbwJRAx17/jAIXCTanHOJZhB6PJ1iAk/84b/xlsVKFSyNyLXIj0dhppoE0+CRws7wlzA==} + '@solidity-parser/parser@0.19.0': + resolution: {integrity: sha512-RV16k/qIxW/wWc+mLzV3ARyKUaMUTBy9tOLMzFhtNSKYeTAanQ3a5MudJKf/8arIFnA2L27SNjarQKmFg0w/jA==} '@spruceid/siwe-parser@1.1.3': resolution: {integrity: sha512-oQ8PcwDqjGWJvLmvAF2yzd6iniiWxK0Qtz+Dw+gLD/W5zOQJiKIUXwslHOm8VB8OOOKW9vfR3dnPBhHaZDvRsw==} @@ -14112,9 +14115,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-plugin-solidity@1.4.1: - resolution: {integrity: sha512-Mq8EtfacVZ/0+uDKTtHZGW3Aa7vEbX/BNx63hmVg6YTiTXSiuKP0amj0G6pGwjmLaOfymWh3QgXEZkjQbU8QRg==} - engines: {node: '>=16'} + prettier-plugin-solidity@1.4.2: + resolution: {integrity: sha512-VVD/4XlDjSzyPWWCPW8JEleFa8JNKFYac5kNlMjVXemQyQZKfpekPMhFZSePuXB6L+RixlFvWe20iacGjFYrLw==} + engines: {node: '>=18'} peerDependencies: prettier: '>=2.3.0' @@ -14447,6 +14450,12 @@ packages: react: '>= 16.8.0 || 18.0.0' react-dom: '>= 16.8.0 || 18.0.0' + react-hook-form@7.54.2: + resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-icons@4.11.0: resolution: {integrity: sha512-V+4khzYcE5EBk/BvcuYRq6V/osf11ODUM2J8hg2FDSswRrGvqiYUYPRy4OdrWaQOBj4NcpJfmHZLNaD+VH0TyA==} peerDependencies: @@ -15068,6 +15077,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + send@0.18.0: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} engines: {node: '>= 0.8.0'} @@ -18893,7 +18907,7 @@ snapshots: isomorphic-fetch: 3.0.0 lodash: 4.17.21 prettier: 2.8.8 - prettier-plugin-solidity: 1.4.1(prettier@2.8.8) + prettier-plugin-solidity: 1.4.2(prettier@2.8.8) web3: 1.10.4(bufferutil@4.0.8)(utf-8-validate@5.0.10) optionalDependencies: '@defi.org/chai-bignumber': 3.0.2(bignumber.js@9.1.2) @@ -22553,7 +22567,7 @@ snapshots: antlr4ts: 0.5.0-alpha.4 optional: true - '@solidity-parser/parser@0.18.0': {} + '@solidity-parser/parser@0.19.0': {} '@spruceid/siwe-parser@1.1.3': dependencies: @@ -30831,7 +30845,7 @@ snapshots: jest-regex-util@29.6.3: {} - jest-styled-components@7.2.0(styled-components@6.0.7(babel-plugin-styled-components@1.13.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)): + jest-styled-components@7.2.0(styled-components@6.0.7): dependencies: '@adobe/css-tools': 4.3.1 styled-components: 6.0.7(babel-plugin-styled-components@1.13.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -32944,11 +32958,11 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-solidity@1.4.1(prettier@2.8.8): + prettier-plugin-solidity@1.4.2(prettier@2.8.8): dependencies: - '@solidity-parser/parser': 0.18.0 + '@solidity-parser/parser': 0.19.0 prettier: 2.8.8 - semver: 7.5.4 + semver: 7.6.3 prettier@2.8.8: {} @@ -33338,6 +33352,10 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + react-hook-form@7.54.2(react@18.2.0): + dependencies: + react: 18.2.0 + react-icons@4.11.0(react@18.2.0): dependencies: react: 18.2.0 @@ -34138,6 +34156,8 @@ snapshots: dependencies: lru-cache: 6.0.0 + semver@7.6.3: {} + send@0.18.0: dependencies: debug: 2.6.9