diff --git a/package.json b/package.json index 73c421b57a..28a4a42015 100755 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "noop-ts": "^1.0.3", "react": "^16.9.6", "react-copy-to-clipboard": "^5.0.2", + "react-countdown": "^2.3.2", "react-dom": "npm:@hot-loader/react-dom@^17.0.1", "react-i18next": "^11.16.5", "react-markdown": "^4.3.1", diff --git a/src/components/v2/Icon/icons/check.tsx b/src/components/v2/Icon/icons/check.tsx index 56f44e1672..95ebc56d6d 100644 --- a/src/components/v2/Icon/icons/check.tsx +++ b/src/components/v2/Icon/icons/check.tsx @@ -2,12 +2,18 @@ import * as React from 'react'; import { SVGProps } from 'react'; const SvgCheck = (props: SVGProps) => ( - + diff --git a/src/components/v2/Icon/icons/dots.tsx b/src/components/v2/Icon/icons/dots.tsx new file mode 100644 index 0000000000..799bf5035f --- /dev/null +++ b/src/components/v2/Icon/icons/dots.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { SVGProps } from 'react'; + +const SvgDots = (props: SVGProps) => ( + + + + + +); + +export default SvgDots; diff --git a/src/components/v2/Icon/icons/exclamation.tsx b/src/components/v2/Icon/icons/exclamation.tsx new file mode 100644 index 0000000000..cef4c5c2db --- /dev/null +++ b/src/components/v2/Icon/icons/exclamation.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { SVGProps } from 'react'; + +const SvgExclamation = (props: SVGProps) => ( + + + + +); + +export default SvgExclamation; diff --git a/src/components/v2/Icon/icons/index.ts b/src/components/v2/Icon/icons/index.ts index fdb532a098..9d05a5f635 100644 --- a/src/components/v2/Icon/icons/index.ts +++ b/src/components/v2/Icon/icons/index.ts @@ -37,6 +37,8 @@ export { default as checkInline } from './checkInline'; export { default as mark } from './mark'; export { default as arrowShaft } from './arrowShaft'; export { default as notice } from './notice'; +export { default as dots } from './dots'; +export { default as exclamation } from './exclamation'; // Coin icons export { default as aave } from './coins/aave'; diff --git a/src/components/v2/ProgressBar/index.stories.tsx b/src/components/v2/ProgressBar/index.stories.tsx index c404c893e2..e62a526b83 100644 --- a/src/components/v2/ProgressBar/index.stories.tsx +++ b/src/components/v2/ProgressBar/index.stories.tsx @@ -13,6 +13,18 @@ export const ValidProgressBar = () => ( ); +export const ProgressBarWithCustomProgressColor = () => ( + +); + export const ValidProgressBarWithTooltip = () => ( { const safeValue = value < max ? value : max; @@ -40,6 +43,7 @@ export const ProgressBar = ({ const styles = useStyles({ over: mark ? safeValue > mark : false, secondaryOver: mark ? !!(secondaryValue && secondaryValue > mark) : false, + successColor, }); const renderMark = (props?: NonNullable['mark']) => ( diff --git a/src/components/v2/ProgressBar/styles.ts b/src/components/v2/ProgressBar/styles.ts index cd6c01775e..63352a19c7 100644 --- a/src/components/v2/ProgressBar/styles.ts +++ b/src/components/v2/ProgressBar/styles.ts @@ -1,22 +1,28 @@ import { css } from '@emotion/react'; import { useTheme } from '@mui/material'; -export const useStyles = ({ over, secondaryOver }: { over: boolean; secondaryOver: boolean }) => { +export const useStyles = ({ + over, + secondaryOver, + successColor, +}: { + over: boolean; + secondaryOver: boolean; + successColor: string; +}) => { const theme = useTheme(); return { slider: css` display: block; - color: ${over ? theme.palette.interactive.error50 : theme.palette.interactive.success}; + color: ${over ? theme.palette.interactive.error50 : successColor}; background-color: ${theme.palette.background.default}; height: ${theme.spacing(2)}; padding: 0 !important; &.Mui-disabled { - color: ${over ? theme.palette.interactive.error50 : theme.palette.interactive.success}; + color: ${over ? theme.palette.interactive.error50 : successColor}; } .MuiSlider-track { - background-color: ${over - ? theme.palette.interactive.error50 - : theme.palette.interactive.success}; + background-color: ${over ? theme.palette.interactive.error50 : successColor}; height: ${theme.spacing(2)}; border-radius: ${theme.spacing(1)}; } diff --git a/src/components/v2/VoteProposalUi/ActiveVotingProgress/index.tsx b/src/components/v2/VoteProposalUi/ActiveVotingProgress/index.tsx new file mode 100644 index 0000000000..a5cf6752f8 --- /dev/null +++ b/src/components/v2/VoteProposalUi/ActiveVotingProgress/index.tsx @@ -0,0 +1,117 @@ +/** @jsxImportSource @emotion/react */ +import React, { useMemo } from 'react'; +import { BigNumber } from 'bignumber.js'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'translation'; +import { PALETTE } from 'theme/MuiThemeProvider/muiTheme'; +import { TokenId } from 'types'; +import { convertWeiToCoins } from 'utilities/common'; +import { ProgressBar } from '../../ProgressBar'; +import { useStyles } from '../styles'; + +interface IActiveVotingProgressProps { + tokenId: TokenId; + votedForWei?: BigNumber; + votedAgainstWei?: BigNumber; + abstainedWei?: BigNumber; + votedTotalWei?: BigNumber; +} + +const getValueString = (tokenId: TokenId, valueWei?: BigNumber) => { + // if !valueWei the progress row will not be rendered + if (!valueWei) return undefined; + return convertWeiToCoins({ + valueWei, + tokenId, + returnInReadableFormat: true, + }); +}; + +const getValueNumber = (tokenId: TokenId, valueWei?: BigNumber) => { + if (!valueWei) return 0; + return +convertWeiToCoins({ + valueWei, + tokenId, + returnInReadableFormat: false, + }).toFormat(); +}; + +export const ActiveVotingProgress: React.FC = ({ + tokenId, + votedForWei, + votedAgainstWei, + abstainedWei, + votedTotalWei, +}) => { + const styles = useStyles(); + const { t } = useTranslation(); + + const votedTotalCoins = getValueNumber(tokenId, votedTotalWei); + + const defaultProgressbarProps = { + step: 0.0001, + min: 0, + + // || 1 is used for rendering an empty progressbar for case when votedTotalCoins is 0 + max: votedTotalCoins || 1, + }; + + const activeProposalVotingData = useMemo( + () => [ + { + id: 'for', + label: t('voteProposalUi.statusCard.for'), + value: getValueString(tokenId, votedForWei), + progressBarProps: { + ariaLabel: t('voteProposalUi.statusCard.ariaLabelFor'), + value: getValueNumber(tokenId, votedForWei), + }, + }, + { + id: 'against', + label: t('voteProposalUi.statusCard.against'), + value: getValueString(tokenId, votedAgainstWei), + progressBarProps: { + successColor: PALETTE.interactive.error50, + ariaLabel: t('voteProposalUi.statusCard.ariaLabelAgainst'), + value: getValueNumber(tokenId, votedAgainstWei), + }, + }, + { + id: 'abstain', + label: t('voteProposalUi.statusCard.abstain'), + value: getValueString(tokenId, abstainedWei), + progressBarProps: { + successColor: PALETTE.text.secondary, + ariaLabel: t('voteProposalUi.statusCard.ariaLabelAbstain'), + value: getValueNumber(tokenId, abstainedWei), + }, + }, + ], + [votedForWei, votedAgainstWei, abstainedWei], + ); + + return ( +
+ {activeProposalVotingData.map(({ id, label, value, progressBarProps }) => { + if (!value) { + return null; + } + return ( + +
+ + {label} + + + + {value} + +
+ +
+ ); + })} +
+ ); +}; diff --git a/src/components/v2/VoteProposalUi/index.stories.tsx b/src/components/v2/VoteProposalUi/index.stories.tsx new file mode 100644 index 0000000000..1bcc13fefa --- /dev/null +++ b/src/components/v2/VoteProposalUi/index.stories.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { BigNumber } from 'bignumber.js'; +import { withThemeProvider, withCenterStory } from 'stories/decorators'; +import { VoteProposalUi } from '.'; + +export default { + title: 'Components/VoteProposalUi', + decorators: [withThemeProvider, withCenterStory({ width: 750 })], + parameters: { + backgrounds: { + default: 'Primary', + }, + }, +}; + +export const Active = () => ( + +); +export const Queued = () => ( + +); +export const ReadyToExecute = () => ( + +); +export const Executed = () => ( + +); +export const Cancelled = () => ( + +); diff --git a/src/components/v2/VoteProposalUi/index.tsx b/src/components/v2/VoteProposalUi/index.tsx new file mode 100644 index 0000000000..9c08a5c9b9 --- /dev/null +++ b/src/components/v2/VoteProposalUi/index.tsx @@ -0,0 +1,224 @@ +/** @jsxImportSource @emotion/react */ +import React, { useMemo } from 'react'; +import { BigNumber } from 'bignumber.js'; +import Countdown from 'react-countdown'; +import { CountdownRenderProps } from 'react-countdown/dist/Countdown'; +import { SerializedStyles } from '@emotion/react'; +import Paper from '@mui/material/Paper'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; + +import { useTranslation } from 'translation'; +import { TokenId } from 'types'; +import { Icon, IconName } from '../Icon'; +import { Spinner } from '../Spinner'; +import { ActiveVotingProgress } from './ActiveVotingProgress'; +import { useStyles } from './styles'; + +type ProposalCardStatus = 'queued' | 'readyToExecute' | 'executed' | 'cancelled'; +type ProposalStatus = 'active' | ProposalCardStatus; + +interface IStatusCard { + status: ProposalCardStatus; +} + +const StatusCard: React.FC = ({ status }) => { + const styles = useStyles(); + const { t } = useTranslation(); + + const statusContent: Record< + ProposalCardStatus, + { + iconWrapperCss: SerializedStyles | SerializedStyles[]; + iconName: IconName; + iconCss: SerializedStyles | SerializedStyles[]; + label: string; + } + > = useMemo( + () => ({ + queued: { + iconWrapperCss: [styles.iconWrapper, styles.iconDotsWrapper], + iconName: 'dots', + iconCss: styles.icon, + label: t('voteProposalUi.statusCard.queued'), + }, + readyToExecute: { + iconWrapperCss: [styles.iconWrapper, styles.iconInfoWrapper], + iconName: 'exclamation', + iconCss: styles.icon, + label: t('voteProposalUi.statusCard.readyToExecute'), + }, + executed: { + iconWrapperCss: [styles.iconWrapper, styles.iconMarkWrapper], + iconName: 'mark', + iconCss: [styles.icon, styles.iconCheck], + label: t('voteProposalUi.statusCard.executed'), + }, + cancelled: { + iconWrapperCss: [styles.iconWrapper, styles.iconCloseWrapper], + iconName: 'close', + iconCss: styles.icon, + label: t('voteProposalUi.statusCard.cancelled'), + }, + }), + [], + ); + + switch (status) { + case 'queued': + case 'readyToExecute': + case 'executed': + case 'cancelled': + return ( + <> +
+ +
+ + {statusContent[status].label} + + + ); + default: + return ; + } +}; + +type UserVoteStatus = 'votedFor' | 'votedAgainst' | 'abstained'; + +interface IVoteProposalUiProps { + className?: string; + proposalNumber: number; + proposalText: string; + proposalStatus: ProposalStatus; + cancelDate?: Date; + userVoteStatus?: UserVoteStatus; + votedForWei?: BigNumber; + votedAgainstWei?: BigNumber; + abstainedWei?: BigNumber; + tokenId?: TokenId; +} + +export const VoteProposalUi: React.FC = ({ + className, + proposalNumber, + proposalText, + proposalStatus, + cancelDate, + userVoteStatus, + votedForWei, + votedAgainstWei, + abstainedWei, + tokenId, +}) => { + const styles = useStyles(); + const { t } = useTranslation(); + + const voteStatusText = useMemo(() => { + switch (userVoteStatus) { + case 'votedFor': + return t('voteProposalUi.voteStatus.votedFor'); + case 'votedAgainst': + return t('voteProposalUi.voteStatus.votedAgainst'); + case 'abstained': + return t('voteProposalUi.voteStatus.abstained'); + default: + return t('voteProposalUi.voteStatus.notVoted'); + } + }, [userVoteStatus]); + + const countdownRenderer = ({ + days, + hours, + minutes, + seconds, + completed, + }: CountdownRenderProps) => { + if (completed) { + // Render a completed state + return null; + } + // Render a countdown + if (days) { + return t('voteProposalUi.countdownFormat.daysIncluded', { days, hours, minutes, seconds }); + } + if (hours) { + return t('voteProposalUi.countdownFormat.hoursIncluded', { hours, minutes, seconds }); + } + if (minutes) { + return t('voteProposalUi.countdownFormat.minutesIncluded', { minutes, seconds }); + } + return t('voteProposalUi.countdownFormat.minutesIncluded', { seconds }); + }; + + const votedTotalWei = BigNumber.sum.apply(null, [ + votedForWei || 0, + votedAgainstWei || 0, + abstainedWei || 0, + ]); + + return ( + + + +
+
+ + #{proposalNumber} + + {proposalStatus === 'active' && ( + + {t('voteProposalUi.proposalStatus.active')} + + )} +
+ + {voteStatusText} +
+ + + {proposalText} + + +
+ {cancelDate && ( + + {t('voteProposalUi.activeUntil')} + + {t('voteProposalUi.activeUntilDate', { date: cancelDate })} + + + )} + + + + +
+
+ + {proposalStatus === 'active' ? ( + tokenId && ( + + ) + ) : ( + + )} + +
+
+ ); +}; diff --git a/src/components/v2/VoteProposalUi/styles.ts b/src/components/v2/VoteProposalUi/styles.ts new file mode 100644 index 0000000000..e5c39be236 --- /dev/null +++ b/src/components/v2/VoteProposalUi/styles.ts @@ -0,0 +1,152 @@ +import { css } from '@emotion/react'; +import { alpha, useTheme } from '@mui/material'; + +export const useStyles = () => { + const theme = useTheme(); + + return { + root: css` + padding-top: 0; + padding-bottom: 0; + ${theme.breakpoints.down('sm')} { + padding-left: 0; + padding-right: 0; + } + `, + gridItem: css` + padding: ${theme.spacing(6, 0)}; + ${theme.breakpoints.down('sm')} { + padding-left: ${theme.spacing(6)}; + padding-right: ${theme.spacing(6)}; + } + `, + gridItemLeft: css` + padding-right: ${theme.spacing(6)}; + display: flex; + flex-direction: column; + justify-content: space-between; + `, + cardHeader: css` + display: flex; + justify-content: space-between; + align-items: center; + `, + cardBadges: css` + /* TODO */ + `, + cardBadgeItem: css` + padding: ${theme.spacing(1, 3)}; + background-color: ${theme.palette.secondary.light}; + border-radius: ${theme.shape.borderRadius.small}px; + margin-right: ${theme.spacing(2)}; + `, + cardBadgeNumber: css` + /* TODO */ + `, + cardBadgeActive: css` + background-color: ${alpha(theme.palette.interactive.success as string, 0.1)}; + color: ${theme.palette.interactive.success}; + `, + cardTitle: css` + margin-top: ${theme.spacing(5)}; + margin-bottom: ${theme.spacing(6)}; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + `, + cardFooter: css` + display: flex; + justify-content: space-between; + `, + activeUntilDate: css` + margin-left: ${theme.spacing(1)}; + `, + gridItemRight: css` + padding-left: ${theme.spacing(6)}; + border-left: 1px solid ${theme.palette.secondary.light}; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + ${theme.breakpoints.down('sm')} { + flex-direction: row; + border-left: none; + border-top: 1px solid ${theme.palette.secondary.light}; + padding-top: ${theme.spacing(10)}; + padding-bottom: ${theme.spacing(10)}; + } + `, + + /* StatusCard styles */ + votesWrapper: css` + display: flex; + flex-direction: column; + width: 100%; + `, + voteRow: css` + display: flex; + justify-content: space-between; + width: 100%; + margin-bottom: ${theme.spacing(1)}; + margin-top: ${theme.spacing(6)}; + & > * { + font-size: ${theme.spacing(3)}; + } + &:first-of-type { + margin-top: 0; + } + `, + statusText: css` + color: ${theme.palette.text.primary}; + text-transform: none; + margin-top: ${theme.spacing(2)}; + text-align: center; + ${theme.breakpoints.down('sm')} { + margin-top: 0; + margin-left: ${theme.spacing(3)}; + } + `, + + iconWrapper: css` + border-radius: 50%; + width: ${theme.shape.iconSize.xxLarge}px; + height: ${theme.shape.iconSize.xxLarge}px; + display: flex; + align-items: center; + justify-content: center; + ${theme.breakpoints.down('sm')} { + width: ${theme.shape.iconSize.xLarge}px; + height: ${theme.shape.iconSize.xLarge}px; + } + `, + iconDotsWrapper: css` + background-color: ${theme.palette.text.secondary}; + `, + iconInfoWrapper: css` + background-color: ${theme.palette.interactive.primary}; + `, + iconMarkWrapper: css` + background-color: ${theme.palette.interactive.success}; + `, + iconCloseWrapper: css` + background-color: ${theme.palette.interactive.error}; + `, + icon: css` + width: ${theme.shape.iconSize.medium}px; + height: ${theme.shape.iconSize.medium}px; + color: white; + ${theme.breakpoints.down('sm')} { + width: ${theme.shape.iconSize.small}px; + height: ${theme.shape.iconSize.small}px; + } + `, + iconCheck: css` + background-color: ${theme.palette.interactive.success}; + border-radius: 50%; + stroke-width: ${theme.spacing(0.5)}; + `, + }; +}; diff --git a/src/theme/MuiThemeProvider/muiTheme.ts b/src/theme/MuiThemeProvider/muiTheme.ts index f96b9b1594..28dd61beed 100644 --- a/src/theme/MuiThemeProvider/muiTheme.ts +++ b/src/theme/MuiThemeProvider/muiTheme.ts @@ -88,9 +88,11 @@ export const SHAPE = { large: SPACING * 6, } as any, // our custom types seem to clash with the default MUI types iconSize: { + small: SPACING * 3, medium: SPACING * 4, large: SPACING * 5, xLarge: SPACING * 6, + xxLarge: SPACING * 10, }, footerHeight: '56px', bannerHeight: '56px', diff --git a/src/translation/translations/en.json b/src/translation/translations/en.json index a4de0b845a..0e97289cf0 100644 --- a/src/translation/translations/en.json +++ b/src/translation/translations/en.json @@ -528,5 +528,36 @@ "dailyDistribution": "Daily Distribution:", "progressBar": "Xvs Distribution progress", "remaining": "Remaining:" + }, + "voteProposalUi": { + "statusCard": { + "for": "For", + "ariaLabelFor": "votes for", + "against": "Against", + "ariaLabelAgainst": "votes against", + "abstain": "Abstain", + "ariaLabelAbstain": "votes abstain", + "queued": "Queue", + "readyToExecute": "Ready to execute", + "executed": "Executed", + "cancelled": "Cancelled" + }, + "proposalStatus": { + "active": "Active" + }, + "voteStatus": { + "votedFor": "Voted for", + "votedAgainst": "Voted against", + "abstained": "Abstained", + "notVoted": "Not voted" + }, + "activeUntil": "Active until:", + "activeUntilDate": "{{ date, dd MMM HH:mm }}", + "countdownFormat": { + "daysIncluded": "{{days}}d {{hours}}h : {{minutes}}m : {{seconds}}s", + "hoursIncluded": "{{hours}}h : {{minutes}}m : {{seconds}}s", + "minutesIncluded": "{{minutes}}m : {{seconds}}s", + "secondsIncluded": "{{seconds}}s" + } } } diff --git a/src/types/mui.d.ts b/src/types/mui.d.ts index bccfdc347a..ab4deb2622 100644 --- a/src/types/mui.d.ts +++ b/src/types/mui.d.ts @@ -46,9 +46,11 @@ declare module '@mui/material/styles' { large: number; }; iconSize: { + small: number; medium: number; large: number; xLarge: number; + xxLarge: number; }; footerHeight: string; bannerHeight: string; diff --git a/yarn.lock b/yarn.lock index 94452471fa..665e838d4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18995,6 +18995,13 @@ react-copy-to-clipboard@^5.0.2: copy-to-clipboard "^3" prop-types "^15.5.8" +react-countdown@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/react-countdown/-/react-countdown-2.3.2.tgz#4cc27f28f2dcd47237ee66e4b9f6d2a21fc0b0ad" + integrity sha512-Q4SADotHtgOxNWhDdvgupmKVL0pMB9DvoFcxv5AzjsxVhzOVxnttMbAywgqeOdruwEAmnPhOhNv/awAgkwru2w== + dependencies: + prop-types "^15.7.2" + react-dev-utils@^11.0.3: version "11.0.4" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.4.tgz#a7ccb60257a1ca2e0efe7a83e38e6700d17aa37a"