diff --git a/IPFS.json b/IPFS.json index 1ad575d41..88a167339 100644 --- a/IPFS.json +++ b/IPFS.json @@ -36,6 +36,14 @@ "multiChainBanner": [], "featureFlags": { "ledgerLiveL2": true + }, + "pages": { + "/withdrawals": { + "shouldDisable": true + }, + "/rewards": { + "shouldDisable": true + } } } } diff --git a/assets/icons/lido-multichain/metis.svg b/assets/icons/lido-multichain/metis.svg new file mode 100644 index 000000000..a575ed614 --- /dev/null +++ b/assets/icons/lido-multichain/metis.svg @@ -0,0 +1 @@ + diff --git a/config/external-config/frontend-fallback.ts b/config/external-config/frontend-fallback.ts new file mode 100644 index 000000000..be1b040e7 --- /dev/null +++ b/config/external-config/frontend-fallback.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import { + Manifest, + ManifestConfig, + ManifestConfigPage, + ManifestConfigPageList, + ManifestEntry, + isManifestValid, +} from 'config/external-config'; +import { getDexConfig } from 'features/withdrawals/request/withdrawal-rates'; + +import FallbackLocalManifest from 'IPFS.json' assert { type: 'json' }; + +export const getBackwardCompatibleConfig = ( + config: ManifestEntry['config'], +): ManifestEntry['config'] => { + let pages: ManifestConfig['pages']; + const configPages = config.pages; + if (configPages) { + pages = (Object.keys(configPages) as ManifestConfigPage[]) + .filter((key) => ManifestConfigPageList.has(key)) + .reduce( + (acc, key) => { + if (acc) { + acc[key] = { ...configPages[key] }; + } + + return acc; + }, + {} as ManifestConfig['pages'], + ); + } + + return { + enabledWithdrawalDexes: config.enabledWithdrawalDexes.filter( + (dex) => !!getDexConfig(dex), + ), + featureFlags: { ...(config.featureFlags ?? {}) }, + multiChainBanner: config.multiChainBanner ?? [], + pages, + }; +}; + +export const useFallbackManifestEntry = ( + prefetchedManifest: unknown, + chain: number, +): ManifestEntry => { + return useMemo(() => { + const isValid = isManifestValid(prefetchedManifest, chain); + return isValid + ? prefetchedManifest[chain] + : (FallbackLocalManifest as unknown as Manifest)[chain]; + }, [prefetchedManifest, chain]); +}; diff --git a/config/external-config/index.ts b/config/external-config/index.ts index c7d70a8b7..25f1edf6c 100644 --- a/config/external-config/index.ts +++ b/config/external-config/index.ts @@ -4,4 +4,15 @@ export type { Manifest, ManifestEntry, ExternalConfig, + ManifestConfigPage, } from './types'; +export { ManifestConfigPageList, ManifestConfigPageEnum } from './types'; +export { + isManifestValid, + isManifestEntryValid, + isEnabledDexesValid, + isFeatureFlagsValid, + isMultiChainBannerValid, + isPagesValid, + shouldRedirectToRoot, +} from './utils'; diff --git a/config/external-config/types.ts b/config/external-config/types.ts index 6131ad22e..6979905f8 100644 --- a/config/external-config/types.ts +++ b/config/external-config/types.ts @@ -16,8 +16,29 @@ export type ManifestConfig = { featureFlags: { ledgerLiveL2?: boolean; }; + pages?: { + [page in ManifestConfigPage]?: { + shouldDisable?: boolean; + sections?: [string, ...string[]]; + }; + }; }; +export enum ManifestConfigPageEnum { + Stake = '/', + Wrap = '/wrap', + Withdrawals = '/withdrawals', + Rewards = '/rewards', + Settings = '/settings', + Referral = '/referral', +} + +export type ManifestConfigPage = `${ManifestConfigPageEnum}`; + +export const ManifestConfigPageList = new Set( + Object.values(ManifestConfigPageEnum), +); + export type ExternalConfig = Omit & ManifestConfig & { fetchMeta: UseQueryResult; diff --git a/config/external-config/use-external-config-context.ts b/config/external-config/use-external-config-context.ts index 29d25bea6..d13ae14ee 100644 --- a/config/external-config/use-external-config-context.ts +++ b/config/external-config/use-external-config-context.ts @@ -4,13 +4,13 @@ import { useQuery } from '@tanstack/react-query'; import { config } from 'config'; import { STRATEGY_LAZY } from 'consts/react-query-strategies'; import { IPFS_MANIFEST_URL } from 'consts/external-links'; +import { isManifestEntryValid } from 'config/external-config'; import { standardFetcher } from 'utils/standardFetcher'; - import { getBackwardCompatibleConfig, - isManifestEntryValid, useFallbackManifestEntry, -} from './utils'; +} from './frontend-fallback'; + import type { ExternalConfig, ManifestEntry } from './types'; export const useExternalConfigContext = ( diff --git a/config/external-config/utils.ts b/config/external-config/utils.ts index 31a6f15f7..3e84f8af6 100644 --- a/config/external-config/utils.ts +++ b/config/external-config/utils.ts @@ -1,10 +1,39 @@ -import { useMemo } from 'react'; -import type { Manifest, ManifestEntry } from './types'; -import { getDexConfig } from 'features/withdrawals/request/withdrawal-rates'; +import { config } from 'config'; +import { + Manifest, + ManifestConfig, + ManifestConfigPage, + ManifestConfigPageEnum, + ManifestEntry, +} from 'config/external-config'; + +export const isMultiChainBannerValid = (config: object) => { + // allow empty config + if (!('multiChainBanner' in config) || !config.multiChainBanner) return true; + + if (!Array.isArray(config.multiChainBanner)) return false; + + const multiChainBanner = config.multiChainBanner; + + if ( + !multiChainBanner.every( + (chainId) => typeof chainId === 'number' && chainId > 0, + ) + ) + return false; + + return !(new Set(multiChainBanner).size !== multiChainBanner.length); +}; + +export const isFeatureFlagsValid = (config: object) => { + // allow empty config + if (!('featureFlags' in config) || !config.featureFlags) return true; -import FallbackLocalManifest from 'IPFS.json' assert { type: 'json' }; + // only objects + return !(typeof config.featureFlags !== 'object'); +}; -const isEnabledDexesValid = (config: object) => { +export const isEnabledDexesValid = (config: object) => { if ( !( 'enabledWithdrawalDexes' in config && @@ -25,30 +54,18 @@ const isEnabledDexesValid = (config: object) => { return new Set(enabledWithdrawalDexes).size === enabledWithdrawalDexes.length; }; -const isMultiChainBannerValid = (config: object) => { - // allow empty config - if (!('multiChainBanner' in config) || !config.multiChainBanner) return true; - - if (!Array.isArray(config.multiChainBanner)) return false; - - const multiChainBanner = config.multiChainBanner; - - if ( - !multiChainBanner.every( - (chainId) => typeof chainId === 'number' && chainId > 0, - ) - ) - return false; - - return !(new Set(multiChainBanner).size !== multiChainBanner.length); -}; +export const isPagesValid = (config: object) => { + if (!('pages' in config)) { + return true; + } -const isFeatureFlagsValid = (config: object) => { - // allow empty config - if (!('featureFlags' in config) || !config.featureFlags) return true; + const pages = config.pages as ManifestConfig['pages']; + if (pages && typeof pages === 'object') { + // INFO: exclude possible issue when stack interface can be deactivated + return !pages[ManifestConfigPageEnum.Stake]?.shouldDisable; + } - // only objects - return !(typeof config.featureFlags !== 'object'); + return false; }; export const isManifestEntryValid = ( @@ -65,25 +82,18 @@ export const isManifestEntryValid = ( ) { const config = entry.config; - return [isEnabledDexesValid, isMultiChainBannerValid, isFeatureFlagsValid] + return [ + isEnabledDexesValid, + isMultiChainBannerValid, + isFeatureFlagsValid, + isPagesValid, + ] .map((validator) => validator(config)) .every((isValid) => isValid); } return false; }; -export const getBackwardCompatibleConfig = ( - config: ManifestEntry['config'], -): ManifestEntry['config'] => { - return { - enabledWithdrawalDexes: config.enabledWithdrawalDexes.filter( - (dex) => !!getDexConfig(dex), - ), - featureFlags: { ...(config.featureFlags ?? {}) }, - multiChainBanner: config.multiChainBanner ?? [], - }; -}; - export const isManifestValid = ( manifest: unknown, chain: number, @@ -96,14 +106,20 @@ export const isManifestValid = ( return false; }; -export const useFallbackManifestEntry = ( - prefetchedManifest: unknown, - chain: number, -): ManifestEntry => { - return useMemo(() => { - const isValid = isManifestValid(prefetchedManifest, chain); - return isValid - ? prefetchedManifest[chain] - : (FallbackLocalManifest as unknown as Manifest)[chain]; - }, [prefetchedManifest, chain]); +// Use in Next backend side +export const shouldRedirectToRoot = ( + currentPath: string, + manifest: Manifest | null, +): boolean => { + const { defaultChain } = config; + const chainSettings = manifest?.[`${defaultChain}`]; + const pages = chainSettings?.config?.pages; + const isDeactivate = + !!pages?.[currentPath as ManifestConfigPage]?.shouldDisable; + // https://nextjs.org/docs/messages/gsp-redirect-during-prerender + const isBuild = process.env.npm_lifecycle_event === 'build'; + + return ( + currentPath !== ManifestConfigPageEnum.Stake && isDeactivate && !isBuild + ); }; diff --git a/consts/chains.ts b/consts/chains.ts index cd959b127..dde822a08 100644 --- a/consts/chains.ts +++ b/consts/chains.ts @@ -21,6 +21,7 @@ export enum LIDO_MULTICHAIN_CHAINS { 'Mode Chain' = 34443, 'Zircuit Chain' = 48900, Unichain = 130, + Metis = 1088, } // TODO: move to @lidofinance/lido-ethereum-sdk package diff --git a/pages/index.tsx b/pages/index.tsx index b66cd7855..794fdb2e0 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -4,6 +4,6 @@ import { HomePageIpfs } from 'features/ipfs'; import { getDefaultStaticProps } from 'utilsApi/get-default-static-props'; -export const getStaticProps = getDefaultStaticProps(); +export const getStaticProps = getDefaultStaticProps('/'); export default config.ipfsMode ? HomePageIpfs : StakePage; diff --git a/pages/referral.tsx b/pages/referral.tsx index 3ab13965b..cbc5e76ac 100644 --- a/pages/referral.tsx +++ b/pages/referral.tsx @@ -11,6 +11,6 @@ const Referral: FC = () => { ); }; -export const getStaticProps = getDefaultStaticProps(); +export const getStaticProps = getDefaultStaticProps('/referral'); export default Referral; diff --git a/pages/rewards.tsx b/pages/rewards.tsx index 0d7fea95f..ceb652dff 100644 --- a/pages/rewards.tsx +++ b/pages/rewards.tsx @@ -32,6 +32,6 @@ const Rewards: FC = () => { ); }; -export const getStaticProps = getDefaultStaticProps(); +export const getStaticProps = getDefaultStaticProps('/rewards'); export default Rewards; diff --git a/pages/settings.tsx b/pages/settings.tsx index 273833e93..b065179db 100644 --- a/pages/settings.tsx +++ b/pages/settings.tsx @@ -13,7 +13,7 @@ const Settings: FC = () => { ); }; -export const getStaticProps = getDefaultStaticProps(async () => { +export const getStaticProps = getDefaultStaticProps('/settings', async () => { if (!config.ipfsMode) return { notFound: true }; return { props: {} }; diff --git a/pages/withdrawals/[mode].tsx b/pages/withdrawals/[mode].tsx index 3d28df4ec..404c48d96 100644 --- a/pages/withdrawals/[mode].tsx +++ b/pages/withdrawals/[mode].tsx @@ -41,7 +41,7 @@ export const getStaticPaths: GetStaticPaths< export const getStaticProps = getDefaultStaticProps< WithdrawalsModePageParams, WithdrawalsModePageParams ->(async ({ params }) => { +>('/withdrawals', async ({ params }) => { if (!params?.mode) return { notFound: true }; return { props: { mode: params.mode } }; }); diff --git a/pages/wrap/[[...mode]].tsx b/pages/wrap/[[...mode]].tsx index 13c82fe9f..d519e0e30 100644 --- a/pages/wrap/[[...mode]].tsx +++ b/pages/wrap/[[...mode]].tsx @@ -44,7 +44,7 @@ export const getStaticPaths: GetStaticPaths = async () => { export const getStaticProps = getDefaultStaticProps< WrapModePageProps, WrapModePageParams ->(async ({ params }) => { +>('/wrap', async ({ params }) => { const mode = params?.mode; if (!mode) return { props: { mode: 'wrap' } }; if (mode[0] === 'unwrap') return { props: { mode: 'unwrap' } }; diff --git a/providers/external-forbidden-route.tsx b/providers/external-forbidden-route.tsx new file mode 100644 index 000000000..e1709058f --- /dev/null +++ b/providers/external-forbidden-route.tsx @@ -0,0 +1,47 @@ +import { useState, useCallback, useMemo, ReactNode } from 'react'; +import { useRouter } from 'next/router'; + +import { useRouterPath } from 'shared/hooks/use-router-path'; +import { useConfig } from 'config'; +import { + ManifestConfigPage, + ManifestConfigPageEnum, +} from 'config/external-config'; +import { HOME_PATH } from 'consts/urls'; + +import { LayoutEffectSsrDelayed } from 'shared/components/layout-effect-ssr-delayed'; + +export const ExternalForbiddenRouteProvider = ({ + children, +}: { + children: ReactNode; +}) => { + const [showContent, setShowContent] = useState(true); + const router = useRouter(); + const path = useRouterPath(); + const { pages } = useConfig().externalConfig; + + const checkPathEffect = useCallback(() => { + if (pages) { + const paths = Object.keys(pages) as ManifestConfigPage[]; + const forbiddenPath = paths.find((pathKey) => path.includes(pathKey)); + if ( + forbiddenPath && + forbiddenPath !== ManifestConfigPageEnum.Stake && + pages[forbiddenPath]?.shouldDisable + ) { + setShowContent(false); + void router.push(HOME_PATH).finally(() => setShowContent(true)); + } + } + }, [pages, path, router]); + + const effectDeps = useMemo(() => [pages, path], [pages, path]); + + return ( + <> + + {showContent && children} + + ); +}; diff --git a/providers/index.tsx b/providers/index.tsx index 748927147..39990d96e 100644 --- a/providers/index.tsx +++ b/providers/index.tsx @@ -11,6 +11,7 @@ import { AppFlagProvider } from './app-flag'; import { IPFSInfoBoxStatusesProvider } from './ipfs-info-box-statuses'; import { InpageNavigationProvider } from './inpage-navigation'; import { ModalProvider } from './modal-provider'; +import { ExternalForbiddenRouteProvider } from './external-forbidden-route'; type ProvidersProps = { prefetchedManifest?: unknown; @@ -36,7 +37,11 @@ export const Providers: FC> = ({ - {children} + + + {children} + + diff --git a/shared/components/layout/header/components/navigation/navigation.tsx b/shared/components/layout/header/components/navigation/navigation.tsx index a1823bf7e..496d4e4ac 100644 --- a/shared/components/layout/header/components/navigation/navigation.tsx +++ b/shared/components/layout/header/components/navigation/navigation.tsx @@ -1,4 +1,4 @@ -import { FC, memo } from 'react'; +import { FC, memo, useMemo } from 'react'; import { Wallet, Stake, Wrap, Withdraw } from '@lidofinance/lido-ui'; import { @@ -9,12 +9,23 @@ import { REWARDS_PATH, getPathWithoutFirstSlash, } from 'consts/urls'; +import { useConfig } from 'config'; +import { ManifestConfigPage } from 'config/external-config'; import { LocalLink } from 'shared/components/local-link'; import { useRouterPath } from 'shared/hooks/use-router-path'; import { Nav, NavLink } from './styles'; -const routes = [ +type PageRoute = { + name: string; + path: string; + icon: React.ReactNode; + exact?: boolean; + full_path?: string; + subPaths?: string[]; +}; + +const routes: PageRoute[] = [ { name: 'Stake', path: HOME_PATH, @@ -39,8 +50,24 @@ const routes = [ icon: , }, ]; + export const Navigation: FC = memo(() => { const pathname = useRouterPath(); + const { + externalConfig: { pages }, + } = useConfig(); + + const availableRoutes = useMemo(() => { + if (!pages) return routes; + + const paths = Object.keys(pages) as ManifestConfigPage[]; + return routes.filter((route) => { + const path = paths.find((path) => route.path.includes(path)); + if (!path) return true; + return !pages[path]?.shouldDisable; + }); + }, [pages]); + let pathnameWithoutQuery = pathname.split('?')[0]; if (pathnameWithoutQuery[pathnameWithoutQuery.length - 1] === '/') { // Remove last '/' @@ -49,7 +76,7 @@ export const Navigation: FC = memo(() => { return (