From 766eab755baf0c48372a7661ca4c03395c88c12b Mon Sep 17 00:00:00 2001 From: XiaoYhun Date: Thu, 19 Oct 2023 15:05:29 +0700 Subject: [PATCH] Chain favourite (#2300) * update UI * update favorite chains UI * fix same chain name issue * some improvements * refactor code * improve animation * undo test codes * remove unuse code * pr env * fix warning * revert * fix comment * fix comment --- .../NetworkModal/DraggableNetworkButton.tsx | 316 ++++++++++++++++++ .../Header/web3/NetworkModal/Networks.tsx | 248 -------------- .../Header/web3/NetworkModal/index.tsx | 220 ++++++++++-- src/components/Tooltip/index.tsx | 12 +- src/services/identity.ts | 5 +- src/state/authen/reducer.ts | 2 +- 6 files changed, 529 insertions(+), 274 deletions(-) create mode 100644 src/components/Header/web3/NetworkModal/DraggableNetworkButton.tsx delete mode 100644 src/components/Header/web3/NetworkModal/Networks.tsx diff --git a/src/components/Header/web3/NetworkModal/DraggableNetworkButton.tsx b/src/components/Header/web3/NetworkModal/DraggableNetworkButton.tsx new file mode 100644 index 0000000000..155584247c --- /dev/null +++ b/src/components/Header/web3/NetworkModal/DraggableNetworkButton.tsx @@ -0,0 +1,316 @@ +import { ChainId, getChainType } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import { motion, useAnimationControls, useDragControls } from 'framer-motion' +import { rgba } from 'polished' +import { stringify } from 'querystring' +import { MutableRefObject, RefObject, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { Minus, Plus } from 'react-feather' +import { useNavigate } from 'react-router-dom' +import { Text } from 'rebass' +import styled, { css } from 'styled-components' + +import Icon from 'components/Icons/Icon' +import Row from 'components/Row' +import { MouseoverTooltip } from 'components/Tooltip' +import { NetworkInfo } from 'constants/networks/type' +import { Z_INDEXS } from 'constants/styles' +import { SUPPORTED_WALLETS } from 'constants/wallets' +import { useActiveWeb3React } from 'hooks' +import { ChainState } from 'hooks/useChainsConfig' +import useParsedQueryString from 'hooks/useParsedQueryString' +import useTheme from 'hooks/useTheme' +import { useChangeNetwork } from 'hooks/web3/useChangeNetwork' + +const NewLabel = styled.span` + font-size: 12px; + color: ${({ theme }) => theme.red}; + margin-left: 2px; + margin-top: -10px; +` + +const MaintainLabel = styled.span` + font-size: 8px; + color: ${({ theme }) => theme.red}; + margin-left: 2px; + margin-top: -10px; +` + +const ListItem = styled(motion.div)<{ selected?: boolean; $disabled?: boolean; $dragging?: boolean }>` + width: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 10px 8px; + border-radius: 999px; + overflow: hidden; + white-space: nowrap; + font-size: 14px; + height: 36px; + color: ${({ theme }) => theme.subText}; + background-color: ${({ theme }) => theme.tableHeader}; + user-select: none; + cursor: pointer; + gap: 6px; + transition: background-color 0.2s ease; + .drag-button { + opacity: 0; + display: none; + } + :hover .drag-button { + opacity: 1; + display: block; + } + + ${({ theme, selected }) => + selected + ? css` + background-color: ${theme.buttonBlack}; + & > div { + color: ${theme.text}; + } + ` + : css` + :hover { + background-color: ${theme.background}; + } + `} + + ${({ $disabled }) => + $disabled && + css` + cursor: not-allowed; + color: ${({ theme }) => theme.subText + '72'}; + `} + + ${({ theme }) => theme.mediaWidth.upToSmall` + font-size: 12px; + `}; +` + +const CircleGreen = styled.div` + height: 16px; + width: 16px; + background-color: ${({ theme }) => theme.primary}; + background-clip: content-box; + border: solid 2px ${({ theme }) => rgba(theme.primary, 0.3)}; + border-radius: 8px; + margin-left: auto; +` +const WalletWrapper = styled.div` + height: 18px; + width: 18px; + margin-left: auto; + margin-right: 4px; + > img { + height: 18px; + width: 18px; + } +` + +export default function DraggableNetworkButton({ + droppableRefs, + networkInfo, + activeChainIds, + isSelected, + disabledMsg, + isEdittingMobile, + isAddButton, + dragConstraints, + customToggleModal, + customOnSelectNetwork, + onChangedNetwork, + onDrop, + onFavoriteClick, +}: { + droppableRefs: MutableRefObject + networkInfo: NetworkInfo + activeChainIds?: ChainId[] + isSelected?: boolean + disabledMsg?: string + isEdittingMobile?: boolean + isAddButton?: boolean + dragConstraints?: RefObject + customToggleModal?: () => void + customOnSelectNetwork?: (chainId: ChainId) => void + onChangedNetwork?: () => void + onDrop?: (dropIndex: string) => void + onFavoriteClick?: () => void +}) { + const theme = useTheme() + const { isWrongNetwork, walletSolana, walletEVM } = useActiveWeb3React() + const { changeNetwork } = useChangeNetwork() + const qs = useParsedQueryString() + const navigate = useNavigate() + const [isDragging, setIsDragging] = useState(false) + const dragControls = useDragControls() + const animateControls = useAnimationControls() + const { state, icon, chainId, name } = networkInfo + const isMaintenance = state === ChainState.MAINTENANCE + const disabled = (activeChainIds ? !activeChainIds?.includes(chainId) : false) || isMaintenance + const selected = isSelected && !isWrongNetwork + const walletKey = + chainId === ChainId.SOLANA ? walletSolana.walletKey : walletEVM.chainId === chainId ? walletEVM.walletKey : null + + const handleChainSelect = () => { + if (disabled) return + customToggleModal?.() + if (customOnSelectNetwork) { + customOnSelectNetwork(chainId) + } else if (getChainType(chainId) === getChainType(chainId)) { + changeNetwork(chainId, () => { + const { inputCurrency, outputCurrency, ...rest } = qs + navigate( + { + search: stringify(rest), + }, + { replace: true }, + ) + onChangedNetwork?.() + }) + } else { + changeNetwork(chainId, () => { + navigate( + { + search: '', + }, + { replace: true }, + ) + onChangedNetwork?.() + }) + } + } + + const variants = { + dragging: { + boxShadow: '0 5px 10px #00000060', + filter: 'brightness(0.8)', + scale: 1.05, + zIndex: 2, + }, + normal: { + boxShadow: '0 0px 0px #00000060', + filter: 'brightness(1)', + scale: 1, + zIndex: 'unset', + x: 0, + y: 0, + }, + } + + const handleDragStart = () => { + animateControls.start('dragging') + setIsDragging(true) + } + const handleDragEnd = (e: any) => { + animateControls.start('normal') + + const eventTarget = e.target as HTMLDivElement + + if (!eventTarget) return + + const droppedElPosition = [ + eventTarget.getBoundingClientRect().left + eventTarget.getBoundingClientRect().width / 2, + eventTarget.getBoundingClientRect().top + eventTarget.getBoundingClientRect().height / 2, + ] + + const currentEl: HTMLDivElement | undefined = droppableRefs.current?.find((el: HTMLDivElement) => { + const { left, top, right, bottom } = el.getBoundingClientRect() + return ( + left < droppedElPosition[0] && + top < droppedElPosition[1] && + right > droppedElPosition[0] && + bottom > droppedElPosition[1] + ) + }) + if (currentEl) { + onDrop?.(currentEl.id) + } + } + + return ( + + setIsDragging(true)} + onLayoutAnimationComplete={() => setIsDragging(false)} + layoutId={networkInfo.chainId.toString() + networkInfo.route} + selected={selected} + animate={animateControls} + transition={{ type: 'spring', damping: 50, stiffness: 1000 }} + variants={variants} + style={{ boxShadow: '0 0px 0px #00000060' }} + onClick={() => !selected && !isDragging && handleChainSelect()} + $disabled={disabled} + > + Switch Network + + + {name} + + {state === ChainState.NEW && ( + + New + + )} + {isMaintenance && ( + + Maintainance + + )} + {selected && !walletKey && } + {walletKey && ( + + {SUPPORTED_WALLETS[walletKey].name + + )} + + {isMobile ? ( + isEdittingMobile && ( +
{ + e.stopPropagation() + onFavoriteClick?.() + }} + > + {isAddButton ? : } +
+ ) + ) : ( +
{ + dragControls.start(e) + e.stopPropagation() + }} + onClick={e => e.stopPropagation()} + > + +
+ )} +
+
+ ) +} diff --git a/src/components/Header/web3/NetworkModal/Networks.tsx b/src/components/Header/web3/NetworkModal/Networks.tsx deleted file mode 100644 index 745a3472a5..0000000000 --- a/src/components/Header/web3/NetworkModal/Networks.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { ChainId, getChainType } from '@kyberswap/ks-sdk-core' -import { Trans, t } from '@lingui/macro' -import { darken, rgba } from 'polished' -import { stringify } from 'querystring' -import React from 'react' -import { useNavigate } from 'react-router-dom' -import { Flex, Text } from 'rebass' -import styled, { css } from 'styled-components' - -import { ButtonEmpty } from 'components/Button' -import { MouseoverTooltip } from 'components/Tooltip' -import { NetworkInfo } from 'constants/networks/type' -import { Z_INDEXS } from 'constants/styles' -import { SUPPORTED_WALLETS } from 'constants/wallets' -import { useActiveWeb3React } from 'hooks' -import useChainsConfig, { ChainState } from 'hooks/useChainsConfig' -import useParsedQueryString from 'hooks/useParsedQueryString' -import useTheme from 'hooks/useTheme' -import { useChangeNetwork } from 'hooks/web3/useChangeNetwork' - -const NewLabel = styled.span` - font-size: 12px; - color: ${({ theme }) => theme.red}; - margin-left: 2px; - margin-top: -10px; -` - -const MaintainLabel = styled.span` - font-size: 8px; - color: ${({ theme }) => theme.red}; - margin-left: 2px; - margin-top: -10px; -` - -const ListItem = styled.div<{ selected?: boolean }>` - width: 100%; - display: flex; - justify-content: flex-start; - align-items: center; - padding: 10px 8px; - border-radius: 999px; - overflow: hidden; - white-space: nowrap; - font-size: 14px; - height: 36px; - ${({ theme, selected }) => - selected && - css` - background-color: ${theme.buttonBlack}; - & > div { - color: ${theme.text}; - } - `} - - ${({ theme }) => theme.mediaWidth.upToSmall` - font-size: 12px; - `} -` - -const SelectNetworkButton = styled(ButtonEmpty)<{ disabled: boolean }>` - color: ${({ theme }) => theme.primary}; - background-color: ${({ theme }) => theme.tableHeader}; - display: flex; - justify-content: center; - align-items: center; - height: fit-content; - text-decoration: none; - transition: all 0.1s ease; - &:hover { - background-color: ${({ theme }) => darken(0.1, theme.tableHeader)}; - color: ${({ theme }) => theme.text} !important; - } - &:disabled { - opacity: 50%; - cursor: not-allowed; - &:hover { - border: 1px solid transparent; - } - } -` -const gap = '1rem' -const NetworkList = styled.div<{ mt: number; mb: number }>` - display: flex; - align-items: center; - gap: ${gap}; - flex-wrap: wrap; - width: 100%; - margin-top: ${({ mt }) => mt}px; - margin-bottom: ${({ mb }) => mb}px; - & > * { - width: calc(33.33% - ${gap} * 2 / 3); - } - ${({ theme }) => theme.mediaWidth.upToSmall` - & > * { - width: calc(50% - ${gap} / 2); - } - `} -` - -const CircleGreen = styled.div` - height: 16px; - width: 16px; - background-color: ${({ theme }) => theme.primary}; - background-clip: content-box; - border: solid 2px ${({ theme }) => rgba(theme.primary, 0.3)}; - border-radius: 8px; - margin-left: auto; -` -const WalletWrapper = styled.div` - height: 18px; - width: 18px; - margin-left: auto; - margin-right: 4px; - > img { - height: 18px; - width: 18px; - } -` - -const Networks = ({ - onChangedNetwork, - mt = 30, - mb = 0, - isAcceptedTerm = true, - activeChainIds, - selectedId, - customOnSelectNetwork, - customToggleModal, - disabledMsg, -}: { - onChangedNetwork?: () => void - mt?: number - mb?: number - isAcceptedTerm?: boolean - activeChainIds?: ChainId[] - selectedId?: ChainId - customOnSelectNetwork?: (chainId: ChainId) => void - customToggleModal?: () => void - disabledMsg?: string -}) => { - const { chainId: currentChainId, isWrongNetwork, walletEVM, walletSolana } = useActiveWeb3React() - const { changeNetwork } = useChangeNetwork() - const qs = useParsedQueryString() - const navigate = useNavigate() - const theme = useTheme() - const onSelect = (chainId: ChainId) => { - customToggleModal?.() - if (customOnSelectNetwork) { - customOnSelectNetwork(chainId) - } else if (getChainType(currentChainId) === getChainType(chainId)) { - changeNetwork(chainId, () => { - const { inputCurrency, outputCurrency, ...rest } = qs - navigate( - { - search: stringify(rest), - }, - { replace: true }, - ) - onChangedNetwork?.() - }) - } else { - changeNetwork(chainId, () => { - navigate( - { - search: '', - }, - { replace: true }, - ) - onChangedNetwork?.() - }) - } - } - - const { supportedChains } = useChainsConfig() - - return ( - - {supportedChains.map(({ chainId: itemChainId, icon, name, state }: NetworkInfo, i: number) => { - const isMaintenance = state === ChainState.MAINTENANCE - const disabled = - !isAcceptedTerm || (activeChainIds ? !activeChainIds?.includes(itemChainId) : false) || isMaintenance - const selected = selectedId === itemChainId && !isWrongNetwork - - const imgSrc = icon - const walletKey = - itemChainId === ChainId.SOLANA - ? walletSolana.walletKey - : walletEVM.chainId === itemChainId - ? walletEVM.walletKey - : null - return ( - - !selected && onSelect(itemChainId)} - data-testid="network-button" - disabled={disabled} - > - - Switch Network - - - {name} - - - {state === ChainState.NEW && ( - - New - - )} - {isMaintenance && ( - - Maintainance - - )} - {selected && !walletKey && } - {walletKey && ( - - {SUPPORTED_WALLETS[walletKey].name - - )} - - - - ) - })} - - ) -} - -export default React.memo(Networks) diff --git a/src/components/Header/web3/NetworkModal/index.tsx b/src/components/Header/web3/NetworkModal/index.tsx index 9d318a475a..a847cd47da 100644 --- a/src/components/Header/web3/NetworkModal/index.tsx +++ b/src/components/Header/web3/NetworkModal/index.tsx @@ -1,22 +1,48 @@ import { ChainId } from '@kyberswap/ks-sdk-core' import { Trans } from '@lingui/macro' -import { X } from 'react-feather' -import { Flex, Text } from 'rebass' +import { useEffect, useRef, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { Save, X } from 'react-feather' +import { Button, Text } from 'rebass' +import { useUpdateProfileMutation } from 'services/identity' import styled from 'styled-components' +import { ButtonAction } from 'components/Button' +import Column from 'components/Column' import Modal from 'components/Modal' +import Row, { RowBetween, RowFit } from 'components/Row' +import { NetworkInfo } from 'constants/networks/type' import { Z_INDEXS } from 'constants/styles' import { useActiveWeb3React } from 'hooks' +import useChainsConfig from 'hooks/useChainsConfig' +import useTheme from 'hooks/useTheme' import { ApplicationModal } from 'state/application/actions' import { useModalOpen, useNetworkModalToggle } from 'state/application/hooks' +import { useSessionInfo } from 'state/authen/hooks' import { TYPE } from 'theme' -import Networks from './Networks' +import DraggableNetworkButton from './DraggableNetworkButton' const Wrapper = styled.div` width: 100%; - padding: 32px 24px 24px; + padding: 20px; ` +const gap = isMobile ? '8px' : '16px' + +const NetworkList = styled.div` + display: flex; + align-items: center; + column-gap: ${gap}; + row-gap: 4px; + flex-wrap: wrap; + width: 100%; + & > * { + width: calc(50% - ${gap} / 2); + } +` + +const FAVORITE_DROPZONE_ID = 'favorite-dropzone' +const CHAINS_DROPZONE_ID = 'chains-dropzone' export default function NetworkModal({ activeChainIds, @@ -33,40 +59,188 @@ export default function NetworkModal({ customToggleModal?: () => void disabledMsg?: string }): JSX.Element | null { + const theme = useTheme() const { isWrongNetwork } = useActiveWeb3React() + const [requestSaveProfile] = useUpdateProfileMutation() + const { userInfo } = useSessionInfo() + + const [favoriteChains, setFavoriteChains] = useState(userInfo?.data?.favouriteChainIds || []) + const [isEdittingMobile, setIsEdittingMobile] = useState(false) + const wrapperRef = useRef(null) + const networkModalOpen = useModalOpen(ApplicationModal.NETWORK) const toggleNetworkModalGlobal = useNetworkModalToggle() const toggleNetworkModal = customToggleModal || toggleNetworkModalGlobal + + const droppableRefs = useRef([]) + const { supportedChains } = useChainsConfig() + + const handleDrop = (chainId: string, dropId: string) => { + if (dropId === FAVORITE_DROPZONE_ID) { + const chainInfo = supportedChains.find(item => item.chainId.toString() === chainId) + if (chainInfo && !favoriteChains.includes(chainInfo)) { + saveFavoriteChains([...favoriteChains, chainInfo.chainId.toString()]) + } + } + if (dropId === CHAINS_DROPZONE_ID) { + if (favoriteChains.some(fChainId => fChainId === chainId)) { + saveFavoriteChains([...favoriteChains.filter(fChainId => fChainId !== chainId)]) + } + } + } + + const handleFavoriteChangeMobile = (chainId: string, isAdding: boolean) => { + if (isAdding) { + const chainInfo = supportedChains.find(item => item.chainId.toString() === chainId) + if (chainInfo && !favoriteChains.includes(chainInfo)) { + saveFavoriteChains([...favoriteChains, chainInfo.chainId.toString()]) + } + } else { + if (favoriteChains.some(fChainId => fChainId === chainId)) { + saveFavoriteChains([...favoriteChains.filter(fChainId => fChainId !== chainId)]) + } + } + } + + const saveFavoriteChains = (chains: string[]) => { + const uniqueArray = Array.from(new Set(chains)) + requestSaveProfile({ data: { favouriteChainIds: uniqueArray } }) + setFavoriteChains(uniqueArray) + } + + const renderNetworkButton = (networkInfo: NetworkInfo, isAdding: boolean) => { + return ( + { + handleDrop(networkInfo.chainId.toString(), dropId) + }} + customToggleModal={customToggleModal} + customOnSelectNetwork={customOnSelectNetwork} + onChangedNetwork={toggleNetworkModal} + // Mobile only props + isAddButton={isAdding} + isEdittingMobile={isEdittingMobile} + onFavoriteClick={() => handleFavoriteChangeMobile(networkInfo.chainId.toString(), isAdding)} + /> + ) + } + + useEffect(() => { + setFavoriteChains(userInfo?.data?.favouriteChainIds || []) + }, [userInfo]) + return ( - - + + {isWrongNetwork ? Wrong Chain : Select a Chain} - - + - - - {isWrongNetwork && ( - - Please connect to the appropriate chain. - - )} - + + + + + + + Favorite Chain(s) + +
+ {isMobile && + (isEdittingMobile ? ( + + ) : ( + + ))} +
+
{ + if (ref) { + droppableRefs.current[0] = ref + } + }} + id={FAVORITE_DROPZONE_ID} + > + {favoriteChains.length === 0 ? ( + + + Drag your favourite chain(s) here + + + ) : ( + + {supportedChains + .filter(chain => favoriteChains.some(i => i === chain.chainId.toString())) + .map((networkInfo: NetworkInfo) => { + return renderNetworkButton(networkInfo, false) + })} + + )} +
+ + + + Chain List + +
+
+ {isWrongNetwork && ( + + Please connect to the appropriate chain. + + )} + { + if (ref) { + droppableRefs.current[1] = ref + } + }} + id={CHAINS_DROPZONE_ID} + > + {supportedChains + .filter(chain => !favoriteChains.some(i => i === chain.chainId.toString())) + .map((networkInfo: NetworkInfo) => { + return renderNetworkButton(networkInfo, true) + })} + +
) diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index e19d577876..2c14821285 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -37,7 +37,16 @@ interface TooltipProps extends Omit { children?: React.ReactNode } -export default function Tooltip({ text, width, maxWidth, size, onMouseEnter, onMouseLeave, ...rest }: TooltipProps) { +export default function Tooltip({ + text, + width, + maxWidth, + size, + onMouseEnter, + onMouseLeave, + show, + ...rest +}: TooltipProps) { return ( ) : null } + show={!!text && show} {...rest} /> ) diff --git a/src/services/identity.ts b/src/services/identity.ts index afcbef54a7..d062f32058 100644 --- a/src/services/identity.ts +++ b/src/services/identity.ts @@ -39,7 +39,10 @@ const identityApi = createApi({ method: 'PUT', }), }), - updateProfile: builder.mutation({ + updateProfile: builder.mutation< + any, + { nickname?: string; avatarURL?: string; data?: { favouriteChainIds: string[] } } + >({ query: body => ({ url: `/v1/profile/me`, body, diff --git a/src/state/authen/reducer.ts b/src/state/authen/reducer.ts index 7310fcf4f4..74640b4899 100644 --- a/src/state/authen/reducer.ts +++ b/src/state/authen/reducer.ts @@ -6,7 +6,7 @@ export type UserProfile = { telegramUsername: string nickname: string avatarUrl: string - data: { hasAccessToKyberAI: boolean } + data: { hasAccessToKyberAI: boolean; favouriteChainIds?: string[] } } export type ConfirmProfile = { showModal: boolean