diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 2c6ed536fc..4b5dff9449 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -114,7 +114,7 @@ jobs: VITE_TAG: ${{ needs.prepare.outputs.image_tag }} CURRENT_BRANCH: ${{ needs.prepare.outputs.current_branch }} NODE_OPTIONS: '--max_old_space_size=4096' - run: yarn build + run: yarn build-dev - name: Docker build and push uses: docker/build-push-action@v2 diff --git a/src/assets/images/default_avatar.png b/src/assets/images/default_avatar.png new file mode 100644 index 0000000000..96645558d2 Binary files /dev/null and b/src/assets/images/default_avatar.png differ diff --git a/src/assets/images/portfolio/nft_logo.png b/src/assets/images/portfolio/nft_logo.png new file mode 100644 index 0000000000..a8e1f23926 Binary files /dev/null and b/src/assets/images/portfolio/nft_logo.png differ diff --git a/src/assets/images/portfolio/portfolio1.png b/src/assets/images/portfolio/portfolio1.png new file mode 100644 index 0000000000..5ccd9e9780 Binary files /dev/null and b/src/assets/images/portfolio/portfolio1.png differ diff --git a/src/assets/images/portfolio/portfolio2.png b/src/assets/images/portfolio/portfolio2.png new file mode 100644 index 0000000000..344e424ec1 Binary files /dev/null and b/src/assets/images/portfolio/portfolio2.png differ diff --git a/src/assets/images/portfolio/portfolio3.png b/src/assets/images/portfolio/portfolio3.png new file mode 100644 index 0000000000..294307c0c3 Binary files /dev/null and b/src/assets/images/portfolio/portfolio3.png differ diff --git a/src/assets/images/truesight-v2/modal_background.png b/src/assets/images/share/background_kyberai.png similarity index 100% rename from src/assets/images/truesight-v2/modal_background.png rename to src/assets/images/share/background_kyberai.png diff --git a/src/assets/images/truesight-v2/modal_background_mobile.png b/src/assets/images/share/background_mobile_kyberai.png similarity index 100% rename from src/assets/images/truesight-v2/modal_background_mobile.png rename to src/assets/images/share/background_mobile_kyberai.png diff --git a/src/assets/images/share/background_mobile_portfolio.png b/src/assets/images/share/background_mobile_portfolio.png new file mode 100644 index 0000000000..c8aa28750b Binary files /dev/null and b/src/assets/images/share/background_mobile_portfolio.png differ diff --git a/src/assets/images/share/background_portfolio.png b/src/assets/images/share/background_portfolio.png new file mode 100644 index 0000000000..ccc117f449 Binary files /dev/null and b/src/assets/images/share/background_portfolio.png differ diff --git a/src/assets/svg/nft_icon.svg b/src/assets/svg/nft_icon.svg index 86114a87ce..82750254d6 100644 --- a/src/assets/svg/nft_icon.svg +++ b/src/assets/svg/nft_icon.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/svg/portfolio.svg b/src/assets/svg/portfolio.svg new file mode 100644 index 0000000000..2830c6e6df --- /dev/null +++ b/src/assets/svg/portfolio.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svg/tokens_icon.svg b/src/assets/svg/tokens_icon.svg new file mode 100644 index 0000000000..89a8d40383 --- /dev/null +++ b/src/assets/svg/tokens_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/Announcement/Popups/TransactionPopup.tsx b/src/components/Announcement/Popups/TransactionPopup.tsx index e1286ecf70..317debd107 100644 --- a/src/components/Announcement/Popups/TransactionPopup.tsx +++ b/src/components/Announcement/Popups/TransactionPopup.tsx @@ -205,13 +205,13 @@ const getTitle = (type: string, success: boolean) => { } const getSummary = (transaction: TransactionDetails) => { - const { type, hash, group } = transaction + const { type, hash } = transaction const { success } = getTransactionStatus(transaction) - const shortHash = 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65) + const shortHash = 'Hash: ' + getShortenAddress(hash) - const summary = group ? SUMMARY[type]?.(transaction) ?? shortHash : shortHash + const summary = SUMMARY[type]?.(transaction) ?? shortHash let formatSummary, title = getTitle(type, success) diff --git a/src/components/Badge/index.tsx b/src/components/Badge/index.tsx index ac30b2cb6f..3f3df8b36b 100644 --- a/src/components/Badge/index.tsx +++ b/src/components/Badge/index.tsx @@ -1,14 +1,19 @@ import { readableColor } from 'polished' import { PropsWithChildren } from 'react' +import { Text } from 'rebass' import styled, { DefaultTheme } from 'styled-components' +import { DropdownArrowIcon } from 'components/ArrowRotate' import { Color } from 'theme/styled' +import { formatDisplayNumber } from 'utils/numbers' export enum BadgeVariant { DEFAULT = 'DEFAULT', NEGATIVE = 'NEGATIVE', POSITIVE = 'POSITIVE', PRIMARY = 'PRIMARY', + BLUE = 'BLUE', + WHITE = 'WHITE', WARNING = 'WARNING', WARNING_OUTLINE = 'WARNING_OUTLINE', @@ -26,6 +31,10 @@ function pickBackgroundColor(variant: BadgeVariant | undefined, theme: DefaultTh return theme.green1 case BadgeVariant.PRIMARY: return theme.primary + '33' + case BadgeVariant.BLUE: + return theme.blue + '33' + case BadgeVariant.WHITE: + return theme.white + '33' case BadgeVariant.WARNING: return theme.warning + '33' case BadgeVariant.WARNING_OUTLINE: @@ -54,6 +63,10 @@ function pickFontColor(variant: BadgeVariant | undefined, theme: DefaultTheme): return theme.warning case BadgeVariant.PRIMARY: return theme.primary + case BadgeVariant.BLUE: + return theme.blue + case BadgeVariant.WHITE: + return theme.white case BadgeVariant.WARNING_OUTLINE: return theme.warning default: @@ -74,3 +87,18 @@ const Badge = styled.div>` ` export default Badge + +// todo update my earning use this +export const PercentBadge = ({ percent }: { percent: number }) => { + return ( + + 0} />{' '} + + {formatDisplayNumber(percent, { style: 'percent', fractionDigits: 2 })} + + + ) +} diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index a20ba80a34..c7fec05f88 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -414,6 +414,9 @@ const StyledButtonAction = styled(RebassButton)<{ $color?: string }>` background-color: ${({ theme, $color }) => ($color ? $color + '10' : theme.subText + '10')}; transform: translateY(2px); } + :disabled { + cursor: not-allowed; + } ` export const ButtonAction = ({ diff --git a/src/components/CheckBox.tsx b/src/components/CheckBox.tsx index 4ae40f3938..bf0f7961fc 100644 --- a/src/components/CheckBox.tsx +++ b/src/components/CheckBox.tsx @@ -6,6 +6,9 @@ const CheckboxWrapper = styled.input` position: relative; transform: scale(1.35); accent-color: ${({ theme }) => theme.primary}; + &:focus-visible { + outline-width: 0; + } :indeterminate::before { content: ''; diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index e69e4d2a59..dcbb883a6c 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -53,7 +53,7 @@ const ModalConfirm: React.FC = () => { - + {content} diff --git a/src/components/DoubleLogo/index.tsx b/src/components/DoubleLogo/index.tsx index 147598a68d..f145b2c9c9 100644 --- a/src/components/DoubleLogo/index.tsx +++ b/src/components/DoubleLogo/index.tsx @@ -1,8 +1,10 @@ import { Currency } from '@kyberswap/ks-sdk-core' +import { Box } from 'rebass' import styled, { CSSProperties } from 'styled-components' import CurrencyLogo from 'components/CurrencyLogo' import Logo from 'components/Logo' +import { RowFit } from 'components/Row' const Wrapper = styled.div<{ margin: boolean; sizeraw: number }>` position: relative; @@ -80,3 +82,37 @@ export function DoubleCurrencyLogoV2({ ) } + +export function DoubleLogoWithChain({ + logoUrl1, + logoUrl2, + chainUrl, + size = 36, + chainSize = 18, +}: { + logoUrl1: string + logoUrl2: string + chainUrl: string + size?: number + chainSize?: number +}) { + return ( + + {logoUrl1 && ( + + + + )} + {logoUrl2 && ( + + + + )} + {chainUrl && ( + + + + )} + + ) +} diff --git a/src/components/DownloadWalletModal/index.tsx b/src/components/DownloadWalletModal/index.tsx index 1fe76d7921..4cc8e79a7a 100644 --- a/src/components/DownloadWalletModal/index.tsx +++ b/src/components/DownloadWalletModal/index.tsx @@ -8,6 +8,7 @@ import Column from 'components/Column' import Modal from 'components/Modal' import Row, { RowBetween } from 'components/Row' import { connections } from 'constants/wallets' +import { useActiveWeb3React } from 'hooks' import useTheme from 'hooks/useTheme' import { ApplicationModal } from 'state/application/actions' import { useCloseModal, useModalOpen } from 'state/application/hooks' @@ -41,6 +42,8 @@ export default function DownloadWalletModal() { const theme = useTheme() const isOpen = useModalOpen(ApplicationModal.DOWNLOAD_WALLET) const closeModal = useCloseModal(ApplicationModal.DOWNLOAD_WALLET) + const { account } = useActiveWeb3React() + if (!account) return null return ( diff --git a/src/components/EarningPieChart/index.tsx b/src/components/EarningPieChart/index.tsx index 5e47100148..3787e469bc 100644 --- a/src/components/EarningPieChart/index.tsx +++ b/src/components/EarningPieChart/index.tsx @@ -1,7 +1,7 @@ import { ChainId } from '@kyberswap/ks-sdk-core' import { Trans, t } from '@lingui/macro' import { darken, rgba } from 'polished' -import { useCallback, useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { HelpCircle } from 'react-feather' import { PieChart, pieChartDefaultProps } from 'react-minimal-pie-chart' import { Flex, Text } from 'rebass' @@ -12,6 +12,7 @@ import { EMPTY_ARRAY } from 'constants/index' import useTheme from 'hooks/useTheme' import { Loading } from 'pages/ProAmmPool/ContentLoader' import { formatDisplayNumber } from 'utils/numbers' +import { getProxyTokenLogo } from 'utils/tokenInfo' const LegendsWrapper = styled.div` display: flex; @@ -58,7 +59,7 @@ type LegendProps = { logoUrl?: string chainId?: ChainId label: string - value: string + value?: string percent: number active?: boolean @@ -100,7 +101,7 @@ const Legend: React.FC = ({ justifyContent: 'center', }} > - {logoUrl ? : } + {logoUrl ? : } {chainId && ( = ({ whiteSpace: 'nowrap', }} > - {formatDisplayNumber(value, { style: 'currency', fractionDigits: 2 })} ( + {value !== undefined && formatDisplayNumber(value, { style: 'currency', fractionDigits: 2 })} ( {formatDisplayNumber(percent / 100, { style: 'percent', fractionDigits: 3 })}) @@ -165,22 +166,24 @@ const COLORS = [ '#fee440', '#c0392b', ] +const getColor = (i: number) => COLORS[i % COLORS.length] -type DataEntry = { +export type DataEntry = { chainId?: ChainId logoUrl?: string + value?: string // usd symbol: string - value: string percent: number } type Props = { className?: string - isLoading?: boolean totalValue?: string data?: DataEntry[] horizontalLayout?: boolean + totalColumn: number + shareMode?: boolean } const customStyles: React.CSSProperties = { transition: 'all .3s', cursor: 'pointer' } @@ -193,12 +196,15 @@ const LoadingData = () => [ }, ] +// todo danh test my earning const EarningPieChart: React.FC = ({ data, totalValue = '', className, isLoading = false, horizontalLayout, + totalColumn, + shareMode, }) => { const [selectedIndex, setSelectedIndex] = useState(-1) const [isHoveringChart, setHoveringChart] = useState(false) @@ -220,7 +226,7 @@ const EarningPieChart: React.FC = ({ } return data.map((entry, i) => { - const color = selectedIndex === i ? darken(0.15, COLORS[i]) : COLORS[i] + const color = selectedIndex === i ? darken(0.15, getColor(i)) : getColor(i) return { title: entry.symbol, @@ -238,17 +244,17 @@ const EarningPieChart: React.FC = ({ const coloredData = data.map((entry, i) => { return { ...entry, - color: COLORS[i], + color: getColor(i), } }) - if (coloredData.length <= 5) { + if (totalColumn === 1) { return [coloredData] } const half = Math.ceil(coloredData.length / 2) return [coloredData.slice(0, half), coloredData.slice(half)] - }, [data, isLoading]) + }, [data, isLoading, totalColumn]) const handleMouseOver = useCallback( (_: any, index: number) => { @@ -269,6 +275,39 @@ const EarningPieChart: React.FC = ({ setHoveringChart(false) }, []) + const renderLegends = () => { + if (!data?.length) return null + return ( + + {legendData.map((columnData, columnIndex) => { + if (!columnData.length) { + return null + } + return ( + + {columnData.map((entry, i) => { + const index = (legendData?.[columnIndex - 1]?.length || 0) + i + return ( + setSelectedIndex(index)} + onMouseOut={() => setSelectedIndex(-1)} + /> + ) + })} + + ) + })} + + ) + } + if (horizontalLayout) { return ( = ({ - {isLoading ? ( - - ) : ( - - {legendData.map((columnData, columnIndex) => { - if (!columnData.length) { - return null - } - - return ( - - {columnData.map((entry, i) => { - const index = (legendData?.[columnIndex - 1]?.length || 0) + i - return ( - setSelectedIndex(index)} - onMouseOut={() => setSelectedIndex(-1)} - /> - ) - })} - - ) - })} - - )} + {isLoading ? : renderLegends()} ) } @@ -403,40 +411,9 @@ const EarningPieChart: React.FC = ({ - {isLoading ? ( - - ) : ( - - {legendData.map((columnData, columnIndex) => { - if (!columnData.length) { - return null - } - - return ( - - {columnData.map((entry, i) => { - const index = (legendData?.[columnIndex - 1]?.length || 0) + i - return ( - setSelectedIndex(index)} - onMouseOut={() => setSelectedIndex(-1)} - /> - ) - })} - - ) - })} - - )} + {isLoading ? : renderLegends()} ) } -export default EarningPieChart +export default memo(EarningPieChart) diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index b762a5f02f..75fcfc2a8f 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -17,6 +17,7 @@ import { THRESHOLD_HEADER, Z_INDEXS } from 'constants/styles' import { useActiveWeb3React } from 'hooks' import useMixpanel, { MIXPANEL_TYPE } from 'hooks/useMixpanel' import useTheme from 'hooks/useTheme' +import { useLazyNavigateToMyFirstPortfolio } from 'pages/NotificationCenter/Portfolio/helpers' import { useHolidayMode } from 'state/user/hooks' import { MEDIA_WIDTHS } from 'theme' @@ -26,7 +27,7 @@ import AnalyticNavGroup from './groups/AnalyticNavGroup' import EarnNavGroup from './groups/EarnNavGroup' import KyberDAONavGroup from './groups/KyberDaoGroup' import SwapNavGroup from './groups/SwapNavGroup' -import { StyledNavExternalLink } from './styleds' +import { StyledNavExternalLink, StyledNavLink } from './styleds' const HeaderFrame = styled.div<{ hide?: boolean }>` height: ${({ hide }) => (hide ? 0 : undefined)}; @@ -150,6 +151,12 @@ const BlogWrapper = styled.span` } ` +const PortfolioWrapper = styled(StyledNavLink)` + ${({ theme }) => theme.mediaWidth.upToXXSmall` + display: none; + `}; +` + const Title = styled(Link)` display: flex; align-items: center; @@ -184,7 +191,7 @@ export default function Header() { const theme = useTheme() const { pathname } = useLocation() const isPartnerSwap = pathname.startsWith(APP_PATHS.PARTNER_SWAP) - + const navigateToMyPortFolio = useLazyNavigateToMyFirstPortfolio() const { mixpanelHandler } = useMixpanel() const upToXXSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToXXSmall}px)`) const upToLarge = useMedia(`(max-width: ${MEDIA_WIDTHS.upToLarge}px)`) @@ -222,6 +229,15 @@ export default function Header() { + { + e.preventDefault() + navigateToMyPortFolio() + }} + > + Portfolio + diff --git a/src/components/Header/web3/SignWallet/ProfileContent.tsx b/src/components/Header/web3/SignWallet/ProfileContent.tsx index 239180826d..31a96bb9de 100644 --- a/src/components/Header/web3/SignWallet/ProfileContent.tsx +++ b/src/components/Header/web3/SignWallet/ProfileContent.tsx @@ -1,7 +1,7 @@ import KyberOauth2, { LoginMethod } from '@kybernetwork/oauth2' -import { Trans } from '@lingui/macro' +import { Trans, t } from '@lingui/macro' import { rgba } from 'polished' -import { useState } from 'react' +import React, { ReactNode, useCallback, useMemo, useState } from 'react' import { LogOut, UserPlus } from 'react-feather' import { useNavigate } from 'react-router-dom' import { useMedia } from 'react-use' @@ -19,10 +19,9 @@ import { useActiveWeb3React } from 'hooks' import useLogin from 'hooks/useLogin' import useTheme from 'hooks/useTheme' import { PROFILE_MANAGE_ROUTES } from 'pages/NotificationCenter/const' -import { ApplicationModal } from 'state/application/actions' -import { useToggleModal } from 'state/application/hooks' import { ConnectedProfile, useProfileInfo } from 'state/profile/hooks' import { MEDIA_WIDTHS } from 'theme' +import { isAddressString } from 'utils' import getShortenAddress from 'utils/getShortenAddress' import { shortString } from 'utils/string' @@ -118,139 +117,209 @@ const ProfileItemWrapper = styled(RowBetween)<{ active: boolean }>` `} ` +type DisplayProps = { title: string | undefined; description: string; avatarUrl: string | undefined } -const ProfileItem = ({ - data: { active, name: account, profile, id, type }, - totalGuest, -}: { - data: ConnectedProfile - totalGuest: number -}) => { +type ItemProps = { + data: DisplayProps + onClick: (data: any) => void + renderAction?: (data: any) => ReactNode + actionLabel?: string +} + +const ProfileItem = ({ data, onClick, renderAction }: ItemProps) => { + const { title, description, avatarUrl } = data const theme = useTheme() - const navigate = useNavigate() - const upToMedium = useMedia(`(max-width: ${MEDIA_WIDTHS.upToMedium}px)`) - const toggleModal = useToggleModal(ApplicationModal.SWITCH_PROFILE_POPUP) - const { signIn, signOut } = useLogin() const [loading, setLoading] = useState(false) - const guest = type === LoginMethod.ANONYMOUS - const onClick = async () => { - if (active || loading) return + const wrappedOnClick = async () => { + if (loading) return setLoading(true) - await signIn({ - account: id, - loginMethod: type, - showSessionExpired: true, - }) + await onClick(data) setLoading(false) - toggleModal() } - const signOutBtn = - !active && (guest ? totalGuest > 1 : true) ? ( - { - e?.stopPropagation() - signOut(id, guest) - }} - /> - ) : null - return ( - - {active && } + - + - + - {profile?.nickname && ( - - {shortString(profile?.nickname ?? '', 18)} + {title && ( + + {shortString(title ?? '', 18)} )} - - {type === LoginMethod.ETH ? getShortenAddress(account) : shortString(account, 20)} + + {description} - {active && signOutBtn} - {!active && signOutBtn} + {renderAction?.(data)} - {active && ( - - { - e?.stopPropagation() - navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PROFILE}`) - toggleModal() - }} - > - -   - Edit current account - + + ) +} + +const ProfileItemActive = ({ data, onClick, actionLabel }: ItemProps) => { + const theme = useTheme() + if (!data) return null + const { title, description, avatarUrl } = data + return ( + + + + + + + + + {title && ( + + {shortString(title ?? '', 18)} + + )} + + {description} + + - )} + + + + { + e?.stopPropagation() + onClick(data) + }} + > + +   + {actionLabel} + + ) } + const ProfileContent = ({ scroll, toggleModal }: { scroll?: boolean; toggleModal: () => void }) => { - const { signIn, signOutAll } = useLogin() + const { signIn, signOutAll, signOut } = useLogin() const { profiles, totalGuest } = useProfileInfo() const { account } = useActiveWeb3React() + const theme = useTheme() + const upToMedium = useMedia(`(max-width: ${MEDIA_WIDTHS.upToMedium}px)`) + const navigate = useNavigate() + + const renderAction = useCallback( + ({ id, type }: ConnectedProfile) => { + const guest = type === LoginMethod.ANONYMOUS + return (guest ? totalGuest > 1 : true) ? ( + { + e?.stopPropagation() + signOut(id, guest) + }} + /> + ) : null + }, + [signOut, totalGuest, theme, upToMedium], + ) + + const onItemClick = useCallback( + async (profile: ConnectedProfile) => { + await signIn({ + account: profile.id, + loginMethod: profile.type, + showSessionExpired: true, + }) + toggleModal() + }, + [signIn, toggleModal], + ) + const onItemActiveClick = useCallback(() => { + navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PROFILE}`) + toggleModal() + }, [navigate, toggleModal]) + + const formatProfile = useMemo( + () => + profiles.map(el => ({ + data: { + ...el, + title: el.profile?.nickname, + avatarUrl: el.profile?.avatarUrl, + description: isAddressString(1, el.name) ? getShortenAddress(el.name) : shortString(el.name, 20), + }, + actionLabel: t`Edit current account`, + renderAction, + onClick: el.active ? onItemActiveClick : onItemClick, + })), + [profiles, renderAction, onItemClick, onItemActiveClick], + ) if (!profiles.length) return null - const listNotActive = profiles.slice(1) - const totalAccount = profiles.length + const listNotActive = formatProfile.slice(1) + const totalAccount = formatProfile.length + + return ( + 1}> + {!KyberOauth2.getConnectedAccounts().includes(account?.toLowerCase() ?? '') && ( + { + toggleModal() + signIn() + }} + > + Add Account + + )} + {totalAccount > 1 && ( + { + signOutAll() + toggleModal() + }} + > + Sign out of all accounts + + )} + + } + options={listNotActive} + /> + ) +} + +type Props = { + scroll: boolean + actions?: ReactNode + options: ItemProps[] + activeItem: ItemProps +} + +export function ProfilePanel({ scroll, actions, activeItem, options = [] }: Props) { return ( - - - {listNotActive.map(data => ( - + + + {options.map((el, i) => ( + ))} - 1}> - {!KyberOauth2.getConnectedAccounts().includes(account?.toLowerCase() ?? '') && ( - { - toggleModal() - signIn() - }} - > - Add Account - - )} - {totalAccount > 1 && ( - { - signOutAll() - toggleModal() - }} - > - Sign out of all accounts - - )} - + {actions} ) } diff --git a/src/components/LocalLoader/index.tsx b/src/components/LocalLoader/index.tsx index df528ee733..4266004524 100644 --- a/src/components/LocalLoader/index.tsx +++ b/src/components/LocalLoader/index.tsx @@ -1,4 +1,4 @@ -import styled, { css, keyframes } from 'styled-components' +import styled, { CSSProperties, css, keyframes } from 'styled-components' const pulse = keyframes` 0% { transform: scale(1); } @@ -33,11 +33,12 @@ const AnimatedImg = styled.div` interface LocalLoaderProps { fill?: boolean + style?: CSSProperties } -const LocalLoader = ({ fill }: LocalLoaderProps) => { +const LocalLoader = ({ fill, style }: LocalLoaderProps) => { return ( - + loading-icon diff --git a/src/components/Logo/index.tsx b/src/components/Logo/index.tsx index a1a0f2e2b8..c0008b0e95 100644 --- a/src/components/Logo/index.tsx +++ b/src/components/Logo/index.tsx @@ -5,6 +5,7 @@ import { ImageProps } from 'rebass' import styled from 'styled-components' import { NETWORKS_INFO } from 'hooks/useChainsConfig' +import useTheme from 'hooks/useTheme' import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo' import { getNativeTokenLogo } from 'utils' @@ -53,11 +54,13 @@ export function TokenLogoWithChain(data: any) { const chainId: ChainId = currency?.chainId || chainParam const nativeLogo = getNativeTokenLogo(chainId) const tokenLogo = (currency?.isNative ? nativeLogo : currency?.logoURI) || tokenLogoParam - const ratio = 0.7 + const ratio = 0.6 const networkSize = ratio * parseInt(size + '') + const theme = useTheme() + return ( -
+
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 4339cb7e8d..97955921cf 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -11,6 +11,7 @@ import { ReactComponent as MenuIcon } from 'assets/svg/all_icon.svg' import { ReactComponent as BlogIcon } from 'assets/svg/blog.svg' import { ReactComponent as BridgeIcon } from 'assets/svg/bridge_icon.svg' import { ReactComponent as LightIcon } from 'assets/svg/light.svg' +import { ReactComponent as PortfolioIcon } from 'assets/svg/portfolio.svg' import { ReactComponent as RoadMapIcon } from 'assets/svg/roadmap.svg' import { ButtonEmpty, ButtonPrimary } from 'components/Button' import { AutoColumn } from 'components/Column' @@ -34,6 +35,7 @@ import { useActiveWeb3React } from 'hooks' import useClaimReward from 'hooks/useClaimReward' import useMixpanel, { MIXPANEL_TYPE } from 'hooks/useMixpanel' import useTheme from 'hooks/useTheme' +import { useLazyNavigateToMyFirstPortfolio } from 'pages/NotificationCenter/Portfolio/helpers' import { PROFILE_MANAGE_ROUTES } from 'pages/NotificationCenter/const' import { ApplicationModal } from 'state/application/actions' import { useModalOpen, useToggleModal } from 'state/application/hooks' @@ -56,13 +58,17 @@ const MenuItem = styled.li` align-items: center; color: ${({ theme }) => theme.subText}; font-size: 15px; + cursor: pointer; svg { margin-right: 8px; height: 16px; width: 16px; } - + :hover { + text-decoration: none; + color: ${({ theme }) => theme.text}; + } a { color: ${({ theme }) => theme.subText}; display: flex; @@ -220,6 +226,7 @@ export default function Menu() { const { mixpanelHandler } = useMixpanel() const navigate = useNavigate() + const navigateToMyPortFolio = useLazyNavigateToMyFirstPortfolio() const setShowTutorialSwapGuide = useTutorialSwapGuide()[1] const openTutorialSwapGuide = () => { @@ -353,6 +360,16 @@ export default function Menu() { /> + { + toggle() + navigateToMyPortFolio() + }} + > + + Portfolio + + {showCampaign && ( )} + {showAbout && ( {trigger} - { - - {isOpen && ( - + {isOpen && ( + + - - - - - {title} - - - -
{children}
-
-
-
- )} -
- } + + + + {title} + + + +
{children}
+
+ + + )} + ) } diff --git a/src/components/Modal/ModalTemplate.tsx b/src/components/Modal/ModalTemplate.tsx new file mode 100644 index 0000000000..4ca7c8699a --- /dev/null +++ b/src/components/Modal/ModalTemplate.tsx @@ -0,0 +1,41 @@ +import { X } from 'react-feather' +import { Text } from 'rebass' +import styled from 'styled-components' + +import Modal, { ModalProps } from 'components/Modal' +import { RowBetween } from 'components/Row' +import useTheme from 'hooks/useTheme' + +const Wrapper = styled.div` + margin: 0; + padding: 24px 24px; + width: 100%; + display: flex; + gap: 24px; + flex-direction: column; +` + +// modal with close, title +const ModalTemplate = ({ + title, + closeButton = true, + ...props +}: ModalProps & { title: string; closeButton?: boolean }) => { + const theme = useTheme() + const { children, onDismiss } = props + return ( + + + + + {title} + + {closeButton && } + + {children} + + + ) +} + +export default ModalTemplate diff --git a/src/components/Pagination/index.tsx b/src/components/Pagination/index.tsx index b345836148..0966635902 100644 --- a/src/components/Pagination/index.tsx +++ b/src/components/Pagination/index.tsx @@ -17,8 +17,12 @@ export default function Pagination({ style = {}, haveBg = true, className, + hideWhenSinglePage = true, + onNext, + onBack, + disableBack, + disableNext, }: { - onPageChange: (newPage: number) => void totalCount: number siblingCount?: number currentPage: number @@ -26,6 +30,12 @@ export default function Pagination({ style?: CSSProperties haveBg?: boolean className?: string + hideWhenSinglePage?: boolean + onPageChange: (newPage: number) => void + onNext?: () => void + onBack?: () => void + disableBack?: boolean + disableNext?: boolean }) { const upToExtraSmall = useMedia('(max-width: 576px)') @@ -42,7 +52,7 @@ export default function Pagination({ const lastPage = paginationRange[paginationRange.length - 1] as number // If there are less than 2 times in pagination range we shall not render the component - if (currentPage === 0 || paginationRange.length < 2) { + if (currentPage === 0 || (paginationRange.length < 2 && hideWhenSinglePage)) { return null } @@ -54,19 +64,29 @@ export default function Pagination({ onPageChange(lastPage) } - const onNext = () => { - if (currentPage < paginationRange[paginationRange.length - 1]) { + const prevNextStyle = !!onBack && !!onNext + + const handleNext = () => { + if (prevNextStyle) { + onNext() + return + } + if (Number(currentPage) < Number(paginationRange[paginationRange.length - 1])) { onPageChange(currentPage + 1) } } - const onPrevious = () => { + const handleBack = () => { + if (prevNextStyle) { + onBack() + return + } if (currentPage > 1) { onPageChange(currentPage - 1) } } - if (upToExtraSmall) { + if (upToExtraSmall && !prevNextStyle) { return ( - + @@ -86,7 +106,7 @@ export default function Pagination({ - + @@ -103,28 +123,29 @@ export default function Pagination({ return ( - + - {paginationRange.map((pageNumber, index) => { - if (pageNumber === DOTS) { - return - } - return ( - onPageChange(pageNumber as number)} - > - - {pageNumber} - - - ) - })} - + {!prevNextStyle && + paginationRange.map((pageNumber, index) => { + if (pageNumber === DOTS) { + return + } + return ( + onPageChange(pageNumber as number)} + > + + {pageNumber} + + + ) + })} + diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 326c38c5ed..ffe25040d4 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef } from 'react' import { Navigate } from 'react-router-dom' +import { usePrevious } from 'react-use' import LocalLoader from 'components/LocalLoader' import { RTK_QUERY_TAGS } from 'constants/index' -import { useInvalidateTagKyberAi } from 'hooks/useInvalidateTags' +import { useInvalidateTagKyberAi, useInvalidateTagPortfolio } from 'hooks/useInvalidateTags' import { useSessionInfo } from 'state/authen/hooks' import { useIsWhiteListKyberAI } from 'state/user/hooks' @@ -12,10 +13,33 @@ type Props = { redirectUrl?: string } -// wait utils sign in eth/anonymous done (error/success) +// todo generic it +const useSubscribeChangeUserInfo = () => { + const { userInfo } = useSessionInfo() + const invalidateTags = useInvalidateTagPortfolio() + const prevIdentityId = usePrevious(userInfo?.identityId) + + useEffect(() => { + // todo + if (prevIdentityId && prevIdentityId !== userInfo?.identityId) { + try { + invalidateTags([ + RTK_QUERY_TAGS.GET_LIST_WALLET_PORTFOLIO, + RTK_QUERY_TAGS.GET_FAVORITE_PORTFOLIO, + RTK_QUERY_TAGS.GET_SETTING_PORTFOLIO, + RTK_QUERY_TAGS.GET_LIST_PORTFOLIO, + ]) + } catch (error) {} + } + }, [userInfo?.identityId, invalidateTags, prevIdentityId]) +} + +// wait until sign in eth/anonymous done (error/success) const ProtectedRoute = ({ children }: Props) => { const { pendingAuthentication } = useSessionInfo() const loaded = useRef(false) + useSubscribeChangeUserInfo() + if (pendingAuthentication && !loaded.current) return loaded.current = true return children diff --git a/src/components/SearchInput.tsx b/src/components/SearchInput.tsx index 284ef4a027..f3a4277175 100644 --- a/src/components/SearchInput.tsx +++ b/src/components/SearchInput.tsx @@ -1,6 +1,7 @@ -import { Search } from 'react-feather' +import { Search, X } from 'react-feather' import styled, { CSSProperties } from 'styled-components' +import { ButtonEmpty } from 'components/Button' import useTheme from 'hooks/useTheme' const SearchContainer = styled.div` @@ -33,6 +34,16 @@ const Input = styled.input` color: ${({ theme }) => theme.disableText}; } ` + +export type SearchInputProps = { + maxLength?: number + placeholder: string + value: string + onChange: (val: string) => void + style?: CSSProperties + className?: string + clearable?: boolean +} export default function SearchInput({ value, maxLength = 255, @@ -40,18 +51,24 @@ export default function SearchInput({ placeholder, style = {}, className, -}: { - maxLength?: number - placeholder: string - value: string - onChange: (val: string) => void - style?: CSSProperties - className?: string -}) { + clearable, +}: SearchInputProps) { const theme = useTheme() + const handleXClick = () => onChange('') return ( - onChange(e.target.value)} /> + onChange(e.target.value)} + style={{ backgroundColor: style?.backgroundColor }} + /> + {clearable && value && ( + + + + )} ) diff --git a/src/components/Section/TabDraggable.tsx b/src/components/Section/TabDraggable.tsx new file mode 100644 index 0000000000..b885822bc9 --- /dev/null +++ b/src/components/Section/TabDraggable.tsx @@ -0,0 +1,159 @@ +import { motion } from 'framer-motion' +import { ReactNode, useEffect, useRef, useState } from 'react' +import styled, { DefaultTheme } from 'styled-components' + +import { ReactComponent as DropdownSVG } from 'assets/svg/down.svg' +import Row from 'components/Row' +import TabButton from 'components/TabButton' +import { ICON_ID } from 'constants/index' +import useTheme from 'hooks/useTheme' +import SimpleTooltip from 'pages/TrueSightV2/components/SimpleTooltip' + +const TabWrapper = styled(motion.div)` + overflow: auto; + cursor: grab; + display: inline-flex; + width: fit-content; + position: relative; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + min-width: 100%; + > * { + flex: 1 0 fit-content; + scroll-snap-align: start; + } + &.no-scroll { + scroll-snap-type: unset; + scroll-behavior: unset; + > * { + scroll-snap-align: unset; + } + } + ${({ theme }) => theme.mediaWidth.upToSmall` + min-width: initial; + flex: 1; + `} +` + +export type TabITem = { + type: T + icon?: ICON_ID + tooltip?: (theme: DefaultTheme) => ReactNode + title: string +} +const ARROW_SIZE = 44 + +export default function TabDraggable({ + activeTab, + tabs, + onChange, + trackingChangeTab, +}: { + tabs: TabITem[] + activeTab: T + onChange?: (type: T) => void + trackingChangeTab?: (fromTab: T, toTab: T) => void +}) { + const theme = useTheme() + const [showScrollRightButton, setShowScrollRightButton] = useState(false) + const [scrollLeftValue, setScrollLeftValue] = useState(0) + const wrapperRef = useRef(null) + const tabListRef = useRef([]) + + useEffect(() => { + wrapperRef.current?.scrollTo({ left: scrollLeftValue, behavior: 'smooth' }) + }, [scrollLeftValue]) + + useEffect(() => { + const wRef = wrapperRef.current + if (!wRef) return + const handleWheel = (e: any) => { + e.preventDefault() + setScrollLeftValue(prev => Math.min(Math.max(prev + e.deltaY, 0), wRef.scrollWidth - wRef.clientWidth)) + } + if (wRef) { + wRef.addEventListener('wheel', handleWheel) + } + return () => wRef?.removeEventListener('wheel', handleWheel) + }, []) + + useEffect(() => { + const handleResize = () => { + setShowScrollRightButton( + Boolean(wrapperRef.current?.clientWidth && wrapperRef.current?.clientWidth < wrapperRef.current?.scrollWidth), + ) + } + handleResize() + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, []) + + const indexActive = tabs.findIndex(e => e.type === activeTab) + + return ( + + e.preventDefault()} + style={{ paddingRight: showScrollRightButton ? ARROW_SIZE : undefined }} + > + {tabs.map(({ type, title, tooltip }, index) => { + const props = { + onClick: () => { + trackingChangeTab?.(activeTab, type) + onChange?.(type) + if (!wrapperRef.current) return + const tabRef = tabListRef.current[index] + const wRef = wrapperRef.current + if (tabRef.offsetLeft < wRef.scrollLeft) { + setScrollLeftValue(tabRef.offsetLeft) + } + if (wRef.scrollLeft + wRef.clientWidth < tabRef.offsetLeft + tabRef.offsetWidth) { + setScrollLeftValue(tabRef.offsetLeft + tabRef.offsetWidth - wRef.offsetWidth) + } + }, + } + return ( + + { + if (el) { + tabListRef.current[index] = el + } + }} + /> + + ) + })} + + {showScrollRightButton && ( + { + setScrollLeftValue(prev => prev + 120) + }} + /> + )} + + ) +} diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx new file mode 100644 index 0000000000..31b350f298 --- /dev/null +++ b/src/components/Section/index.tsx @@ -0,0 +1,98 @@ +import React, { Dispatch, ReactNode, SetStateAction, useRef } from 'react' +import { isMobile } from 'react-device-detect' +import { Text } from 'rebass' +import styled from 'styled-components' + +import { RowBetween, RowFit } from 'components/Row' +import TabDraggable, { TabITem } from 'components/Section/TabDraggable' +import useTheme from 'hooks/useTheme' + +const SectionTitle = styled.div` + font-size: 16px; + line-height: 20px; + font-weight: 500; + padding: 16px 16px 16px 16px; + border-bottom: 1px solid ${({ theme }) => theme.border + '80'}; + color: ${({ theme }) => theme.text}; +` + +const Content = styled.div` + padding: 16px 16px 16px 16px; +` + +const StyledSectionWrapper = styled.div<{ show?: boolean }>` + display: ${({ show }) => (show ?? 'auto' ? 'auto' : 'none !important')}; + content-visibility: auto; + border-radius: 20px; + border: 1px solid ${({ theme }) => theme.border}; + background: linear-gradient(332deg, rgb(32 32 32) 0%, rgba(15, 15, 15, 1) 80%); + margin-bottom: 36px; + display: flex; + flex-direction: column; +` + +export type SectionProps = { + title?: ReactNode + id?: string + children: ReactNode + style?: React.CSSProperties + contentStyle?: React.CSSProperties + actions?: ReactNode + tabs?: TabITem[] + activeTab?: T + showHeader?: boolean + onTabClick?: (val: T) => void | Dispatch> +} + +export default function Section({ + title = '', + id, + children, + style, + contentStyle, + actions, + tabs, + activeTab, + onTabClick, + showHeader = true, +}: SectionProps) { + const theme = useTheme() + const ref = useRef(null) + const renderAction = () => + actions ? ( + + {actions} + + ) : null + return ( + + {showHeader && ( + + {tabs && activeTab ? ( + + + + + {renderAction()} + + ) : ( + + {title} + {renderAction()} + + )} + + )} + {children} + + ) +} diff --git a/src/components/Select/MultipleChainSelect/SelectButton.tsx b/src/components/Select/MultipleChainSelect/SelectButton.tsx index 94f26d5c39..453cd14856 100644 --- a/src/components/Select/MultipleChainSelect/SelectButton.tsx +++ b/src/components/Select/MultipleChainSelect/SelectButton.tsx @@ -23,12 +23,17 @@ const Label = styled.span<{ labelColor?: string }>` overflow: hidden; text-overflow: ellipsis; ` -type Props = MultipleChainSelectProps -const SelectButton: React.FC = ({ selectedChainIds, chainIds, activeRender, activeStyle, labelColor }) => { +const SelectButton: React.FC = ({ + selectedChainIds, + chainIds, + activeRender, + activeStyle, + labelColor, +}) => { const theme = useTheme() const renderButtonBody = () => { - if (selectedChainIds.length === chainIds.length) { + if (selectedChainIds.length === chainIds?.length) { return ( diff --git a/src/components/Select/MultipleChainSelect/index.tsx b/src/components/Select/MultipleChainSelect/index.tsx index 29e87a1839..aea1cb2b97 100644 --- a/src/components/Select/MultipleChainSelect/index.tsx +++ b/src/components/Select/MultipleChainSelect/index.tsx @@ -9,6 +9,7 @@ import { ReactComponent as LogoKyber } from 'assets/svg/logo_kyber.svg' import Checkbox from 'components/CheckBox' import Select from 'components/Select' import { MouseoverTooltip } from 'components/Tooltip' +import { MAINNET_NETWORKS } from 'constants/networks' import useChainsConfig, { NETWORKS_INFO } from 'hooks/useChainsConfig' import useTheme from 'hooks/useTheme' @@ -23,7 +24,7 @@ export const StyledLogo = styled.img` export type MultipleChainSelectProps = { className?: string comingSoonList?: ChainId[] - chainIds: ChainId[] + chainIds?: ChainId[] selectedChainIds: ChainId[] handleChangeChains: (v: ChainId[]) => void onTracking?: () => void @@ -74,8 +75,9 @@ const StyledSelect = styled(Select)` background-color: ${({ theme }) => theme.buttonGray}; ` +const defaultChains = [...MAINNET_NETWORKS] const MultipleChainSelect: React.FC = ({ className, style, ...props }) => { - const { comingSoonList = [], selectedChainIds = [], handleChangeChains, chainIds = [], onTracking } = props + const { comingSoonList = [], selectedChainIds = [], handleChangeChains, chainIds = defaultChains, onTracking } = props const options = chainIds.map(id => ({ value: id, label: id })) const theme = useTheme() const selectedChains = selectedChainIds.filter(item => !comingSoonList.includes(item)) @@ -118,7 +120,7 @@ const MultipleChainSelect: React.FC = ({ className, st onHideMenu={onHideMenu} className={className} style={style} - activeRender={_ => } + activeRender={_ => } options={options} optionStyle={{ padding: 0 }} optionRender={item => { diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx index 9b840f55c5..380e58911e 100644 --- a/src/components/Select/index.tsx +++ b/src/components/Select/index.tsx @@ -85,7 +85,7 @@ const SearchWrapper = styled.div` color: ${({ theme }) => theme.text}; } ` -export type SelectOption = { value?: string | number; label: ReactNode; onSelect?: () => void } +export type SelectOption = { value?: string | number; label: ReactNode; onSelect?: () => void; subLabel?: ReactNode } const getOptionValue = (option: SelectOption | undefined) => { if (!option) return '' @@ -113,6 +113,7 @@ function Select({ onHideMenu, withSearch, placement = 'bottom', + arrow = true, }: { value?: string | number className?: string @@ -129,6 +130,7 @@ function Select({ placement?: string withSearch?: boolean onHideMenu?: () => void // hide without changes + arrow?: boolean }) { const [selected, setSelected] = useState(getOptionValue(options?.[0])) const [showMenu, setShowMenu] = useState(false) @@ -200,7 +202,7 @@ function Select({ className={className} > {activeRender ? activeRender(selectedInfo) : getOptionLabel(selectedInfo)} - + {arrow && } {showMenu && ( diff --git a/src/components/ShareModal/ShareImageModal/NodeContents.tsx b/src/components/ShareModal/ShareImageModal/NodeContents.tsx new file mode 100644 index 0000000000..d184ba452d --- /dev/null +++ b/src/components/ShareModal/ShareImageModal/NodeContents.tsx @@ -0,0 +1,226 @@ +import { ReactNode, forwardRef } from 'react' +import { QRCode } from 'react-qrcode-logo' +import { useMedia } from 'react-use' +import { Text } from 'rebass' +import { SHARE_TYPE } from 'services/social' +import styled from 'styled-components' +import { Navigation, Pagination } from 'swiper' +import { Swiper, SwiperSlide } from 'swiper/react' + +import BgKyberAi from 'assets/images/share/background_kyberai.png' +import BgKyberAIMobile from 'assets/images/share/background_mobile_kyberai.png' +import BgPortfolioMobile from 'assets/images/share/background_mobile_portfolio.png' +import BgPortfolio from 'assets/images/share/background_portfolio.png' +import Column from 'components/Column' +import Row, { RowBetween, RowFit } from 'components/Row' +import { RenderContentFn } from 'components/ShareModal/ShareImageModal' +import { SIZES } from 'components/ShareModal/ShareImageModal/const' +import KyberSwapShareLogo from 'pages/TrueSightV2/components/KyberSwapShareLogo' +import { InfoWrapper, LegendWrapper } from 'pages/TrueSightV2/components/chart' +import { MEDIA_WIDTHS } from 'theme' + +const getScale = (currentSize: number, expectSize: number) => + (currentSize / expectSize) ** (currentSize > expectSize ? -1 : 1) + +const ImageInner = styled.div<{ bg: string }>` + width: ${SIZES.WIDTH_PC}px; + height: ${SIZES.HEIGH_PC}px; + aspect-ratio: ${SIZES.WIDTH_PC} / ${SIZES.HEIGH_PC}; + background-color: ${({ theme }) => theme.background}; + display: flex; + flex-direction: column; + padding: 32px; + gap: 10px; + position: relative; + :before { + content: ' '; + position: absolute; + inset: 0 0 0 0; + opacity: 0.25; + background: url(${({ bg }) => bg}); + background-size: cover; + z-index: -1; + } +` + +const ImageInnerMobile = styled.div<{ bg: string }>` + width: 400px; + height: ${SIZES.HEIGHT_MB}px; + aspect-ratio: 1/2; + background-color: ${({ theme }) => theme.background}; + display: flex; + flex-direction: column; + padding: 24px; + gap: 16px; + position: relative; + :before { + content: ' '; + position: absolute; + inset: 0 0 0 0; + opacity: 1; + background: url(${({ bg }) => bg}); + background-size: cover; + z-index: -1; + } + + ${LegendWrapper} { + position: initial; + justify-content: flex-start; + } + ${InfoWrapper} { + position: initial; + gap: 12px; + font-size: 12px; + justify-content: space-between; + } +` + +type ContentProps = { + content?: RenderContentFn | RenderContentFn[] + isMobileMode: boolean + kyberswapLogoTitle: ReactNode + leftLogo: ReactNode + title?: string + sharingUrl: string + shareIndex: number + shareType: SHARE_TYPE + setShareIndex: (v: number) => void + imageHeight: number | undefined +} + +const BG_BY_TYPE: Partial<{ [k in SHARE_TYPE]: { mobile: string; pc: string } }> = { + [SHARE_TYPE.KYBER_AI]: { mobile: BgKyberAIMobile, pc: BgKyberAi }, + [SHARE_TYPE.PORTFOLIO]: { mobile: BgPortfolioMobile, pc: BgPortfolio }, +} + +type RenderSlideProps = { render: RenderContentFn; scale?: number; ref?: React.ForwardedRef } +const NodeContents = forwardRef(function NodeContents( + { + content, + isMobileMode, + setShareIndex, + kyberswapLogoTitle, + leftLogo, + title, + sharingUrl, + shareIndex, + shareType, + imageHeight, + }, + ref, +) { + const upToSmall = useMedia(`(max-width:${MEDIA_WIDTHS.upToSmall}px)`) + + const renderMobile = ({ render, scale, ref }: RenderSlideProps) => ( + + {leftLogo} + + + + + {title} + + + {render?.(true)} + + + + +
+ +
+
+
+
+ ) + + const renderPc = ({ render, scale, ref }: RenderSlideProps) => ( + + + + {leftLogo} + + + +
+ +
+
+
+ + + {title} + + + {render?.(false)} +
+ ) + + if (Array.isArray(content) && content.length > 1) + return ( + { + setShareIndex(val.realIndex) + }} + > + {content.map((render, index) => { + const contentWidth = upToSmall ? window.innerWidth - 40 : SIZES.VIEW_WIDTH_PC + const contentHeight = imageHeight || SIZES.HEIGHT_MB + + const scale = isMobileMode + ? getScale(SIZES.HEIGHT_MB, contentHeight) + : upToSmall + ? getScale(contentWidth, SIZES.WIDTH_PC) + : getScale(SIZES.HEIGH_PC, contentHeight) + + const props = { render, scale, ref: index === shareIndex ? ref : null } + return ( + + {isMobileMode ? renderMobile(props) : renderPc(props)} + + ) + })} + + ) + + const params = { render: content as RenderContentFn, ref } + return isMobileMode ? renderMobile(params) : renderPc(params) +}) +export default NodeContents diff --git a/src/components/ShareModal/ShareImageModal/const.ts b/src/components/ShareModal/ShareImageModal/const.ts new file mode 100644 index 0000000000..a3647073c0 --- /dev/null +++ b/src/components/ShareModal/ShareImageModal/const.ts @@ -0,0 +1,8 @@ +export const SIZES = { + VIEW_WIDTH_PC: 840, + WIDTH_PC: 1050, + HEIGH_PC: 612, + + HEIGHT_MB: 800, + VIEW_HEIGHT_MB: 620, +} diff --git a/src/components/ShareModal/ShareImageModal/index.tsx b/src/components/ShareModal/ShareImageModal/index.tsx new file mode 100644 index 0000000000..cd79b8cdba --- /dev/null +++ b/src/components/ShareModal/ShareImageModal/index.tsx @@ -0,0 +1,524 @@ +import { Trans } from '@lingui/macro' +import { rgba } from 'polished' +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { Check, X } from 'react-feather' +import { useMedia } from 'react-use' +import { Text } from 'rebass' +import { SHARE_TYPE, useCreateShareLinkMutation } from 'services/social' +import styled, { css } from 'styled-components' + +import Icon from 'components/Icons/Icon' +import LoadingIcon from 'components/Loader' +import Modal from 'components/Modal' +import Row, { RowBetween, RowFit } from 'components/Row' +import { getSocialShareUrls } from 'components/ShareModal' +import NodeContents from 'components/ShareModal/ShareImageModal/NodeContents' +import { SIZES } from 'components/ShareModal/ShareImageModal/const' +import { ENV_LEVEL } from 'constants/env' +import { ENV_TYPE } from 'constants/type' +import useCopyClipboard from 'hooks/useCopyClipboard' +import useInterval from 'hooks/useInterval' +import useShareImage from 'hooks/useShareImage' +import useTheme from 'hooks/useTheme' +import LoadingTextAnimation from 'pages/TrueSightV2/components/LoadingTextAnimation' +import { MEDIA_WIDTHS } from 'theme' +import { downloadImage } from 'utils/index' + +const Wrapper = styled.div` + padding: 20px; + border-radius: 20px; + background-color: ${({ theme }) => theme.tableHeader}; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + width: 100%; + min-width: min(50vw, 880px); + .time-frame-legend { + display: none; + } +` + +const Input = styled.input` + background-color: transparent; + height: 34px; + color: ${({ theme }) => theme.text}; + :focus { + } + outline: none; + box-shadow: none; + width: 95%; + border: none; +` +const InputWrapper = styled.div` + background-color: ${({ theme }) => theme.buttonBlack}; + height: 36px; + padding-left: 16px; + border-radius: 20px; + border: 1px solid ${({ theme }) => theme.border}; + flex: 1; + display: flex; +` + +const IconButton = styled.div<{ disabled?: boolean }>` + height: 36px; + width: 36px; + display: flex; + justify-content: center; + align-items: center; + background-color: ${({ theme }) => theme.subText + '32'}; + border-radius: 18px; + cursor: pointer; + :hover { + filter: brightness(1.2); + } + :active { + box-shadow: 0 2px 4px 4px rgba(0, 0, 0, 0.2); + } + color: ${({ theme }) => theme.subText} !important; + + a { + color: ${({ theme }) => theme.subText} !important; + } + ${({ disabled }) => + disabled && + css` + cursor: default; + pointer-events: none; + color: ${({ theme }) => theme.subText + '80'} !important; + a { + color: ${({ theme }) => theme.subText + '80'} !important; + } + `} +` + +const ImageWrapper = styled.div<{ isMobileMode?: boolean }>` + max-height: 80vh; + position: relative; + max-width: 100%; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + overflow: hidden; + ${({ isMobileMode }) => + isMobileMode + ? css` + width: 100%; + aspect-ratio: 1/2; + height: ${SIZES.VIEW_HEIGHT_MB}; + ` + : css` + width: ${SIZES.VIEW_WIDTH_PC}px; + height: 490px; + `} + + --swiper-navigation-size: 12px; + + .swiper-button-prev, + .swiper-button-next { + color: #ffffff; + background: ${({ theme }) => rgba(theme.subText, 0.2)}; + width: 32px; + height: 32px; + margin-top: 0; + border-radius: 50%; + transform: translateY(-50%); + :hover { + filter: brightness(1.2); + } + } + + .swiper-pagination-bullet { + background: ${({ theme }) => theme.subText}; + } + + .swiper-pagination-bullet-active { + border-radius: 4px; + background: ${({ theme }) => theme.primary}; + } +` + +const Loader = styled.div` + position: absolute; + inset: 0; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background: ${({ theme }) => theme.buttonBlack}; + z-index: 4; + border-radius: 8px; + padding: 0 12px; +` + +type ShareData = { + shareUrl?: string[] + imageUrl?: string[] + blob?: Blob[] +} + +const ShareUrlPanel = ({ + sharingUrl, + disabled, + isCopied, + loadingType, + onClickShareSocial, +}: { + sharingUrl: string + disabled: boolean + isCopied: boolean | undefined + loadingType: ShareType | undefined + onClickShareSocial: (v: ShareType) => void +}) => { + const theme = useTheme() + return ( + + + + + + {itemShares.map(type => ( + onClickShareSocial?.(type)}> + {loadingType === type ? : } + + ))} + onClickShareSocial?.(ShareType.COPY)}> + {loadingType === ShareType.COPY ? ( + + ) : isCopied ? ( + + ) : ( + + )} + + + ) +} + +const ShareImage = ({ imageUrl }: { imageUrl: string }) => + imageUrl ? ( +
+ ) : null + +const debug = false +// todo move another file, check open popup auto upload image ???, my earning, split file +enum ShareType { + TELEGRAM = 'telegram', + FB = 'facebook', + DISCORD = 'discord', + TWITTER = 'twitter', + COPY = 'copy to clipboard', + DOWNLOAD_IMAGE = 'download image', + COPY_IMAGE = 'copy image', +} +const itemShares = [ShareType.TELEGRAM, ShareType.TWITTER, ShareType.FB, ShareType.DISCORD] +type ShareResponse = { imageUrl?: string; blob?: Blob; shareUrl?: string } +export type RenderContentFn = (mobileMode?: boolean) => ReactNode +export default function ShareImageModal({ + title, + content, + isOpen, + onClose, + onShareClick, + shareType, + imageName, + leftLogo, + kyberswapLogoTitle, + redirectUrl, +}: { + title?: string + content?: RenderContentFn | RenderContentFn[] + isOpen: boolean + onClose?: () => void + onShareClick?: (network: string) => void + shareType: SHARE_TYPE + imageName: string + leftLogo: ReactNode + kyberswapLogoTitle: ReactNode + redirectUrl?: string +}) { + const theme = useTheme() + const ref = useRef(null) + const refImgWrapper = useRef(null) + const autoUpload = !(debug || Array.isArray(content)) + + const [loadingType, setLoadingType] = useState() + const [loading, setLoading] = useState(autoUpload) + const [isError, setIsError] = useState(false) + const [isMobileMode, setIsMobileMode] = useState(isMobile) + const [mobileData, setMobileData] = useState({}) + const [desktopData, setDesktopData] = useState({}) + const shareImage = useShareImage() + const [createShareLink] = useCreateShareLinkMutation() + const above768 = useMedia(`(min-width:${MEDIA_WIDTHS.upToSmall}px)`) + + const [shareIndex, setShareIndex] = useState(0) + const shareData = isMobileMode ? mobileData : desktopData + + const blob = shareData?.blob?.[shareIndex] + const imageUrl = shareData?.imageUrl?.[shareIndex] || '' + const sharingUrl = shareData?.shareUrl?.[shareIndex] || '' + + const handleGenerateImage = useCallback( + async (shareUrl: string, mobile: boolean): Promise => { + const element = ref.current + if (element) { + setIsError(false) + const shareId = shareUrl?.split('/').pop() + + if (!shareId) { + setLoading(false) + setIsError(true) + } + try { + const { imageUrl, blob } = await shareImage(element, shareType, shareId) + const fn = (prev: ShareData) => { + const imageUrls = prev.imageUrl || [] + const blobs = prev.blob || [] + imageUrls[shareIndex] = imageUrl + blobs[shareIndex] = blob + return { ...prev, imageUrl: imageUrls, blob: blobs } + } + mobile ? setMobileData(fn) : setDesktopData(fn) + return { imageUrl, blob, shareUrl } + } catch (err) { + console.log(err) + setLoading(false) + setIsError(true) + } + } else { + setLoading(false) + } + return {} + }, + [shareImage, shareType, shareIndex], + ) + + const timeout = useRef() + const createShareFunction = async (callback?: (data: ShareResponse) => void) => { + if (loading && !autoUpload) return + timeout.current && clearTimeout(timeout.current) + if (!isOpen) { + timeout.current = setTimeout(() => { + setLoading(true) + setIsError(false) + setIsMobileMode(isMobile) + setDesktopData({}) + setMobileData({}) + setLoadingType(undefined) + }, 400) + return + } + if (sharingUrl) return + setLoading(true) + setIsError(false) + const shareUrl = await createShareLink({ + redirectURL: ENV_LEVEL === ENV_TYPE.LOCAL ? 'https://kyberswap.com' : redirectUrl || window.location.href, + type: shareType, + }).unwrap() + const fn = (prev: ShareData) => { + const shareUrls = prev.shareUrl || [] + shareUrls[shareIndex] = shareUrl + return { ...prev, shareUrl: shareUrls } + } + if (isMobileMode) { + setMobileData(fn) + } else { + setDesktopData(fn) + } + timeout.current = setTimeout(async () => { + const data = await handleGenerateImage(shareUrl, isMobileMode) + callback?.(data) + }, 1000) + } + + useEffect(() => { + autoUpload && createShareFunction() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, isMobileMode]) + + const [isCopiedImage, setCopiedImage] = useCopyClipboard(2000) + const copyImage = (blob: Blob | undefined) => { + if (blob) { + setCopiedImage(blob) + } + } + + const handleImageCopyClick = () => { + if (!blob) { + setLoadingType(ShareType.COPY_IMAGE) + createShareFunction(({ blob }) => { + copyImage(blob) + setLoadingType(undefined) + }) + return + } + copyImage(blob) + } + + const [isCopied, setCopied] = useCopyClipboard(2000) + const callbackShareSocial = (shareType: ShareType, sharingUrl = '') => { + const { facebook, telegram, discord, twitter } = getSocialShareUrls(sharingUrl) + onShareClick?.(shareType) + switch (shareType) { + case ShareType.FB: + window.open(facebook) + break + case ShareType.TELEGRAM: + window.open(telegram) + break + case ShareType.DISCORD: + window.open(discord) + break + case ShareType.TWITTER: + window.open(twitter) + break + case ShareType.COPY: + setCopied(sharingUrl) + break + } + } + const onClickShareSocial = (shareType: ShareType) => { + if (sharingUrl) callbackShareSocial(shareType, sharingUrl) + else { + setLoadingType(shareType) + createShareFunction(({ shareUrl }) => { + callbackShareSocial(shareType, shareUrl) + setLoadingType(undefined) + }) + } + } + + const handleDownloadClick = () => { + if (!blob) { + setLoadingType(ShareType.DOWNLOAD_IMAGE) + createShareFunction(({ blob }) => { + downloadImage(blob, imageName) + setLoadingType(undefined) + }) + return + } + downloadImage(blob, imageName) + } + + useEffect(() => { + if (imageUrl) { + const img = new Image() + img.src = imageUrl + img.onload = () => { + setLoading(false) + } + } + }, [imageUrl]) + + const disableShareSocial = autoUpload ? !sharingUrl : false + const disableDownloadImage = autoUpload ? !blob : false + const [imageHeight, setImageHeight] = useState() + + useEffect(() => { + const onResize = () => { + setImageHeight(refImgWrapper.current?.getBoundingClientRect?.()?.height) + } + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + useInterval( + () => { + setImageHeight(refImgWrapper.current?.getBoundingClientRect?.()?.height) + }, + imageHeight || !isOpen ? null : 100, + ) + + const propsContents = { + isMobileMode, + content, + setShareIndex, + kyberswapLogoTitle, + leftLogo, + title, + sharingUrl, + ref, + shareIndex, + isOpen, + shareType, + imageHeight, + } + + return ( + + + + + Share this with your friends! + + onClose?.()} /> + + + + + {loading ? ( + <> + + + + + + + + ) : isError ? ( + + Some errors have occurred, please try again later! + + ) : !autoUpload ? ( + + ) : ( + + )} + + + + setIsMobileMode(prev => !prev)}> + + + + + {loadingType === ShareType.DOWNLOAD_IMAGE ? : } + + + {loadingType === ShareType.COPY_IMAGE ? ( + + ) : isCopiedImage ? ( + + ) : ( + + )} + + + + + + ) +} diff --git a/src/components/Table.tsx b/src/components/Table.tsx new file mode 100644 index 0000000000..724e6aef21 --- /dev/null +++ b/src/components/Table.tsx @@ -0,0 +1,249 @@ +import { Trans } from '@lingui/macro' +import { ReactNode, useCallback, useMemo, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { Text } from 'rebass' +import styled, { CSSProperties, css } from 'styled-components' + +import { ReactComponent as NoDataIcon } from 'assets/svg/no-data.svg' +import Column from 'components/Column' +import AnimatedLoader from 'components/Loader/AnimatedLoader' +import Pagination from 'components/Pagination' +import Row from 'components/Row' +import { MouseoverTooltip } from 'components/Tooltip' +import useTheme from 'hooks/useTheme' + +const TableHeader = styled.thead<{ column: number }>` + padding: 16px 20px; + font-size: 12px; + align-items: center; + height: fit-content; + position: relative; + border-radius: 20px 20px 0 0; + border-bottom: ${({ theme }) => `1px solid ${theme.border}`}; + text-align: right; +` +const TBody = styled.tbody`` + +const Thead = styled.th` + text-transform: uppercase; + color: ${({ theme }) => theme.subText}; + background-color: ${({ theme }) => theme.tableHeader}; + padding: 16px 20px; +` +const TRow = styled.tr` + padding: 0px; + border-bottom: ${({ theme }) => `1px solid ${theme.border}`}; +` + +export type TableColumn = { + title: ReactNode + dataIndex?: string + align?: 'left' | 'center' | 'right' + tooltip?: ReactNode + render?: (data: { value: any; item: T }) => ReactNode + style?: CSSProperties + sticky?: boolean +} + +const TableWrapper = styled.table` + border-collapse: collapse; + ${isMobile && + css` + [data-sticky='true'] { + position: sticky; + z-index: 2; + left: 0; + } + [data-sticky='true']::before { + box-shadow: inset 10px 0 8px -8px #00000099; + position: absolute; + top: 0; + right: 0; + bottom: -1px; + width: 10px; + transform: translate(100%); + transition: box-shadow 0.5s; + content: ''; + pointer-events: none; + } + `} +` + +const Td = styled.td` + background: ${({ theme }) => theme.background}; + font-size: 14px; + align-items: center; + padding: 10px 20px; + z-index: 0; +` + +const LoadingWrapper = styled(Row)` + position: absolute; + inset: 0 0 0 0; + background: ${({ theme }) => theme.background}; + opacity: 0.8; + z-index: 2; + border-radius: 20px; + padding-top: min(25vh, 25%); + justify-content: center; + align-items: flex-start; + box-sizing: border-box; + ${({ theme }) => theme.mediaWidth.upToSmall` + width: 100vw; + border-radius: 0; + `} +` + +export default function Table({ + data = [], + columns = [], + style: tableStyle, + headerStyle, + totalItems, + pageSize = 10, + onPageChange, + rowStyle, + loading, + pagination, + emptyMsg, +}: { + data: T[] + columns: TableColumn[] + style?: CSSProperties + headerStyle?: CSSProperties + totalItems?: number + pageSize?: number + onPageChange?: (v: number) => void + rowStyle?: (record: T, index: number) => CSSProperties | undefined + loading?: boolean + pagination?: { + show?: boolean + onNext?: () => void + onBack?: () => void + disableBack?: boolean + disableNext?: boolean + hideWhenSinglePage?: boolean + } + emptyMsg?: string +}) { + const [currentPage, setCurrentPage] = useState(1) + const theme = useTheme() + + const onChangePageWrap = useCallback( + (page: number) => { + onPageChange?.(page) + setCurrentPage(page) + }, + [onPageChange], + ) + + const filterData = useMemo(() => { + return data.length > pageSize ? data.slice((currentPage - 1) * pageSize, currentPage * pageSize) : data + }, [data, pageSize, currentPage]) + + const defaultStyle = { width: columns.some(e => e.style) ? undefined : `${100 / columns.length}%` } + + const { show: showPagination = true, hideWhenSinglePage = false, ...paginationProps } = pagination || {} + return ( + + + + + + {columns.map(({ tooltip, title, align, style, sticky }, i) => { + const customStyle = headerStyle ? undefined : style + return ( + + +
+ {tooltip ? ( + + {title} + + ) : ( + title + )} +
+
+ + ) + })} +
+
+ + {filterData.length + ? filterData.map((item, i) => ( + + {columns.map(({ dataIndex, align, render, style = defaultStyle, sticky }, j) => { + const value = item[dataIndex as keyof T] + let content = null + try { + content = render ? render({ value, item }) : (value as ReactNode) + } catch (error) {} + return ( + + {content} + + ) + })} + + )) + : null} + +
+ {loading && ( + + + + )} +
+ {filterData.length === 0 && ( + + + {emptyMsg || No data found} + + )} + {showPagination && ( + + )} +
+ ) +} diff --git a/src/components/Tabs.tsx b/src/components/Tabs/FarmTabs.tsx similarity index 96% rename from src/components/Tabs.tsx rename to src/components/Tabs/FarmTabs.tsx index 920377d048..721632bc53 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs/FarmTabs.tsx @@ -2,7 +2,7 @@ import { rgba } from 'polished' import { FC, ReactNode } from 'react' import styled, { CSSProperties } from 'styled-components' -import HorizontalScroll from './HorizontalScroll' +import HorizontalScroll from 'components/HorizontalScroll' interface TabsProps { activeKey: string | number diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx new file mode 100644 index 0000000000..e628615c0a --- /dev/null +++ b/src/components/Tabs/index.tsx @@ -0,0 +1,68 @@ +import styled, { CSSProperties, css } from 'styled-components' + +import Row from 'components/Row' + +const ListTab = styled.div` + display: flex; + width: 100%; + gap: 2px; + align-items: center; + justify-content: space-between; + padding: 3px; + overflow-x: auto; +` + +const TabWrapper = styled(Row)` + position: relative; + + width: 100%; + background-color: ${({ theme }) => theme.buttonBlack}; + border-radius: 20px; + justify-content: center; + + overflow: hidden; +` + +const TabItem = styled.div<{ active: boolean }>` + width: 100%; + padding: 6px; + font-weight: 500; + font-size: 12px; + line-height: 14px; + text-align: center; + cursor: pointer; + user-select: none; + color: ${({ theme }) => theme.subText}; + border-radius: 20px; + :hover { + color: ${({ theme }) => theme.text}; + background-color: ${({ theme }) => theme.tabActive}; + } + ${({ active }) => + active && + css` + color: ${({ theme }) => theme.text}; + background-color: ${({ theme }) => theme.border} !important; + `} +` + +interface TabProps { + activeTab: T + setActiveTab: React.Dispatch> + tabs: readonly { readonly title: string; readonly value: T }[] + style?: CSSProperties +} + +export default function Tabs({ activeTab, setActiveTab, tabs, style }: TabProps) { + return ( + + + {tabs.map(tab => ( + setActiveTab(tab.value)}> + {tab.title} + + ))} + + + ) +} diff --git a/src/components/Tutorial/TutorialSwap/index.tsx b/src/components/Tutorial/TutorialSwap/index.tsx index 02958aca04..1933b0a618 100644 --- a/src/components/Tutorial/TutorialSwap/index.tsx +++ b/src/components/Tutorial/TutorialSwap/index.tsx @@ -398,9 +398,12 @@ const getListSteps = (isLogin: boolean) => { ]) } +// todo move export const TutorialKeys = { SHOWED_SWAP_GUIDE: 'showedTutorialSwapGuide', SHOWED_LO_GUIDE: 'showedTutorialLO', + SHOWED_PORTFOLIO_GUIDE: 'showedTutorialPortfolio', + SHOWED_PORTFOLIO_DISCLAIMER: 'showedDisclaimPortfolio', } export default memo(function TutorialSwap() { diff --git a/src/components/TutorialModal.tsx b/src/components/TutorialModal.tsx new file mode 100644 index 0000000000..669024ba87 --- /dev/null +++ b/src/components/TutorialModal.tsx @@ -0,0 +1,330 @@ +import { Trans } from '@lingui/macro' +import React, { ReactNode, useLayoutEffect, useReducer } from 'react' +import { X } from 'react-feather' +import { useMedia } from 'react-use' +import { Text } from 'rebass' +import styled, { CSSProperties, keyframes } from 'styled-components' + +import { ButtonOutlined, ButtonPrimary } from 'components/Button' +import Modal from 'components/Modal' +import Row, { RowBetween } from 'components/Row' +import useTheme from 'hooks/useTheme' +import { MEDIA_WIDTHS } from 'theme' + +const Wrapper = styled.div` + border-radius: 20px; + background-color: ${({ theme }) => theme.tableHeader}; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + width: min(95vw, 808px); + + ${({ theme }) => theme.mediaWidth.upToSmall` + min-height: 70vh; + `} +` +const fadeInScale = keyframes` + 0% { opacity: 0; transform:scale(0.7) } + 100% { opacity: 1; transform:scale(1)} +` +const fadeInLeft = keyframes` + 0% { opacity: 0.5; transform:translateX(calc(-100% - 40px)) } + 100% { opacity: 1; transform:translateX(0)} +` +const fadeOutRight = keyframes` + 0% { opacity: 1; transform:translateX(0);} + 100% { opacity: 0.5; transform:translateX(calc(100% + 40px)); visibility:hidden; } +` +const fadeInRight = keyframes` + 0% { opacity: 0.5; transform:translateX(calc(100% + 40px)) } + 100% { opacity: 1; transform:translateX(0)} +` +const fadeOutLeft = keyframes` + 0% { opacity: 1; transform:translateX(0);} + 100% { opacity: 0.5; transform:translateX(calc(-100% - 40px)); visibility:hidden; } +` + +const StepWrapper = styled.div` + padding: 0; + margin: 0; + box-sizing: content-box; + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + height: fit-content; + &.fadeInScale { + animation: ${fadeInScale} 0.3s ease; + } + &.fadeOutRight { + animation: ${fadeOutRight} 0.5s ease; + } + &.fadeOutLeft { + animation: ${fadeOutLeft} 0.5s ease; + } + &.fadeInRight { + animation: ${fadeInRight} 0.5s ease; + } + &.fadeInLeft { + animation: ${fadeInLeft} 0.5s ease; + } + img { + object-fit: contain; + } + b { + font-weight: 500; + color: ${({ theme }) => theme.text}; + } + p { + margin-bottom: 16px; + } + + ${({ theme }) => theme.mediaWidth.upToSmall` + p { + margin-bottom: 0px; + } + `} +` + +const StepDot = styled.div<{ active?: boolean }>` + height: 8px; + width: 8px; + border-radius: 50%; + background-color: ${({ theme, active }) => (active ? theme.primary : theme.subText)}; +` + +enum AnimationState { + Idle, + Animating, + Animated, +} +enum SwipeDirection { + LEFT, + RIGHT, +} + +type TutorialAnimationState = { + step: number + animationState: AnimationState + swipe: SwipeDirection +} +const initialState = { + step: 0, + animationState: AnimationState.Idle, + swipe: SwipeDirection.LEFT, +} +enum ActionTypes { + INITIAL = 'INITIAL', + START = 'START', + NEXT_STEP = 'NEXT_STEP', + PREV_STEP = 'PREV_STEP', + ANIMATION_END = 'ANIMATION_END', +} +function reducer(state: TutorialAnimationState, action: ActionTypes) { + switch (action) { + case ActionTypes.INITIAL: + return { + ...initialState, + } + case ActionTypes.START: + return { + step: 1, + animationState: AnimationState.Animating, + swipe: SwipeDirection.LEFT, + } + case ActionTypes.NEXT_STEP: + if (state.animationState !== AnimationState.Animating) { + return { + step: state.step + 1, + animationState: AnimationState.Animating, + swipe: SwipeDirection.LEFT, + } + } + break + case ActionTypes.PREV_STEP: + if (state.animationState !== AnimationState.Animating) { + return { + step: state.step - 1, + animationState: AnimationState.Animating, + swipe: SwipeDirection.RIGHT, + } + } + break + case ActionTypes.ANIMATION_END: + return { ...state, animationState: AnimationState.Idle } + + default: + throw new Error() + } + return state +} + +// todo check kyberai +const StepContent = ({ step, ...rest }: { step: TutorialStep; [k: string]: any }) => { + const theme = useTheme() + const above768 = useMedia(`(min-width: ${MEDIA_WIDTHS.upToSmall}px)`) + if (!step) return null + const { image, text, textStyle } = step + + return ( + +
+ + {text} + + + ) +} + +type TutorialStep = { image: string; text: ReactNode; textStyle?: CSSProperties; title?: string } +const TutorialModal = ({ + steps, + isOpen, + onDismiss, + title, + onFinished, +}: { + steps: TutorialStep[] + isOpen: boolean + onDismiss: () => void + onFinished?: () => void + title?: ReactNode +}) => { + const theme = useTheme() + const [{ step, animationState, swipe }, dispatch] = useReducer(reducer, initialState) + const lastStep = + animationState === AnimationState.Animating ? (swipe === SwipeDirection.LEFT ? step - 1 : step + 1) : undefined + + const above768 = useMedia(`(min-width: ${MEDIA_WIDTHS.upToSmall}px)`) + + useLayoutEffect(() => { + if (isOpen) { + dispatch(ActionTypes.INITIAL) + } + }, [isOpen]) + + const stepTitle = steps[step]?.title + + return ( + + + + + {stepTitle || title} + +
+ +
+
+ + + {animationState === AnimationState.Animating && ( + <> + + dispatch(ActionTypes.ANIMATION_END)} + style={{ position: 'absolute', top: 0, left: 0, backgroundColor: theme.tableHeader }} + /> + + )} + + + + {steps.map((a, index) => ( + + ))} + + {step === 0 ? ( + + + + Maybe later + + + { + dispatch(ActionTypes.START) + }} + > + + Let's get started + + + + ) : ( + + dispatch(ActionTypes.PREV_STEP)} + > + + Back + + + { + if (step < steps.length - 1) { + dispatch(ActionTypes.NEXT_STEP) + } else { + onDismiss() + onFinished?.() + } + }} + > + + {step === steps.length - 1 ? Let's go! : Next} + + + + )} +
+
+ ) +} + +export default React.memo(TutorialModal) diff --git a/src/components/WalletPopup/RewardCenter.tsx b/src/components/WalletPopup/RewardCenter.tsx index d4d4c93e2a..75f118f6f2 100644 --- a/src/components/WalletPopup/RewardCenter.tsx +++ b/src/components/WalletPopup/RewardCenter.tsx @@ -6,6 +6,7 @@ import styled from 'styled-components' import { ReactComponent as DollarIcon } from 'assets/svg/dollar.svg' import { NotificationType } from 'components/Announcement/type' import { ButtonPrimary } from 'components/Button' +import Tabs from 'components/Tabs' import { MouseoverTooltip, TextDashed } from 'components/Tooltip' import useMixpanel, { MIXPANEL_TYPE } from 'hooks/useMixpanel' import { useRewards } from 'hooks/useRewards' @@ -16,7 +17,6 @@ import { formatNumberWithPrecisionRange } from 'utils' import { friendlyError } from 'utils/errorMessage' import CardBackground from './AccountInfo/CardBackground' -import Tab from './Transactions/Tab' import { REWARD_TYPE } from './type' const ContentWrapper = styled.div` @@ -155,7 +155,7 @@ export default function RewardCenter() { - activeTab={activeTab} setActiveTab={setActiveTab} tabs={TABS} /> + activeTab={activeTab} setActiveTab={setActiveTab} tabs={TABS} /> Your Reward diff --git a/src/components/WalletPopup/SendToken/index.tsx b/src/components/WalletPopup/SendToken/index.tsx index 0f120dce91..d900b8382f 100644 --- a/src/components/WalletPopup/SendToken/index.tsx +++ b/src/components/WalletPopup/SendToken/index.tsx @@ -57,6 +57,65 @@ const InputWrapper = styled.div` flex-direction: column; ` +export const useValidateFormatRecipient = () => { + const { account, chainId } = useActiveWeb3React() + const [recipient, setRecipient] = useState('') + const [displayRecipient, setDisplayRecipient] = useState('') + const { address, loading } = useENS(recipient) + + const recipientError = + recipient && ((!loading && !address) || !recipient.startsWith('0x')) + ? t`Invalid wallet address` + : recipient.toLowerCase() === account?.toLowerCase() + ? t`You can’t use your own address as a receiver` + : '' + + const formatRecipient = useCallback( + (val: string) => { + try { + setDisplayRecipient(shortenAddress(chainId, val, isMobile ? 14 : 16)) + } catch { + setDisplayRecipient(val) + } + }, + [chainId], + ) + + const onChangeRecipient = useCallback( + (val: string) => { + setRecipient(val) + formatRecipient(val) + }, + [formatRecipient], + ) + + const onFocus = () => { + setDisplayRecipient(recipient) + } + + const onBlur = () => { + formatRecipient(recipient) + } + + const onPaste = async () => { + try { + const text = await navigator.clipboard.readText() + onChangeRecipient(text) + } catch (error) {} + } + + return { + recipientError, + displayRecipient, + address, + recipient, + onChangeRecipient, + onFocus, + onBlur, + onPaste, + } +} + export default function SendToken({ loadingTokens, currencies, @@ -66,13 +125,13 @@ export default function SendToken({ currencies: Currency[] currencyBalances: { [address: string]: TokenAmount | undefined } }) { - const [recipient, setRecipient] = useState('') - const [displayRecipient, setDisplayRecipient] = useState('') + const { displayRecipient, address, recipientError, recipient, onChangeRecipient, onBlur, onFocus, onPaste } = + useValidateFormatRecipient() const [currencyIn, setCurrency] = useState() const [inputAmount, setInputAmount] = useState('') const [showListToken, setShowListToken] = useState(false) - const { account, chainId } = useActiveWeb3React() + const { chainId } = useActiveWeb3React() const [flowState, setFlowState] = useState(TRANSACTION_STATE_DEFAULT) const theme = useTheme() @@ -89,18 +148,9 @@ export default function SendToken({ setInputAmount(balance?.divide(2).toExact() || '') }, [balance]) + const inSymbol = currencyIn?.symbol const parseInputAmount = tryParseAmount(inputAmount, currencyIn) - const { address, loading } = useENS(recipient) - - const recipientError = - recipient && ((!loading && !address) || !recipient.startsWith('0x')) - ? t`Invalid wallet address` - : recipient.toLowerCase() === account?.toLowerCase() - ? t`You can’t use your own address as a receiver` - : '' - - const inSymbol = currencyIn?.symbol const inputError = useMemo(() => { if (!inputAmount) return if (parseFloat(inputAmount) === 0 || !parseInputAmount) { @@ -150,13 +200,6 @@ export default function SendToken({ } }, [loadingTokens, currencies]) - const onPaste = async () => { - try { - const text = await navigator.clipboard.readText() - onChangeRecipient(text) - } catch (error) {} - } - const ref = useRef(null) useOnClickOutside(ref, () => { setShowListToken(false) @@ -186,27 +229,6 @@ export default function SendToken({ const estimateUsd = usdPriceCurrencyIn * parseFloat(inputAmount) - const formatRecipient = (val: string) => { - try { - setDisplayRecipient(shortenAddress(chainId, val, isMobile ? 14 : 16)) - } catch { - setDisplayRecipient(val) - } - } - - const onChangeRecipient = (val: string) => { - setRecipient(val) - formatRecipient(val) - } - - const onFocus = () => { - setDisplayRecipient(recipient) - } - - const onBlur = () => { - formatRecipient(recipient) - } - return ( diff --git a/src/components/WalletPopup/Transactions/ContractAddress.tsx b/src/components/WalletPopup/Transactions/ContractAddress.tsx index 2e8d93bf4f..08958d03b2 100644 --- a/src/components/WalletPopup/Transactions/ContractAddress.tsx +++ b/src/components/WalletPopup/Transactions/ContractAddress.tsx @@ -1,35 +1,29 @@ -import { t } from '@lingui/macro' import styled from 'styled-components' import CopyHelper from 'components/Copy' import { PrimaryText } from 'components/WalletPopup/Transactions/TransactionItem' import { useActiveWeb3React } from 'hooks' import useTheme from 'hooks/useTheme' -import { TRANSACTION_TYPE, TransactionDetails } from 'state/transactions/type' import { ExternalLink } from 'theme' import { getEtherscanLink } from 'utils' import getShortenAddress from 'utils/getShortenAddress' const StyledLink = styled(ExternalLink)` color: ${({ theme }) => theme.text}; + white-space: nowrap; :hover { text-decoration: none; color: ${({ theme }) => theme.text}; } ` -const ContractAddress = ({ transaction }: { transaction: TransactionDetails }) => { - const { extraInfo = {}, type } = transaction +const ContractAddress = ({ contract }: { contract: string }) => { const { chainId } = useActiveWeb3React() const theme = useTheme() - const prefix = type === TRANSACTION_TYPE.TRANSFER_TOKEN ? t`to` : t`contract` - - return extraInfo.contract ? ( + return contract ? ( - - {prefix}: {getShortenAddress(extraInfo.contract)} - - + {getShortenAddress(contract)} + ) : null } diff --git a/src/components/WalletPopup/Transactions/DeltaTokenAmount.tsx b/src/components/WalletPopup/Transactions/DeltaTokenAmount.tsx index cf944912db..7703f85516 100644 --- a/src/components/WalletPopup/Transactions/DeltaTokenAmount.tsx +++ b/src/components/WalletPopup/Transactions/DeltaTokenAmount.tsx @@ -7,13 +7,14 @@ import { PrimaryText } from 'components/WalletPopup/Transactions/TransactionItem import { getTokenLogo } from 'components/WalletPopup/Transactions/helper' import useTheme from 'hooks/useTheme' -export const TokenAmountWrapper = styled.div` +const TokenAmountWrapper = styled.div` display: flex; align-items: center; gap: 4px; font-size: 12px; ` +// todo move const DeltaTokenAmount = ({ symbol, amount, diff --git a/src/components/WalletPopup/Transactions/Icon.tsx b/src/components/WalletPopup/Transactions/Icon.tsx index adc8e14636..885e581741 100644 --- a/src/components/WalletPopup/Transactions/Icon.tsx +++ b/src/components/WalletPopup/Transactions/Icon.tsx @@ -1,32 +1,23 @@ import { ReactNode } from 'react' -import { Repeat } from 'react-feather' +import { FileText, Repeat } from 'react-feather' import { DefaultTheme } from 'styled-components' import { ReactComponent as ApproveIcon } from 'assets/svg/approve_icon.svg' import { ReactComponent as BridgeIcon } from 'assets/svg/bridge_icon.svg' import { ReactComponent as CrossChain } from 'assets/svg/cross_chain_icon.svg' -import { ReactComponent as LiquidityIcon } from 'assets/svg/liquidity_icon.svg' import { ReactComponent as ThunderIcon } from 'assets/svg/thunder_icon.svg' import { MoneyBag } from 'components/Icons' import IconFailure from 'components/Icons/Failed' import IconSprite from 'components/Icons/Icon' import SendIcon from 'components/Icons/SendIcon' import StakeIcon from 'components/Icons/Stake' -import VoteIcon from 'components/Icons/Vote' import useTheme from 'hooks/useTheme' -import { TRANSACTION_GROUP, TRANSACTION_TYPE, TransactionDetails } from 'state/transactions/type' +import { TRANSACTION_TYPE, TransactionDetails } from 'state/transactions/type' -const MAP_ICON_BY_GROUP: { [group in TRANSACTION_GROUP]: ReactNode } = { - [TRANSACTION_GROUP.SWAP]: , - [TRANSACTION_GROUP.LIQUIDITY]: , - [TRANSACTION_GROUP.KYBERDAO]: , - [TRANSACTION_GROUP.OTHER]: null, -} - -const MAP_ICON_BY_TYPE: (theme: DefaultTheme) => Partial> = ( - theme: DefaultTheme, +const MAP_ICON_BY_TYPE: (theme?: DefaultTheme) => Partial> = ( + theme?: DefaultTheme, ) => ({ - [TRANSACTION_TYPE.KYBERDAO_CLAIM_GAS_REFUND]: , + [TRANSACTION_TYPE.KYBERDAO_CLAIM_GAS_REFUND]: , [TRANSACTION_TYPE.CANCEL_LIMIT_ORDER]: , [TRANSACTION_TYPE.BRIDGE]: , [TRANSACTION_TYPE.CROSS_CHAIN_SWAP]: , @@ -36,11 +27,21 @@ const MAP_ICON_BY_TYPE: (theme: DefaultTheme) => Partial, [TRANSACTION_TYPE.KYBERDAO_MIGRATE]: , [TRANSACTION_TYPE.KYBERDAO_UNSTAKE]: , + [TRANSACTION_TYPE.SWAP]: , }) const Icon = ({ txs }: { txs: TransactionDetails }) => { const theme = useTheme() - const icon = MAP_ICON_BY_TYPE(theme)[txs.type] || MAP_ICON_BY_GROUP[txs.group] || + const icon = MAP_ICON_BY_TYPE(theme)[txs.type] || return icon as JSX.Element } +// todo have a task to remove wallet txs + +// todo +export const getTxsIcon = (type: any) => { + const map = MAP_ICON_BY_TYPE() + const key = Object.keys(map).find(key => key.toLowerCase() === type.toLowerCase()) as TRANSACTION_TYPE + return map[key] || +} + export default Icon diff --git a/src/components/WalletPopup/Transactions/PendingWarning.tsx b/src/components/WalletPopup/Transactions/PendingWarning.tsx deleted file mode 100644 index a4e43a5073..0000000000 --- a/src/components/WalletPopup/Transactions/PendingWarning.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Trans, t } from '@lingui/macro' -import { Text } from 'rebass' - -import { MouseoverTooltip } from 'components/Tooltip' -import { NUMBERS } from 'components/WalletPopup/Transactions/helper' -import useTheme from 'hooks/useTheme' -import ErrorWarningPanel from 'pages/Bridge/ErrorWarning' -import { ExternalLink } from 'theme' - -export default function PendingWarning() { - const theme = useTheme() - const min = NUMBERS.STALLED_MINS - return ( - - - Transaction stuck?{' '} - - - See here - - - - - } - /> - ) -} diff --git a/src/components/WalletPopup/Transactions/PoolFarmLink.tsx b/src/components/WalletPopup/Transactions/PoolFarmLink.tsx deleted file mode 100644 index d4d2a55752..0000000000 --- a/src/components/WalletPopup/Transactions/PoolFarmLink.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Flex, Text } from 'rebass' - -import { DoubleCurrencyLogoV2 } from 'components/DoubleLogo' -import SendIcon from 'components/Icons/SendIcon' -import { getTokenLogo } from 'components/WalletPopup/Transactions/helper' -import { APP_PATHS } from 'constants/index' -import { TRANSACTION_TYPE, TransactionDetails, TransactionExtraInfo2Token } from 'state/transactions/type' -import { ExternalLink } from 'theme' - -const PoolFarmLink = ({ transaction }: { transaction: TransactionDetails }) => { - const { extraInfo = {}, type } = transaction - const { tokenSymbolIn, tokenSymbolOut, tokenAddressIn, tokenAddressOut, contract } = - extraInfo as TransactionExtraInfo2Token - - if (!contract || !(tokenSymbolIn && tokenSymbolOut)) return null - - const isFarm = [TRANSACTION_TYPE.HARVEST].includes(type) - const isElastic = [ - TRANSACTION_TYPE.ELASTIC_ADD_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_CREATE_POOL, - TRANSACTION_TYPE.ELASTIC_REMOVE_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_COLLECT_FEE, - TRANSACTION_TYPE.ELASTIC_INCREASE_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_ZAP_IN_LIQUIDITY, - TRANSACTION_TYPE.HARVEST, - ].includes(type) - - const logoUrlIn = getTokenLogo(tokenAddressIn) - const logoUrlOut = getTokenLogo(tokenAddressOut) - return ( - - - - - {tokenSymbolIn}/{tokenSymbolOut} - - - - - ) -} -export default PoolFarmLink diff --git a/src/components/WalletPopup/Transactions/Status.tsx b/src/components/WalletPopup/Transactions/Status.tsx index 16b0053a85..7f5306ac6d 100644 --- a/src/components/WalletPopup/Transactions/Status.tsx +++ b/src/components/WalletPopup/Transactions/Status.tsx @@ -1,133 +1,28 @@ import { t } from '@lingui/macro' -import axios from 'axios' -import debounce from 'lodash/debounce' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useState } from 'react' import { Repeat } from 'react-feather' -import { useDispatch } from 'react-redux' import { Flex } from 'rebass' import { CheckCircle } from 'components/Icons' import IconFailure from 'components/Icons/Failed' -import WarningIcon from 'components/Icons/WarningIcon' -import Loader from 'components/Loader' import { PrimaryText } from 'components/WalletPopup/Transactions/TransactionItem' -import { isTxsPendingTooLong as isShowPendingWarning } from 'components/WalletPopup/Transactions/helper' -import { CancellingOrderInfo } from 'components/swapv2/LimitOrder/useCancellingOrders' -import { BFF_API } from 'constants/env' -import { MultichainTransferStatus } from 'hooks/bridge/useGetBridgeTransfers' import useTheme from 'hooks/useTheme' -import { isCrossChainTxsPending } from 'pages/CrossChain/helpers' -import { AppDispatch } from 'state' -import { modifyTransaction } from 'state/transactions/actions' -import { TRANSACTION_TYPE, TransactionDetails } from 'state/transactions/type' -import { getTransactionStatus } from 'utils/transaction' +import { TransactionHistory } from 'pages/NotificationCenter/Portfolio/type' -const MAX_TIME_CHECK_STATUS = 7 * 86_400_000 // the time that we don't need to interval check -const TYPE_NEED_CHECK_PENDING = [ - TRANSACTION_TYPE.CANCEL_LIMIT_ORDER, - TRANSACTION_TYPE.BRIDGE, - TRANSACTION_TYPE.CROSS_CHAIN_SWAP, -] +function StatusIcon({ transaction }: { transaction: TransactionHistory }) { + const { status } = transaction + const success = status === 'success' -const isTxsActuallySuccess = (txs: TransactionDetails) => txs.extraInfo?.actuallySuccess - -// this component to interval call api/listen firebase to check transaction status actually done or not -function StatusIcon({ - transaction, - cancellingOrderInfo, -}: { - transaction: TransactionDetails - cancellingOrderInfo: CancellingOrderInfo -}) { - const { type, hash, extraInfo, chainId, addedTime } = transaction - const { pending: pendingRpc, success } = getTransactionStatus(transaction) - - const needCheckActuallyPending = - success && - TYPE_NEED_CHECK_PENDING.includes(type) && - !isTxsActuallySuccess(transaction) && - Date.now() - addedTime < MAX_TIME_CHECK_STATUS - - const isPendingTooLong = isShowPendingWarning(transaction) - const [isPendingState, setIsPendingState] = useState(needCheckActuallyPending ? null : pendingRpc) - - const dispatch = useDispatch() - const { loading, isOrderCancelling } = cancellingOrderInfo - - const interval = useRef() - - const checkStatus = useCallback(async () => { - try { - if (isTxsActuallySuccess(transaction) && interval.current) { - clearInterval(interval.current) - return - } - - let isPending = false - const isLoadingRemoteData = type === TRANSACTION_TYPE.CANCEL_LIMIT_ORDER && loading - switch (type) { - case TRANSACTION_TYPE.CANCEL_LIMIT_ORDER: - const orderId = extraInfo?.arbitrary?.order_id - isPending = isOrderCancelling(orderId) - break - case TRANSACTION_TYPE.BRIDGE: { - const { data: response } = await axios.get(`${BFF_API}/v1/cross-chain-history/multichain-transfers/${hash}`) - isPending = response?.data?.status === MultichainTransferStatus.Processing - break - } - case TRANSACTION_TYPE.CROSS_CHAIN_SWAP: { - const { data: response } = await axios.get(`${BFF_API}/v1/cross-chain-history/squid-transfers/${hash}`) - isPending = isCrossChainTxsPending(response?.data?.status) - break - } - } - if (!isPending && !isLoadingRemoteData) { - dispatch( - modifyTransaction({ - chainId, - hash, - extraInfo: { ...extraInfo, actuallySuccess: true }, - }), - ) - } - setIsPendingState(isPending) - } catch (error) { - console.error('Checking txs status error: ', error) - interval.current && clearInterval(interval.current) - } - }, [isOrderCancelling, chainId, dispatch, transaction, extraInfo, hash, type, loading]) - - const checkStatusDebounced = useMemo(() => debounce(checkStatus, 1000), [checkStatus]) - - useEffect(() => { - if (!needCheckActuallyPending) { - setIsPendingState(pendingRpc) - return - } - checkStatusDebounced() - if (TYPE_NEED_CHECK_PENDING.includes(type)) { - interval.current = setInterval(checkStatusDebounced, 5000) - } - return () => interval.current && clearInterval(interval.current) - }, [needCheckActuallyPending, pendingRpc, checkStatusDebounced, type]) + const [isPendingState] = useState(null) const theme = useTheme() - const checkingStatus = isPendingState === null - const pendingText = isPendingTooLong ? t`Pending` : t`Processing` - const pendingIcon = isPendingTooLong ? ( - - ) : ( - - ) + const pendingText = t`Processing` + const pendingIcon = return ( - - {checkingStatus ? t`Checking` : isPendingState ? pendingText : success ? t`Completed` : t`Failed`} - - {checkingStatus ? ( - - ) : isPendingState ? ( + {isPendingState ? pendingText : success ? t`Completed` : t`Failed`} + {isPendingState ? ( pendingIcon ) : success ? ( diff --git a/src/components/WalletPopup/Transactions/Tab.tsx b/src/components/WalletPopup/Transactions/Tab.tsx deleted file mode 100644 index 265795e721..0000000000 --- a/src/components/WalletPopup/Transactions/Tab.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useCallback, useLayoutEffect, useState } from 'react' -import styled, { css } from 'styled-components' - -import Row from 'components/Row' - -const ListTab = styled.div` - display: flex; - width: 100%; - gap: 2px; - align-items: center; - justify-content: space-between; - padding: 3px; - overflow-x: auto; -` - -type WrapperProps = { - $scrollable: boolean - $scrollLeft: boolean - $scrollRight: boolean -} -const TabWrapper = styled(Row).attrs(props => ({ - 'data-scrollable': props.$scrollable, - 'data-scroll-left': props.$scrollLeft, - 'data-scroll-right': props.$scrollRight, -}))` - position: relative; - - width: 100%; - background-color: ${({ theme }) => theme.buttonBlack}; - border-radius: 20px; - justify-content: center; - - overflow: hidden; - - &[data-scrollable='true'] { - justify-content: flex-start; - - ${ListTab} { - justify-content: flex-start; - } - - &[data-scroll-left='true'] { - ::before { - content: ''; - width: 36px; - height: 100%; - - position: absolute; - top: 0; - left: 0; - transform: translateX(-1px); - - display: flex; - align-items: center; - - background: linear-gradient( - -90deg, - rgba(0, 0, 0, 0) 0%, - ${({ theme }) => theme.background} 90%, - ${({ theme }) => theme.background} 100% - ); - } - } - - &[data-scroll-right='true'] { - ::after { - content: ''; - width: 36px; - height: 100%; - - position: absolute; - top: 0; - right: 0; - transform: translateX(1px); - - display: flex; - justify-content: flex-end; - align-items: center; - - background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0) 0%, - ${({ theme }) => theme.background} 90%, - ${({ theme }) => theme.background} 100% - ); - } - } - } -` - -const TabItem = styled.div<{ active: boolean }>` - width: 100%; - padding: 6px; - font-weight: 500; - font-size: 12px; - line-height: 14px; - text-align: center; - cursor: pointer; - user-select: none; - color: ${({ theme }) => theme.subText}; - border-radius: 20px; - :hover { - color: ${({ theme }) => theme.text}; - background-color: ${({ theme }) => theme.tabActive}; - } - ${({ active }) => - active - ? css` - color: ${({ theme }) => theme.text}; - background-color: ${({ theme }) => theme.border} !important; - ` - : null} -` - -interface TabProps { - activeTab: T - setActiveTab: React.Dispatch> - tabs: readonly { readonly title: string; readonly value: T }[] -} -function Tab({ activeTab, setActiveTab, tabs }: TabProps) { - const [isScrollable, setScrollable] = useState(false) - const [scrollLeft, setScrollLeft] = useState(false) - const [scrollRight, setScrollRight] = useState(false) - - const [listRef, setListRef] = useState(null) - - const handleScroll = useCallback(() => { - if (!listRef) return - const { clientWidth, scrollWidth, scrollLeft } = listRef - setScrollable(clientWidth < scrollWidth) - setScrollLeft(scrollLeft > 0) - setScrollRight(scrollLeft < scrollWidth - clientWidth) - }, [listRef]) - - useLayoutEffect(() => { - if (!listRef) return - const resizeHandler = () => { - const { clientWidth, scrollWidth, scrollLeft } = listRef - setScrollable(clientWidth < scrollWidth) - setScrollLeft(scrollLeft > 0) - setScrollRight(scrollLeft < scrollWidth - clientWidth) - } - - const { ResizeObserver } = window - if (typeof ResizeObserver === 'function') { - const resizeObserver = new ResizeObserver(resizeHandler) - resizeObserver.observe(listRef) - - return () => resizeObserver.disconnect() - } else { - window.addEventListener('resize', resizeHandler) - return () => window.removeEventListener('resize', resizeHandler) - } - }, [listRef]) - - return ( - - setListRef(listRef)} onScroll={handleScroll}> - {tabs.map(tab => ( - setActiveTab(tab.value)}> - {tab.title} - - ))} - - - ) -} - -export default Tab diff --git a/src/components/WalletPopup/Transactions/TransactionItem.tsx b/src/components/WalletPopup/Transactions/TransactionItem.tsx index 7b3d1bf4d1..27138d27c5 100644 --- a/src/components/WalletPopup/Transactions/TransactionItem.tsx +++ b/src/components/WalletPopup/Transactions/TransactionItem.tsx @@ -1,38 +1,21 @@ -import { ChainId } from '@kyberswap/ks-sdk-core' -import { Trans, t } from '@lingui/macro' +import { Trans } from '@lingui/macro' import dayjs from 'dayjs' -import { Fragment, ReactNode, forwardRef } from 'react' +import { forwardRef } from 'react' import { Flex, Text } from 'rebass' import styled, { CSSProperties } from 'styled-components' -import { ReactComponent as ArrowDown } from 'assets/svg/arrow_down.svg' -import { ReactComponent as NftIcon } from 'assets/svg/nft_icon.svg' -import SendIcon from 'components/Icons/SendIcon' -import { NetworkLogo } from 'components/Logo' +import Badge, { BadgeVariant } from 'components/Badge' import Row from 'components/Row' import ContractAddress from 'components/WalletPopup/Transactions/ContractAddress' -import DeltaTokenAmount, { TokenAmountWrapper } from 'components/WalletPopup/Transactions/DeltaTokenAmount' -import Icon from 'components/WalletPopup/Transactions/Icon' -import PendingWarning from 'components/WalletPopup/Transactions/PendingWarning' -import PoolFarmLink from 'components/WalletPopup/Transactions/PoolFarmLink' +import { getTxsIcon } from 'components/WalletPopup/Transactions/Icon' import Status from 'components/WalletPopup/Transactions/Status' -import { isTxsPendingTooLong } from 'components/WalletPopup/Transactions/helper' -import { CancellingOrderInfo } from 'components/swapv2/LimitOrder/useCancellingOrders' -import { APP_PATHS, ETHER_ADDRESS } from 'constants/index' -import { NETWORKS_INFO } from 'constants/networks' import useTheme from 'hooks/useTheme' import { getAxelarScanUrl } from 'pages/CrossChain' -import { - TRANSACTION_TYPE, - TransactionDetails, - TransactionExtraBaseInfo, - TransactionExtraInfo1Token, - TransactionExtraInfo2Token, - TransactionExtraInfoHarvestFarm, - TransactionExtraInfoStakeFarm, -} from 'state/transactions/type' -import { ExternalLink, ExternalLinkIcon } from 'theme' -import { getEtherscanLink, getNativeTokenLogo } from 'utils' +import { BalanceCell, getTxsAction } from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Transactions' +import { TransactionHistory } from 'pages/NotificationCenter/Portfolio/type' +import { TRANSACTION_TYPE } from 'state/transactions/type' +import { ExternalLinkIcon } from 'theme' +import { getEtherscanLink } from 'utils' const ItemWrapper = styled.div` border-bottom: 1px solid ${({ theme }) => theme.border}; @@ -60,374 +43,52 @@ export const PrimaryText = styled(Text)` color: ${({ theme }) => theme.subText}; ` -const DescriptionBasic = (transaction: TransactionDetails) => { - const { extraInfo = {} } = transaction - const { summary = '' } = extraInfo as TransactionExtraBaseInfo - return {summary} -} - -// ex: claim 3knc -const Description1Token = (transaction: TransactionDetails) => { - const { extraInfo = {}, type } = transaction - const { tokenSymbol, tokenAmount, tokenAddress } = extraInfo as TransactionExtraInfo1Token - // +10KNC or -10KNC - const plus = [TRANSACTION_TYPE.KYBERDAO_CLAIM, TRANSACTION_TYPE.KYBERDAO_CLAIM_GAS_REFUND].includes(type) - return -} - -//ex: +3knc -2usdt -const Description2Token = (transaction: TransactionDetails) => { - const { extraInfo = {}, type, chainId } = transaction - const { tokenAmountIn, tokenAmountOut, tokenSymbolIn, tokenSymbolOut, tokenAddressIn, tokenAddressOut } = - extraInfo as TransactionExtraInfo2Token - - const signTokenOut = ![ - TRANSACTION_TYPE.CLASSIC_ADD_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_ADD_LIQUIDITY, - TRANSACTION_TYPE.CLASSIC_CREATE_POOL, - TRANSACTION_TYPE.ELASTIC_CREATE_POOL, - TRANSACTION_TYPE.ELASTIC_INCREASE_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_ZAP_IN_LIQUIDITY, - ].includes(type) - - const signTokenIn = [ - TRANSACTION_TYPE.CLASSIC_REMOVE_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_REMOVE_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_COLLECT_FEE, - ].includes(type) - - return ( - <> - - - - ) -} - -// ex: stake -3knc -const DescriptionKyberDaoStake = (transaction: TransactionDetails) => { - const { extraInfo = {}, type } = transaction - const { tokenSymbol, tokenAmount, tokenAddress } = extraInfo as TransactionExtraInfo1Token - const votingPower = extraInfo?.arbitrary?.votingPower - const isUnstake = type === TRANSACTION_TYPE.KYBERDAO_UNSTAKE - return ( - <> - {isUnstake ? null : } - - - ) -} - -const StyledLink = styled(ExternalLink)` - &:hover { - text-decoration: none; - } -` -const NftLink = ({ - nftId, - canNavigate = true, - type, -}: { - nftId: string - canNavigate?: boolean - type: TRANSACTION_TYPE -}) => { - const theme = useTheme() - const plus = [TRANSACTION_TYPE.ELASTIC_WITHDRAW_LIQUIDITY, TRANSACTION_TYPE.UNSTAKE].includes(type) - const icon = ( - - - -  {plus ? '+' : '-'} #{nftId} - -  {canNavigate && } - - ) - if (!canNavigate) return icon - return ( - - {icon} - - ) -} - -const DescriptionLiquidity = (transaction: TransactionDetails) => { - const { nftId } = (transaction.extraInfo ?? {}) as TransactionExtraInfo2Token - return { - leftComponent: Description2Token(transaction), - rightComponent: nftId ? ( - - ) : ( - - ), - } -} - -const DescriptionHarvestFarmReward = (transaction: TransactionDetails) => { - const { rewards = [] } = (transaction.extraInfo ?? {}) as TransactionExtraInfoHarvestFarm - return ( - <> - {rewards.map(item => ( - - ))} - - ) -} - -const DescriptionBridge = (transaction: TransactionDetails) => { - const { extraInfo = {} } = transaction - const { - tokenAmountIn, - tokenSymbolIn, - chainIdIn = ChainId.MAINNET, - chainIdOut = ChainId.MAINNET, - tokenAddressIn, - } = extraInfo as TransactionExtraInfo2Token - const theme = useTheme() - - return { - leftComponent: ( - <> -
- - - {NETWORKS_INFO[chainIdIn].name} - - -
- - - {NETWORKS_INFO[chainIdOut].name} - - - ), - rightComponent: ( - - ), - } -} - -const DescriptionCrossChain = (transaction: TransactionDetails) => { - const { extraInfo = {} } = transaction - const { - tokenAmountIn, - tokenSymbolIn, - chainIdIn = ChainId.MAINNET, - chainIdOut = ChainId.MAINNET, - tokenAddressIn, - tokenAddressOut, - tokenAmountOut, - tokenSymbolOut, - tokenLogoURLIn, - tokenLogoURLOut, - rate, - } = extraInfo as TransactionExtraInfo2Token - const theme = useTheme() - - return { - leftComponent: ( - <> - - - - - - - - ), - rightComponent: ( - - ), - } -} - -// ex: approve elastic farm, approve knc, claim 3knc -const DescriptionApproveClaim = (transaction: TransactionDetails) => { - const { extraInfo = {}, type } = transaction - const { tokenSymbol, tokenAmount, tokenAddress } = extraInfo as TransactionExtraInfo1Token - const { summary = '' } = extraInfo as TransactionExtraBaseInfo - const plus = [TRANSACTION_TYPE.CLAIM_REWARD].includes(type) - - return summary ? ( - {summary} - ) : ( - - ) -} - -const DescriptionLimitOrder = (transaction: TransactionDetails) => { - const { extraInfo = {} } = transaction - const { tokenAmountIn, tokenAmountOut, tokenSymbolIn, tokenSymbolOut } = extraInfo as TransactionExtraInfo2Token - if (!tokenAmountIn) - return ( - - Cancel all orders - - ) - return ( - - - - to - - - - ) -} - -const DescriptionStakeFarm = (transaction: TransactionDetails) => { - const { extraInfo = {}, type } = transaction - const { pairs = [] } = extraInfo as TransactionExtraInfoStakeFarm - if (pairs?.length) - return ( - <> - {pairs.map(({ nftId }) => ( - - ))} - - ) - const { tokenAmount, tokenSymbol } = extraInfo as TransactionExtraInfo1Token - return -} - -const DESCRIPTION_MAP: { - [type in TRANSACTION_TYPE]: ( - txs: TransactionDetails, - ) => null | JSX.Element | { leftComponent: ReactNode; rightComponent: ReactNode } -} = { - [TRANSACTION_TYPE.ELASTIC_FORCE_WITHDRAW_LIQUIDITY]: DescriptionBasic, - [TRANSACTION_TYPE.KYBERDAO_VOTE]: DescriptionBasic, - [TRANSACTION_TYPE.KYBERDAO_DELEGATE]: DescriptionBasic, - [TRANSACTION_TYPE.KYBERDAO_UNDELEGATE]: DescriptionBasic, - - [TRANSACTION_TYPE.UNSTAKE]: DescriptionStakeFarm, - [TRANSACTION_TYPE.STAKE]: DescriptionStakeFarm, - [TRANSACTION_TYPE.ELASTIC_DEPOSIT_LIQUIDITY]: DescriptionStakeFarm, - [TRANSACTION_TYPE.ELASTIC_WITHDRAW_LIQUIDITY]: DescriptionStakeFarm, - - [TRANSACTION_TYPE.APPROVE]: DescriptionApproveClaim, - [TRANSACTION_TYPE.CLAIM_REWARD]: DescriptionApproveClaim, - - [TRANSACTION_TYPE.KYBERDAO_STAKE]: DescriptionKyberDaoStake, - [TRANSACTION_TYPE.KYBERDAO_UNSTAKE]: DescriptionKyberDaoStake, - [TRANSACTION_TYPE.KYBERDAO_CLAIM]: Description1Token, - [TRANSACTION_TYPE.KYBERDAO_CLAIM_GAS_REFUND]: Description1Token, - - [TRANSACTION_TYPE.TRANSFER_TOKEN]: Description1Token, - - [TRANSACTION_TYPE.UNWRAP_TOKEN]: Description2Token, - [TRANSACTION_TYPE.WRAP_TOKEN]: Description2Token, - [TRANSACTION_TYPE.SWAP]: Description2Token, - [TRANSACTION_TYPE.KYBERDAO_MIGRATE]: Description2Token, - - [TRANSACTION_TYPE.BRIDGE]: DescriptionBridge, - [TRANSACTION_TYPE.CROSS_CHAIN_SWAP]: DescriptionCrossChain, - [TRANSACTION_TYPE.CANCEL_LIMIT_ORDER]: DescriptionLimitOrder, - - [TRANSACTION_TYPE.CLASSIC_CREATE_POOL]: DescriptionLiquidity, - [TRANSACTION_TYPE.ELASTIC_CREATE_POOL]: DescriptionLiquidity, - [TRANSACTION_TYPE.ELASTIC_ADD_LIQUIDITY]: DescriptionLiquidity, - [TRANSACTION_TYPE.CLASSIC_ADD_LIQUIDITY]: DescriptionLiquidity, - [TRANSACTION_TYPE.CLASSIC_REMOVE_LIQUIDITY]: DescriptionLiquidity, - [TRANSACTION_TYPE.ELASTIC_REMOVE_LIQUIDITY]: DescriptionLiquidity, - [TRANSACTION_TYPE.ELASTIC_INCREASE_LIQUIDITY]: DescriptionLiquidity, - [TRANSACTION_TYPE.ELASTIC_ZAP_IN_LIQUIDITY]: DescriptionLiquidity, - [TRANSACTION_TYPE.ELASTIC_COLLECT_FEE]: DescriptionLiquidity, - - [TRANSACTION_TYPE.HARVEST]: DescriptionHarvestFarmReward, -} - type Prop = { - transaction: TransactionDetails + transaction: TransactionHistory style: CSSProperties isMinimal: boolean - cancellingOrderInfo: CancellingOrderInfo } -export default forwardRef(function TransactionItem( - { transaction, style, isMinimal, cancellingOrderInfo }: Prop, - ref, -) { - const { type, addedTime, hash, chainId } = transaction +export default forwardRef(function TransactionItem({ transaction, style, isMinimal }: Prop, ref) { + const { contract = '', type } = getTxsAction(transaction) + const { chain, blockTime, txHash, tag } = transaction + const chainId = chain?.chainId const theme = useTheme() - const info: any = DESCRIPTION_MAP?.[type]?.(transaction) - const leftComponent: ReactNode = info?.leftComponent !== undefined ? info?.leftComponent : info - const rightComponent: ReactNode = info?.rightComponent - const isStalled = isTxsPendingTooLong(transaction) - return ( - - {isStalled && } - + {!isMinimal && ( - + {getTxsIcon(type)}{' '} )} - {type} + {type}{' '} + {tag === 'SCAM' && ( + + Spam + + )} - + - {leftComponent} + - {rightComponent || } - {dayjs(addedTime).format('DD/MM/YYYY HH:mm:ss')} + + {dayjs(blockTime * 1000).format('DD/MM/YYYY HH:mm:ss')} diff --git a/src/components/WalletPopup/Transactions/helper.ts b/src/components/WalletPopup/Transactions/helper.ts index 862c0860f6..775262c294 100644 --- a/src/components/WalletPopup/Transactions/helper.ts +++ b/src/components/WalletPopup/Transactions/helper.ts @@ -1,20 +1,8 @@ import { findCacheToken } from 'hooks/Tokens' -import { TRANSACTION_GROUP, TransactionDetails } from 'state/transactions/type' -import { getTransactionStatus } from 'utils/transaction' export const NUMBERS = { STALL_WARNING_HEIGHT: 36, - TRANSACTION_LINE_HEIGHT: 15, - STALLED_MINS: 5, -} - -export const isTxsPendingTooLong = (txs: TransactionDetails) => { - const { pending: pendingTxsStatus } = getTransactionStatus(txs) - return ( - pendingTxsStatus && - Date.now() - txs.addedTime > NUMBERS.STALLED_MINS * 60_000 && - txs.group === TRANSACTION_GROUP.SWAP - ) + TRANSACTION_LINE_HEIGHT: 18, } export const getTokenLogo = (address: string | undefined) => findCacheToken(address ?? '')?.logoURI ?? '' diff --git a/src/components/WalletPopup/Transactions/index.tsx b/src/components/WalletPopup/Transactions/index.tsx index d7cb6969ad..901ffefb32 100644 --- a/src/components/WalletPopup/Transactions/index.tsx +++ b/src/components/WalletPopup/Transactions/index.tsx @@ -1,25 +1,21 @@ -import { Trans, t } from '@lingui/macro' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Trans } from '@lingui/macro' +import { memo, useCallback, useEffect, useRef } from 'react' import { Info } from 'react-feather' import AutoSizer from 'react-virtualized-auto-sizer' import { VariableSizeList } from 'react-window' import { Flex, Text } from 'rebass' +import { useGetTransactionsQuery } from 'services/portfolio' import styled, { CSSProperties } from 'styled-components' -import Tab from 'components/WalletPopup/Transactions/Tab' +import Dots from 'components/Dots' +import Loader from 'components/Loader' +import Row from 'components/Row' import { NUMBERS } from 'components/WalletPopup/Transactions/helper' -import useCancellingOrders, { CancellingOrderInfo } from 'components/swapv2/LimitOrder/useCancellingOrders' +import { EMPTY_ARRAY } from 'constants/index' import { useActiveWeb3React } from 'hooks' import { fetchListTokenByAddresses, findCacheToken, useIsLoadedTokenDefault } from 'hooks/Tokens' -import { isSupportKyberDao } from 'hooks/kyberdao' import useTheme from 'hooks/useTheme' -import { useSortRecentTransactions } from 'state/transactions/hooks' -import { - TRANSACTION_GROUP, - TransactionDetails, - TransactionExtraInfo1Token, - TransactionExtraInfo2Token, -} from 'state/transactions/type' +import { TransactionHistory } from 'pages/NotificationCenter/Portfolio/type' import TransactionItem from './TransactionItem' @@ -56,96 +52,54 @@ function RowItem({ transaction, setRowHeight, isMinimal, - cancellingOrderInfo, }: { - transaction: TransactionDetails + transaction: TransactionHistory style: CSSProperties index: number setRowHeight: (v: number, height: number) => void isMinimal: boolean - cancellingOrderInfo: CancellingOrderInfo }) { const rowRef = useRef(null) useEffect(() => { /** because react-window don't support dynamic height => manually calc height for each item * - * --- warning --- * title * left right * - * => item height = warning_height + tile_height + max(height_left, height_right) + gap + padding + * => item height = tile_height + max(height_left, height_right) + gap + padding */ const leftCol = rowRef.current?.querySelector('.left-column') const rightCol = rowRef.current?.querySelector('.right-column') if (leftCol && rightCol && rowRef.current) { const { paddingTop, paddingBottom, gap } = getComputedStyle(rowRef.current) const rowGap = parseFloat(gap) - const warningHeight = rowRef.current.dataset.stalled === 'true' ? NUMBERS.STALL_WARNING_HEIGHT + rowGap : 0 const rowNum = Math.max(leftCol.children.length, rightCol.children.length) + 1 // 1 for title setRowHeight( index, parseFloat(paddingTop) + parseFloat(paddingBottom) + - warningHeight + NUMBERS.TRANSACTION_LINE_HEIGHT * rowNum + (rowNum - 1) * rowGap, ) } }, [rowRef, index, setRowHeight]) - return ( - - ) + return } -// This is intentional, we don't need to persist in localStorage -let storedActiveTab = '' + function ListTransaction({ isMinimal }: { isMinimal: boolean }) { - const listTab = useMemo( - () => [ - { title: t`All`, value: '' }, - { title: t`Swaps`, value: TRANSACTION_GROUP.SWAP }, - { title: t`Liquidity`, value: TRANSACTION_GROUP.LIQUIDITY }, - { title: t`KyberDAO`, value: TRANSACTION_GROUP.KYBERDAO }, - { title: t`Others`, value: TRANSACTION_GROUP.OTHER }, - ], - [], + const { chainId, account } = useActiveWeb3React() + + const { data, isFetching } = useGetTransactionsQuery( + { walletAddress: account || '', chainIds: [chainId], limit: 100, endTime: 0 }, + { skip: !account, refetchOnMountOrArgChange: true, pollingInterval: 30_000 }, ) + const transactions = data?.data || EMPTY_ARRAY - const transactions = useSortRecentTransactions(false) - const { chainId } = useActiveWeb3React() - const [activeTab, setActiveTab] = useState(storedActiveTab) const theme = useTheme() - const cancellingOrderInfo = useCancellingOrders() const listTokenAddress = useRef([]) - const pushAddress = (address: string) => { - if (address && !listTokenAddress.current.includes(address)) listTokenAddress.current.push(address) - } - - const formatTransactions = useMemo(() => { - const result: TransactionDetails[] = [] - transactions.forEach(list => { - list.forEach(txs => { - if (!activeTab || txs.group === activeTab) { - result.push(txs) - const { tokenAddress } = (txs.extraInfo as TransactionExtraInfo1Token) ?? {} - const { tokenAddressIn, tokenAddressOut } = (txs.extraInfo as TransactionExtraInfo2Token) ?? {} - pushAddress(tokenAddress) - pushAddress(tokenAddressIn) - pushAddress(tokenAddressOut) - } - }) - }) - - return result - }, [transactions, activeTab]) const total = listTokenAddress.current const isLoadedTokenDefault = useIsLoadedTokenDefault() @@ -172,29 +126,26 @@ function ListTransaction({ isMinimal }: { isMinimal: boolean }) { return rowHeights.current[index] || 100 } - useEffect(() => { - storedActiveTab = activeTab - }, [activeTab]) - - const filterTab = useMemo(() => { - return listTab.filter(tab => { - if (tab.value === TRANSACTION_GROUP.KYBERDAO) { - return isSupportKyberDao(chainId) - } - return true - }) - }, [chainId, listTab]) - return ( - activeTab={activeTab} setActiveTab={setActiveTab} tabs={filterTab} /> - {formatTransactions.length === 0 ? ( + {transactions.length === 0 ? ( - - - You have no Transaction History. - + {isFetching ? ( + + {' '} + + Loading Transactions + + + ) : ( + <> + + + You have no Transaction History. + + + )} ) : ( @@ -205,8 +156,8 @@ function ListTransaction({ isMinimal }: { isMinimal: boolean }) { itemSize={getRowHeight} ref={listRef} outerRef={onRefChange} - itemCount={formatTransactions.length} - itemData={formatTransactions} + itemCount={transactions.length} + itemData={transactions} > {({ data, index, style }) => ( )} diff --git a/src/components/swapv2/LimitOrder/ListOrder/TabSelector.tsx b/src/components/swapv2/LimitOrder/ListOrder/TabSelector.tsx deleted file mode 100644 index d3027fb9d5..0000000000 --- a/src/components/swapv2/LimitOrder/ListOrder/TabSelector.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { t } from '@lingui/macro' -import { Flex } from 'rebass' - -import TabButton from 'components/TabButton' - -import { LimitOrderStatus } from '../type' - -const TabSelector = ({ - className, - activeTab, - setActiveTab, -}: { - className?: string - activeTab: LimitOrderStatus - setActiveTab: (n: LimitOrderStatus) => void -}) => { - const style = { padding: '16px', flex: 'unset', fontSize: '14px' } - return ( - - { - setActiveTab(LimitOrderStatus.ACTIVE) - }} - text={t`Active Orders`} - /> - { - setActiveTab(LimitOrderStatus.CLOSED) - }} - /> - - ) -} -export default TabSelector diff --git a/src/components/swapv2/LimitOrder/ListOrder/index.tsx b/src/components/swapv2/LimitOrder/ListOrder/index.tsx index 68ee8f4c4c..260f85155e 100644 --- a/src/components/swapv2/LimitOrder/ListOrder/index.tsx +++ b/src/components/swapv2/LimitOrder/ListOrder/index.tsx @@ -6,7 +6,7 @@ import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' import { Trash } from 'react-feather' import { useNavigate } from 'react-router-dom' import { useMedia } from 'react-use' -import { Flex, Text } from 'rebass' +import { Text } from 'rebass' import { useGetListOrdersQuery } from 'services/limitOrder' import styled from 'styled-components' @@ -17,6 +17,7 @@ import LocalLoader from 'components/LocalLoader' import Pagination from 'components/Pagination' import Row from 'components/Row' import SearchInput from 'components/SearchInput' +import Section from 'components/Section' import Select from 'components/Select' import SubscribeNotificationButton from 'components/SubscribeButton' import useRequestCancelOrder from 'components/swapv2/LimitOrder/ListOrder/useRequestCancelOrder' @@ -43,7 +44,6 @@ import { calcPercentFilledOrder, getPayloadTracking, isActiveStatus } from '../h import { LimitOrder, LimitOrderStatus } from '../type' import useCancellingOrders from '../useCancellingOrders' import OrderItem from './OrderItem' -import TabSelector from './TabSelector' import TableHeader from './TableHeader' const Wrapper = styled.div` @@ -51,12 +51,8 @@ const Wrapper = styled.div` flex-direction: column; border-radius: 20px; gap: 1rem; - border: 1px solid ${({ theme }) => theme.border}; ${({ theme }) => theme.mediaWidth.upToSmall` - margin-left: -16px; width: 100vw; - border-left: none; - border-right: none; `}; ` @@ -130,7 +126,10 @@ const SearchInputWrapped = styled(SearchInput)` max-width: unset; `}; ` - +const getTabs = () => [ + { type: LimitOrderStatus.ACTIVE, title: t`Active Orders` }, + { type: LimitOrderStatus.CLOSED, title: t`Order History` }, +] export default function ListLimitOrder({ customChainId }: { customChainId?: ChainId }) { const { account, chainId: walletChainId, networkInfo } = useActiveWeb3React() const chainId = customChainId || walletChainId @@ -301,7 +300,7 @@ export default function ListLimitOrder({ customChainId }: { customChainId?: Chai const subscribeBtn = !isPartnerSwap && ( @@ -314,89 +313,91 @@ export default function ListLimitOrder({ customChainId }: { customChainId?: Chai calcPercentFilledOrder(currentOrder.filledTakingAmount, currentOrder.takingAmount, currentOrder.takerAssetDecimals) return ( - - - - {!upToSmall && subscribeBtn} - - - - - {upToSmall && subscribeBtn} - + style={{ background: 'transparent', margin: upToSmall ? '0 -16px' : undefined }} + onTabClick={onSelectTab} + tabs={getTabs()} + activeTab={isTabActive ? LimitOrderStatus.ACTIVE : LimitOrderStatus.CLOSED} + actions={!upToSmall && subscribeBtn} + contentStyle={{ margin: '0 -16px', paddingBottom: 0 }} + > + + + + {upToSmall && subscribeBtn} + + + - - - - {loading ? ( - - ) : ( -
- - - {orders.map((order, index) => ( - - ))} - - {orders.length !== 0 ? ( - - {isTabActive && ( - - - - Cancel All - - - )} - {totalOrder > PAGE_SIZE && ( - + + {loading ? ( + + ) : ( +
+ + + {orders.map((order, index) => ( + - )} - - ) : ( - - - - {keyword ? ( - No orders found. - ) : isTabActive ? ( - You don't have any open orders yet. - ) : ( - You don't have any order history. + ))} + + {orders.length !== 0 ? ( + + {isTabActive && ( + + + + Cancel All + + )} - - - )} -
- )} + {totalOrder > PAGE_SIZE && ( + + )} +
+ ) : ( + + + + {keyword ? ( + No orders found. + ) : isTabActive ? ( + You don't have any open orders yet. + ) : ( + You don't have any order history. + )} + + + )} +
+ )} +
)} -
+ ) } diff --git a/src/components/swapv2/LimitOrder/ListOrder/useRequestCancelOrder.tsx b/src/components/swapv2/LimitOrder/ListOrder/useRequestCancelOrder.tsx index 9834d4d3b6..f1dd178c91 100644 --- a/src/components/swapv2/LimitOrder/ListOrder/useRequestCancelOrder.tsx +++ b/src/components/swapv2/LimitOrder/ListOrder/useRequestCancelOrder.tsx @@ -23,7 +23,7 @@ import { getReadingContract } from 'utils/getContract' import { sendEVMTransaction } from 'utils/sendTransaction' import { ErrorName } from 'utils/sentry' import { formatSignature } from 'utils/transaction' -import useEstimateGasTxs from 'utils/useEstimateGasTxs' +import { useLazyEstimateGasTxs } from 'utils/useEstimateGasTxs' import { formatAmountOrder, getErrorMessage, getPayloadTracking } from '../helpers' import { CancelOrderFunction, CancelOrderResponse, CancelOrderType, LimitOrder } from '../type' @@ -275,7 +275,7 @@ export const useProcessCancelOrder = ({ export const useEstimateFee = ({ isCancelAll = false, orders }: { isCancelAll?: boolean; orders: LimitOrder[] }) => { const getEncodeData = useGetEncodeLimitOrder() - const estimateGas = useEstimateGasTxs() + const estimateGas = useLazyEstimateGasTxs() const [gasFeeHardCancel, setGasFeeHardCancel] = useState('') useEffect(() => { diff --git a/src/constants/index.ts b/src/constants/index.ts index 1703e63178..00abe91aaa 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -176,6 +176,9 @@ export const APP_PATHS = { IAM_LOGIN: '/login', IAM_LOGOUT: '/logout', IAM_CONSENT: '/consent', + + PORTFOLIO: '/portfolio', + MY_PORTFOLIO: '/my-portfolio', } as const export const TERM_FILES_PATH = { @@ -242,6 +245,12 @@ export const RTK_QUERY_TAGS = { GET_LIST_ORDERS: 'GET_LIST_ORDERS', GET_FARM_V2: 'GET_FARM_V2', + + // portfolio + GET_LIST_PORTFOLIO: 'GET_LIST_PORTFOLIO', + GET_LIST_WALLET_PORTFOLIO: 'GET_LIST_WALLET_PORTFOLIO', + GET_SETTING_PORTFOLIO: 'GET_SETTING_PORTFOLIO', + GET_FAVORITE_PORTFOLIO: 'GET_FAVORITE_PORTFOLIO', } export const TRANSACTION_STATE_DEFAULT: TransactionFlowState = { diff --git a/src/constants/styles.ts b/src/constants/styles.ts index 81e85c1732..2a065cfa25 100644 --- a/src/constants/styles.ts +++ b/src/constants/styles.ts @@ -16,7 +16,7 @@ export const Z_INDEXS = { export const THRESHOLD_HEADER = { BLOG: '1600px', - ABOUT: '1440px', + ABOUT: '1600px', ANALYTIC: '1320px', KYBERDAO: '1040px', CAMPAIGNS: '560px', diff --git a/src/hooks/useCopyClipboard.ts b/src/hooks/useCopyClipboard.ts index ef01fa3577..ea84b4d13d 100644 --- a/src/hooks/useCopyClipboard.ts +++ b/src/hooks/useCopyClipboard.ts @@ -1,13 +1,17 @@ import { useCallback, useEffect, useState } from 'react' import { useCopyToClipboard } from 'react-use' -export default function useCopyClipboard(timeout = 500): [string | undefined, (toCopy: string) => void] { - const [copied, setCopied] = useState(undefined) +export default function useCopyClipboard(timeout = 500): [string | Blob | undefined, (toCopy: string | Blob) => void] { + const [copied, setCopied] = useState(undefined) const [, copy] = useCopyToClipboard() const staticCopy = useCallback( - (text: string) => { - copy(text) - setCopied(text) + (data: string | Blob) => { + if (data instanceof Blob) { + navigator.clipboard.write([new ClipboardItem({ ['image/png']: data })]) + } else { + copy(data) + } + setCopied(data) }, [copy], ) diff --git a/src/hooks/useInvalidateTags.ts b/src/hooks/useInvalidateTags.ts index 3a1c9cb3e1..f981d33245 100644 --- a/src/hooks/useInvalidateTags.ts +++ b/src/hooks/useInvalidateTags.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import announcementApi from 'services/announcement' import limitOrderApi from 'services/limitOrder' +import portfolioApi from 'services/portfolio' import kyberAIApi from 'pages/TrueSightV2/hooks/useKyberAIData' import { useAppDispatch } from 'state/hooks' @@ -26,3 +27,7 @@ export const useInvalidateTagKyberAi = () => { export const useInvalidateTagLimitOrder = () => { return useInvalidateTags(limitOrderApi) } + +export const useInvalidateTagPortfolio = () => { + return useInvalidateTags(portfolioApi) +} diff --git a/src/hooks/useLogin.tsx b/src/hooks/useLogin.tsx index 1e9daa4be5..7e5a5a41c0 100644 --- a/src/hooks/useLogin.tsx +++ b/src/hooks/useLogin.tsx @@ -66,8 +66,7 @@ const useLogin = (autoLogin = false) => { const isAnonymous = loginMethod === LoginMethod.ANONYMOUS try { const profile = await createProfile().unwrap() - const formatProfile = { ...profile } - setProfile({ profile: formatProfile, isAnonymous, account }) + setProfile({ profile, isAnonymous, account }) } catch (error) { const e = new Error('createProfile Error', { cause: error }) e.name = 'createProfile Error' @@ -112,12 +111,12 @@ const useLogin = (autoLogin = false) => { console.log('sign in anonymous err', error) hasError = true } finally { - setLoading(false) await getProfile({ walletAddress: account, account: guestAccount, loginMethod: LoginMethod.ANONYMOUS, }) + setLoading(false) // todo !hasError && showSuccessMsg && showSignInSuccess(guestAccount, LoginMethod.ANONYMOUS) } }, diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 6f5f98e61c..a8b051839d 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -38,6 +38,7 @@ import VerifyAuth from './Verify/VerifyAuth' const Login = lazy(() => import('./Oauth/Login')) const Logout = lazy(() => import('./Oauth/Logout')) const Consent = lazy(() => import('./Oauth/Consent')) +const PortfolioDetail = lazy(() => import('./NotificationCenter/Portfolio/PortfolioDetail')) // test page for swap only through elastic const ElasticSwap = lazy(() => import('./ElasticSwap')) @@ -72,6 +73,12 @@ const GrantProgramPage = lazy(() => import('pages/GrantProgram')) const NotificationCenter = lazy(() => import('pages/NotificationCenter')) const Icons = lazy(() => import('./Icons')) +const portfolioRoutes = [ + APP_PATHS.MY_PORTFOLIO, + `${APP_PATHS.PORTFOLIO}/:portfolioId/:wallet?`, + `${APP_PATHS.MY_PORTFOLIO}/:portfolioId/:wallet?`, +] + const AppWrapper = styled.div` display: flex; flex-flow: column; @@ -368,6 +375,18 @@ export default function App() { } /> + {portfolioRoutes.map(path => ( + + + + } + /> + ))} + } /> } /> {ENV_LEVEL === ENV_TYPE.LOCAL && } />} diff --git a/src/pages/Farm/ElasticFarmv2/components/FarmCard.tsx b/src/pages/Farm/ElasticFarmv2/components/FarmCard.tsx index 8466f71570..5c32c3ece1 100644 --- a/src/pages/Farm/ElasticFarmv2/components/FarmCard.tsx +++ b/src/pages/Farm/ElasticFarmv2/components/FarmCard.tsx @@ -21,7 +21,7 @@ import HoverInlineText from 'components/HoverInlineText' import { TwoWayArrow } from 'components/Icons' import Harvest from 'components/Icons/Harvest' import { RowBetween, RowFit } from 'components/Row' -import Tabs from 'components/Tabs' +import Tabs from 'components/Tabs/FarmTabs' import { MouseoverTooltip } from 'components/Tooltip' import TransactionConfirmationModal, { TransactionErrorContent } from 'components/TransactionConfirmationModal' import { FeeTag } from 'components/YieldPools/ElasticFarmGroup/styleds' diff --git a/src/pages/Farm/ElasticFarmv2/components/StakeWithNFTsModal.tsx b/src/pages/Farm/ElasticFarmv2/components/StakeWithNFTsModal.tsx index c6a0e0be58..3f2f18545d 100644 --- a/src/pages/Farm/ElasticFarmv2/components/StakeWithNFTsModal.tsx +++ b/src/pages/Farm/ElasticFarmv2/components/StakeWithNFTsModal.tsx @@ -19,7 +19,7 @@ import LocalLoader from 'components/LocalLoader' import Modal from 'components/Modal' import PriceVisualize from 'components/ProAmm/PriceVisualize' import Row, { RowBetween, RowFit } from 'components/Row' -import Tabs from 'components/Tabs' +import Tabs from 'components/Tabs/FarmTabs' import TransactionConfirmationModal, { TransactionErrorContent } from 'components/TransactionConfirmationModal' import { APP_PATHS } from 'constants/index' import { useActiveWeb3React } from 'hooks' diff --git a/src/pages/Farm/ElasticFarmv2/components/UpdateLiquidityModal.tsx b/src/pages/Farm/ElasticFarmv2/components/UpdateLiquidityModal.tsx index 0e545bfc77..c26cf7f05b 100644 --- a/src/pages/Farm/ElasticFarmv2/components/UpdateLiquidityModal.tsx +++ b/src/pages/Farm/ElasticFarmv2/components/UpdateLiquidityModal.tsx @@ -13,7 +13,7 @@ import DoubleCurrencyLogo from 'components/DoubleLogo' import Modal from 'components/Modal' import PriceVisualize from 'components/ProAmm/PriceVisualize' import { RowBetween, RowFit } from 'components/Row' -import Tabs from 'components/Tabs' +import Tabs from 'components/Tabs/FarmTabs' import { MouseoverTooltip } from 'components/Tooltip' import TransactionConfirmationModal, { TransactionErrorContent } from 'components/TransactionConfirmationModal' import useIsTickAtLimit from 'hooks/useIsTickAtLimit' diff --git a/src/pages/MyEarnings/EarningsBreakdownPanel.tsx b/src/pages/MyEarnings/EarningsBreakdownPanel.tsx index eafe9bf4a0..98a3b61d3a 100644 --- a/src/pages/MyEarnings/EarningsBreakdownPanel.tsx +++ b/src/pages/MyEarnings/EarningsBreakdownPanel.tsx @@ -1,17 +1,17 @@ import { ChainId } from '@kyberswap/ks-sdk-core' -import { Trans } from '@lingui/macro' -import { useEffect, useMemo, useState } from 'react' -import { Flex, Text } from 'rebass' -import styled from 'styled-components' +import { t } from '@lingui/macro' +import { ReactNode, memo, useEffect, useMemo, useState } from 'react' +import { Text } from 'rebass' +import styled, { CSSProperties } from 'styled-components' -import EarningPieChart from 'components/EarningPieChart' +import EarningPieChart, { DataEntry } from 'components/EarningPieChart' import { fetchListTokenByAddresses } from 'hooks/Tokens' import useTheme from 'hooks/useTheme' import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo' import { EarningsBreakdown } from 'types/myEarnings' import { formatDisplayNumber } from 'utils/numbers' -type WrapperProps = { $columns: 1 | 2 } +type WrapperProps = { $columns: number; $border?: boolean } const Wrapper = styled.div.attrs(({ $columns }) => ({ 'data-columns': $columns, }))` @@ -23,7 +23,7 @@ const Wrapper = styled.div.attrs(({ $columns }) => ({ padding: 16px; border-radius: 24px; background-color: ${({ theme }) => theme.buttonBlack}; - border: 1px solid ${({ theme }) => theme.border}; + border: 1px solid ${({ theme, $border }) => ($border ? theme.border : 'transparent')}; transition: all 500ms ease, background 0s, border 0s, color 0s; @@ -48,9 +48,63 @@ type Token = { value: string percent: number } -const EarningsBreakdownPanel: React.FC = ({ isLoading, data, className, horizontalLayout }) => { +// todo move to other file +const TokenAllocationChartLocal = ({ + className, + numberOfTokens, + isLoading, + horizontalLayout, + totalUsd, + data, + title, + border = true, + style, + shareMode, +}: { + className?: string + numberOfTokens: number + totalUsd: number + isLoading?: boolean + horizontalLayout?: boolean + data: DataEntry[] + title?: ReactNode + border?: boolean + style?: CSSProperties + shareMode?: boolean +}) => { const theme = useTheme() + const totalColumn = horizontalLayout && numberOfTokens <= 6 ? 1 : numberOfTokens >= 4 ? 2 : 1 + return ( + + {title && ( + + {title} + + )} + {isLoading || !data ? ( + + ) : ( + + )} + + ) +} +export const TokenAllocationChart = memo(TokenAllocationChartLocal) +const EarningsBreakdownPanel: React.FC = ({ isLoading, data, className, horizontalLayout }) => { const numberOfTokens = data?.breakdowns.length || 0 const [tokens, setTokens] = useState<{ [chainId: string]: { [address: string]: WrappedTokenInfo } }>({}) @@ -98,53 +152,33 @@ const EarningsBreakdownPanel: React.FC = ({ isLoading, data, className, h getData() }, [missingTokensByChainId]) - return ( - 5 ? 2 : 1}> - - - - Tokens Breakdown - - - + const formatData = useMemo(() => { + return ( + data?.breakdowns.map(item => ({ + ...item, + logoUrl: + item.chainId && item.address && !item.logoUrl ? tokens[item.chainId]?.[item.address]?.logoURI : item.logoUrl, + symbol: + item.chainId && item.address && !item.symbol + ? tokens[item.chainId]?.[item.address]?.symbol || '' + : item.symbol, + })) || [] + ) + }, [data, tokens]) - {isLoading || !data ? ( - - ) : ( - ({ - ...item, - logoUrl: - item.chainId && item.address && !item.logoUrl - ? tokens[item.chainId]?.[item.address]?.logoURI - : item.logoUrl, - symbol: - item.chainId && item.address && !item.symbol - ? tokens[item.chainId]?.[item.address]?.symbol || '' - : item.symbol, - }))} - totalValue={formatDisplayNumber(data.totalValue, { style: 'currency', significantDigits: 3 })} - /> - )} - + return ( + ) } diff --git a/src/pages/NotificationCenter/CreateAlert/CreateAlertForm.tsx b/src/pages/NotificationCenter/CreateAlert/CreateAlertForm.tsx index 6e34f6fffe..67ee40650d 100644 --- a/src/pages/NotificationCenter/CreateAlert/CreateAlertForm.tsx +++ b/src/pages/NotificationCenter/CreateAlert/CreateAlertForm.tsx @@ -17,8 +17,10 @@ import RefreshButton from 'components/SwapForm/RefreshButton' import { MouseoverTooltip } from 'components/Tooltip' import TradePrice from 'components/swapv2/TradePrice' import { PRICE_ALERT_TOPIC_ID } from 'constants/env' +import { MAINNET_NETWORKS } from 'constants/networks' import { useActiveWeb3React } from 'hooks' import { useBaseTradeInfoWithAggregator } from 'hooks/useBaseTradeInfo' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' import useMixpanel, { MIXPANEL_TYPE } from 'hooks/useMixpanel' import useNotification from 'hooks/useNotification' import useParsedQueryString from 'hooks/useParsedQueryString' @@ -41,7 +43,6 @@ import { ConfirmAlertModalData, CreatePriceAlertPayload, DEFAULT_ALERT_COOLDOWN, - NETWORK_OPTIONS, PriceAlertStat, PriceAlertType, TYPE_OPTIONS, @@ -182,6 +183,13 @@ export default function CreateAlert({ const navigate = useNavigate() + const networkOptions = useMemo(() => { + return MAINNET_NETWORKS.map(id => ({ + value: id, + label: NETWORKS_INFO[id].name, + })) + }, []) + return ( <>
@@ -196,7 +204,7 @@ export default function CreateAlert({ { setSelectedChain(chain) navigate({ search: '' }, { replace: true }) diff --git a/src/pages/NotificationCenter/Menu/MenuItem.tsx b/src/pages/NotificationCenter/Menu/MenuItem.tsx index 3dad2a3ec7..9572a84d50 100644 --- a/src/pages/NotificationCenter/Menu/MenuItem.tsx +++ b/src/pages/NotificationCenter/Menu/MenuItem.tsx @@ -41,6 +41,9 @@ const StyledLink = styled(Link)<{ $isChildren?: boolean; $expand?: boolean; $isF $isChildren || $isFirstParent ? 'none' : `1px solid ${theme.border}`}; border-bottom: ${({ theme, $isChildren, $expand }) => $isChildren || !$expand ? 'none' : `1px solid ${theme.border}`}; + &:focus-visible { + outline-width: 0; + } ` type WrapperProps = { @@ -91,7 +94,7 @@ const MenuItem: React.FC = ({ data, style, unread, isChildren, onChildren const { mixpanelHandler } = useMixpanel() const onClickMenu = (e: React.MouseEvent) => { e.stopPropagation() - isChildren && onChildrenClick?.() + if (isChildren || (!isChildren && childs?.length === 0)) onChildrenClick?.() if (onClick) { onClick() return diff --git a/src/pages/NotificationCenter/Menu/index.tsx b/src/pages/NotificationCenter/Menu/index.tsx index 343862659d..c8c0dca15b 100644 --- a/src/pages/NotificationCenter/Menu/index.tsx +++ b/src/pages/NotificationCenter/Menu/index.tsx @@ -41,7 +41,7 @@ export type MenuItemType = { defaultExpand?: boolean } -const getMenuItems: () => MenuItemType[] = () => +const getMenuItems = (): MenuItemType[] => [ { route: PROFILE_MANAGE_ROUTES.PROFILE, @@ -103,6 +103,12 @@ const getMenuItems: () => MenuItemType[] = () => }, ], }, + { + route: PROFILE_MANAGE_ROUTES.PORTFOLIO, + icon: , + title: t`Portfolio`, + childs: [], + }, ].map(el => { return { ...el, @@ -127,10 +133,8 @@ const MenuForDesktop = ({ unread, onChildrenClick, toggleImportProfile }: PropsM const { profile } = useProfileInfo() const upToMedium = useMedia(`(max-width: ${MEDIA_WIDTHS.upToMedium}px)`) - const menuItems = useMemo(() => getMenuItems(), []) - const menuItemDeskTop = useMemo(() => { - return menuItems.map(el => { + return getMenuItems().map(el => { if (el.route !== PROFILE_MANAGE_ROUTES.PROFILE) return el const guestText = isSignInGuestDefault ? t`Guest` : t`Imported Guest` const childs: MenuItemType[] = [ @@ -154,7 +158,7 @@ const MenuForDesktop = ({ unread, onChildrenClick, toggleImportProfile }: PropsM }) return { ...el, childs } }) - }, [signedAccount, isSigInGuest, profile, toggleImportProfile, isSignInGuestDefault, menuItems]) + }, [signedAccount, isSigInGuest, profile, toggleImportProfile, isSignInGuestDefault]) return ( diff --git a/src/pages/NotificationCenter/Portfolio/Modals/AddWalletPortfolioModal.tsx b/src/pages/NotificationCenter/Portfolio/Modals/AddWalletPortfolioModal.tsx new file mode 100644 index 0000000000..f6ec7b6ecd --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/Modals/AddWalletPortfolioModal.tsx @@ -0,0 +1,123 @@ +import { Trans, t } from '@lingui/macro' +import { useEffect, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { Flex, Text } from 'rebass' + +import { ButtonOutlined, ButtonPrimary } from 'components/Button' +import Column from 'components/Column' +import Dots from 'components/Dots' +import Input from 'components/Input' +import ModalTemplate from 'components/Modal/ModalTemplate' +import useTheme from 'hooks/useTheme' +import { PortfolioWallet } from 'pages/NotificationCenter/Portfolio/type' + +const AddWalletPortfolioModal = ({ + isOpen, + onDismiss, + wallet, + onConfirm, + defaultWallet, +}: { + defaultWallet?: string + isOpen: boolean + onDismiss: () => void + wallet?: PortfolioWallet + onConfirm: (data: { walletAddress: string; nickName: string; walletId?: number }) => Promise +}) => { + const [name, setName] = useState('') + const [walletAddress, setWalletAddress] = useState('') + + useEffect(() => { + setWalletAddress(defaultWallet || '') + }, [defaultWallet, isOpen]) + + useEffect(() => { + if (!wallet) return + setName(wallet.nickName) + setWalletAddress(wallet.walletAddress) + }, [wallet]) + + const isEdit = !!wallet + + const theme = useTheme() + + const handleDismiss = () => { + onDismiss() + setName('') + setWalletAddress('') + } + + const renderContent = () => { + return ( + <> + + + Enter your wallet address + + setWalletAddress(e.target.value)} + placeholder={t`Wallet address`} + /> + + + + Wallet name (Optional) + + setName(e.target.value)} + placeholder={t`Wallet name`} + maxLength={50} + /> + + + ) + } + + const [loading, setLoading] = useState(false) + const onCreate = async () => { + if (loading || !walletAddress) return + setLoading(true) + await onConfirm({ nickName: name, walletAddress, walletId: wallet?.id }) + handleDismiss() + setLoading(false) + } + + return ( + + {renderContent()} + + + Cancel + + + + {loading ? ( + + Saving + + ) : isEdit ? ( + Save + ) : ( + Add Wallet + )} + + + + ) +} +export default AddWalletPortfolioModal diff --git a/src/pages/NotificationCenter/Portfolio/Modals/CreatePortfolioModal.tsx b/src/pages/NotificationCenter/Portfolio/Modals/CreatePortfolioModal.tsx new file mode 100644 index 0000000000..7ca0074156 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/Modals/CreatePortfolioModal.tsx @@ -0,0 +1,102 @@ +import { Trans, t } from '@lingui/macro' +import { useEffect, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { Flex, Text } from 'rebass' + +import { ButtonOutlined, ButtonPrimary } from 'components/Button' +import Column from 'components/Column' +import Dots from 'components/Dots' +import Input from 'components/Input' +import ModalTemplate from 'components/Modal/ModalTemplate' +import useTheme from 'hooks/useTheme' +import { Portfolio } from 'pages/NotificationCenter/Portfolio/type' + +const CreatePortfolioModal = ({ + isOpen, + onDismiss, + portfolio, + onConfirm, + defaultName, +}: { + isOpen: boolean + onDismiss: () => void + onConfirm: (data: { name: string }) => Promise + portfolio?: Portfolio + defaultName?: string +}) => { + const [name, setName] = useState('') + const isEdit = !!portfolio + + const theme = useTheme() + + useEffect(() => { + setName(defaultName || '') + }, [defaultName, isOpen]) + + const handleDismiss = () => { + onDismiss() + setName('') + } + + const renderContent = () => { + return ( + + + Enter your Portfolio name + + setName(e.target.value)} + placeholder={isEdit ? t`Edit Portfolio Name` : t`Portfolio Name`} + /> + + ) + } + + const [loading, setLoading] = useState(false) + const onCreate = async () => { + if (loading || !name) return + setLoading(true) + await onConfirm({ name }) + handleDismiss() + setLoading(false) + } + + return ( + + {renderContent()} + + + + Cancel + + + + {loading ? ( + + Saving + + ) : isEdit ? ( + Save + ) : ( + Create Portfolio + )} + + + + ) +} + +export default CreatePortfolioModal diff --git a/src/pages/NotificationCenter/Portfolio/Modals/Disclaimer.tsx b/src/pages/NotificationCenter/Portfolio/Modals/Disclaimer.tsx new file mode 100644 index 0000000000..5dd8ca0347 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/Modals/Disclaimer.tsx @@ -0,0 +1,83 @@ +import { Trans, t } from '@lingui/macro' +import { rgba } from 'polished' +import { useCallback, useState } from 'react' +import { Text } from 'rebass' +import styled from 'styled-components' + +import { ButtonPrimary } from 'components/Button' +import CheckBox from 'components/CheckBox' +import ModalTemplate from 'components/Modal/ModalTemplate' + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 20px; +` +const TextWrapper = styled.div` + line-height: 20px; + font-size: 14px; + color: ${({ theme }) => theme.subText}; +` + +const Disclaimer = styled.div` + padding: 12px; + + display: flex; + align-items: center; + gap: 8px; + + font-size: 10px; + line-height: 14px; + font-weight: 400; + + border-radius: 16px; + background-color: ${({ theme }) => rgba(theme.subText, 0.2)}; + color: ${({ theme }) => theme.text}; + + cursor: pointer; + transition: background-color 100ms linear; +` + +export default function DisclaimerPortfolio({ onConfirm }: { onConfirm: () => void }) { + const [accepted, setAccepted] = useState(false) + + const handleClickDisclaimer = useCallback(() => { + const newValue = !accepted + setAccepted(newValue) + }, [accepted]) + + return ( + + + + + My Portfolio enables users to manage decentralized assets across all KyberSwap supported chains. + + + + Note that asset values can experience significant fluctuations due to market conditions. + + + + + KyberSwap strives to provide accurate and timely data, it's essential for users to conduct their own + research and due diligence before making investment decisions based on the provided information. + + + + + + + + By using our platform, you accept these risks and are solely responsible for any investment decisions. + + + + + + Agree + + + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/AddressPanel.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/AddressPanel.tsx new file mode 100644 index 0000000000..63b511c7ea --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/AddressPanel.tsx @@ -0,0 +1,387 @@ +import { Trans, t } from '@lingui/macro' +import { useCallback, useMemo, useState } from 'react' +import { Eye, EyeOff, Plus, Share2 } from 'react-feather' +import { useLocation, useNavigate } from 'react-router-dom' +import { useMedia } from 'react-use' +import { Flex, Text } from 'rebass' +import styled, { css } from 'styled-components' + +import DefaultAvatar from 'assets/images/default_avatar.png' +import { NotificationType } from 'components/Announcement/type' +import { DropdownArrowIcon } from 'components/ArrowRotate' +import Avatar from 'components/Avatar' +import { ButtonAction, ButtonPrimary } from 'components/Button' +import Column from 'components/Column' +import { ProfilePanel } from 'components/Header/web3/SignWallet/ProfileContent' +import TransactionSettingsIcon from 'components/Icons/TransactionSettingsIcon' +import MenuFlyout from 'components/MenuFlyout' +import Row, { RowBetween, RowFit } from 'components/Row' +import Select, { SelectOption } from 'components/Select' +import { MouseoverTooltip } from 'components/Tooltip' +import { APP_PATHS } from 'constants/index' +import { useActiveWeb3React } from 'hooks' +import useTheme from 'hooks/useTheme' +import AddWalletPortfolioModal from 'pages/NotificationCenter/Portfolio/Modals/AddWalletPortfolioModal' +import { MAXIMUM_PORTFOLIO } from 'pages/NotificationCenter/Portfolio/const' +import { useAddWalletToPortfolio, useParseWalletPortfolioParam } from 'pages/NotificationCenter/Portfolio/helpers' +import { + Portfolio, + PortfolioWallet, + PortfolioWalletBalanceResponse, + PortfolioWalletPayload, +} from 'pages/NotificationCenter/Portfolio/type' +import { PROFILE_MANAGE_ROUTES } from 'pages/NotificationCenter/const' +import { useNotify } from 'state/application/hooks' +import { MEDIA_WIDTHS } from 'theme' +import getShortenAddress from 'utils/getShortenAddress' +import { formatDisplayNumber } from 'utils/numbers' +import { shortString } from 'utils/string' +import { formatTime } from 'utils/time' + +const BalanceGroup = styled.div` + display: flex; + gap: 16px; + align-items: center; +` + +const browserCustomStyle = css` + padding: 0; + border-radius: 20px; + top: 120px; + right: unset; + ${({ theme }) => theme.mediaWidth.upToLarge` + top: unset; + bottom: 3.5rem; + `}; +` + +const ActionGroups = styled(Row)` + gap: 12px; + width: fit-content; + ${({ theme }) => theme.mediaWidth.upToSmall` + width: 100%; + `}; +` + +const ButtonCreatePortfolio = ({ portfolioOptions }: { portfolioOptions: PortfolioOption[] }) => { + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + const { wallet, portfolioId } = useParseWalletPortfolioParam() + const theme = useTheme() + const { account } = useActiveWeb3React() + const navigate = useNavigate() + const [walletInfo, setWalletInfo] = useState<{ walletAddress: string; portfolioId: string }>({ + walletAddress: '', + portfolioId: '', + }) + const onDismiss = () => { + setWalletInfo({ walletAddress: '', portfolioId: '' }) + } + const _onAddWallet = useAddWalletToPortfolio() + const onAddWallet = (data: PortfolioWalletPayload) => _onAddWallet({ ...data, portfolioId: walletInfo.portfolioId }) + + const isMaximum = portfolioOptions.length >= MAXIMUM_PORTFOLIO && !wallet + + const addWalletOptions: SelectOption[] = useMemo(() => { + const opts = portfolioOptions.map(({ portfolio, totalUsd }) => ({ + label: shortString(portfolio.name, 30), + onSelect: () => { + setWalletInfo({ walletAddress: wallet, portfolioId: portfolio.id }) + }, + subLabel: formatDisplayNumber(totalUsd, { style: 'currency', fractionDigits: 2 }), + })) + if (opts.length < MAXIMUM_PORTFOLIO) { + opts.push({ + label: t`A new portfolio`, + onSelect: () => { + navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PORTFOLIO}?wallet=${wallet}`) + }, + subLabel: '', + }) + } + return opts + }, [portfolioOptions, wallet, navigate]) + + if (!account || portfolioOptions.some(({ portfolio }) => portfolio.id === portfolioId) || isMaximum) + return ( + + You can only create up to 2 portfolios. Manage your portfolios{' '} + navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PORTFOLIO}`)} + color={theme.primary} + sx={{ cursor: 'pointer' }} + > + here + + + ) : ( + '' + ) + } + placement="top" + > + navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PORTFOLIO}?autoShowCreate=1`)} + > + +   + Create Portfolio + + + ) + + const addPortfolioOptions = [ + { + label: t`Replicate this portfolio`, + onSelect: () => navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PORTFOLIO}?cloneId=${portfolioId}`), + }, + { + label: t`Create a blank portfolio`, + onSelect: () => navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PORTFOLIO}`), + }, + ] + + const props = { + arrowColor: theme.textReverse, + style: { + background: theme.primary, + borderRadius: 999, + height: 36, + fontWeight: '500', + fontSize: 14, + flex: upToSmall ? 1 : undefined, + }, + } + + if (wallet) { + return ( + <> + ( + + +   + Create Portfolio + + )} + /> + ) +} + +export type PortfolioOption = { portfolio: Portfolio; totalUsd: number; active: boolean } +const AddressPanel = ({ + activePortfolio, + data, + isLoading, + onShare, + portfolioOptions, +}: { + isLoading: boolean + wallets: PortfolioWallet[] + activePortfolio: Portfolio | undefined + onChangeWallet: (v: string) => void + data: PortfolioWalletBalanceResponse | undefined + onShare: () => void + portfolioOptions: PortfolioOption[] +}) => { + const theme = useTheme() + const [showBalance, setShowBalance] = useState(true) + + const navigate = useNavigate() + const [isOpen, setIsOpen] = useState(false) + const { pathname } = useLocation() + const isMyPortfolioPage = pathname.startsWith(APP_PATHS.MY_PORTFOLIO) + const { wallet } = useParseWalletPortfolioParam() + const { lastUpdatedAt, totalUsd = 0 } = data || {} + + const accountText = ( + + {isLoading ? '--' : activePortfolio?.name || getShortenAddress(wallet)} + + ) + const upToMedium = useMedia(`(max-width: ${MEDIA_WIDTHS.upToMedium}px)`) + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + + const renderAction = useCallback( + () => ( + { + e?.stopPropagation() + setIsOpen(!isOpen) + navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PORTFOLIO}`) + }} + /> + ), + [isOpen, theme, upToMedium, navigate], + ) + + const notify = useNotify() + const onClickPortfolio = useCallback( + (data: Portfolio) => { + navigate(`${APP_PATHS.MY_PORTFOLIO}/${data.id}`) + setIsOpen(false) + const portfolioName = data.name + notify({ + title: t`Portfolio switched`, + summary: t`Switched successfully to ${portfolioName}`, + type: NotificationType.SUCCESS, + }) + }, + [navigate, notify], + ) + + const formatPortfolio = useMemo(() => { + // todo + return portfolioOptions + .filter(e => !e.active) + .map(({ portfolio, totalUsd }) => ({ + data: { + ...portfolio, + title: portfolio.name, + description: formatDisplayNumber(totalUsd, { style: 'currency', fractionDigits: 2 }), + avatarUrl: '', + }, + // todo raw data field instead ? + renderAction, + onClick: onClickPortfolio, + })) + }, [portfolioOptions, renderAction, onClickPortfolio]) + + const balance = ( + + + {!upToSmall && } + + {showBalance ? formatDisplayNumber(totalUsd, { style: 'currency', fractionDigits: 2 }) : '******'} + + + + ) + + return ( + <> + + {isLoading || !isMyPortfolioPage || formatPortfolio.length === 0 ? ( + accountText + ) : ( + + {accountText} + {formatPortfolio.length > 0 && } + + } + customStyle={browserCustomStyle} + isOpen={isOpen} + toggle={() => setIsOpen(!isOpen)} + > + navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PORTFOLIO}`), + actionLabel: t`Portfolio Settings`, + data: { + title: activePortfolio?.name, + description: formatDisplayNumber(totalUsd, { style: 'currency', fractionDigits: 2 }), + avatarUrl: DefaultAvatar, + }, + }} + /> + + )} + + {upToSmall && balance} + + + Data last refreshed: {lastUpdatedAt ? formatTime(lastUpdatedAt) : '-'} + + + + + {!upToSmall && balance} + + + setShowBalance(!showBalance)} + > + {showBalance ? : } + + + + + + + + + ) +} +export default AddressPanel diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Allowances/index.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Allowances/index.tsx new file mode 100644 index 0000000000..589e2eb826 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Allowances/index.tsx @@ -0,0 +1,244 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import dayjs from 'dayjs' +import { ethers } from 'ethers' +import { rgba } from 'polished' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { Trash } from 'react-feather' +import { usePrevious } from 'react-use' +import { useLazyGetTokenApprovalQuery } from 'services/portfolio' + +import { NotificationType } from 'components/Announcement/type' +import { ButtonAction } from 'components/Button' +import { CheckCircle } from 'components/Icons' +import Loader from 'components/Loader' +import LocalLoader from 'components/LocalLoader' +import Row, { RowFit } from 'components/Row' +import Table, { TableColumn } from 'components/Table' +import { ERC20_ABI } from 'constants/abis/erc20' +import { EMPTY_ARRAY } from 'constants/index' +import { useActiveWeb3React, useWeb3React } from 'hooks' +import useDebounce from 'hooks/useDebounce' +import useShowLoadingAtLeastTime from 'hooks/useShowLoadingAtLeastTime' +import useTheme from 'hooks/useTheme' +import { useChangeNetwork } from 'hooks/web3/useChangeNetwork' +import { TokenCellWithWalletAddress } from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/WalletInfo' +import { PortfolioSection, SearchPortFolio } from 'pages/NotificationCenter/Portfolio/PortfolioDetail/styled' +import { formatAllowance } from 'pages/NotificationCenter/Portfolio/helpers' +import { TokenAllowAnce } from 'pages/NotificationCenter/Portfolio/type' +import { useNotify } from 'state/application/hooks' +import { useHasPendingApproval, useTransactionAdder } from 'state/transactions/hooks' +import { TRANSACTION_TYPE } from 'state/transactions/type' +import { ExternalLink } from 'theme' +import { getEtherscanLink } from 'utils' +import { friendlyError } from 'utils/errorMessage' +import { getSigningContract } from 'utils/getContract' +import getShortenAddress from 'utils/getShortenAddress' + +const SpenderCell = ({ value, item }: { value: string; item: TokenAllowAnce }) => { + return ( + + {item['spenderName'] || getShortenAddress(value ?? '')} + + ) +} + +const ActionButton = ({ + item, + revokeAllowance, + refetch, +}: { + item: TokenAllowAnce + revokeAllowance: (v: TokenAllowAnce) => void + refetch: any // todo +}) => { + const theme = useTheme() + const { amount, ownerAddress, tokenAddress, spenderAddress } = item + const pendingApproval = useHasPendingApproval(tokenAddress, spenderAddress) + const [approveSuccess, setApproveSuccess] = useState(false) + + const prev = usePrevious(pendingApproval) + useEffect(() => { + if (!pendingApproval && prev) { + setApproveSuccess(true) + } + }, [pendingApproval, prev, refetch]) + + const { account } = useActiveWeb3React() + const [loading, setLoading] = useState(false) + const onClick = async () => { + setLoading(true) + await revokeAllowance(item) + setLoading(false) + } + const isLoading = pendingApproval || loading + + const disabled = + approveSuccess || isLoading || !amount || amount === '0' || account?.toLowerCase() !== ownerAddress.toLowerCase() + const color = disabled ? theme.border : theme.red + return ( + + + {isLoading ? : } + + + ) +} + +const getColumns = (revokeAllowance: (v: TokenAllowAnce) => void, refetch: any): TableColumn[] => [ + // todo + { + title: t`Asset`, + dataIndex: 'token', + align: 'left', + render: ({ item }: { item: TokenAllowAnce }) => ( + + ), + sticky: true, + }, + { + title: t`Allowance`, + dataIndex: 'amount', + render: ({ value, item }) => formatAllowance(value, item.decimals), + }, + { + title: t`Authorized Spender`, + dataIndex: 'spenderAddress', + render: SpenderCell, + style: isMobile ? { width: 200 } : undefined, + }, + { + title: t`Last Updated`, + dataIndex: 'lastUpdateTimestamp', + render: ({ value }) => dayjs(+value * 1000).format('DD/MM/YYYY HH:mm:ss'), + }, + { + title: t`Revoke`, + align: 'right', + dataIndex: 'spenderAddress', + render: ({ item }) => , + style: { width: 40 }, + }, +] + +const useFetchAllowance = ({ wallets, chainIds }: { wallets: string[]; chainIds: ChainId[] }) => { + const [data, setData] = useState([]) + const [fetchAllowance, { isFetching }] = useLazyGetTokenApprovalQuery() + const loading = useShowLoadingAtLeastTime(isFetching, 300) + const controller = useRef(new AbortController()) + + const fetchData = useCallback( + async function () { + controller.current.abort() + controller.current = new AbortController() + const signal = controller.current.signal + try { + if (!wallets.length) { + throw new Error('Empty addresses') + } + const resp = await Promise.all(wallets.map(address => fetchAllowance({ chainIds, address }).unwrap())) + if (signal.aborted) return + setData(resp.map(e => e.approvals).flat()) + } catch (error) { + if (signal.aborted) return + setData([]) + } + }, + [chainIds, wallets, fetchAllowance], + ) + + useEffect(() => { + fetchData() + }, [fetchData]) + + return { data, isFetching: loading, refetch: fetchData } +} +export default function Allowances({ walletAddresses, chainIds }: { walletAddresses: string[]; chainIds: ChainId[] }) { + const { data, isFetching, refetch } = useFetchAllowance({ wallets: walletAddresses, chainIds }) + const theme = useTheme() + + const { chainId: currentChain } = useActiveWeb3React() + const { library } = useWeb3React() + const { changeNetwork } = useChangeNetwork() + const notify = useNotify() + const addTransactionWithType = useTransactionAdder() + const revokeAllowance = useCallback( + async (data: TokenAllowAnce) => { + const { chainId } = data + const handleRevoke = async function ({ tokenAddress, spenderAddress, ownerAddress, symbol }: TokenAllowAnce) { + try { + if (!library) return + const tokenContract = getSigningContract(tokenAddress, ERC20_ABI, library, ownerAddress) + const tx = await tokenContract.approve(spenderAddress, ethers.constants.Zero) + addTransactionWithType({ + hash: tx.hash, + type: TRANSACTION_TYPE.APPROVE, + extraInfo: { + tokenSymbol: symbol, + tokenAddress, + contract: spenderAddress, + }, + }) + } catch (error) { + notify({ type: NotificationType.ERROR, title: t`Revoke Allowance Error`, summary: friendlyError(error) }) + console.error('Error revoking allowance:', error) + } + } + if (currentChain !== chainId) return changeNetwork(chainId, () => handleRevoke(data), undefined, true) + return handleRevoke(data) + }, + [changeNetwork, currentChain, library, addTransactionWithType, notify], + ) + + const columns = useMemo(() => { + return getColumns(revokeAllowance, refetch) + }, [revokeAllowance, refetch]) + + const [search, setSearch] = useState('') + const searchDebounce = useDebounce(search, 500) + const formatData = useMemo(() => { + if (!data) return EMPTY_ARRAY + return searchDebounce + ? data.filter( + e => + e.symbol.toLowerCase().includes(searchDebounce.toLowerCase()) || + e.spenderName.toLowerCase().includes(searchDebounce.toLowerCase()) || + e.tokenAddress.toLowerCase() === searchDebounce.toLowerCase() || + e.spenderAddress.toLowerCase() === searchDebounce.toLowerCase(), + ) + : data + }, [data, searchDebounce]) + + return ( + + + Token Allowances + + } + contentStyle={{ padding: 0 }} + actions={ + + } + > + {isFetching ? ( + + ) : ( + + )} + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Header/Search.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Header/Search.tsx new file mode 100644 index 0000000000..593ae40977 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Header/Search.tsx @@ -0,0 +1,243 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import { useState } from 'react' +import { isMacOs, isMobile } from 'react-device-detect' +import { Star } from 'react-feather' +import { useLocalStorage, useMedia } from 'react-use' +import { Text } from 'rebass' +import { + useGetFavoritesPortfoliosQuery, + useGetTrendingPortfoliosQuery, + useSearchPortfolioQuery, + useToggleFavoritePortfolioMutation, +} from 'services/portfolio' +import styled from 'styled-components' + +import { NotificationType } from 'components/Announcement/type' +import Avatar from 'components/Avatar' +import History from 'components/Icons/History' +import Icon from 'components/Icons/Icon' +import Row, { RowFit } from 'components/Row' +import { EMPTY_ARRAY } from 'constants/index' +import useDebounce from 'hooks/useDebounce' +import useShowLoadingAtLeastTime from 'hooks/useShowLoadingAtLeastTime' +import useTheme from 'hooks/useTheme' +import { useNavigateToPortfolioDetail } from 'pages/NotificationCenter/Portfolio/helpers' +import { PortfolioSearchData } from 'pages/NotificationCenter/Portfolio/type' +import { SearchSection, SearchWithDropdown } from 'pages/TrueSightV2/components/SearchWithDropDown' +import { StarWithAnimation } from 'pages/TrueSightV2/components/WatchlistStar' +import { useNotify } from 'state/application/hooks' +import { MEDIA_WIDTHS } from 'theme' +import { isAddress } from 'utils' +import getShortenAddress from 'utils/getShortenAddress' +import { formatDisplayNumber } from 'utils/numbers' +import { shortString } from 'utils/string' + +const ShortCut = styled.span` + background-color: ${({ theme }) => theme.buttonBlack}; + color: ${({ theme }) => theme.subText}; + border-radius: 16px; + padding: 2px 8px; + font-size: 10px; +` + +const columns = [{ align: 'right', label: 'Value', style: { width: '100px', minWidth: 'auto' } }] +const DropdownItem = styled.tr` + padding: 6px; + background-color: ${({ theme }) => theme.tableHeader}; + height: 36px; + font-size: 12px; + font-weight: 400; + :hover { + filter: brightness(1.3); + } +` + +const getPortfolioId = (data: PortfolioSearchData) => data.id || data.name + +const PortfolioItem = ({ + onSelect, + data, + favorites, +}: { + onSelect: (v: PortfolioSearchData) => void + data: PortfolioSearchData + favorites: PortfolioSearchData[] +}) => { + const theme = useTheme() + const navigate = useNavigateToPortfolioDetail() + const displayName = data.name || data.id + const id = getPortfolioId(data) + const [toggleFavorite, { isLoading }] = useToggleFavoritePortfolioMutation() + const isFavorite = favorites.some(e => e.id === id) + + const notify = useNotify() + const onToggleFavorite = async () => { + try { + if (isLoading) return + await toggleFavorite({ value: id, isAdd: !isFavorite }).unwrap() + } catch (error) { + notify({ + type: NotificationType.WARNING, + summary: t`You can only watch up to 3 portfolios.`, + title: t`Save favorites`, + }) + } + } + + return ( + { + navigate({ portfolioId: id, myPortfolio: false }, false, { search: 1 }) + onSelect(data) + }} + > + + + + ) +} + +export default function Search() { + const theme = useTheme() + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + const [search, setSearch] = useState('') + const [expanded, setExpanded] = useState(false) + const [history, setHistory] = useLocalStorage>('portfolios-history', []) + const searchDebounced = useDebounce(search, 500) + + const saveToHistory = (data: PortfolioSearchData) => { + const list = history || [] + if (!list.some(t => getPortfolioId(t) === getPortfolioId(data))) { + setHistory([data, ...list].slice(0, 3)) + } + } + const { data: favorites = EMPTY_ARRAY, isLoading: isLoadingFavorite } = useGetFavoritesPortfoliosQuery(undefined, { + skip: !expanded, + }) + const { data: trending = EMPTY_ARRAY, isFetching: isLoadingTrending } = useGetTrendingPortfoliosQuery(undefined, { + skip: !expanded, + }) + + const { data = EMPTY_ARRAY, isFetching: isLoadingSearch } = useSearchPortfolioQuery( + { value: searchDebounced }, + { + skip: !searchDebounced, + }, + ) + + const searchData = searchDebounced ? data : EMPTY_ARRAY + + const isSearching = useShowLoadingAtLeastTime(isLoadingSearch || isLoadingFavorite || isLoadingTrending, 500) + + const onSelect = (data: PortfolioSearchData) => { + setExpanded(false) + setTimeout(() => { + saveToHistory(data) + }, 300) + } + + const itemTrending = trending.map(e => ( + + )) + const itemSearch = searchData.map(e => ( + + )) + const itemFavorite = favorites.map(e => ( + + )) + const itemHistory = + history?.map(e => ) || + EMPTY_ARRAY + + // todo memo, and kyberAI + const sections: SearchSection[] = searchData?.length + ? [ + { + items: itemSearch, + title: ( + + Search Result + + ), + }, + ] + : [ + { + title: ( + + + + Search History + + + ), + items: itemHistory, + show: !!history?.length, + }, + { + title: ( + + + + Favorites + + + ), + items: itemFavorite, + loading: isLoadingFavorite, + show: !!favorites.length && !isLoadingFavorite, + }, + { + title: ( + + + + Trending + + + ), + items: itemTrending, + loading: isLoadingTrending, + show: !!trending.length && !isLoadingTrending, + }, + ] + return ( + + Oops, we couldnt find your address or portfolio! +
+ You can try searching for another address or portfolio + + } + expanded={expanded} + setExpanded={setExpanded} + placeholder={t`Enter wallet address or portfolio ID`} + sections={sections} + columns={columns} + value={search} + noSearchResult={!!(searchDebounced && !isSearching && !searchData?.length)} + onChange={setSearch} + style={{ maxWidth: upToSmall ? '100%' : undefined }} + searchIcon={upToSmall ? : {isMacOs ? 'Cmd+K' : 'Ctrl+K'}} + /> + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Header/index.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Header/index.tsx new file mode 100644 index 0000000000..0229bfddf6 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Header/index.tsx @@ -0,0 +1,51 @@ +import { Trans } from '@lingui/macro' +import { useLocation, useNavigate } from 'react-router-dom' +import { useMedia } from 'react-use' +import { Flex, Text } from 'rebass' + +import { ReactComponent as PortfolioIcon } from 'assets/svg/portfolio.svg' +import { ButtonOutlined } from 'components/Button' +import TransactionSettingsIcon from 'components/Icons/TransactionSettingsIcon' +import Row, { RowBetween } from 'components/Row' +import { APP_PATHS } from 'constants/index' +import useTheme from 'hooks/useTheme' +import Search from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Header/Search' +import { PROFILE_MANAGE_ROUTES } from 'pages/NotificationCenter/const' +import { MEDIA_WIDTHS } from 'theme' + +export default function Header() { + const theme = useTheme() + const navigate = useNavigate() + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + const { pathname } = useLocation() + + return ( + <> + + + + + {pathname.startsWith(APP_PATHS.MY_PORTFOLIO) ? My Portfolio : Portfolio} + + + + {!upToSmall && } + navigate(`${APP_PATHS.PROFILE_MANAGE}${PROFILE_MANAGE_ROUTES.PORTFOLIO}`)} + > + + {!upToSmall && ( + <> +  Settings + + )} + + + + {upToSmall && } + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Liquidity/PositionDetailsModal.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Liquidity/PositionDetailsModal.tsx new file mode 100644 index 0000000000..4c6c4eb88b --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Liquidity/PositionDetailsModal.tsx @@ -0,0 +1,369 @@ +import { Trans } from '@lingui/macro' +import { useState } from 'react' +import { X } from 'react-feather' +import { Text } from 'rebass' +import styled, { DefaultTheme } from 'styled-components' + +import Badge, { BadgeVariant } from 'components/Badge' +import { ButtonAction } from 'components/Button' +import Column from 'components/Column' +import Divider from 'components/Divider' +import Logo from 'components/Logo' +import Modal from 'components/Modal' +import Row, { RowBetween } from 'components/Row' +import useTheme from 'hooks/useTheme' +import { formatUnitsToFixed } from 'utils/formatBalance' +import { formatDisplayNumber } from 'utils/numbers' + +import { LiquidityData } from '../../type' + +const Wrapper = styled.div` + background-color: ${({ theme }) => theme.background}; + padding: 16px; + border-radius: 16px; + border: 1px solid ${({ theme }) => theme.border}; + width: 100%; +` +const logoStyle = { width: '20px', height: '20px', borderRadius: '4px' } + +const calculateTextProfitColor = (value: number | undefined, theme: DefaultTheme) => { + if (!value) return theme.subText + if (value > 0) return theme.primary + if (value < 0) return theme.red + return theme.subText +} + +export default function PositionDetailsModal({ + isOpen, + position, + onClose, +}: { + isOpen: boolean + position: LiquidityData | null + onClose: () => void +}) { + const [tab, setTab] = useState(0) + const theme = useTheme() + if (!position) return null + + const token0 = position.balance.lpData.lpPoolData.token0 + const token1 = position.balance.lpData.lpPoolData.token1 + const lpPositionData = position.balance.lpData.lpPositionData + const lpUniV2Data = position.balance.lpData.lpUniV2Data + + const currentToken0Amount = lpPositionData?.currentToken0Amount || lpUniV2Data?.currentToken0Amount + const currentToken1Amount = lpPositionData?.currentToken1Amount || lpUniV2Data?.currentToken1Amount + const totalToken0Amount = lpPositionData?.totalToken0Amount || lpUniV2Data?.totalToken0Amount + const totalToken1Amount = lpPositionData?.totalToken1Amount || lpUniV2Data?.totalToken1Amount + const currentToken0Usd = lpPositionData?.currentToken0Value || lpUniV2Data?.currentToken0Usd || 0 + const currentToken1Usd = lpPositionData?.currentToken1Value || lpUniV2Data?.currentToken1Usd || 0 + const totalToken0Usd = lpPositionData?.totalToken0Value || lpUniV2Data?.totalToken0Usd || 0 + const totalToken1Usd = lpPositionData?.totalToken1Value || lpUniV2Data?.totalToken1Usd || 0 + const currentValueRatio = + +currentToken0Usd && +currentToken1Usd ? currentToken0Usd / (+currentToken0Usd + +currentToken1Usd) : 0 + const totalValueRatio = +totalToken0Usd && +totalToken1Usd ? totalToken0Usd / (totalToken0Usd + totalToken1Usd) : 0 + + const claimedFeeToken0Amount = lpUniV2Data?.totalFeeEarned0 || lpPositionData?.totalFeeEarned0 + const claimedFeeToken1Amount = lpUniV2Data?.totalFeeEarned1 || lpPositionData?.totalFeeEarned1 + const claimedFeeToken0Usd = lpUniV2Data?.feeToken0Usd || lpPositionData?.totalFeeEarned0Usd + const claimedFeeToken1Usd = lpUniV2Data?.feeToken1Usd || lpPositionData?.totalFeeEarned1Usd + const fees = position.balance.underlying.filter(item => item.assetType === 'reward') || [] + const farmRewards = position.balance.harvestedReward + + return ( + { + onClose() + setTab(0) + }} + > + + + + + + Position Details + + {lpPositionData && lpPositionData.tokenId} + + { + onClose() + setTab(0) + }} + > + + + + div': { flex: 1 } }} + > + + + Profit & Loss + + + {lpPositionData?.pnl + ? formatDisplayNumber(lpPositionData.pnl, { + style: 'currency', + fractionDigits: 2, + allowDisplayNegative: true, + }) + : '--'} + + + + + Impermanent Loss + + + {lpPositionData?.impermanentLoss + ? formatDisplayNumber(lpPositionData.impermanentLoss, { + style: 'currency', + fractionDigits: 2, + allowDisplayNegative: true, + }) + : '--'} + + + + + Fees Earned + + + {lpPositionData?.totalFeeEarned + ? formatDisplayNumber(lpPositionData.totalFeeEarned, { style: 'currency', fractionDigits: 2 }) + : '--'} + + + + + Farm Rewards + + + {lpPositionData?.totalFarmingReward + ? formatDisplayNumber(lpPositionData.totalFarmingReward, { style: 'currency', fractionDigits: 2 }) + : '--'} + + + + div': { flex: 1 } }} + > +
+ + Token + + + Current Liquidity + + + Provided Liquidity + + + + {token0.symbol} + + + + + + {token1.symbol} + + + +
+
+ + div': { cursor: 'pointer', '&.active': { color: theme.primary } } }} + > + setTab(0)}> + My Fee Earnings + + {farmRewards !== undefined && ( + <> + + setTab(1)}> + My Farm Rewards + + + )} + + {tab === 0 && ( + div': { flex: 1 } }} + > +
+ + Token + + + Unclaimed + + + Claimed + + + + {token0.symbol} + + + + + + {token1.symbol} + + + +
+
+ )} + {tab === 1 && ( + div': { flex: 1 } }} + > +
+ + Token + + + Unclaimed + + + Claimed + + {farmRewards?.map(item => { + return ( + <> + + + {item.token.symbol} + + + + -- + + + + -- + + + + + + {+item.balance ? formatUnitsToFixed(item.balance, item.token.decimals, 8) : '--'} + + + + {+item.quotes.usd.value + ? formatDisplayNumber(item.quotes.usd.value, { style: 'currency', fractionDigits: 2 }) + : '--'} + + + + + ) + })} +
+
+ )} + + Powered by Krystal Liquidity Lens + +
+
+
+ ) +} + +const RenderCurrencyValues = ({ + amount, + tokenDecimals, + usd, + badge, +}: { + amount?: string + tokenDecimals?: number + usd?: number + badge?: string +}) => { + const theme = useTheme() + return ( + + + {amount !== undefined && +amount !== 0 ? formatUnitsToFixed(amount, tokenDecimals, 8) : '--'} + + + + {!!usd ? formatDisplayNumber(usd, { style: 'currency', fractionDigits: 2 }) : '--'} + + {badge && ( + + {badge} + + )} + + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Liquidity/index.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Liquidity/index.tsx new file mode 100644 index 0000000000..8f4a32f768 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Liquidity/index.tsx @@ -0,0 +1,560 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import { formatUnits } from 'ethers/lib/utils' +import { rgba } from 'polished' +import { useMemo, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { useMedia } from 'react-use' +import { Text } from 'rebass' +import { useGetLiquidityPortfolioQuery } from 'services/portfolio' + +import Badge, { BadgeVariant } from 'components/Badge' +import { ButtonAction } from 'components/Button' +import Column from 'components/Column' +import CopyHelper from 'components/Copy' +import Divider from 'components/Divider' +import { DoubleLogoWithChain } from 'components/DoubleLogo' +import { MoneyBag } from 'components/Icons' +import Wallet from 'components/Icons/Wallet' +import LocalLoader from 'components/LocalLoader' +import Logo, { TokenLogoWithChain } from 'components/Logo' +import Row, { RowBetween, RowFit } from 'components/Row' +import SearchInput from 'components/SearchInput' +import Table, { TableColumn } from 'components/Table' +import { useActiveWeb3React } from 'hooks' +import useTheme from 'hooks/useTheme' +import ChevronIcon from 'pages/TrueSightV2/components/ChevronIcon' +import { MEDIA_WIDTHS } from 'theme' +import { formatUnitsToFixed } from 'utils/formatBalance' +import getShortenAddress from 'utils/getShortenAddress' +import { formatDisplayNumber } from 'utils/numbers' + +import { LiquidityData } from '../../type' +import PositionDetailsModal from './PositionDetailsModal' + +export default function Liquidity({ walletAddresses, chainIds }: { chainIds: ChainId[]; walletAddresses: string[] }) { + const theme = useTheme() + const [isOpenDetailModal, setIsOpenDetailModal] = useState(false) + const [selectedPosition, setSelectedPosition] = useState(null) + const { account } = useActiveWeb3React() + //.. 0x3a96325a47e9fae32e72d5bd7401e58c6e5c423b use this address for testing purpose + const { data, isLoading } = useGetLiquidityPortfolioQuery( + { + addresses: walletAddresses, + chainIds: chainIds, + quoteSymbols: 'usd', + orderBy: 'liquidity', + orderASC: false, + positionStatus: 'open', + }, + { skip: !account }, + ) + + const columns: TableColumn[] = [ + { + title: t`Pool | NFT ID`, + render: ({ item }: { item: LiquidityData }) => { + const token0 = item.balance.lpData.lpPoolData.token0 + const token1 = item.balance.lpData.lpPoolData.token1 + const amp = item.balance.lpData.lpPoolData.amp + const fee = item.balance.lpData.lpPoolData.fee + const isFarming = + item.balance.lpData.lpPositionData?.totalFarmingReward && + item.balance.lpData.lpPositionData.totalFarmingReward > 0 + const isClassic = item.balance.project === 'KyberSwap' && item.balance.tokenType === 'ERC20' + const isElastic = item.balance.project === 'KyberSwap' && item.balance.tokenType === 'ERC721' + return ( + + + + + + {token0.symbol} - {token1.symbol} + + + + div': { padding: '0px 4px' } }}> + {isClassic ? Classic : null} + {isElastic ? Elastic : null} + {amp ? AMP {amp} : null} + {fee ? FEE {fee}% : null} + {isFarming ? ( + + + + ) : null} + + + {getShortenAddress(item.balance.userAddress)} + + + + ) + }, + align: 'left', + sticky: true, + style: { width: isMobile ? 140 : undefined, paddingTop: 22, paddingBottom: 22 }, + }, + { + title: t`Pool Tokens`, + render: ({ item }: { item: LiquidityData }) => { + const token0 = item.balance.underlying[0] + const token1 = item.balance.underlying[1] + return ( + + + + + {formatUnitsToFixed(token0.balance, token0.token.decimals, 4)} {token0.token.symbol} + + + + + + {formatUnitsToFixed(token1.balance, token1.token.decimals, 4)} {token1.token.symbol} + + + + ) + }, + style: isMobile ? { width: 140 } : undefined, + align: 'right', + }, + { + title: t`Fees & Rewards`, + align: 'right', + render: ({ item }: { item: LiquidityData }) => { + return ( + + {item.balance.underlying + .filter(item => item.assetType === 'reward') + .map((item, index: number) => { + return ( + + + + {formatUnitsToFixed(item.balance, item.token.decimals, 4)} {item.token.symbol} + + + ) + })} + + ) + }, + style: isMobile ? { width: 140 } : undefined, + }, + { + title: t`Current Value`, + align: 'right', + render: ({ item }: { item: LiquidityData }) => ( + + {formatDisplayNumber( + item.balance.underlying + .filter(item => item.assetType === 'underlying') + .map(item => item.quotes.usd.value) + .reduce((a, b) => a + b), + { style: 'currency', significantDigits: 4 }, + )} + + ), + style: isMobile ? { width: 140 } : undefined, + }, + { + title: t`Profit & Loss`, + align: 'right', + render: ({ item }: { item: LiquidityData }) => { + const pnl = item.balance.lpData.lpUniV2Data?.pnl || item.balance.lpData.lpPositionData?.pnl || 0 + return ( + 0 ? theme.primary : pnl < 0 ? theme.red : theme.text}> + {pnl + ? formatDisplayNumber(pnl, { style: 'currency', fractionDigits: 2, allowDisplayNegative: true }) + : '--'} + + ) + }, + style: isMobile ? { width: 140 } : undefined, + }, + { + title: t`Action`, + align: 'right', + render: ({ item }: { item: LiquidityData }) => { + return ( + + { + setIsOpenDetailModal(true) + setSelectedPosition(item) + }} + style={{ + backgroundColor: rgba(theme.subText, 0.2), + color: theme.subText, + width: '32px', + height: '32px', + display: 'flex', + justifyContent: 'center', + }} + > + + + + + + ) + }, + style: isMobile ? { width: 140 } : undefined, + }, + ] + + const formattedData = useMemo(() => { + if (!data) return [] + const newData: Array<{ chainId: number; protocol: string; protocolLogo: string; data: Array }> = [] + + data.data.forEach((item: LiquidityData) => { + const dataIndex = newData.findIndex(t => t.chainId === item.chainId && t.protocol === item.balance.project) + if (dataIndex < 0) { + newData.push({ + chainId: item.chainId, + protocol: item.balance.project, + protocolLogo: item.balance.projectLogo, + data: [item], + }) + } else { + newData[dataIndex].data.push(item) + } + }) + return newData.sort(a => (a.protocol === 'KyberSwap' ? -1 : 1)) + }, [data]) + + return ( + <> + {isLoading ? ( + + ) : ( + formattedData.map(item => { + return ( + + ) + }) + )} + setIsOpenDetailModal(false)} + /> + + ) +} + +const ProtocolChainWrapper = ({ + protocolLogo, + chainId, + protocolName, + data, + columns, +}: { + protocolLogo: string + chainId: number + protocolName: string + data: Array + columns: TableColumn[] +}) => { + const [isExpanded, setIsExpanded] = useState(true) + const [search, setSearch] = useState('') + const above768 = useMedia(`(min-width: ${MEDIA_WIDTHS.upToSmall}px)`) + + const theme = useTheme() + + const filteredData = useMemo(() => { + if (search === '') return data + + return data.filter(item => { + const searchTerms = [ + item.chainName, + item.balance.project, + item.balance.tokenId, + item.balance.userAddress, + item.balance.web3ProjectAddress, + item.balance.lpData.lpPoolData.token0.address, + item.balance.lpData.lpPoolData.token0.symbol, + item.balance.lpData.lpPoolData.token1.address, + item.balance.lpData.lpPoolData.token1.symbol, + ] + + return searchTerms.some(term => term.toLowerCase().includes(search.toLowerCase())) + }) + }, [data, search]) + + return ( + + + + + {protocolName} + + {above768 && ( + + )} + + setIsExpanded(prev => !prev)}> + + + + + {!above768 && ( + + + + )} + {isExpanded && + (above768 ? ( +
+ + + + + {isMobile + ? isAddress(ChainId.MAINNET, displayName) + ? getShortenAddress(displayName) + : shortString(displayName, 22) + : displayName} + + + + + {formatDisplayNumber(data.totalUsd, { style: 'currency', fractionDigits: 2 })} + +
+ ) : ( + <> + {filteredData.map(item => ( + + ))} + + ))} + + ) +} + +const MobilePositionInfo = ({ position, onClick }: { position: LiquidityData; onClick?: () => void }) => { + const theme = useTheme() + const token0 = position.balance.lpData.lpPoolData.token0 + const token1 = position.balance.lpData.lpPoolData.token1 + const amp = position.balance.lpData.lpPoolData.amp + const fee = position.balance.lpData.lpPoolData.fee + const isFarming = + position.balance.lpData.lpPositionData?.totalFarmingReward && + position.balance.lpData.lpPositionData.totalFarmingReward > 0 + const isClassic = position.balance.project === 'KyberSwap' && position.balance.tokenType === 'ERC20' + const isElastic = position.balance.project === 'KyberSwap' && position.balance.tokenType === 'ERC721' + const pnl = position.balance.lpData.lpUniV2Data?.pnl || position.balance.lpData.lpPositionData?.pnl || 0 + const hasRewards = position.balance.underlying.filter(i => i.assetType === 'reward').length > 0 + return ( + + + + + + {token0.symbol} - {token1.symbol} + + + + + div': { padding: '0px 4px' } }}> + {isClassic ? Classic : null} + {isElastic ? Elastic : null} + {amp ? AMP {amp} : null} + {fee ? FEE {fee}% : null} + {isFarming ? ( + + + + ) : null} + + + + {getShortenAddress(position.balance.userAddress)} + + + + + + + Profit & Loss + + 0 ? theme.primary : pnl < 0 ? theme.red : theme.text}> + {pnl + ? formatDisplayNumber(pnl, { style: 'currency', fractionDigits: 2, allowDisplayNegative: true }) + : '--'} + + + + + Current Value + + + {formatDisplayNumber( + position.balance.underlying + .filter(item => item.assetType === 'underlying') + .map(item => item.quotes.usd.value) + .reduce((a, b) => a + b), + { style: 'currency', significantDigits: 4 }, + )} + + + + +
+ + Pool Tokens + + + Amount + + + Value + + + + + {token0.symbol} + + + + {formatDisplayNumber(formatUnits(position.balance.underlying[0].balance, token0.decimals), { + style: 'decimal', + fractionDigits: 4, + })} + + + {formatDisplayNumber(position.balance.underlying[0].quotes.usd.value, { + style: 'currency', + fractionDigits: 2, + })} + + + + + {token1.symbol} + + + + {formatDisplayNumber(formatUnits(position.balance.underlying[1].balance, token1.decimals), { + style: 'decimal', + fractionDigits: 4, + })} + + + {formatDisplayNumber(position.balance.underlying[1].quotes.usd.value, { + style: 'currency', + fractionDigits: 2, + })} + +
+ {hasRewards && ( + <> + +
+ + Rewards + + + Amount + + + Value + + {position.balance.underlying + .filter(i => i.assetType === 'reward') + .map(item => { + return ( + <> + + + + {item.token.symbol} + + + + {formatDisplayNumber(formatUnits(item.balance, item.token.decimals), { + style: 'decimal', + fractionDigits: 4, + })} + + + {formatDisplayNumber(item.quotes.usd.value, { + style: 'currency', + fractionDigits: 2, + })} + + + ) + })} +
+ + )} + + onClick?.()} + style={{ + backgroundColor: rgba(theme.subText, 0.2), + color: theme.subText, + width: '32px', + height: '32px', + display: 'flex', + justifyContent: 'center', + }} + > + + + + + +
+ ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/ListTab.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/ListTab.tsx new file mode 100644 index 0000000000..5ecaa7acce --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/ListTab.tsx @@ -0,0 +1,90 @@ +import { t } from '@lingui/macro' +import { Fragment, ReactNode, useMemo } from 'react' +import { FileText } from 'react-feather' +import { useMedia } from 'react-use' +import { Flex, Text } from 'rebass' +import styled from 'styled-components' + +import { ReactComponent as LiquidityIcon } from 'assets/svg/liquidity_icon.svg' +import { ReactComponent as NftIcon } from 'assets/svg/nft_icon.svg' +import { ReactComponent as TokensIcon } from 'assets/svg/tokens_icon.svg' +import { CheckCircle } from 'components/Icons' +import Row, { RowFit } from 'components/Row' +import Select from 'components/Select' +import useTheme from 'hooks/useTheme' +import { PortfolioTab } from 'pages/NotificationCenter/Portfolio/type' +import { MEDIA_WIDTHS } from 'theme' + +const Divider = styled.div` + height: 20px; + width: 2px; + background-color: ${({ theme }) => theme.border}; +` +type TabType = { value: PortfolioTab; icon: ReactNode; title: string } +const TabItem = ({ + data: { icon, title }, + active, + onClick, +}: { + active: boolean + onClick: () => void + data: TabType +}) => { + const theme = useTheme() + return ( + + {icon} + + {title} + + + ) +} + +const getOptions = () => [ + { value: PortfolioTab.TOKEN, icon: , title: t`Tokens` }, + { value: PortfolioTab.LIQUIDITY, icon: , title: t`Liquidity` }, + { value: PortfolioTab.NFT, icon: , title: t`NFTs` }, + { value: PortfolioTab.TRANSACTIONS, icon: , title: t`Transactions` }, + { value: PortfolioTab.ALLOWANCES, icon: , title: t`Allowances` }, +] +const selectOptions = getOptions().map(e => ({ + ...e, + label: ( + + {e.icon} {e.title} + + ), +})) + +export default function ListTab({ activeTab, setTab }: { activeTab: PortfolioTab; setTab: (v: PortfolioTab) => void }) { + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + const theme = useTheme() + const options = useMemo(() => getOptions(), []) + + if (upToSmall) + return ( + ( + + + {item?.label} + + )} + /> + )} + + + + {activeTab === PortfolioTab.TOKEN && } + {activeTab === PortfolioTab.LIQUIDITY && } + {activeTab === PortfolioTab.ALLOWANCES && } + {activeTab === PortfolioTab.TRANSACTIONS && ( + + )} + {activeTab === PortfolioTab.NFT && } + setShowShare(false)} + content={shareContents} + shareType={SHARE_TYPE.PORTFOLIO} + imageName={'portfolio.png'} + leftLogo={ + + + {isMyPortfolioPage ? ( + My Portfolio + ) : ( + Portfolio {portfolio?.name || portfolio?.id} + )} + + {currentData && ( + + {formatDisplayNumber(currentData.totalUsd, { style: 'currency', fractionDigits: 2 })} + + )} + + } + kyberswapLogoTitle={'Portfolio'} + /> + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/ActionPopup.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/ActionPopup.tsx new file mode 100644 index 0000000000..082b7378a8 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/ActionPopup.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react' +import { Text } from 'rebass' +import styled from 'styled-components' + +import CheckBox from 'components/CheckBox' +import TransactionSettingsIcon from 'components/Icons/TransactionSettingsIcon' +import Popover from 'components/Popover' +import Row from 'components/Row' +import { useOnClickOutside } from 'hooks/useOnClickOutside' +import useTheme from 'hooks/useTheme' +import { DisplayField } from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/WalletInfo' + +const SettingsWrapper = styled.div` + padding: 16px; + border-radius: 20px; + min-width: 200px; + display: flex; + flex-direction: column; + gap: 16px; + background-color: ${({ theme }) => theme.tableHeader}; +` + +export default function ActionPopup({ + fields, + onChangeDisplayField, +}: { + fields: DisplayField[] + onChangeDisplayField: (fields: DisplayField[]) => void +}) { + const theme = useTheme() + const [localFields, setLocalStateFields] = useState(fields) + + useEffect(() => { + setLocalStateFields(fields) + }, [fields]) + + const [showSettings, setShowSettings] = useState(false) + const ref = useRef(null) + useOnClickOutside(ref, () => { + setShowSettings(false) + onChangeDisplayField(localFields) + }) + + return ( + + {localFields.map(item => ( + + e.stopPropagation()} + onChange={() => + setLocalStateFields(prev => prev.map(el => (el.key === item.key ? { ...el, show: !el.show } : el))) + } + /> + + {item.title} + + + ))} + + } + noArrow={true} + placement="bottom-end" + > + setShowSettings(v => !v)} /> + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/TokenAllocation.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/TokenAllocation.tsx new file mode 100644 index 0000000000..9df2e96862 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/TokenAllocation.tsx @@ -0,0 +1,285 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import { useCallback, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Flex, Text } from 'rebass' +import { useGetChainsAllocationQuery, useGetTokenAllocationQuery } from 'services/portfolio' +import styled, { css } from 'styled-components' + +import { ReactComponent as LiquidityIcon } from 'assets/svg/liquidity_icon.svg' +import { DataEntry } from 'components/EarningPieChart' +import LocalLoader from 'components/LocalLoader' +import { NetworkLogo, TokenLogoWithChain } from 'components/Logo' +import Row from 'components/Row' +import Section from 'components/Section' +import Table, { TableColumn } from 'components/Table' +import { EMPTY_ARRAY } from 'constants/index' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' +import useShowLoadingAtLeastTime from 'hooks/useShowLoadingAtLeastTime' +import useTheme from 'hooks/useTheme' +import { TokenAllocationChart } from 'pages/MyEarnings/EarningsBreakdownPanel' +import useFilterBalances from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/useFilterBalances' +import { SECTION_STYLE } from 'pages/NotificationCenter/Portfolio/PortfolioDetail/styled' +import { PORTFOLIO_POLLING_INTERVAL } from 'pages/NotificationCenter/Portfolio/const' +import { PortfolioChainBalance, PortfolioWalletBalance } from 'pages/NotificationCenter/Portfolio/type' +import { formatDisplayNumber } from 'utils/numbers' +import { isInEnum } from 'utils/string' +import { getProxyTokenLogo } from 'utils/tokenInfo' + +export const LiquidityScore = () => { + const theme = useTheme() + return ( + + + Test Test + + ) +} + +const TokenCell = ({ item, shareMode }: { item: PortfolioWalletBalance; shareMode: boolean }) => { + const theme = useTheme() + return ( + + + + {item.tokenSymbol} + + + ) +} + +const getTokenColumns = (mobile: boolean, shareMode: boolean) => { + const sticky = !shareMode && mobile + const columnsTokens: TableColumn[] = [ + { + title: t`Token`, + dataIndex: 'token', + align: 'left', + render: props => , + sticky, + }, + // { + // title: t`Liquidity Score`, + // tooltip: ( + // + // Liquidity Score of a token refers to how easily that token can be bought or sold in the market without + // significantly impacting its price. Read more here ↗ + // + // ), + // dataIndex: 'test', + // render: LiquidityScore, + // style: isMobile ? { width: 120 } : undefined, + // }, + { + title: t`Balance`, + dataIndex: 'amount', + render: ({ value }) => formatDisplayNumber(value, { style: 'decimal', significantDigits: 6 }), + style: sticky ? { width: 100 } : undefined, + align: 'left', + }, + { + title: t`Value`, + dataIndex: 'valueUsd', + render: ({ value }) => formatDisplayNumber(value, { style: 'currency', fractionDigits: 2 }), + style: sticky ? { width: 100 } : undefined, + align: 'left', + }, + { + title: shareMode ? t`Asset` : t`Asset Ratio`, + align: 'right', + dataIndex: 'percentage', + render: ({ value }) => + value === '0' ? '<0.01%' : formatDisplayNumber(value / 100, { style: 'percent', fractionDigits: 2 }), + style: sticky ? { width: 80 } : undefined, + }, + ] + const fieldShareMode = ['token', 'percentage', 'valueUsd'] + return shareMode ? columnsTokens.filter(el => fieldShareMode.includes(el.dataIndex)) : columnsTokens +} + +const ChainCell = ({ item: { chainId } }: { item: PortfolioChainBalance }) => { + const theme = useTheme() + return ( + + + + {NETWORKS_INFO[chainId].name} + + + ) +} + +const getChainColumns = (mobile: boolean, shareMode: boolean) => { + const sticky = !shareMode && mobile + const columnsChains: TableColumn[] = [ + { title: t`Chain`, dataIndex: 'token', align: 'left', render: ChainCell, sticky }, + { + title: t`Value`, + dataIndex: 'valueUsd', + render: ({ value }) => formatDisplayNumber(value, { style: 'currency', fractionDigits: 2 }), + style: sticky ? { width: 100 } : undefined, + }, + { + title: shareMode ? t`Asset` : t`Asset Ratio`, + align: 'right', + dataIndex: 'percentage', + render: ({ value }) => + value === '0' ? '<0.01%' : formatDisplayNumber(value / 100, { style: 'percent', fractionDigits: 2 }), + style: sticky ? { width: 80 } : undefined, + }, + ] + return columnsChains +} + +const Content = styled(Row)<{ mobile: boolean }>` + gap: 16px; + align-items: flex-start; + ${({ mobile }) => + mobile && + css` + flex-direction: column; + align-items: center; + `} +` + +export enum AllocationTab { + TOKEN = `token`, + CHAIN = `chain`, + // LIQUIDITY_SCORE = `liquidity-score`, +} + +const getMapTitle = () => ({ + [AllocationTab.TOKEN]: t`Token Distribution`, + [AllocationTab.CHAIN]: t`Chain Distribution`, + // [AllocationTab.LIQUIDITY_SCORE]: t`Liquidity Score`, +}) + +const tabs = Object.keys(getMapTitle()).map(key => ({ title: getMapTitle()[key], type: key })) + +export default function TokenAllocation({ + walletAddresses, + chainIds, + shareMode, + mobile, + defaultTab, + totalUsd, + isAllChain, +}: { + walletAddresses: string[] + chainIds: ChainId[] + shareMode?: boolean + mobile?: boolean + defaultTab?: AllocationTab + totalUsd: number + isAllChain: boolean +}) { + const [params, setParams] = useSearchParams() + const type = params.get('type') || AllocationTab.TOKEN + const [tab, setTab] = useState( + defaultTab || (isInEnum(type, AllocationTab) ? type : AllocationTab.TOKEN), + ) + const isTokenTab = tab === AllocationTab.TOKEN + + const onChangeTab = useCallback( + (tab: AllocationTab) => { + setTab(tab) + params.set('type', tab) + setParams(params) + }, + [setParams, params], + ) + + const { + data: dataTokens, + isLoading: isLoadingTokens, + isFetching: isFetchingTokens, + } = useGetTokenAllocationQuery( + { walletAddresses, chainIds }, + { + skip: !walletAddresses.length || !isTokenTab, + refetchOnMountOrArgChange: !shareMode, + pollingInterval: shareMode ? undefined : PORTFOLIO_POLLING_INTERVAL, + }, + ) + const { + data: dataChains, + isLoading: isLoadingChain, + isFetching: isFetchingchains, + } = useGetChainsAllocationQuery( + { walletAddresses, chainIds }, + { + skip: !walletAddresses.length || tab !== AllocationTab.CHAIN, + refetchOnMountOrArgChange: !shareMode, + pollingInterval: shareMode ? undefined : PORTFOLIO_POLLING_INTERVAL, + }, + ) + + const isLoading = useShowLoadingAtLeastTime(isLoadingTokens || isLoadingChain, 400) + const isFetching = useShowLoadingAtLeastTime(isFetchingTokens || isFetchingchains, 400) + + const data = isTokenTab ? dataTokens : dataChains + + const filterBalance = useFilterBalances() + + const { chartData, tableData }: { chartData: DataEntry[]; tableData: DataEntry[] } = useMemo(() => { + return filterBalance(data?.balances || EMPTY_ARRAY) + }, [data, filterBalance]) + + const tableColumns = useMemo(() => { + return isTokenTab ? getTokenColumns(!!mobile, !!shareMode) : getChainColumns(!!mobile, !!shareMode) + }, [isTokenTab, shareMode, mobile]) + + const sectionProps = shareMode + ? { + title: getMapTitle()[tab], + style: mobile + ? { width: '100%', flex: 1, background: 'transparent', border: 'none' } + : { width: '100%', flex: 1 }, + showHeader: !mobile, + } + : { + tabs, + activeTab: tab, + onTabClick: onChangeTab, + style: SECTION_STYLE, + } + return ( + {...sectionProps} contentStyle={mobile ? { padding: 0 } : undefined}> + + + {isLoading ? ( + + ) : ( +
+ )} + + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/WalletInfo.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/WalletInfo.tsx new file mode 100644 index 0000000000..7a36eb9143 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/WalletInfo.tsx @@ -0,0 +1,313 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import { rgba } from 'polished' +import { useCallback, useMemo, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { Flex, Text } from 'rebass' +import { useGetWalletsAllocationQuery } from 'services/portfolio' +import styled, { CSSProperties } from 'styled-components' + +// import { ReactComponent as LiquidityIcon } from 'assets/svg/liquidity_icon.svg' +import { ButtonAction } from 'components/Button' +import Column from 'components/Column' +import Icon from 'components/Icons/Icon' +import Wallet from 'components/Icons/Wallet' +import LocalLoader from 'components/LocalLoader' +import { TokenLogoWithChain } from 'components/Logo' +import Row, { RowFit } from 'components/Row' +import Table, { TableColumn } from 'components/Table' +import { EMPTY_ARRAY } from 'constants/index' +import useDebounce from 'hooks/useDebounce' +import useTheme from 'hooks/useTheme' +import ActionPopup from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/ActionPopup' +// import { LiquidityScore } from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/TokenAllocation' +import useFilterBalances from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/useFilterBalances' +import { PortfolioSection, SearchPortFolio } from 'pages/NotificationCenter/Portfolio/PortfolioDetail/styled' +import { PORTFOLIO_POLLING_INTERVAL } from 'pages/NotificationCenter/Portfolio/const' +import { PortfolioWalletBalance } from 'pages/NotificationCenter/Portfolio/type' +import SmallKyberScoreMeter from 'pages/TrueSightV2/components/SmallKyberScoreMeter' +import { calculateValueToColor } from 'pages/TrueSightV2/utils' +import { ExternalLink } from 'theme' +import { getEtherscanLink } from 'utils' +import getShortenAddress from 'utils/getShortenAddress' +import { formatDisplayNumber } from 'utils/numbers' +import { navigateToSwapPage } from 'utils/redirect' + +const WalletLabelWrapper = styled.div<{ color: string }>` + display: flex; + gap: 2px; + background-color: ${({ color, theme }) => rgba(color === theme.text ? theme.subText : color, 0.2)}; + color: ${({ color }) => color}; + border-radius: 16px; + font-size: 10px; + font-weight: 500; + padding: 2px 4px; + width: fit-content; +` + +export const WalletLabel = ({ + color, + walletAddress, + chainId, + tokenAddress, +}: { + color: string + walletAddress: string + chainId: ChainId + tokenAddress?: string +}) => { + return ( + + + + {getShortenAddress(walletAddress || '')} + + + ) +} + +export const TokenCellWithWalletAddress = ({ + item: { tokenAddress, chainId, logoUrl, walletAddress, symbol }, + walletColor, + style, +}: { + item: { + logoUrl: string + chainId: number + walletAddress: string + tokenAddress?: string + symbol: string + } + walletColor?: string + style?: CSSProperties +}) => { + const theme = useTheme() + return ( + + + + {symbol} + + + + ) +} + +export type DisplayField = { title: string; show: boolean; key: string } +const getDefaultFields = () => [ + { title: t`Price`, show: true, key: 'priceUsd' }, + { title: t`Balance`, show: true, key: 'amount' }, + { title: t`Value USD`, show: true, key: 'valueUsd' }, + { title: t`KyberScore`, show: true, key: 'kyberScore' }, +] + +const ActionTitle = ({ + displayFields, + onChangeDisplayField, +}: { + displayFields: DisplayField[] + onChangeDisplayField: (fields: DisplayField[]) => void +}) => { + return ( + + Action + + ) +} + +const ActionButton = ({ item: { tokenAddress, chainId } }: { item: PortfolioWalletBalance }) => { + const theme = useTheme() + return ( + + {/* alert('in dev')} + > + + */} + { + navigateToSwapPage({ chain: chainId, address: tokenAddress }) + }} + > + + + + ) +} + +const KyberScore = ({ + item: { kyberScore = 0, kyberScoreTag, priceUsd, kyberScoreCreatedAt = 0 }, +}: { + item: PortfolioWalletBalance +}) => { + const theme = useTheme() + // todo + return ( + + + + {kyberScoreTag || 'Not Applicable'} + + + ) +} + +const getColumns = (displayFields: DisplayField[], onChangeDisplayField: (fields: DisplayField[]) => void) => { + return [ + { + title: t`Token`, + align: 'left', + render: ({ + item: { tokenLogo, tokenSymbol, chainId, walletAddress, tokenAddress }, + }: { + item: PortfolioWalletBalance + }) => ( + + ), + sticky: true, + style: isMobile ? { width: 140 } : undefined, + }, + { + title: t`Balance`, + dataIndex: 'amount', + render: ({ value }: { value: string }) => formatDisplayNumber(value, { style: 'decimal', significantDigits: 6 }), + style: isMobile ? { width: 120 } : undefined, + align: 'left', + }, + { + title: t`Price`, + dataIndex: 'priceUsd', + render: ({ value }: { value: string }) => formatDisplayNumber(value, { style: 'currency', significantDigits: 6 }), + align: 'left', + style: isMobile ? { width: 120 } : undefined, + }, + { + title: t`Value`, + dataIndex: 'valueUsd', + render: ({ value }: { value: string }) => formatDisplayNumber(value, { style: 'currency', fractionDigits: 2 }), + style: isMobile ? { width: 120 } : undefined, + align: 'left', + }, + // { + // title: t`Liquidity Score`, + // dataIndex: 'token', + // render: LiquidityScore, + // tooltip: ( + // + // Liquidity Score of a token refers to how easily that token can be bought or sold in the market without + // significantly impacting its price. Read more here ↗ + // + // ), + // style: isMobile ? { width: 120 } : undefined, + // }, + // { + // title: t`24H Volatility Score`, + // dataIndex: 'token', + // render: () => 'test', + // tooltip: ( + // + // Volatility score measures the price volatility of the token. Find out more about the score{' '} + // here ↗ + // + // ), + // style: isMobile ? { width: 120 } : undefined, + // }, + { + title: t`KyberScore`, + dataIndex: 'kyberScore', + render: KyberScore, + tooltip: ( + + KyberScore uses AI to measure the upcoming trend of a token (bullish or bearish) by taking into account + multiple on-chain and off-chain indicators. The score ranges from 0 to 100. The higher the score, the more + bullish the token in the short-term. Read more here ↗ + + ), + style: isMobile ? { width: 120 } : undefined, + }, + { + title: , + align: 'right', + render: ActionButton, + }, + ].filter(el => + displayFields.some(field => (el.dataIndex ? field.key === el.dataIndex && field.show : true)), + ) as TableColumn[] +} + +export default function WalletInfo({ walletAddresses, chainIds }: { walletAddresses: string[]; chainIds: ChainId[] }) { + const theme = useTheme() + const { data, isFetching } = useGetWalletsAllocationQuery( + { walletAddresses, chainIds }, + { skip: !walletAddresses.length, refetchOnMountOrArgChange: true, pollingInterval: PORTFOLIO_POLLING_INTERVAL }, + ) + + const [displayFields, setDisplayFields] = useState(getDefaultFields()) + const onChangeDisplayField = useCallback((fields: DisplayField[]) => { + setDisplayFields(fields) + }, []) + const columns = useMemo(() => { + return getColumns(displayFields, onChangeDisplayField) + }, [displayFields, onChangeDisplayField]) + + const [search, setSearch] = useState('') + const searchDebounce = useDebounce(search, 500) + + const filterBalance = useFilterBalances() + const formatData = useMemo(() => { + if (!data?.balances?.length) return EMPTY_ARRAY + const list = data.balances + return searchDebounce + ? list.filter( + e => + e.tokenSymbol.toLowerCase().includes(searchDebounce.toLowerCase()) || + e.tokenAddress.toLowerCase().includes(searchDebounce.toLowerCase()), + ) + : filterBalance(list).tableData + }, [data, searchDebounce, filterBalance]) + + return ( + + + Wallet + + } + contentStyle={{ padding: 0 }} + actions={ + + } + > + {isFetching ? ( + + ) : ( +
+ )} + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/index.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/index.tsx new file mode 100644 index 0000000000..667ba6b853 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/index.tsx @@ -0,0 +1,23 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' + +import TokenAllocation from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/TokenAllocation' +import WalletInfo from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/WalletInfo' + +const Tokens = ({ + mobile, + ...props +}: { + walletAddresses: string[] + chainIds: ChainId[] + mobile?: boolean + totalUsd: number + isAllChain: boolean +}) => { + return ( + <> + + + + ) +} +export default Tokens diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/useFilterBalances.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/useFilterBalances.tsx new file mode 100644 index 0000000000..e07cfeee9a --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/useFilterBalances.tsx @@ -0,0 +1,62 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { t } from '@lingui/macro' +import { useCallback } from 'react' +import { useLocation } from 'react-router-dom' +import { useGetPortfoliosSettingsQuery } from 'services/portfolio' + +import { DataEntry } from 'components/EarningPieChart' +import { APP_PATHS, EMPTY_ARRAY } from 'constants/index' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' +import { PortfolioChainBalance, PortfolioWalletBalance } from 'pages/NotificationCenter/Portfolio/type' + +const mappingFn = (data: PortfolioWalletBalance | PortfolioChainBalance): DataEntry => { + const chainId = Number(data.chainId) as ChainId + const isChainInfo = !(data as PortfolioWalletBalance).tokenAddress + const { tokenLogo, tokenSymbol } = data as PortfolioWalletBalance + return { + percent: +data.percentage, + symbol: isChainInfo ? NETWORKS_INFO[chainId].name : tokenSymbol, + logoUrl: isChainInfo ? NETWORKS_INFO[chainId].icon : tokenLogo, + chainId: isChainInfo ? undefined : chainId, + } +} + +const useFilterBalances = () => { + const { pathname } = useLocation() + const { data: settings } = useGetPortfoliosSettingsQuery(undefined, { + skip: !pathname.startsWith(APP_PATHS.MY_PORTFOLIO), + }) + + const filterBalance = useCallback( + (balances: T[]) => { + if (!balances?.length) return { chartData: EMPTY_ARRAY, tableData: EMPTY_ARRAY } + + // filter amount > threshold + const bigItems: T[] = [] + const chartData: DataEntry[] = [] + let othersPercent = 0 + + balances.forEach(el => { + const canShow = settings?.isHideDust + ? parseFloat(el.valueUsd) >= Number(settings?.dustThreshold) && +el.percentage !== 0 + : true + if (canShow) bigItems.push(el) + + if (canShow && chartData.length < 5) chartData.push(mappingFn(el)) + else othersPercent += +el.percentage + }) + + if (chartData.length && othersPercent) { + chartData.push({ + symbol: t`Others`, + percent: othersPercent, + }) + } + return { chartData, tableData: settings?.isHideDust ? bigItems : balances } + }, + [settings], + ) + + return filterBalance +} +export default useFilterBalances diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Transactions/index.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Transactions/index.tsx new file mode 100644 index 0000000000..9f1da18744 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/Transactions/index.tsx @@ -0,0 +1,283 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import dayjs from 'dayjs' +import { useEffect, useMemo, useRef, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { ExternalLink as ExternalLinkIcon, FileText } from 'react-feather' +import { Text } from 'rebass' +import { useGetTransactionsQuery } from 'services/portfolio' +import { CSSProperties } from 'styled-components' + +import { ReactComponent as NftIcon } from 'assets/svg/nft_icon.svg' +import Badge, { BadgeVariant } from 'components/Badge' +import Column from 'components/Column' +import LocalLoader from 'components/LocalLoader' +import Logo, { NetworkLogo } from 'components/Logo' +import Row, { RowFit } from 'components/Row' +import Table, { TableColumn } from 'components/Table' +import { MouseoverTooltip } from 'components/Tooltip' +import { getTxsIcon } from 'components/WalletPopup/Transactions/Icon' +import { EMPTY_ARRAY } from 'constants/index' +import { NativeCurrencies } from 'constants/tokens' +import useDebounce from 'hooks/useDebounce' +import useShowLoadingAtLeastTime from 'hooks/useShowLoadingAtLeastTime' +import useTheme from 'hooks/useTheme' +import { WalletLabel } from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Tokens/WalletInfo' +import { PortfolioSection, SearchPortFolio } from 'pages/NotificationCenter/Portfolio/PortfolioDetail/styled' +import { formatAllowance } from 'pages/NotificationCenter/Portfolio/helpers' +import { TransactionHistory } from 'pages/NotificationCenter/Portfolio/type' +import { ExternalLink } from 'theme' +import { getEtherscanLink } from 'utils' +import getShortenAddress from 'utils/getShortenAddress' +import { formatDisplayNumber, uint256ToFraction } from 'utils/numbers' +import { shortString } from 'utils/string' + +export const getTxsAction = ({ + contractInteraction = { methodName: '', contractName: '' }, + tokenTransfers = [], + tokenApproval, + to, +}: TransactionHistory) => { + const { contractName, methodName } = contractInteraction + switch (methodName) { + case 'approve': + return { + type: tokenApproval?.amount === '0' ? `revoke` : `approve`, + contract: tokenApproval?.spenderAddress, + contractName, + } + } + const type = methodName || `contractInteraction` + if (tokenTransfers.length > 1) return { type, contract: to, contractName } + + const firstToken = tokenTransfers?.[0] + if (firstToken) { + const isSend = firstToken.amount.startsWith('-') + return { + type: isSend ? 'send' : 'receive', + contract: firstToken.otherAddress, + contractName: contractName || firstToken.otherName, + prefix: isSend ? t`to` : t`from`, + } + } + return { type, contractName, contract: tokenApproval?.spenderAddress } +} + +const TxsHashCell = ({ item: { txHash, blockTime, chain, walletAddress } }: { item: TransactionHistory }) => { + const theme = useTheme() + return ( + + + + + + {getShortenAddress(txHash)} + {' '} + + + + + {dayjs(blockTime * 1000).format('DD/MM/YYYY HH:mm')} + + + + ) +} + +const GasFeeCell = ({ item: { gasPrice, chain, nativeTokenPrice, gasUsed, gas } }: { item: TransactionHistory }) => { + if (gasPrice === '0') return null + const native = NativeCurrencies[chain?.chainId as ChainId] + const totalGas = uint256ToFraction(gasPrice, native.decimals).multiply(gasUsed || gas) // todo + const usdValue = +totalGas.toSignificant(native.decimals) * nativeTokenPrice + return ( + <> + {totalGas.toSignificant(6)} {native.symbol} ( + {formatDisplayNumber(usdValue, { style: 'currency', fractionDigits: 2 })}) + + ) +} + +const InteractionCell = ({ item }: { item: TransactionHistory }) => { + const { contract = '', contractName, type, prefix } = getTxsAction(item) + const { chain, tag } = item + const theme = useTheme() + return ( + + + {getTxsIcon(type)} + {type} + + + {prefix} {contractName || getShortenAddress(contract)} + + {tag === 'SCAM' && ( + + Spam tx + + )} + + ) +} + +// todo +export const BalanceCell = ({ + item: { tokenTransfers = [], tokenApproval, status }, + inWalletUI, + className, + style, +}: { + item: TransactionHistory + inWalletUI?: boolean + className?: string + style?: CSSProperties +}) => { + const logoStyle = { width: '20px', minWidth: '20px', height: '20px', borderRadius: '4px' } + const theme = useTheme() + const maxSymbolLength = 22 + return ( + + {status === 'failed' ? ( + inWalletUI ? null : ( + + Failed + + ) + ) : tokenApproval ? ( + + {' '} + {formatAllowance(tokenApproval.amount, tokenApproval.token.decimals)} {tokenApproval?.token?.symbol} + + ) : ( + tokenTransfers.map(({ token, amount }, i) => { + const plus = !amount.startsWith('-') + return ( + + {token.nftTokenId ? ( + + ) : ( + + )} + + {plus && '+'} + {formatDisplayNumber(uint256ToFraction(amount, token.decimals), { + style: 'decimal', + fractionDigits: 4, + allowDisplayNegative: true, + })}{' '} + {inWalletUI ? ( + maxSymbolLength ? token.symbol : ''}> + {shortString(token.symbol, maxSymbolLength)} + + ) : ( + token.symbol + )} + + + ) + }) + )} + + ) +} + +const getColumns = (): TableColumn[] => [ + { + title: t`Tx Hash`, + dataIndex: 'txHash', + render: TxsHashCell, + align: 'left', + sticky: true, + style: { width: isMobile ? 140 : undefined, paddingTop: 22, paddingBottom: 22 }, + }, + { + title: t`Interaction`, + render: InteractionCell, + style: isMobile ? { width: 140 } : undefined, + align: 'left', + }, + { + title: t`Result`, + dataIndex: 'amount', + align: 'left', + render: BalanceCell, + style: isMobile ? { width: 140 } : undefined, + }, + { title: t`Txs Fee`, render: GasFeeCell, align: 'right', style: isMobile ? { width: 200 } : undefined }, +] + +const pageSize = isMobile ? 10 : 20 +export default function Transactions({ chainIds, wallet }: { chainIds: ChainId[]; wallet: string }) { + const theme = useTheme() + const [search, setSearch] = useState('') + const searchDebounce = useDebounce(search, 500) + const [endTime, setEndTime] = useState(0) + const lastEndTime = useRef([]) + + const { data, isLoading, isFetching } = useGetTransactionsQuery( + { + limit: pageSize, + endTime, + chainIds, + walletAddress: wallet, + tokenAddress: searchDebounce, // todo symbol+name + }, + { skip: !wallet }, + ) + const loading = useShowLoadingAtLeastTime(isLoading, 300) + const visibleData: TransactionHistory[] = data?.data || EMPTY_ARRAY + + useEffect(() => { + setEndTime(0) + }, [wallet, chainIds, searchDebounce]) + + const onNext = () => { + if (isFetching) return + const lastItemTime = visibleData[visibleData.length - 1]?.blockTime + setEndTime(v => { + if (lastItemTime - 1 !== v) { + lastEndTime.current.push(endTime) + return lastItemTime - 1 + } + return v + }) + } + const onBack = () => { + if (isFetching) return + const time = lastEndTime.current.pop() + time !== undefined && setEndTime(time) + } + + const columns = useMemo(() => getColumns(), []) + + return ( + + + Transactions + + } + contentStyle={{ padding: '0' }} + actions={} + > + {loading ? ( + + ) : ( +
(record.tag === 'SCAM' ? { opacity: 0.4 } : undefined)} + loading={isFetching} + pagination={{ + onNext, + onBack, + disableNext: visibleData.length < pageSize, + disableBack: !lastEndTime.current.length, + }} + /> + )} + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/index.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/index.tsx new file mode 100644 index 0000000000..2ea745e28b --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/index.tsx @@ -0,0 +1,153 @@ +import { Trans, t } from '@lingui/macro' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Text } from 'rebass' +import { useGetMyPortfoliosQuery } from 'services/portfolio' +import styled, { CSSProperties } from 'styled-components' + +import tutorial1 from 'assets/images/truesight-v2/tutorial_1.png' +import tutorial2 from 'assets/images/truesight-v2/tutorial_2.png' +import tutorial3 from 'assets/images/truesight-v2/tutorial_3.png' +import tutorial4 from 'assets/images/truesight-v2/tutorial_4.png' +import tutorial5 from 'assets/images/truesight-v2/tutorial_5.png' +import LocalLoader from 'components/LocalLoader' +import { TutorialKeys } from 'components/Tutorial/TutorialSwap' +import TutorialModal from 'components/TutorialModal' +import { useActiveWeb3React } from 'hooks' +import useShowLoadingAtLeastTime from 'hooks/useShowLoadingAtLeastTime' +import useTheme from 'hooks/useTheme' +import DisclaimerPortfolio from 'pages/NotificationCenter/Portfolio/Modals/Disclaimer' +import Overview from 'pages/NotificationCenter/Portfolio/PortfolioDetail/Overview' +import PortfolioStat from 'pages/NotificationCenter/Portfolio/PortfolioDetail/PortfolioStat' +import { useNavigateToMyFirstPortfolio, useParseWalletPortfolioParam } from 'pages/NotificationCenter/Portfolio/helpers' + +import Header from './Header' + +const PageWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: stretch; + padding: 24px 36px; + gap: 24px; + width: 100%; + max-width: 1464px; + + ${({ theme }) => theme.mediaWidth.upToSmall` + gap: 16px; + padding: 20px 16px; +`}; +` + +const textStyle: CSSProperties = { + height: '168px', +} +// todo update image +const Step1 = () => { + const theme = useTheme() + return ( + + We are thrilled to introduce you to{' '} + + My Portfolio + {' '} + feature, designed to help you track your investments and stay on top of your financial goals. With this tool, you + can easily monitor your portfolio's performance and make informed decisions about your investments. +
To help you get started, we recommend watching our tutorial, which will guide you through the process of + setting up and using our portfolio feature. This tutorial will provide you with all the information you need to + get the most out of this powerful tool. +
+ ) +} + +const Step4 = () => { + return ( + + Here are some useful pointers to optimize your portfolio management: +
    +
  • Use the search bar to search for address you'd like to explore and add to create a portfolio.
  • +
  • Share your portfolio with different visual charts!
  • +
  • Manage your portfolio by keeping track of token prices by setting up price alerts.
  • +
  • Access KyberAI with supported tokens.
  • +
+ If you wish to view this guide again, you can enable it from the settings. Maximize your returns and minimize your + risks with KyberSwap smart portfolio management! +
+ ) +} + +const getListSteps = () => [ + { + text: , + image: tutorial1, + textStyle, + title: t`Welcome to My Portfolio`, + }, + { + text: t`Make sure to connect and sign in with your wallet to get the full experience on Portfolio Management. In this section, we will go through the steps to set up your portfolio. We will cover the basics of creating a portfolio and bundle multiple wallets together`, + image: tutorial2, + textStyle, + title: t`Setting up your Portfolio`, + }, + { + text: t`Click on the dropdown box to switch between portfolios. Share your portfolio with your friends by selecting the Share icon. Choose from different visual charts and send it as a thumbnail`, + image: tutorial3, + textStyle, + title: t`Switch between Portfolios`, + }, + { + text: t`Track and manage all your assets in one place with the Portfolio Management Dashboard on KyberSwap. The Dashboard’s Visual Charts offers a comprehensive overview of your holdings and defi-related activities, along with advanced filter and analytics options for selected wallets and portfolio across various protocols and chains supported by KyberSwap. You can now easily keep track of your assets and stay informed about your portfolio's performance`, + image: tutorial4, + textStyle, + title: t`Explore (Dashboard, Visual Charts, Wallet)`, + }, + { text: , image: tutorial5, textStyle, title: t`Tutorial - Tips` }, +] + +export default function PortfolioDetail() { + const { account } = useActiveWeb3React() + const { wallet, portfolioId } = useParseWalletPortfolioParam() + const showOverview = !wallet && !portfolioId + + const { isLoading: loading, data } = useGetMyPortfoliosQuery() + const isLoading = useShowLoadingAtLeastTime(loading, 300) + const navigate = useNavigateToMyFirstPortfolio() + const navigateToMyPortfolio = useCallback(() => navigate(data, true), [data, navigate]) + + const [showTutorialState, setShowTutorial] = useState(!localStorage.getItem(TutorialKeys.SHOWED_PORTFOLIO_GUIDE)) + const showTutorial = showTutorialState && account + const [showDisclaimer, setShowDisclaimer] = useState(!localStorage.getItem(TutorialKeys.SHOWED_PORTFOLIO_DISCLAIMER)) + + const onDismissTutorial = useCallback(() => { + setShowTutorial(false) + localStorage.setItem(TutorialKeys.SHOWED_PORTFOLIO_GUIDE, '1') + navigateToMyPortfolio() + }, [navigateToMyPortfolio]) + + const onConfirmDisclaimer = () => { + setShowDisclaimer(false) + localStorage.setItem(TutorialKeys.SHOWED_PORTFOLIO_DISCLAIMER, '1') + } + + useEffect(() => { + if (!showDisclaimer && !showTutorial && showOverview) navigateToMyPortfolio() + }, [showDisclaimer, showTutorial, showOverview, navigateToMyPortfolio]) + + const steps = useMemo(() => getListSteps(), []) + + return ( + +
+ {isLoading ? ( + + ) : showOverview ? ( + + ) : ( + + )} + {showDisclaimer ? ( + + ) : ( + + )} + + ) +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioDetail/styled.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/styled.tsx new file mode 100644 index 0000000000..fa972523f1 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioDetail/styled.tsx @@ -0,0 +1,39 @@ +import { isMobile } from 'react-device-detect' +import { useMedia } from 'react-use' +import { CSSProperties } from 'styled-components' + +import SearchInput, { SearchInputProps } from 'components/SearchInput' +import Section, { SectionProps } from 'components/Section' +import useTheme from 'hooks/useTheme' +import { MEDIA_WIDTHS } from 'theme' + +export const SearchPortFolio = (props: SearchInputProps) => { + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + const theme = useTheme() + return ( + + ) +} + +export const SECTION_STYLE: CSSProperties = isMobile + ? { + marginLeft: '-16px', + marginRight: '-16px', + borderLeft: 'none', + borderRight: 'none', + borderRadius: 0, + } + : {} +export function PortfolioSection(props: SectionProps) { + return {...props} style={{ ...props.style, ...SECTION_STYLE }} /> +} diff --git a/src/pages/NotificationCenter/Portfolio/PortfolioItem.tsx b/src/pages/NotificationCenter/Portfolio/PortfolioItem.tsx new file mode 100644 index 0000000000..6f99fc8d84 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/PortfolioItem.tsx @@ -0,0 +1,362 @@ +import { Trans, t } from '@lingui/macro' +import { useEffect, useState } from 'react' +import { isMobile } from 'react-device-detect' +import { Edit2, Plus, Trash } from 'react-feather' +import Skeleton from 'react-loading-skeleton' +import { useSearchParams } from 'react-router-dom' +import { Flex, Text } from 'rebass' +import { + useDeletePortfolioMutation, + useGetWalletsPortfoliosQuery, + useRemoveWalletFromPortfolioMutation, + useUpdatePortfolioMutation, +} from 'services/portfolio' +import styled from 'styled-components' + +import { ReactComponent as PortfolioIcon } from 'assets/svg/portfolio.svg' +import { NotificationType } from 'components/Announcement/type' +import { ButtonAction, ButtonLight, ButtonOutlined } from 'components/Button' +import Column from 'components/Column' +import { useShowConfirm } from 'components/ConfirmModal' +import Row, { RowFit } from 'components/Row' +import { MouseoverTooltip } from 'components/Tooltip' +import useParsedQueryString from 'hooks/useParsedQueryString' +import useTheme from 'hooks/useTheme' +import AddWalletPortfolioModal from 'pages/NotificationCenter/Portfolio/Modals/AddWalletPortfolioModal' +import CreatePortfolioModal from 'pages/NotificationCenter/Portfolio/Modals/CreatePortfolioModal' +import { useAddWalletToPortfolio, useNavigateToPortfolioDetail } from 'pages/NotificationCenter/Portfolio/helpers' +import { Portfolio, PortfolioWallet, PortfolioWalletPayload } from 'pages/NotificationCenter/Portfolio/type' +import { useNotify } from 'state/application/hooks' +import getShortenAddress from 'utils/getShortenAddress' +import { shortString } from 'utils/string' + +const Card = styled.div` + display: flex; + border-radius: 16px; + padding: 16px; + gap: 16px; + background-color: ${({ theme }) => theme.buttonBlack}; + display: flex; + flex-direction: column; + ${({ theme }) => theme.mediaWidth.upToSmall` + .skeleton { + width: 100%; + } + `} +` +const defaultWalletWidth = '210px' +const WalletCard = styled.div` + display: flex; + border-radius: 16px; + padding: 12px; + gap: 6px; + align-items: center; + justify-content: space-between; + background: ${({ theme }) => theme.background}; + display: flex; + font-size: 14px; + flex-basis: ${defaultWalletWidth}; + ${({ theme }) => theme.mediaWidth.upToSmall` + flex-basis: 100%; + `} +` +enum Actions { + Edit, + Delete, + View, +} + +const ActionButtons = ({ onDelete, onEdit }: { onDelete: () => void; onEdit: () => void }) => { + const theme = useTheme() + return ( + + + + + + + + + ) +} + +const WalletItem = ({ + onChangeWalletAction, + data, +}: { + onChangeWalletAction: (v: Actions, wallet: PortfolioWallet) => void + data: PortfolioWallet +}) => { + const { nickName, walletAddress } = data + return ( + + + {nickName ? shortString(nickName, 18) : getShortenAddress(walletAddress)} + + onChangeWalletAction(Actions.Delete, data)} + onEdit={() => onChangeWalletAction(Actions.Edit, data)} + /> + + ) +} + +const TitleGroup = styled(Row)` + gap: 14px; + ${({ theme }) => theme.mediaWidth.upToSmall` + flex-direction: column; + `} +` + +const ActionsGroup = styled(RowFit)` + gap: 14px; + ${({ theme }) => theme.mediaWidth.upToSmall` + justify-content: space-between; + width: 100%; + `} +` + +const PortfolioItem = ({ portfolio }: { portfolio: Portfolio }) => { + const theme = useTheme() + const { name, id }: Portfolio = portfolio + const { data: wallets = [], isFetching: isLoadingWallets } = useGetWalletsPortfoliosQuery({ portfolioId: id }) + const { portfolioId, wallet } = useParsedQueryString<{ portfolioId: string; wallet: string }>() + const [searchParams, setSearchParams] = useSearchParams() + useEffect(() => { + if (portfolioId === id) { + searchParams.delete('portfolioId') + setSearchParams(searchParams) + showModalAddWalletPortfolio() + } + }, [portfolioId, id, searchParams, setSearchParams]) + + const maximumWallet = 4 + + const [editWallet, setEditWallet] = useState() + const [showModalWallet, setShowModalWallet] = useState(false) + const [showEditPortfolio, setShowEditPortfolio] = useState(false) + + const showModalAddWalletPortfolio = (wallet?: PortfolioWallet) => { + setEditWallet(wallet) + setShowModalWallet(true) + } + const hideAddWalletModal = () => { + setShowModalWallet(false) + setEditWallet(undefined) + } + + const [deletePortfolio] = useDeletePortfolioMutation() + const [removeWallet] = useRemoveWalletFromPortfolioMutation() + const [updatePortfolio] = useUpdatePortfolioMutation() + const showConfirm = useShowConfirm() + const notify = useNotify() + + const onUpdatePortfolio = async ({ name }: { name: string }) => { + try { + await updatePortfolio({ name, id }).unwrap() + notify({ + type: NotificationType.SUCCESS, + title: t`Portfolio updated`, + summary: t`Your portfolio have been successfully updated`, + }) + } catch (error) { + notify({ + type: NotificationType.ERROR, + title: t`Portfolio update failed`, + summary: t`Failed to update your portfolio, please try again.`, + }) + } + } + + const onDeletePortfolio = async () => { + try { + await deletePortfolio(id).unwrap() + notify({ + type: NotificationType.SUCCESS, + title: t`Portfolio deleted`, + summary: t`Your portfolio have been successfully deleted`, + }) + } catch (error) { + notify({ + type: NotificationType.ERROR, + title: t`Portfolio failed`, + summary: t`Failed to delete your portfolio, please try again.`, + }) + } + } + + const onDeleteWalletPortfolio = async (data: { walletAddress: string; portfolioId: string }) => { + try { + await removeWallet(data).unwrap() + notify({ + type: NotificationType.SUCCESS, + title: t`Portfolio updated`, + summary: t`Your portfolio has been successfully updated`, + }) + } catch (error) { + notify({ + type: NotificationType.ERROR, + title: t`Portfolio update failed`, + summary: t`Failed to update your portfolio, please try again.`, + }) + } + } + + const _onAddUpdateWallet = useAddWalletToPortfolio() + const onAddUpdateWallet = async (data: PortfolioWalletPayload) => { + await _onAddUpdateWallet({ ...data, portfolioId: id }) + searchParams.delete('wallet') + setSearchParams(searchParams) + } + + const navigate = useNavigateToPortfolioDetail() + const onChangePortfolioAction = (val: Actions) => { + switch (val) { + case Actions.Delete: + showConfirm({ + isOpen: true, + title: t`Delete Portfolio`, + confirmText: t`Delete`, + cancelText: t`Cancel`, + content: t`Do you want to delete portfolio "${name}"?`, + onConfirm: onDeletePortfolio, + }) + break + case Actions.Edit: + setShowEditPortfolio(true) + break + case Actions.View: + navigate({ portfolioId: id }) + break + } + } + + const onChangeWalletAction = (val: Actions, wallet: PortfolioWallet) => { + switch (val) { + case Actions.Delete: + const { walletAddress } = wallet + const shortWallet = getShortenAddress(walletAddress) + showConfirm({ + isOpen: true, + title: t`Delete Wallet`, + confirmText: t`Delete`, + cancelText: t`Cancel`, + content: t`Do you want to delete wallet "${shortWallet}" from portfolio "${name}"?`, + onConfirm: () => onDeleteWalletPortfolio({ walletAddress, portfolioId: id }), + }) + break + case Actions.Edit: + showModalAddWalletPortfolio(wallet) + break + } + } + + const canAddWallet = wallets.length < maximumWallet + return ( + + + + {name} + onChangePortfolioAction(Actions.Delete)} + onEdit={() => onChangePortfolioAction(Actions.Edit)} + /> + + + + showModalAddWalletPortfolio() : undefined} + > + +  Add Wallet + + + navigate({ portfolioId: id })} + > + +  Dashboard + + + + + + + + Wallet Count:{' '} + + {wallets.length}/{maximumWallet} + + + + + + {isLoadingWallets ? ( + new Array(isMobile ? 1 : 3) + .fill(0) + .map((_, i) => ( + + )) + ) : wallets.length ? ( + wallets.map(wallet => ( + + )) + ) : ( + + You haven't added any wallets to your portfolio yet + + )} + + + + + setShowEditPortfolio(false)} + portfolio={portfolio} + defaultName={portfolio.name} + onConfirm={onUpdatePortfolio} + /> + + ) +} + +export default PortfolioItem diff --git a/src/pages/NotificationCenter/Portfolio/buttons.tsx b/src/pages/NotificationCenter/Portfolio/buttons.tsx new file mode 100644 index 0000000000..6a38d03eb3 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/buttons.tsx @@ -0,0 +1,23 @@ +import styled, { css } from 'styled-components' + +import { ButtonOutlined, ButtonPrimary } from 'components/Button' + +const shareButtonStyle = css` + width: 120px; + height: 36px; + font-size: 14px; + ${({ theme }) => theme.mediaWidth.upToSmall` + max-width: 45%; + width: 164px; + `} +` +export const ButtonCancel = styled(ButtonOutlined)` + white-space: nowrap; + ${shareButtonStyle} +` +export const ButtonSave = styled(ButtonPrimary)` + ${shareButtonStyle} +` +export const ButtonExport = styled(ButtonOutlined)` + ${shareButtonStyle} +` diff --git a/src/pages/NotificationCenter/Portfolio/const.ts b/src/pages/NotificationCenter/Portfolio/const.ts new file mode 100644 index 0000000000..24f58b978c --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/const.ts @@ -0,0 +1,5 @@ +import { TIMES_IN_SECS } from 'constants/index' + +export const MAXIMUM_PORTFOLIO = 2 + +export const PORTFOLIO_POLLING_INTERVAL = 5 * TIMES_IN_SECS.ONE_MIN * 1000 diff --git a/src/pages/NotificationCenter/Portfolio/helpers.ts b/src/pages/NotificationCenter/Portfolio/helpers.ts new file mode 100644 index 0000000000..74001bf8d8 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/helpers.ts @@ -0,0 +1,128 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { t } from '@lingui/macro' +import { ethers } from 'ethers' +import { stringify } from 'querystring' +import { useCallback } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + useAddWalletToPortfolioMutation, + useLazyGetMyPortfoliosQuery, + useUpdateWalletToPortfolioMutation, +} from 'services/portfolio' + +import { NotificationType } from 'components/Announcement/type' +import { APP_PATHS } from 'constants/index' +import { useActiveWeb3React } from 'hooks' +import { Portfolio } from 'pages/NotificationCenter/Portfolio/type' +import { useNotify } from 'state/application/hooks' +import { isAddress } from 'utils' +import { formatDisplayNumber, uint256ToFraction } from 'utils/numbers' +import { isULIDString } from 'utils/string' + +export const formatAllowance = (value: string, decimals: number) => { + return ethers.BigNumber.from(value).gte( + ethers.BigNumber.from('1000000000000000000000000000000000000000000000000000000000000000'), + ) + ? t`Unlimited` + : formatDisplayNumber(uint256ToFraction(value, decimals), { style: 'decimal', significantDigits: 6 }) // todo uint256ToFraction +} + +export const useParseWalletPortfolioParam = () => { + const { portfolioId, wallet: walletParam } = useParams<{ wallet?: string; portfolioId?: string }>() + const wallet = isAddress(ChainId.MAINNET, walletParam) || isAddress(ChainId.MAINNET, portfolioId) || '' + return { + wallet, + portfolioId: isAddress(ChainId.MAINNET, portfolioId) || !isULIDString(portfolioId) ? '' : portfolioId, + } +} + +type Params = { wallet?: string; portfolioId?: string; myPortfolio?: boolean } + +// path/id/wallet or path/wallet +export const getPortfolioDetailUrl = ({ wallet, portfolioId, myPortfolio = true }: Params) => + [myPortfolio ? APP_PATHS.MY_PORTFOLIO : APP_PATHS.PORTFOLIO, portfolioId, wallet].filter(Boolean).join('/') + +export const useNavigateToPortfolioDetail = () => { + const navigate = useNavigate() + return useCallback( + (data: Params, replace = false, params: Record = {}) => { + navigate(`${getPortfolioDetailUrl(data)}${params ? `?${stringify(params)}` : ''}`, { replace }) + }, + [navigate], + ) +} + +export const useNavigateToMyFirstPortfolio = () => { + const { portfolioId } = useParseWalletPortfolioParam() + const { account } = useActiveWeb3React() + const navigate = useNavigateToPortfolioDetail() + + return useCallback( + (data: Portfolio[] | undefined, replace = false) => { + if (portfolioId && data?.some(el => el.id === portfolioId)) { + return + } + if (!account) { + navigate({}, replace) + return + } + if (!data?.length) { + navigate({ wallet: account }, replace) + return + } + navigate({ portfolioId: data?.[0]?.id }, replace) + }, + [account, navigate, portfolioId], + ) +} + +export const useLazyNavigateToMyFirstPortfolio = () => { + const navigate = useNavigateToMyFirstPortfolio() + const [getPortfolio] = useLazyGetMyPortfoliosQuery() + + return useCallback(async () => { + try { + const { data } = await getPortfolio(undefined, true) + navigate(data) + } catch (error) {} + }, [navigate, getPortfolio]) +} + +export const useAddWalletToPortfolio = () => { + const [addWallet] = useAddWalletToPortfolioMutation() + const [updateWallet] = useUpdateWalletToPortfolioMutation() + const notify = useNotify() + + const onAddUpdateWallet = useCallback( + async ({ + walletId, + ...data + }: { + walletAddress: string + nickName: string + walletId?: number + portfolioId: string + }) => { + try { + await (walletId ? updateWallet(data).unwrap() : addWallet(data).unwrap()) + notify({ + type: NotificationType.SUCCESS, + title: t`Portfolio updated`, + summary: t`Your portfolio has been successfully updated`, + }) + } catch (error) { + notify({ + type: NotificationType.ERROR, + title: t`Portfolio update failed`, + summary: + error?.data?.code === 4090 + ? t`Similar wallet address detected in a portfolio, please try again.` + : t`Failed to update your portfolio, please try again.`, + }) + } + }, + [addWallet, notify, updateWallet], + ) + + return onAddUpdateWallet +} diff --git a/src/pages/NotificationCenter/Portfolio/index.tsx b/src/pages/NotificationCenter/Portfolio/index.tsx new file mode 100644 index 0000000000..5db7b1e691 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/index.tsx @@ -0,0 +1,316 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { Trans, t } from '@lingui/macro' +import { Fragment, useCallback, useEffect, useState } from 'react' +import { Plus, Save, X } from 'react-feather' +import Skeleton from 'react-loading-skeleton' +import { useSearchParams } from 'react-router-dom' +import { useMedia } from 'react-use' +import { Text } from 'rebass' +import { + useClonePortfolioMutation, + useCreatePortfolioMutation, + useGetMyPortfoliosQuery, + useGetPortfolioByIdQuery, + useGetPortfoliosSettingsQuery, + useUpdatePortfoliosSettingsMutation, +} from 'services/portfolio' +import styled from 'styled-components' + +import { NotificationType } from 'components/Announcement/type' +import { ButtonPrimary } from 'components/Button' +import Column from 'components/Column' +import Row, { RowBetween } from 'components/Row' +import Tabs from 'components/Tabs' +import Toggle from 'components/Toggle' +import { MouseoverTooltip } from 'components/Tooltip' +import { EMPTY_ARRAY } from 'constants/index' +import { useActiveWeb3React } from 'hooks' +import useParsedQueryString from 'hooks/useParsedQueryString' +import useShowLoadingAtLeastTime from 'hooks/useShowLoadingAtLeastTime' +import useTheme from 'hooks/useTheme' +import CreatePortfolioModal from 'pages/NotificationCenter/Portfolio/Modals/CreatePortfolioModal' +import PortfolioItem from 'pages/NotificationCenter/Portfolio/PortfolioItem' +import { ButtonCancel, ButtonSave } from 'pages/NotificationCenter/Portfolio/buttons' +import { MAXIMUM_PORTFOLIO } from 'pages/NotificationCenter/Portfolio/const' +import WarningSignMessage, { WarningConnectWalletMessage } from 'pages/NotificationCenter/Profile/WarningSignMessage' +import { useNotify } from 'state/application/hooks' +import { MEDIA_WIDTHS } from 'theme' +import { isAddress } from 'utils' + +const ActionsWrapper = styled.div` + display: flex; + gap: 20px; + ${({ theme }) => theme.mediaWidth.upToSmall` + justify-content: space-between; + gap: 12px; + `} +` +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px 24px; + padding-bottom: 16px; + ${({ theme }) => theme.mediaWidth.upToMedium` + padding-bottom: 16px; + `} +` + +const Header = styled.div` + justify-content: space-between; + display: flex; + align-items: center; + cursor: pointer; + transform: translateX(-4px); +` + +const PortfolioStat = styled(Row)` + gap: 16px; + width: fit-content; + ${({ theme }) => theme.mediaWidth.upToMedium` + width: 100%; + justify-content: space-between; + `} +` + +const Divider = styled.div` + font-size: 12px; + line-height: 16px; + color: ${({ theme }) => theme.subText}; + border-top: 1px solid ${({ theme }) => theme.border}; + ${({ theme }) => theme.mediaWidth.upToMedium` + padding-left: 16px; + padding-right: 16px; + `} +` +const THRESHOLD_OPTIONS = [1, 10, 100].map(el => ({ value: el, title: `< ${el}` })) + +const SettingWrapper = styled(RowBetween)` + ${({ theme }) => theme.mediaWidth.upToSmall` + flex-direction: column; + gap: 24px; + `}; +` +const BalanceThreshold = styled(Row)` + gap: 16px; + justify-content: flex-end; + ${({ theme }) => theme.mediaWidth.upToSmall` + flex-direction: column; + gap: 8px; + width: 100%; + align-items: flex-start; + `}; +` + +export default function PortfolioSettings() { + const upToMedium = useMedia(`(max-width: ${MEDIA_WIDTHS.upToMedium}px)`) + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + const { account } = useActiveWeb3React() + const [showCreate, setShowCreate] = useState(false) + + const { data: portfolios = EMPTY_ARRAY, isLoading: isFetching } = useGetMyPortfoliosQuery() + const { data: settings } = useGetPortfoliosSettingsQuery() + const [searchParams, setSearchParams] = useSearchParams() + + const { + cloneId = '', + wallet, + autoShowCreate, + } = useParsedQueryString<{ cloneId: string; wallet: string; autoShowCreate: string }>() + const loading = useShowLoadingAtLeastTime(isFetching, wallet ? 0 : 700) + const { data: clonePortfolio } = useGetPortfolioByIdQuery({ id: cloneId }, { skip: !cloneId }) + useEffect(() => { + if (clonePortfolio || isAddress(ChainId.MAINNET, wallet) || autoShowCreate) { + setShowCreate(true) + } + }, [clonePortfolio, wallet, autoShowCreate]) + + const showModalCreatePortfolio = () => { + setShowCreate(true) + } + const hideModalCreatePortfolio = () => { + setShowCreate(false) + searchParams.delete('autoShowCreate') + setSearchParams(searchParams) + } + + const theme = useTheme() + + const [threshold, setThreshold] = useState(THRESHOLD_OPTIONS[0].value) + const [hideSmallBalance, setHideSmallBalance] = useState(true) + + const resetSetting = useCallback(() => { + if (!settings) return + setHideSmallBalance(settings.isHideDust) + setThreshold(settings.dustThreshold) + }, [settings]) + + useEffect(() => { + resetSetting() + }, [resetSetting]) + + const [saveSetting] = useUpdatePortfoliosSettingsMutation() + const savePortfolioSetting = async () => { + try { + await saveSetting({ dustThreshold: +threshold, isHideDust: hideSmallBalance }).unwrap() + notify({ + type: NotificationType.SUCCESS, + title: t`Portfolio setting saved`, + summary: t`Your portfolio settings have been successfully saved`, + }) + } catch (error) { + notify({ + type: NotificationType.ERROR, + title: t`Portfolio save failed`, + summary: t`Create portfolio settings save failed, please try again.`, + }) + } + } + + const hasChangeSettings = settings?.dustThreshold !== threshold || settings?.isHideDust !== hideSmallBalance + const disableBtnSave = loading || !hasChangeSettings + const canCreatePortfolio = !!account && portfolios.length < MAXIMUM_PORTFOLIO && !loading + + const [createPortfolio] = useCreatePortfolioMutation() + const [clonePortfolioRequest] = useClonePortfolioMutation() + + const notify = useNotify() + const addPortfolio = async (data: { name: string }) => { + try { + const resp = await (clonePortfolio + ? clonePortfolioRequest({ ...data, portfolioId: clonePortfolio.id }).unwrap() + : createPortfolio(data).unwrap()) + notify({ + type: NotificationType.SUCCESS, + title: t`Portfolio created`, + summary: t`Your portfolio have been successfully created`, + }) + if (clonePortfolio) { + searchParams.delete('cloneId') + setSearchParams(searchParams) + } + if (wallet && resp?.id) { + searchParams.set('portfolioId', resp.id) + setSearchParams(searchParams) + } + } catch (error) { + notify({ + type: NotificationType.ERROR, + title: t`Portfolio create failed`, + summary: t`Create portfolio failed, please try again.`, + }) + } + } + + return ( + +
+ {!upToMedium && ( + + Portfolios + + )} + + + + Portfolios count:{' '} + + {portfolios.length}/{MAXIMUM_PORTFOLIO} + + + + + + +   + Create Portfolio + + + +
+ {!account ? ( + + ) : ( + + )} + + + + + {loading ? ( + + ) : !portfolios.length ? ( + + You don't have any portfolio. + + ) : ( + portfolios.map((item, i) => ( + + + {i !== portfolios.length - 1 && } + + )) + )} + + + + + + Hide small token balances + + setHideSmallBalance(v => !v)} + /> + + {hideSmallBalance && ( + + + Small balances threshold + + + tabs={THRESHOLD_OPTIONS} + style={{ width: upToSmall ? '100%' : 200 }} + activeTab={threshold} + setActiveTab={setThreshold} + /> + + )} + + + + + {loading ? Saving... : Save} + + + + Cancel + + + +
+ ) +} diff --git a/src/pages/NotificationCenter/Portfolio/type.ts b/src/pages/NotificationCenter/Portfolio/type.ts new file mode 100644 index 0000000000..371fb97f78 --- /dev/null +++ b/src/pages/NotificationCenter/Portfolio/type.ts @@ -0,0 +1,319 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' + +export type PortfolioWallet = { id: number; walletAddress: string; nickName: string } + +export type PortfolioSetting = { + isHideDust: boolean + dustThreshold: number +} + +export type Portfolio = { + name: string + id: string + identityId: string + isHideDust: boolean + dustThreshold: number + isPublic: boolean +} + +export type PortfolioWalletBalance = { + chainId: number + tokenAddress: string + amount: string + decimals: number + valueUsd: string + priceUsd: string + tokenSymbol: string + tokenLogo: string + percentage: string + // wallet info section + kyberScore?: number + kyberScoreTag?: number + kyberScoreCreatedAt?: number + walletAddress: string +} + +export type PortfolioChainBalance = { + chainId: ChainId + percentage: string + valueUsd: string +} + +export type PortfolioWalletBalanceResponse = { + totalUsd: number + lastUpdatedAt: number + balances?: PortfolioWalletBalance[] +} + +export type PortfolioChainBalanceResponse = { + totalUsd: number + lastUpdatedAt: number + balances?: PortfolioChainBalance[] +} + +export type TokenAllowAnce = { + amount: string + chainId: number + decimals: number + hasPrice: boolean + is_checked: boolean + lastUpdateTimestamp: string + lastUpdateTxHash: string + logo: string + name: string + ownerAddress: string + spenderAddress: string + spenderName: string + symbol: string + tag: string + tokenAddress: string +} + +export type TokenAllowAnceResponse = { + approvals: TokenAllowAnce[] +} +type Token = { + address: string + symbol: string + name: string + decimals: number + logo: string + tag: string + nftTokenId?: number +} + +type TransactionToken = { + token: Token + otherAddress: string + otherName: string + tokenType: string + amount: string + valueInUsd: number + currentPrice: number + historicalValueInUsd: number + historicalPrice: number +} +export type TransactionHistory = { + chain: { + chainName: string + chainId: number + chainLogo: string + } + walletAddress: string + txHash: string + blockTime: number + blockNumber: number + from: string + to: string + value: string + gas: number + gasUsed: number + gasPrice: string + nativeTokenPrice: number + historicalNativeTokenPrice: number + inputData: string + status: 'failed' | 'success' + tokenTransfers: TransactionToken[] + contractInteraction: { + contractName: string + methodName: string + } + tokenApproval?: { + amount: string + spenderAddress: string + token: Token + } + tag: string +} + +export type TransactionHistoryResponse = { + data: TransactionHistory[] + timestamp: number +} + +export enum PortfolioTab { + TOKEN = 'tokens', + LIQUIDITY = 'liquidity', + NFT = 'nft', + TRANSACTIONS = 'transactions', + ALLOWANCES = 'allowances', +} + +export type PortfolioWalletPayload = { + walletAddress: string + nickName: string + walletId?: number + portfolioId?: string +} + +type NFTCollectionDetail = { + address: string + name: string + slug: string + marketplace: string + description: string + updateAt: string + banner: string + thumbnail: string + floorPrice: null + externalLink: string + twitter: string + instagram: string + isVerified: false + discordUrl: string + chainId: number +} + +export type NFTAttributes = { trait_type: string; value: string } +export type NFTDetail = { + chainID: ChainId + collectibleAddress: string + collectibleName: string + currentPrice: null + externalData: { + name: string + description: string + image: string + animation: string + attributes: NFTAttributes[] | null + } + favorite: false + lastSalePrice: null + ownerAddress: string + paymentToken: string + tokenBalance: string + tokenID: string + tokenUrl: string +} +export type NFTBalance = { + wallet: string + collectibleName: string + collectibleAddress: string + collectibleSymbol: string + collectibleLogo: string + collectionDetail: NFTCollectionDetail + nftType: null + items: NFTDetail[] + totalNFT: number + chainID: ChainId +} + +export type NFTTokenDetail = { + wallet: string + collectibleName: string + collectibleAddress: string + collectibleSymbol: string + collectibleLogo: string + collectionDetail: NFTCollectionDetail + nftType: string + item: NFTDetail + totalNFT: number + chainID: ChainId +} + +export type NftCollectionResponse = { + data: NFTBalance[] + totalData: number + timestamp: number +} + +export type PortfolioSearchData = { + id: string + name: string + totalUsd: string +} + +export type LiquidityData = { + chainName: string + chainId: number + chainLogo: string + balance: { + project: string + projectLogo: string + showWarning: boolean + tokenId: string + tokenType: string + userAddress: string + web3ProjectAddress: string + underlying: Array<{ + token: Token + balance: string + quotes: { + usd: { + symbol: string + price: number + priceChange24hPercentage: number + value: number + timestamp: number + } + } + assetType: string + }> + harvestedReward?: Array<{ + token: Token + balance: string + quotes: { + usd: { + value: number + } + } + }> + lpData: { + lpPoolData: { + token0: Token + token1: Token + fee?: number + amp?: number + poolAddress: string + } + lpUniV2Data?: { + pnl: number + currentToken0Amount: string + currentToken1Amount: string + totalToken0Amount: string + totalToken1Amount: string + currentToken0Usd: number + currentToken1Usd: number + totalToken0Usd: number + totalToken1Usd: number + totalFeeEarned0: string + totalFeeEarned1: string + feeToken0Usd: number + feeToken1Usd: number + } + lpPositionData?: { + tokenId: string + currentToken0Amount: string + currentToken1Amount: string + totalToken0Amount: string + totalToken1Amount: string + currentToken0Value: number + currentToken1Value: number + totalToken0Value: number + totalToken1Value: number + impermanentLoss: number + totalFeeEarned0: string + totalFeeEarned1: string + totalFeeEarned: number + totalFarmingReward: number + totalFeeEarned0Usd: number + totalFeeEarned1Usd: number + pnl: number + } + } + } +} + +export type LiquidityDataResponse = { + data: Array + stats: { + openPositionCount: number + closedPositionCount: number + liquidity: number + pnl: number + unclaimedFees: number + yesterdayEarning: number + apr: number + protocols: Array + } +} diff --git a/src/pages/NotificationCenter/Profile/WarningSignMessage.tsx b/src/pages/NotificationCenter/Profile/WarningSignMessage.tsx index e24d5bfb57..14c45831d5 100644 --- a/src/pages/NotificationCenter/Profile/WarningSignMessage.tsx +++ b/src/pages/NotificationCenter/Profile/WarningSignMessage.tsx @@ -1,5 +1,6 @@ import { Trans } from '@lingui/macro' import { rgba } from 'polished' +import React, { ReactNode } from 'react' import { Info } from 'react-feather' import { useMedia } from 'react-use' import { Text } from 'rebass' @@ -10,6 +11,7 @@ import Row from 'components/Row' import { useActiveWeb3React } from 'hooks' import useLogin from 'hooks/useLogin' import useTheme from 'hooks/useTheme' +import { useWalletModalToggle } from 'state/application/hooks' import { useSessionInfo } from 'state/authen/hooks' import { useSignedAccountInfo } from 'state/profile/hooks' import { ExternalLink, MEDIA_WIDTHS } from 'theme' @@ -27,47 +29,92 @@ const WarningWrapper = styled.div` padding: 12px 14px; `} ` + +const WarningConnectWrapper = styled.div` + border-radius: 24px; + background-color: ${({ theme }) => rgba(theme.subText, 0.2)}; + display: flex; + align-items: center; + gap: 20px; + padding: 8px 14px; + ${({ theme }) => theme.mediaWidth.upToMedium` + gap: 10px; + padding: 12px 14px; + `} +` +export const WarningConnectWalletMessage = ({ msg, outline }: { msg: ReactNode; outline?: boolean }) => { + const { account } = useActiveWeb3React() + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) + const btnWidth = upToSmall ? '45%' : '110px' + const theme = useTheme() + const connectWallet = useWalletModalToggle() + if (account) return null + + const propsBtn = { + fontSize: '14px', + width: btnWidth, + height: '30px', + onClick: connectWallet, + children: Connect, + } + return ( + + + + + {msg} + + + {React.createElement(outline && !upToSmall ? ButtonOutlined : ButtonPrimary, propsBtn)} + + ) +} + const DOC_URL = 'https://docs.kyberswap.com/kyberswap-solutions/kyberswap-interface/profiles' -const WarningSignMessage = () => { +const WarningSignMessage = ({ msg, outline }: { msg: ReactNode; outline?: boolean }) => { const { signIn } = useLogin() const { pendingAuthentication } = useSessionInfo() const { isSigInGuest } = useSignedAccountInfo() const { account } = useActiveWeb3React() const upToMedium = useMedia(`(max-width: ${MEDIA_WIDTHS.upToMedium}px)`) + const upToSmall = useMedia(`(max-width: ${MEDIA_WIDTHS.upToSmall}px)`) const btnWidth = upToMedium ? '45%' : '110px' const theme = useTheme() if (pendingAuthentication || !isSigInGuest) return null + + const propsBtn = { + fontSize: '14px', + width: btnWidth, + height: '30px', + onClick: () => signIn({ account }), + children: Sign-in, + } + + const readMoreLink = !upToSmall ? ( + + Read more here ↗ + + ) : null + return ( - {!upToMedium && } + {!upToSmall && } - - You are not signed in with this wallet address. Click Sign-In to link your wallet to a profile. This will - allow us to offer you a better experience. - {!upToMedium ? ( - <> - {' '} - Read more here ↗ - - ) : ( - '' - )} - + {msg} {readMoreLink} - - {upToMedium && ( + + {upToSmall && ( window.open(DOC_URL)}> Read More )} - signIn({ account })}> - Sign-in - + {React.createElement(outline && !upToSmall ? ButtonOutlined : ButtonPrimary, propsBtn)} ) } + export default WarningSignMessage diff --git a/src/pages/NotificationCenter/Profile/index.tsx b/src/pages/NotificationCenter/Profile/index.tsx index 091f00adba..ddbc07af5a 100644 --- a/src/pages/NotificationCenter/Profile/index.tsx +++ b/src/pages/NotificationCenter/Profile/index.tsx @@ -225,7 +225,10 @@ export default function Profile() { )} - + {signedAccount && isSignInEth && ( diff --git a/src/pages/NotificationCenter/const.ts b/src/pages/NotificationCenter/const.ts index 4671c3a0b0..fce345720c 100644 --- a/src/pages/NotificationCenter/const.ts +++ b/src/pages/NotificationCenter/const.ts @@ -2,7 +2,8 @@ import { Currency } from '@kyberswap/ks-sdk-core' import { t } from '@lingui/macro' import { TIMES_IN_SECS } from 'constants/index' -import { MAINNET_NETWORKS, NETWORKS_INFO } from 'constants/networks' +import { MAINNET_NETWORKS } from 'constants/networks' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' import { formatTimeDuration } from 'utils/time' export enum PriceAlertType { @@ -12,8 +13,8 @@ export enum PriceAlertType { export enum PROFILE_MANAGE_ROUTES { PROFILE = '/profile', - CREATE_ALERT = '/create-alert', + PORTFOLIO = '/portfolio', ALL_NOTIFICATION = '/notification', PREFERENCE = '/notification/preferences', @@ -24,7 +25,7 @@ export enum PROFILE_MANAGE_ROUTES { BRIDGE = '/notification/bridge', KYBER_AI_TOKENS = '/notification/kyber-ai', KYBER_AI_WATCH_LIST = '/notification/kyber-ai-watch-list', - CROSS_CHAIN = '/cross-chain', + CROSS_CHAIN = '/notification/cross-chain', } export type CreatePriceAlertPayload = { diff --git a/src/pages/NotificationCenter/index.tsx b/src/pages/NotificationCenter/index.tsx index 262d4acacd..0607dc876f 100644 --- a/src/pages/NotificationCenter/index.tsx +++ b/src/pages/NotificationCenter/index.tsx @@ -9,6 +9,7 @@ import CreateAlert from 'pages/NotificationCenter/CreateAlert' import GeneralAnnouncement from 'pages/NotificationCenter/GeneralAnnouncement' import Menu from 'pages/NotificationCenter/Menu' import Overview from 'pages/NotificationCenter/NotificationPreference' +import Portfolio from 'pages/NotificationCenter/Portfolio' import PriceAlerts from 'pages/NotificationCenter/PriceAlerts' import Profile from 'pages/NotificationCenter/Profile' import { PROFILE_MANAGE_ROUTES } from 'pages/NotificationCenter/const' @@ -146,7 +147,7 @@ function NotificationCenter() { element={} /> } /> - + } /> } diff --git a/src/pages/ProAmmPools/KyberAIModalInPool.tsx b/src/pages/ProAmmPools/KyberAIModalInPool.tsx index 3420609c80..f02cbb69a8 100644 --- a/src/pages/ProAmmPools/KyberAIModalInPool.tsx +++ b/src/pages/ProAmmPools/KyberAIModalInPool.tsx @@ -20,8 +20,9 @@ import KyberScoreMeter from 'pages/TrueSightV2/components/KyberScoreMeter' import SimpleTooltip from 'pages/TrueSightV2/components/SimpleTooltip' import { SUPPORTED_NETWORK_KYBERAI } from 'pages/TrueSightV2/constants/index' import { useTokenOverviewQuery } from 'pages/TrueSightV2/hooks/useKyberAIData' -import { calculateValueToColor, formatTokenPrice, navigateToSwapPage } from 'pages/TrueSightV2/utils' +import { calculateValueToColor, formatTokenPrice } from 'pages/TrueSightV2/utils' import { useIsWhiteListKyberAI } from 'state/user/hooks' +import { navigateToSwapPage } from 'utils/redirect' const Wrapper = styled.div` width: 100%; diff --git a/src/pages/TrueSightV2/components/ChevronIcon.tsx b/src/pages/TrueSightV2/components/ChevronIcon.tsx index ca42349dc1..e32b7789a2 100644 --- a/src/pages/TrueSightV2/components/ChevronIcon.tsx +++ b/src/pages/TrueSightV2/components/ChevronIcon.tsx @@ -1,6 +1,6 @@ const ChevronIcon = ({ color, rotate }: { color: string; rotate: string }) => { return ( - + ) diff --git a/src/pages/TrueSightV2/components/KyberAIShareModal.tsx b/src/pages/TrueSightV2/components/KyberAIShareModal.tsx index b808b1b9e5..d9d9bcc7f3 100644 --- a/src/pages/TrueSightV2/components/KyberAIShareModal.tsx +++ b/src/pages/TrueSightV2/components/KyberAIShareModal.tsx @@ -1,205 +1,19 @@ import { Trans } from '@lingui/macro' -import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' -import { isMobile } from 'react-device-detect' -import { X } from 'react-feather' -import { QRCode } from 'react-qrcode-logo' +import { ReactNode } from 'react' import { useParams } from 'react-router-dom' import { useMedia } from 'react-use' import { Text } from 'rebass' -import { SHARE_TYPE, useCreateShareLinkMutation } from 'services/social' -import styled, { css } from 'styled-components' +import { SHARE_TYPE } from 'services/social' -import modalBackground from 'assets/images/truesight-v2/modal_background.png' -import modalBackgroundMobile from 'assets/images/truesight-v2/modal_background_mobile.png' -import Column from 'components/Column' -import Icon from 'components/Icons/Icon' -import Modal from 'components/Modal' -import Row, { RowBetween, RowFit } from 'components/Row' -import { getSocialShareUrls } from 'components/ShareModal' -import useCopyClipboard from 'hooks/useCopyClipboard' -import useShareImage from 'hooks/useShareImage' +import ShareImageModal from 'components/ShareModal/ShareImageModal' import useTheme from 'hooks/useTheme' -import { ExternalLink, MEDIA_WIDTHS } from 'theme' -import { downloadImage } from 'utils/index' +import { MEDIA_WIDTHS } from 'theme' import { getProxyTokenLogo } from 'utils/tokenInfo' import { NETWORK_IMAGE_URL } from '../constants' import useKyberAIAssetOverview from '../hooks/useKyberAIAssetOverview' -import KyberSwapShareLogo from './KyberSwapShareLogo' -import LoadingTextAnimation from './LoadingTextAnimation' -import { InfoWrapper, LegendWrapper } from './chart' -const Wrapper = styled.div` - padding: 20px; - border-radius: 20px; - background-color: ${({ theme }) => theme.tableHeader}; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - width: 100%; - min-width: min(50vw, 880px); - .time-frame-legend { - display: none; - } -` - -const Input = styled.input` - background-color: transparent; - height: 34px; - color: ${({ theme }) => theme.text}; - :focus { - } - outline: none; - box-shadow: none; - width: 95%; - border: none; -` -const InputWrapper = styled.div` - background-color: ${({ theme }) => theme.buttonBlack}; - height: 36px; - padding-left: 16px; - border-radius: 20px; - border: 1px solid ${({ theme }) => theme.border}; - flex: 1; - display: flex; -` - -const IconButton = styled.div<{ disabled?: boolean }>` - height: 36px; - width: 36px; - display: flex; - justify-content: center; - align-items: center; - background-color: ${({ theme }) => theme.subText + '32'}; - border-radius: 18px; - cursor: pointer; - :hover { - filter: brightness(1.2); - } - :active { - box-shadow: 0 2px 4px 4px rgba(0, 0, 0, 0.2); - } - color: ${({ theme }) => theme.subText} !important; - - a { - color: ${({ theme }) => theme.subText} !important; - } - ${({ disabled }) => - disabled && - css` - cursor: default; - pointer-events: none; - color: ${({ theme }) => theme.subText + '80'} !important; - a { - color: ${({ theme }) => theme.subText + '80'} !important; - } - `} -` -const ImageWrapper = styled.div<{ isMobileMode?: boolean }>` - max-height: 80vh; - position: relative; - max-width: 100%; - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; - overflow: hidden; - ${({ isMobileMode }) => - isMobileMode - ? css` - height: 620px; - aspect-ratio: 1/2; - ` - : css` - height: 490px; - width: 840px; - `} -` -const ImageInner = styled.div` - width: 1050px; - height: 612px; - aspect-ratio: 1050/612; - background-color: ${({ theme }) => theme.background}; - display: flex; - flex-direction: column; - padding: 32px; - gap: 10px; - position: relative; - :before { - content: ' '; - position: absolute; - inset: 0 0 0 0; - opacity: 0.25; - background: url(${modalBackground}); - background-size: cover; - z-index: -1; - } -` - -const ImageInnerMobile = styled.div` - width: 400px; - height: 800px; - aspect-ratio: 1/2; - background-color: ${({ theme }) => theme.background}; - display: flex; - flex-direction: column; - padding: 24px; - gap: 16px; - position: relative; - :before { - content: ' '; - position: absolute; - inset: 0 0 0 0; - opacity: 1; - background: url(${modalBackgroundMobile}); - background-size: cover; - z-index: -1; - } - - ${LegendWrapper} { - position: initial; - justify-content: flex-start; - } - ${InfoWrapper} { - position: initial; - gap: 12px; - font-size: 12px; - justify-content: space-between; - } - /* .recharts-responsive-container { - height: 490px !important; - } */ -` - -const Loader = styled.div` - position: absolute; - inset: 0; - height: 100%; - width: 100%; - display: flex; - align-items: center; - justify-content: center; - background: ${({ theme }) => theme.buttonBlack}; - z-index: 4; - border-radius: 8px; - padding: 0 12px; -` - -type ShareData = { - shareUrl?: string - imageUrl?: string - blob?: Blob -} - -export default function KyberAIShareModal({ - title, - content, - isOpen, - onClose, - onShareClick, -}: { +export default function KyberAIShareModal(props: { title?: string content?: (mobileMode?: boolean) => ReactNode isOpen: boolean @@ -209,124 +23,7 @@ export default function KyberAIShareModal({ const theme = useTheme() const { chain } = useParams() const { data: tokenOverview } = useKyberAIAssetOverview() - - const ref = useRef(null) - const refMobile = useRef(null) - const [loading, setLoading] = useState(true) - const [isError, setIsError] = useState(false) - const [isMobileMode, setIsMobileMode] = useState(isMobile) - const [mobileData, setMobileData] = useState({}) - const [desktopData, setDesktopData] = useState({}) - const shareImage = useShareImage() - const [createShareLink] = useCreateShareLinkMutation() const above768 = useMedia(`(min-width:${MEDIA_WIDTHS.upToSmall}px)`) - const sharingUrl = (isMobileMode ? mobileData.shareUrl : desktopData.shareUrl) || '' - const imageUrl = (isMobileMode ? mobileData.imageUrl : desktopData.imageUrl) || '' - const blob = isMobileMode ? mobileData.blob : desktopData.blob - - const handleGenerateImageDesktop = useCallback( - async (shareUrl: string) => { - if (ref.current) { - setIsError(false) - const shareId = shareUrl?.split('/').pop() - - if (!shareId) { - setLoading(false) - setIsError(true) - } - try { - const { imageUrl, blob } = await shareImage(ref.current, SHARE_TYPE.KYBER_AI, shareId) - setDesktopData(prev => { - return { ...prev, imageUrl, blob } - }) - } catch (err) { - console.log(err) - setLoading(false) - setIsError(true) - } - } else { - setLoading(false) - } - }, - [shareImage], - ) - - const handleGenerateImageMobile = useCallback( - async (shareUrl: string) => { - if (refMobile.current) { - setIsError(false) - const shareId = shareUrl?.split('/').pop() - if (!shareId) return - try { - const { imageUrl, blob } = await shareImage(refMobile.current, SHARE_TYPE.KYBER_AI, shareId) - setMobileData(prev => { - return { ...prev, imageUrl, blob } - }) - } catch (err) { - console.log(err) - setLoading(false) - setIsError(true) - } - } else { - setLoading(false) - } - }, - [shareImage], - ) - - useEffect(() => { - let timeout: NodeJS.Timeout - const createShareFunction = async () => { - if (!isOpen) { - timeout = setTimeout(() => { - setLoading(true) - setIsError(false) - setIsMobileMode(isMobile) - setDesktopData({}) - setMobileData({}) - }, 400) - } - if (isOpen) { - if ((isMobileMode && !mobileData.shareUrl) || (!isMobileMode && !desktopData.shareUrl)) { - setLoading(true) - setIsError(false) - const shareUrl = await createShareLink({ - redirectURL: window.location.href, - type: SHARE_TYPE.KYBER_AI, - }).unwrap() - if (isMobileMode && !mobileData.shareUrl) { - setMobileData({ shareUrl }) - } else { - setDesktopData({ shareUrl }) - } - timeout = setTimeout(() => { - isMobileMode ? handleGenerateImageMobile(shareUrl) : handleGenerateImageDesktop(shareUrl) - }, 1000) - } - } - } - createShareFunction() - return () => { - timeout && clearTimeout(timeout) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, isMobileMode]) - - const [, staticCopy] = useCopyClipboard() - const handleCopyClick = () => { - staticCopy(sharingUrl || '') - onShareClick?.('copy to clipboard') - } - const handleImageCopyClick = () => { - if (blob) { - const clipboardItem = new ClipboardItem({ ['image/png']: blob }) - navigator.clipboard.write([clipboardItem]) - } - } - const handleDownloadClick = () => { - downloadImage(blob, 'kyberAI_share_image.png') - } - const TokenInfo = () => ( <> {tokenOverview && ( @@ -374,164 +71,22 @@ export default function KyberAIShareModal({ ) - useEffect(() => { - if (imageUrl) { - const img = new Image() - img.src = imageUrl - img.onload = () => { - setLoading(false) - } - } - }, [imageUrl]) - - const { facebook, telegram, discord, twitter } = getSocialShareUrls(sharingUrl) - return ( - - - - - Share this with your friends! + } + imageName="kyberAI_share_image.png" + kyberswapLogoTitle={ + + + KyberAI | + {' '} + + Ape Smart - onClose?.()} /> - - - - - - onShareClick?.('telegram')}> - - - - - onShareClick?.('twitter')}> - - - - - onShareClick?.('facebook')}> - - - - - onShareClick?.('discord')}> - - - - - - - - - - {loading && - (isMobileMode ? ( - - - - - - - - - {title} - - - {content?.(true)} - - - - -
- -
- - - - ) : ( - - - - - - - -
- -
-
-
- - - {title} - - - {content?.(false)} -
- ))} - {loading ? ( - - - - - - ) : isError ? ( - - Some errors have occurred, please try again later! - - ) : ( - <> - {imageUrl && ( -
- )} - - )} - - - setIsMobileMode(prev => !prev)}> - - - - - - - - - - - - - + + } + /> ) } diff --git a/src/pages/TrueSightV2/components/KyberSwapShareLogo.tsx b/src/pages/TrueSightV2/components/KyberSwapShareLogo.tsx index ee5735b877..f4a2af6395 100644 --- a/src/pages/TrueSightV2/components/KyberSwapShareLogo.tsx +++ b/src/pages/TrueSightV2/components/KyberSwapShareLogo.tsx @@ -1,72 +1,34 @@ -export default function KyberSwapShareLogo({ width, height }: { width?: string; height?: string }) { +import { ReactNode } from 'react' +import { Text } from 'rebass' + +import useTheme from 'hooks/useTheme' + +const defaultWidth = 204 +const defaultHeight = 72 +// todo move +export default function KyberSwapShareLogo({ width = defaultWidth, title }: { width?: number; title?: ReactNode }) { + const theme = useTheme() + + const scale = width / defaultWidth return ( - - - - - - - - - - - - - - - - - + + {title} + +
) } diff --git a/src/pages/TrueSightV2/components/SearchWithDropDown.tsx b/src/pages/TrueSightV2/components/SearchWithDropDown.tsx index e28ee8c4be..290d049be4 100644 --- a/src/pages/TrueSightV2/components/SearchWithDropDown.tsx +++ b/src/pages/TrueSightV2/components/SearchWithDropDown.tsx @@ -1,11 +1,11 @@ import { Trans, t } from '@lingui/macro' -import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { X } from 'react-feather' import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' import { useLocation, useNavigate } from 'react-router-dom' import { useLocalStorage, useMedia } from 'react-use' import { Text } from 'rebass' -import styled, { css, keyframes } from 'styled-components' +import styled, { CSSProperties, css, keyframes } from 'styled-components' import { ButtonEmpty } from 'components/Button' import History from 'components/Icons/History' @@ -281,41 +281,57 @@ const TokenItem = ({ token, onClick }: { token: ITokenSearchResult; onClick?: () ) } -const SearchResultTableWrapper = ({ header, children }: { header?: ReactNode; children?: ReactNode }) => { +type TableColumn = { align: string; label: string; style: CSSProperties } + +const SearchResultTableWrapper = ({ + header, + children, + columns, + searching, +}: { + header?: ReactNode + children?: ReactNode + columns: TableColumn[] + searching?: boolean +}) => { return (
- - - + {columns.map(col => ( + + ))} - - - - - - - - + {!searching && ( + + + + {columns.map(col => ( + + ))} + + + )} {children} ) } +const columns = [ + { align: 'left', label: 'KyberScore', style: { width: '100px', minWidth: 'auto' } }, + { align: 'left', label: 'Price', style: { width: '100px' } }, + { align: 'right', label: '24H', style: { width: '60px' } }, +] let checkedNewData = false -const SearchWithDropdown = () => { +const SearchWithDropdownKyberAI = () => { const theme = useTheme() const mixpanelHandler = useMixpanelKyberAI() const { pathname } = useLocation() const [expanded, setExpanded] = useState(false) const [search, setSearch] = useState('') - const [height, setHeight] = useState(0) - const wrapperRef = useRef(null) - const inputRef = useRef(null) - const dropdownRef = useRef(null) - const contentRef = useRef(null) const debouncedSearch = useDebounce(search, 1000) const { data: searchResult, isFetching } = useSearchTokenQuery( { q: debouncedSearch, size: 10 }, @@ -364,6 +380,160 @@ const SearchWithDropdown = () => { const haveSearchResult = debouncedSearch !== '' && searchResult && searchResult.length > 0 && !isFetching const noSearchResult = debouncedSearch !== '' && searchResult && searchResult.length === 0 && !isFetching const isLoading = isFetching && search === debouncedSearch + const searchResultNode = (searchResult || []).map(item => ( + { + setExpanded(false) + saveToHistory(item) + mixpanelHandler(MIXPANEL_TYPE.KYBERAI_SEARCH_TOKEN_SUCCESS, { + token_name: item.symbol?.toUpperCase(), + source: pathname.includes(APP_PATHS.KYBERAI_EXPLORE) ? 'explore' : KYBERAI_LISTYPE_TO_MIXPANEL[listType], + search_term: search, + }) + }} + /> + )) + const historyNode = (history || []) + .slice(0, 3) + .map((item, index) => setExpanded(false)} />) + + const bullishNode = top5bullish?.data?.slice(0, 3).map((item, index) => ( + { + setExpanded(false) + saveToHistory(formatTokenType(item)) + mixpanelHandler(MIXPANEL_TYPE.KYBERAI_SEARCH_TOKEN_SUCCESS, { + token_name: item.symbol?.toUpperCase(), + source: pathname.includes(APP_PATHS.KYBERAI_EXPLORE) ? 'explore' : KYBERAI_LISTYPE_TO_MIXPANEL[listType], + token_type: 'bullish', + }) + }} + /> + )) + + const bearishNode = top5bearish?.data?.slice(0, 3).map((item, index) => ( + { + setExpanded(false) + saveToHistory(formatTokenType(item)) + mixpanelHandler(MIXPANEL_TYPE.KYBERAI_SEARCH_TOKEN_SUCCESS, { + token_name: item.symbol?.toUpperCase(), + source: pathname.includes(APP_PATHS.KYBERAI_EXPLORE) ? 'explore' : KYBERAI_LISTYPE_TO_MIXPANEL[listType], + token_type: 'bearish', + }) + }} + /> + )) + + // todo + const sections: any[] = haveSearchResult + ? [{ items: searchResultNode }] + : [ + { + title: ( + + + Search History + + ), + items: historyNode, + show: !!history, + }, + { + title: ( + + + Bullish Tokens + + ), + loading: isBullishLoading, + items: bullishNode, + }, + { + title: ( + + + Bearish Tokens + + ), + loading: isBearishLoading, + items: bearishNode, + }, + ] + + return ( + + Ape Smart! + + } + /> + ) +} + +export type SearchSection = { + items: ReactNode[] | JSX.Element[] + title?: ReactNode + loading?: boolean + show?: boolean +} + +// todo move to component, memo, refactor props +export const SearchWithDropdown = ({ + placeholder, + sections, + value: search, + onChange: setSearch, + id, + searchIcon, + searching, + noSearchResult, + noResultText, + expanded, + setExpanded, + columns, + style, +}: { + placeholder: string + id?: string + sections: SearchSection[] + value: string + onChange: (value: string) => void + searchIcon?: ReactNode + searching: boolean + noSearchResult: boolean + noResultText: ReactNode + expanded: boolean + setExpanded: React.Dispatch> + columns: TableColumn[] + style?: CSSProperties +}) => { + const theme = useTheme() + + const [height, setHeight] = useState(0) + const wrapperRef = useRef(null) + const inputRef = useRef(null) + const dropdownRef = useRef(null) + const above768 = useMedia(`(min-width:${MEDIA_WIDTHS.upToSmall}px)`) useOnClickOutside(wrapperRef, () => setExpanded(false)) @@ -378,12 +548,15 @@ const SearchWithDropdown = () => { return () => { inputEl.removeEventListener('focusin', onFocus) } - }, []) + }, [setExpanded]) - const handleXClick = useCallback((e: any) => { - setSearch('') - e.stopPropagation() - }, []) + const handleXClick = useCallback( + (e: any) => { + setSearch('') + e.stopPropagation() + }, + [setSearch], + ) useEffect(() => { if (!dropdownRef.current) return @@ -399,184 +572,73 @@ const SearchWithDropdown = () => { } }, []) - const content = useMemo(() => { - return ( -
- {isLoading ? ( - <> - + useEffect(() => { + function onKeydown(e: KeyboardEvent) { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + // cmd+k or ctrl+k + e.preventDefault() + setExpanded(v => !v) + } + } + window.addEventListener('keydown', onKeydown) + return () => { + window.removeEventListener('keydown', onKeydown) + } + }, [setExpanded]) + + return ( + !expanded && inputRef.current?.focus()} expanded={expanded} style={style}> + setSearch(e.target.value)} + autoComplete="off" + ref={inputRef} + /> + + {search && ( + + + + )} + + {searchIcon || } + + + +
+ {searching ? ( + - - ) : haveSearchResult ? ( - <> - - {searchResult.map(item => ( - { - setExpanded(false) - saveToHistory(item) - mixpanelHandler(MIXPANEL_TYPE.KYBERAI_SEARCH_TOKEN_SUCCESS, { - token_name: item.symbol?.toUpperCase(), - source: pathname.includes(APP_PATHS.KYBERAI_EXPLORE) - ? 'explore' - : KYBERAI_LISTYPE_TO_MIXPANEL[listType], - search_term: debouncedSearch, - }) - }} - /> - ))} - - - ) : noSearchResult ? ( - <> + ) : noSearchResult ? ( - - Oops, we couldnt find your token! We will regularly add new tokens that have achieved a certain - trading volume - + {noResultText} - - ) : ( - <> - {history && ( - - - Search History - - } - > - {history.slice(0, 3).map((item, index) => ( - setExpanded(false)} /> - ))} - - )} - - - Bullish Tokens - - } - > - {isBullishLoading ? ( - - ) : ( - top5bullish?.data?.slice(0, 3).map((item, index) => ( - { - setExpanded(false) - saveToHistory(formatTokenType(item)) - mixpanelHandler(MIXPANEL_TYPE.KYBERAI_SEARCH_TOKEN_SUCCESS, { - token_name: item.symbol?.toUpperCase(), - source: pathname.includes(APP_PATHS.KYBERAI_EXPLORE) - ? 'explore' - : KYBERAI_LISTYPE_TO_MIXPANEL[listType], - token_type: 'bullish', - }) - }} - /> - )) - )} - - - - Bearish Tokens - - } - > - {isBearishLoading ? ( - - ) : ( - top5bearish?.data?.slice(0, 3).map((item, index) => ( - { - setExpanded(false) - saveToHistory(formatTokenType(item)) - mixpanelHandler(MIXPANEL_TYPE.KYBERAI_SEARCH_TOKEN_SUCCESS, { - token_name: item.symbol?.toUpperCase(), - source: pathname.includes(APP_PATHS.KYBERAI_EXPLORE) - ? 'explore' - : KYBERAI_LISTYPE_TO_MIXPANEL[listType], - token_type: 'bearish', - }) - }} - /> - )) - )} - - - )} -
- ) - }, [ - above768, - haveSearchResult, - isBearishLoading, - isLoading, - theme, - listType, - isBullishLoading, - pathname, - saveToHistory, - history, - noSearchResult, - searchResult, - top5bullish, - top5bearish, - mixpanelHandler, - debouncedSearch, - ]) - - return ( - <> - !expanded && inputRef.current?.focus()} expanded={expanded}> - { - setSearch(e.target.value) - }} - autoComplete="off" - ref={inputRef} - /> - - {search && ( - - - + ) : ( + sections.map((el, i) => + el.show === false ? null : ( + + {el.loading ? : el.items} + + ), + ) )} - - - Ape Smart! - - - - {content} - {expanded && } - - - +
+ {expanded && } + + ) } -export default React.memo(SearchWithDropdown) +export default React.memo(SearchWithDropdownKyberAI) diff --git a/src/pages/TrueSightV2/components/SmallKyberScoreMeter.tsx b/src/pages/TrueSightV2/components/SmallKyberScoreMeter.tsx index 20f6a3f4ed..c0205f6665 100644 --- a/src/pages/TrueSightV2/components/SmallKyberScoreMeter.tsx +++ b/src/pages/TrueSightV2/components/SmallKyberScoreMeter.tsx @@ -8,7 +8,7 @@ import Column from 'components/Column' import Icon from 'components/Icons/Icon' import { RowFit } from 'components/Row' -import { IKyberScoreChart, ITokenList } from '../types' +import { ITokenList } from '../types' import { calculateValueToColor, formatTokenPrice } from '../utils' import { gaugeList } from './KyberScoreMeter' import SimpleTooltip from './SimpleTooltip' @@ -27,8 +27,16 @@ const GaugeValue = styled.div` justify-content: center; bottom: 6px; ` -function SmallKyberScoreMeter({ disabledTooltip, token }: { disabledTooltip?: boolean; token: ITokenList }) { - const data: IKyberScoreChart | undefined = token?.kyberScore3D?.[token.kyberScore3D.length - 1] +function SmallKyberScoreMeter({ + disabledTooltip, + token, + createdAt: createdAtParam, +}: { + disabledTooltip?: boolean + token: ITokenList + createdAt?: number +}) { + const createdAt = token?.kyberScore3D?.[token.kyberScore3D.length - 1]?.createdAt || createdAtParam const value = token?.kyberScore const theme = useTheme() const emptyColor = theme.subText + '30' @@ -54,11 +62,9 @@ function SmallKyberScoreMeter({ disabledTooltip, token }: { disabledTooltip?: bo - - Calculated at {data.createdAt ? dayjs(data.createdAt * 1000).format('DD/MM/YYYY HH:mm A') : '--'} - + Calculated at {createdAt ? dayjs(createdAt * 1000).format('DD/MM/YYYY HH:mm A') : '--'} KyberScore:{' '} diff --git a/src/pages/TrueSightV2/components/TutorialModal.tsx b/src/pages/TrueSightV2/components/TutorialModal.tsx deleted file mode 100644 index 1e517119bb..0000000000 --- a/src/pages/TrueSightV2/components/TutorialModal.tsx +++ /dev/null @@ -1,451 +0,0 @@ -import { Trans } from '@lingui/macro' -import React, { useEffect, useLayoutEffect, useReducer } from 'react' -import { X } from 'react-feather' -import { useMedia } from 'react-use' -import { Text } from 'rebass' -import styled, { keyframes } from 'styled-components' - -import tutorial1 from 'assets/images/truesight-v2/tutorial_1.png' -import tutorial2 from 'assets/images/truesight-v2/tutorial_2.png' -import tutorial3 from 'assets/images/truesight-v2/tutorial_3.png' -import tutorial4 from 'assets/images/truesight-v2/tutorial_4.png' -import tutorial6 from 'assets/images/truesight-v2/tutorial_6.png' -import { ButtonOutlined, ButtonPrimary } from 'components/Button' -import ApeIcon from 'components/Icons/ApeIcon' -import Modal from 'components/Modal' -import Row, { RowBetween } from 'components/Row' -import useTheme from 'hooks/useTheme' -import { ApplicationModal } from 'state/application/actions' -import { useModalOpen, useToggleModal } from 'state/application/hooks' -import { MEDIA_WIDTHS } from 'theme' - -const Wrapper = styled.div` - border-radius: 20px; - background-color: ${({ theme }) => theme.tableHeader}; - padding: 20px; - display: flex; - flex-direction: column; - gap: 16px; - width: min(95vw, 808px); - - ${({ theme }) => theme.mediaWidth.upToSmall` - min-height: 70vh; - `} -` -const fadeInScale = keyframes` - 0% { opacity: 0; transform:scale(0.7) } - 100% { opacity: 1; transform:scale(1)} -` -const fadeInLeft = keyframes` - 0% { opacity: 0.5; transform:translateX(calc(-100% - 40px)) } - 100% { opacity: 1; transform:translateX(0)} -` -const fadeOutRight = keyframes` - 0% { opacity: 1; transform:translateX(0);} - 100% { opacity: 0.5; transform:translateX(calc(100% + 40px)); visibility:hidden; } -` -const fadeInRight = keyframes` - 0% { opacity: 0.5; transform:translateX(calc(100% + 40px)) } - 100% { opacity: 1; transform:translateX(0)} -` -const fadeOutLeft = keyframes` - 0% { opacity: 1; transform:translateX(0);} - 100% { opacity: 0.5; transform:translateX(calc(-100% - 40px)); visibility:hidden; } -` - -const StepWrapper = styled.div` - padding: 0; - margin: 0; - box-sizing: content-box; - display: flex; - flex-direction: column; - gap: 20px; - width: 100%; - height: fit-content; - &.fadeInScale { - animation: ${fadeInScale} 0.3s ease; - } - &.fadeOutRight { - animation: ${fadeOutRight} 0.5s ease; - } - &.fadeOutLeft { - animation: ${fadeOutLeft} 0.5s ease; - } - &.fadeInRight { - animation: ${fadeInRight} 0.5s ease; - } - &.fadeInLeft { - animation: ${fadeInLeft} 0.5s ease; - } - img { - object-fit: contain; - } - b { - font-weight: 500; - color: ${({ theme }) => theme.text}; - } - p { - margin-bottom: 16px; - } - - ${({ theme }) => theme.mediaWidth.upToSmall` - p { - margin-bottom: 0px; - } - `} -` - -const StepDot = styled.div<{ active?: boolean }>` - height: 8px; - width: 8px; - border-radius: 50%; - background-color: ${({ theme, active }) => (active ? theme.primary : theme.subText)}; -` - -const steps = [ - { - image: tutorial2, - text: ( - -

- Whether you're looking to identify new tokens to trade, or get alpha on a specific token, KyberAI has it - all! KyberAI currently provides trading insights on 4000+ tokens across 7 blockchains! -

{' '} -

- For traders who are in discovery mode, start with the Rankings section. Here you will see top tokens under - each of the 7 categories -{' '} - Bullish, Bearish, Top CEX Inflow, Top CEX Outflow, Top Traded, Trending Soon, Currently Trending. We - update the token rankings multiple times a day! -

{' '} -

- For traders looking to spot alpha on specific tokens, start with the Explore section. You will find a number - of On-Chain and Technical insights on your token that you can look at to make an informed trading decision. -

-
- ), - }, - { - image: tutorial3, - text: ( - -

- A unique trading insight offered by KyberAI is the KyberScore. KyberScore uses AI to measure the - upcoming trend (bullish or bearish) of a token by taking into account multiple on-chain and off-chain - indicators. The score ranges from 0 to 100. Higher the score, more bullish the token in the short-term. -

{' '} -

- Each token supported by KyberAI is assigned a KyberScore. It refreshes multiple times a day as we collect more - data on the token. You can find the KyberScore of a token in the Rankings or Explore section. - Read more about the calculation here. -

{' '} -

- Note: KyberScore should not be considered as financial advice -

-
- ), - }, - { - image: tutorial4, - text: ( - -

- For traders, analyzing & interpreting on-chain data can be very powerful. It helps us see what whales, smart - money and other traders are up to. And so, KyberAI has cherry picked the best on-chain indicators to help - traders like you spot alpha on your tokens. Check out the On-Chain Analysis tab of the Explore{' '} - section! -

-

- The best traders combine on-chain analysis with technical analysis (TA). TA is used to identify trading - opportunities by evaluating price charts, price trends, patterns etc. KyberAI makes TA easy for traders. Check - out the Technical Analysis tab of the Explore section! -

-
- ), - }, - { - image: tutorial6, - text: ( - -

That's not all! Here are a few handy tips so you can get the most out of KyberAI:

{' '} -
    -
  • - Use the search bar to search for any token you'd like to explore. KyberAI supports 4000+ tokens! -
  • -
  • - Subscribe to receive daily emails on the top tokens as recommended by KyberAI! -
  • -
  • - Monitoring the price of a token? Set a price alert, sit back, and we'll notify you! -
  • -
  • - Create a watchlist of your favorite tokens, and access it quickly! -
  • -
{' '} -

If you wish to view this guide again, you can enable it from the settings.

-

- Ape Smart with KyberAI. -

-
- ), - }, -] - -enum AnimationState { - Idle, - Animating, - Animated, -} -enum SwipeDirection { - LEFT, - RIGHT, -} - -type TutorialAnimationState = { - step: number - animationState: AnimationState - swipe: SwipeDirection -} -const initialState = { - step: 0, - animationState: AnimationState.Idle, - swipe: SwipeDirection.LEFT, -} -enum ActionTypes { - INITIAL = 'INITIAL', - START = 'START', - NEXT_STEP = 'NEXT_STEP', - PREV_STEP = 'PREV_STEP', - ANIMATION_END = 'ANIMATION_END', -} -function reducer(state: TutorialAnimationState, action: ActionTypes) { - switch (action) { - case ActionTypes.INITIAL: - return { - ...initialState, - } - case ActionTypes.START: - return { - step: 1, - animationState: AnimationState.Idle, - swipe: SwipeDirection.LEFT, - } - case ActionTypes.NEXT_STEP: - if (state.step < steps.length && state.animationState !== AnimationState.Animating) { - return { - step: state.step + 1, - animationState: AnimationState.Animating, - swipe: SwipeDirection.LEFT, - } - } - break - case ActionTypes.PREV_STEP: - if (state.animationState !== AnimationState.Animating) { - return { - step: state.step - 1, - animationState: AnimationState.Animating, - swipe: SwipeDirection.RIGHT, - } - } - break - case ActionTypes.ANIMATION_END: - return { ...state, animationState: AnimationState.Idle } - - default: - throw new Error() - } - return state -} - -const StepContent = ({ step, ...rest }: { step: number; [k: string]: any }) => { - const theme = useTheme() - const { image, text } = steps[step - 1] - const above768 = useMedia(`(min-width: ${MEDIA_WIDTHS.upToSmall}px)`) - return ( - -
- - {text} - - - ) -} - -const TutorialModal = () => { - const theme = useTheme() - const isOpen = useModalOpen(ApplicationModal.KYBERAI_TUTORIAL) - const toggle = useToggleModal(ApplicationModal.KYBERAI_TUTORIAL) - const [{ step, animationState, swipe }, dispatch] = useReducer(reducer, initialState) - const lastStep = - animationState === AnimationState.Animating ? (swipe === SwipeDirection.LEFT ? step - 1 : step + 1) : undefined - - const above768 = useMedia(`(min-width: ${MEDIA_WIDTHS.upToSmall}px)`) - - useEffect(() => { - if (!localStorage.getItem('showedKyberAITutorial')) { - // auto show for first time all user - toggle() - localStorage.setItem('showedKyberAITutorial', '1') - } - }, [toggle]) - - useLayoutEffect(() => { - if (isOpen) { - dispatch(ActionTypes.INITIAL) - } - }, [isOpen]) - - return ( - - - - - - Welcome to - KyberAI - -
- beta -
-
-
- -
-
- {step === 0 && ( - <> -
- - - - We're thrilled to have you onboard and can't wait for you to start exploring the world of - trading powered by KyberAI. We've created this short - tutorial for you to highlight KyberAI's main features. Ready? - - - - - - - Maybe later - - - { - dispatch(ActionTypes.START) - }} - > - - Let's get started - - - - - )} - {step > 0 && ( - <> - - {animationState === AnimationState.Animating && ( - <> - - dispatch(ActionTypes.ANIMATION_END)} - style={{ position: 'absolute', top: 0, left: 0, backgroundColor: theme.tableHeader }} - /> - - )} - - - - {steps.map((a, index) => ( - - ))} - - - dispatch(ActionTypes.PREV_STEP)}> - - Back - - - { - if (step < steps.length) { - dispatch(ActionTypes.NEXT_STEP) - } else { - toggle() - } - }} - > - - {step === steps.length ? Let's go! : Next} - - - - - )} - - - ) -} - -export default React.memo(TutorialModal) diff --git a/src/pages/TrueSightV2/components/TutorialModalKyberAI.tsx b/src/pages/TrueSightV2/components/TutorialModalKyberAI.tsx new file mode 100644 index 0000000000..8f066f18be --- /dev/null +++ b/src/pages/TrueSightV2/components/TutorialModalKyberAI.tsx @@ -0,0 +1,166 @@ +import { Trans } from '@lingui/macro' +import { useEffect } from 'react' +import { isMobile } from 'react-device-detect' +import { CSSProperties } from 'styled-components' + +import tutorial1 from 'assets/images/truesight-v2/tutorial_1.png' +import tutorial2 from 'assets/images/truesight-v2/tutorial_2.png' +import tutorial3 from 'assets/images/truesight-v2/tutorial_3.png' +import tutorial4 from 'assets/images/truesight-v2/tutorial_4.png' +import tutorial6 from 'assets/images/truesight-v2/tutorial_6.png' +import ApeIcon from 'components/Icons/ApeIcon' +import TutorialModal from 'components/TutorialModal' +import useTheme from 'hooks/useTheme' +import { ApplicationModal } from 'state/application/actions' +import { useModalOpen, useToggleModal } from 'state/application/hooks' + +const Step1 = () => { + const theme = useTheme() + return ( + + We're thrilled to have you onboard and can't wait for you to start exploring the world of trading + powered by KyberAI. We've created this short tutorial for you to + highlight KyberAI's main features. Ready? + + ) +} + +const textStyle: CSSProperties = { + height: isMobile ? '35vh' : '202px', +} + +const steps = [ + { image: tutorial1, text: , textStyle: { ...textStyle, height: 'auto' } }, + { + image: tutorial2, + textStyle, + text: ( + +

+ Whether you're looking to identify new tokens to trade, or get alpha on a specific token, KyberAI has it + all! KyberAI currently provides trading insights on 4000+ tokens across 7 blockchains! +

{' '} +

+ For traders who are in discovery mode, start with the Rankings section. Here you will see top tokens under + each of the 7 categories -{' '} + Bullish, Bearish, Top CEX Inflow, Top CEX Outflow, Top Traded, Trending Soon, Currently Trending. We + update the token rankings multiple times a day! +

{' '} +

+ For traders looking to spot alpha on specific tokens, start with the Explore section. You will find a number + of On-Chain and Technical insights on your token that you can look at to make an informed trading decision. +

+
+ ), + }, + { + image: tutorial3, + textStyle, + text: ( + +

+ A unique trading insight offered by KyberAI is the KyberScore. KyberScore uses AI to measure the + upcoming trend (bullish or bearish) of a token by taking into account multiple on-chain and off-chain + indicators. The score ranges from 0 to 100. Higher the score, more bullish the token in the short-term. +

{' '} +

+ Each token supported by KyberAI is assigned a KyberScore. It refreshes multiple times a day as we collect more + data on the token. You can find the KyberScore of a token in the Rankings or Explore section. + Read more about the calculation here. +

{' '} +

+ Note: KyberScore should not be considered as financial advice +

+
+ ), + }, + { + image: tutorial4, + textStyle, + text: ( + +

+ For traders, analyzing & interpreting on-chain data can be very powerful. It helps us see what whales, smart + money and other traders are up to. And so, KyberAI has cherry picked the best on-chain indicators to help + traders like you spot alpha on your tokens. Check out the On-Chain Analysis tab of the Explore{' '} + section! +

+

+ The best traders combine on-chain analysis with technical analysis (TA). TA is used to identify trading + opportunities by evaluating price charts, price trends, patterns etc. KyberAI makes TA easy for traders. Check + out the Technical Analysis tab of the Explore section! +

+
+ ), + }, + { + image: tutorial6, + textStyle, + text: ( + +

That's not all! Here are a few handy tips so you can get the most out of KyberAI:

{' '} +
    +
  • + Use the search bar to search for any token you'd like to explore. KyberAI supports 4000+ tokens! +
  • +
  • + Subscribe to receive daily emails on the top tokens as recommended by KyberAI! +
  • +
  • + Monitoring the price of a token? Set a price alert, sit back, and we'll notify you! +
  • +
  • + Create a watchlist of your favorite tokens, and access it quickly! +
  • +
{' '} +

If you wish to view this guide again, you can enable it from the settings.

+

+ Ape Smart with KyberAI. +

+
+ ), + }, +] + +const TutorialModalKyberAI = () => { + const isOpen = useModalOpen(ApplicationModal.KYBERAI_TUTORIAL) + const toggle = useToggleModal(ApplicationModal.KYBERAI_TUTORIAL) + const theme = useTheme() + + useEffect(() => { + if (!localStorage.getItem('showedKyberAITutorial')) { + // auto show for first time all user + toggle() + localStorage.setItem('showedKyberAITutorial', '1') + } + }, [toggle]) + + return ( + + + Welcome to + KyberAI + +
+ beta +
+ + } + /> + ) +} +export default TutorialModalKyberAI diff --git a/src/pages/TrueSightV2/components/WatchlistButton.tsx b/src/pages/TrueSightV2/components/WatchlistButton.tsx index 2c5f419ce2..c32550d744 100644 --- a/src/pages/TrueSightV2/components/WatchlistButton.tsx +++ b/src/pages/TrueSightV2/components/WatchlistButton.tsx @@ -505,7 +505,7 @@ function WatchlistButton({ { ;(isWatched || !isReachMaxLimit) && setOpenMenu(true) }} @@ -531,7 +531,7 @@ function WatchlistButton({ onSelect(watchlists, watched) }} > - + {watchlists.name} ({watchlists.assetNumber}) ) diff --git a/src/pages/TrueSightV2/components/WatchlistStar.tsx b/src/pages/TrueSightV2/components/WatchlistStar.tsx index 0e04c14cbf..1c55d4c0db 100644 --- a/src/pages/TrueSightV2/components/WatchlistStar.tsx +++ b/src/pages/TrueSightV2/components/WatchlistStar.tsx @@ -4,14 +4,14 @@ import { useTheme } from 'styled-components' export const StarWithAnimation = ({ loading, - watched, + active: watched, onClick, size, disabled, wrapperStyle, stopPropagation, }: { - watched: boolean + active: boolean loading?: boolean onClick?: (e: any) => void size?: number diff --git a/src/pages/TrueSightV2/components/index.tsx b/src/pages/TrueSightV2/components/index.tsx index 2259916d83..b843526fdf 100644 --- a/src/pages/TrueSightV2/components/index.tsx +++ b/src/pages/TrueSightV2/components/index.tsx @@ -33,10 +33,9 @@ export const StyledSectionWrapper = styled.div<{ show?: boolean }>` display: flex; flex-direction: column; gap: 16px; - height: 580px; ` -export const SectionTitle = styled.div` +const SectionTitle = styled.div` font-size: 16px; line-height: 20px; font-weight: 500; @@ -45,7 +44,7 @@ export const SectionTitle = styled.div` border-bottom: 1px solid ${({ theme }) => theme.border + '80'}; color: ${({ theme }) => theme.text}; ` -export const SectionDescription = styled.div<{ show?: boolean }>` +const SectionDescription = styled.div<{ show?: boolean }>` font-size: 14px; line-height: 20px; text-overflow: ellipsis; @@ -78,7 +77,7 @@ const ButtonWrapper = styled.div` } ` -export const FullscreenButton = React.memo(function FCButton({ +const FullscreenButton = React.memo(function FCButton({ elementRef, onClick, }: { @@ -168,7 +167,7 @@ export const SectionWrapper = ({ const docsLink = activeTab === ChartTab.Second && !!docsLinks[1] ? docsLinks[1] : docsLinks[0] return ( - + {above768 ? ( <> {/* DESKTOP */} diff --git a/src/pages/TrueSightV2/components/table/LiquidityMarkets.tsx b/src/pages/TrueSightV2/components/table/LiquidityMarkets.tsx index 0129e209bd..392d046d6e 100644 --- a/src/pages/TrueSightV2/components/table/LiquidityMarkets.tsx +++ b/src/pages/TrueSightV2/components/table/LiquidityMarkets.tsx @@ -16,8 +16,9 @@ import useTheme from 'hooks/useTheme' import { useGetLiquidityMarketsQuery as useGetLiquidityMarketsCoinmarketcap } from 'pages/TrueSightV2/hooks/useCoinmarketcapData' import useKyberAIAssetOverview from 'pages/TrueSightV2/hooks/useKyberAIAssetOverview' import { ChartTab } from 'pages/TrueSightV2/types' -import { colorFundingRateText, formatShortNum, formatTokenPrice, navigateToSwapPage } from 'pages/TrueSightV2/utils' +import { colorFundingRateText, formatShortNum, formatTokenPrice } from 'pages/TrueSightV2/utils' import { MEDIA_WIDTHS } from 'theme' +import { navigateToSwapPage } from 'utils/redirect' import { LoadingHandleWrapper } from '.' diff --git a/src/pages/TrueSightV2/components/table/index.tsx b/src/pages/TrueSightV2/components/table/index.tsx index 7c980a060d..a4185ab8e9 100644 --- a/src/pages/TrueSightV2/components/table/index.tsx +++ b/src/pages/TrueSightV2/components/table/index.tsx @@ -31,10 +31,10 @@ import { colorFundingRateText, formatLocaleStringNum, formatTokenPrice, - navigateToSwapPage, } from 'pages/TrueSightV2/utils' import { ExternalLink } from 'theme' import { getEtherscanLink, shortenAddress } from 'utils' +import { navigateToSwapPage } from 'utils/redirect' import { getProxyTokenLogo } from 'utils/tokenInfo' import ChevronIcon from '../ChevronIcon' diff --git a/src/pages/TrueSightV2/index.tsx b/src/pages/TrueSightV2/index.tsx index 5a9c82b4a8..0b7a8b2ef8 100644 --- a/src/pages/TrueSightV2/index.tsx +++ b/src/pages/TrueSightV2/index.tsx @@ -10,12 +10,12 @@ import Row, { RowBetween, RowFit } from 'components/Row' import { APP_PATHS } from 'constants/index' import useTheme from 'hooks/useTheme' import SubscribeButtonKyberAI from 'pages/TrueSightV2/components/SubscireButtonKyberAI' +import TutorialModalKyberAI from 'pages/TrueSightV2/components/TutorialModalKyberAI' import { MEDIA_WIDTHS } from 'theme' import TrueSightWidget from './components/KyberAIWidget' import NewUpdateAnnoucement from './components/NewUpdateAnnoucement' import SearchWithDropDown from './components/SearchWithDropDown' -import TutorialModal from './components/TutorialModal' import SingleToken from './pages/SingleToken' import TokenAnalysisList from './pages/TokenAnalysisList' @@ -118,7 +118,7 @@ export default function TrueSightV2() { {isExplore ? : } - + diff --git a/src/pages/TrueSightV2/pages/SingleToken.tsx b/src/pages/TrueSightV2/pages/SingleToken.tsx index 8f8fd01128..acff69904c 100644 --- a/src/pages/TrueSightV2/pages/SingleToken.tsx +++ b/src/pages/TrueSightV2/pages/SingleToken.tsx @@ -18,6 +18,7 @@ import { MIXPANEL_TYPE, useMixpanelKyberAI } from 'hooks/useMixpanel' import useTheme from 'hooks/useTheme' import { PROFILE_MANAGE_ROUTES } from 'pages/NotificationCenter/const' import { MEDIA_WIDTHS } from 'theme' +import { navigateToSwapPage } from 'utils/redirect' import { escapeScriptHtml } from 'utils/string' import DisplaySettings from '../components/DisplaySettings' @@ -32,7 +33,6 @@ import { DEFAULT_EXPLORE_PAGE_TOKEN, MIXPANEL_KYBERAI_TAG, NETWORK_IMAGE_URL, NE import useChartStatesReducer, { ChartStatesContext } from '../hooks/useChartStatesReducer' import useKyberAIAssetOverview from '../hooks/useKyberAIAssetOverview' import { DiscoverTokenTab, IAssetOverview } from '../types' -import { navigateToSwapPage } from '../utils' import LiquidityAnalysis from './LiquidityAnalysis' import OnChainAnalysis from './OnChainAnalysis' import TechnicalAnalysis from './TechnicalAnalysis' diff --git a/src/pages/TrueSightV2/pages/TokenAnalysisList.tsx b/src/pages/TrueSightV2/pages/TokenAnalysisList.tsx index cb71924c7e..16ece5fca1 100644 --- a/src/pages/TrueSightV2/pages/TokenAnalysisList.tsx +++ b/src/pages/TrueSightV2/pages/TokenAnalysisList.tsx @@ -1,27 +1,26 @@ import { Trans, t } from '@lingui/macro' -import { motion } from 'framer-motion' import { rgba } from 'polished' -import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react' import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { useEffectOnce, useMedia } from 'react-use' import { Text } from 'rebass' -import styled, { DefaultTheme, css } from 'styled-components' +import styled, { css } from 'styled-components' -import { ReactComponent as DropdownSVG } from 'assets/svg/down.svg' import Column from 'components/Column' import Icon from 'components/Icons/Icon' import AnimatedLoader from 'components/Loader/AnimatedLoader' import Pagination from 'components/Pagination' import Row, { RowFit } from 'components/Row' -import TabButton from 'components/TabButton' -import { APP_PATHS, ICON_ID, SORT_DIRECTION } from 'constants/index' +import TabDraggable, { TabITem } from 'components/Section/TabDraggable' +import { APP_PATHS, SORT_DIRECTION } from 'constants/index' import { MIXPANEL_TYPE, useMixpanelKyberAI } from 'hooks/useMixpanel' import { useOnClickOutside } from 'hooks/useOnClickOutside' import useTheme from 'hooks/useTheme' import { StyledSectionWrapper } from 'pages/TrueSightV2/components' import TokenFilter from 'pages/TrueSightV2/components/TokenFilter' import { MEDIA_WIDTHS } from 'theme' +import { navigateToSwapPage } from 'utils/redirect' import FeedbackSurvey from '../components/FeedbackSurvey' import KyberAIShareModal from '../components/KyberAIShareModal' @@ -33,7 +32,7 @@ import { DEFAULT_PARAMS_BY_TAB, KYBERAI_LISTYPE_TO_MIXPANEL, SORT_FIELD, Z_INDEX import { useTokenListQuery } from '../hooks/useKyberAIData' import useRenderRankingList from '../hooks/useRenderRankingList' import { IKyberScoreChart, ITokenList, KyberAIListType } from '../types' -import { navigateToSwapPage, useFormatParamsFromUrl } from '../utils' +import { useFormatParamsFromUrl } from '../utils' const SIZE_MOBILE = '1080px' @@ -219,32 +218,6 @@ const ActionButton = styled.button<{ color: string }>` gap: 4px; ` -const TabWrapper = styled(motion.div)` - overflow: auto; - cursor: grab; - display: inline-flex; - width: fit-content; - position: relative; - scroll-snap-type: x mandatory; - scroll-behavior: smooth; - min-width: 100%; - > * { - flex: 1 0 fit-content; - scroll-snap-align: start; - } - &.no-scroll { - scroll-snap-type: unset; - scroll-behavior: unset; - > * { - scroll-snap-align: unset; - } - } - ${({ theme }) => theme.mediaWidth.upToSmall` - min-width: initial; - flex: 1; - `} -` - const LoadingWrapper = styled(Row)` position: absolute; inset: 0 0 0 0; @@ -262,226 +235,125 @@ const LoadingWrapper = styled(Row)` `} ` -const ARROW_SIZE = 40 +const getTokenTypeList = (): TabITem[] => [ + { type: KyberAIListType.MYWATCHLIST, icon: 'star', title: t`My Watchlist` }, + { type: KyberAIListType.ALL, title: t`All` }, + { + type: KyberAIListType.BULLISH, + title: t`Bullish`, + icon: 'bullish', + tooltip: theme => ( + + Tokens with the highest chance of price increase in the next 24H + (highest KyberScore). + + ), + }, + { + type: KyberAIListType.BEARISH, + title: t`Bearish`, + icon: 'bearish', + tooltip: theme => ( + + Tokens with the highest chance of price decrease in the next 24H + (lowest KyberScore). + + ), + }, + { + type: KyberAIListType.KYBERSWAP_DELTA, + title: t`KyberScore Delta`, + icon: 'bearish', + tooltip: theme => ( + + + Tokens with a significant change in KyberScore between two + consecutive time periods. This may indicate a change in trend of the token. + + + ), + }, + { + type: KyberAIListType.TOP_CEX_INFLOW, + title: t`Top CEX Positive Netflow`, + icon: 'download', + tooltip: theme => ( + + Tokens with the highest net deposits to Centralized Exchanges in the + last 3 Days. Possible incoming sell pressure. + + ), + }, + { + type: KyberAIListType.TOP_CEX_OUTFLOW, + title: t`Top CEX Negative Netflow`, + icon: 'upload', + tooltip: theme => ( + + Tokens with the highest net withdrawals from Centralized Exchanges in + the last 3 Days. Possible buy pressure. + + ), + }, + { + type: KyberAIListType.FUNDING_RATE, + title: t`Funding Rates`, + icon: 'coin-bag', + tooltip: () => ( + + + Tokens with funding rates on centralized exchanges. Positive funding rate suggests traders are bullish & + vice-versa for negative rates. Extreme rates may result in leveraged positions getting squeezed. + + + ), + }, + { + type: KyberAIListType.TOP_TRADED, + title: t`Top Traded`, + icon: 'coin-bag', + tooltip: theme => ( + + Tokens with the highest 24H trading volume. + + ), + }, + { + type: KyberAIListType.TRENDING_SOON, + title: t`Trending Soon`, + icon: 'trending-soon', + tooltip: theme => ( + + Tokens that could be trending in the near future. Trending indicates + interest in a token - it doesnt imply bullishness or bearishness. + + ), + }, + { + type: KyberAIListType.TRENDING, + title: t`Currently Trending`, + icon: 'flame', + tooltip: theme => ( + + Tokens that are currently trending in the market. + + ), + }, +] const TokenListDraggableTabs = ({ tab, setTab }: { tab: KyberAIListType; setTab: (type: KyberAIListType) => void }) => { - const theme = useTheme() const mixpanelHandler = useMixpanelKyberAI() - const [showScrollRightButton, setShowScrollRightButton] = useState(false) - const [scrollLeftValue, setScrollLeftValue] = useState(0) - const wrapperRef = useRef(null) - const tabListRef = useRef([]) - - const tokenTypeList: { - type: KyberAIListType - icon?: ICON_ID - tooltip?: (theme: DefaultTheme) => ReactNode - title: string - }[] = [ - { type: KyberAIListType.MYWATCHLIST, icon: 'star', title: t`My Watchlist` }, - { type: KyberAIListType.ALL, title: t`All` }, - { - type: KyberAIListType.BULLISH, - title: t`Bullish`, - icon: 'bullish', - tooltip: theme => ( - - Tokens with the highest chance of price increase in the next 24H - (highest KyberScore). - - ), - }, - { - type: KyberAIListType.BEARISH, - title: t`Bearish`, - icon: 'bearish', - tooltip: theme => ( - - Tokens with the highest chance of price decrease in the next 24H - (lowest KyberScore). - - ), - }, - { - type: KyberAIListType.KYBERSWAP_DELTA, - title: t`KyberScore Delta`, - icon: 'bearish', - tooltip: theme => ( - - - Tokens with a significant change in KyberScore between two - consecutive time periods. This may indicate a change in trend of the token. - - - ), - }, - { - type: KyberAIListType.TOP_CEX_INFLOW, - title: t`Top CEX Positive Netflow`, - icon: 'download', - tooltip: theme => ( - - Tokens with the highest net deposits to Centralized Exchanges in - the last 3 Days. Possible incoming sell pressure. - - ), - }, - { - type: KyberAIListType.TOP_CEX_OUTFLOW, - title: t`Top CEX Negative Netflow`, - icon: 'upload', - tooltip: theme => ( - - Tokens with the highest net withdrawals from Centralized Exchanges - in the last 3 Days. Possible buy pressure. - - ), - }, - { - type: KyberAIListType.FUNDING_RATE, - title: t`Funding Rates`, - icon: 'coin-bag', - tooltip: () => ( - - - Tokens with funding rates on centralized exchanges. Positive funding rate suggests traders are bullish & - vice-versa for negative rates. Extreme rates may result in leveraged positions getting squeezed. - - - ), - }, - { - type: KyberAIListType.TOP_TRADED, - title: t`Top Traded`, - icon: 'coin-bag', - tooltip: theme => ( - - Tokens with the highest 24H trading volume. - - ), - }, - { - type: KyberAIListType.TRENDING_SOON, - title: t`Trending Soon`, - icon: 'trending-soon', - tooltip: theme => ( - - Tokens that could be trending in the near future. Trending - indicates interest in a token - it doesnt imply bullishness or bearishness. - - ), - }, - { - type: KyberAIListType.TRENDING, - title: t`Currently Trending`, - icon: 'flame', - tooltip: theme => ( - - Tokens that are currently trending in the market. - - ), - }, - ] - - useEffect(() => { - wrapperRef.current?.scrollTo({ left: scrollLeftValue, behavior: 'smooth' }) - }, [scrollLeftValue]) - - useEffect(() => { - const wRef = wrapperRef.current - if (!wRef) return - const handleWheel = (e: any) => { - e.preventDefault() - setScrollLeftValue(prev => Math.min(Math.max(prev + e.deltaY, 0), wRef.scrollWidth - wRef.clientWidth)) - } - if (wRef) { - wRef.addEventListener('wheel', handleWheel) - } - return () => wRef?.removeEventListener('wheel', handleWheel) - }, []) - - useEffect(() => { - const handleResize = () => { - setShowScrollRightButton( - Boolean(wrapperRef.current?.clientWidth && wrapperRef.current?.clientWidth < wrapperRef.current?.scrollWidth), - ) - } - handleResize() - window.addEventListener('resize', handleResize) - return () => { - window.removeEventListener('resize', handleResize) - } - }, []) - - const indexActive = tokenTypeList.findIndex(e => e.type === tab) - + const onTabClick = (fromTab: KyberAIListType, toTab: KyberAIListType) => { + mixpanelHandler(MIXPANEL_TYPE.KYBERAI_RANKING_CATEGORY_CLICK, { + from_cate: KYBERAI_LISTYPE_TO_MIXPANEL[fromTab], + to_cate: KYBERAI_LISTYPE_TO_MIXPANEL[toTab], + source: KYBERAI_LISTYPE_TO_MIXPANEL[fromTab], + }) + } return ( - - e.preventDefault()} - style={{ paddingRight: showScrollRightButton ? ARROW_SIZE : undefined }} - > - {tokenTypeList.map(({ type, title, tooltip }, index) => { - const props = { - onClick: () => { - mixpanelHandler(MIXPANEL_TYPE.KYBERAI_RANKING_CATEGORY_CLICK, { - from_cate: KYBERAI_LISTYPE_TO_MIXPANEL[tab], - to_cate: KYBERAI_LISTYPE_TO_MIXPANEL[type], - source: KYBERAI_LISTYPE_TO_MIXPANEL[tab], - }) - setTab(type) - if (!wrapperRef.current) return - const tabRef = tabListRef.current[index] - const wRef = wrapperRef.current - if (tabRef.offsetLeft < wRef.scrollLeft) { - setScrollLeftValue(tabRef.offsetLeft) - } - if (wRef.scrollLeft + wRef.clientWidth < tabRef.offsetLeft + tabRef.offsetWidth) { - setScrollLeftValue(tabRef.offsetLeft + tabRef.offsetWidth - wRef.offsetWidth) - } - }, - } - return ( - - { - if (el) { - tabListRef.current[index] = el - } - }} - /> - - ) - })} - - {showScrollRightButton && ( - { - setScrollLeftValue(prev => prev + 120) - }} - /> - )} - + + {...{ activeTab: tab, onChange: setTab, trackingChangeTab: onTabClick, tabs: getTokenTypeList() }} + /> ) } diff --git a/src/pages/TrueSightV2/utils/index.tsx b/src/pages/TrueSightV2/utils/index.tsx index 6d1ac618e1..4da3878fbf 100644 --- a/src/pages/TrueSightV2/utils/index.tsx +++ b/src/pages/TrueSightV2/utils/index.tsx @@ -106,16 +106,6 @@ export const getErrorMessage = (error: any) => { return mapErr[code] || t`Error occur, please try again.` } -export const navigateToSwapPage = ({ address, chain }: { address?: string; chain?: string }) => { - if (!address || !chain) return - const wethAddress = WETH[NETWORK_TO_CHAINID[chain]].address - const formattedChain = chain === 'bsc' ? 'bnb' : chain - window.open( - window.location.origin + - `${APP_PATHS.SWAP}/${formattedChain}?inputCurrency=${wethAddress}&outputCurrency=${address}`, - '_blank', - ) -} export const navigateToLimitPage = ({ address, chain }: { address?: string; chain?: string }) => { if (!address || !chain) return const wethAddress = WETH[NETWORK_TO_CHAINID[chain]].address diff --git a/src/services/portfolio.ts b/src/services/portfolio.ts new file mode 100644 index 0000000000..0aa029bfb7 --- /dev/null +++ b/src/services/portfolio.ts @@ -0,0 +1,338 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { createApi } from '@reduxjs/toolkit/query/react' +import { baseQueryOauthDynamic } from 'services/baseQueryOauth' + +import { BFF_API } from 'constants/env' +import { RTK_QUERY_TAGS } from 'constants/index' +import { + LiquidityDataResponse, + NFTBalance, + NFTTokenDetail, + NftCollectionResponse, + Portfolio, + PortfolioChainBalanceResponse, + PortfolioSearchData, + PortfolioSetting, + PortfolioWallet, + PortfolioWalletBalanceResponse, + TokenAllowAnceResponse, + TransactionHistoryResponse, +} from 'pages/NotificationCenter/Portfolio/type' + +const KRYSTAL_API = 'https://api.krystal.app/all' +const portfolioApi = createApi({ + reducerPath: 'portfolioApi', + baseQuery: baseQueryOauthDynamic({ baseUrl: `${BFF_API}/v1/portfolio-service` }), + tagTypes: [ + RTK_QUERY_TAGS.GET_LIST_PORTFOLIO, + RTK_QUERY_TAGS.GET_LIST_WALLET_PORTFOLIO, + RTK_QUERY_TAGS.GET_SETTING_PORTFOLIO, + RTK_QUERY_TAGS.GET_FAVORITE_PORTFOLIO, + ], + endpoints: builder => ({ + getMyPortfolios: builder.query({ + query: () => ({ + url: '/portfolios', + authentication: true, + }), + transformResponse: (data: any) => data?.data?.portfolios, + providesTags: [RTK_QUERY_TAGS.GET_LIST_PORTFOLIO], + }), + getPortfolioById: builder.query({ + query: ({ id }) => ({ + url: `/portfolios/${id}`, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + }), + searchPortfolio: builder.query({ + query: params => ({ + url: `/search`, + params, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + }), + getTrendingPortfolios: builder.query({ + query: () => ({ + url: `/search/trending`, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + }), + getFavoritesPortfolios: builder.query({ + query: () => ({ + url: `/search/favorites`, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + providesTags: [RTK_QUERY_TAGS.GET_FAVORITE_PORTFOLIO], + }), + toggleFavoritePortfolio: builder.mutation<{ id: string }, { value: string; isAdd: boolean }>({ + query: ({ isAdd, ...body }) => ({ + url: '/favorites', + method: isAdd ? 'POST' : 'DELETE', + body, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + invalidatesTags: [RTK_QUERY_TAGS.GET_FAVORITE_PORTFOLIO], + }), + createPortfolio: builder.mutation<{ id: string }, { name: string }>({ + query: body => ({ + url: '/portfolios', + method: 'POST', + body, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + invalidatesTags: [RTK_QUERY_TAGS.GET_LIST_PORTFOLIO], + }), + clonePortfolio: builder.mutation({ + query: body => ({ + url: '/portfolios/clone', + method: 'POST', + body, + authentication: true, + }), + invalidatesTags: [RTK_QUERY_TAGS.GET_LIST_PORTFOLIO], + }), + updatePortfolio: builder.mutation({ + query: ({ id, ...body }) => ({ + url: `/portfolios/${id}`, + method: 'PUT', + body, + authentication: true, + }), + invalidatesTags: [RTK_QUERY_TAGS.GET_LIST_PORTFOLIO], + }), + deletePortfolio: builder.mutation({ + query: id => ({ + url: `/portfolios/${id}`, + method: 'DELETE', + authentication: true, + }), + invalidatesTags: [RTK_QUERY_TAGS.GET_LIST_PORTFOLIO], + }), + // setting + getPortfoliosSettings: builder.query({ + query: () => ({ + url: '/settings', + authentication: true, + }), + transformResponse: (data: any) => data?.data, + providesTags: [RTK_QUERY_TAGS.GET_SETTING_PORTFOLIO], + }), + updatePortfoliosSettings: builder.mutation({ + query: body => ({ + url: `/settings`, + method: 'PUT', + body, + authentication: true, + }), + invalidatesTags: [RTK_QUERY_TAGS.GET_SETTING_PORTFOLIO], + }), + // wallets + getWalletsPortfolios: builder.query({ + query: ({ portfolioId }) => ({ + url: `/portfolios/${portfolioId}/wallets`, + authentication: true, + }), + transformResponse: (data: any) => data?.data?.wallets, + providesTags: [RTK_QUERY_TAGS.GET_LIST_WALLET_PORTFOLIO], + }), + addWalletToPortfolio: builder.mutation( + { + query: ({ portfolioId, ...body }) => ({ + url: `/portfolios/${portfolioId}/wallets`, + method: 'POST', + body, + authentication: true, + }), + invalidatesTags: [RTK_QUERY_TAGS.GET_LIST_WALLET_PORTFOLIO], + }, + ), + updateWalletToPortfolio: builder.mutation< + Portfolio, + { portfolioId: string; walletAddress: string; nickName: string } + >({ + query: ({ portfolioId, walletAddress, ...body }) => ({ + url: `/portfolios/${portfolioId}/wallets/${walletAddress}`, + method: 'PUT', + body, + authentication: true, + }), + invalidatesTags: [RTK_QUERY_TAGS.GET_LIST_WALLET_PORTFOLIO], + }), + removeWalletFromPortfolio: builder.mutation({ + query: ({ portfolioId, walletAddress }) => ({ + url: `/portfolios/${portfolioId}/wallets/${walletAddress}`, + method: 'DELETE', + authentication: true, + }), + invalidatesTags: [RTK_QUERY_TAGS.GET_LIST_WALLET_PORTFOLIO], + }), + // metadata + getRealtimeBalance: builder.query({ + query: ({ walletAddresses }) => ({ + url: `${BFF_API}/v1/wallet-service/balances/realtime/total`, + params: { walletAddresses: walletAddresses.join(',') }, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + }), + getTokenAllocation: builder.query< + PortfolioWalletBalanceResponse, + { walletAddresses: string[]; chainIds: ChainId[] } + >({ + query: ({ walletAddresses, chainIds }) => ({ + url: `${BFF_API}/v1/wallet-service/balances/realtime/tokens`, + params: { walletAddresses: walletAddresses.join(','), chainIds: chainIds.join(',') }, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + }), + getChainsAllocation: builder.query< + PortfolioChainBalanceResponse, + { walletAddresses: string[]; chainIds: ChainId[] } + >({ + query: ({ walletAddresses, chainIds }) => ({ + url: `${BFF_API}/v1/wallet-service/balances/realtime/chains`, + params: { walletAddresses: walletAddresses.join(','), chainIds: chainIds.join(',') }, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + }), + getWalletsAllocation: builder.query< + PortfolioWalletBalanceResponse, + { walletAddresses: string[]; chainIds: ChainId[] } + >({ + query: ({ walletAddresses, chainIds }) => ({ + url: `${BFF_API}/v1/wallet-service/balances/realtime/wallets`, + params: { walletAddresses: walletAddresses.join(','), chainIds: chainIds.join(',') }, + authentication: true, + }), + transformResponse: (data: any) => data?.data, + }), + getTokenApproval: builder.query({ + query: params => ({ + url: `${KRYSTAL_API}/v1/approval/list`, + params, + }), + transformResponse: (data: any) => data?.data, + }), + getNftCollections: builder.query< + NftCollectionResponse, + { addresses: string[]; chainIds?: ChainId[]; page: number; pageSize: number; search: string } + >({ + query: params => ({ + url: `${KRYSTAL_API}/v1/balance/listNftCollection`, + params: { ...params, withNft: false }, + }), + transformResponse: (data: any) => { + data.data = data.data.map((chain: any) => chain.balances).flat() + return data + }, + }), + getNftCollectionDetail: builder.query< + NFTBalance, + { + address: string + chainId: ChainId + page: number + pageSize: number + search: string + collectionAddress: string + } + >({ + query: params => ({ + url: `${KRYSTAL_API}/v1/balance/listNftInCollection`, + params, + }), + transformResponse: (data: any) => data?.data, + }), + getNftDetail: builder.query< + NFTTokenDetail, + { + address: string + chainId: ChainId | undefined + tokenID: string + } + >({ + query: params => ({ + url: `${KRYSTAL_API}/v1/nft/getNftDetail`, + params, + }), + transformResponse: (data: any) => data?.data, + }), + getTransactions: builder.query< + TransactionHistoryResponse, + { + walletAddress: string + chainIds?: ChainId[] + limit: number + endTime: number + tokenAddress?: string + tokenSymbol?: string + } + >({ + query: params => ({ + url: `${KRYSTAL_API}/v1/txHistory/getHistory`, + params, + }), + }), + // liquidity + getLiquidityPortfolio: builder.query< + LiquidityDataResponse, + { + addresses: string[] + chainIds?: ChainId[] + quoteSymbols?: string + offset?: number + orderBy?: string + orderASC?: boolean + positionStatus?: string + limit?: number + protocols?: string + q?: string + } + >({ + query: params => ({ + url: `${KRYSTAL_API}/v2/balance/lp`, + params, + }), + }), + }), +}) + +export const { + useGetMyPortfoliosQuery, + useLazyGetMyPortfoliosQuery, + useCreatePortfolioMutation, + useUpdatePortfolioMutation, + useGetRealtimeBalanceQuery, + useLazyGetTokenApprovalQuery, + useGetTransactionsQuery, + useDeletePortfolioMutation, + useAddWalletToPortfolioMutation, + useGetWalletsPortfoliosQuery, + useRemoveWalletFromPortfolioMutation, + useUpdateWalletToPortfolioMutation, + useGetPortfolioByIdQuery, + useClonePortfolioMutation, + useGetPortfoliosSettingsQuery, + useUpdatePortfoliosSettingsMutation, + useGetFavoritesPortfoliosQuery, + useGetTrendingPortfoliosQuery, + useSearchPortfolioQuery, + useToggleFavoritePortfolioMutation, + useGetNftCollectionsQuery, + useGetNftCollectionDetailQuery, + useGetNftDetailQuery, + useGetTokenAllocationQuery, + useGetChainsAllocationQuery, + useGetWalletsAllocationQuery, + useGetLiquidityPortfolioQuery, +} = portfolioApi + +export default portfolioApi diff --git a/src/services/social.ts b/src/services/social.ts index 6fea844576..36ec377094 100644 --- a/src/services/social.ts +++ b/src/services/social.ts @@ -6,6 +6,7 @@ import { BFF_API } from 'constants/env' export enum SHARE_TYPE { KYBER_AI = 'KYBER_AI', MY_EARNINGS = 'MY_EARNINGS', + PORTFOLIO = 'PORTFOLIO', } const SocialApi = createApi({ diff --git a/src/state/index.ts b/src/state/index.ts index 4b605c7c8e..f80400607d 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -15,6 +15,7 @@ import ksSettingApi from 'services/ksSetting' import kyberAISubscriptionApi from 'services/kyberAISubscription' import kyberDAO from 'services/kyberDAO' import limitOrderApi from 'services/limitOrder' +import portfolioApi from 'services/portfolio' import priceAlertApi from 'services/priceAlert' import routeApi from 'services/route' import socialApi from 'services/social' @@ -128,6 +129,7 @@ const store = configureStore({ [blockServiceApi.reducerPath]: blockServiceApi.reducer, [blackjackApi.reducerPath]: blackjackApi.reducer, [knProtocolApi.reducerPath]: knProtocolApi.reducer, + [portfolioApi.reducerPath]: portfolioApi.reducer, }, middleware: getDefaultMiddleware => getDefaultMiddleware({ thunk: true, immutableCheck: false, serializableCheck: false }) @@ -153,6 +155,7 @@ const store = configureStore({ .concat(tokenApi.middleware) .concat(blockServiceApi.middleware) .concat(blackjackApi.middleware) + .concat(portfolioApi.middleware) .concat(knProtocolApi.middleware), preloadedState, }) diff --git a/src/state/transactions/hooks.tsx b/src/state/transactions/hooks.tsx index 456bd85d35..57e8ca874a 100644 --- a/src/state/transactions/hooks.tsx +++ b/src/state/transactions/hooks.tsx @@ -76,22 +76,6 @@ export function useAllTransactions(allChain = false): GroupedTxsByHash | undefin }, [allChain, transactions, chainId, account]) } -export function useSortRecentTransactions(recentOnly = true, allChain = false) { - const allTransactions = useAllTransactions(allChain) - const { account } = useActiveWeb3React() - return useMemo(() => { - const txGroups: TransactionDetails[][] = allTransactions - ? (Object.values(allTransactions).filter(Boolean) as TransactionDetails[][]) - : [] - return txGroups - .filter(txs => { - const isMyGroup = isOwnTransactionGroup(txs, account) - return recentOnly ? isTransactionGroupRecent(txs) && isMyGroup : isMyGroup - }) - .sort(newTransactionsGroupFirst) - }, [allTransactions, recentOnly, account]) -} - export function useIsTransactionPending(transactionHash?: string): boolean { const transactions = useAllTransactions() @@ -104,20 +88,7 @@ export function useIsTransactionPending(transactionHash?: string): boolean { } function isOwnTransactionGroup(txs: TransactionDetails[], account: string | undefined): boolean { - return !!account && txs[0]?.from === account && !!txs[0]?.group -} - -/** - * Returns whether a transaction happened in the last day (86400 seconds * 1000 milliseconds / second) - * @param tx to check for recency - */ -function isTransactionGroupRecent(txs: TransactionDetails[]): boolean { - return new Date().getTime() - (txs[0]?.addedTime ?? 0) < 86_400_000 -} - -// we want the latest one to come first, so return negative if a is after b -function newTransactionsGroupFirst(a: TransactionDetails[], b: TransactionDetails[]) { - return (b[0]?.addedTime ?? 0) - (a[0]?.addedTime ?? 0) + return !!account && txs[0]?.from === account } /** diff --git a/src/state/transactions/reducer.ts b/src/state/transactions/reducer.ts index df7c6e293d..ab2c610a18 100644 --- a/src/state/transactions/reducer.ts +++ b/src/state/transactions/reducer.ts @@ -2,7 +2,6 @@ import { ChainId } from '@kyberswap/ks-sdk-core' import { createReducer } from '@reduxjs/toolkit' import { findTx } from 'utils' -import { getTransactionGroupByType } from 'utils/transaction' import { addTransaction, @@ -55,7 +54,6 @@ export default createReducer(initialState, builder => addedTime: Date.now(), chainId, extraInfo, - group: getTransactionGroupByType(type), }) chainTxs[txs[0].hash] = txs transactions[chainId] = clearOldTransactions(chainTxs) diff --git a/src/state/transactions/type.ts b/src/state/transactions/type.ts index 2554be947c..00ff0ad89d 100644 --- a/src/state/transactions/type.ts +++ b/src/state/transactions/type.ts @@ -69,7 +69,6 @@ export type TransactionExtraInfo = ( | TransactionExtraInfoHarvestFarm | TransactionExtraInfoStakeFarm ) & { - actuallySuccess?: boolean needCheckSubgraph?: boolean arbitrary?: any // To store anything arbitrary, so it has any type } @@ -87,7 +86,6 @@ export interface TransactionDetails { nonce?: number sentAtBlock?: number extraInfo?: TransactionExtraInfo - group: TRANSACTION_GROUP chainId: ChainId } @@ -112,13 +110,6 @@ export type TransactionPayload = TransactionHistory & { chainId: ChainId } -/** - * when you put a new type, let's do: - * 1. classify it by putting it into GROUP_TRANSACTION_BY_TYPE - * 2. add a case in SUMMARY in TransactionPopup.tsx to render notification detail by type - * 3. add a case in RENDER_DESCRIPTION_MAP in TransactionItem.tsx to render transaction detail by type - * if you forgot. typescript error will occur. - */ export enum TRANSACTION_TYPE { WRAP_TOKEN = 'Wrap Token', UNWRAP_TOKEN = 'Unwrap Token', @@ -158,60 +149,3 @@ export enum TRANSACTION_TYPE { CANCEL_LIMIT_ORDER = 'Cancel Limit Order', TRANSFER_TOKEN = 'Send', } - -export const GROUP_TRANSACTION_BY_TYPE = { - SWAP: [ - TRANSACTION_TYPE.SWAP, - TRANSACTION_TYPE.WRAP_TOKEN, - TRANSACTION_TYPE.UNWRAP_TOKEN, - TRANSACTION_TYPE.CROSS_CHAIN_SWAP, - ], - LIQUIDITY: [ - TRANSACTION_TYPE.CLASSIC_ADD_LIQUIDITY, - TRANSACTION_TYPE.CLASSIC_CREATE_POOL, - TRANSACTION_TYPE.ELASTIC_CREATE_POOL, - TRANSACTION_TYPE.ELASTIC_ADD_LIQUIDITY, - TRANSACTION_TYPE.CLASSIC_REMOVE_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_REMOVE_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_INCREASE_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_ZAP_IN_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_DEPOSIT_LIQUIDITY, - TRANSACTION_TYPE.ELASTIC_WITHDRAW_LIQUIDITY, - TRANSACTION_TYPE.STAKE, - TRANSACTION_TYPE.UNSTAKE, - TRANSACTION_TYPE.HARVEST, - TRANSACTION_TYPE.ELASTIC_COLLECT_FEE, - TRANSACTION_TYPE.ELASTIC_FORCE_WITHDRAW_LIQUIDITY, - ], - KYBERDAO: [ - TRANSACTION_TYPE.KYBERDAO_STAKE, - TRANSACTION_TYPE.KYBERDAO_UNSTAKE, - TRANSACTION_TYPE.KYBERDAO_DELEGATE, - TRANSACTION_TYPE.KYBERDAO_UNDELEGATE, - TRANSACTION_TYPE.KYBERDAO_MIGRATE, - TRANSACTION_TYPE.KYBERDAO_VOTE, - TRANSACTION_TYPE.KYBERDAO_CLAIM, - TRANSACTION_TYPE.KYBERDAO_CLAIM_GAS_REFUND, - ], - OTHER: [ - // to make sure you don't forgot - TRANSACTION_TYPE.APPROVE, - TRANSACTION_TYPE.CLAIM_REWARD, - TRANSACTION_TYPE.BRIDGE, - TRANSACTION_TYPE.CANCEL_LIMIT_ORDER, - TRANSACTION_TYPE.TRANSFER_TOKEN, - ], -} - -export enum TRANSACTION_GROUP { - SWAP = 'swap', - LIQUIDITY = 'liquidity', - KYBERDAO = 'kyber_dao', - OTHER = 'other', -} - -const totalType = Object.values(TRANSACTION_TYPE).length -const totalClassify = Object.values(GROUP_TRANSACTION_BY_TYPE).reduce((total, element) => total + element.length, 0) -if (totalType !== totalClassify) { - throw new Error('Please set up group of the new transaction. Put your new type into GROUP_TRANSACTION_BY_TYPE') -} diff --git a/src/utils/formatBalance.ts b/src/utils/formatBalance.ts index f809e0f97e..cf364355e5 100644 --- a/src/utils/formatBalance.ts +++ b/src/utils/formatBalance.ts @@ -1,5 +1,6 @@ import { BigNumber } from '@ethersproject/bignumber' import { Fraction } from '@kyberswap/ks-sdk-core' +import { BigNumberish } from 'ethers' import { formatUnits } from 'ethers/lib/utils' import JSBI from 'jsbi' import Numeral from 'numeral' @@ -81,6 +82,6 @@ export const fixedFormatting = (value: BigNumber, decimals: number) => { return parseFloat(res).toString() } -export const formatUnitsToFixed = (amount: BigNumber, decimals?: number, decimalPlaces?: number) => { +export const formatUnitsToFixed = (amount: BigNumberish, decimals?: number, decimalPlaces?: number) => { return (+(+formatUnits(amount, decimals)).toFixed(decimalPlaces ?? 3)).toString() } diff --git a/src/utils/redirect.ts b/src/utils/redirect.ts index d472a54b66..551dff0fb6 100644 --- a/src/utils/redirect.ts +++ b/src/utils/redirect.ts @@ -1,9 +1,12 @@ -import { ChainId } from '@kyberswap/ks-sdk-core' +import { ChainId, WETH } from '@kyberswap/ks-sdk-core' import { useCallback } from 'react' import { useNavigate } from 'react-router-dom' +import { APP_PATHS } from 'constants/index' import { useActiveWeb3React } from 'hooks' +import { NETWORKS_INFO } from 'hooks/useChainsConfig' import { useChangeNetwork } from 'hooks/web3/useChangeNetwork' +import { getChainIdFromSlug } from 'utils/string' const whiteListDomains = [/https:\/\/(.+?\.)?kyberswap\.com$/, /https:\/\/(.+)\.kyberengineering\.io$/] @@ -74,3 +77,14 @@ export const useNavigateToUrl = () => { [changeNetwork, currentChain, redirect], ) } + +export const navigateToSwapPage = ({ address, chain }: { address?: string; chain?: string | number }) => { + if (!address || !chain) return + const chainId: ChainId | undefined = !isNaN(+chain) ? +chain : getChainIdFromSlug(chain as string) + if (!chainId) return + window.open( + window.location.origin + + `${APP_PATHS.SWAP}/${NETWORKS_INFO[chainId].route}?inputCurrency=${WETH[chainId].address}&outputCurrency=${address}`, + '_blank', + ) +} diff --git a/src/utils/string.ts b/src/utils/string.ts index 3e0f7d6358..98eb447d28 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -42,10 +42,17 @@ export const isEmailValid = (value: string | undefined) => (value || '').trim().match(/^\w+([\.-]?\w)*@\w+([\.-]?\w)*(\.\w{2,10})+$/) export const getChainIdFromSlug = (network: string | undefined): ChainId | undefined => { - return SUPPORTED_NETWORKS.find(chainId => NETWORKS_INFO[chainId].route === network) + return network === 'bsc' + ? ChainId.BSCMAINNET + : SUPPORTED_NETWORKS.find(chainId => NETWORKS_INFO[chainId].route === network) } export function capitalizeFirstLetter(str?: string) { const string = str || '' return string.charAt(0).toUpperCase() + string.slice(1) } + +export function isULIDString(str = '') { + const ulidPattern = /[0-7][0-9A-HJKMNP-TV-Z]{25}/ + return ulidPattern.test(str) +} diff --git a/src/utils/transaction.ts b/src/utils/transaction.ts index dc47bc3e2b..dd5d5909dd 100644 --- a/src/utils/transaction.ts +++ b/src/utils/transaction.ts @@ -1,18 +1,6 @@ import { ethers } from 'ethers' -import { - GROUP_TRANSACTION_BY_TYPE, - TRANSACTION_GROUP, - TRANSACTION_TYPE, - TransactionDetails, -} from 'state/transactions/type' - -export const getTransactionGroupByType = (type: TRANSACTION_TYPE) => { - if (GROUP_TRANSACTION_BY_TYPE.SWAP.includes(type)) return TRANSACTION_GROUP.SWAP - if (GROUP_TRANSACTION_BY_TYPE.LIQUIDITY.includes(type)) return TRANSACTION_GROUP.LIQUIDITY - if (GROUP_TRANSACTION_BY_TYPE.KYBERDAO.includes(type)) return TRANSACTION_GROUP.KYBERDAO - return TRANSACTION_GROUP.OTHER -} +import { TransactionDetails } from 'state/transactions/type' export const getTransactionStatus = (transaction: TransactionDetails) => { const pending = !transaction?.receipt diff --git a/src/utils/useEstimateGasTxs.ts b/src/utils/useEstimateGasTxs.ts index 02a8d57d82..f1afb46702 100644 --- a/src/utils/useEstimateGasTxs.ts +++ b/src/utils/useEstimateGasTxs.ts @@ -1,12 +1,19 @@ import { WETH } from '@kyberswap/ks-sdk-core' import { BigNumber, ethers } from 'ethers' -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useActiveWeb3React, useWeb3React } from 'hooks' import { useTokenPrices } from 'state/tokenPrices/hooks' -type EstimateParams = { contractAddress: string; encodedData: string; value?: BigNumber } -function useEstimateGasTxs(): (v: EstimateParams) => Promise<{ gas: BigNumber | null; gasInUsd: number | null }> { +type EstimateParams = { + contractAddress?: string + encodedData?: string + value?: BigNumber + estimateGasFn?: () => Promise +} +export function useLazyEstimateGasTxs(): ( + v: EstimateParams, +) => Promise<{ gas: BigNumber | null; gasInUsd: number | null }> { const { account, chainId } = useActiveWeb3React() const { library } = useWeb3React() @@ -15,24 +22,20 @@ function useEstimateGasTxs(): (v: EstimateParams) => Promise<{ gas: BigNumber | const usdPriceNative = tokensPrices[WETH[chainId].wrapped.address] ?? 0 return useCallback( - async ({ contractAddress, encodedData, value = BigNumber.from(0) }: EstimateParams) => { - const estimateGasOption = { - from: account, - to: contractAddress, - data: encodedData, - value, - } + async ({ contractAddress, encodedData, value = BigNumber.from(0), estimateGasFn }: EstimateParams) => { let formatGas: number | null = null let gas: BigNumber | null = null try { - if (!account || !library) throw new Error() - const [estimateGas, gasPrice] = await Promise.all([ - library.getSigner().estimateGas(estimateGasOption), - library.getSigner().getGasPrice(), - ]) + if (!account || !library || (!estimateGasFn && !contractAddress)) throw new Error() + const getGasFee = estimateGasFn + ? estimateGasFn() + : library.getSigner().estimateGas({ from: account, to: contractAddress, data: encodedData, value }) + const [estimateGas, gasPrice] = await Promise.all([getGasFee, library.getSigner().getGasPrice()]) gas = gasPrice && estimateGas ? estimateGas.mul(gasPrice) : null formatGas = gas ? parseFloat(ethers.utils.formatEther(gas)) : null - } catch (error) {} + } catch (error) { + console.log('estimate gas err:', error) + } return { gas, @@ -42,4 +45,30 @@ function useEstimateGasTxs(): (v: EstimateParams) => Promise<{ gas: BigNumber | [account, library, usdPriceNative], ) } + +function useEstimateGasTxs({ contractAddress, value, encodedData, estimateGasFn }: EstimateParams) { + const [gasInfo, setGasInfo] = useState<{ gas: BigNumber | null; gasInUsd: number | null }>({ + gas: null, + gasInUsd: null, + }) + + const estimateGas = useLazyEstimateGasTxs() + + const params = useMemo(() => { + return { contractAddress, value, encodedData, estimateGasFn } + }, [contractAddress, value, encodedData, estimateGasFn]) + + useEffect(() => { + const controller = new AbortController() + const getGasFee = async () => { + const data = await estimateGas(params) + if (controller.signal.aborted) return + setGasInfo(data) + } + getGasFee() + return () => controller.abort() + }, [params, estimateGas]) + + return gasInfo +} export default useEstimateGasTxs
{header}KyberScorePrice24H
{header} + {col.label} +