From 8c2b69ad1ead703119d4cf9ce1daf02ddd788798 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 Nov 2024 15:03:13 +0200 Subject: [PATCH] fix: added connect wallet --- .github/workflows/ipfs_deploy.yml | 4 +- src/components/BackButton3D.tsx | 128 ++++++ src/components/BasicModal.tsx | 264 +++++++++++ src/components/BigButton.tsx | 139 ++++++ src/components/ChainNameWithIcon.tsx | 31 ++ src/components/CopyAndExternalIconsSet.tsx | 94 ++++ src/components/CopyToClipboard.tsx | 43 ++ src/components/Link.tsx | 2 +- src/components/NetworkIcon.tsx | 55 +++ src/components/RocketLoader.tsx | 92 ++++ src/components/Spinner.tsx | 97 ++++ src/components/Tooltip.tsx | 56 +++ src/components/Transactions/ActionModal.tsx | 99 ++++ .../Transactions/ActionModalContent.tsx | 348 ++++++++++++++ .../Transactions/BasicActionModal.tsx | 67 +++ .../Transactions/CopyErrorButton.tsx | 56 +++ .../Transactions/TransactionInfoItem.tsx | 433 ++++++++++++++++++ .../Transactions/TransactionsModal.tsx | 48 ++ .../Transactions/TransactionsModalContent.tsx | 65 +++ src/components/Web3/BlockTitleWithTooltip.tsx | 105 +++++ src/components/Web3/ChainsIcons.tsx | 38 ++ .../Web3/wallet/AccountAddressInfo.tsx | 83 ++++ .../Web3/wallet/AccountInfoModal.tsx | 73 +++ .../Web3/wallet/AccountInfoModalContent.tsx | 312 +++++++++++++ .../Web3/wallet/ConnectWalletButton.tsx | 382 +++++++++++++++ .../Web3/wallet/ConnectWalletModal.tsx | 81 ++++ .../Web3/wallet/ConnectWalletModalContent.tsx | 150 ++++++ .../Web3/wallet/ImpersonatedForm.tsx | 78 ++++ src/components/Web3/wallet/WalletItem.tsx | 63 +++ src/components/Web3/wallet/WalletWidget.tsx | 132 ++++++ src/components/Web3Icons/AssetIcon.tsx | 54 +++ src/components/Web3Icons/ChainIcon.tsx | 47 ++ src/components/Web3Icons/WalletIcon.tsx | 48 ++ src/components/layouts/AppHeader.tsx | 384 +++++++++++++++- src/components/layouts/AppModeSwitcher.tsx | 79 ++++ src/components/layouts/SettingsButton.tsx | 324 +++++++++++++ src/components/layouts/ThemeSwitcher.tsx | 123 +++++ src/components/primitives/CustomSkeleton.tsx | 47 ++ src/{ => configs}/appConfig.ts | 2 +- src/configs/chains.ts | 170 +++++++ src/configs/configs.ts | 13 + src/{ => configs}/localStorage.ts | 34 +- src/{ => configs}/routes.ts | 0 src/helpers/ensHelpers.tsx | 41 ++ src/helpers/getScanLink.ts | 18 + src/helpers/useLastTxLocalStatus.tsx | 21 + src/providers/WagmiProvider.tsx | 64 +++ src/providers/Web3HelperProvider.tsx | 21 + src/providers/index.tsx | 4 + src/requests/fetchInitialData.ts | 2 +- src/store/ensSlice.ts | 183 ++++++++ src/store/index.ts | 17 +- src/store/rpcSwitcherSlice.ts | 279 +++++++++++ src/store/selectors/ensSelectors.ts | 129 ++++++ src/store/selectors/rpcSwitcherSelectors.ts | 17 + src/store/transactionsSlice.ts | 301 ++++++++++++ src/store/uiSlice.ts | 50 +- src/store/web3Slice.ts | 30 ++ src/styles/text-center-ellipsis.ts | 6 + src/styles/useClickOutside.tsx | 29 ++ src/types.ts | 49 ++ tsconfig.json | 2 +- 62 files changed, 6162 insertions(+), 44 deletions(-) create mode 100644 src/components/BackButton3D.tsx create mode 100644 src/components/BasicModal.tsx create mode 100644 src/components/BigButton.tsx create mode 100644 src/components/ChainNameWithIcon.tsx create mode 100644 src/components/CopyAndExternalIconsSet.tsx create mode 100644 src/components/CopyToClipboard.tsx create mode 100644 src/components/NetworkIcon.tsx create mode 100644 src/components/RocketLoader.tsx create mode 100644 src/components/Spinner.tsx create mode 100644 src/components/Tooltip.tsx create mode 100644 src/components/Transactions/ActionModal.tsx create mode 100644 src/components/Transactions/ActionModalContent.tsx create mode 100644 src/components/Transactions/BasicActionModal.tsx create mode 100644 src/components/Transactions/CopyErrorButton.tsx create mode 100644 src/components/Transactions/TransactionInfoItem.tsx create mode 100644 src/components/Transactions/TransactionsModal.tsx create mode 100644 src/components/Transactions/TransactionsModalContent.tsx create mode 100644 src/components/Web3/BlockTitleWithTooltip.tsx create mode 100644 src/components/Web3/ChainsIcons.tsx create mode 100644 src/components/Web3/wallet/AccountAddressInfo.tsx create mode 100644 src/components/Web3/wallet/AccountInfoModal.tsx create mode 100644 src/components/Web3/wallet/AccountInfoModalContent.tsx create mode 100644 src/components/Web3/wallet/ConnectWalletButton.tsx create mode 100644 src/components/Web3/wallet/ConnectWalletModal.tsx create mode 100644 src/components/Web3/wallet/ConnectWalletModalContent.tsx create mode 100644 src/components/Web3/wallet/ImpersonatedForm.tsx create mode 100644 src/components/Web3/wallet/WalletItem.tsx create mode 100644 src/components/Web3/wallet/WalletWidget.tsx create mode 100644 src/components/Web3Icons/AssetIcon.tsx create mode 100644 src/components/Web3Icons/ChainIcon.tsx create mode 100644 src/components/Web3Icons/WalletIcon.tsx create mode 100644 src/components/layouts/AppModeSwitcher.tsx create mode 100644 src/components/layouts/SettingsButton.tsx create mode 100644 src/components/layouts/ThemeSwitcher.tsx create mode 100644 src/components/primitives/CustomSkeleton.tsx rename src/{ => configs}/appConfig.ts (99%) create mode 100644 src/configs/chains.ts create mode 100644 src/configs/configs.ts rename src/{ => configs}/localStorage.ts (60%) rename src/{ => configs}/routes.ts (100%) create mode 100644 src/helpers/ensHelpers.tsx create mode 100644 src/helpers/getScanLink.ts create mode 100644 src/helpers/useLastTxLocalStatus.tsx create mode 100644 src/providers/WagmiProvider.tsx create mode 100644 src/providers/Web3HelperProvider.tsx create mode 100644 src/store/ensSlice.ts create mode 100644 src/store/rpcSwitcherSlice.ts create mode 100644 src/store/selectors/ensSelectors.ts create mode 100644 src/store/selectors/rpcSwitcherSelectors.ts create mode 100644 src/store/transactionsSlice.ts create mode 100644 src/store/web3Slice.ts create mode 100644 src/styles/text-center-ellipsis.ts create mode 100644 src/styles/useClickOutside.tsx diff --git a/.github/workflows/ipfs_deploy.yml b/.github/workflows/ipfs_deploy.yml index 74313bea..97426e16 100644 --- a/.github/workflows/ipfs_deploy.yml +++ b/.github/workflows/ipfs_deploy.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest environment: name: 'IPFS' - url: https://${{ steps.pinata.outputs.hash }}.ipfs.cf-ipfs.com/ + url: https://${{ steps.pinata.outputs.hash }}.ipfs.dweb.link/ outputs: pinata_hash: '${{ steps.pinata.outputs.hash }}' steps: @@ -67,5 +67,5 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, release_id: data.id, - body: data.body + `\n
\n Ipfs deployment: ` + `${{steps.pinata.outputs.uri}}`, + body: data.body + `\n
\n Ipfs deployment: ` + `[${{steps.pinata.outputs.uri}}](${{steps.pinata.outputs.uri}})`, }) diff --git a/src/components/BackButton3D.tsx b/src/components/BackButton3D.tsx new file mode 100644 index 00000000..5e2fdbba --- /dev/null +++ b/src/components/BackButton3D.tsx @@ -0,0 +1,128 @@ +import { Box, SxProps, useTheme } from '@mui/system'; +import React from 'react'; + +import BackArrow from '../assets/icons/backArrow.svg'; +import { texts } from '../helpers/texts/texts'; +import { useStore } from '../providers/ZustandStoreProvider'; +import { BoxWith3D } from './BoxWith3D'; +import { IconBox } from './primitives/IconBox'; + +interface BackButton3DProps { + onClick: () => void; + isVisibleOnMobile?: boolean; + alwaysWithBorders?: boolean; + isSmall?: boolean; + wrapperCss?: SxProps; + css?: SxProps; + alwaysVisible?: boolean; +} + +export function BackButton3D({ + onClick, + isVisibleOnMobile, + alwaysWithBorders, + isSmall, + wrapperCss, + css, + alwaysVisible, +}: BackButton3DProps) { + const theme = useTheme(); + + const isRendered = useStore((store) => store.isRendered); + + if (typeof window !== 'undefined') { + if (window.history.length <= 1 && !alwaysVisible) { + return null; + } + } + + return ( +
+ + + + ({ + mr: 5, + zIndex: 2, + width: 15, + height: 15, + '> svg': { + width: 15, + height: 15, + [theme.breakpoints.up('lg')]: { + width: isSmall ? 15 : 21, + height: isSmall ? 15 : 21, + }, + }, + [theme.breakpoints.up('lg')]: { + width: isSmall ? 15 : 21, + height: isSmall ? 15 : 21, + mr: isSmall ? 5 : 10, + }, + path: { + transition: 'all 0.2s ease', + fill: isRendered + ? `${theme.palette.$textLight} !important` + : theme.palette.$textLight, + }, + })}> + + + + + + {texts.other.backButtonTitle} + + + + + +
+ ); +} diff --git a/src/components/BasicModal.tsx b/src/components/BasicModal.tsx new file mode 100644 index 00000000..37dd2154 --- /dev/null +++ b/src/components/BasicModal.tsx @@ -0,0 +1,264 @@ +import { Dialog } from '@headlessui/react'; +import { Box, SxProps, useTheme } from '@mui/system'; +import { ReactNode } from 'react'; + +import CloseIcon from '../assets/icons/cross.svg'; +import { media } from '../styles/themeMUI'; +import { useMediaQuery } from '../styles/useMediaQuery'; +import { BackButton3D } from './BackButton3D'; +import { BoxWith3D } from './BoxWith3D'; +import { IconBox } from './primitives/IconBox'; +import NoSSR from './primitives/NoSSR'; + +const ContentWrapper = ({ + children, + maxWidth, + contentCss, + withMinHeight, + minHeight, +}: { + children: ReactNode; + maxWidth?: number | string; + contentCss?: SxProps; + withMinHeight?: boolean; + minHeight?: number; +}) => { + const theme = useTheme(); + + return ( + <> + + {children} + + + div': { width: '100%', height: '100%', display: 'flex' }, + }} + css={{ + [theme.breakpoints.up('sm')]: { + position: 'relative', + overflowX: 'hidden', + overflowY: 'auto', + width: '100%', + display: 'block', + maxHeight: 'calc(100vh - 20px)', + height: 'unset', + maxWidth: maxWidth || 460, + p: '24px 30px', + minHeight: 'unset', + '@media only screen and (min-height: 575px)': { + minHeight: withMinHeight ? 500 : minHeight ? minHeight : 'unset', + }, + }, + }}> + + {children} + + + + ); +}; + +export interface BasicModalProps { + isOpen: boolean; + setIsOpen: (value: boolean) => void; + children: ReactNode; + maxWidth?: number | string; + withCloseButton?: boolean; + withoutOverlap?: boolean; + onBackButtonClick?: () => void; + contentCss?: SxProps; + modalCss?: SxProps; + initialFocus?: any; + withMinHeight?: boolean; + minHeight?: number; +} + +export function BasicModal({ + isOpen, + setIsOpen, + children, + maxWidth, + withCloseButton, + withoutOverlap, + onBackButtonClick, + contentCss, + modalCss, + initialFocus, + withMinHeight, + minHeight, +}: BasicModalProps) { + const theme = useTheme(); + const sm = useMediaQuery(media.sm); + + return ( + + { + setIsOpen(false); + }}> + {!withoutOverlap && ( + + + ); +} diff --git a/src/components/BigButton.tsx b/src/components/BigButton.tsx new file mode 100644 index 00000000..98441fa7 --- /dev/null +++ b/src/components/BigButton.tsx @@ -0,0 +1,139 @@ +import { Box, SxProps, useTheme } from '@mui/system'; +import { MouseEventHandler, ReactNode } from 'react'; + +import { BoxWith3D } from './BoxWith3D'; +import { Spinner } from './Spinner'; + +export interface BigButtonProps { + type?: 'button' | 'submit'; + color?: 'black' | 'white'; + activeColorType?: 'for' | 'against'; + children: string | ReactNode; + disabled?: boolean; + loading?: boolean; + alwaysWithBorders?: boolean; + onClick?: MouseEventHandler; + css?: SxProps; + withoutActions?: boolean; +} + +export function BigButton({ + type = 'button', + color = 'black', + activeColorType, + children, + disabled, + loading, + onClick, + alwaysWithBorders, + css, + withoutActions, +}: BigButtonProps) { + const theme = useTheme(); + + const borderSize = 4; + const mobileWidth = '97px'; + const mobileHeight = '35px'; + const tabletWidth = '134px'; + const tabletHeight = '44px'; + const width = '156px'; + const height = '50px'; + + const contentColor = color === 'black' ? '$mainButton' : '$whiteButton'; + const textColor = color === 'black' ? '$textWhite' : '$text'; + const activeColor = + activeColorType === 'for' + ? '$mainFor' + : activeColorType === 'against' + ? '$mainAgainst' + : color === 'white' + ? '$light' + : undefined; + + return ( + + + + + {children} + + {loading && ( + + + + )} + + + + ); +} diff --git a/src/components/ChainNameWithIcon.tsx b/src/components/ChainNameWithIcon.tsx new file mode 100644 index 00000000..22a73699 --- /dev/null +++ b/src/components/ChainNameWithIcon.tsx @@ -0,0 +1,31 @@ +import { getChainName } from '@bgd-labs/react-web3-icons/dist/utils'; +import { Box, SxProps } from '@mui/system'; + +import { NetworkIcon } from './NetworkIcon'; + +interface ChainNameWithIconProps { + chainId: number; + onlyIcon?: boolean; + iconSize?: number; + css?: SxProps; + textCss?: SxProps; +} + +export function ChainNameWithIcon({ + chainId, + onlyIcon, + iconSize, + css, + textCss, +}: ChainNameWithIconProps) { + return ( + + + {!onlyIcon && ( + + {getChainName({ chainId })} + + )} + + ); +} diff --git a/src/components/CopyAndExternalIconsSet.tsx b/src/components/CopyAndExternalIconsSet.tsx new file mode 100644 index 00000000..fd9e5a08 --- /dev/null +++ b/src/components/CopyAndExternalIconsSet.tsx @@ -0,0 +1,94 @@ +import { Box, SxProps, useTheme } from '@mui/system'; +import React from 'react'; + +import CopyIcon from '../assets/icons/copy.svg'; +import LinkIcon from '../assets/icons/linkIcon.svg'; +import { CopyToClipboard } from './CopyToClipboard'; +import { Link } from './Link'; +import { IconBox } from './primitives/IconBox'; + +interface CopyAndExternalIconsSetProps { + iconSize: number; + copyText?: string; + copyTooltipText?: string; + externalLink?: string; + sx?: SxProps; +} + +export function CopyAndExternalIconsSet({ + iconSize, + copyText, + copyTooltipText, + externalLink, + sx, +}: CopyAndExternalIconsSetProps) { + const theme = useTheme(); + + return ( + + {!!copyText && ( + + + svg': { + width: iconSize - 2, + height: iconSize - 2, + }, + path: { + transition: 'all 0.2s ease', + stroke: theme.palette.$textSecondary, + }, + hover: { path: { stroke: theme.palette.$main } }, + }}> + + + + + )} + + {!!externalLink && ( + + + svg': { + width: iconSize, + height: iconSize, + }, + path: { + transition: 'all 0.2s ease', + stroke: theme.palette.$textSecondary, + }, + hover: { path: { stroke: theme.palette.$main } }, + }}> + + + + + )} + + ); +} diff --git a/src/components/CopyToClipboard.tsx b/src/components/CopyToClipboard.tsx new file mode 100644 index 00000000..3d04d276 --- /dev/null +++ b/src/components/CopyToClipboard.tsx @@ -0,0 +1,43 @@ +import { Box } from '@mui/system'; +import React, { ReactNode, useState } from 'react'; +import { CopyToClipboard as CTC } from 'react-copy-to-clipboard'; + +import { texts } from '../helpers/texts/texts'; +import { Tooltip } from './Tooltip'; + +interface CopyToClipboardProps { + copyText: string; + copyTooltipText?: string; + children: ReactNode; +} + +export function CopyToClipboard({ + copyText, + copyTooltipText, + children, +}: CopyToClipboardProps) { + const [copied, setCopied] = useState(false); + + return ( + { + setCopied(true); + setTimeout(() => setCopied(false), 1000); + }}> + + + {copied + ? texts.other.copied + : copyTooltipText || texts.other.copy} + + }> + {children} + + + + ); +} diff --git a/src/components/Link.tsx b/src/components/Link.tsx index c1b4dca6..a19de4d2 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -3,7 +3,7 @@ import NextLink, { LinkProps as NextLinkProps } from 'next/link'; import { ReactNode, useMemo } from 'react'; import { resolve } from 'url'; -import { isForIPFS } from '../appConfig'; +import { isForIPFS } from '../configs/appConfig'; interface LinkProps extends NextLinkProps { title?: string; diff --git a/src/components/NetworkIcon.tsx b/src/components/NetworkIcon.tsx new file mode 100644 index 00000000..b53e2f43 --- /dev/null +++ b/src/components/NetworkIcon.tsx @@ -0,0 +1,55 @@ +import { Box, SxProps } from '@mui/system'; +import { useEffect, useState } from 'react'; +import { toHex } from 'viem'; + +import { chainInfoHelper } from '../configs/configs'; +import { Tooltip } from './Tooltip'; +import ChainIcon from './Web3Icons/ChainIcon'; + +interface NetworkIconProps { + chainId: number; + size?: number; + css?: SxProps; + withTooltip?: boolean; +} + +export function NetworkIcon({ + chainId, + size, + css, + withTooltip, +}: NetworkIconProps) { + const [chain, setChain] = useState( + chainInfoHelper.getChainParameters(chainId), + ); + + useEffect(() => { + if (chainId) { + setChain(chainInfoHelper.getChainParameters(chainId)); + } + }, [chainId]); + + return ( + <> + {withTooltip ? ( + + {chain.name}: {chain.id}
({toHex(chain.id)}) + + }> + +
+ ) : ( + + )} + + ); +} diff --git a/src/components/RocketLoader.tsx b/src/components/RocketLoader.tsx new file mode 100644 index 00000000..ebd885b1 --- /dev/null +++ b/src/components/RocketLoader.tsx @@ -0,0 +1,92 @@ +import RocketLoaderIcon from '../assets/rocketLoader.svg'; +import { IconBox } from './primitives/IconBox'; + +interface RocketLoaderProps { + size?: number; +} + +export function RocketLoader({ size = 77 }: RocketLoaderProps) { + return ( + ({ + width: size, + height: size, + '> svg': { + width: size, + height: size, + }, + '#rocket_fire': { + '@keyframes rocketFire': { + '0%': { + opacity: 1, + }, + '25%': { + opacity: 0, + }, + '50%': { + opacity: 0, + }, + '75%': { + opacity: 0, + }, + '100%': { + opacity: 1, + }, + }, + animation: `rocketFire 1.5s linear infinite`, + }, + '#rocket_fire_m': { + '@keyframes rocketFireM': { + '0%': { + opacity: 0, + }, + '25%': { + opacity: 1, + }, + '50%': { + opacity: 0, + }, + '75%': { + opacity: 1, + }, + '100%': { + opacity: 0, + }, + }, + animation: `rocketFireM 1.5s linear infinite`, + }, + '#rocket_fire_s': { + '@keyframes rocketFireS': { + '0%': { + opacity: 0, + }, + '25%': { + opacity: 0, + }, + '50%': { + opacity: 1, + }, + '75%': { + opacity: 0, + }, + '100%': { + opacity: 0, + }, + }, + animation: `rocketFireS 1.5s linear infinite`, + }, + '.black': { + fill: theme.palette.$mainElements, + }, + '.white_bg_black_stroke': { + fill: theme.palette.$textWhite, + stroke: theme.palette.$mainElements, + }, + '.white': { + fill: theme.palette.$textWhite, + }, + })}> + + + ); +} diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx new file mode 100644 index 00000000..3bd8e4dc --- /dev/null +++ b/src/components/Spinner.tsx @@ -0,0 +1,97 @@ +import { Box, SxProps } from '@mui/system'; + +export interface SpinnerProps { + css?: SxProps; + size: number; + loaderLineColor: string; + loaderCss: SxProps; + lineSize?: number; +} + +export function Spinner({ + css, + size, + loaderLineColor, + loaderCss, + lineSize = 2, +}: SpinnerProps) { + return ( + + + + + + + + + ); +} diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 00000000..633767e1 --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,56 @@ +import { Box, SxProps } from '@mui/system'; +import { ReactNode } from 'react'; + +interface TooltipProps { + children: ReactNode; + tooltipContent: ReactNode; + color?: 'light' | 'dark'; + position?: 'top'; + tooltipCss?: SxProps; +} + +export function Tooltip({ + children, + tooltipContent, + color = 'light', + position, + tooltipCss, +}: TooltipProps) { + return ( + + {children} + + + {tooltipContent} + + + ); +} diff --git a/src/components/Transactions/ActionModal.tsx b/src/components/Transactions/ActionModal.tsx new file mode 100644 index 00000000..b95d6648 --- /dev/null +++ b/src/components/Transactions/ActionModal.tsx @@ -0,0 +1,99 @@ +import { Box, useTheme } from '@mui/system'; +import React, { ReactNode } from 'react'; + +import { texts } from '../../helpers/texts/texts'; +import { useLastTxLocalStatus } from '../../helpers/useLastTxLocalStatus'; +import { TransactionUnion } from '../../store/transactionsSlice'; +import { BigButton, BigButtonProps } from '../BigButton'; +import { BasicActionModal } from './BasicActionModal'; + +interface ActionModalProps + extends Pick, + Pick { + callbackFunction: () => Promise; + isOpen: boolean; + setIsOpen: (value: boolean) => void; + topBlock?: ReactNode; + contentMinHeight?: number; + children: ReactNode; + actionButtonTitle: string; + withCancelButton?: boolean; +} + +export function ActionModal({ + callbackFunction, + isOpen, + setIsOpen, + topBlock, + contentMinHeight, + activeColorType, + children, + actionButtonTitle, + withCancelButton, + type, + payload, +}: ActionModalProps) { + const theme = useTheme(); + + const { + error, + setError, + loading, + isTxStart, + setIsTxStart, + executeTxWithLocalStatuses, + fullTxErrorMessage, + setFullTxErrorMessage, + tx, + } = useLastTxLocalStatus({ type, payload }); + + const handleClick = async () => + await executeTxWithLocalStatuses({ + callbackFunction, + }); + + return ( + + {children} + + + {withCancelButton && ( + setIsOpen(false)} + css={{ + mr: withCancelButton ? 24 : 0, + [theme.breakpoints.up('lg')]: { mr: withCancelButton ? 20 : 0 }, + }}> + {texts.other.cancel} + + )} + handleClick()}> + {actionButtonTitle} + + + + ); +} diff --git a/src/components/Transactions/ActionModalContent.tsx b/src/components/Transactions/ActionModalContent.tsx new file mode 100644 index 00000000..6b2bbb0d --- /dev/null +++ b/src/components/Transactions/ActionModalContent.tsx @@ -0,0 +1,348 @@ +import { + selectTxExplorerLink, + TxLocalStatusTxParams, +} from '@bgd-labs/frontend-web3-utils'; +import { Box, useTheme } from '@mui/system'; +import React, { ReactNode } from 'react'; + +import LinkIcon from '../../assets/icons/linkIcon.svg'; +import RocketError from '../../assets/rocketError.svg'; +import RocketReplaced from '../../assets/rocketReplaced.svg'; +import RocketSuccess from '../../assets/rocketSuccess.svg'; +import { chainInfoHelper } from '../../configs/configs'; +import { texts } from '../../helpers/texts/texts'; +import { useStore } from '../../providers/ZustandStoreProvider'; +import { TransactionUnion } from '../../store/transactionsSlice'; +import { BigButton } from '../BigButton'; +import { Link } from '../Link'; +import { IconBox } from '../primitives/IconBox'; +import { RocketLoader } from '../RocketLoader'; +import { CopyErrorButton } from './CopyErrorButton'; + +export interface ActionModalContentProps { + topBlock?: ReactNode; + setIsOpen: (value: boolean) => void; + contentMinHeight?: number; + children: ReactNode; + isTxStart: boolean; + setIsTxStart: (value: boolean) => void; + error: Error | string; + setError: (value: string) => void; + successElement?: ReactNode; + closeButtonText?: string; + withoutTryAgainWhenError?: boolean; + fullTxErrorMessage?: string; + tx?: TxLocalStatusTxParams; +} + +export function ActionModalContent({ + topBlock, + setIsOpen, + contentMinHeight = 240, + children, + isTxStart, + setIsTxStart, + setError, + successElement, + closeButtonText, + withoutTryAgainWhenError, + fullTxErrorMessage, + tx, +}: ActionModalContentProps) { + const theme = useTheme(); + const transactionsPool = useStore((store) => store.transactionsPool); + + const rocketSize = 77; + + const isFinalStatus = + !tx?.pending && (tx?.isError || tx?.isSuccess || tx?.isReplaced); + + return ( + <> + {topBlock} + + + {isTxStart ? ( + <> + + + {tx?.pending && ( + + + + )} + {tx?.isError && ( + svg': { + width: rocketSize, + height: rocketSize, + }, + }}> + + + )} + {tx?.isSuccess && ( + svg': { + width: rocketSize, + height: rocketSize, + }, + }}> + + + )} + {tx?.isReplaced && ( + svg': { + width: rocketSize, + height: rocketSize, + }, + }}> + + + )} + + + + {tx?.pending && texts.transactions.pending} + {tx?.isSuccess && texts.transactions.success} + {tx?.isError && texts.transactions.error} + {tx?.isReplaced && texts.transactions.replaced} + + + {tx?.pending && texts.transactions.pendingDescription} + {tx?.isSuccess && !!successElement + ? successElement + : tx?.isSuccess && texts.transactions.executed} + {tx?.isError && texts.transactions.notExecuted} + {tx?.isReplaced && texts.transactions.txReplaced} + + + + + {tx?.isError && ( + + {fullTxErrorMessage && ( + + )} + + )} + + {tx?.hash && tx?.walletType && ( + + + + {tx.replacedTxHash + ? texts.other.transactionHash + : texts.other.viewOnExplorer} + + svg': { + width: 12, + height: 12, + }, + ml: 4, + position: 'relative', + }}> + + + + + )} + {tx?.isReplaced && tx?.replacedTxHash && tx?.hash && ( + + {tx.chainId && ( + + + {texts.other.replacedTransactionHash} + + svg': { + width: 12, + height: 12, + }, + ml: 4, + position: 'relative', + }}> + + + + )} + + )} + + + + + {(tx?.isSuccess || tx?.isReplaced) && ( + setIsOpen(false)}> + {closeButtonText || texts.other.close} + + )} + {tx?.isError && ( + <> + setIsOpen(false)}> + {closeButtonText || texts.other.close} + + {!withoutTryAgainWhenError && ( + { + setIsTxStart(false); + setError(''); + }}> + {texts.transactions.tryAgain} + + )} + + )} + + + ) : ( + <>{children} + )} + + + ); +} diff --git a/src/components/Transactions/BasicActionModal.tsx b/src/components/Transactions/BasicActionModal.tsx new file mode 100644 index 00000000..1a5875d5 --- /dev/null +++ b/src/components/Transactions/BasicActionModal.tsx @@ -0,0 +1,67 @@ +import { SxProps } from '@mui/system'; +import React, { useEffect } from 'react'; + +import { BasicModal } from '../BasicModal'; +import { + ActionModalContent, + ActionModalContentProps, +} from './ActionModalContent'; + +export interface BasicActionModalProps extends ActionModalContentProps { + isOpen: boolean; + setFullTxErrorMessage: (value: string) => void; + withMinHeight?: boolean; + minHeight?: number; + contentCss?: SxProps; +} + +export function BasicActionModal({ + isOpen, + setIsOpen, + topBlock, + contentMinHeight, + children, + isTxStart, + setIsTxStart, + error, + setError, + successElement, + withoutTryAgainWhenError, + fullTxErrorMessage, + setFullTxErrorMessage, + tx, + withMinHeight, + minHeight, + contentCss, +}: BasicActionModalProps) { + useEffect(() => { + setIsTxStart(false); + setError(''); + setFullTxErrorMessage(''); + }, [isOpen]); + + return ( + + + {children} + + + ); +} diff --git a/src/components/Transactions/CopyErrorButton.tsx b/src/components/Transactions/CopyErrorButton.tsx new file mode 100644 index 00000000..a8ab0262 --- /dev/null +++ b/src/components/Transactions/CopyErrorButton.tsx @@ -0,0 +1,56 @@ +import { Box, useTheme } from '@mui/system'; +import React from 'react'; + +import CopyIcon from '../../assets/icons/copy.svg'; +import { texts } from '../../helpers/texts/texts'; +import { CopyToClipboard } from '../CopyToClipboard'; +import { IconBox } from '../primitives/IconBox'; + +interface CopyErrorButtonProps { + errorMessage: Error | string; +} + +export function CopyErrorButton({ errorMessage }: CopyErrorButtonProps) { + const theme = useTheme(); + + return ( + + + {texts.other.copyError} + svg': { + width: 9, + height: 9, + }, + ml: 4, + path: { + transition: 'all 0.2s ease', + stroke: theme.palette.$textSecondary, + }, + }}> + + + + + ); +} diff --git a/src/components/Transactions/TransactionInfoItem.tsx b/src/components/Transactions/TransactionInfoItem.tsx new file mode 100644 index 00000000..7635849e --- /dev/null +++ b/src/components/Transactions/TransactionInfoItem.tsx @@ -0,0 +1,433 @@ +import { + selectTxExplorerLink, + TransactionStatus, +} from '@bgd-labs/frontend-web3-utils'; +import { Box, useTheme } from '@mui/system'; +import dayjs from 'dayjs'; +import React from 'react'; + +import ArrowRightIcon from '../../assets/icons/arrowRight.svg'; +import CheckIcon from '../../assets/icons/check.svg'; +import CrossIcon from '../../assets/icons/cross.svg'; +import ReplacedIcon from '../../assets/icons/replacedIcon.svg'; +import { appConfig } from '../../configs/appConfig'; +import { chainInfoHelper } from '../../configs/configs'; +import { getScanLink } from '../../helpers/getScanLink'; +import { texts } from '../../helpers/texts/texts'; +import { useStore } from '../../providers/ZustandStoreProvider'; +import { TxType, TxWithStatus } from '../../store/transactionsSlice'; +import { textCenterEllipsis } from '../../styles/text-center-ellipsis'; +import { ChainNameWithIcon } from '../ChainNameWithIcon'; +import { CopyAndExternalIconsSet } from '../CopyAndExternalIconsSet'; +import { Link } from '../Link'; +import { IconBox } from '../primitives/IconBox'; +import { Spinner } from '../Spinner'; + +interface TransactionInfoItemProps { + tx: TxWithStatus; +} + +export function TransactionInfoItem({ tx }: TransactionInfoItemProps) { + const theme = useTheme(); + + const transactionsPool = useStore((store) => store.transactionsPool); + const activeWallet = useStore((store) => store.activeWallet); + + const NetworkIconWitchChainN = () => { + return ( + + ); + }; + + const NetworkIconWitchGovCoreChainN = () => { + return ( + + ); + }; + + return ( + + + {tx.localTimestamp && ( + + {dayjs.unix(tx.localTimestamp).format('MMM D, h:mm A')} + + )} + + + + + {tx.type === TxType.test && <>{texts.transactions.testTransaction}} + {tx.type === TxType.createPayload && tx.payload && ( + <> + {texts.transactions.createPayloadTx}{' '} + #{tx.payload.payloadId} on + + )} + {tx.type === TxType.createProposal && tx.payload && ( + <> + {texts.transactions.createProposalTx}{' '} + #{tx.payload.proposalId} on + + )} + {tx.type === TxType.activateVoting && tx.payload && ( + <> + {texts.transactions.activateVotingTx}{' '} + #{tx.payload.proposalId} on + + )} + {tx.type === TxType.sendProofs && tx.payload && ( + <> + {texts.transactions.sendProofsTx}{' '} + {/*{getAssetName(tx.payload.underlyingAsset)} for the proposal{' '}*/} + #{tx.payload.proposalId}, on + + )} + {tx.type === TxType.activateVotingOnVotingMachine && tx.payload && ( + <> + {texts.transactions.activateVotingOnVotingMachineTx}{' '} + #{tx.payload.proposalId}, on + + )} + {tx.type === TxType.vote && tx.payload && ( + <> + {texts.transactions.voteTx}{' '} + {tx.payload.support ? 'for' : 'against'}{' '} + {tx.payload.voter !== activeWallet?.address && ( + <> + {texts.transactions.voteTxAsRepresentative}{' '} + + + {textCenterEllipsis(tx.payload.voter, 6, 4)} + + + + + )}{' '} + for the proposal #{tx.payload.proposalId} on{' '} + + + )} + {tx.type === TxType.closeAndSendVote && tx.payload && ( + <> + {texts.transactions.closeVoteTx} #{tx.payload.proposalId}{' '} + on {' '} + {texts.transactions.sendVoteResultsTx}{' '} + + + )} + {tx.type === TxType.executeProposal && tx.payload && ( + <> + {texts.transactions.executeProposalTx}{' '} + #{tx.payload.proposalId} on + + )} + {tx.type === TxType.executePayload && tx.payload && ( + <> + {texts.transactions.executePayloadTx}{' '} + #{tx.payload.payloadId} on + + )} + {/*{tx.type === TxType.delegate && tx.payload && (*/} + {/* <>*/} + {/* {' '}*/} + {/* on */} + {/* */} + {/*)}*/} + {tx.type === TxType.cancelProposal && tx.payload && ( + <> + {texts.transactions.cancelProposalTx}{' '} + #{tx.payload.proposalId} on + + )} + {/*{tx.type === TxType.representations && tx.payload && (*/} + {/* <>*/} + {/* {' '}*/} + {/* on */} + {/* */} + {/*)}*/} + {tx.type === TxType.claimFees && tx.payload && ( + <> + {texts.creationFee.claimGuaranteeTxInfo( + tx.payload.proposalIds.length, + )}{' '} + {tx.payload.proposalIds.map((id) => ( + {id} + ))}{' '} + on + + )} + + + + + {tx.pending && ( + + )} + {tx.status && ( + svg': { + width: 16, + height: 16, + }, + ellipse: { + fill: + tx.status === TransactionStatus.Replaced + ? theme.palette.$textSecondary + : undefined, + }, + path: { + stroke: + tx.status === TransactionStatus.Success + ? theme.palette.$mainFor + : tx.status === TransactionStatus.Replaced + ? undefined + : theme.palette.$mainAgainst, + fill: + tx.status === TransactionStatus.Replaced + ? theme.palette.$textSecondary + : undefined, + '&:last-of-type': { + stroke: + tx.status === TransactionStatus.Success + ? theme.palette.$mainFor + : tx.status === TransactionStatus.Replaced + ? theme.palette.$textSecondary + : theme.palette.$mainAgainst, + }, + }, + }}> + {tx.status === TransactionStatus.Success ? ( + + ) : tx.status === TransactionStatus.Replaced ? ( + + ) : ( + + )} + + )} + + + + + + {tx.hash && ( + + + + {textCenterEllipsis(tx.hash, 5, 5)} + + + + + + )} + + {tx.replacedTxHash && ( + + svg': { + width: 12, + height: 12, + }, + path: { + transition: 'all 0.2s ease', + stroke: theme.palette.$text, + }, + }}> + + + + )} + + {tx.replacedTxHash && tx.hash && ( + + + + {textCenterEllipsis(tx.replacedTxHash, 5, 5)} + + + + + + )} + + + ); +} diff --git a/src/components/Transactions/TransactionsModal.tsx b/src/components/Transactions/TransactionsModal.tsx new file mode 100644 index 00000000..07268f80 --- /dev/null +++ b/src/components/Transactions/TransactionsModal.tsx @@ -0,0 +1,48 @@ +import { selectAllTransactionsByWallet } from '@bgd-labs/frontend-web3-utils'; +import React from 'react'; + +import { useStore } from '../../providers/ZustandStoreProvider'; +import { TransactionUnion, TxType } from '../../store/transactionsSlice'; +import { BasicModal } from '../BasicModal'; +import { TransactionsModalContent } from './TransactionsModalContent'; + +interface TransactionsModalProps { + isOpen: boolean; + setIsOpen: (value: boolean) => void; +} + +export function TransactionsModal({ + isOpen, + setIsOpen, +}: TransactionsModalProps) { + const activeWallet = useStore((store) => store.activeWallet); + const transactionsPool = useStore((store) => store.transactionsPool); + + const allTransactions = activeWallet + ? selectAllTransactionsByWallet( + transactionsPool, + activeWallet.address, + ) + : []; + + return ( + + + !!Object.keys(TxType).find((key) => key === tx.type)?.length, + ) + .sort((a, b) => b.localTimestamp - a.localTimestamp)} + onBackButtonClick={() => { + setIsOpen(false); + // setAccountInfoModalOpen(true); + }} + /> + + ); +} diff --git a/src/components/Transactions/TransactionsModalContent.tsx b/src/components/Transactions/TransactionsModalContent.tsx new file mode 100644 index 00000000..ca88bfec --- /dev/null +++ b/src/components/Transactions/TransactionsModalContent.tsx @@ -0,0 +1,65 @@ +import { Box } from '@mui/system'; +import React from 'react'; + +import { texts } from '../../helpers/texts/texts'; +import { AllTransactions } from '../../store/transactionsSlice'; +import { BackButton3D } from '../BackButton3D'; +import { Divider } from '../primitives/Divider'; +import { TransactionInfoItem } from './TransactionInfoItem'; + +interface TransactionsModalContentProps { + allTransactions: AllTransactions; + onBackButtonClick: () => void; + forTest?: boolean; +} + +export function TransactionsModalContent({ + allTransactions, + onBackButtonClick, + forTest, +}: TransactionsModalContentProps) { + return ( + + + {texts.transactions.allTransactions} + + + ({ + my: 14, + borderBottomColor: theme.palette.$secondaryBorder, + width: '100%', + })} + /> + + ({ + overflowY: forTest ? 'scroll' : 'unset', + pr: forTest ? 20 : 0, + height: forTest ? 191 : '100%', + [theme.breakpoints.up('sm')]: { + overflowY: 'scroll', + pr: 20, + height: forTest ? 128 : 510, + }, + [theme.breakpoints.up('lg')]: { + height: forTest ? 139 : 580, + }, + })}> + {allTransactions.map((tx, index) => ( + + ))} + + + + + + + ); +} diff --git a/src/components/Web3/BlockTitleWithTooltip.tsx b/src/components/Web3/BlockTitleWithTooltip.tsx new file mode 100644 index 00000000..48f7d85f --- /dev/null +++ b/src/components/Web3/BlockTitleWithTooltip.tsx @@ -0,0 +1,105 @@ +import { Popover } from '@headlessui/react'; +import { Box, useTheme } from '@mui/system'; +import React from 'react'; + +import InfoIcon from '../../assets/icons/info.svg'; +import { IconBox } from '../primitives/IconBox'; + +interface BlockTitleWithTooltipProps { + title: string; + description: string; + isPopoverVisible?: boolean; + onClick?: () => void; + isClicked?: boolean; + isRed?: boolean; +} + +export function BlockTitleWithTooltip({ + title, + description, + isPopoverVisible = true, + onClick, + isClicked, + isRed, +}: BlockTitleWithTooltipProps) { + const theme = useTheme(); + + return ( + + + {title} + + + {isPopoverVisible && ( + + *': { + lineHeight: 0, + }, + hover: { opacity: 0.6 }, + }}> + svg': { + width: 14, + height: 14, + path: { + fill: isClicked + ? theme.palette.$text + : isRed + ? theme.palette.$error + : theme.palette.$text, + }, + }, + }}> + + + + + + {description} + + + )} + + ); +} diff --git a/src/components/Web3/ChainsIcons.tsx b/src/components/Web3/ChainsIcons.tsx new file mode 100644 index 00000000..d91997e2 --- /dev/null +++ b/src/components/Web3/ChainsIcons.tsx @@ -0,0 +1,38 @@ +import { Box, useTheme } from '@mui/system'; + +import { NetworkIcon } from '../NetworkIcon'; + +interface ChainsIconsProps { + chains: number[]; + alwaysVisible?: boolean; +} + +export function ChainsIcons({ chains, alwaysVisible }: ChainsIconsProps) { + const theme = useTheme(); + + if (chains.length <= 1 && !alwaysVisible) return null; + + return ( + + {chains.map((chainId, index) => ( + + ))} + + ); +} diff --git a/src/components/Web3/wallet/AccountAddressInfo.tsx b/src/components/Web3/wallet/AccountAddressInfo.tsx new file mode 100644 index 00000000..79d94b9a --- /dev/null +++ b/src/components/Web3/wallet/AccountAddressInfo.tsx @@ -0,0 +1,83 @@ +import { Box, useTheme } from '@mui/system'; +import makeBlockie from 'ethereum-blockies-base64'; +import React from 'react'; + +import { getScanLink } from '../../../helpers/getScanLink'; +import { texts } from '../../../helpers/texts/texts'; +import { CopyAndExternalIconsSet } from '../../CopyAndExternalIconsSet'; +import { Divider } from '../../primitives/Divider'; +import { Image } from '../../primitives/Image'; + +interface AccountAddressInfoProps { + activeAddress: string; + chainId: number; + ensNameAbbreviated?: string; + ensAvatar?: string; + isAvatarExists?: boolean; + forTest?: boolean; + onDisconnectButtonClick: () => void; +} + +export function AccountAddressInfo({ + isAvatarExists, + forTest, + activeAddress, + ensAvatar, + ensNameAbbreviated, + chainId, + onDisconnectButtonClick, +}: AccountAddressInfoProps) { + const theme = useTheme(); + + return ( + <> + + + + + + + {ensNameAbbreviated} + + + + + + + + {texts.walletConnect.disconnect} + + + + + + ); +} diff --git a/src/components/Web3/wallet/AccountInfoModal.tsx b/src/components/Web3/wallet/AccountInfoModal.tsx new file mode 100644 index 00000000..c256ed4f --- /dev/null +++ b/src/components/Web3/wallet/AccountInfoModal.tsx @@ -0,0 +1,73 @@ +import { selectAllTransactionsByWallet } from '@bgd-labs/frontend-web3-utils'; +import React from 'react'; +import { zeroAddress } from 'viem'; + +import { appConfig } from '../../../configs/appConfig'; +import { useStore } from '../../../providers/ZustandStoreProvider'; +import { RepresentedAddress } from '../../../types'; +import { BasicModal } from '../../BasicModal'; +import { AccountInfoModalContent } from './AccountInfoModalContent'; + +interface AccountInfoModalProps { + isOpen: boolean; + setIsOpen: (value: boolean) => void; + setAllTransactionModalOpen: (value: boolean) => void; + ensName?: string; + ensAvatar?: string; + isAvatarExists?: boolean; + representedAddresses?: RepresentedAddress[]; + onDisconnectButtonClick: () => void; +} + +export function AccountInfoModal({ + isOpen, + setIsOpen, + setAllTransactionModalOpen, + ensName, + ensAvatar, + isAvatarExists, + representedAddresses, + onDisconnectButtonClick, +}: AccountInfoModalProps) { + const activeWallet = useStore((store) => store.activeWallet); + const allTxsFromStore = useStore((store) => + selectAllTransactionsByWallet( + store.transactionsPool, + activeWallet?.address || zeroAddress, + ), + ); + + const allTransactions = activeWallet ? allTxsFromStore : []; + + return ( + setTimeout(() => setIsOpen(value), 1)} + withCloseButton + maxWidth={690}> + b.localTimestamp - a.localTimestamp, + )} + onDelegateButtonClick={() => setIsOpen(false)} + onRepresentationsButtonClick={() => setIsOpen(false)} + onReturnFeeButtonClick={() => { + setIsOpen(false); + }} + onDisconnectButtonClick={onDisconnectButtonClick} + onAllTransactionButtonClick={() => { + setIsOpen(false); + setAllTransactionModalOpen(true); + }} + representedAddresses={representedAddresses} + /> + + ); +} diff --git a/src/components/Web3/wallet/AccountInfoModalContent.tsx b/src/components/Web3/wallet/AccountInfoModalContent.tsx new file mode 100644 index 00000000..fc3dd87e --- /dev/null +++ b/src/components/Web3/wallet/AccountInfoModalContent.tsx @@ -0,0 +1,312 @@ +import { Box, useTheme } from '@mui/system'; +import React from 'react'; + +import ClaimFeeIcon from '../../../assets/icons/claimFee.svg'; +import DelegationIcon from '../../../assets/icons/delegationIcon.svg'; +import RepresentationIcon from '../../../assets/representation/representationVotingPower.svg'; +import { ROUTES } from '../../../configs/routes'; +import { texts } from '../../../helpers/texts/texts'; +import { useStore } from '../../../providers/ZustandStoreProvider'; +import { AllTransactions, TxType } from '../../../store/transactionsSlice'; +import { textCenterEllipsis } from '../../../styles/text-center-ellipsis'; +import { media } from '../../../styles/themeMUI'; +import { useMediaQuery } from '../../../styles/useMediaQuery'; +import { RepresentedAddress } from '../../../types'; +import { Link } from '../../Link'; +import { Divider } from '../../primitives/Divider'; +import { IconBox } from '../../primitives/IconBox'; +import { TransactionInfoItem } from '../../Transactions/TransactionInfoItem'; +import { AccountAddressInfo } from './AccountAddressInfo'; + +interface AccountInfoModalContentProps { + activeAddress: string; + chainId: number; + isActive: boolean; + allTransactions: AllTransactions; + onAllTransactionButtonClick: () => void; + onDisconnectButtonClick: () => void; + onDelegateButtonClick: () => void; + onRepresentationsButtonClick: () => void; + onReturnFeeButtonClick?: () => void; + ensName?: string; + ensAvatar?: string; + isAvatarExists?: boolean; + forTest?: boolean; + representedAddresses?: RepresentedAddress[]; +} + +type internalLink = { + forTest?: boolean; + onClick: () => void; + route?: string; + title: string; + iconType?: 'delegate' | 'representation' | 'creationFee'; +}; + +function InternalLink({ + onClick, + route, + forTest, + title, + iconType, +}: internalLink) { + const theme = useTheme(); + + return ( + + svg': { + width: 16, + height: 16, + }, + }}> + {!!iconType && iconType === 'delegate' ? ( + + ) : !!iconType && iconType === 'representation' ? ( + + ) : !!iconType && iconType === 'creationFee' ? ( + + ) : ( + <> + )} + + + {!forTest && !!route ? ( + + + {title} + + + ) : ( + + {title} + + )} + + ); +} + +export function AccountInfoModalContent({ + activeAddress, + chainId, + isActive, + allTransactions, + onAllTransactionButtonClick, + onDisconnectButtonClick, + onDelegateButtonClick, + onRepresentationsButtonClick, + onReturnFeeButtonClick, + forTest, + ensName, + ensAvatar, + isAvatarExists, + representedAddresses, +}: AccountInfoModalContentProps) { + const theme = useTheme(); + const sm = useMediaQuery(media.sm); + const appMode = useStore((store) => store.appMode); + + const ensNameAbbreviated = ensName + ? ensName.length > 30 + ? textCenterEllipsis(ensName, sm ? 10 : 6, sm ? 10 : 6) + : ensName + : undefined; + + const isRepresentedAvailable = + typeof representedAddresses !== 'undefined' && + !!representedAddresses.length; + + const filteredTransactions = allTransactions.filter( + (tx) => !!Object.keys(TxType).find((key) => key === tx.type)?.length, + ); + + const visibleTxCount = forTest ? 1 : isRepresentedAvailable ? 3 : 4; + + return ( + <> + + + + + + + + + + {!!onReturnFeeButtonClick && appMode === 'expert' && ( + + )} + + + + + {/*{isRepresentedAvailable && (*/} + {/* */} + {/*)}*/} + + {/*{!forTest && }*/} + + {isActive && !!filteredTransactions.length && ( + + + + {filteredTransactions.length === 0 + ? texts.walletConnect.transactions + : texts.walletConnect.lastTransaction( + filteredTransactions.length, + )} + + + + + + {filteredTransactions + .slice(0, visibleTxCount) + .map((tx, index) => ( + + ))} + + + {filteredTransactions.length > visibleTxCount && ( + + {texts.walletConnect.allTransactions} + + )} + + + )} + + ); +} diff --git a/src/components/Web3/wallet/ConnectWalletButton.tsx b/src/components/Web3/wallet/ConnectWalletButton.tsx new file mode 100644 index 00000000..76f6d0b0 --- /dev/null +++ b/src/components/Web3/wallet/ConnectWalletButton.tsx @@ -0,0 +1,382 @@ +import { + selectAllTransactionsByWallet, + selectPendingTransactionByWallet, + TransactionStatus, +} from '@bgd-labs/frontend-web3-utils'; +import { Box, useTheme } from '@mui/system'; +import dayjs from 'dayjs'; +import makeBlockie from 'ethereum-blockies-base64'; +import React, { useEffect, useState } from 'react'; +import { zeroAddress } from 'viem'; + +import SuccessIcon from '../../../assets/icons/check.svg'; +import ErrorIcon from '../../../assets/icons/cross.svg'; +import { appConfig } from '../../../configs/appConfig'; +import { getLocalStorageLastConnectedWallet } from '../../../configs/localStorage'; +import { texts } from '../../../helpers/texts/texts'; +import { useStore } from '../../../providers/ZustandStoreProvider'; +import { textCenterEllipsis } from '../../../styles/text-center-ellipsis'; +import { media } from '../../../styles/themeMUI'; +import { useMediaQuery } from '../../../styles/useMediaQuery'; +import { RepresentativeAddress } from '../../../types'; +import { ChainNameWithIcon } from '../../ChainNameWithIcon'; +import { CustomSkeleton } from '../../primitives/CustomSkeleton'; +import { IconBox } from '../../primitives/IconBox'; +import { Image } from '../../primitives/Image'; +import { Spinner } from '../../Spinner'; + +interface ConnectWalletButtonProps { + onClick: () => void; + ensName?: string; + ensAvatar?: string; + isAvatarExists?: boolean; + representative?: RepresentativeAddress; +} + +export function ConnectWalletButton({ + onClick, + ensName, + ensAvatar, + isAvatarExists, + // representative, +}: ConnectWalletButtonProps) { + const walletActivating = useStore((store) => store.walletActivating); + const walletConnectedTimeLock = useStore( + (store) => store.walletConnectedTimeLock, + ); + const activeWallet = useStore((store) => store.activeWallet); + + const theme = useTheme(); + const lg = useMediaQuery(media.lg); + + const isActive = activeWallet?.isActive; + const activeAddress = activeWallet?.address || ''; + + const allTxsFromStore = useStore((store) => + selectAllTransactionsByWallet( + store.transactionsPool, + activeAddress || zeroAddress, + ), + ); + const pendingTxsFromStore = useStore((store) => + selectPendingTransactionByWallet( + store.transactionsPool, + activeAddress || zeroAddress, + ), + ); + + const [loading, setLoading] = useState(true); + + const allTransactions = activeAddress ? allTxsFromStore : []; + const lastTransaction = allTransactions[allTransactions.length - 1]; + + const ensNameAbbreviated = ensName + ? ensName.length > 11 + ? textCenterEllipsis(ensName, 5, 3) + : ensName + : undefined; + + const [lastTransactionSuccess, setLastTransactionSuccess] = useState(false); + const [lastTransactionError, setLastTransactionError] = useState(false); + + useEffect(() => { + if (lastTransaction?.status && activeWallet && !walletConnectedTimeLock) { + if (lastTransaction.status === TransactionStatus.Success) { + setLastTransactionSuccess(true); + setTimeout(() => setLastTransactionSuccess(false), 1000); + } else if (lastTransaction.status === TransactionStatus.Reverted) { + setLastTransactionError(true); + setTimeout(() => setLastTransactionError(false), 1000); + } + } + }, [lastTransaction]); + + const lastConnectedWallet = getLocalStorageLastConnectedWallet(); + + useEffect(() => { + if (!!lastConnectedWallet || !activeWallet) { + setLoading(false); + } + }, [lastConnectedWallet]); + + // get all pending tx's from connected wallet + const allPendingTransactions = activeAddress ? pendingTxsFromStore : []; + // filtered pending tx's, if now > tx.timestamp + 30 min, than remove tx from pending array to not show loading spinner in connect wallet button + const filteredPendingTx = allPendingTransactions.filter( + (tx) => dayjs().unix() <= dayjs.unix(tx.localTimestamp).unix() + 1800, + ); + + return ( + <> + {loading ? ( + <> + + + + + ) : ( + <> + {!isActive ? ( + + + {walletActivating + ? texts.walletConnect.connectButtonConnecting + : texts.walletConnect.connectButtonConnect} + + {walletActivating && ( + + + + )} + + ) : ( + + + + + {lastTransactionError && ( + svg': { + width: 13, + height: 13, + }, + mr: 5, + path: { stroke: theme.palette.$textWhite }, + }}> + + + )} + {lastTransactionSuccess && ( + svg': { + width: 15, + height: 15, + }, + mr: 5, + path: { stroke: theme.palette.$textWhite }, + }}> + + + )} + + + {lastTransactionError + ? 'Error' + : lastTransactionSuccess + ? 'Success' + : ensNameAbbreviated + ? ensNameAbbreviated + : textCenterEllipsis(activeAddress, 4, 4)} + + + {!!filteredPendingTx.length && ( + + )} + + + + + {/*{!!representative?.address && }*/} + + )} + + )} + + ); +} diff --git a/src/components/Web3/wallet/ConnectWalletModal.tsx b/src/components/Web3/wallet/ConnectWalletModal.tsx new file mode 100644 index 00000000..c5debf62 --- /dev/null +++ b/src/components/Web3/wallet/ConnectWalletModal.tsx @@ -0,0 +1,81 @@ +import { WalletType } from '@bgd-labs/frontend-web3-utils'; +import { getWeb3WalletName } from '@bgd-labs/react-web3-icons/dist/utils'; +import { useEffect, useState } from 'react'; + +import { useStore } from '../../../providers/ZustandStoreProvider'; +import { BasicModal } from '../../BasicModal'; +import { ConnectWalletModalContent } from './ConnectWalletModalContent'; +import { Wallet } from './WalletItem'; + +interface ConnectWalletModalProps { + isOpen: boolean; + setIsOpen: (value: boolean) => void; +} + +export const wallets: Wallet[] = [ + { + walletType: WalletType.Injected, + walletName: getWeb3WalletName(), + isVisible: true, + }, + { + walletType: WalletType.Coinbase, + walletName: 'Coinbase Wallet', + isVisible: true, + }, + { + walletType: WalletType.WalletConnect, + walletName: 'WalletConnect', + isVisible: true, + }, + { + walletType: WalletType.Safe, + walletName: 'Safe Wallet', + isVisible: typeof window !== 'undefined' && window !== window.parent, + }, + { + walletType: WalletType.Impersonated, + walletName: 'Impersonated Wallet', + isVisible: true, + }, +]; + +export function ConnectWalletModal({ + isOpen, + setIsOpen, +}: ConnectWalletModalProps) { + const walletActivating = useStore((store) => store.walletActivating); + const walletConnectionError = useStore( + (store) => store.walletConnectionError, + ); + + const [impersonatedFormOpen, setImpersonatedFormOpen] = useState(false); + + useEffect(() => { + setImpersonatedFormOpen(false); + }, [isOpen]); + + useEffect(() => { + if (!walletActivating && !walletConnectionError) { + setIsOpen(false); + } + }, [walletActivating, walletConnectionError]); + + return ( + setImpersonatedFormOpen(false) : undefined + }> + + + ); +} diff --git a/src/components/Web3/wallet/ConnectWalletModalContent.tsx b/src/components/Web3/wallet/ConnectWalletModalContent.tsx new file mode 100644 index 00000000..8cf57689 --- /dev/null +++ b/src/components/Web3/wallet/ConnectWalletModalContent.tsx @@ -0,0 +1,150 @@ +import { Box } from '@mui/system'; +import React from 'react'; + +import { texts } from '../../../helpers/texts/texts'; +import { BoxWith3D } from '../../BoxWith3D'; +import { RocketLoader } from '../../RocketLoader'; +import { ImpersonatedForm } from './ImpersonatedForm'; +import { Wallet, WalletItem } from './WalletItem'; + +interface ConnectWalletModalContentProps { + walletActivating: boolean; + wallets: Wallet[]; + onWalletButtonClick?: () => void; + walletConnectionError?: string; + withoutHelpText?: boolean; + impersonatedFormOpen?: boolean; + setImpersonatedFormOpen?: (value: boolean) => void; +} + +export function ConnectWalletModalContent({ + walletActivating, + wallets, + onWalletButtonClick, + walletConnectionError, + withoutHelpText, + impersonatedFormOpen, + setImpersonatedFormOpen, +}: ConnectWalletModalContentProps) { + return ( + + + + {texts.walletConnect.connectWallet} + + + + {walletActivating ? ( + + + + + {texts.walletConnect.connecting} + + + {texts.walletConnect.walletConfirmation} + + + ) : ( + <> + {impersonatedFormOpen && !!setImpersonatedFormOpen ? ( + + ) : ( + <> + {wallets.map((wallet) => ( + + {wallet.isVisible && ( + + )} + + ))} + + )} + + )} + + {walletConnectionError && ( + + + {walletConnectionError} + + + )} + + + + {!withoutHelpText && ( + + + {texts.walletConnect.needHelpDescription} + + + )} + + ); +} diff --git a/src/components/Web3/wallet/ImpersonatedForm.tsx b/src/components/Web3/wallet/ImpersonatedForm.tsx new file mode 100644 index 00000000..8dc65c7b --- /dev/null +++ b/src/components/Web3/wallet/ImpersonatedForm.tsx @@ -0,0 +1,78 @@ +import { WalletType } from '@bgd-labs/frontend-web3-utils'; +import { Box } from '@mui/system'; +import React from 'react'; +import { Field, Form } from 'react-final-form'; + +import { appConfig } from '../../../configs/appConfig'; +import { texts } from '../../../helpers/texts/texts'; +import { useStore } from '../../../providers/ZustandStoreProvider'; +import { BigButton } from '../../BigButton'; +import { Input } from '../../primitives/Input'; + +export function ImpersonatedForm() { + const impersonated = useStore((store) => store.impersonated); + const setImpersonated = useStore((store) => store.setImpersonated); + const connectWallet = useStore((store) => store.connectWallet); + + const handleFormSubmit = async ({ + impersonatedAddress, + }: { + impersonatedAddress: string; + }) => { + setImpersonated(impersonatedAddress); + await connectWallet(WalletType.Impersonated, appConfig.govCoreChainId); + }; + + return ( + + + + onSubmit={handleFormSubmit} + initialValues={{ + impersonatedAddress: impersonated?.address, + }}> + {({ handleSubmit }) => ( + + + {(props) => ( + + )} + + + + {texts.walletConnect.impersonatedButtonTitle} + + + + )} + + + + ); +} diff --git a/src/components/Web3/wallet/WalletItem.tsx b/src/components/Web3/wallet/WalletItem.tsx new file mode 100644 index 00000000..d9e38f1a --- /dev/null +++ b/src/components/Web3/wallet/WalletItem.tsx @@ -0,0 +1,63 @@ +import { WalletType } from '@bgd-labs/frontend-web3-utils'; +import { Box } from '@mui/system'; +import React from 'react'; + +import { useStore } from '../../../providers/ZustandStoreProvider'; +import { BoxWith3D } from '../../BoxWith3D'; +import WalletIcon from '../../Web3Icons/WalletIcon'; + +export type Wallet = { + walletType: WalletType; + walletName: string; + onClick?: () => void; + isVisible?: boolean; + setOpenImpersonatedForm?: (value: boolean) => void; +}; + +export function WalletItem({ + walletType, + walletName, + onClick, + setOpenImpersonatedForm, +}: Wallet) { + const connectWallet = useStore((state) => state.connectWallet); + + const iconSize = 28; + + const handleWalletClick = async () => { + if (walletType === WalletType.Impersonated && setOpenImpersonatedForm) { + setOpenImpersonatedForm(true); + } else { + await connectWallet(walletType); + } + }; + + return ( + + + + {walletName} + + + + + ); +} diff --git a/src/components/Web3/wallet/WalletWidget.tsx b/src/components/Web3/wallet/WalletWidget.tsx new file mode 100644 index 00000000..9152ddb0 --- /dev/null +++ b/src/components/Web3/wallet/WalletWidget.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react'; +import { Hex } from 'viem'; + +import { useStore } from '../../../providers/ZustandStoreProvider'; +import { selectENSAvatar } from '../../../store/selectors/ensSelectors'; +import { TransactionsModal } from '../../Transactions/TransactionsModal'; +import { AccountInfoModal } from './AccountInfoModal'; +import { ConnectWalletButton } from './ConnectWalletButton'; +import { ConnectWalletModal } from './ConnectWalletModal'; + +export function WalletWidget() { + // const appMode = useStore((store) => store.appMode); + const activeWallet = useStore((store) => store.activeWallet); + const fetchEnsAvatarByAddress = useStore( + (store) => store.fetchEnsAvatarByAddress, + ); + const resetWalletConnectionError = useStore( + (store) => store.resetWalletConnectionError, + ); + const ensData = useStore((store) => store.ensData); + const fetchEnsNameByAddress = useStore( + (store) => store.fetchEnsNameByAddress, + ); + const disconnectActiveWallet = useStore( + (store) => store.disconnectActiveWallet, + ); + + const activeAddress = activeWallet?.address || ''; + + const [shownUserName, setShownUserName] = useState( + activeAddress, + ); + const [shownAvatar, setShownAvatar] = useState(undefined); + const [isAvatarExists, setIsAvatarExists] = useState( + undefined, + ); + + const [accountInfoModalOpen, setAccountInfoModalOpen] = useState(false); + const [connectWalletModalOpen, setConnectWalletModalOpen] = useState(false); + const [allTransactionModalOpen, setAllTransactionModalOpen] = useState(false); + + useEffect(() => { + if (activeAddress) { + setShownUserName(activeAddress); + fetchEnsNameByAddress(activeAddress).then(() => { + const addressData = ensData[activeAddress.toLocaleLowerCase() as Hex]; + setShownUserName( + addressData && addressData.name ? addressData.name : activeAddress, + ); + selectENSAvatar({ + ensData, + fetchEnsAvatarByAddress, + address: activeAddress, + setAvatar: setShownAvatar, + setIsAvatarExists, + }); + }); + } + }, [ensData, activeAddress]); + + // const representedAddresses = getRepresentedAddresses(representationData); + + useEffect(() => { + resetWalletConnectionError(); + }, [connectWalletModalOpen]); + + const handleButtonClick = () => { + if (activeWallet?.isActive) { + if (!accountInfoModalOpen) { + setAccountInfoModalOpen(true); + } + } else { + setConnectWalletModalOpen(true); + } + }; + + const handleDisconnectClick = async () => { + await disconnectActiveWallet(); + setAccountInfoModalOpen(false); + }; + + return ( + <> + + + + + + {allTransactionModalOpen && ( + + )} + {/*{powersInfoModalOpen && (*/} + {/* */} + {/*)}*/} + + {/*{appMode === 'expert' && (*/} + {/* */} + {/*)}*/} + + ); +} diff --git a/src/components/Web3Icons/AssetIcon.tsx b/src/components/Web3Icons/AssetIcon.tsx new file mode 100644 index 00000000..439cdecf --- /dev/null +++ b/src/components/Web3Icons/AssetIcon.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { AssetIcon as AI } from '@bgd-labs/react-web3-icons'; +import { + AssetIconProps, + ExternalComponentBaseProps, +} from '@bgd-labs/react-web3-icons/dist/utils'; +import { Box, SxProps } from '@mui/system'; + +import { CustomSkeleton } from '../primitives/CustomSkeleton'; + +/** + * Renders an asset icon specified by symbol. + */ +const AssetIcon = ({ + symbol, + size, + css, + ...props +}: AssetIconProps & + ExternalComponentBaseProps & { + size?: number; + css?: SxProps; + }) => { + return ( + + + + + } + {...props} + /> + + ); +}; + +export default AssetIcon; diff --git a/src/components/Web3Icons/ChainIcon.tsx b/src/components/Web3Icons/ChainIcon.tsx new file mode 100644 index 00000000..ff8f9f01 --- /dev/null +++ b/src/components/Web3Icons/ChainIcon.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { StaticChainIcon as CI } from '@bgd-labs/react-web3-icons'; +import { ExternalComponentBaseProps } from '@bgd-labs/react-web3-icons/dist/utils'; +import { Box, SxProps } from '@mui/system'; + +import { CustomSkeleton } from '../primitives/CustomSkeleton'; + +interface ChainIconProps extends ExternalComponentBaseProps { + chainId: number; + size?: number; + css?: SxProps; +} +/** + * Renders a chain icon specified by chainId. + */ +const ChainIcon = ({ chainId, size, css, ...props }: ChainIconProps) => { + return ( + + + + + } + {...props} + /> + + ); +}; + +export default ChainIcon; diff --git a/src/components/Web3Icons/WalletIcon.tsx b/src/components/Web3Icons/WalletIcon.tsx new file mode 100644 index 00000000..07984799 --- /dev/null +++ b/src/components/Web3Icons/WalletIcon.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { WalletIcon as WI } from '@bgd-labs/react-web3-icons'; +import { ExternalComponentBaseProps } from '@bgd-labs/react-web3-icons/dist/utils/index'; +import { Box, SxProps } from '@mui/system'; + +import { CustomSkeleton } from '../primitives/CustomSkeleton'; + +interface WalletIconProps extends ExternalComponentBaseProps { + walletName: string; + size?: number; + css?: SxProps; +} + +/** + * Renders a wallet icon specified by walletName. + */ +const WalletIcon = ({ walletName, size, css, ...props }: WalletIconProps) => { + return ( + + + + + } + {...props} + /> + + ); +}; + +export default WalletIcon; diff --git a/src/components/layouts/AppHeader.tsx b/src/components/layouts/AppHeader.tsx index 6d544e2d..4e7219b5 100644 --- a/src/components/layouts/AppHeader.tsx +++ b/src/components/layouts/AppHeader.tsx @@ -1,19 +1,31 @@ import { Box, useTheme } from '@mui/system'; import { usePathname } from 'next/navigation'; -import React, { useState } from 'react'; - -import Logo from '/src/assets/logo.svg'; +import React, { useEffect, useRef, useState } from 'react'; +import WarningIcon from '../../assets/icons/warningIcon.svg'; +import Logo from '../../assets/logo.svg'; +import { + isForIPFS, + isTermsAndConditionsVisible, +} from '../../configs/appConfig'; +import { ROUTES } from '../../configs/routes'; import { texts } from '../../helpers/texts/texts'; import { useStore } from '../../providers/ZustandStoreProvider'; -import { ROUTES } from '../../routes'; +import { selectIsRpcAppHasErrors } from '../../store/selectors/rpcSwitcherSelectors'; import { media } from '../../styles/themeMUI'; +import { useClickOutside } from '../../styles/useClickOutside'; import { useMediaQuery } from '../../styles/useMediaQuery'; import { useScrollDirection } from '../../styles/useScrollDirection'; import { BoxWith3D } from '../BoxWith3D'; import { Link } from '../Link'; import { Container } from '../primitives/Container'; +import { Divider } from '../primitives/Divider'; import { IconBox } from '../primitives/IconBox'; +import NoSSR from '../primitives/NoSSR'; +import { WalletWidget } from '../Web3/wallet/WalletWidget'; +import { AppModeSwitcher } from './AppModeSwitcher'; +import { SettingsButton } from './SettingsButton'; +import { ThemeSwitcher } from './ThemeSwitcher'; const headerNavItems = [ { @@ -35,13 +47,50 @@ export function AppHeader() { const path = usePathname(); const sm = useMediaQuery(media.sm); + const wrapperRef = useRef(null); const isRendered = useStore((store) => store.isRendered); + const activeWallet = useStore((store) => store.activeWallet); + const checkAppMode = useStore((store) => store.checkAppMode); const appMode = useStore((store) => store.appMode); + const isAppBlockedByTerms = useStore((store) => store.isAppBlockedByTerms); + const isModalOpen = useStore((store) => store.isModalOpen); + const setIsTermModalOpen = useStore((store) => store.setIsTermModalOpen); + const isRpcHasError = useStore((store) => selectIsRpcAppHasErrors(store)); const { scrollDirection } = useScrollDirection(); - const [mobileMenuOpen] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const handleOpenMobileMenu = () => { + if (typeof document !== 'undefined') { + document.body.style.overflow = 'hidden'; + } + setMobileMenuOpen(true); + }; + + const handleCloseMobileMenu = () => { + if (typeof document !== 'undefined') { + document.body.style.overflow = 'unset'; + } + setMobileMenuOpen(false); + }; + + useClickOutside({ + ref: wrapperRef, + outsideClickFunc: () => setTimeout(() => handleCloseMobileMenu(), 10), + additionalCondition: mobileMenuOpen, + }); + + useEffect(() => { + checkAppMode(); + }, [activeWallet?.isActive]); + + useEffect(() => { + if (sm) { + handleCloseMobileMenu(); + } + }, [sm]); if (appMode === 'default') { if (headerNavItems.some((item) => item.title === 'Create')) { @@ -62,13 +111,12 @@ export function AppHeader() { component="header" sx={{ position: 'sticky', - // top: - // scrollDirection === 'down' - // ? isModalOpen || isAppBlockedByTerms - // ? 0 - // : -82 - // : 0, - top: scrollDirection === 'down' ? -82 : 0, + top: + scrollDirection === 'down' + ? isModalOpen || isAppBlockedByTerms + ? 0 + : -82 + : 0, pt: mobileMenuOpen ? 0 : 8, pb: mobileMenuOpen ? 0 : 8, zIndex: 110, @@ -226,9 +274,7 @@ export function AppHeader() { height: 8, opacity: // isClickedOnStartButtonOnHelpModal || !isRendered - // ? 0 - // : 1, - 1, + !isRendered ? 0 : 1, zIndex: 100, position: 'absolute', top: -3, @@ -278,9 +324,317 @@ export function AppHeader() { ))} + + + + + + + { + if (mobileMenuOpen) { + handleCloseMobileMenu(); + } else { + handleOpenMobileMenu(); + } + }}> + + + + + + + {isRpcHasError && !mobileMenuOpen && ( + svg': { + width: 12, + height: 10, + }, + }}> + + + )} + + + + + + + div, .BoxWith3D__content': { + height: '100%', + '@media only screen and (max-height: 370px)': { + height: 'unset', + }, + }, + }}> + + {headerNavItems.map((item) => ( + + {item.title === texts.header.navTutorial ? ( + { + // closeHelpModals(); + // setIsTermModalOpen(false); + // setIsHelpModalOpen(true); + handleCloseMobileMenu(); + }} + sx={{ + color: '$textLight', + mb: 15, + display: 'block', + }}> + + {item.title} + + + ) : ( + handleCloseMobileMenu()} + inNewWindow={item.title !== 'Create'} + css={{ + color: '$textLight', + mb: 14, + display: 'block', + }}> + + {item.title} + + + )} + + ))} + handleCloseMobileMenu()} + css={{ + color: '$textLight', + mb: 14, + display: 'block', + }}> + + {texts.header.adi} + + + handleCloseMobileMenu()} + css={{ + color: '$textLight', + mb: 14, + display: 'block', + }}> + + {texts.header.payloadsExplorer} + + + handleCloseMobileMenu()} + css={{ + color: '$textLight', + mb: 14, + display: 'block', + }}> + + + {texts.header.changeRPC} + + {isRpcHasError && ( + svg': { + width: 12, + height: 10, + }, + }}> + + + )} + + + {!isForIPFS && isTermsAndConditionsVisible && ( + { + // closeHelpModals(); + // setIsTermModalOpen(true); + handleCloseMobileMenu(); + }} + sx={{ + textAlign: 'left', + color: '$textLight', + mb: 14, + display: 'block', + }}> + + {texts.header.termsAndConditions} + + + )} + + + + + + + {texts.header.theme} + + + + + + + + + + {mobileMenuOpen && ( + ); } diff --git a/src/components/layouts/AppModeSwitcher.tsx b/src/components/layouts/AppModeSwitcher.tsx new file mode 100644 index 00000000..c89d0505 --- /dev/null +++ b/src/components/layouts/AppModeSwitcher.tsx @@ -0,0 +1,79 @@ +import { Box, useTheme } from '@mui/system'; +import React from 'react'; + +import { texts } from '../../helpers/texts/texts'; +import { useStore } from '../../providers/ZustandStoreProvider'; +import { AppModeType } from '../../types'; +import { Divider } from '../primitives/Divider'; + +export const appModes: { mode: AppModeType; title: string }[] = [ + { + mode: 'default', + title: texts.header.appModeDefault, + }, + { + mode: 'dev', + title: texts.header.appModeDev, + }, + { + mode: 'expert', + title: texts.header.appModeExpert, + }, +]; + +export function AppModeSwitcher() { + const theme = useTheme(); + + const activeWallet = useStore((store) => store.activeWallet); + const appMode = useStore((store) => store.appMode); + const setAppMode = useStore((store) => store.setAppMode); + + return ( + <> + {!activeWallet?.isContractAddress && ( + + + {texts.header.appMode} + + + {appModes.map((mode) => { + return ( + { + setAppMode(mode.mode); + }} + sx={{ + mb: 12, + display: 'block', + color: '$textLight', + cursor: appMode === mode.mode ? 'default' : 'pointer', + transition: 'all 0.2s ease', + [theme.breakpoints.up('sm')]: { + color: '$textDisabled', + }, + hover: { + color: + appMode === mode.mode + ? theme.palette.$textDisabled + : theme.palette.$textWhite, + }, + }}> + + {mode.title} + + + ); + })} + + )} + + ); +} diff --git a/src/components/layouts/SettingsButton.tsx b/src/components/layouts/SettingsButton.tsx new file mode 100644 index 00000000..0e566190 --- /dev/null +++ b/src/components/layouts/SettingsButton.tsx @@ -0,0 +1,324 @@ +import { Menu } from '@headlessui/react'; +import { Box, SxProps, useTheme } from '@mui/system'; +import React, { ReactNode } from 'react'; + +import SettingsIcon from '../../assets/icons/settings.svg'; +import SettingsBordersIcon from '../../assets/icons/settingsBorders.svg'; +import WarningIcon from '../../assets/icons/warningIcon.svg'; +import { + isForIPFS, + isTermsAndConditionsVisible, +} from '../../configs/appConfig'; +import { ROUTES } from '../../configs/routes'; +import { texts } from '../../helpers/texts/texts'; +import { useStore } from '../../providers/ZustandStoreProvider'; +import { selectIsRpcAppHasErrors } from '../../store/selectors/rpcSwitcherSelectors'; +import { textCenterEllipsis } from '../../styles/text-center-ellipsis'; +import { BoxWith3D } from '../BoxWith3D'; +import { Link } from '../Link'; +import { Divider } from '../primitives/Divider'; +import { IconBox } from '../primitives/IconBox'; +import { AppModeSwitcher } from './AppModeSwitcher'; +import { ThemeSwitcher } from './ThemeSwitcher'; + +export function SettingsButton({ + setIsTermModalOpen, +}: { + setIsTermModalOpen: (value: boolean) => void; +}) { + const theme = useTheme(); + + const rpcAppErrors = useStore((store) => store.rpcAppErrors); + const isRpcHasError = useStore((store) => selectIsRpcAppHasErrors(store)); + + const filteredAppErrors = Object.values(rpcAppErrors).filter( + (error) => error.error, + ); + + const SettingButtonIconWrapper = ({ + children, + sx, + }: { + children: ReactNode; + sx: SxProps; + }) => { + return ( + svg': { + width: 16, + height: 16, + [theme.breakpoints.up('lg')]: { + width: 22, + height: 22, + }, + }, + [theme.breakpoints.up('lg')]: { + width: 22, + height: 22, + }, + }}> + {children} + + ); + }; + + return ( + <> + + {({ open, close }) => ( + <> + div': { + '&:first-of-type': { + opacity: 0, + }, + '&:nth-of-type(2)': { + opacity: 1, + }, + }, + }, + }}> + + + + + + + + + {isRpcHasError && ( + <> + svg': { + width: 10, + height: 8, + [theme.breakpoints.up('lg')]: { + width: 12, + height: 10, + }, + }, + [theme.breakpoints.up('lg')]: { + width: 12, + height: 10, + }, + }}> + + + + + {texts.other.rpcError( + filteredAppErrors.length, + textCenterEllipsis(filteredAppErrors[0].rpcUrl, 12, 12), + )} + + + )} + + + + + + + + {texts.header.adi} + + + + + + {texts.header.payloadsExplorer} + + + + + + + {texts.header.changeRPC} + + {isRpcHasError && ( + svg': { + width: 10, + height: 8, + [theme.breakpoints.up('lg')]: { + width: 12, + height: 10, + }, + }, + [theme.breakpoints.up('lg')]: { + width: 12, + height: 10, + }, + }}> + + + )} + + + + + + + {texts.header.theme} + + + + {!isForIPFS && isTermsAndConditionsVisible && ( + <> + + { + close(); + setIsTermModalOpen(true); + }} + sx={{ + textAlign: 'left', + color: '$textDisabled', + hover: { + color: theme.palette.$textWhite, + }, + }}> + + {texts.header.termsAndConditions} + + + + )} + + + + )} + + + ); +} diff --git a/src/components/layouts/ThemeSwitcher.tsx b/src/components/layouts/ThemeSwitcher.tsx new file mode 100644 index 00000000..499b06f7 --- /dev/null +++ b/src/components/layouts/ThemeSwitcher.tsx @@ -0,0 +1,123 @@ +import { Box, useTheme } from '@mui/system'; +import { useTheme as useThemeNext } from 'next-themes'; +import React from 'react'; + +import DarkThemeIcon from '../../assets/icons/darkTheme.svg'; +import LightThemeIcon from '../../assets/icons/lightTheme.svg'; +import { useStore } from '../../providers/ZustandStoreProvider'; +import { IconBox } from '../primitives/IconBox'; +import NoSSR from '../primitives/NoSSR'; + +export function ThemeSwitcher() { + const themeMUI = useTheme(); + const { theme, setTheme } = useThemeNext(); + + const setIsThemeSwitched = useStore((store) => store.setIsThemeSwitched); + + return ( + { + setIsThemeSwitched(); + setTimeout(() => setTheme(theme === 'light' ? 'dark' : 'light'), 10); + }} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + border: `1px solid ${themeMUI.palette.$textWhite}`, + backgroundColor: '$disabled', + width: '100%', + p: 3, + position: 'relative', + }}> + + + + svg': { + width: 23, + height: 23, + }, + animation: + themeMUI.palette.mode === 'light' ? `iconAnimation 1.5s` : '', + path: { fill: themeMUI.palette.$textLight }, + }}> + + + + + svg': { + width: 20, + height: 20, + }, + path: { + fill: + themeMUI.palette.mode === 'dark' + ? themeMUI.palette.$headerGray + : themeMUI.palette.$main, + }, + }}> + + + + + + ); +} diff --git a/src/components/primitives/CustomSkeleton.tsx b/src/components/primitives/CustomSkeleton.tsx new file mode 100644 index 00000000..f738c48b --- /dev/null +++ b/src/components/primitives/CustomSkeleton.tsx @@ -0,0 +1,47 @@ +import { Box } from '@mui/system'; +import React from 'react'; + +export function CustomSkeleton({ + width, + height, + circle, +}: { + width?: number | string; + height?: number | string; + circle?: boolean; +}) { + return ( + + ({ + '@keyframes skeletonSlide': { + '0%': { + left: '-100px', + }, + '100%': { + left: 'calc(100% + 100px)', + }, + }, + position: 'relative', + backgroundColor: `${theme.palette.$light} !important`, + width, + height, + borderRadius: circle ? '50%' : '0', + overflow: 'hidden', + '&:before': { + content: `""`, + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '0', + height: '100%', + boxShadow: `0 0 80px 20px ${theme.palette.$textSecondary}`, + animation: `skeletonSlide 1s infinite ease-in-out`, + }, + })} + /> + + ); +} diff --git a/src/appConfig.ts b/src/configs/appConfig.ts similarity index 99% rename from src/appConfig.ts rename to src/configs/appConfig.ts index bd0354ea..6b5a602e 100644 --- a/src/appConfig.ts +++ b/src/configs/appConfig.ts @@ -31,7 +31,7 @@ import { zkSync, } from 'viem/chains'; -import { env } from './env'; +import { env } from '../env'; export type CoreNetworkName = 'mainnet' | 'sepolia'; diff --git a/src/configs/chains.ts b/src/configs/chains.ts new file mode 100644 index 00000000..fa831bfb --- /dev/null +++ b/src/configs/chains.ts @@ -0,0 +1,170 @@ +import { Draft } from 'immer'; +import { Chain, createClient, fallback, http } from 'viem'; +import { + arbitrum, + avalanche, + avalancheFuji, + base, + bsc, + bscTestnet, + gnosis, + mainnet, + metis, + optimism, + polygon, + polygonMumbai, + scroll, + sepolia, + zkSync, +} from 'viem/chains'; + +// chains RPC urls +export const initialRpcUrls: Record = { + [mainnet.id]: [ + process.env.NEXT_PUBLIC_RPC_MAINNET || 'https://rpc.ankr.com/eth', + 'https://rpc.ankr.com/eth', + 'https://eth.nodeconnect.org', + ], + [polygon.id]: [ + process.env.NEXT_PUBLIC_RPC_POLYGON || + 'https://polygon.blockpi.network/v1/rpc/public', + 'https://polygon.blockpi.network/v1/rpc/public', + 'https://polygon.llamarpc.com', + 'https://polygon-bor.publicnode.com', + 'https://endpoints.omniatech.io/v1/matic/mainnet/public', + ], + [avalanche.id]: [ + process.env.NEXT_PUBLIC_RPC_AVALANCHE || + 'https://api.avax.network/ext/bc/C/rpc', + 'https://api.avax.network/ext/bc/C/rpc', + 'https://avalanche.drpc.org', + 'https://avax.meowrpc.com', + 'https://avalanche.blockpi.network/v1/rpc/public', + ], + [bsc.id]: [ + process.env.NEXT_PUBLIC_RPC_BNB || 'https://bsc.meowrpc.com', + 'https://binance.llamarpc.com', + 'https://bsc.meowrpc.com', + ], + [base.id]: [ + process.env.NEXT_PUBLIC_RPC_BASE || + 'https://base.blockpi.network/v1/rpc/public', + 'https://base.blockpi.network/v1/rpc/public', + 'https://base.llamarpc.com', + 'https://base-mainnet.public.blastapi.io', + 'https://base.meowrpc.com', + ], + [arbitrum.id]: [ + process.env.NEXT_PUBLIC_RPC_ARBITRUM || + 'https://endpoints.omniatech.io/v1/arbitrum/one/public', + 'https://arbitrum.llamarpc.com', + 'https://arb-mainnet-public.unifra.io', + 'https://endpoints.omniatech.io/v1/arbitrum/one/public', + ], + [metis.id]: [ + process.env.NEXT_PUBLIC_RPC_METIS || + 'https://metis-mainnet.public.blastapi.io', + 'https://metis.api.onfinality.io/public', + ], + [optimism.id]: [ + process.env.NEXT_PUBLIC_RPC_OPTIMISM || + 'https://optimism.blockpi.network/v1/rpc/public', + 'https://optimism.blockpi.network/v1/rpc/public', + 'https://optimism.llamarpc.com', + 'https://optimism.publicnode.com', + ], + [gnosis.id]: [ + process.env.NEXT_PUBLIC_RPC_GNOSIS || + 'https://gnosis.blockpi.network/v1/rpc/public', + 'https://gnosis.blockpi.network/v1/rpc/public', + 'https://gnosis-mainnet.public.blastapi.io', + ], + [scroll.id]: [ + process.env.NEXT_PUBLIC_RPC_SCROLL || + 'https://scroll.blockpi.network/v1/rpc/public', + 'https://scroll.blockpi.network/v1/rpc/public', + 'https://scroll-mainnet.public.blastapi.io', + ], + [zkSync.id]: [ + process.env.NEXT_PUBLIC_RPC_ZKEVM || 'https://zksync.meowrpc.com', + 'https://mainnet.era.zksync.io', + ], + // testnets + [sepolia.id]: [ + 'https://eth-sepolia.public.blastapi.io', + 'https://endpoints.omniatech.io/v1/eth/sepolia/public', + 'https://ethereum-sepolia.blockpi.network/v1/rpc/public', + 'https://ethereum-sepolia.publicnode.com', + ], + [polygonMumbai.id]: ['https://rpc.ankr.com/polygon_mumbai'], + [avalancheFuji.id]: [ + 'https://avalanche-fuji.blockpi.network/v1/rpc/public', + 'https://api.avax-test.network/ext/bc/C/rpc', + 'https://avalanche-fuji-c-chain.publicnode.com', + 'https://rpc.ankr.com/avalanche_fuji', + ], + [bscTestnet.id]: ['https://data-seed-prebsc-1-s1.bnbchain.org:8545'], +}; + +export function setChain(chain: Chain, url?: string) { + return { + ...chain, + rpcUrls: { + ...chain.rpcUrls, + default: { + ...chain.rpcUrls.default, + http: [url || initialRpcUrls[chain.id][0], ...initialRpcUrls[chain.id]], + }, + }, + blockExplorers: { + ...chain.blockExplorers, + default: + chain.id === gnosis.id + ? { name: 'Gnosis chain explorer', url: 'https://gnosisscan.io' } + : chain.blockExplorers?.default || mainnet.blockExplorers.default, + }, + }; +} + +export const CHAINS: Record = { + [mainnet.id]: setChain(mainnet), + [polygon.id]: setChain(polygon), + [avalanche.id]: setChain(avalanche), + [bsc.id]: setChain(bsc), + [base.id]: setChain(base), + [arbitrum.id]: setChain(arbitrum), + [metis.id]: setChain(metis), + [optimism.id]: setChain(optimism), + [gnosis.id]: setChain(gnosis), + [scroll.id]: setChain(scroll), + [zkSync.id]: setChain(zkSync), + // testnets + [sepolia.id]: setChain(sepolia), + [polygonMumbai.id]: setChain(polygonMumbai), + [avalancheFuji.id]: setChain(avalancheFuji), + [bscTestnet.id]: setChain(bscTestnet), +}; + +export const fallBackConfig = { + rank: false, + retryDelay: 30, + retryCount: 3, +}; + +export const createViemClient = ( + chain: Chain, + rpcUrl: string, + withoutFallback?: boolean, +) => + createClient({ + batch: { + multicall: true, + }, + chain: setChain(chain, rpcUrl) as Draft, + transport: withoutFallback + ? http(rpcUrl) + : fallback( + [http(rpcUrl), ...initialRpcUrls[chain.id].map((url) => http(url))], + fallBackConfig, + ), + }); diff --git a/src/configs/configs.ts b/src/configs/configs.ts new file mode 100644 index 00000000..dc096ec9 --- /dev/null +++ b/src/configs/configs.ts @@ -0,0 +1,13 @@ +import { initChainInformationConfig } from '@bgd-labs/frontend-web3-utils'; + +import { CHAINS } from './chains'; + +// ipfs gateway to get proposals metadata +export const ipfsGateway = 'https://ipfs.io/ipfs'; +export const fallbackGateways = [ + 'https://dweb.link/ipfs', + 'https://ipfs.eth.aragon.network/ipfs', + 'https://ipfs.runfission.com/ipfs', +]; + +export const chainInfoHelper = initChainInformationConfig(CHAINS); diff --git a/src/localStorage.ts b/src/configs/localStorage.ts similarity index 60% rename from src/localStorage.ts rename to src/configs/localStorage.ts index 75c7f9c9..3c96f208 100644 --- a/src/localStorage.ts +++ b/src/configs/localStorage.ts @@ -3,14 +3,46 @@ import { WalletType, } from '@bgd-labs/frontend-web3-utils'; -import { AppModeType, IsGaslessVote } from './types'; +import { + AppClientsStorage, + AppModeType, + EnsDataItem, + IsGaslessVote, +} from '../types'; export enum LocalStorageKeys { GaslessVote = 'isGaslessVote', TermsAccept = 'termsAccept', AppMode = 'appMode', + EnsAddresses = 'EnsAddresses', + RpcUrls = 'rpcs_urls_4', } +// for ENS +export const getLocalStorageEnsAddresses = () => { + return localStorage?.getItem(LocalStorageKeys.EnsAddresses); +}; + +export const setLocalStorageEnsAddresses = ( + ensAddresses: Record, +) => { + localStorage?.setItem( + LocalStorageKeys.EnsAddresses, + JSON.stringify(ensAddresses), + ); +}; + +// for RPC switcher +export const getLocalStorageRpcUrls = () => { + return localStorage?.getItem(LocalStorageKeys.RpcUrls); +}; + +export const setLocalStorageRpcUrls = ( + rpcUrls: Record, +) => { + localStorage?.setItem(LocalStorageKeys.RpcUrls, JSON.stringify(rpcUrls)); +}; + // for UI export const getLocalStorageLastConnectedWallet = () => { return localStorage?.getItem(Web3LocalStorageKeys.LastConnectedWallet) as diff --git a/src/routes.ts b/src/configs/routes.ts similarity index 100% rename from src/routes.ts rename to src/configs/routes.ts diff --git a/src/helpers/ensHelpers.tsx b/src/helpers/ensHelpers.tsx new file mode 100644 index 00000000..5d4be904 --- /dev/null +++ b/src/helpers/ensHelpers.tsx @@ -0,0 +1,41 @@ +import makeBlockie from 'ethereum-blockies-base64'; +import { Address, createClient, Hex, http, isAddress } from 'viem'; +import { mainnet } from 'viem/chains'; +import { getEnsAddress, getEnsAvatar, getEnsName, normalize } from 'viem/ens'; + +const client = createClient({ chain: mainnet, transport: http() }); + +export const getName = async (address: Hex) => { + try { + const name = await getEnsName(client, { address }); + return name ? name : undefined; + } catch (error) { + console.error('ENS name lookup error', error); + } +}; + +export const getAvatar = async (name: string, address: string) => { + try { + const background_image = await getEnsAvatar(client, { name }); + return background_image ? background_image : makeBlockie(address); + } catch (error) { + console.error('ENS avatar lookup error', error); + } +}; + +export const getAddress = async (name: string) => { + try { + const address = await getEnsAddress(client, { + name: normalize(name), + }); + return (address ? address.toLocaleLowerCase() : undefined) as + | Address + | undefined; + } catch (error) { + console.error('ENS address lookup error', error); + } +}; + +export const isEnsName = (address: string) => !isAddress(address); + +export const ENS_TTL = 60 * 60 * 24; // 1 day diff --git a/src/helpers/getScanLink.ts b/src/helpers/getScanLink.ts new file mode 100644 index 00000000..f538eae7 --- /dev/null +++ b/src/helpers/getScanLink.ts @@ -0,0 +1,18 @@ +import { Address } from 'viem'; + +import { appConfig } from '../configs/appConfig'; +import { chainInfoHelper } from '../configs/configs'; + +export function getScanLink({ + chainId = appConfig.govCoreChainId, + address, + type = 'address', +}: { + chainId?: number; + address: Address | string; + type?: 'address' | 'tx'; +}) { + return `${ + chainInfoHelper.getChainParameters(chainId).blockExplorers?.default.url + }/${type}/${address}`; +} diff --git a/src/helpers/useLastTxLocalStatus.tsx b/src/helpers/useLastTxLocalStatus.tsx new file mode 100644 index 00000000..c9e2d659 --- /dev/null +++ b/src/helpers/useLastTxLocalStatus.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useLastTxLocalStatus as baseUseTxLocalStatus } from '@bgd-labs/frontend-web3-utils'; +import { zeroAddress } from 'viem'; + +import { useStore } from '../providers/ZustandStoreProvider'; +import { TransactionUnion } from '../store/transactionsSlice'; + +export function useLastTxLocalStatus({ + type, + payload, +}: Pick) { + const transactionsPool = useStore((store) => store.transactionsPool); + const activeWallet = useStore((store) => store.activeWallet); + return baseUseTxLocalStatus({ + transactionsPool, + activeAddress: activeWallet?.address || zeroAddress, + type, + payload, + }); +} diff --git a/src/providers/WagmiProvider.tsx b/src/providers/WagmiProvider.tsx new file mode 100644 index 00000000..46135912 --- /dev/null +++ b/src/providers/WagmiProvider.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { + createWagmiConfig, + WagmiZustandSync, +} from '@bgd-labs/frontend-web3-utils'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React, { useMemo } from 'react'; + +import { appConfig, WC_PROJECT_ID } from '../configs/appConfig'; +import { CHAINS } from '../configs/chains'; +import { useStore } from './ZustandStoreProvider'; + +const queryClient = new QueryClient(); + +export default function WagmiProvider() { + const getImpersonatedAddress = useStore( + (store) => store.getImpersonatedAddress, + ); + const setWagmiConfig = useStore((store) => store.setWagmiConfig); + const setDefaultChainId = useStore((store) => store.setDefaultChainId); + const changeActiveWalletAccount = useStore( + (store) => store.changeActiveWalletAccount, + ); + + const config = useMemo(() => { + return createWagmiConfig({ + chains: CHAINS, + connectorsInitProps: { + appName: 'AAVEGovernanceV3', + defaultChainId: appConfig.govCoreChainId, + wcParams: { + projectId: WC_PROJECT_ID, + metadata: { + name: 'Aave governance', + description: + 'User interface to interact with the Aave governance v3 smart contracts', + url: 'https://vote.onaave.com', + icons: [ + 'https://imagedelivery.net/_aTEfDRm7z3tKgu9JhfeKA/c54c2635-3522-4d32-0e97-2329a733ee00/lg', + ], + }, + }, + }, + getImpersonatedAccount: getImpersonatedAddress, + ssr: true, + }); + }, []); + + return ( + + + + ); +} diff --git a/src/providers/Web3HelperProvider.tsx b/src/providers/Web3HelperProvider.tsx new file mode 100644 index 00000000..58fc2526 --- /dev/null +++ b/src/providers/Web3HelperProvider.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; + +import { appUsedNetworks } from '../configs/appConfig'; +import { chainInfoHelper } from '../configs/configs'; +import { useStore } from './ZustandStoreProvider'; + +function Child() { + const initEns = useStore((state) => state.initEns); + const initClients = useStore((state) => state.initClients); + + useEffect(() => { + initEns(); + initClients(chainInfoHelper, appUsedNetworks); + }, []); + + return null; +} + +export default function Web3HelperProvider() { + return ; +} diff --git a/src/providers/index.tsx b/src/providers/index.tsx index 74ee8a2a..dc63dd34 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -2,12 +2,16 @@ import ThemeRegistry from './ThemeRegistry'; import { TRPCReactProvider } from './TRPCReactProvider'; +import WagmiProvider from './WagmiProvider'; +import Web3HelperProvider from './Web3HelperProvider'; import { ZustandStoreProvider } from './ZustandStoreProvider'; export default function Providers({ children }: { children: React.ReactNode }) { return ( + + {children} diff --git a/src/requests/fetchInitialData.ts b/src/requests/fetchInitialData.ts index 9b0659d3..11aeb840 100644 --- a/src/requests/fetchInitialData.ts +++ b/src/requests/fetchInitialData.ts @@ -2,7 +2,7 @@ import { IGovernanceCore_ABI } from '@bgd-labs/aave-address-book'; import { Client } from 'viem'; import { readContract } from 'viem/actions'; -import { appConfig } from '../appConfig'; +import { appConfig } from '../configs/appConfig'; import { getGovCoreConfigs } from './utils/getGovCoreConfigs'; export async function fetchInitialData({ diff --git a/src/store/ensSlice.ts b/src/store/ensSlice.ts new file mode 100644 index 00000000..ba20dcfe --- /dev/null +++ b/src/store/ensSlice.ts @@ -0,0 +1,183 @@ +import { StoreSlice } from '@bgd-labs/frontend-web3-utils'; +import dayjs from 'dayjs'; +import { produce } from 'immer'; +import { Address } from 'viem'; + +import { + getLocalStorageEnsAddresses, + setLocalStorageEnsAddresses, +} from '../configs/localStorage'; +import { getAddress, getAvatar, getName } from '../helpers/ensHelpers'; +import { EnsDataItem, ENSProperty } from '../types'; +import { ENSDataHasBeenFetched } from './selectors/ensSelectors'; + +export interface IEnsSlice { + ensData: Record; + addressesNameInProgress: Record; + addressesAvatarInProgress: Record; + + initEns: () => void; + setEns: (ens: Record) => void; + setProperty: ( + address: Address, + property: ENSProperty, + value?: string, + isExists?: boolean, + ) => void; + + fetchEnsNameByAddress: (address: Address) => Promise; + fetchEnsAvatarByAddress: (address: Address, name?: string) => Promise; + fetchAddressByEnsName: (name: string) => Promise
; +} + +export const createEnsSlice: StoreSlice = (set, get) => ({ + ensData: {}, + addressesNameInProgress: {}, + addressesAvatarInProgress: {}, + + initEns: () => { + const ens = getLocalStorageEnsAddresses(); + if (ens) { + set((state) => + produce(state, (draft) => { + const parsedEns: Record = JSON.parse(ens); + for (const key in parsedEns) { + draft.ensData[key as Address] = parsedEns[key as Address]; + } + }), + ); + } + }, + setEns: (ens) => { + set((state) => + produce(state, (draft) => { + for (const key in ens) { + draft.ensData[key as Address] = ens[key]; + } + }), + ); + setLocalStorageEnsAddresses(get().ensData); + }, + setProperty: (address, property, value, isExists) => { + set((state) => + produce(state, (draft) => { + const currentEntry = draft.ensData[address] || {}; + + if (property === ENSProperty.AVATAR) { + currentEntry[property] = { url: value, isExists }; + } else { + currentEntry[property] = value; + } + + currentEntry.fetched = currentEntry.fetched || {}; + currentEntry.fetched[property] = dayjs().unix(); + draft.ensData[address] = currentEntry; + }), + ); + setTimeout(() => setLocalStorageEnsAddresses(get().ensData), 1); + }, + + fetchEnsNameByAddress: async (address) => { + const lowercasedAddress = address.toLocaleLowerCase() as Address; + // check if already exist or pending + if ( + ENSDataHasBeenFetched( + get().ensData, + lowercasedAddress, + ENSProperty.NAME, + ) || + get().addressesNameInProgress[lowercasedAddress] + ) { + return; + } + + set((state) => + produce(state, (draft) => { + draft.addressesNameInProgress[lowercasedAddress] = true; + }), + ); + + const name = await getName(lowercasedAddress); + + set((state) => + produce(state, (draft) => { + delete draft.addressesNameInProgress[lowercasedAddress]; + }), + ); + + get().setProperty(lowercasedAddress, ENSProperty.NAME, name); + }, + fetchEnsAvatarByAddress: async (address, name) => { + const lowercasedAddress = address.toLocaleLowerCase() as Address; + // check if already exist or pending + if ( + ENSDataHasBeenFetched( + get().ensData, + lowercasedAddress, + ENSProperty.AVATAR, + ) || + get().addressesAvatarInProgress[lowercasedAddress] || + !name + ) { + return; + } + + set((state) => + produce(state, (draft) => { + draft.addressesAvatarInProgress[lowercasedAddress] = true; + }), + ); + + const avatar = await getAvatar(name, address); + let isExists: boolean | undefined = undefined; + + if (avatar) { + const avatarResponseStatus = (await fetch(avatar)).status; + isExists = avatarResponseStatus === 200; + } + + set((state) => + produce(state, (draft) => { + delete draft.addressesAvatarInProgress[lowercasedAddress]; + }), + ); + + get().setProperty(lowercasedAddress, ENSProperty.AVATAR, avatar, isExists); + }, + fetchAddressByEnsName: async (name) => { + const address = + (Object.keys(get().ensData).find( + (address) => + get().ensData[address.toLocaleLowerCase() as Address].name === name, + ) as Address) || undefined; + + if (address) { + return address; + } + + set((state) => + produce(state, (draft) => { + draft.addressesNameInProgress[name] = true; + }), + ); + + const addressFromEns = await getAddress(name); + + set((state) => + produce(state, (draft) => { + delete draft.addressesNameInProgress[name]; + }), + ); + + if (addressFromEns) { + get().setProperty( + addressFromEns.toLocaleLowerCase() as Address, + ENSProperty.NAME, + name, + ); + return addressFromEns; + } + + return; + }, +}); diff --git a/src/store/index.ts b/src/store/index.ts index f4ebd4f4..2f6b2f08 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,13 +2,28 @@ import { StoreApi } from 'zustand'; +import { createEnsSlice, IEnsSlice } from './ensSlice'; +import { createRpcSwitcherSlice, IRpcSwitcherSlice } from './rpcSwitcherSlice'; +import { + createTransactionsSlice, + TransactionsSlice, +} from './transactionsSlice'; import { createUISlice, IUISlice } from './uiSlice'; +import { createWeb3Slice, IWeb3Slice } from './web3Slice'; -export type RootState = IUISlice; +export type RootState = IUISlice & + IWeb3Slice & + TransactionsSlice & + IEnsSlice & + IRpcSwitcherSlice; export const createRootSlice = ( set: StoreApi['setState'], get: StoreApi['getState'], ) => ({ ...createUISlice(set, get), + ...createWeb3Slice(set, get), + ...createTransactionsSlice(set, get), + ...createEnsSlice(set, get), + ...createRpcSwitcherSlice(set, get), }); diff --git a/src/store/rpcSwitcherSlice.ts b/src/store/rpcSwitcherSlice.ts new file mode 100644 index 00000000..282fe17a --- /dev/null +++ b/src/store/rpcSwitcherSlice.ts @@ -0,0 +1,279 @@ +import { IPayloadsControllerCore_ABI } from '@bgd-labs/aave-address-book'; +import { + blockLimit, + getPayloadsCreated, +} from '@bgd-labs/aave-governance-ui-helpers'; +import { StoreSlice } from '@bgd-labs/frontend-web3-utils'; +import { Draft, produce } from 'immer'; +import { Client, getContract, zeroAddress, zeroHash } from 'viem'; +import { getBlock, getProof } from 'viem/actions'; +import { mainnet } from 'viem/chains'; + +import { appConfig } from '../configs/appConfig'; +import { createViemClient } from '../configs/chains'; +import { chainInfoHelper } from '../configs/configs'; +import { + getLocalStorageRpcUrls, + setLocalStorageRpcUrls, +} from '../configs/localStorage'; +import { + AppClient, + AppClientsStorage, + ChainInfo, + RpcSwitcherFormData, + SetRpcErrorParams, +} from '../types'; +import { selectAppClients } from './selectors/rpcSwitcherSelectors'; +import { TransactionsSlice } from './transactionsSlice'; +import { IWeb3Slice } from './web3Slice'; + +export interface IRpcSwitcherSlice { + appClients: Record; + appClientsForm: Record; + initClientsLoaded: boolean; + initClients: (clients: ChainInfo, appUsedNetworks: number[]) => void; + updateClients: (formData: RpcSwitcherFormData) => void; + syncTransactionsClients: () => void; + syncLocalStorage: () => void; + syncAppClientsForm: () => void; + + rpcAppErrors: Record< + number, + { error: boolean; rpcUrl: string; chainId: number } + >; + setRpcError: ({ isError, rpcUrl, chainId }: SetRpcErrorParams) => void; + + setRpcFormError: ({ isError, rpcUrl, chainId }: SetRpcErrorParams) => void; + rpcFormErrors: Record; + checkRpcUrl: (rpcUrl: string, chainId: number) => Promise; +} + +export const createRpcSwitcherSlice: StoreSlice< + IRpcSwitcherSlice, + IWeb3Slice & TransactionsSlice +> = (set, get) => ({ + appClients: {}, + appClientsForm: {}, + initClientsLoaded: false, + initClients: (clients, appUsedNetworks) => { + const rpcUrlsFromStorage = getLocalStorageRpcUrls(); + + if ( + rpcUrlsFromStorage !== null && + rpcUrlsFromStorage !== undefined && + !!Object.keys(JSON.parse(rpcUrlsFromStorage)).length && + Object.keys(JSON.parse(rpcUrlsFromStorage)).every((chainId) => + appUsedNetworks.includes(Number(chainId)), + ) && + appUsedNetworks.length === + Object.keys(JSON.parse(rpcUrlsFromStorage)).length + ) { + const parsedRpcUrlsFromStorage = JSON.parse(rpcUrlsFromStorage) as Record< + number, + AppClientsStorage + >; + + set((state) => + produce(state, (draft) => { + Object.keys(parsedRpcUrlsFromStorage) + .filter((chainId) => appUsedNetworks.includes(Number(chainId))) + .forEach((chainId) => { + const chainIdNumber = Number(chainId); + const chain = clients.getChainParameters(chainIdNumber); + + if (chain) { + draft.appClients[chainIdNumber] = { + rpcUrl: parsedRpcUrlsFromStorage[chainIdNumber].rpcUrl, + instance: createViemClient( + chain, + parsedRpcUrlsFromStorage[chainIdNumber].rpcUrl, + ), + }; + } + }); + }), + ); + } else { + set((state) => + produce(state, (draft) => { + Object.keys(clients.clientInstances) + .filter((chainId) => appUsedNetworks.includes(Number(chainId))) + .forEach((chainId) => { + const chainIdNumber = Number(chainId); + + draft.appClients[chainIdNumber] = { + rpcUrl: + clients.getChainParameters(chainIdNumber).rpcUrls.default + .http[0], + instance: clients.clientInstances[chainIdNumber] + .instance as Draft, + }; + }); + }), + ); + get().syncLocalStorage(); + } + + get().syncTransactionsClients(); + get().syncAppClientsForm(); + + Object.keys(get().appClientsForm).forEach((chainId) => { + const rpcUrl = get().appClientsForm[+chainId].rpcUrl; + get().setRpcError({ isError: false, rpcUrl, chainId: +chainId }); + get().setRpcFormError({ isError: false, rpcUrl, chainId: +chainId }); + }); + + set({ initClientsLoaded: true }); + }, + updateClients: (formData) => { + formData.forEach(({ chainId, rpcUrl }) => { + set((state) => + produce(state, (draft) => { + draft.appClients[chainId].rpcUrl = rpcUrl; + draft.appClients[chainId].instance = createViemClient( + chainInfoHelper.getChainParameters(chainId), + rpcUrl, + ); + }), + ); + }); + + get().syncTransactionsClients(); + get().syncLocalStorage(); + get().syncAppClientsForm(); + + Object.values(formData).forEach((data) => { + get().setRpcError({ + isError: false, + rpcUrl: data.rpcUrl, + chainId: data.chainId, + }); + get().setRpcFormError({ + isError: false, + rpcUrl: data.rpcUrl, + chainId: data.chainId, + }); + }); + }, + syncTransactionsClients: () => { + const clients = selectAppClients(get()); + Object.entries(clients).forEach((value) => { + const clientChainId = Number(value[0]); + const client = value[1]; + get().setClient(clientChainId, client); + }); + }, + syncLocalStorage: () => { + const parsedProvidersForLocalStorage = Object.entries( + get().appClients, + ).reduce( + (acc, [key, value]) => { + acc[key] = { rpcUrl: value.rpcUrl }; + return acc; + }, + {} as Record, + ); + + setLocalStorageRpcUrls(parsedProvidersForLocalStorage); + }, + syncAppClientsForm: () => { + const parsedProvidersForLocalStorage = Object.entries( + get().appClients, + ).reduce( + (acc, [key, value]) => { + acc[key] = { rpcUrl: value.rpcUrl }; + return acc; + }, + {} as Record, + ); + + set({ appClientsForm: parsedProvidersForLocalStorage }); + }, + + rpcAppErrors: {}, + setRpcError: ({ isError, rpcUrl, chainId }) => { + if (!!rpcUrl && !!chainId) { + set((state) => + produce(state, (draft) => { + draft.rpcAppErrors[chainId] = { + error: isError, + rpcUrl, + chainId, + }; + }), + ); + } + }, + + rpcFormErrors: {}, + setRpcFormError: ({ isError, rpcUrl, chainId }) => { + set((state) => + produce(state, (draft) => { + draft.rpcFormErrors[rpcUrl] = { + error: isError, + chainId: chainId, + }; + }), + ); + }, + checkRpcUrl: async (rpcUrl, chainId) => { + if ( + get().rpcFormErrors.hasOwnProperty(rpcUrl) && + get().rpcFormErrors[rpcUrl].chainId === chainId + ) { + return; + } + const client = createViemClient( + chainInfoHelper.getChainParameters(chainId), + rpcUrl, + true, + ); + + const contractAddresses = + appConfig.payloadsControllerConfig[chainId].contractAddresses; + const lastPayloadsController = + contractAddresses[contractAddresses.length - 1]; + const payloadsControllerContract = getContract({ + abi: IPayloadsControllerCore_ABI, + address: lastPayloadsController, + client, + }); + + try { + // initial request to our contract + await payloadsControllerContract.read.getPayloadsCount(); + // check get logs if initial request success + try { + const currentBlock = await getBlock(client, { blockTag: 'latest' }); + + await getPayloadsCreated({ + contractAddress: payloadsControllerContract.address, + client, + startBlock: Number(currentBlock.number) - blockLimit, + endBlock: Number(currentBlock.number), + chainId, + }); + get().setRpcFormError({ isError: false, rpcUrl, chainId }); + + // check get proofs if initial request success and chainId it's mainnet + if (mainnet.id === chainId) { + try { + await getProof(client, { + address: zeroAddress, + storageKeys: [zeroHash], + blockNumber: currentBlock.number, + }); + + get().setRpcFormError({ isError: false, rpcUrl, chainId }); + } catch { + get().setRpcFormError({ isError: true, rpcUrl, chainId }); + } + } + } catch { + get().setRpcFormError({ isError: true, rpcUrl, chainId }); + } + } catch { + get().setRpcFormError({ isError: true, rpcUrl, chainId }); + } + }, +}); diff --git a/src/store/selectors/ensSelectors.ts b/src/store/selectors/ensSelectors.ts new file mode 100644 index 00000000..7e44e150 --- /dev/null +++ b/src/store/selectors/ensSelectors.ts @@ -0,0 +1,129 @@ +import dayjs from 'dayjs'; +import { Address, isAddress } from 'viem'; + +import { ENS_TTL, isEnsName } from '../../helpers/ensHelpers'; +import { EnsDataItem, ENSProperty } from '../../types'; +import { IEnsSlice } from '../ensSlice'; + +export const ENSDataExists = ( + ensData: Record<`0x${string}`, EnsDataItem>, + address: Address | string, + property: ENSProperty, +) => { + const lowercasedAddress = address.toLocaleLowerCase() as Address; + return Boolean( + ensData[lowercasedAddress] && ensData[lowercasedAddress][property], + ); +}; + +export const ENSDataHasBeenFetched = ( + ensData: Record<`0x${string}`, EnsDataItem>, + address: Address, + property: ENSProperty, +) => { + const currentTime = dayjs().unix(); + const fetchTime = ensData[address]?.fetched?.[property]; + if (!fetchTime) return false; + + return currentTime - fetchTime <= ENS_TTL; +}; + +export const checkIsGetAddressByENSNamePending = ( + addressesNameInProgress: Record, + name: string, +) => { + return addressesNameInProgress[name] || false; +}; + +export const getAddressByENSNameIfExists = ( + ensData: Record<`0x${string}`, EnsDataItem>, + name: string, +) => { + return Object.keys(ensData).find( + (address) => ensData[address.toLocaleLowerCase() as Address].name === name, + ); +}; + +export const selectENSAvatar = ({ + fetchEnsAvatarByAddress, + ensData, + address, + setAvatar, + setIsAvatarExists, +}: { + fetchEnsAvatarByAddress: (address: Address, name?: string) => Promise; + ensData: Record<`0x${string}`, EnsDataItem>; + address: Address; + setAvatar: (value: string | undefined) => void; + setIsAvatarExists: (value: boolean | undefined) => void; +}) => { + const lowercasedAddress = address.toLocaleLowerCase() as Address; + const ENSData = ensData[lowercasedAddress]; + + if (ENSData && ENSData.name) { + fetchEnsAvatarByAddress(address, ENSData.name).then(() => { + setAvatar( + ENSDataExists(ensData, address, ENSProperty.AVATAR) + ? ensData[lowercasedAddress].avatar?.url + : undefined, + ); + setIsAvatarExists(ensData[lowercasedAddress].avatar?.isExists); + }); + } else { + setAvatar(undefined); + setIsAvatarExists(false); + } +}; + +export const selectInputToAddress = async ({ + store, + activeAddress, + addressTo, +}: { + store: IEnsSlice; + activeAddress: Address; + addressTo: Address | string; +}) => { + // check is address `to` not ens name + if (isAddress(addressTo)) { + return addressTo as Address; + } else { + // get address `to` from ens name + const addressFromENSName = await store.fetchAddressByEnsName(addressTo); + if (addressFromENSName) { + if (addressFromENSName.toLowerCase() === activeAddress.toLowerCase()) { + return ''; + } else { + return addressFromENSName; + } + } else { + return ''; + } + } +}; + +export const checkIfAddressENS = ( + ensData: Record<`0x${string}`, EnsDataItem>, + activeWalletAddress: Address, + address?: Address | string, +) => { + if ( + address === undefined || + address.toLowerCase() === activeWalletAddress.toLowerCase() + ) { + return ''; + } else if (isEnsName(address)) { + const addressFromENS = getAddressByENSNameIfExists(ensData, address); + if (addressFromENS) { + if (addressFromENS?.toLowerCase() === activeWalletAddress.toLowerCase()) { + return ''; + } else { + return addressFromENS; + } + } else { + return address; + } + } else { + return address; + } +}; diff --git a/src/store/selectors/rpcSwitcherSelectors.ts b/src/store/selectors/rpcSwitcherSelectors.ts new file mode 100644 index 00000000..4774efb2 --- /dev/null +++ b/src/store/selectors/rpcSwitcherSelectors.ts @@ -0,0 +1,17 @@ +import { Client } from 'viem'; + +import { IRpcSwitcherSlice } from '../rpcSwitcherSlice'; + +export const selectAppClients = (store: IRpcSwitcherSlice) => { + return Object.entries(store.appClients).reduce( + (acc, [key, value]) => { + acc[key] = value.instance; + return acc; + }, + {} as Record, + ); +}; + +export const selectIsRpcAppHasErrors = (store: IRpcSwitcherSlice) => { + return Object.values(store.rpcAppErrors).some((error) => error.error); +}; diff --git a/src/store/transactionsSlice.ts b/src/store/transactionsSlice.ts new file mode 100644 index 00000000..5fc59e46 --- /dev/null +++ b/src/store/transactionsSlice.ts @@ -0,0 +1,301 @@ +import { + BaseTx as BT, + createTransactionsSlice as createBaseTransactionsSlice, + ITransactionsSlice, + IWalletSlice, + StoreSlice, + TransactionStatus, + WalletType, +} from '@bgd-labs/frontend-web3-utils'; +import { produce } from 'immer'; +import { Address, Hex } from 'viem'; + +import { gelatoApiKeys } from '../configs/appConfig'; + +export enum TxType { + createPayload = 'createPayload', + createProposal = 'createProposal', + activateVoting = 'activateVoting', + sendProofs = 'sendProofs', + activateVotingOnVotingMachine = 'activateVotingOnVotingMachine', + vote = 'vote', + closeAndSendVote = 'closeAndSendVote', + executeProposal = 'executeProposal', + executePayload = 'executePayload', + delegate = 'delegate', + test = 'test', + cancelProposal = 'cancelProposal', + representations = 'representations', + claimFees = 'claimFees', +} + +type BaseTx = BT & { + status?: TransactionStatus; + pending: boolean; + walletType: WalletType; +}; + +type CreatePayloadTx = BaseTx & { + type: TxType.createPayload; + payload: { + chainId: number; + payloadId: number; + payloadsController: string; + }; +}; + +type CreateProposalTx = BaseTx & { + type: TxType.createProposal; + payload: { + proposalId: number; + }; +}; + +type ActivateVotingTx = BaseTx & { + type: TxType.activateVoting; + payload: { + proposalId: number; + }; +}; + +type SendProofsTx = BaseTx & { + type: TxType.sendProofs; + payload: { + proposalId: number; + blockHash: string; + underlyingAsset: string; + withSlot: boolean; + }; +}; + +type ActivateVotingOnVotingMachineTx = BaseTx & { + type: TxType.activateVotingOnVotingMachine; + payload: { + proposalId: number; + }; +}; + +type VotingTx = BaseTx & { + type: TxType.vote; + payload: { + proposalId: number; + support: boolean; + voter: string; + }; +}; + +type CloseAndSendVoteTx = BaseTx & { + type: TxType.closeAndSendVote; + payload: { + proposalId: number; + }; +}; + +type ExecuteProposalTx = BaseTx & { + type: TxType.executeProposal; + payload: { + proposalId: number; + }; +}; + +type ExecutePayloadTx = BaseTx & { + type: TxType.executePayload; + payload: { + proposalId: number; + payloadId: number; + chainId: number; + payloadController?: Hex; + }; +}; + +// type DelegateTx = BaseTx & { +// type: TxType.delegate; +// payload: { +// delegateData: DelegateItem[]; +// formDelegateData: DelegateData[]; +// timestamp: number; +// }; +// }; + +type TestTx = BaseTx & { + type: TxType.test; +}; + +type CancelProposalTx = BaseTx & { + type: TxType.cancelProposal; + payload: { + proposalId: number; + }; +}; + +// type RepresentationsTx = BaseTx & { +// type: TxType.representations; +// payload: { +// initialData: RepresentationFormData[]; +// data: RepresentationFormData[]; +// timestamp: number; +// }; +// }; + +type ReturnFeesTx = BaseTx & { + type: TxType.claimFees; + payload: { + creator: Address; + proposalIds: number[]; + }; +}; + +export type TransactionUnion = + | CreatePayloadTx + | CreateProposalTx + | ActivateVotingTx + | SendProofsTx + | ActivateVotingOnVotingMachineTx + | VotingTx + | CloseAndSendVoteTx + | ExecuteProposalTx + | ExecutePayloadTx + // | DelegateTx + | TestTx + | CancelProposalTx + // | RepresentationsTx + | ReturnFeesTx; + +export type TransactionsSlice = ITransactionsSlice & { + isGelatoAvailableChains: Record; + checkIsGelatoAvailableWithApiKey: (chainId: number) => Promise; +}; + +export type TxWithStatus = TransactionUnion & { + status?: TransactionStatus; + pending: boolean; + replacedTxHash?: Hex; +}; + +export type AllTransactions = TxWithStatus[]; + +export const createTransactionsSlice: StoreSlice< + TransactionsSlice, + IWalletSlice +> = (set, get) => ({ + ...createBaseTransactionsSlice({ + txStatusChangedCallback: async () => { + // const updateProposalData = async (proposalId: number) => { + // await get().getDetailedProposalsData({ + // ids: [proposalId], + // fullData: true, + // }); + // }; + // switch (data.type) { + // case TxType.createPayload: + // await get().getDetailedPayloadsData( + // data.payload.chainId, + // data.payload.payloadsController as Hex, + // [data.payload.payloadId], + // ); + // set({ + // totalPayloadsCount: { + // ...get().totalPayloadsCount, + // [data.payload.payloadsController]: data.payload.payloadId + 1, + // }, + // }); + // break; + // case TxType.activateVoting: + // await updateProposalData(data.payload.proposalId); + // break; + // case TxType.sendProofs: + // await updateProposalData(data.payload.proposalId); + // break; + // case TxType.activateVotingOnVotingMachine: + // await updateProposalData(data.payload.proposalId); + // break; + // case TxType.vote: { + // const proposalData = getProposalDataById({ + // detailedProposalsData: get().detailedProposalsData, + // configs: get().configs, + // contractsConstants: get().contractsConstants, + // representativeLoading: get().representativeLoading, + // activeWallet: get().activeWallet, + // representative: get().representative, + // blockHashBalanceLoadings: get().blockHashBalanceLoadings, + // blockHashBalance: get().blockHashBalance, + // proposalId: data.payload.proposalId, + // }); + // + // if (proposalData) { + // const startBlock = + // proposalData.proposal.data.votingMachineData.createdBlock; + // + // await updateProposalData(data.payload.proposalId); + // await get().getVoters( + // data.payload.proposalId, + // proposalData.proposal.data.votingChainId, + // startBlock, + // ); + // } + // break; + // } + // case TxType.closeAndSendVote: + // await updateProposalData(data.payload.proposalId); + // break; + // case TxType.executeProposal: + // await updateProposalData(data.payload.proposalId); + // break; + // case TxType.executePayload: + // if (data.payload.payloadController) { + // await get().getPayloadsExploreData( + // data.payload.chainId, + // data.payload.payloadController, + // ); + // } else { + // await updateProposalData(data.payload.proposalId); + // } + // break; + // case TxType.delegate: + // await get().getDelegateData(); + // get().setIsDelegateChangedView(false); + // break; + // case TxType.representations: + // await get().getRepresentationData(); + // get().setIsRepresentationsChangedView(false); + // get().resetL1Balances(); + // break; + // case TxType.cancelProposal: + // await updateProposalData(data.payload.proposalId); + // break; + // case TxType.claimFees: + // await get().updateCreationFeesDataByCreator( + // data.payload.creator, + // data.payload.proposalIds, + // ); + // await get().getDetailedProposalsData({ + // ids: data.payload.proposalIds, + // fullData: true, + // }); + // break; + // } + }, + // for initial don't set default clients because of rpc switcher flow + defaultClients: {}, + })(set, get), + + isGelatoAvailableChains: {}, + checkIsGelatoAvailableWithApiKey: async (chainId) => { + if (typeof get().isGelatoAvailableChains[chainId] === 'undefined') { + if (gelatoApiKeys[chainId]) { + await get().checkIsGelatoAvailable(chainId); + set((state) => + produce(state, (draft) => { + draft.isGelatoAvailableChains[chainId] = get().isGelatoAvailable; + }), + ); + } else { + set((state) => + produce(state, (draft) => { + draft.isGelatoAvailableChains[chainId] = false; + }), + ); + } + } + }, +}); diff --git a/src/store/uiSlice.ts b/src/store/uiSlice.ts index 9da9c7a2..0f37289b 100644 --- a/src/store/uiSlice.ts +++ b/src/store/uiSlice.ts @@ -1,8 +1,6 @@ -// TODO +import { IWalletSlice, StoreSlice } from '@bgd-labs/frontend-web3-utils'; -import { StoreSlice } from '@bgd-labs/frontend-web3-utils'; - -import { isForIPFS, isTermsAndConditionsVisible } from '../appConfig'; +import { isForIPFS, isTermsAndConditionsVisible } from '../configs/appConfig'; import { getLocalStorageAppMode, getLocalStorageGaslessVote, @@ -10,8 +8,9 @@ import { setLocalStorageAppMode, setLocalStorageGaslessVote, setLocalStorageTermsAccept, -} from '../localStorage'; +} from '../configs/localStorage'; import { AppModeType } from '../types'; +import { TransactionsSlice } from './transactionsSlice'; export interface IUISlice { isGaslessVote: boolean; @@ -31,16 +30,23 @@ export interface IUISlice { appMode: AppModeType; checkAppMode: () => void; setAppMode: (appMode: AppModeType) => void; + + isModalOpen: boolean; + setModalOpen: (value: boolean) => void; + + isTermModalOpen: boolean; + setIsTermModalOpen: (value: boolean) => void; } -export const createUISlice: StoreSlice = (set) => ({ +export const createUISlice: StoreSlice< + IUISlice, + IWalletSlice & TransactionsSlice +> = (set, get) => ({ isGaslessVote: true, - checkIsGaslessVote: () => { + checkIsGaslessVote: (chainId) => { if ( - // eslint-disable-next-line - true - // get().isGelatoAvailableChains[chainId] && - // !get().activeWallet?.isContractAddress + get().isGelatoAvailableChains[chainId] && + !get().activeWallet?.isContractAddress ) { if (getLocalStorageGaslessVote() === 'on') { set({ isGaslessVote: true }); @@ -88,11 +94,7 @@ export const createUISlice: StoreSlice = (set) => ({ appMode: 'default', checkAppMode: () => { - if ( - // eslint-disable-next-line - true - // get().activeWallet?.isContractAddress - ) { + if (get().activeWallet?.isContractAddress) { setLocalStorageAppMode('default'); set({ appMode: 'default' }); } else { @@ -106,11 +108,7 @@ export const createUISlice: StoreSlice = (set) => ({ } }, setAppMode: (appMode) => { - if ( - // eslint-disable-next-line - true - // get().activeWallet?.isContractAddress - ) { + if (get().activeWallet?.isContractAddress) { setLocalStorageAppMode('default'); set({ appMode: 'default' }); } else { @@ -118,4 +116,14 @@ export const createUISlice: StoreSlice = (set) => ({ set({ appMode }); } }, + + isModalOpen: false, + setModalOpen: (value) => { + set({ isModalOpen: value }); + }, + + isTermModalOpen: false, + setIsTermModalOpen: (value) => { + set({ isModalOpen: value, isTermModalOpen: value }); + }, }); diff --git a/src/store/web3Slice.ts b/src/store/web3Slice.ts new file mode 100644 index 00000000..537b37b9 --- /dev/null +++ b/src/store/web3Slice.ts @@ -0,0 +1,30 @@ +import { + createWalletSlice, + IWalletSlice, + StoreSlice, +} from '@bgd-labs/frontend-web3-utils'; + +import { TransactionsSlice } from './transactionsSlice'; + +export type IWeb3Slice = IWalletSlice & { + // need for connect wallet button to not show last tx status always after connected wallet + walletConnectedTimeLock: boolean; + connectSigner: () => void; +}; + +export const createWeb3Slice: StoreSlice = ( + set, + get, +) => ({ + ...createWalletSlice({ + walletConnected: () => { + get().connectSigner(); + }, + })(set, get), + + walletConnectedTimeLock: false, + connectSigner() { + set({ walletConnectedTimeLock: true }); + setTimeout(() => set({ walletConnectedTimeLock: false }), 1000); + }, +}); diff --git a/src/styles/text-center-ellipsis.ts b/src/styles/text-center-ellipsis.ts new file mode 100644 index 00000000..0b19289a --- /dev/null +++ b/src/styles/text-center-ellipsis.ts @@ -0,0 +1,6 @@ +export function textCenterEllipsis(str: string, from: number, to: number) { + return `${(str || '').substr(0, from)}...${(str || '').substr( + (str || '').length - to, + (str || '').length, + )}`; +} diff --git a/src/styles/useClickOutside.tsx b/src/styles/useClickOutside.tsx new file mode 100644 index 00000000..b2a36b24 --- /dev/null +++ b/src/styles/useClickOutside.tsx @@ -0,0 +1,29 @@ +import React, { useEffect } from 'react'; + +type UseClickOutsideType = { + ref: React.RefObject; + outsideClickFunc: () => void; + additionalCondition?: boolean; +}; + +export function useClickOutside({ + ref, + outsideClickFunc, + additionalCondition, +}: UseClickOutsideType) { + useEffect(() => { + function handleClickOutside(event: any) { + if ( + ref.current && + !ref.current.contains(event.target) && + additionalCondition + ) { + outsideClickFunc(); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref, additionalCondition]); +} diff --git a/src/types.ts b/src/types.ts index 51d7d9ef..abf0bf56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ // ui +import { Address, Chain, Client } from 'viem'; + export type AppModeType = 'default' | 'dev' | 'expert'; export type IsGaslessVote = 'on' | 'off'; @@ -103,3 +105,50 @@ export type ProposalItemDataByWallet = Pick & { }; // Proposal screen (WIP) + +// ENS +export enum ENSProperty { + NAME = 'name', + AVATAR = 'avatar', +} + +export type EnsDataItem = { + name?: string; + avatar?: { + url?: string; + isExists?: boolean; + }; + fetched?: { + name?: number; + avatar?: number; + }; +}; + +// RPC switching +export type AppClient = { + instance: Client; + rpcUrl: string; +}; + +export type RpcSwitcherFormData = { chainId: number; rpcUrl: string }[]; + +export type AppClientsStorage = Omit; + +export type ChainInfo = { + clientInstances: Record; + getChainParameters: (chainId: number) => Chain; +}; + +export type SetRpcErrorParams = { + isError: boolean; + rpcUrl: string; + chainId: number; +}; + +// representation +export type RepresentativeAddress = { + chainsIds: number[]; + address: Address | ''; +}; + +export type RepresentedAddress = { chainId: number; address: Address | '' }; diff --git a/tsconfig.json b/tsconfig.json index 71057fca..0f6aa132 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "noEmit": true, "esModuleInterop": true, "module": "ESNext", - "moduleResolution": "node", + "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "isolatedModules": true,