diff --git a/src/appConstants/apiFields.ts b/src/appConstants/apiFields.ts index 2f760344f..f67264a97 100644 --- a/src/appConstants/apiFields.ts +++ b/src/appConstants/apiFields.ts @@ -46,3 +46,19 @@ export const NODE_STATUS_PREVIEW_FIELDS = [ 'auctionQualified', 'isInDangerZone' ]; + +export const ACCOUNT_TOKENS_FIELDS = [ + 'type', + 'identifier', + 'collection', + 'name', + 'ticker', + 'decimals', + 'assets', + 'price', + 'totalLiquidity', + 'isLowLiquidity', + 'lowLiquidityThresholdPercent', + 'balance', + 'valueUsd' +]; diff --git a/src/appConstants/index.ts b/src/appConstants/index.ts index 1c9b287af..50cf0f4b0 100644 --- a/src/appConstants/index.ts +++ b/src/appConstants/index.ts @@ -27,6 +27,7 @@ export const AUCTION_LIST_MIN_DISPLAY_ROW_COUNT = 6; export const LEGACY_DELEGATION_NODES_IDENTITY = 'multiversx'; export const HEROTAG_SUFFIX = '.elrond'; export const TEMP_LOCAL_NOTIFICATION_DISMISSED = 'tempNotificationDismissed2'; +export const CUSTOM_NETWORK_ID = 'custom-network'; export const NEW_VERSION_NOTIFICATION = 'newExplorerVersion'; export const SC_INIT_CHARACTERS_LENGTH = 13; diff --git a/src/assets/scss/_shared-styles.scss b/src/assets/scss/_shared-styles.scss index 0f200afbf..6922cb874 100644 --- a/src/assets/scss/_shared-styles.scss +++ b/src/assets/scss/_shared-styles.scss @@ -32,6 +32,7 @@ border-bottom-right-radius: $input-border-radius-lg !important; } } + &.input-group-search { width: auto; .form-control { @@ -55,6 +56,11 @@ border-top-right-radius: $input-border-radius-sm !important; border-bottom-right-radius: $input-border-radius-sm !important; } + &.has-validation { + .input-group-text { + height: 2.063rem; + } + } } &.has-validation { .input-group-text { diff --git a/src/assets/scss/components/_components.scss b/src/assets/scss/components/_components.scss index 6bc45f1e3..b8c464f60 100644 --- a/src/assets/scss/components/_components.scss +++ b/src/assets/scss/components/_components.scss @@ -2,6 +2,7 @@ @import '../../../components/BlocksTable/blocksTable.styles.scss'; @import '../../../components/Cards/cards.styles.scss'; @import '../../../components/Chart/chart.styles.scss'; +@import '../../../components/CustomNetwork/customNetwork.styles.scss'; @import '../../../components/DataDecode/dataDecode.styles.scss'; @import '../../../components/DetailItem/detailItem.styles.scss'; @import '../../../components/ExpandRow/expandRow.styles.scss'; diff --git a/src/assets/scss/components/_pages.scss b/src/assets/scss/components/_pages.scss index 25af80445..f71135710 100644 --- a/src/assets/scss/components/_pages.scss +++ b/src/assets/scss/components/_pages.scss @@ -1,4 +1,5 @@ @import '../../../pages/AccountDetails/AccountStaking/accountStaking.styles.scss'; +@import '../../../pages/AccountDetails/AccountTokensTable/accountTokensTable.styles.scss'; @import '../../../pages/Analytics/analytics.styles.scss'; @import '../../../pages/BlockDetails/blockDetails.styles.scss'; @import '../../../pages/Identities/identities.styles.scss'; diff --git a/src/assets/scss/elements/_badges.scss b/src/assets/scss/elements/_badges.scss index 86e556808..870696a31 100644 --- a/src/assets/scss/elements/_badges.scss +++ b/src/assets/scss/elements/_badges.scss @@ -144,6 +144,12 @@ button.badge { rgba(215, 221, 232, 0.3) ); } + &.active { + @extend .badge-grey; + &:before { + display: none; + } + } } &-primary { color: var(--primary); diff --git a/src/components/CollapsibleArrows/CollapsibleArrows.tsx b/src/components/CollapsibleArrows/CollapsibleArrows.tsx index 936656235..325bef04c 100644 --- a/src/components/CollapsibleArrows/CollapsibleArrows.tsx +++ b/src/components/CollapsibleArrows/CollapsibleArrows.tsx @@ -15,7 +15,7 @@ export const CollapsibleArrows = ({ }: CollapsibleArrowsPropsType) => { return ( diff --git a/src/components/CustomNetwork/CustomNetworkDetails.tsx b/src/components/CustomNetwork/CustomNetworkDetails.tsx new file mode 100644 index 000000000..2e808e47a --- /dev/null +++ b/src/components/CustomNetwork/CustomNetworkDetails.tsx @@ -0,0 +1,162 @@ +import React, { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import classNames from 'classnames'; +import { Collapse } from 'react-bootstrap'; +import { useSelector } from 'react-redux'; + +import { CUSTOM_NETWORK_ID } from 'appConstants'; +import { CollapsibleArrows, CopyButton } from 'components'; +import { networks } from 'config'; +import { storage, scrollToElement } from 'helpers'; +import { useGetNetworkChangeLink } from 'hooks'; +import { faTrash, faCheck } from 'icons/regular'; +import { activeNetworkSelector } from 'redux/selectors'; +import { WithClassnameType } from 'types'; + +export interface NetworkDetailUIType { + title: string; + description: React.ReactNode; +} + +const NetworkDetail = ({ title, description }: NetworkDetailUIType) => { + return ( +
+
{title}:
+
{description}
+
+ ); +}; + +export const CustomNetworkDetails = ({ className }: WithClassnameType) => { + const getNetworkChangeLink = useGetNetworkChangeLink(); + const activeNetwork = useSelector(activeNetworkSelector); + const { isCustom: activeNetworkIsCustom } = activeNetwork; + + const configCustomNetwork = networks.filter((network) => network.isCustom)[0]; + const existingCustomNetwork = activeNetworkIsCustom + ? activeNetwork + : configCustomNetwork; + + const [open, setOpen] = useState(false); + + const isSavedCustomNetworkActive = + configCustomNetwork?.id === activeNetwork?.id; + const defaultNetwork = networks.find((network) => Boolean(network.default)); + const defaultNetworkId = defaultNetwork?.id ?? networks[0]?.id; + + const removeNetwork = () => { + storage.removeFromLocal(CUSTOM_NETWORK_ID); + window.location.href = getNetworkChangeLink({ + networkId: defaultNetworkId + }); + }; + + const applyNetwork = () => { + window.location.href = getNetworkChangeLink({ + networkId: CUSTOM_NETWORK_ID + }); + }; + + if (!existingCustomNetwork) { + return null; + } + + return ( +
+ + { + scrollToElement('.custom-network-details', 50); + }} + > +
+
+ {!isSavedCustomNetworkActive && ( + <> +
Saved Custom Network Config
+ false} + /> + + )} + {existingCustomNetwork.name && ( + + )} + {existingCustomNetwork.apiAddress && ( + + {existingCustomNetwork.apiAddress}{' '} + +
+ } + /> + )} + {existingCustomNetwork.chainId && ( + + {existingCustomNetwork.chainId} + + } + /> + )} + {existingCustomNetwork.egldLabel && ( + + )} +
+ {!isSavedCustomNetworkActive && ( + + )} + +
+
+
+ + + ); +}; diff --git a/src/components/CustomNetwork/CustomNetworkInput.tsx b/src/components/CustomNetwork/CustomNetworkInput.tsx new file mode 100644 index 000000000..4387ac2b5 --- /dev/null +++ b/src/components/CustomNetwork/CustomNetworkInput.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import classNames from 'classnames'; +import { useSelector } from 'react-redux'; + +import { networks } from 'config'; +import { useCustomNetwork } from 'hooks'; +import { faCircleNotch } from 'icons/regular'; +import { faCheck } from 'icons/solid'; +import { activeNetworkSelector } from 'redux/selectors'; +import { WithClassnameType } from 'types'; + +export const CustomNetworkInput = ({ className }: WithClassnameType) => { + const activeNetwork = useSelector(activeNetworkSelector); + const { isCustom: activeNetworkIsCustom } = activeNetwork; + + const configCustomNetwork = networks.filter((network) => network.isCustom)[0]; + const existingCustomNetwork = activeNetworkIsCustom + ? activeNetwork + : configCustomNetwork; + + const [customNetworkUrl, setcustomNetworkUrl] = useState( + existingCustomNetwork?.apiAddress ?? '' + ); + const [generalError, setGeneralError] = useState(''); + const { setCustomNetwork, isSaving, errors } = + useCustomNetwork(customNetworkUrl); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + setCustomNetwork(); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + setGeneralError(''); + setcustomNetworkUrl(e.target.value); + }; + + useEffect(() => { + if (errors?.apiAddress) { + setGeneralError(errors.apiAddress); + } + }, [errors]); + + return ( +
+
+ + + {generalError && ( +
{generalError}
+ )} +
+
+ ); +}; diff --git a/src/components/CustomNetwork/CustomNetworkMenu.tsx b/src/components/CustomNetwork/CustomNetworkMenu.tsx new file mode 100644 index 000000000..295e7e8cd --- /dev/null +++ b/src/components/CustomNetwork/CustomNetworkMenu.tsx @@ -0,0 +1,25 @@ +import { forwardRef } from 'react'; +import classNames from 'classnames'; +import { Dropdown } from 'react-bootstrap'; + +import { CustomNetworkInput, CustomNetworkDetails } from 'components'; + +export const CustomNetworkMenu = forwardRef( + ({ children, className, style }: any, ref: any) => { + return ( +
+ {children} + +
+ Custom Network API Address + + +
+
+ ); + } +); diff --git a/src/components/CustomNetwork/customNetwork.styles.scss b/src/components/CustomNetwork/customNetwork.styles.scss new file mode 100644 index 000000000..02c9d4632 --- /dev/null +++ b/src/components/CustomNetwork/customNetwork.styles.scss @@ -0,0 +1,9 @@ +.custom-network-menu { + width: 15rem; + --dropdown-divider-bg: var(--neutral-700); + .input-group-seamless { + .form-control { + padding-right: 2.5rem; + } + } +} diff --git a/src/components/CustomNetwork/index.ts b/src/components/CustomNetwork/index.ts new file mode 100644 index 000000000..509059c09 --- /dev/null +++ b/src/components/CustomNetwork/index.ts @@ -0,0 +1,3 @@ +export * from './CustomNetworkDetails'; +export * from './CustomNetworkInput'; +export * from './CustomNetworkMenu'; diff --git a/src/components/Filters/TableSearch.tsx b/src/components/Filters/TableSearch.tsx index 3440bbe38..e76da7556 100644 --- a/src/components/Filters/TableSearch.tsx +++ b/src/components/Filters/TableSearch.tsx @@ -61,7 +61,8 @@ export const TableSearch = ({
diff --git a/src/components/FormatValue/FormatDisplayValue/FormatDisplayValue.tsx b/src/components/FormatValue/FormatDisplayValue/FormatDisplayValue.tsx index 4fe5343dd..5f3315bfb 100644 --- a/src/components/FormatValue/FormatDisplayValue/FormatDisplayValue.tsx +++ b/src/components/FormatValue/FormatDisplayValue/FormatDisplayValue.tsx @@ -11,6 +11,7 @@ export interface FormatDisplayValueUIType symbol?: React.ReactNode; label?: React.ReactNode; details?: React.ReactNode; + hideLessThanOne?: boolean; showTooltipSymbol?: boolean; showTooltipLabel?: boolean; spacedLabel?: boolean; @@ -26,14 +27,15 @@ export const FormatDisplayValue = (props: FormatDisplayValueUIType) => { egldLabel, details, digits = DIGITS, - showLastNonZeroDecimal = false, + showLastNonZeroDecimal, + hideLessThanOne, showLabel = true, showTooltip = true, - showSymbol = false, - superSuffix = false, - showTooltipSymbol = false, - showTooltipLabel = false, - spacedLabel = false, + showSymbol, + superSuffix, + showTooltipSymbol, + showTooltipLabel, + spacedLabel, decimalOpacity = true, className } = props; @@ -43,6 +45,9 @@ export const FormatDisplayValue = (props: FormatDisplayValueUIType) => { const displayLabel = label ?? (token ? token : egldLabel); const DisplayValue = () => { + if (hideLessThanOne) { + return {'< 1'}; + } const completeValueParts = String(completeValue).split('.'); const decimalArray = completeValueParts?.[1]?.split('') ?? []; const areAllDigitsZeroes = decimalArray.every((digit) => digit === ZERO); diff --git a/src/components/FormatValue/FormatNumber/FormatNumber.tsx b/src/components/FormatValue/FormatNumber/FormatNumber.tsx index 50b4d935e..8f7de7c24 100644 --- a/src/components/FormatValue/FormatNumber/FormatNumber.tsx +++ b/src/components/FormatValue/FormatNumber/FormatNumber.tsx @@ -33,14 +33,10 @@ export const FormatNumber = (props: FormatNumberUIType) => { ); } - let formattedValue = bNamount.isInteger() + const formattedValue = bNamount.isInteger() ? completeValue : formatBigNumber({ value: bNamount, maxDigits }); - if (hideLessThanOne && bNamount.isLessThan(1)) { - formattedValue = '< 1'; - } - return ( { completeValue={completeValue} symbol={symbol} egldLabel={label} + hideLessThanOne={hideLessThanOne && bNamount.isLessThan(1)} showSymbol={Boolean(symbol)} showLabel={Boolean(label)} showTooltipSymbol={Boolean(symbol)} diff --git a/src/components/LowLiquidityTooltip/LowLiquidityTooltip.tsx b/src/components/LowLiquidityTooltip/LowLiquidityTooltip.tsx index cff25e9bf..4cd7c6ea0 100644 --- a/src/components/LowLiquidityTooltip/LowLiquidityTooltip.tsx +++ b/src/components/LowLiquidityTooltip/LowLiquidityTooltip.tsx @@ -1,4 +1,5 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import BigNumber from 'bignumber.js'; import classNames from 'classnames'; import { FormatUSD, Overlay } from 'components'; import { faSquareInfo } from 'icons/solid'; @@ -18,17 +19,23 @@ export const LowLiquidityTooltip = ({ return null; } - const { totalLiquidity, isLowLiquidity } = token; + const { totalLiquidity, isLowLiquidity, lowLiquidityThresholdPercent } = + token; + if (!isLowLiquidity) { return null; } + const displayTresholdPercent = new BigNumber( + lowLiquidityThresholdPercent ?? 0.5 + ).toFormat(); + return ( - Less than 0.5% of total Token Supply captured in xExchange Liquidity - Pools. + Less than {displayTresholdPercent}% of total Token Supply captured in + xExchange Liquidity Pools. {showTotalLiquidity && totalLiquidity && ( <> () diff --git a/src/components/ProvidersTable/ProvidersTable.tsx b/src/components/ProvidersTable/ProvidersTable.tsx index 57ca9ed3c..1e3823b3b 100644 --- a/src/components/ProvidersTable/ProvidersTable.tsx +++ b/src/components/ProvidersTable/ProvidersTable.tsx @@ -18,21 +18,21 @@ export const ProvidersTable = (props: ProvidersTableUIType) => { const { providers, showIndex = true, showIdentity = true } = props; const [displayProviders, setDisplayProviders] = useState(providers); - const sort = useGetSort(); + const { sort, order } = useGetSort(); useEffect(() => { - if (sort.sort && sort.order) { + if (sort && order) { setDisplayProviders((existing) => sortProviders({ - field: sort.sort as SortProviderFieldEnum, - order: sort.order, + field: sort as SortProviderFieldEnum, + order: order, sortArray: [...existing] }) ); } else { setDisplayProviders(providers); } - }, [sort.sort, sort.order]); + }, [sort, order]); return (
{ className='detail-card' /> - + {walletAddress && ( + + )}
diff --git a/src/components/index.ts b/src/components/index.ts index 74b916654..0b65de469 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,6 +10,7 @@ export * from './Cards'; export * from './CollapsibleArrows'; export * from './CollectionBlock'; export * from './CopyButton'; +export * from './CustomNetwork'; export * from './DataDecode'; export * from './DetailItem'; export * from './ExpandRow'; diff --git a/src/config/config.devnet.ts b/src/config/config.devnet.ts index a119d982b..953305006 100644 --- a/src/config/config.devnet.ts +++ b/src/config/config.devnet.ts @@ -1,7 +1,11 @@ import { NetworkType } from 'types/network.types'; + +import { getStorageCustomNetworks } from './helpers'; import { allApps, schema } from './sharedConfig'; export * from './sharedConfig'; +export const hasExtraNetworks = true; + export const networks: NetworkType[] = [ { default: true, @@ -15,7 +19,10 @@ export const networks: NetworkType[] = [ explorerAddress: 'https://devnet-explorer.multiversx.com', nftExplorerAddress: 'https://devnet.xspotlight.com', apiAddress: 'https://devnet-api.multiversx.com' - } + }, + + // Saved Custom Network Configs + ...getStorageCustomNetworks() ]; export const multiversxApps = allApps([ diff --git a/src/config/config.mainnet.ts b/src/config/config.mainnet.ts index a689a950c..5ee63208a 100644 --- a/src/config/config.mainnet.ts +++ b/src/config/config.mainnet.ts @@ -1,4 +1,6 @@ import { NetworkType } from 'types/network.types'; + +import { getStorageCustomNetworks } from './helpers'; import { allApps, schema } from './sharedConfig'; export * from './sharedConfig'; @@ -16,7 +18,10 @@ export const networks: NetworkType[] = [ nftExplorerAddress: 'https://xspotlight.com', apiAddress: 'https://api.multiversx.com', growthApi: 'https://tools.multiversx.com/growth-api' - } + }, + + // Saved Custom Network Configs + ...getStorageCustomNetworks() ]; export const multiversxApps = allApps(); diff --git a/src/config/config.multiple.ts b/src/config/config.multiple.ts index a33b3681b..8163decc5 100644 --- a/src/config/config.multiple.ts +++ b/src/config/config.multiple.ts @@ -1,9 +1,15 @@ import { NetworkType } from 'types/network.types'; -import { getInternalNetworks, getInternalLinks } from './helpers'; +import { + getInternalNetworks, + getStorageCustomNetworks, + getInternalLinks +} from './helpers'; import { allApps, schema } from './sharedConfig'; export * from './sharedConfig'; +export const hasExtraNetworks = true; + export const networks: NetworkType[] = [ { default: true, @@ -32,7 +38,10 @@ export const networks: NetworkType[] = [ }, // Internal Testnets - ...getInternalNetworks() + ...getInternalNetworks(), + + // Saved Custom Network Configs + ...getStorageCustomNetworks() ]; export const links = getInternalLinks(networks); diff --git a/src/config/config.testnet.ts b/src/config/config.testnet.ts index 7e74adad2..c02021642 100644 --- a/src/config/config.testnet.ts +++ b/src/config/config.testnet.ts @@ -1,7 +1,11 @@ import { NetworkType } from 'types/network.types'; + +import { getStorageCustomNetworks } from './helpers'; import { allApps, schema } from './sharedConfig'; export * from './sharedConfig'; +export const hasExtraNetworks = true; + export const networks: NetworkType[] = [ { default: true, @@ -15,7 +19,10 @@ export const networks: NetworkType[] = [ explorerAddress: 'https://testnet-explorer.multiversx.com', nftExplorerAddress: 'https://testnet.xspotlight.com', apiAddress: 'https://testnet-api.multiversx.com' - } + }, + + // Saved Custom Network Configs + ...getStorageCustomNetworks() ]; export const multiversxApps = allApps([ diff --git a/src/config/helpers/getInternalLinks.ts b/src/config/helpers/getInternalLinks.ts index cc681d669..8b226289e 100644 --- a/src/config/helpers/getInternalLinks.ts +++ b/src/config/helpers/getInternalLinks.ts @@ -1,3 +1,4 @@ +import { DEFAULT_HOSTNAME } from 'config'; import { NetworkType, NetworkUrlType } from 'types/network.types'; export const getInternalLinks = (networks: NetworkType[]): NetworkUrlType[] => { @@ -6,12 +7,12 @@ export const getInternalLinks = (networks: NetworkType[]): NetworkUrlType[] => { process.env.VITE_APP_SHARE_PREFIX === 'internal-' ) { const internalLinks = networks - .filter(({ id, name }) => id && name) + .filter(({ id, name, isCustom }) => id && name && !isCustom) .map(({ id = '', name = '' }) => { return { id, name, - url: `https://${id}.${process.env.VITE_APP_SHARE_PREFIX}explorer.multiversx.com` + url: `https://${id}.${process.env.VITE_APP_SHARE_PREFIX}${DEFAULT_HOSTNAME}` }; }); diff --git a/src/config/helpers/getInternalNetworks.ts b/src/config/helpers/getInternalNetworks.ts index c2756d844..e68ba05ee 100644 --- a/src/config/helpers/getInternalNetworks.ts +++ b/src/config/helpers/getInternalNetworks.ts @@ -1,4 +1,4 @@ -import { NetworkType } from 'types/network.types'; +import { NetworkAdapterEnum, NetworkType } from 'types'; export const getInternalNetworks = (): NetworkType[] => { if (process.env.VITE_APP_INTERNAL_NETWORKS) { @@ -12,7 +12,7 @@ export const getInternalNetworks = (): NetworkType[] => { return parsedNetworks.map((network: NetworkType) => { return { ...network, - ...(!network?.adapter ? { adapter: 'api' } : {}), + ...(!network?.adapter ? { adapter: NetworkAdapterEnum.api } : {}), ...(!network?.egldLabel ? { egldLabel: 'xEGLD' } : {}), ...(!network?.chainId ? { chainId: 'T' } : {}) }; diff --git a/src/config/helpers/getStorageCustomNetworks.ts b/src/config/helpers/getStorageCustomNetworks.ts new file mode 100644 index 000000000..3d0e8878a --- /dev/null +++ b/src/config/helpers/getStorageCustomNetworks.ts @@ -0,0 +1,53 @@ +import moment from 'moment'; + +import { CUSTOM_NETWORK_ID } from 'appConstants'; +import { hasExtraNetworks } from 'config'; +import { cookie } from 'helpers/cookie'; +import { storage } from 'helpers/storage'; +import { NetworkAdapterEnum, NetworkType } from 'types'; + +export const getStorageCustomNetworks = (): NetworkType[] => { + if (!hasExtraNetworks) { + return []; + } + + try { + const cookieNetworks = cookie.getFromCookies(CUSTOM_NETWORK_ID); + + // change custom network across sub-subdomains + if (cookieNetworks) { + try { + const parsedCookieNetworks = JSON.parse(cookieNetworks); + if (parsedCookieNetworks && parsedCookieNetworks.length > 0) { + const in30Days = new Date(moment().add(30, 'days').toDate()); + storage.saveToLocal({ + key: CUSTOM_NETWORK_ID, + data: JSON.stringify(parsedCookieNetworks), + expirationDate: in30Days + }); + + cookie.removeFromCookies(CUSTOM_NETWORK_ID); + } + } catch {} + } + + const storageNetworks = storage.getFromLocal(CUSTOM_NETWORK_ID); + const parsedNetworks = JSON.parse(storageNetworks); + + if (parsedNetworks && parsedNetworks.length > 0) { + return parsedNetworks.map((network: NetworkType) => { + return { + ...network, + ...(!network?.adapter ? { adapter: NetworkAdapterEnum.api } : {}), + ...(!network?.egldLabel ? { egldLabel: 'xEGLD' } : {}), + ...(!network?.chainId ? { chainId: 'T' } : {}), + isCustom: true + }; + }); + } + } catch { + return []; + } + + return []; +}; diff --git a/src/config/helpers/index.ts b/src/config/helpers/index.ts index 0aa015e6e..8cb7c6a5e 100644 --- a/src/config/helpers/index.ts +++ b/src/config/helpers/index.ts @@ -1,2 +1,3 @@ export * from './getInternalLinks'; export * from './getInternalNetworks'; +export * from './getStorageCustomNetworks'; diff --git a/src/config/sharedConfig.ts b/src/config/sharedConfig.ts index 83c36a81f..bddd0b56d 100644 --- a/src/config/sharedConfig.ts +++ b/src/config/sharedConfig.ts @@ -31,6 +31,11 @@ export const SHARE_PREFIX = process.env.VITE_APP_SHARE_PREFIX ? process.env.VITE_APP_SHARE_PREFIX.replace('-', '') : ''; +export const DEFAULT_HOSTNAME = + process.env.VITE_APP_DEFAULT_HOSTNAME ?? 'explorer.multiversx.com'; + +export const hasExtraNetworks = false; + export const links: NetworkUrlType[] = [ { id: 'mainnet', diff --git a/src/helpers/cookie.ts b/src/helpers/cookie.ts new file mode 100644 index 000000000..5cbefd48b --- /dev/null +++ b/src/helpers/cookie.ts @@ -0,0 +1,38 @@ +import { CUSTOM_NETWORK_ID } from 'appConstants'; +import { DEFAULT_HOSTNAME } from 'config/sharedConfig'; + +type KeyType = typeof CUSTOM_NETWORK_ID; + +const domain = `domain=.${process.env.VITE_APP_SHARE_PREFIX}${DEFAULT_HOSTNAME}`; + +export const cookie = { + saveToCookies: ({ + key, + data, + expirationDate + }: { + key: KeyType; + data: string; + expirationDate: Date; + }) => { + const expires = `expires=${expirationDate.toUTCString()}`; + document.cookie = `${key}=${data}; ${expires}; ${domain}; path=/;`; + }, + getFromCookies: (key: KeyType) => { + const name = key + '='; + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + let c = cookies[i]; + while (c.charAt(0) == ' ') { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return ''; + }, + removeFromCookies: (key: KeyType) => { + document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; ${domain}; path=/;`; + } +}; diff --git a/src/helpers/getValue/getItemsPage.ts b/src/helpers/getValue/getItemsPage.ts new file mode 100644 index 000000000..822ce3022 --- /dev/null +++ b/src/helpers/getValue/getItemsPage.ts @@ -0,0 +1,48 @@ +import BigNumber from 'bignumber.js'; + +interface GetItemsPageType { + currentPage: number; + itemsPerPage: number; + items: any[]; +} + +export const getItemsPage = ({ + items, + currentPage, + itemsPerPage +}: GetItemsPageType) => { + const itemsPerPageBigNumber = new BigNumber(itemsPerPage); + const currentPageBigNumber = new BigNumber(currentPage); + const itemsLengthBigNumber = new BigNumber(items.length); + + const totalPages = Math.ceil( + itemsLengthBigNumber.dividedBy(itemsPerPage).toNumber() + ); + + const totalPagesArray = Array.from({ length: totalPages }); + const ranges = totalPagesArray.map((_, index) => [ + itemsPerPageBigNumber.times(index), + itemsPerPageBigNumber.times(new BigNumber(index).plus(1)) + ]); + + const rangesLengthBigNumber = new BigNumber(ranges.length); + const currentRange = ranges.find((_, index) => { + if (rangesLengthBigNumber.lte(currentPage)) { + return rangesLengthBigNumber.minus(1).isEqualTo(index); + } + + return currentPageBigNumber.minus(1).isEqualTo(index); + }); + + if (!currentRange) { + return items; + } + + const [currentRangeStart, currentRangeEnd] = currentRange; + const slicedTokensArray = items.slice( + currentRangeStart.toNumber(), + currentRangeEnd.toNumber() + ); + + return slicedTokensArray; +}; diff --git a/src/helpers/getValue/index.ts b/src/helpers/getValue/index.ts index f5ebe40aa..f2b5ed3ba 100644 --- a/src/helpers/getValue/index.ts +++ b/src/helpers/getValue/index.ts @@ -4,6 +4,7 @@ export * from './getAccountStakingDetails'; export * from './getAccountValidatorStakeDetails'; export * from './getColors'; export * from './getDisplayReceiver'; +export * from './getItemsPage'; export * from './getNftText'; export * from './getNodeIcon'; export * from './getNodeIssue'; diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 056f1094a..4cd02b7a2 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -5,6 +5,7 @@ export * from './amountWithoutRounding'; export * from './analytics'; export * from './capitalize'; export * from './capitalizeFirstLetter'; +export * from './cookie'; export * from './copyToClipboard'; export * from './downloadFile'; export * from './formatValue'; @@ -15,9 +16,11 @@ export * from './isEllipsisActive'; export * from './isHash'; export * from './isMetachain'; export * from './isUtf8'; +export * from './isValidTokenValue'; export * from './parseAmount'; export * from './parseJwt'; export * from './partitionBy'; +export * from './scrollToElement'; export * from './processData'; export * from './storage'; export * from './stringIsFloat'; diff --git a/src/helpers/isValidTokenValue.ts b/src/helpers/isValidTokenValue.ts new file mode 100644 index 000000000..4252f7767 --- /dev/null +++ b/src/helpers/isValidTokenValue.ts @@ -0,0 +1,14 @@ +import BigNumber from 'bignumber.js'; + +import { LOW_LIQUIDITY_DISPLAY_TRESHOLD } from 'appConstants'; +import { TokenType } from 'types'; + +export const isValidTokenValue = (token: TokenType) => { + return Boolean( + token.valueUsd && + (!token.isLowLiquidity || + new BigNumber(token.valueUsd).isLessThan( + LOW_LIQUIDITY_DISPLAY_TRESHOLD + )) + ); +}; diff --git a/src/helpers/scrollToElement.ts b/src/helpers/scrollToElement.ts new file mode 100644 index 000000000..1f6c066eb --- /dev/null +++ b/src/helpers/scrollToElement.ts @@ -0,0 +1,12 @@ +export const scrollToElement = (selector: string, timeout?: number) => { + const activeElement = document.querySelector(selector); + setTimeout(() => { + if (activeElement) { + activeElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'start' + }); + } + }, timeout); +}; diff --git a/src/helpers/storage.ts b/src/helpers/storage.ts index d2e7d1de4..4ca8428cc 100644 --- a/src/helpers/storage.ts +++ b/src/helpers/storage.ts @@ -1,9 +1,13 @@ import moment from 'moment'; -import { TEMP_LOCAL_NOTIFICATION_DISMISSED } from 'appConstants'; +import { + TEMP_LOCAL_NOTIFICATION_DISMISSED, + CUSTOM_NETWORK_ID +} from 'appConstants'; type KeyType = | 'theme' | 'accessToken' + | typeof CUSTOM_NETWORK_ID | typeof TEMP_LOCAL_NOTIFICATION_DISMISSED; export const storage = { diff --git a/src/hooks/adapter/adapter.ts b/src/hooks/adapter/adapter.ts index 6b619de34..2207e898c 100644 --- a/src/hooks/adapter/adapter.ts +++ b/src/hooks/adapter/adapter.ts @@ -739,6 +739,13 @@ export const useAdapter = () => { provider({ baseUrl: `${growthApi}/explorer/widgets`, url }), getGrowthHeaders: (url: string) => - provider({ baseUrl: `${growthApi}/explorer/headers`, url }) + provider({ baseUrl: `${growthApi}/explorer/headers`, url }), + + // Network Config + getNetworkConfig: (baseUrl: string) => + provider({ + baseUrl, + url: '/dapp/config' + }) }; }; diff --git a/src/hooks/adapter/useAdapterConfig.ts b/src/hooks/adapter/useAdapterConfig.ts index f33076d51..33575cba1 100644 --- a/src/hooks/adapter/useAdapterConfig.ts +++ b/src/hooks/adapter/useAdapterConfig.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { METACHAIN_SHARD_ID, TIMEOUT } from 'appConstants'; import { activeNetworkSelector } from 'redux/selectors'; import { + NetworkAdapterEnum, AdapterProviderPropsType, ApiAdapterResponseType } from 'types/adapter.types'; @@ -56,7 +57,7 @@ export const useAdapterConfig = () => { } }; - const adapter: 'api' | 'elastic' = networkAdapter as any; + const adapter = networkAdapter as NetworkAdapterEnum; const { provider, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index fc4aa4f87..999449b40 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,9 +5,11 @@ export * from './pageStats'; export * from './urlFilters'; export * from './useActiveRoute'; export * from './useCheckVersion'; +export * from './useCustomNetwork'; export * from './useDebounce'; export * from './useGetExplorerTitle'; export * from './useGetHash'; +export * from './useGetNetworkChangeLink'; export * from './useGetNodesCategoryCount'; export * from './useGetShardText'; export * from './useGetEpochRemainingTime'; diff --git a/src/hooks/useCustomNetwork.ts b/src/hooks/useCustomNetwork.ts new file mode 100644 index 000000000..5a5bdce32 --- /dev/null +++ b/src/hooks/useCustomNetwork.ts @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react'; +import moment from 'moment'; +import { useSelector } from 'react-redux'; + +import { CUSTOM_NETWORK_ID } from 'appConstants'; +import { networks } from 'config'; +import { cookie, storage, getSubdomainNetwork } from 'helpers'; +import { useAdapter, useGetNetworkChangeLink } from 'hooks'; +import { activeNetworkSelector } from 'redux/selectors'; +import { DappNetworkConfigType, NetworkType, NetworkAdapterEnum } from 'types'; + +export interface CustomNetworkErrorType { + apiAddress?: string; + chainId?: string; + adapter?: string; + egldLabel?: string; + explorerAddress?: string; +} + +const validateUrl = (url: string) => { + if (!url) { + return 'Required'; + } + + try { + new URL(url); + return ''; + } catch (err) { + return 'Invalid Url'; + } +}; + +export const useCustomNetwork = (customUrl: string) => { + const { getNetworkConfig } = useAdapter(); + const getNetworkChangeLink = useGetNetworkChangeLink(); + const { isSubSubdomain } = getSubdomainNetwork(); + const activeNetwork = useSelector(activeNetworkSelector); + + const { isCustom: activeNetworkIsCustom } = activeNetwork; + const configCustomNetwork = networks.filter((network) => network.isCustom)[0]; + + const existingNetwork = activeNetworkIsCustom + ? activeNetwork + : configCustomNetwork; + + const [isSaving, setIsSaving] = useState(); + const [errors, setErrors] = useState(); + const [customNetworkConfig, setCustomNetworkConfig] = useState< + NetworkType | undefined + >(existingNetwork); + + const setCustomNetwork = async () => { + setIsSaving(true); + setErrors(undefined); + const urlError = validateUrl(customUrl); + if (urlError) { + setErrors((errors) => { + return { ...errors, apiAddress: urlError }; + }); + setIsSaving(false); + return; + } + + const apiAddress = new URL(customUrl).toString().replace(/\/+$/, ''); + + const { data, success } = await getNetworkConfig(apiAddress); + if (data && success) { + const { chainId, egldLabel, explorerAddress, walletAddress, name } = + data as DappNetworkConfigType; + + if (chainId && egldLabel && walletAddress && explorerAddress) { + const customNetwork = { + id: CUSTOM_NETWORK_ID, + name: `Custom ${name ?? 'Network'}`, + adapter: NetworkAdapterEnum.api, + theme: 'testnet', + isCustom: true, + apiAddress, + chainId, + egldLabel, + walletAddress, + explorerAddress + }; + + try { + const in2Minutes = new Date(moment().add(2, 'minutes').toDate()); + const in30Days = new Date(moment().add(30, 'days').toDate()); + const configData = { + key: CUSTOM_NETWORK_ID as typeof CUSTOM_NETWORK_ID, + data: JSON.stringify([customNetwork]), + expirationDate: isSubSubdomain ? in2Minutes : in30Days + }; + if (isSubSubdomain) { + cookie.saveToCookies(configData); + } else { + storage.saveToLocal(configData); + } + } catch (error) { + console.error('Unable to Save Custom Network: ', error); + setErrors((errors) => { + return { ...errors, apiAddress: 'Unable to Save Custom Network' }; + }); + setIsSaving(false); + return; + } + + setCustomNetworkConfig(customNetwork); + setIsSaving(false); + + // we want to reset the whole state, react router's navigate might lead to unwanted innacuracies + window.location.href = getNetworkChangeLink({ + networkId: CUSTOM_NETWORK_ID + }); + + return; + } + } + + setErrors((errors) => { + return { ...errors, apiAddress: 'Invalid API Config' }; + }); + setIsSaving(false); + }; + + useEffect(() => { + if (customUrl) { + setIsSaving(false); + } + }, [customUrl]); + + return { setCustomNetwork, isSaving, customNetworkConfig, errors }; +}; diff --git a/src/hooks/useGetNetworkChangeLink.ts b/src/hooks/useGetNetworkChangeLink.ts new file mode 100644 index 000000000..37ed220d7 --- /dev/null +++ b/src/hooks/useGetNetworkChangeLink.ts @@ -0,0 +1,20 @@ +import { useSelector } from 'react-redux'; + +import { getSubdomainNetwork } from 'helpers'; +import { defaultNetworkSelector } from 'redux/selectors'; + +export const useGetNetworkChangeLink = () => { + const { id: defaultNetworkId } = useSelector(defaultNetworkSelector); + const { isSubSubdomain } = getSubdomainNetwork(); + + const getNetworkChangeLink = ({ networkId }: { networkId?: string }) => { + if (isSubSubdomain && window?.location?.hostname) { + const [_omit, ...rest] = window.location.hostname.split('.'); + return `https://${[networkId, ...rest].join('.')}`; + } + + return networkId === defaultNetworkId ? '/' : `/${networkId}`; + }; + + return getNetworkChangeLink; +}; diff --git a/src/icons/regular/fontawesomeFree.ts b/src/icons/regular/fontawesomeFree.ts index f4383e9f4..cd69fa66e 100644 --- a/src/icons/regular/fontawesomeFree.ts +++ b/src/icons/regular/fontawesomeFree.ts @@ -110,6 +110,7 @@ import { faStream, faSync, faTimes, + faTrash, faTrophy, faUpLong as faUp, faUserCheck, @@ -228,6 +229,7 @@ export { faSync, faTerminal, faTimes, + faTrash, faTrophy, faUp, faUpRight, diff --git a/src/icons/regular/fontawesomePro.ts b/src/icons/regular/fontawesomePro.ts index 3f6d4c58c..4213aaa2f 100644 --- a/src/icons/regular/fontawesomePro.ts +++ b/src/icons/regular/fontawesomePro.ts @@ -108,6 +108,7 @@ import { faSync, faTerminal, faTimes, + faTrash, faTrophy, faUp, faUpRight, @@ -230,6 +231,7 @@ export { faSync, faTerminal, faTimes, + faTrash, faTrophy, faUp, faUpRight, diff --git a/src/icons/solid/fontawesomeFree.ts b/src/icons/solid/fontawesomeFree.ts index 3bdd499b0..2ce86b230 100644 --- a/src/icons/solid/fontawesomeFree.ts +++ b/src/icons/solid/fontawesomeFree.ts @@ -107,6 +107,7 @@ import { faStream, faSync, faTimes, + faTrash, faTrophy, faUpLong as faUp, faUser, @@ -226,6 +227,7 @@ export { faSync, faTerminal, faTimes, + faTrash, faTrophy, faUp, faUpRight, diff --git a/src/icons/solid/fontawesomePro.ts b/src/icons/solid/fontawesomePro.ts index b43b034b6..6b699d5ca 100644 --- a/src/icons/solid/fontawesomePro.ts +++ b/src/icons/solid/fontawesomePro.ts @@ -108,6 +108,7 @@ import { faSync, faTerminal, faTimes, + faTrash, faTrophy, faUp, faUpRight, @@ -230,6 +231,7 @@ export { faSync, faTerminal, faTimes, + faTrash, faTrophy, faUp, faUpRight, diff --git a/src/layouts/AccountLayout/AccountDetailsCard/AccountDetailsCard.tsx b/src/layouts/AccountLayout/AccountDetailsCard/AccountDetailsCard.tsx index d2af317b8..6960b3f28 100644 --- a/src/layouts/AccountLayout/AccountDetailsCard/AccountDetailsCard.tsx +++ b/src/layouts/AccountLayout/AccountDetailsCard/AccountDetailsCard.tsx @@ -1,13 +1,8 @@ import React, { useEffect } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import BigNumber from 'bignumber.js'; import { useDispatch, useSelector } from 'react-redux'; -import { - ELLIPSIS, - MAX_ACOUNT_TOKENS_BALANCE, - LOW_LIQUIDITY_DISPLAY_TRESHOLD -} from 'appConstants'; +import { ELLIPSIS, MAX_ACOUNT_TOKENS_BALANCE } from 'appConstants'; import { NativeTokenSymbol } from 'components'; import { CardItem, @@ -22,7 +17,8 @@ import { urlBuilder, formatHerotag, formatBigNumber, - getTotalTokenUsdValue + getTotalTokenUsdValue, + isValidTokenValue } from 'helpers'; import { useAdapter, useIsSovereign } from 'hooks'; import { faClock, faExclamationTriangle } from 'icons/regular'; @@ -118,12 +114,7 @@ export const AccountDetailsCard = () => { } if (accountTokensValueData.success) { const validTokenValues = accountTokensValueData.data.filter( - (token: TokenType) => - token.valueUsd && - (!token.isLowLiquidity || - new BigNumber(token.valueUsd).isLessThan( - LOW_LIQUIDITY_DISPLAY_TRESHOLD - )) + (token: TokenType) => isValidTokenValue(token) ); const tokenBalance = getTotalTokenUsdValue(validTokenValues); accountExtraDetails.tokenBalance = tokenBalance; diff --git a/src/layouts/Layout/components/Header/components/EcosystemMenu/ecosystemMenu.styles.scss b/src/layouts/Layout/components/Header/components/EcosystemMenu/ecosystemMenu.styles.scss index 0946a3c59..0b373b0ef 100644 --- a/src/layouts/Layout/components/Header/components/EcosystemMenu/ecosystemMenu.styles.scss +++ b/src/layouts/Layout/components/Header/components/EcosystemMenu/ecosystemMenu.styles.scss @@ -2,7 +2,6 @@ display: flex; flex-direction: column; margin-top: 0; - overflow: hidden; &-item { display: block; diff --git a/src/layouts/Layout/components/Header/components/Switcher/Switcher.tsx b/src/layouts/Layout/components/Header/components/Switcher/Switcher.tsx index f50b9fb14..ac477844c 100644 --- a/src/layouts/Layout/components/Header/components/Switcher/Switcher.tsx +++ b/src/layouts/Layout/components/Header/components/Switcher/Switcher.tsx @@ -3,55 +3,34 @@ import classNames from 'classnames'; import { Anchor, Dropdown } from 'react-bootstrap'; import { useSelector } from 'react-redux'; -import { networks, links } from 'config'; +import { CustomNetworkMenu } from 'components'; +import { networks, links, hasExtraNetworks } from 'config'; import { getSubdomainNetwork } from 'helpers'; +import { useGetNetworkChangeLink } from 'hooks'; import { faAngleDown } from 'icons/solid'; - -import { activeNetworkSelector, defaultNetworkSelector } from 'redux/selectors'; +import { activeNetworkSelector } from 'redux/selectors'; export const Switcher = () => { const { id: activeNetworkId, name: activeNetworkName } = useSelector( activeNetworkSelector ); - const { id: defaultNetworkId } = useSelector(defaultNetworkSelector); const { isSubSubdomain } = getSubdomainNetwork(); + const getNetworkChangeLink = useGetNetworkChangeLink(); - const networkLinks = networks.map(({ name, id }) => { - let url = id === defaultNetworkId ? '/' : `/${id}`; - if (isSubSubdomain && window?.location?.hostname) { - const [_omit, ...rest] = window.location.hostname.split('.'); - url = `https://${[id, ...rest].join('.')}`; - } - return { - name, - url, - id - }; - }); - - return ( - - -
{activeNetworkName}
- -
+ const networkLinks = networks + .filter((network) => !network.isCustom) + .map(({ name, id }) => { + const url = getNetworkChangeLink({ networkId: id }); + return { + name, + url, + id + }; + }); - + const LinksList = () => { + return ( +
{links.length > 0 ? ( <> {links.map((link) => ( @@ -105,6 +84,41 @@ export const Switcher = () => { )} )} +
+ ); + }; + + return ( + + +
{activeNetworkName}
+ +
+ +
+ {hasExtraNetworks ? ( + + + + ) : ( + + )} +
); diff --git a/src/layouts/Layout/components/Header/components/Switcher/switcher.styles.scss b/src/layouts/Layout/components/Header/components/Switcher/switcher.styles.scss index 55b81da30..f6cbf049b 100644 --- a/src/layouts/Layout/components/Header/components/Switcher/switcher.styles.scss +++ b/src/layouts/Layout/components/Header/components/Switcher/switcher.styles.scss @@ -52,6 +52,24 @@ } .dropdown-menu { + .network-switch-list { + min-height: 2rem; + max-height: calc(100dvh - #{$header-navbar-height} - 6rem); + overflow-y: scroll; + @include media-breakpoint-up(lg) { + max-height: calc(100dvh - #{$header-navbar-height} - 1rem); + } + } + .custom-network-menu { + .network-list { + @include media-breakpoint-up(lg) { + min-height: 2rem; + max-height: calc(100dvh - #{$header-navbar-height} - 8.25rem); + overflow-y: scroll; + } + } + } + .dropdown-item { --dropdown-link-color: var(--body-color); --dropdown-link-hover-color: var(--primary); diff --git a/src/layouts/Layout/components/Header/header.styles.scss b/src/layouts/Layout/components/Header/header.styles.scss index f29a1afa1..9f3a5f42e 100644 --- a/src/layouts/Layout/components/Header/header.styles.scss +++ b/src/layouts/Layout/components/Header/header.styles.scss @@ -239,6 +239,8 @@ &.active { transform: translateX(0%); + overflow-y: auto; + overflow-x: hidden; } } @@ -260,7 +262,6 @@ } .ecosystem-menu-wrapper { - overflow: hidden; @include media-breakpoint-up(lg) { position: absolute; right: calc(((100vw - 960px) / 2) + 0.75rem); diff --git a/src/layouts/Layout/layout.styles.scss b/src/layouts/Layout/layout.styles.scss index 1ba32ea04..72db17405 100644 --- a/src/layouts/Layout/layout.styles.scss +++ b/src/layouts/Layout/layout.styles.scss @@ -7,7 +7,7 @@ > .main-content-container { @include media-breakpoint-up(md) { - min-height: calc(100vh - #{$footer-height + $header-navbar-height}); + min-height: calc(100dvh - #{$footer-height + $header-navbar-height}); } } diff --git a/src/layouts/TokenLayout/TokenDetailsCard.tsx b/src/layouts/TokenLayout/TokenDetailsCard.tsx index b49b7adc3..ab9db6d0a 100644 --- a/src/layouts/TokenLayout/TokenDetailsCard.tsx +++ b/src/layouts/TokenLayout/TokenDetailsCard.tsx @@ -109,13 +109,14 @@ export const TokenDetailsCard = () => { ]; const smallStatsCards = [ - supply + supply && new BigNumber(supply).isGreaterThanOrEqualTo(0) ? { title: 'Supply', value: new BigNumber(supply).toFormat(0) } : {}, - circulatingSupply + circulatingSupply && + new BigNumber(circulatingSupply).isGreaterThanOrEqualTo(0) ? { title: 'Circulating', value: new BigNumber(circulatingSupply).toFormat(0) diff --git a/src/pages/AccountDetails/AccountTokens.tsx b/src/pages/AccountDetails/AccountTokens.tsx index c66a012f8..9171d3a27 100644 --- a/src/pages/AccountDetails/AccountTokens.tsx +++ b/src/pages/AccountDetails/AccountTokens.tsx @@ -1,9 +1,8 @@ import { useEffect, useRef, useState } from 'react'; -import BigNumber from 'bignumber.js'; import { useSelector } from 'react-redux'; import { useParams, useSearchParams } from 'react-router-dom'; -import { ZERO, LOW_LIQUIDITY_DISPLAY_TRESHOLD } from 'appConstants'; +import { ZERO } from 'appConstants'; import { DetailItem, Loader, @@ -15,6 +14,7 @@ import { FormatUSD, LowLiquidityTooltip } from 'components'; +import { isValidTokenValue } from 'helpers'; import { useAdapter, useGetPage } from 'hooks'; import { faCoins } from 'icons/solid'; import { AccountTabs } from 'layouts/AccountLayout/AccountTabs'; @@ -90,6 +90,7 @@ export const AccountTokens = () => { {dataReady === true && accountTokens.length > 0 && ( <> {accountTokens.map((token) => { + const isValidDisplayValue = isValidTokenValue(token); return ( { showLastNonZeroDecimal />
- {token.valueUsd && - (!token.isLowLiquidity || - new BigNumber(token.valueUsd).isLessThan( - LOW_LIQUIDITY_DISPLAY_TRESHOLD - )) && ( - - ( - - ) - - )} + {isValidDisplayValue && ( + + ( + + ) + + )}
diff --git a/src/pages/AccountDetails/AccountTokensTable/AccountTokensTable.tsx b/src/pages/AccountDetails/AccountTokensTable/AccountTokensTable.tsx new file mode 100644 index 000000000..d56fcb14f --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/AccountTokensTable.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; + +import { ZERO, MAX_RESULTS, ACCOUNT_TOKENS_FIELDS } from 'appConstants'; +import { + Pager, + PageSize, + PageState, + FormatAmount, + TokenLink, + FormatUSD, + LowLiquidityTooltip, + FormatNumber, + Sort, + Loader, + Overlay +} from 'components'; +import { isValidTokenValue } from 'helpers'; +import { useAdapter } from 'hooks'; +import { faCoins } from 'icons/solid'; +import { AccountTabs } from 'layouts/AccountLayout/AccountTabs'; +import { activeNetworkSelector, accountSelector } from 'redux/selectors'; +import { TokenType, SortOrderEnum } from 'types'; + +import { AccountTokensTableHeader } from './components'; +import { SortTokenFieldEnum } from './helpers'; +import { usePageTokens, useProcessTokens } from './hooks'; + +const ColSpanWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +export const AccountTokensTable = () => { + const { id: activeNetworkId } = useSelector(activeNetworkSelector); + const { account } = useSelector(accountSelector); + const { txCount } = account; + const { getAccountTokens } = useAdapter(); + const { hash: address } = useParams() as any; + + const [isDataReady, setIsDataReady] = useState(); + const [accountTokens, setAccountTokens] = useState([]); + + const fetchAccountTokens = async () => { + const { data, success } = await getAccountTokens({ + address, + includeMetaESDT: true, + size: MAX_RESULTS, + fields: ACCOUNT_TOKENS_FIELDS.join(',') + }); + if (success && data) { + setAccountTokens(data); + } + setIsDataReady(success); + }; + + const hasValidValues = accountTokens.some((token) => + isValidTokenValue(token) + ); + const processedAccountTokens = useProcessTokens(accountTokens); + const pagedTokens = usePageTokens(processedAccountTokens); + + useEffect(() => { + fetchAccountTokens(); + }, [txCount, activeNetworkId, address]); + + return ( +
+
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + {isDataReady === undefined && ( + + + + )} + {isDataReady === false && ( + + + + )} + {isDataReady === true && ( + <> + {pagedTokens.length > 0 ? ( + <> + {pagedTokens.map((token) => { + const isValidDisplayValue = isValidTokenValue(token); + + return ( + + + + + + + + ); + })} + + ) : ( + <> + + + + + )} + + )} + +
+ + + + + + + + + Portofolio %} + /> +
+
+ + + ({token.assets?.name ?? token.name}) + +
+
+ + + {token.price ? ( +
+ + +
+ ) : ( + - + )} +
+ {isValidDisplayValue ? ( + + ) : ( + - + )} + + {token.portofolioPercentage && + token.portofolioPercentage.isGreaterThan(0) ? ( + + ) : ( + - + )} +
+
+ +
+ + 0} + /> +
+
+ ); +}; diff --git a/src/pages/AccountDetails/AccountTokensTable/accountTokensTable.styles.scss b/src/pages/AccountDetails/AccountTokensTable/accountTokensTable.styles.scss new file mode 100644 index 000000000..6e66ea822 --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/accountTokensTable.styles.scss @@ -0,0 +1,14 @@ +.account-tokens-table { + td { + width: 12.5%; + &:first-of-type { + width: 30%; + } + &:nth-child(2) { + width: 30%; + } + &:nth-child(4) { + width: 15%; + } + } +} diff --git a/src/pages/AccountDetails/AccountTokensTable/components/AccountTokensTableHeader.tsx b/src/pages/AccountDetails/AccountTokensTable/components/AccountTokensTableHeader.tsx new file mode 100644 index 000000000..d87167aa2 --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/components/AccountTokensTableHeader.tsx @@ -0,0 +1,89 @@ +import classNames from 'classnames'; +import { useSearchParams } from 'react-router-dom'; + +import { Pager, TableSearch } from 'components'; +import { TokenTypeEnum } from 'types'; + +export interface AccountTokensTableHeaderUIType { + tokenCount?: number; +} + +export const AccountTokensTableHeader = ({ + tokenCount = 0 +}: AccountTokensTableHeaderUIType) => { + const [searchParams, setSearchParams] = useSearchParams(); + const { type } = Object.fromEntries(searchParams); + + const updateTokenType = (typeValue?: TokenTypeEnum) => () => { + const { type, page, size, ...rest } = Object.fromEntries(searchParams); + const nextUrlParams = { + ...rest, + ...(typeValue ? { type: typeValue } : {}) + }; + + setSearchParams(nextUrlParams); + }; + + return ( + <> +
+ +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    +
    + +
    +
    + + + ); +}; diff --git a/src/pages/AccountDetails/AccountTokensTable/components/index.ts b/src/pages/AccountDetails/AccountTokensTable/components/index.ts new file mode 100644 index 000000000..22188ef15 --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/components/index.ts @@ -0,0 +1 @@ +export * from './AccountTokensTableHeader'; diff --git a/src/pages/AccountDetails/AccountTokensTable/helpers/filterTokens.ts b/src/pages/AccountDetails/AccountTokensTable/helpers/filterTokens.ts new file mode 100644 index 000000000..661a2b097 --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/helpers/filterTokens.ts @@ -0,0 +1,38 @@ +import { TokenTypeEnum } from 'types'; + +import { ProcessedTokenType } from '../helpers'; + +export interface FilterTokensType { + tokens: ProcessedTokenType[]; + type?: TokenTypeEnum; + search?: string; +} + +export const filterTokens = ({ tokens, type, search }: FilterTokensType) => { + const searchTerm = (term?: string) => { + if (!search) { + return true; + } + + if (!term) { + return false; + } + + return term.toLowerCase().includes(search.toLowerCase()); + }; + + return tokens + .filter((token) => !type || token.type === type) + .filter(({ name, identifier, assets }) => { + if (!search) { + return true; + } + + return ( + searchTerm(name) || + searchTerm(identifier) || + searchTerm(assets?.description) || + searchTerm(assets?.name) + ); + }); +}; diff --git a/src/pages/AccountDetails/AccountTokensTable/helpers/index.ts b/src/pages/AccountDetails/AccountTokensTable/helpers/index.ts new file mode 100644 index 000000000..078568b77 --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/helpers/index.ts @@ -0,0 +1,2 @@ +export * from './filterTokens'; +export * from './sortTokens'; diff --git a/src/pages/AccountDetails/AccountTokensTable/helpers/sortTokens.ts b/src/pages/AccountDetails/AccountTokensTable/helpers/sortTokens.ts new file mode 100644 index 000000000..3902ce851 --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/helpers/sortTokens.ts @@ -0,0 +1,128 @@ +import BigNumber from 'bignumber.js'; + +import { LOW_LIQUIDITY_DISPLAY_TRESHOLD } from 'appConstants'; +import { formatAmount } from 'helpers'; +import { TokenType, SortOrderEnum } from 'types'; + +export enum SortTokenFieldEnum { + name = 'name', + balance = 'balance', + price = 'price', + value = 'value', + portofolioPercent = 'portofolioPercent' +} + +export interface ProcessedTokenType extends TokenType { + portofolioPercentage: BigNumber; +} + +export interface SortTokensType { + field?: SortTokenFieldEnum; + order?: SortOrderEnum; + tokens: ProcessedTokenType[]; + tokenBalance?: string; +} + +const getTokenDisplayValue = ({ + valueUsd, + isLowLiquidity +}: ProcessedTokenType) => { + if ( + valueUsd && + (!isLowLiquidity || + new BigNumber(valueUsd).isLessThan(LOW_LIQUIDITY_DISPLAY_TRESHOLD)) + ) { + return new BigNumber(valueUsd); + } + + return new BigNumber(0); +}; + +const getPortofolioPercent = ({ + token, + tokenBalance +}: { + token: ProcessedTokenType; + tokenBalance: string; +}) => { + return token.valueUsd && tokenBalance + ? new BigNumber(token.valueUsd).dividedBy(tokenBalance).times(100) + : new BigNumber(0); +}; + +export const sortTokens = ({ + field = SortTokenFieldEnum.value, + order = SortOrderEnum.desc, + tokens = [], + tokenBalance +}: SortTokensType) => { + if (field && order) { + const sortParams = order === SortOrderEnum.asc ? [1, -1] : [-1, 1]; + + switch (true) { + case field === SortTokenFieldEnum.name: + tokens.sort((a, b) => { + const aName = a.assets?.name ?? a.name; + const bName = b.assets?.name ?? b.name; + return aName.toLowerCase() > bName.toLowerCase() + ? sortParams[0] + : sortParams[1]; + }); + break; + + case field === SortTokenFieldEnum.value: + tokens.sort((a, b) => { + const aValue = getTokenDisplayValue(a); + const bValue = getTokenDisplayValue(b); + return aValue.isGreaterThan(bValue) ? sortParams[0] : sortParams[1]; + }); + break; + + case field === SortTokenFieldEnum.balance: + tokens.sort((a, b) => { + const aBalance = formatAmount({ + input: new BigNumber(a.balance ?? 0).toString(10), + decimals: a.decimals, + showLastNonZeroDecimal: true + }); + const bBalance = formatAmount({ + input: new BigNumber(b.balance ?? 0).toString(10), + decimals: b.decimals, + showLastNonZeroDecimal: true + }); + return new BigNumber(aBalance).isGreaterThan(bBalance) + ? sortParams[0] + : sortParams[1]; + }); + break; + + case field === SortTokenFieldEnum.portofolioPercent: + if (!tokenBalance) { + return tokens; + } + + tokens.sort((a, b) => { + const aPercent = getPortofolioPercent({ token: a, tokenBalance }); + const bPercent = getPortofolioPercent({ token: b, tokenBalance }); + return new BigNumber(aPercent).isGreaterThan(bPercent) + ? sortParams[0] + : sortParams[1]; + }); + break; + + case field === SortTokenFieldEnum.price: + tokens.sort((a, b) => { + return new BigNumber(a.price ?? 0).isGreaterThan(b.price ?? 0) + ? sortParams[0] + : sortParams[1]; + }); + break; + + default: + return tokens; + break; + } + } + + return tokens; +}; diff --git a/src/pages/AccountDetails/AccountTokensTable/hooks/index.ts b/src/pages/AccountDetails/AccountTokensTable/hooks/index.ts new file mode 100644 index 000000000..ac9358af0 --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useProcessTokens'; +export * from './usePageTokens'; diff --git a/src/pages/AccountDetails/AccountTokensTable/hooks/usePageTokens.ts b/src/pages/AccountDetails/AccountTokensTable/hooks/usePageTokens.ts new file mode 100644 index 000000000..8727c4311 --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/hooks/usePageTokens.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; + +import { getItemsPage } from 'helpers'; +import { useGetPage } from 'hooks'; +import { ProcessedTokenType } from '../helpers'; + +export const usePageTokens = (accountTokens: ProcessedTokenType[]) => { + const { page, size } = useGetPage(); + + return useMemo(() => { + const processedTokens = getItemsPage({ + items: accountTokens, + currentPage: page, + itemsPerPage: size + }); + + return processedTokens; + }, [accountTokens, page, size]); +}; diff --git a/src/pages/AccountDetails/AccountTokensTable/hooks/useProcessTokens.ts b/src/pages/AccountDetails/AccountTokensTable/hooks/useProcessTokens.ts new file mode 100644 index 000000000..5aa2de6fe --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/hooks/useProcessTokens.ts @@ -0,0 +1,90 @@ +import { useMemo } from 'react'; +import BigNumber from 'bignumber.js'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams, useSearchParams } from 'react-router-dom'; + +import { isValidTokenValue, getTotalTokenUsdValue } from 'helpers'; +import { useGetSearch, useGetSort } from 'hooks'; +import { accountExtraSelector } from 'redux/selectors'; +import { setAccountExtra, getInitialAccountExtraState } from 'redux/slices'; +import { TokenTypeEnum, TokenType, SortOrderEnum } from 'types'; + +import { + filterTokens, + sortTokens, + ProcessedTokenType, + SortTokenFieldEnum +} from '../helpers'; + +export const useProcessTokens = (accountTokens: TokenType[]) => { + const dispatch = useDispatch(); + + const [searchParams] = useSearchParams(); + const { hash: address } = useParams() as any; + const { accountExtra, isFetched: isAccountExtraFetched } = + useSelector(accountExtraSelector); + const { address: extraAddress } = accountExtra; + const { search } = useGetSearch(); + const { sort, order } = useGetSort(); + const { type } = Object.fromEntries(searchParams); + + const validTokenValues = accountTokens.filter((token: TokenType) => + isValidTokenValue(token) + ); + const tokenBalance = getTotalTokenUsdValue(validTokenValues); + + if (!isAccountExtraFetched && address === extraAddress) { + const accountExtraDetails = getInitialAccountExtraState().accountExtra; + accountExtraDetails.tokenBalance = tokenBalance; + dispatch( + setAccountExtra({ + accountExtra: { ...accountExtraDetails, address }, + isFetched: true + }) + ); + } + + const processTokens = ({ tokens }: { tokens: ProcessedTokenType[] }) => { + const filteredTokens = filterTokens({ + tokens, + type: type as TokenTypeEnum, + search + }); + + let currentSort = sort; + let currentOrder = order; + if (!(sort && order)) { + const hasValidValues = filteredTokens.some((token) => + isValidTokenValue(token) + ); + if (!hasValidValues) { + currentSort = SortTokenFieldEnum.name; + currentOrder = SortOrderEnum.asc; + } + } + + const sortedTokens = sortTokens({ + tokens: filteredTokens, + field: currentSort as SortTokenFieldEnum, + order: currentOrder, + tokenBalance + }); + + return sortedTokens; + }; + + const processedAccountTokens = useMemo(() => { + const processedSortArray = accountTokens.map((token) => { + const portofolioPercentage = + token.valueUsd && tokenBalance + ? new BigNumber(token.valueUsd).dividedBy(tokenBalance).times(100) + : new BigNumber(0); + return { ...token, portofolioPercentage }; + }); + + const processedTokens = processTokens({ tokens: processedSortArray }); + return [...processedTokens]; + }, [accountTokens, type, search, sort, order, tokenBalance]); + + return processedAccountTokens; +}; diff --git a/src/pages/AccountDetails/AccountTokensTable/index.ts b/src/pages/AccountDetails/AccountTokensTable/index.ts new file mode 100644 index 000000000..3a2626910 --- /dev/null +++ b/src/pages/AccountDetails/AccountTokensTable/index.ts @@ -0,0 +1 @@ +export * from './AccountTokensTable'; diff --git a/src/pages/Tokens/components/TokensTable/TokensTable.tsx b/src/pages/Tokens/components/TokensTable/TokensTable.tsx index 6976eec18..f03b4e552 100644 --- a/src/pages/Tokens/components/TokensTable/TokensTable.tsx +++ b/src/pages/Tokens/components/TokensTable/TokensTable.tsx @@ -2,10 +2,7 @@ import { Fragment } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import BigNumber from 'bignumber.js'; -import { - ELLIPSIS, - LOW_LIQUIDITY_MARKET_CAP_DISPLAY_TRESHOLD -} from 'appConstants'; +import { ELLIPSIS } from 'appConstants'; import { NetworkLink, FormatAmount, @@ -13,7 +10,7 @@ import { LowLiquidityTooltip, FormatUSD } from 'components'; -import { urlBuilder } from 'helpers'; +import { isValidTokenValue, urlBuilder } from 'helpers'; import { useGetSort, useGetSearch, useIsNativeTokenSearched } from 'hooks'; import { faDiamond } from 'icons/regular'; import { TokenType, TokenSortEnum, SortOrderEnum } from 'types'; @@ -144,20 +141,14 @@ export const TokensTable = ({ )} - {token.marketCap && - (!token.isLowLiquidity || - new BigNumber(token.marketCap).isLessThan( - LOW_LIQUIDITY_MARKET_CAP_DISPLAY_TRESHOLD - )) && ( - <> - - - )} + {isValidTokenValue(token) && token.marketCap && ( + + )} {token.accounts diff --git a/src/redux/slices/metaTags.ts b/src/redux/slices/metaTags.ts index dfd916b86..9f1bf06a7 100644 --- a/src/redux/slices/metaTags.ts +++ b/src/redux/slices/metaTags.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { BRAND_NAME } from 'appConstants'; -import { SHARE_PREFIX } from 'config'; +import { DEFAULT_HOSTNAME, SHARE_PREFIX } from 'config'; import { capitalize } from 'helpers'; import { MetaTagsType } from 'types/metaTags.types'; @@ -10,7 +10,7 @@ const DEFAULT_TITLE = `${BRAND_NAME}${ } Explorer`; const DEFAULT_DESCRIPTION = 'A highly scalable, fast and secure blockchain platform for distributed apps, enterprise use cases and the new internet economy.'; -const DEFAULT_PREVIEW = `https://${process.env.VITE_APP_SHARE_PREFIX}explorer.multiversx.com/${process.env.VITE_APP_SHARE_PREFIX}share.jpg`; +const DEFAULT_PREVIEW = `https://${process.env.VITE_APP_SHARE_PREFIX}${DEFAULT_HOSTNAME}/${process.env.VITE_APP_SHARE_PREFIX}share.jpg`; export const getInitialMetaTagsState = (): MetaTagsType => { return { diff --git a/src/redux/slices/networks.ts b/src/redux/slices/networks.ts index c20ca3987..db5c7db3a 100644 --- a/src/redux/slices/networks.ts +++ b/src/redux/slices/networks.ts @@ -1,13 +1,13 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { networks } from 'config'; import { getSubdomainNetwork } from 'helpers'; -import { NetworkType } from 'types/network.types'; +import { NetworkAdapterEnum, NetworkType } from 'types'; export const emptyNetwork: NetworkType = { default: false, id: 'not-configured', name: 'NOT CONFIGURED', - adapter: 'api', + adapter: NetworkAdapterEnum.api, theme: '', egldLabel: '', walletAddress: '', diff --git a/src/routes/layouts/accountLayout.ts b/src/routes/layouts/accountLayout.ts index 621536d84..0e3a8e440 100644 --- a/src/routes/layouts/accountLayout.ts +++ b/src/routes/layouts/accountLayout.ts @@ -7,7 +7,7 @@ import { AccountNodes } from 'pages/AccountDetails/AccountNodes'; import { AccountCollectionRoles } from 'pages/AccountDetails/AccountRoles/AccountCollectionRoles'; import { AccountTokenRoles } from 'pages/AccountDetails/AccountRoles/AccountTokenRoles'; import { AccountStaking } from 'pages/AccountDetails/AccountStaking'; -import { AccountTokens } from 'pages/AccountDetails/AccountTokens'; +import { AccountTokensTable } from 'pages/AccountDetails/AccountTokensTable'; import { AccountTransactions } from 'pages/AccountDetails/AccountTransactions'; import { AccountUpgrades } from 'pages/AccountDetails/AccountUpgrades'; import { OldRouteRedirect } from 'pages/AccountDetails/OldRouteRedirect'; @@ -87,7 +87,7 @@ export const accountLayout: TitledRouteObject[] = [ path: accountsRoutes.accountTokens, title: 'Account Tokens', preventScroll: true, - Component: AccountTokens + Component: AccountTokensTable }, { path: accountsRoutes.accountNfts, diff --git a/src/types/adapter.types.ts b/src/types/adapter.types.ts index 60efbf683..edbf71c78 100644 --- a/src/types/adapter.types.ts +++ b/src/types/adapter.types.ts @@ -1,5 +1,10 @@ import { SortOrderEnum, TransactionInPoolTypeEnum } from 'types'; +export enum NetworkAdapterEnum { + api = 'api', + elastic = 'elastic' +} + export interface BaseApiType { page?: number; size?: number; diff --git a/src/types/network.types.ts b/src/types/network.types.ts index 86e3c3dee..bf28bb6f0 100644 --- a/src/types/network.types.ts +++ b/src/types/network.types.ts @@ -1,7 +1,8 @@ import { NetworkType as NetworkConfigType } from '@multiversx/sdk-dapp/types/network.types'; +import { NetworkAdapterEnum } from './adapter.types'; export interface NetworkType extends Partial { - adapter: 'api' | 'elastic'; + adapter: NetworkAdapterEnum | string; // temporary, will be restricted on a future network adapter overhaul theme?: string; default?: boolean; accessToken?: boolean; @@ -11,6 +12,7 @@ export interface NetworkType extends Partial { proxyUrl?: string; nftExplorerAddress?: string; isSovereign?: boolean; + isCustom?: boolean; } export interface NetworkUrlType { @@ -18,3 +20,19 @@ export interface NetworkUrlType { name: string; url: string; } + +export interface DappNetworkConfigType { + id: string | number; + name: string; + egldLabel: string; + decimals: string; + egldDenomination: string; + gasPerDataByte: string; + apiTimeout: string; + walletConnectDeepLink: string; + walletConnectBridgeAddresses: string[]; + walletAddress: string; + apiAddress: string; + explorerAddress: string; + chainId: string; +} diff --git a/src/types/token.types.ts b/src/types/token.types.ts index 3f6cc0405..6caf22c16 100644 --- a/src/types/token.types.ts +++ b/src/types/token.types.ts @@ -28,6 +28,7 @@ export interface TokenType { assets?: TokenAssetType; totalLiquidity?: number; isLowLiquidity?: boolean; + lowLiquidityThresholdPercent?: number; transfersCount?: number; roles?: TokenRolesType[]; } diff --git a/src/widgets/StatsCard/SmallStatsCard.tsx b/src/widgets/StatsCard/SmallStatsCard.tsx index 53d31267b..ab9445d8d 100644 --- a/src/widgets/StatsCard/SmallStatsCard.tsx +++ b/src/widgets/StatsCard/SmallStatsCard.tsx @@ -6,6 +6,10 @@ export const SmallStatsCard = ({ value, className }: StatsCardUIType) => { + if (!(title && value)) { + return null; + } + return (