Highlights banners on home page (#3175)

* basic implementation

* fix loading state of image

* tests

* try to fix flaky tests
This commit is contained in:
tom goriunov
2025-12-18 12:21:53 +01:00
committed by GitHub
parent 71219d661b
commit 5627820db8
24 changed files with 330 additions and 3 deletions
+1
View File
@@ -71,6 +71,7 @@ const UI = Object.freeze({
charts: parseEnvJson<Array<ChainIndicatorId>>(getEnvValue('NEXT_PUBLIC_HOMEPAGE_CHARTS')) || [],
stats: homePageStats,
heroBanner: parseEnvJson<HeroBannerConfig>(getEnvValue('NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG')),
highlights: getExternalAssetFilePath('NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG'),
},
views,
indexingAlert: {
+2
View File
@@ -9,6 +9,8 @@ NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/homepage-highlights/test.json
# Instance ENVs
NEXT_PUBLIC_AD_BANNER_ENABLE_SPECIFY=true
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao', 'humanpassport', 'trustblock', 'smartmuv', 'blockscoutbadges', 'etherscore', 'gitpoap', 'drops', 'humanode']
+1
View File
@@ -27,6 +27,7 @@ ASSETS_ENVS=(
"NEXT_PUBLIC_OG_IMAGE_URL"
"NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL"
"NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL"
"NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG"
)
# Create the assets directory if it doesn't exist
+1
View File
@@ -48,6 +48,7 @@ async function validateEnvs(appEnvs: Record<string, string>) {
'NEXT_PUBLIC_FOOTER_LINKS',
'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL',
'NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL',
'NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG',
];
for await (const envName of envsWithJsonConfig) {
+1
View File
@@ -164,6 +164,7 @@ const schema = yup
.concat(featuresSchemas.beaconChainSchema)
.concat(featuresSchemas.bridgedTokensSchema)
.concat(featuresSchemas.defiDropdownSchema)
.concat(featuresSchemas.highlightsConfigSchema)
.concat(featuresSchemas.marketplaceSchema)
.concat(featuresSchemas.megaEthSchema)
.concat(featuresSchemas.rollupSchema)
@@ -0,0 +1,25 @@
import { HighlightsBannerConfig } from "types/homepage";
import { urlTest } from "../../utils";
import * as yup from 'yup';
const highlightsBannerConfigSchema: yup.ObjectSchema<HighlightsBannerConfig> = yup.object({
title: yup.string().required(),
description: yup.string().required(),
title_color: yup.array().max(2).of(yup.string()),
description_color: yup.array().max(2).of(yup.string()),
background: yup.array().max(2).of(yup.string()),
side_img_url: yup.array().max(2).of(yup.string()),
is_pinned: yup.boolean(),
page_path: yup.string(),
redirect_url: yup.string().test(urlTest),
});
export const highlightsConfigSchema = yup
.object()
.shape({
NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG: yup
.array()
.json()
.of(highlightsBannerConfigSchema)
.min(2)
});
@@ -4,6 +4,7 @@ export * from './apiDocs';
export * from './beaconChain';
export * from './bridgedToken';
export * from './defiDropdown';
export * from './highlights';
export * from './marketplace';
export * from './megaEth';
export * from './rollup';
@@ -92,3 +92,4 @@ NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['widget-1', 'widget-2']
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'}
NEXT_PUBLIC_FLASHBLOCKS_SOCKET_URL=wss://example.com/ws
NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG=https://example.com
@@ -0,0 +1,21 @@
[
{
"title": "Duck Deep into Transactions",
"description": "Explore and track all blockchain transactions",
"title_color": ["#1a472a", "#4ade80"],
"description_color": ["#374151", "#d1d5db"],
"background": ["#e0f2fe", "#0f172a"],
"side_img_url": [
"https://example.com",
"https://example.com"
],
"page_path": "/txs"
},
{
"title": "Capybara Hot Spring Pools",
"description": "Monitor liquidity and staking pools",
"is_pinned": true,
"redirect_url": "https://example.com"
}
]
+18
View File
@@ -144,6 +144,7 @@ Also, be aware that if you customize the name of the currency or any of its deno
| NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'daily_operational_txs' \| 'coin_price' \| 'secondary_coin_price' \| 'market_cap' \| 'tvl'>` | List of charts displayed on the home page | - | - | `['daily_txs','coin_price','market_cap']` | v1.0.x+ |
| NEXT_PUBLIC_HOMEPAGE_STATS | `Array<'latest_batch' \| 'total_blocks' \| 'average_block_time' \| 'total_txs' \| 'total_operational_txs' \| 'latest_l1_state_batch' \| 'wallet_addresses' \| 'gas_tracker' \| 'btc_locked' \| 'current_epoch'>` | List of stats widgets displayed on the home page | - | For zkSync, zkEvm and Arbitrum rollups: `['latest_batch','average_block_time','total_txs','wallet_addresses','gas_tracker']`, for other cases: `['total_blocks','average_block_time','total_txs','wallet_addresses','gas_tracker']` | `['total_blocks','total_txs','wallet_addresses']` | v1.35.x+ |
| NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG | `HeroBannerConfig`, see details [below](#hero-banner-configuration-properties) | Configuration of hero banner appearance. | - | - | See [below](#hero-banner-configuration-properties) | v1.35.0+ |
| NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG | `string` | URL of the file (in `.json` format only) that contains the configuration for banners on the application's homepage, showcasing some of its key functionality. See the full config format [below](#highlights-banner-configuration-properties). The config should contain at least 2 banners, but only 3 banners will be visible at the same time. A larger number of banners in the config allows for random banner rotation upon page load. | - | - | See [below](#highlights-banner-configuration-properties) | upcoming |
#### Hero banner configuration properties
@@ -157,6 +158,23 @@ _Note_ Here, all values are arrays of up to two strings. The first string repres
| search | `{ border_width: [string, string] }` | Search bar customization. Currently supports only width of the border (in px). | - | - | `{ 'border_width': ['0px', '2px'] }` |
| button | `Partial<Record<'_default' \| '_hover' \| '_selected', {'background'?: [string, string]; 'text_color?:[string, string]'}>>` | The button on the banner. It has three possible states: `_default`, `_hover`, and `_selected`. The `_selected` state reflects when the user is logged in or their wallet is connected to the app. | - | - | `{'_default':{'background':['deeppink'],'text_color':['white']}}` |
#### Highlights banner configuration properties
_Note_ Some properties can hold an array of up to two strings. The first string represents the value for the light color mode, while the second string represents the value for the dark color mode. If the array contains only one string, it will be used for both color modes.
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| title | `string` | Title on the banner | Required | - | `Duck Deep into Transactions` |
| description | `string` | Short description of the feature | Required | - | `Explore and track all blockchain transactions` |
| title_color | `[string, string]` | Text color of the title. | - | `['#101112', '#F8FCFF']` | `['#FFB8D4', '#D9FE41']` |
| description_color | `[string, string]` | Text color of the description. | - | `['#718096', '#AEB1B6']` | `['#FFB8D4', '#D9FE41']` |
| background | `[string, string]` | Banner background (could be a solid color, gradient or picture). The string should be a valid `background` CSS property value. | - | `['#EFF7FF', '#2A3340']` | `['deeppink']` |
| side_img_url | `[string, string]` | URL of an image that appears on the right side of the banner. | - | - | `https://placekitten/1400/200` |
| is_pinned | `boolean` | Indicates whether the banner should remain always visible despite potential rotation. | - | - | `https://placekitten/1400/200` |
| page_path | `string` | Internal page path for constructing the banner link. | - | - | `/pools` |
| redirect_url | `string` | External link on the banner. | - | - | `https://example.com` |
&nbsp;
### Navigation
+4
View File
@@ -0,0 +1,4 @@
export const HOMEPAGE_HIGHLIGHTS_BANNER = {
title: 'Duck Deep into Transactions',
description: 'Explore and track all blockchain transactions',
};
+2 -1
View File
@@ -13,7 +13,7 @@ export interface ImageProps extends ChakraImageProps {
export const Image = React.forwardRef<HTMLImageElement, ImageProps>(
function Image(props, ref) {
const { fallback, src, onLoad, onError, skeletonWidth, skeletonHeight, ...rest } = props;
const { fallback, src, onLoad, onError, skeletonWidth, skeletonHeight, alt, ...rest } = props;
const [ loading, setLoading ] = React.useState(true);
const [ error, setError ] = React.useState(false);
@@ -55,6 +55,7 @@ export const Image = React.forwardRef<HTMLImageElement, ImageProps>(
<ChakraImage
ref={ ref }
src={ src }
alt={ alt }
onError={ handleLoadError }
onLoad={ handleLoadSuccess }
{ ...rest }
+12
View File
@@ -33,3 +33,15 @@ export interface HeroBannerConfig {
border_width?: Array<string | undefined>;
};
}
export interface HighlightsBannerConfig {
title: string;
description: string;
title_color?: Array<string | undefined>;
description_color?: Array<string | undefined>;
background?: Array<string | undefined>;
side_img_url?: Array<string | undefined>;
is_pinned?: boolean;
page_path?: string;
redirect_url?: string;
}
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import type { HighlightsBannerConfig } from 'types/homepage';
import { test, expect } from 'playwright/lib';
import Highlights from './Highlights';
const IMAGE_URL_1 = 'https://localhost:3000/my-image.png';
const IMAGE_URL_2 = 'https://localhost:3000/my-image-2.png';
const IMAGE_URL_3 = 'https://localhost:3000/my-image-3.png';
const HIGHLIGHTS_CONFIG_URL = 'https://localhost:3000/homepage-highlights-config.json';
const HIGHLIGHTS_CONFIG: Array<HighlightsBannerConfig> = [
// no adaptive
{
title: 'Duck Deep into Transactions',
description: 'Explore and track all blockchain transactions',
side_img_url: [ IMAGE_URL_1 ],
title_color: [ '#D9FE41' ],
description_color: [ '#CBD0D7' ],
background: [ '#06331B' ],
redirect_url: 'https://example.com',
is_pinned: true,
},
// adaptive
{
title: 'Geese Token Swap',
description: 'Swap tokens across different protocols',
title_color: [ '#1e40af', '#93c5fd' ],
description_color: [ '#64748b', '#94a3b8' ],
background: [
'linear-gradient(82.75deg, #FDDCEF 0.08%, #FAF5FB 51.54%, #FFFBDB 104.15%)',
'linear-gradient(82.75deg, #4F2D6A 0.08%, #3D425A 51.54%, #3A6024 104.15%)',
],
side_img_url: [ IMAGE_URL_2, IMAGE_URL_3 ],
page_path: '/essential-dapps/swap',
is_pinned: true,
},
// default
{
title: 'Duckling Smart Contracts',
description: 'Discover newly deployed smart contracts',
is_pinned: true,
},
];
test('three banners +@dark-mode', async({ render, mockEnvs, mockConfigResponse, mockAssetResponse }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG', HIGHLIGHTS_CONFIG_URL ],
]);
await mockConfigResponse('NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG', HIGHLIGHTS_CONFIG_URL, HIGHLIGHTS_CONFIG);
await mockAssetResponse(IMAGE_URL_1, './playwright/mocks/image_s.jpg');
await mockAssetResponse(IMAGE_URL_2, './playwright/mocks/image_md.jpg');
await mockAssetResponse(IMAGE_URL_3, './playwright/mocks/image_long.jpg');
const component = await render(<Highlights/>);
await expect(component).toHaveScreenshot();
});
test('two banners', async({ render, mockEnvs, mockConfigResponse, mockAssetResponse }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG', HIGHLIGHTS_CONFIG_URL ],
]);
await mockConfigResponse('NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG', HIGHLIGHTS_CONFIG_URL, HIGHLIGHTS_CONFIG.slice(0, 2));
await mockAssetResponse(IMAGE_URL_1, './playwright/mocks/image_s.jpg');
await mockAssetResponse(IMAGE_URL_2, './playwright/mocks/image_md.jpg');
await mockAssetResponse(IMAGE_URL_3, './playwright/mocks/image_long.jpg');
const component = await render(<Highlights/>);
await expect(component).toHaveScreenshot();
});
+46
View File
@@ -0,0 +1,46 @@
import type { StackProps } from '@chakra-ui/react';
import { HStack } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { shuffle } from 'es-toolkit';
import React from 'react';
import type { HighlightsBannerConfig } from 'types/homepage';
import config from 'configs/app';
import useFetch from 'lib/hooks/useFetch';
import { HOMEPAGE_HIGHLIGHTS_BANNER } from 'stubs/homepage';
import HighlightsItem from './highlights/HighlightsItem';
const HIGHLIGHTS_BANNER_COUNT = 3;
const Highlights = (props: StackProps) => {
const fetch = useFetch();
const { isPlaceholderData, data } = useQuery({
queryKey: [ 'homepage-highlights' ],
queryFn: async() => fetch(config.UI.homepage.highlights || '', undefined, { resource: 'homepage-highlights' }) as Promise<Array<HighlightsBannerConfig>>,
select: (data) => {
const pinnedBanners = data.filter((banner) => banner.is_pinned);
const otherBanners = data.filter((banner) => !banner.is_pinned);
return [
...pinnedBanners,
...shuffle(otherBanners),
].slice(0, HIGHLIGHTS_BANNER_COUNT);
},
enabled: Boolean(config.UI.homepage.highlights),
staleTime: Infinity,
placeholderData: Array(HIGHLIGHTS_BANNER_COUNT).fill(HOMEPAGE_HIGHLIGHTS_BANNER),
});
return (
<HStack gap={ 3 } { ...props }>
{ data?.map((banner, index) => (
<HighlightsItem key={ index } data={ banner } isLoading={ isPlaceholderData } totalNum={ data.length }/>
)) }
</HStack>
);
};
export default React.memo(Highlights);
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+113
View File
@@ -0,0 +1,113 @@
import type { JsxStyleProps } from '@chakra-ui/react';
import { Text, VStack, HStack } from '@chakra-ui/react';
import React from 'react';
import type { HighlightsBannerConfig } from 'types/homepage';
import config from 'configs/app';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
import { Heading } from 'toolkit/chakra/heading';
import { Image } from 'toolkit/chakra/image';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
interface ContainerProps extends Omit<Props, 'totalNum'>, JsxStyleProps {
children: React.ReactNode;
}
const Container = ({ children, data, isLoading, ...rest }: ContainerProps) => {
if ('page_path' in data) {
return (
<Link href={ config.app.baseUrl + data.page_path } loading={ isLoading } { ...rest }>
{ children }
</Link>
);
}
if ('redirect_url' in data) {
return (
<Link href={ data.redirect_url } loading={ isLoading } external noIcon { ...rest }>
{ children }
</Link>
);
}
return (
<Skeleton loading={ isLoading } { ...rest }>
{ children }
</Skeleton>
);
};
interface Props {
data: HighlightsBannerConfig;
isLoading: boolean;
totalNum: number;
}
const HighlightsItem = ({ data, isLoading, totalNum }: Props) => {
const imageSrc = useColorModeValue(
data.side_img_url?.[0],
data.side_img_url?.[1] || data.side_img_url?.[0],
);
return (
<Container
data={ data }
isLoading={ isLoading }
display="flex"
alignItems="center"
minH="153px"
pl={ 6 }
w={ `calc((100% - ${ (totalNum - 1) * 12 }px) / ${ totalNum })` }
overflow="hidden"
borderRadius="md"
bg={ !isLoading ? {
_light: data.background?.[0] || '#EFF7FF',
_dark: data.background?.[1] || data.background?.[0] || '#2A3340',
} : undefined }
>
<HStack overflow="hidden" w="100%" gap={ 3 }>
<VStack
alignItems="flex-start"
gap={ 3 }
w={ totalNum === 2 ? '294px' : '193px' }
flexShrink={ 0 }
>
<Heading
level="3"
color={{
_light: data.title_color?.[0] || '#101112',
_dark: data.title_color?.[1] || data.title_color?.[0] || '#F8FCFF',
}}
>
{ data.title }
</Heading>
<Text
textStyle="sm"
color={{
_light: data.description_color?.[0] || '#718096',
_dark: data.description_color?.[1] || data.description_color?.[0] || '#AEB1B6',
}}
>
{ data.description }
</Text>
</VStack>
{ imageSrc && !isLoading && (
<Image
src={ imageSrc }
width="210px"
height="112px"
skeletonWidth="0px"
objectFit="cover"
ml="auto"
mr={ 6 }
/>
) }
</HStack>
</Container>
);
};
export default React.memo(HighlightsItem);
+2
View File
@@ -4,6 +4,7 @@ import React from 'react';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import HeroBanner from 'ui/home/HeroBanner';
import Highlights from 'ui/home/Highlights';
import ChainIndicators from 'ui/home/indicators/ChainIndicators';
import LatestArbitrumL2Batches from 'ui/home/latestBatches/LatestArbitrumL2Batches';
import LatestZkEvmL2Batches from 'ui/home/latestBatches/LatestZkEvmL2Batches';
@@ -37,6 +38,7 @@ const Home = () => {
<Stats/>
<ChainIndicators/>
</Flex>
{ !isMobile && config.UI.homepage.highlights && <Highlights mt={ 3 }/> }
{ isMobile && <AdBanner mt={ 6 } mx="auto" justifyContent="center" format="mobile"/> }
<Flex mt={ 8 } direction={{ base: 'column', lg: 'row' }} columnGap={ 12 } rowGap={ 6 }>
{ leftWidget }
+2 -2
View File
@@ -26,7 +26,7 @@ test('default view', async({ render, mockTextAd, mockAssetResponse, mockApiRespo
await mockApiResponse('general:optimistic_l2_interop_messages', MESSAGES_RESPONSE);
await mockApiResponse('general:optimistic_l2_interop_messages_count', 4000000);
const component = await render(<InteropMessages/>);
await expect(component).toHaveScreenshot();
await expect(component).toHaveScreenshot({ maxDiffPixels: 30 });
});
test.describe('mobile', () => {
@@ -37,6 +37,6 @@ test.describe('mobile', () => {
await mockApiResponse('general:optimistic_l2_interop_messages', MESSAGES_RESPONSE);
await mockApiResponse('general:optimistic_l2_interop_messages_count', 4000000);
const component = await render(<InteropMessages/>);
await expect(component).toHaveScreenshot({ maxDiffPixels: 20 });
await expect(component).toHaveScreenshot({ maxDiffPixels: 30 });
});
});
+4
View File
@@ -32,11 +32,15 @@ test('email login', async({ render, page, mockApiResponse }) => {
await page.getByLabel('pin code 4 of 6').fill('4');
await page.getByLabel('pin code 5 of 6').fill('5');
await page.getByLabel('pin code 6 of 6').fill('6');
// make sure that the focus is removed from the button
await page.getByText('Confirmation code').click();
await expect(page).toHaveScreenshot();
// submit otp code
await mockApiResponse('general:auth_confirm_otp', profileMock.base as never);
await page.getByText('Submit').click();
// make sure that the focus is removed from the button
await page.getByText('Congrats').click();
await expect(page).toHaveScreenshot();
await page.getByLabel('Close').click();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB