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/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/SharedIdentity/IdentityCard/IdentityCard.tsx b/src/components/SharedIdentity/IdentityCard/IdentityCard.tsx index 2338dd27b..3d370ea4c 100644 --- a/src/components/SharedIdentity/IdentityCard/IdentityCard.tsx +++ b/src/components/SharedIdentity/IdentityCard/IdentityCard.tsx @@ -187,16 +187,18 @@ export const IdentityCard = ({ identity }: { identity: IdentityType }) => { className='detail-card' /> -
- - Stake now - -
+ {walletAddress && ( +
+ + Stake now + +
+ )}
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..4d30a43b6 100644 --- a/src/config/config.devnet.ts +++ b/src/config/config.devnet.ts @@ -1,4 +1,6 @@ import { NetworkType } from 'types/network.types'; + +import { getStorageCustomNetworks } from './helpers'; import { allApps, schema } from './sharedConfig'; export * from './sharedConfig'; @@ -15,7 +17,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..d2fb9f75e 100644 --- a/src/config/config.testnet.ts +++ b/src/config/config.testnet.ts @@ -1,4 +1,6 @@ import { NetworkType } from 'types/network.types'; + +import { getStorageCustomNetworks } from './helpers'; import { allApps, schema } from './sharedConfig'; export * from './sharedConfig'; @@ -15,7 +17,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/index.ts b/src/helpers/index.ts index 056f1094a..e51b46834 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'; @@ -18,6 +19,7 @@ export * from './isUtf8'; 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/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/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/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/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; +}