Commit d8ffa02d authored by Max Alekseenko's avatar Max Alekseenko Committed by GitHub

Promo banner in the navigation menu (#2859)

parent bf74f517
import type { ContractCodeIde } from 'types/client/contract'; import type { ContractCodeIde } from 'types/client/contract';
import { type NavItemExternal, type NavigationLayout } from 'types/client/navigation'; import { type NavItemExternal, type NavigationLayout, type NavigationPromoBannerConfig } from 'types/client/navigation';
import { HOME_STATS_WIDGET_IDS, type ChainIndicatorId, type HeroBannerConfig, type HomeStatsWidgetId } from 'types/homepage'; import { HOME_STATS_WIDGET_IDS, type ChainIndicatorId, type HeroBannerConfig, type HomeStatsWidgetId } from 'types/homepage';
import type { NetworkExplorer } from 'types/networks'; import type { NetworkExplorer } from 'types/networks';
import type { ColorThemeId } from 'types/settings'; import type { ColorThemeId } from 'types/settings';
...@@ -37,6 +37,11 @@ const defaultColorTheme = (() => { ...@@ -37,6 +37,11 @@ const defaultColorTheme = (() => {
return COLOR_THEMES.find((theme) => theme.id === envValue) as ColorTheme | undefined; return COLOR_THEMES.find((theme) => theme.id === envValue) as ColorTheme | undefined;
})(); })();
const navigationPromoBanner = (() => {
const envValue = parseEnvJson<NavigationPromoBannerConfig>(getEnvValue('NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG'));
return envValue || undefined;
})();
const UI = Object.freeze({ const UI = Object.freeze({
navigation: { navigation: {
logo: { logo: {
...@@ -51,6 +56,7 @@ const UI = Object.freeze({ ...@@ -51,6 +56,7 @@ const UI = Object.freeze({
otherLinks: parseEnvJson<Array<NavItemExternal>>(getEnvValue('NEXT_PUBLIC_OTHER_LINKS')) || [], otherLinks: parseEnvJson<Array<NavItemExternal>>(getEnvValue('NEXT_PUBLIC_OTHER_LINKS')) || [],
featuredNetworks: getExternalAssetFilePath('NEXT_PUBLIC_FEATURED_NETWORKS'), featuredNetworks: getExternalAssetFilePath('NEXT_PUBLIC_FEATURED_NETWORKS'),
layout: (getEnvValue('NEXT_PUBLIC_NAVIGATION_LAYOUT') || 'vertical') as NavigationLayout, layout: (getEnvValue('NEXT_PUBLIC_NAVIGATION_LAYOUT') || 'vertical') as NavigationLayout,
promoBanner: navigationPromoBanner,
}, },
footer: { footer: {
links: getExternalAssetFilePath('NEXT_PUBLIC_FOOTER_LINKS'), links: getExternalAssetFilePath('NEXT_PUBLIC_FOOTER_LINKS'),
......
...@@ -73,3 +73,4 @@ NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com ...@@ -73,3 +73,4 @@ NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao', 'humanpassport', 'trustblock', 'bankless'] NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao', 'humanpassport', 'trustblock', 'bankless']
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json
NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG={'img_url': 'https://gist.githubusercontent.com/maxaleks/0a02a0f15d018674171dc03e13f85cdd/raw/7ef0a5a3f7ec48bc202634d7aa6dd6fd71c8d0c5/promo.svg', 'text': 'Try Multichain!', 'bg_color': {'light': 'rgb(250, 245, 255)', 'dark': 'rgb(68, 51, 122)'}, 'text_color': {'light': 'rgb(107, 70, 193)', 'dark': 'rgb(233, 216, 253)'}, 'link_url': 'https://www.blockscout.com?utm_source=1&utm_medium=side-menu-banner'}
...@@ -22,7 +22,7 @@ import type { MarketplaceAppOverview, MarketplaceAppSecurityReportRaw, Marketpla ...@@ -22,7 +22,7 @@ import type { MarketplaceAppOverview, MarketplaceAppSecurityReportRaw, Marketpla
import type { MultichainProviderConfig } from '../../../types/client/multichainProviderConfig'; import type { MultichainProviderConfig } from '../../../types/client/multichainProviderConfig';
import type { ApiDocsTabId } from '../../../types/views/apiDocs'; import type { ApiDocsTabId } from '../../../types/views/apiDocs';
import { API_DOCS_TABS } from '../../../types/views/apiDocs'; import { API_DOCS_TABS } from '../../../types/views/apiDocs';
import type { NavItemExternal, NavigationLayout } from '../../../types/client/navigation'; import type { NavItemExternal, NavigationLayout, NavigationPromoBannerConfig } from '../../../types/client/navigation';
import { ROLLUP_TYPES } from '../../../types/client/rollup'; import { ROLLUP_TYPES } from '../../../types/client/rollup';
import type { BridgedTokenChain, TokenBridge } from '../../../types/client/token'; import type { BridgedTokenChain, TokenBridge } from '../../../types/client/token';
import { PROVIDERS as TX_INTERPRETATION_PROVIDERS } from '../../../types/client/txInterpretation'; import { PROVIDERS as TX_INTERPRETATION_PROVIDERS } from '../../../types/client/txInterpretation';
...@@ -906,6 +906,36 @@ const schema = yup ...@@ -906,6 +906,36 @@ const schema = yup
.json() .json()
.of(yup.string()), .of(yup.string()),
NEXT_PUBLIC_NAVIGATION_LAYOUT: yup.string<NavigationLayout>().oneOf([ 'horizontal', 'vertical' ]), NEXT_PUBLIC_NAVIGATION_LAYOUT: yup.string<NavigationLayout>().oneOf([ 'horizontal', 'vertical' ]),
NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG: yup
.mixed()
.test('shape', 'Invalid schema were provided for NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG, it should be either object with img_url, text, bg_color, text_color, link_url or object with img_url and link_url', (data) => {
const isUndefined = data === undefined;
const jsonSchema = yup.object<NavigationPromoBannerConfig>().transform(replaceQuotes).json();
const valueSchema1 = jsonSchema.shape({
img_url: yup.string().required(),
text: yup.string().required(),
bg_color: yup.object().shape({
light: yup.string().required(),
dark: yup.string().required(),
}).required(),
text_color: yup.object().shape({
light: yup.string().required(),
dark: yup.string().required(),
}).required(),
link_url: yup.string().required(),
});
const valueSchema2 = jsonSchema.shape({
img_url: yup.object().shape({
small: yup.string().required(),
large: yup.string().required(),
}).required(),
link_url: yup.string().required(),
});
return isUndefined || valueSchema1.isValidSync(data) || valueSchema2.isValidSync(data);
}),
NEXT_PUBLIC_NETWORK_LOGO: yup.string().test(urlTest), NEXT_PUBLIC_NETWORK_LOGO: yup.string().test(urlTest),
NEXT_PUBLIC_NETWORK_LOGO_DARK: yup.string().test(urlTest), NEXT_PUBLIC_NETWORK_LOGO_DARK: yup.string().test(urlTest),
NEXT_PUBLIC_NETWORK_ICON: yup.string().test(urlTest), NEXT_PUBLIC_NETWORK_ICON: yup.string().test(urlTest),
......
...@@ -10,3 +10,4 @@ NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://example.com ...@@ -10,3 +10,4 @@ NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_METADATA_ADDRESS_TAGS_UPDATE_ENABLED=false NEXT_PUBLIC_METADATA_ADDRESS_TAGS_UPDATE_ENABLED=false
NEXT_PUBLIC_HAS_USER_OPS=true NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST=https://example.com NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST=https://example.com
NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG={'img_url': {'small': 'https://example.com/promo-sm.png', 'large': 'https://example.com/promo-lg.png'}, 'link_url': 'https://example.com'}
...@@ -90,3 +90,4 @@ NEXT_PUBLIC_SAVE_ON_GAS_ENABLED=true ...@@ -90,3 +90,4 @@ NEXT_PUBLIC_SAVE_ON_GAS_ENABLED=true
NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=https://example.com NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['widget-1', 'widget-2'] NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['widget-1', 'widget-2']
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://example.com NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://example.com
NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG={'img_url': 'https://example.com/promo.svg', 'text': 'Promo text', 'bg_color': {'light': 'rgb(250, 245, 255)', 'dark': 'rgb(68, 51, 122)'}, 'text_color': {'light': 'rgb(107, 70, 193)', 'dark': 'rgb(233, 216, 253)'}, 'link_url': 'https://example.com'}
...@@ -165,6 +165,7 @@ _Note_ Here, all values are arrays of up to two strings. The first string repres ...@@ -165,6 +165,7 @@ _Note_ Here, all values are arrays of up to two strings. The first string repres
| NEXT_PUBLIC_OTHER_LINKS | `Array<{url: string; text: string}>` | List of links for the "Other" navigation menu | - | - | `[{'url':'https://blockscout.com','text':'Blockscout'}]` | v1.0.x+ | | NEXT_PUBLIC_OTHER_LINKS | `Array<{url: string; text: string}>` | List of links for the "Other" navigation menu | - | - | `[{'url':'https://blockscout.com','text':'Blockscout'}]` | v1.0.x+ |
| NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES | `Array<string>` | List of menu item routes that should have a lightning label | - | - | `['/accounts']` | v1.31.0+ | | NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES | `Array<string>` | List of menu item routes that should have a lightning label | - | - | `['/accounts']` | v1.31.0+ |
| NEXT_PUBLIC_NAVIGATION_LAYOUT | `vertical \| horizontal` | Navigation menu layout type | - | `vertical` | `horizontal` | v1.32.0+ | | NEXT_PUBLIC_NAVIGATION_LAYOUT | `vertical \| horizontal` | Navigation menu layout type | - | `vertical` | `horizontal` | v1.32.0+ |
| NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG | `string` | Configuration of promo banner in the navigation menu. See [below](#navigation-promo-banner-configuration-properties) list of available properties for particular banner type | - | - | `{'img_url': 'https://example.com/promo.svg', 'text': 'Promo text', 'bg_color': {'light': 'rgb(250, 245, 255)', 'dark': 'rgb(68, 51, 122)'}, 'text_color': {'light': 'rgb(107, 70, 193)', 'dark': 'rgb(233, 216, 253)'}, 'link_url': 'https://example.com'}` | v2.3.0+ |
#### Featured network configuration properties #### Featured network configuration properties
...@@ -177,6 +178,25 @@ _Note_ Here, all values are arrays of up to two strings. The first string repres ...@@ -177,6 +178,25 @@ _Note_ Here, all values are arrays of up to two strings. The first string repres
| isActive | `boolean` | Pass `true` if item should be shown as active in the menu | - | - | `true` | | isActive | `boolean` | Pass `true` if item should be shown as active in the menu | - | - | `true` |
| invertIconInDarkMode | `boolean` | Pass `true` if icon colors should be inverted in dark mode | - | - | `true` | | invertIconInDarkMode | `boolean` | Pass `true` if icon colors should be inverted in dark mode | - | - | `true` |
#### Navigation promo banner configuration properties
##### Text promo banner:
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| img_url | `string` | Displayed icon url. The recommended minimum image size is 60x60 pixels (1:1 aspect ratio). | Required | - | `https://example.com/promo.svg` |
| text | `string` | Displayed text | Required | - | `Promo text` |
| bg_color | `{'light': string, 'dark': string}` | Background color | Required | - | `{'light': 'rgb(250, 245, 255)', 'dark': 'rgb(68, 51, 122)'}` |
| text_color | `{'light': string, 'dark': string}` | Text color | Required | - | `{'light': 'rgb(107, 70, 193)', 'dark': 'rgb(233, 216, 253)'}` |
| link_url | `string` | Redirect link url | Required | - | `https://example.com` |
##### Image promo banner:
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| img_url | `{'small': string, 'large': string}` | Displayed image urls. Small image is used in the collapsed navigation menu and in horizontal navigation, large image is used in the expanded navigation menu and in tooltip. The recommended minimum image sizes are 120x120 pixels (1:1 aspect ratio) for small image and 500x250 pixels (2:1 aspect ratio) for large image. | Required | - | `{'small': 'https://example.com/promo-sm.svg', 'large': 'https://example.com/promo-lg.svg'}` |
| link_url | `string` | Redirect link url | Required | - | `https://example.com` |
&nbsp; &nbsp;
### Footer ### Footer
......
...@@ -113,4 +113,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ...@@ -113,4 +113,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
celo: [ celo: [
[ 'NEXT_PUBLIC_CELO_ENABLED', 'true' ], [ 'NEXT_PUBLIC_CELO_ENABLED', 'true' ],
], ],
navigationPromoBannerText: [
[ 'NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG', '{"img_url": "http://localhost:3000/image.svg", "text": "Try the DUCK!", "bg_color": {"light": "rgb(150, 211, 255)", "dark": "rgb(68, 51, 122)"}, "text_color": {"light": "rgb(69, 69, 69)", "dark": "rgb(233, 216, 253)"}, "link_url": "https://example.com"}' ],
],
navigationPromoBannerImage: [
[ 'NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG', '{"img_url": {"small": "http://localhost:3000/image_s.jpg", "large": "http://localhost:3000/image_md.jpg"}, "link_url": "https://example.com"}' ],
],
}; };
...@@ -32,3 +32,23 @@ export type NavGroupItem = NavItemCommon & { ...@@ -32,3 +32,23 @@ export type NavGroupItem = NavItemCommon & {
}; };
export type NavigationLayout = 'vertical' | 'horizontal'; export type NavigationLayout = 'vertical' | 'horizontal';
export type NavigationPromoBannerConfig = {
img_url: string;
text: string;
bg_color: {
light: string;
dark: string;
};
text_color: {
light: string;
dark: string;
};
link_url: string;
} | {
img_url: {
small: string;
large: string;
};
link_url: string;
};
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import { FEATURED_NETWORKS } from 'mocks/config/network'; import { FEATURED_NETWORKS } from 'mocks/config/network';
import { contextWithAuth } from 'playwright/fixtures/auth'; import { contextWithAuth } from 'playwright/fixtures/auth';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
import Burger from './Burger'; import Burger from './Burger';
...@@ -76,3 +77,25 @@ authTest.describe('auth', () => { ...@@ -76,3 +77,25 @@ authTest.describe('auth', () => {
await expect(page).toHaveScreenshot(); await expect(page).toHaveScreenshot();
}); });
}); });
const promoBannerTest = (type: 'text' | 'image') => {
test.describe(`with promo banner (${ type })`, () => {
const darkModeRule = type === 'text' ? '+@dark-mode' : '';
test.beforeEach(async({ mockEnvs, mockAssetResponse }) => {
await mockEnvs(type === 'text' ? ENVS_MAP.navigationPromoBannerText : ENVS_MAP.navigationPromoBannerImage);
await mockAssetResponse('http://localhost:3000/image.svg', './playwright/mocks/image_svg.svg');
await mockAssetResponse('http://localhost:3000/image_s.jpg', './playwright/mocks/image_s.jpg');
await mockAssetResponse('http://localhost:3000/image_md.jpg', './playwright/mocks/image_md.jpg');
});
test(`${ darkModeRule }`, async({ render, page }) => {
const component = await render(<Burger/>);
await component.getByRole('button', { name: 'Menu button' }).click();
await expect(page).toHaveScreenshot();
});
});
};
promoBannerTest('text');
promoBannerTest('image');
import type { BrowserContext } from '@playwright/test'; import type { BrowserContext, Locator } from '@playwright/test';
import React from 'react'; import React from 'react';
import * as rewardsBalanceMock from 'mocks/rewards/balance'; import * as rewardsBalanceMock from 'mocks/rewards/balance';
...@@ -63,3 +63,36 @@ test('with groped items', async({ render, mockEnvs, page }) => { ...@@ -63,3 +63,36 @@ test('with groped items', async({ render, mockEnvs, page }) => {
await expect(page.getByText('Blocks')).toBeVisible(); await expect(page.getByText('Blocks')).toBeVisible();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 450 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 450 } });
}); });
const promoBannerTest = (type: 'text' | 'image') => {
test.describe(`with promo banner (${ type })`, () => {
let component: Locator;
const darkModeRule = type === 'text' ? '+@dark-mode' : '';
const imageAltText = type === 'text' ? 'Promo banner icon' : 'Promo banner small';
test.beforeEach(async({ render, mockEnvs, mockAssetResponse }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_NAVIGATION_LAYOUT', 'horizontal' ],
...(type === 'text' ? ENVS_MAP.navigationPromoBannerText : ENVS_MAP.navigationPromoBannerImage),
]);
await mockAssetResponse('http://localhost:3000/image.svg', './playwright/mocks/image_svg.svg');
await mockAssetResponse('http://localhost:3000/image_s.jpg', './playwright/mocks/image_s.jpg');
await mockAssetResponse('http://localhost:3000/image_md.jpg', './playwright/mocks/image_md.jpg');
component = await render(<NavigationDesktop/>);
await component.waitFor({ state: 'visible' });
});
test(`${ darkModeRule }`, async() => {
await expect(component).toHaveScreenshot();
});
test('with tooltip', async({ page }) => {
await page.getByAltText(imageAltText).hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 450 } });
});
});
};
promoBannerTest('text');
promoBannerTest('image');
...@@ -9,6 +9,7 @@ import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo'; ...@@ -9,6 +9,7 @@ import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop'; import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop'; import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
import NavigationPromoBanner from '../promoBanner/NavigationPromoBanner';
import RollupStageBadge from '../RollupStageBadge'; import RollupStageBadge from '../RollupStageBadge';
import TestnetBadge from '../TestnetBadge'; import TestnetBadge from '../TestnetBadge';
import NavLink from './NavLink'; import NavLink from './NavLink';
...@@ -42,6 +43,7 @@ const NavigationDesktop = () => { ...@@ -42,6 +43,7 @@ const NavigationDesktop = () => {
</Flex> </Flex>
</chakra.nav> </chakra.nav>
<Flex gap={ 2 }> <Flex gap={ 2 }>
<NavigationPromoBanner/>
{ config.features.rewards.isEnabled && <RewardsButton size="sm"/> } { config.features.rewards.isEnabled && <RewardsButton size="sm"/> }
{ {
(config.features.account.isEnabled && <UserProfileDesktop buttonSize="sm"/>) || (config.features.account.isEnabled && <UserProfileDesktop buttonSize="sm"/>) ||
......
...@@ -6,6 +6,7 @@ import { useColorModeValue } from 'toolkit/chakra/color-mode'; ...@@ -6,6 +6,7 @@ import { useColorModeValue } from 'toolkit/chakra/color-mode';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import useIsAuth from 'ui/snippets/auth/useIsAuth'; import useIsAuth from 'ui/snippets/auth/useIsAuth';
import NavigationPromoBanner from '../promoBanner/NavigationPromoBanner';
import NavLink from '../vertical/NavLink'; import NavLink from '../vertical/NavLink';
import NavLinkRewards from '../vertical/NavLinkRewards'; import NavLinkRewards from '../vertical/NavLinkRewards';
import NavLinkGroup from './NavLinkGroup'; import NavLinkGroup from './NavLinkGroup';
...@@ -98,6 +99,7 @@ const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => { ...@@ -98,6 +99,7 @@ const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => {
</VStack> </VStack>
</Box> </Box>
) } ) }
<NavigationPromoBanner isCollapsed={ isCollapsed }/>
</Box> </Box>
<Box <Box
key="sub" key="sub"
......
import { Flex, Box, useBreakpointValue, chakra } from '@chakra-ui/react';
import React, { useCallback, useState, useEffect } from 'react';
import { keccak256, stringToBytes } from 'viem';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import { Tooltip } from 'toolkit/chakra/tooltip';
import IconSvg from 'ui/shared/IconSvg';
import NavigationPromoBannerContent from './NavigationPromoBannerContent';
const PROMO_BANNER_CLOSED_HASH_KEY = 'nav-promo-banner-closed-hash';
const promoBanner = config.UI.navigation.promoBanner;
const isHorizontalNavigation = config.UI.navigation.layout === 'horizontal';
type Props = {
isCollapsed?: boolean;
};
const NavigationPromoBanner = ({ isCollapsed }: Props) => {
const isMobile = useIsMobile();
const isXLScreen = useBreakpointValue({ base: false, xl: true });
const [ isShown, setIsShown ] = useState(false);
const [ promoBannerHash, setPromoBannerHash ] = useState('');
useEffect(() => {
try {
const promoBannerClosedHash = window.localStorage.getItem(PROMO_BANNER_CLOSED_HASH_KEY);
const promoBannerHash = keccak256(stringToBytes(JSON.stringify(promoBanner)));
setIsShown(promoBannerHash !== promoBannerClosedHash);
setPromoBannerHash(promoBannerHash);
} catch {}
}, []);
const handleClose = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
localStorage.setItem(PROMO_BANNER_CLOSED_HASH_KEY, promoBannerHash);
setIsShown(false);
}, [ promoBannerHash ]);
const isTooltipDisabled = isMobile || (!isHorizontalNavigation && (isCollapsed === false || (isCollapsed === undefined && isXLScreen)));
if (!promoBanner || !isShown) {
return null;
}
return (
<Flex flex={ 1 } mt={ isHorizontalNavigation ? 0 : 3 } pointerEvents="none">
<chakra.a
href={ promoBanner.link_url }
target="_blank"
rel="noopener noreferrer"
pointerEvents="auto"
w="full"
minW={ isHorizontalNavigation ? 'auto' : '60px' }
mt="auto"
position={ isHorizontalNavigation ? undefined : 'sticky' }
bottom={ isHorizontalNavigation ? undefined : { base: 0, lg: 6 } }
overflow="hidden"
_hover={{
opacity: 0.8,
_icon: {
display: 'block',
},
}}
>
<Tooltip
content={ !isTooltipDisabled && (
<NavigationPromoBannerContent
isCollapsed={ false }
isHorizontalNavigation={ false }
/>
) }
showArrow={ false }
positioning={{
placement: isHorizontalNavigation ? 'bottom' : 'right-end',
offset: { crossAxis: 0, mainAxis: isHorizontalNavigation ? 8 : 5 },
}}
contentProps={{
p: 0,
borderRadius: 'base',
bgColor: 'transparent',
boxShadow: isHorizontalNavigation ? '2xl' : 'none',
cursor: 'default',
}}
interactive
>
<Box w="full" position="relative">
<NavigationPromoBannerContent
isCollapsed={ isCollapsed }
isHorizontalNavigation={ isHorizontalNavigation }
/>
<IconSvg
onClick={ handleClose }
name="close"
boxSize={ 3 }
color={{ _light: 'gray.300', _dark: 'gray.600' }}
bgColor="global.body.bg"
borderBottomLeftRadius="sm"
position="absolute"
top="0"
right="0"
display="none"
/>
</Box>
</Tooltip>
</chakra.a>
</Flex>
);
};
export default NavigationPromoBanner;
import { HStack, Text, Box } from '@chakra-ui/react';
import config from 'configs/app';
import { Image } from 'toolkit/chakra/image';
import useNavLinkStyleProps from '../useNavLinkStyleProps';
const promoBanner = config.UI.navigation.promoBanner;
type Props = {
isCollapsed?: boolean;
isHorizontalNavigation?: boolean;
};
const NavigationPromoBannerContent = ({ isCollapsed, isHorizontalNavigation }: Props) => {
const isExpanded = isCollapsed === false;
const navLinkStyleProps = useNavLinkStyleProps({ isCollapsed, isExpanded });
if (!promoBanner) {
return null;
}
return 'text' in promoBanner ? (
<HStack
{ ...navLinkStyleProps.itemProps }
minW={ isHorizontalNavigation ? 'auto' : 'full' }
maxW={ isHorizontalNavigation ? 'auto' : 'full' }
w={ isHorizontalNavigation ? 'auto' : '180px' }
gap={ 2 }
overflow="hidden"
whiteSpace="nowrap"
py={ isHorizontalNavigation ? 1.5 : 2 }
px={ isHorizontalNavigation ? 1.5 : { base: 3, lg: isExpanded ? 3 : '15px', xl: isCollapsed ? '15px' : 3 } }
bgColor={{ _light: promoBanner.bg_color.light, _dark: promoBanner.bg_color.dark }}
>
<Image
src={ promoBanner.img_url }
alt="Promo banner icon"
boxSize={ isHorizontalNavigation ? '20px' : '30px' }
/>
{ !isHorizontalNavigation && (
<Text
{ ...navLinkStyleProps.textProps }
fontWeight="medium"
color={{ _light: promoBanner.text_color.light, _dark: promoBanner.text_color.dark }}
overflow="hidden"
>
{ promoBanner.text }
</Text>
) }
</HStack>
) : (
<Box
position="relative"
minH={ isHorizontalNavigation ? 'auto' : '60px' }
>
<Image
src={ promoBanner.img_url.small }
alt="Promo banner small"
boxSize={ isHorizontalNavigation ? '32px' : '60px' }
borderRadius={ isHorizontalNavigation ? 'sm' : 'base' }
position={ isHorizontalNavigation ? undefined : 'absolute' }
top={ isHorizontalNavigation ? undefined : 'calc(50% - 30px)' }
left={ isHorizontalNavigation ? undefined : 'calc(50% - 30px)' }
opacity={ isHorizontalNavigation ? 1 : { base: 0, lg: isExpanded ? 0 : 1, xl: isCollapsed ? 1 : 0 } }
transitionProperty="opacity"
transitionDuration="normal"
transitionTimingFunction="ease"
/>
<Image
display={ isHorizontalNavigation ? 'none' : 'block' }
src={ promoBanner.img_url.large }
alt="Promo banner large"
w="full"
maxW={{ base: 'full', lg: '180px' }}
borderRadius="base"
aspectRatio={ 2 / 1 }
opacity={{ base: 1, lg: isExpanded ? 1 : 0, xl: isCollapsed ? 0 : 1 }}
transitionProperty="opacity"
transitionDuration="normal"
transitionTimingFunction="ease"
/>
</Box>
);
};
export default NavigationPromoBannerContent;
...@@ -256,3 +256,48 @@ test.describe('with highlighted routes', () => { ...@@ -256,3 +256,48 @@ test.describe('with highlighted routes', () => {
}); });
}); });
}); });
const promoBannerTest = (type: 'text' | 'image') => {
test.describe(`with promo banner (${ type })`, () => {
let component: Locator;
const darkModeRule = type === 'text' ? '+@dark-mode' : '';
const imageAltText = type === 'text' ? 'Promo banner icon' : 'Promo banner small';
test.beforeEach(async({ render, mockEnvs, mockAssetResponse }) => {
await mockEnvs(type === 'text' ? ENVS_MAP.navigationPromoBannerText : ENVS_MAP.navigationPromoBannerImage);
await mockAssetResponse('http://localhost:3000/image.svg', './playwright/mocks/image_svg.svg');
await mockAssetResponse('http://localhost:3000/image_s.jpg', './playwright/mocks/image_s.jpg');
await mockAssetResponse('http://localhost:3000/image_md.jpg', './playwright/mocks/image_md.jpg');
component = await render(
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Box bgColor="lightpink" w="100%"/>
</Flex>,
{ hooksConfig },
);
await component.waitFor({ state: 'visible' });
});
test(`${ darkModeRule }`, async() => {
await expect(component).toHaveScreenshot();
});
test('with tooltip', async({ page }) => {
await page.getByAltText(imageAltText).hover();
await expect(component).toHaveScreenshot();
});
test.describe('xl screen', () => {
test.use({ viewport: pwConfig.viewport.xl });
test(`${ darkModeRule }`, async() => {
await expect(component).toHaveScreenshot();
});
});
});
};
promoBannerTest('text');
promoBannerTest('image');
...@@ -8,6 +8,7 @@ import IconSvg from 'ui/shared/IconSvg'; ...@@ -8,6 +8,7 @@ import IconSvg from 'ui/shared/IconSvg';
import useIsAuth from 'ui/snippets/auth/useIsAuth'; import useIsAuth from 'ui/snippets/auth/useIsAuth';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo'; import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import NavigationPromoBanner from '../promoBanner/NavigationPromoBanner';
import RollupStageBadge from '../RollupStageBadge'; import RollupStageBadge from '../RollupStageBadge';
import TestnetBadge from '../TestnetBadge'; import TestnetBadge from '../TestnetBadge';
import NavLink from './NavLink'; import NavLink from './NavLink';
...@@ -56,7 +57,8 @@ const NavigationDesktop = () => { ...@@ -56,7 +57,8 @@ const NavigationDesktop = () => {
borderRight="1px solid" borderRight="1px solid"
borderColor="border.divider" borderColor="border.divider"
px={{ lg: isExpanded ? 6 : 4, xl: isCollapsed ? 4 : 6 }} px={{ lg: isExpanded ? 6 : 4, xl: isCollapsed ? 4 : 6 }}
py={ 12 } pt={ 12 }
pb={ 6 }
width={{ lg: isExpanded ? '229px' : '92px', xl: isCollapsed ? '92px' : '229px' }} width={{ lg: isExpanded ? '229px' : '92px', xl: isCollapsed ? '92px' : '229px' }}
onClick={ handleContainerClick } onClick={ handleContainerClick }
transitionProperty="width, padding" transitionProperty="width, padding"
...@@ -100,6 +102,7 @@ const NavigationDesktop = () => { ...@@ -100,6 +102,7 @@ const NavigationDesktop = () => {
</VStack> </VStack>
</Box> </Box>
) } ) }
<NavigationPromoBanner isCollapsed={ isCollapsed }/>
<IconSvg <IconSvg
name="arrows/east-mini" name="arrows/east-mini"
width={ 6 } width={ 6 }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment