Commit 904d754c authored by Max Alekseenko's avatar Max Alekseenko

Merge branch 'main' into rework-marketplace-app-header

parents 2e1999d2 f95bb0f8
...@@ -11,6 +11,9 @@ const suggestIdeasFormUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_F ...@@ -11,6 +11,9 @@ const suggestIdeasFormUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_F
const categoriesUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL'); const categoriesUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL');
const adminServiceApiHost = getEnvValue('NEXT_PUBLIC_ADMIN_SERVICE_API_HOST'); const adminServiceApiHost = getEnvValue('NEXT_PUBLIC_ADMIN_SERVICE_API_HOST');
const securityReportsUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL'); const securityReportsUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL');
const featuredApp = getEnvValue('NEXT_PUBLIC_MARKETPLACE_FEATURED_APP');
const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL');
const bannerLinkUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL');
const title = 'Marketplace'; const title = 'Marketplace';
...@@ -22,31 +25,38 @@ const config: Feature<( ...@@ -22,31 +25,38 @@ const config: Feature<(
categoriesUrl: string | undefined; categoriesUrl: string | undefined;
suggestIdeasFormUrl: string | undefined; suggestIdeasFormUrl: string | undefined;
securityReportsUrl: string | undefined; securityReportsUrl: string | undefined;
} featuredApp: string | undefined;
> = (() => { banner: { contentUrl: string; linkUrl: string } | undefined;
}> = (() => {
if (enabled === 'true' && chain.rpcUrl && submitFormUrl) { if (enabled === 'true' && chain.rpcUrl && submitFormUrl) {
const props = {
submitFormUrl,
categoriesUrl,
suggestIdeasFormUrl,
securityReportsUrl,
featuredApp,
banner: bannerContentUrl && bannerLinkUrl ? {
contentUrl: bannerContentUrl,
linkUrl: bannerLinkUrl,
} : undefined,
};
if (configUrl) { if (configUrl) {
return Object.freeze({ return Object.freeze({
title, title,
isEnabled: true, isEnabled: true,
configUrl, configUrl,
submitFormUrl, ...props,
categoriesUrl,
suggestIdeasFormUrl,
securityReportsUrl,
}); });
} else if (adminServiceApiHost) { } else if (adminServiceApiHost) {
return Object.freeze({ return Object.freeze({
title, title,
isEnabled: true, isEnabled: true,
submitFormUrl,
categoriesUrl,
suggestIdeasFormUrl,
securityReportsUrl,
api: { api: {
endpoint: adminServiceApiHost, endpoint: adminServiceApiHost,
basePath: '', basePath: '',
}, },
...props,
}); });
} }
} }
......
...@@ -51,6 +51,9 @@ NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blocksc ...@@ -51,6 +51,9 @@ NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blocksc
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://gist.githubusercontent.com/maxaleks/ce5c7e3de53e8f5b240b88265daf5839/raw/328383c958a8f7ecccf6d50c953bcdf8ab3faa0a/security_reports_goerli_test.json NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://gist.githubusercontent.com/maxaleks/ce5c7e3de53e8f5b240b88265daf5839/raw/328383c958a8f7ecccf6d50c953bcdf8ab3faa0a/security_reports_goerli_test.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
# NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=aave
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/maxaleks/36f779fd7d74877b57ec7a25a9a3a6c9/raw/746a8a59454c0537235ee44616c4690ce3bbf3c8/banner.html
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://www.basename.app
NEXT_PUBLIC_STATS_API_HOST=https://stats-goerli.k8s-dev.blockscout.com NEXT_PUBLIC_STATS_API_HOST=https://stats-goerli.k8s-dev.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.k8s-dev.blockscout.com NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.k8s-dev.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info-test.k8s-dev.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info-test.k8s-dev.blockscout.com
......
...@@ -17,6 +17,7 @@ ASSETS_ENVS=( ...@@ -17,6 +17,7 @@ ASSETS_ENVS=(
"NEXT_PUBLIC_MARKETPLACE_CONFIG_URL" "NEXT_PUBLIC_MARKETPLACE_CONFIG_URL"
"NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL" "NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL"
"NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL" "NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL"
"NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL"
"NEXT_PUBLIC_FEATURED_NETWORKS" "NEXT_PUBLIC_FEATURED_NETWORKS"
"NEXT_PUBLIC_FOOTER_LINKS" "NEXT_PUBLIC_FOOTER_LINKS"
"NEXT_PUBLIC_NETWORK_LOGO" "NEXT_PUBLIC_NETWORK_LOGO"
......
...@@ -194,6 +194,30 @@ const marketplaceSchema = yup ...@@ -194,6 +194,30 @@ const marketplaceSchema = yup
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'), otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}), }),
NEXT_PUBLIC_MARKETPLACE_FEATURED_APP: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema,
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_FEATURED_APP cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema.test(urlTest),
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema.test(urlTest),
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
}); });
const beaconChainSchema = yup const beaconChainSchema = yup
......
...@@ -5,3 +5,6 @@ NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://example.com ...@@ -5,3 +5,6 @@ NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://example.com
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://example.com NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://example.com
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://example.com NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://example.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=aave
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/maxaleks/36f779fd7d74877b57ec7a25a9a3a6c9/raw/746a8a59454c0537235ee44616c4690ce3bbf3c8/banner.html
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://www.basename.app
...@@ -71,6 +71,9 @@ frontend: ...@@ -71,6 +71,9 @@ frontend:
NEXT_PUBLIC_NETWORK_ID: '11155111' NEXT_PUBLIC_NETWORK_ID: '11155111'
NEXT_PUBLIC_NETWORK_EXPLORERS: "[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/etherscan.png?raw=true','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]" NEXT_PUBLIC_NETWORK_EXPLORERS: "[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/etherscan.png?raw=true','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]"
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_FEATURED_APP: zkbob-wallet
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL: https://gist.githubusercontent.com/maxaleks/36f779fd7d74877b57ec7a25a9a3a6c9/raw/746a8a59454c0537235ee44616c4690ce3bbf3c8/banner.html
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL: https://www.basename.app
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']" NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE: gradient_avatar NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE: gradient_avatar
......
...@@ -451,6 +451,9 @@ This feature is **always enabled**, but you can configure its behavior by passin ...@@ -451,6 +451,9 @@ This feature is **always enabled**, but you can configure its behavior by passin
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` | See in [Blockchain parameters](ENVS.md#blockchain-parameters) section | Required | - | `https://core.poa.network` | | NEXT_PUBLIC_NETWORK_RPC_URL | `string` | See in [Blockchain parameters](ENVS.md#blockchain-parameters) section | Required | - | `https://core.poa.network` |
| NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL | `string` | URL of configuration file (`.json` format only) which contains the list of categories to be displayed on the marketplace page in the specified order. If no URL is provided, then the list of categories will be compiled based on the `categories` fields from the marketplace (apps) configuration file | - | - | `https://example.com/marketplace_categories.json` | | NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL | `string` | URL of configuration file (`.json` format only) which contains the list of categories to be displayed on the marketplace page in the specified order. If no URL is provided, then the list of categories will be compiled based on the `categories` fields from the marketplace (apps) configuration file | - | - | `https://example.com/marketplace_categories.json` |
| NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL | `string` | URL of configuration file (`.json` format only) which contains app security reports for displaying security scores on the Marketplace page | - | - | `https://example.com/marketplace_security_reports.json` | | NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL | `string` | URL of configuration file (`.json` format only) which contains app security reports for displaying security scores on the Marketplace page | - | - | `https://example.com/marketplace_security_reports.json` |
| NEXT_PUBLIC_MARKETPLACE_FEATURED_APP | `string` | ID of the featured application to be displayed on the banner on the Marketplace page | - | - | `uniswap` |
| NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL | `string` | URL of the banner HTML content | - | - | `https://example.com/banner` |
| NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL | `string` | URL of the page the banner leads to | - | - | `https://example.com` |
#### Marketplace app configuration properties #### Marketplace app configuration properties
......
...@@ -19,6 +19,7 @@ export enum EventTypes { ...@@ -19,6 +19,7 @@ export enum EventTypes {
EXPERIMENT_STARTED = 'Experiment started', EXPERIMENT_STARTED = 'Experiment started',
FILTERS = 'Filters', FILTERS = 'Filters',
BUTTON_CLICK = 'Button click', BUTTON_CLICK = 'Button click',
PROMO_BANNER = 'Promo banner',
} }
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
...@@ -100,7 +101,7 @@ Type extends EventTypes.PAGE_WIDGET ? ( ...@@ -100,7 +101,7 @@ Type extends EventTypes.PAGE_WIDGET ? (
} | { } | {
'Type': 'Favorite app' | 'More button' | 'Security score' | 'Total contracts' | 'Verified contracts' | 'Analyzed contracts'; 'Type': 'Favorite app' | 'More button' | 'Security score' | 'Total contracts' | 'Verified contracts' | 'Analyzed contracts';
'Info': string; 'Info': string;
'Source': 'Discovery view' | 'Security view' | 'App modal' | 'App page' | 'Security score popup'; 'Source': 'Discovery view' | 'Security view' | 'App modal' | 'App page' | 'Security score popup' | 'Banner';
} | { } | {
'Type': 'Security score'; 'Type': 'Security score';
'Source': 'Analyzed contracts popup'; 'Source': 'Analyzed contracts popup';
...@@ -122,5 +123,9 @@ Type extends EventTypes.BUTTON_CLICK ? { ...@@ -122,5 +123,9 @@ Type extends EventTypes.BUTTON_CLICK ? {
'Content': 'Swap button'; 'Content': 'Swap button';
'Source': string; 'Source': string;
} : } :
Type extends EventTypes.PROMO_BANNER ? {
'Source': 'Marketplace';
'Link': string;
} :
undefined; undefined;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
This diff is collapsed.
import type { MouseEvent } from 'react';
import React from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import config from 'configs/app';
import { apps as appsMock } from 'mocks/apps/apps';
import FeaturedApp from './Banner/FeaturedApp';
import IframeBanner from './Banner/IframeBanner';
const feature = config.features.marketplace;
type BannerProps = {
apps: Array<MarketplaceAppPreview>;
favoriteApps: Array<string>;
isLoading: boolean;
onInfoClick: (id: string) => void;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Banner') => void;
onAppClick: (event: MouseEvent, id: string) => void;
}
const Banner = ({ apps, favoriteApps, isLoading, onInfoClick, onFavoriteClick, onAppClick }: BannerProps) => {
if (!feature.isEnabled) {
return null;
}
if (feature.featuredApp) {
const app = apps.find(app => app.id === feature.featuredApp);
const isFavorite = favoriteApps.includes(feature.featuredApp);
if (!isLoading && !app) {
return null;
}
return (
<FeaturedApp
app={ app || appsMock[0] }
isFavorite={ isFavorite }
isLoading={ isLoading }
onInfoClick={ onInfoClick }
onFavoriteClick={ onFavoriteClick }
onAppClick={ onAppClick }
/>
);
} else if (feature.banner) {
return <IframeBanner contentUrl={ feature.banner.contentUrl } linkUrl={ feature.banner.linkUrl }/>;
}
return null;
};
export default Banner;
import { Link, Skeleton, useColorModeValue, LinkBox, Flex, Image, LinkOverlay, IconButton } from '@chakra-ui/react';
import NextLink from 'next/link';
import type { MouseEvent } from 'react';
import React, { useCallback } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
import FeaturedAppMobile from './FeaturedAppMobile';
type FeaturedAppProps = {
app: MarketplaceAppPreview;
isFavorite: boolean;
isLoading: boolean;
onInfoClick: (id: string) => void;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Banner') => void;
onAppClick: (event: MouseEvent, id: string) => void;
}
const FeaturedApp = ({
app, isFavorite, isLoading, onAppClick,
onInfoClick, onFavoriteClick,
}: FeaturedAppProps) => {
const isMobile = useIsMobile();
const { id, url, external, title, logo, logoDarkMode, shortDescription, categories, internalWallet } = app;
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
const categoriesLabel = categories.join(', ');
const backgroundColor = useColorModeValue('purple.50', 'whiteAlpha.100');
const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Banner' });
onInfoClick(id);
}, [ onInfoClick, id ]);
const handleFavoriteClick = useCallback(() => {
onFavoriteClick(id, isFavorite, 'Banner');
}, [ onFavoriteClick, id, isFavorite ]);
if (isMobile) {
return (
<FeaturedAppMobile
{ ...app }
onInfoClick={ handleInfoClick }
isFavorite={ isFavorite }
onFavoriteClick={ handleFavoriteClick }
isLoading={ isLoading }
onAppClick={ onAppClick }
/>
);
}
return (
<LinkBox role="group">
<Flex
gap={ 6 }
borderRadius="md"
height="136px"
padding={ 5 }
background={ backgroundColor }
mb={ 6 }
>
<Skeleton
isLoaded={ !isLoading }
w="96px"
h="96px"
display="flex"
alignItems="center"
justifyContent="center"
>
<Image
src={ isLoading ? undefined : logoUrl }
alt={ `${ title } app icon` }
borderRadius="md"
/>
</Skeleton>
<Flex flexDirection="column" flex={ 1 } gap={ 2 }>
<Flex alignItems="center" gap={ 3 }>
<Skeleton
isLoaded={ !isLoading }
fontSize="30px"
fontWeight="semibold"
fontFamily="heading"
lineHeight="36px"
>
{ external ? (
<LinkOverlay href={ url } isExternal={ true } marginRight={ 2 }>
{ title }
</LinkOverlay>
) : (
<NextLink href={{ pathname: '/apps/[id]', query: { id } }} passHref legacyBehavior>
<LinkOverlay marginRight={ 2 }>
{ title }
</LinkOverlay>
</NextLink>
) }
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
color="text_secondary"
fontSize="xs"
flex={ 1 }
>
<span>{ categoriesLabel }</span>
</Skeleton>
{ !isLoading && (
<Link
fontSize="sm"
fontWeight="500"
href="#"
onClick={ handleInfoClick }
>
More info
</Link>
) }
{ !isLoading && (
<IconButton
display="flex"
alignItems="center"
aria-label="Mark as favorite"
title="Mark as favorite"
variant="ghost"
colorScheme="gray"
w={ 9 }
h={ 8 }
onClick={ handleFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
/>
) }
</Flex>
<Skeleton
isLoaded={ !isLoading }
fontSize="sm"
lineHeight="20px"
noOfLines={ 2 }
>
{ shortDescription }
</Skeleton>
</Flex>
</Flex>
</LinkBox>
);
};
export default FeaturedApp;
import { IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, Flex } from '@chakra-ui/react';
import type { MouseEvent } from 'react';
import React from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import IconSvg from 'ui/shared/IconSvg';
import MarketplaceAppCardLink from '../MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
interface Props extends MarketplaceAppPreview {
onInfoClick: (event: MouseEvent) => void;
isFavorite: boolean;
onFavoriteClick: () => void;
isLoading: boolean;
onAppClick: (event: MouseEvent, id: string) => void;
}
const FeaturedAppMobile = ({
id,
url,
external,
title,
logo,
logoDarkMode,
shortDescription,
categories,
onInfoClick,
isFavorite,
onFavoriteClick,
isLoading,
internalWallet,
onAppClick,
}: Props) => {
const categoriesLabel = categories.join(', ');
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
return (
<LinkBox
borderRadius="md"
padding={{ base: 3, sm: '20px' }}
role="group"
background={ useColorModeValue('purple.50', 'whiteAlpha.100') }
mb={ 4 }
>
<Flex
flexDirection="row"
height="100%"
alignContent="start"
gap={ 4 }
>
<Flex
flexDirection="column"
alignItems="center"
justifyContent="space-between"
>
<Skeleton
isLoaded={ !isLoading }
w={{ base: '64px', sm: '96px' }}
h={{ base: '64px', sm: '96px' }}
display="flex"
alignItems="center"
justifyContent="center"
>
<Image
src={ isLoading ? undefined : logoUrl }
alt={ `${ title } app icon` }
borderRadius="8px"
/>
</Skeleton>
{ !isLoading && (
<Flex
position={{ base: 'relative', sm: 'absolute' }}
right={{ base: 0, sm: '50px' }}
top={{ base: 0, sm: '24px' }}
>
<Link
fontSize={{ base: 'xs', sm: 'sm' }}
fontWeight="500"
paddingRight={{ sm: 2 }}
href="#"
onClick={ onInfoClick }
>
More info
</Link>
</Flex>
) }
</Flex>
<Flex flexDirection="column" gap={ 2 }>
<Skeleton
isLoaded={ !isLoading }
fontSize={{ base: 'sm', sm: 'lg' }}
lineHeight={{ base: '20px', sm: '28px' }}
paddingRight={{ base: '25px', sm: '110px' }}
fontWeight="semibold"
fontFamily="heading"
display="inline-block"
>
<MarketplaceAppCardLink
id={ id }
url={ url }
external={ external }
title={ title }
onClick={ onAppClick }
/>
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
color="text_secondary"
fontSize="xs"
lineHeight="16px"
>
<span>{ categoriesLabel }</span>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
fontSize={{ base: 'xs', sm: 'sm' }}
lineHeight="20px"
noOfLines={ 3 }
>
{ shortDescription }
</Skeleton>
</Flex>
{ !isLoading && (
<IconButton
display="flex"
alignItems="center"
justifyContent="center"
position="absolute"
right={{ base: 1, sm: '10px' }}
top={{ base: 1, sm: '18px' }}
aria-label="Mark as favorite"
title="Mark as favorite"
variant="ghost"
colorScheme="gray"
w={ 9 }
h={ 8 }
onClick={ onFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
/>
) }
</Flex>
</LinkBox>
);
};
export default React.memo(FeaturedAppMobile);
import { Link, Skeleton, Box } from '@chakra-ui/react';
import React, { useCallback, useState } from 'react';
import * as mixpanel from 'lib/mixpanel/index';
const IframeBanner = ({ contentUrl, linkUrl }: { contentUrl: string; linkUrl: string }) => {
const [ isFrameLoading, setIsFrameLoading ] = useState(true);
const handleIframeLoad = useCallback(() => {
setIsFrameLoading(false);
}, []);
const handleClick = useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PROMO_BANNER, { Source: 'Marketplace', Link: linkUrl });
}, [ linkUrl ]);
return (
<Skeleton
isLoaded={ !isFrameLoading }
position="relative"
h="136px"
w="100%"
borderRadius="md"
mb={{ base: 4, sm: 6 }}
overflow="hidden"
>
<Link
href={ linkUrl }
target="_blank"
rel="noopener noreferrer"
onClick={ handleClick }
position="absolute"
w="100%"
h="100%"
top={ 0 }
left={ 0 }
zIndex={ 1 }
/>
<Box
as="iframe"
h="100%"
w="100%"
src={ contentUrl }
title="Marketplace banner"
onLoad={ handleIframeLoad }
/>
</Skeleton>
);
};
export default IframeBanner;
import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue } from '@chakra-ui/react'; import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, chakra, Flex } from '@chakra-ui/react';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace'; import type { MarketplaceAppPreview } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import MarketplaceAppCardLink from './MarketplaceAppCardLink'; import MarketplaceAppCardLink from './MarketplaceAppCardLink';
...@@ -13,9 +12,10 @@ import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon'; ...@@ -13,9 +12,10 @@ import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
interface Props extends MarketplaceAppPreview { interface Props extends MarketplaceAppPreview {
onInfoClick: (id: string) => void; onInfoClick: (id: string) => void;
isFavorite: boolean; isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Discovery view') => void; onFavoriteClick: (id: string, isFavorite: boolean) => void;
isLoading: boolean; isLoading: boolean;
onAppClick: (event: MouseEvent, id: string) => void; onAppClick: (event: MouseEvent, id: string) => void;
className?: string;
} }
const MarketplaceAppCard = ({ const MarketplaceAppCard = ({
...@@ -33,23 +33,24 @@ const MarketplaceAppCard = ({ ...@@ -33,23 +33,24 @@ const MarketplaceAppCard = ({
isLoading, isLoading,
internalWallet, internalWallet,
onAppClick, onAppClick,
className,
}: Props) => { }: Props) => {
const categoriesLabel = categories.join(', '); const categoriesLabel = categories.join(', ');
const handleInfoClick = useCallback((event: MouseEvent) => { const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
onInfoClick(id); onInfoClick(id);
}, [ onInfoClick, id ]); }, [ onInfoClick, id ]);
const handleFavoriteClick = useCallback(() => { const handleFavoriteClick = useCallback(() => {
onFavoriteClick(id, isFavorite, 'Discovery view'); onFavoriteClick(id, isFavorite);
}, [ onFavoriteClick, id, isFavorite ]); }, [ onFavoriteClick, id, isFavorite ]);
const logoUrl = useColorModeValue(logo, logoDarkMode || logo); const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
return ( return (
<LinkBox <LinkBox
className={ className }
_hover={{ _hover={{
boxShadow: isLoading ? 'none' : 'md', boxShadow: isLoading ? 'none' : 'md',
}} }}
...@@ -57,103 +58,116 @@ const MarketplaceAppCard = ({ ...@@ -57,103 +58,116 @@ const MarketplaceAppCard = ({
boxShadow: isLoading ? 'none' : 'md', boxShadow: isLoading ? 'none' : 'md',
}} }}
borderRadius="md" borderRadius="md"
height="100%"
padding={{ base: 3, sm: '20px' }} padding={{ base: 3, sm: '20px' }}
border="1px" border="1px"
borderColor={ useColorModeValue('gray.200', 'gray.600') } borderColor={ useColorModeValue('gray.200', 'gray.600') }
role="group" role="group"
> >
<Box <Flex
display={{ base: 'grid', sm: 'flex' }} flexDirection={{ base: 'row', sm: 'column' }}
flexDirection="column"
gridTemplateColumns={{ base: '64px 1fr', sm: '1fr' }}
gridTemplateRows={{ base: 'none', sm: 'none' }}
gridRowGap={{ base: 2, sm: 0 }}
gridColumnGap={{ base: 4, sm: 0 }}
height="100%" height="100%"
alignContent="start" alignContent="start"
gap={{ base: 4, sm: 0 }}
> >
<Skeleton <Flex
isLoaded={ !isLoading } display={{ base: 'flex', sm: 'contents' }}
gridRow={{ base: '1 / 4', sm: 'auto' }} flexDirection="column"
marginBottom={ 4 }
w={{ base: '64px', sm: '96px' }}
h={{ base: '64px', sm: '96px' }}
display="flex"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="space-between"
> >
<Image <Skeleton
src={ isLoading ? undefined : logoUrl } isLoaded={ !isLoading }
alt={ `${ title } app icon` } marginBottom={ 4 }
borderRadius="8px" w={{ base: '64px', sm: '96px' }}
/> h={{ base: '64px', sm: '96px' }}
</Skeleton> display="flex"
alignItems="center"
justifyContent="center"
order={{ base: 'auto', sm: 1 }}
>
<Image
src={ isLoading ? undefined : logoUrl }
alt={ `${ title } app icon` }
borderRadius="8px"
/>
</Skeleton>
<Skeleton { !isLoading && (
isLoaded={ !isLoading } <Box
gridColumn={{ base: 2, sm: 'auto' }} display="flex"
as="h3" marginTop={{ base: 0, sm: 'auto' }}
marginBottom={{ base: 0, sm: 2 }} paddingTop={{ base: 0, sm: 4 }}
fontSize={{ base: 'sm', sm: 'lg' }} order={{ base: 'auto', sm: 5 }}
fontWeight="semibold" >
fontFamily="heading" <Link
display="inline-block" fontSize={{ base: 'xs', sm: 'sm' }}
> fontWeight="500"
<MarketplaceAppCardLink paddingRight={{ sm: 2 }}
id={ id } href="#"
url={ url } onClick={ handleInfoClick }
external={ external } >
title={ title } More info
onClick={ onAppClick } </Link>
/> </Box>
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/> ) }
</Skeleton> </Flex>
<Skeleton <Flex
isLoaded={ !isLoading } display={{ base: 'flex', sm: 'contents' }}
marginBottom={{ base: 0, sm: 2 }} flexDirection="column"
color="text_secondary" gap={ 2 }
fontSize="xs"
> >
<span>{ categoriesLabel }</span> <Skeleton
</Skeleton> isLoaded={ !isLoading }
marginBottom={{ base: 0, sm: 2 }}
fontSize={{ base: 'sm', sm: 'lg' }}
lineHeight={{ base: '20px', sm: '28px' }}
paddingRight={{ base: '25px', sm: 0 }}
fontWeight="semibold"
fontFamily="heading"
display="inline-block"
order={{ base: 'auto', sm: 2 }}
>
<MarketplaceAppCardLink
id={ id }
url={ url }
external={ external }
title={ title }
onClick={ onAppClick }
/>
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
</Skeleton>
<Skeleton <Skeleton
isLoaded={ !isLoading } isLoaded={ !isLoading }
fontSize={{ base: 'xs', sm: 'sm' }} marginBottom={{ base: 0, sm: 2 }}
lineHeight="20px" color="text_secondary"
noOfLines={ 3 } fontSize="xs"
> lineHeight="16px"
{ shortDescription } order={{ base: 'auto', sm: 3 }}
</Skeleton> >
<span>{ categoriesLabel }</span>
</Skeleton>
{ !isLoading && ( <Skeleton
<Box isLoaded={ !isLoading }
display="flex" fontSize={{ base: 'xs', sm: 'sm' }}
position={{ base: 'absolute', sm: 'relative' }} lineHeight="20px"
bottom={{ base: 3, sm: 0 }} noOfLines={ 3 }
left={{ base: 3, sm: 0 }} order={{ base: 'auto', sm: 4 }}
marginTop={{ base: 0, sm: 'auto' }}
paddingTop={{ base: 0, sm: 4 }}
> >
<Link { shortDescription }
fontSize={{ base: 'xs', sm: 'sm' }} </Skeleton>
paddingRight={{ sm: 2 }} </Flex>
href="#"
onClick={ handleInfoClick }
>
More info
</Link>
</Box>
) }
{ !isLoading && ( { !isLoading && (
<IconButton <IconButton
display="block" display="flex"
alignItems="center"
justifyContent="center"
position="absolute" position="absolute"
right={{ base: 3, sm: '10px' }} right={{ base: 1, sm: '10px' }}
top={{ base: 3, sm: '14px' }} top={{ base: 1, sm: '10px' }}
aria-label="Mark as favorite" aria-label="Mark as favorite"
title="Mark as favorite" title="Mark as favorite"
variant="ghost" variant="ghost"
...@@ -167,9 +181,9 @@ const MarketplaceAppCard = ({ ...@@ -167,9 +181,9 @@ const MarketplaceAppCard = ({
} }
/> />
) } ) }
</Box> </Flex>
</LinkBox> </LinkBox>
); );
}; };
export default React.memo(MarketplaceAppCard); export default React.memo(chakra(MarketplaceAppCard));
...@@ -42,7 +42,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => { ...@@ -42,7 +42,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => {
position="relative" position="relative"
cursor="pointer" cursor="pointer"
verticalAlign="middle" verticalAlign="middle"
marginBottom={ 1 } mb={ 1 }
/> />
</Tooltip> </Tooltip>
); );
......
import { Grid } from '@chakra-ui/react'; import { Grid } from '@chakra-ui/react';
import React from 'react'; import React, { useCallback } from 'react';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace'; import type { MarketplaceAppPreview } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import EmptySearchResult from './EmptySearchResult'; import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard'; import MarketplaceAppCard from './MarketplaceAppCard';
...@@ -18,6 +20,15 @@ type Props = { ...@@ -18,6 +20,15 @@ type Props = {
} }
const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, onAppClick }: Props) => { const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, onAppClick }: Props) => {
const handleInfoClick = useCallback((id: string) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
showAppInfo(id);
}, [ showAppInfo ]);
const handleFavoriteClick = useCallback((id: string, isFavorite: boolean) => {
onFavoriteClick(id, isFavorite, 'Discovery view');
}, [ onFavoriteClick ]);
return apps.length > 0 ? ( return apps.length > 0 ? (
<Grid <Grid
templateColumns={{ templateColumns={{
...@@ -30,7 +41,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL ...@@ -30,7 +41,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
{ apps.map((app, index) => ( { apps.map((app, index) => (
<MarketplaceAppCard <MarketplaceAppCard
key={ app.id + (isLoading ? index : '') } key={ app.id + (isLoading ? index : '') }
onInfoClick={ showAppInfo } onInfoClick={ handleInfoClick }
id={ app.id } id={ app.id }
external={ app.external } external={ app.external }
url={ app.url } url={ app.url }
...@@ -40,7 +51,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL ...@@ -40,7 +51,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
shortDescription={ app.shortDescription } shortDescription={ app.shortDescription }
categories={ app.categories } categories={ app.categories }
isFavorite={ favoriteApps.includes(app.id) } isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick } onFavoriteClick={ handleFavoriteClick }
isLoading={ isLoading } isLoading={ isLoading }
internalWallet={ app.internalWallet } internalWallet={ app.internalWallet }
onAppClick={ onAppClick } onAppClick={ onAppClick }
......
...@@ -43,7 +43,7 @@ export default function useMarketplace() { ...@@ -43,7 +43,7 @@ export default function useMarketplace() {
const [ contractListModalType, setContractListModalType ] = React.useState<ContractListTypes | null>(null); const [ contractListModalType, setContractListModalType ] = React.useState<ContractListTypes | null>(null);
const [ hasPreviousStep, setHasPreviousStep ] = React.useState<boolean>(false); const [ hasPreviousStep, setHasPreviousStep ] = React.useState<boolean>(false);
const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean, source: 'Discovery view' | 'Security view' | 'App modal') => { const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean, source: 'Discovery view' | 'Security view' | 'App modal' | 'Banner') => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Favorite app', Info: id, Source: source }); mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Favorite app', Info: id, Source: source });
const favoriteApps = getFavoriteApps(); const favoriteApps = getFavoriteApps();
......
...@@ -13,6 +13,9 @@ import Marketplace from './Marketplace'; ...@@ -13,6 +13,9 @@ import Marketplace from './Marketplace';
const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || ''; const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || '';
const MARKETPLACE_SECURITY_REPORTS_URL = const MARKETPLACE_SECURITY_REPORTS_URL =
app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', 'https://marketplace-security-reports.json') || ''; app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', 'https://marketplace-security-reports.json') || '';
const MARKETPLACE_BANNER_CONTENT_URL =
app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL', 'https://marketplace-banner.html') || '';
const MARKETPLACE_BANNER_LINK_URL = 'https://example.com';
const test = base.extend({ const test = base.extend({
context: contextWithEnvs([ context: contextWithEnvs([
...@@ -21,7 +24,7 @@ const test = base.extend({ ...@@ -21,7 +24,7 @@ const test = base.extend({
]) as any, ]) as any,
}); });
test('base view +@mobile +@dark-mode', async({ mount, page }) => { const testFn: Parameters<typeof test>[1] = async({ mount, page }) => {
await page.route(MARKETPLACE_CONFIG_URL, (route) => route.fulfill({ await page.route(MARKETPLACE_CONFIG_URL, (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify(appsMock), body: JSON.stringify(appsMock),
...@@ -36,6 +39,11 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -36,6 +39,11 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
), ),
)); ));
await page.route(MARKETPLACE_BANNER_CONTENT_URL, (route) => route.fulfill({
status: 200,
path: './playwright/mocks/banner.html',
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<Marketplace/> <Marketplace/>
...@@ -43,8 +51,31 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -43,8 +51,31 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
); );
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
};
test('base view +@mobile +@dark-mode', testFn);
const testWithFeaturedApp = test.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', value: MARKETPLACE_CONFIG_URL },
{ name: 'NEXT_PUBLIC_MARKETPLACE_FEATURED_APP', value: 'hop-exchange' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
}); });
testWithFeaturedApp('with featured app +@mobile +@dark-mode', testFn);
const testWithBanner = test.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', value: MARKETPLACE_CONFIG_URL },
{ name: 'NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL', value: MARKETPLACE_BANNER_CONTENT_URL },
{ name: 'NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL', value: MARKETPLACE_BANNER_LINK_URL },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
});
testWithBanner('with banner +@mobile +@dark-mode', testFn);
const testWithScoreFeature = test.extend({ const testWithScoreFeature = test.extend({
context: contextWithEnvs([ context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', value: MARKETPLACE_CONFIG_URL }, { name: 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', value: MARKETPLACE_CONFIG_URL },
......
...@@ -9,6 +9,7 @@ import config from 'configs/app'; ...@@ -9,6 +9,7 @@ import config from 'configs/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useFeatureValue from 'lib/growthbook/useFeatureValue'; import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import Banner from 'ui/marketplace/Banner';
import ContractListModal from 'ui/marketplace/ContractListModal'; import ContractListModal from 'ui/marketplace/ContractListModal';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal'; import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceDisclaimerModal from 'ui/marketplace/MarketplaceDisclaimerModal'; import MarketplaceDisclaimerModal from 'ui/marketplace/MarketplaceDisclaimerModal';
...@@ -167,6 +168,16 @@ const Marketplace = () => { ...@@ -167,6 +168,16 @@ const Marketplace = () => {
</Flex> </Flex>
) } ) }
/> />
<Banner
apps={ displayedApps }
favoriteApps={ favoriteApps }
isLoading={ isPlaceholderData }
onInfoClick={ showAppInfo }
onFavoriteClick={ onFavoriteClick }
onAppClick={ handleAppClick }
/>
<Box marginTop={{ base: 0, lg: 8 }}> <Box marginTop={{ base: 0, lg: 8 }}>
{ (isCategoriesPlaceholderData) ? ( { (isCategoriesPlaceholderData) ? (
<TabsSkeleton tabs={ categoryTabs }/> <TabsSkeleton tabs={ categoryTabs }/>
......
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