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

Merge pull request #1785 from blockscout/marketplace-banner

Marketplace banner
parents 6c4d6674 adad0d40
......@@ -11,6 +11,9 @@ const suggestIdeasFormUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_F
const categoriesUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL');
const adminServiceApiHost = getEnvValue('NEXT_PUBLIC_ADMIN_SERVICE_API_HOST');
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';
......@@ -22,31 +25,38 @@ const config: Feature<(
categoriesUrl: string | undefined;
suggestIdeasFormUrl: string | undefined;
securityReportsUrl: string | undefined;
}
> = (() => {
featuredApp: string | undefined;
banner: { contentUrl: string; linkUrl: string } | undefined;
}> = (() => {
if (enabled === 'true' && chain.rpcUrl && submitFormUrl) {
const props = {
submitFormUrl,
categoriesUrl,
suggestIdeasFormUrl,
securityReportsUrl,
featuredApp,
banner: bannerContentUrl && bannerLinkUrl ? {
contentUrl: bannerContentUrl,
linkUrl: bannerLinkUrl,
} : undefined,
};
if (configUrl) {
return Object.freeze({
title,
isEnabled: true,
configUrl,
submitFormUrl,
categoriesUrl,
suggestIdeasFormUrl,
securityReportsUrl,
...props,
});
} else if (adminServiceApiHost) {
return Object.freeze({
title,
isEnabled: true,
submitFormUrl,
categoriesUrl,
suggestIdeasFormUrl,
securityReportsUrl,
api: {
endpoint: adminServiceApiHost,
basePath: '',
},
...props,
});
}
}
......
......@@ -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_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C
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_VISUALIZE_API_HOST=https://visualizer.k8s-dev.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info-test.k8s-dev.blockscout.com
......
......@@ -17,6 +17,7 @@ ASSETS_ENVS=(
"NEXT_PUBLIC_MARKETPLACE_CONFIG_URL"
"NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL"
"NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL"
"NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL"
"NEXT_PUBLIC_FEATURED_NETWORKS"
"NEXT_PUBLIC_FOOTER_LINKS"
"NEXT_PUBLIC_NETWORK_LOGO"
......
......@@ -194,6 +194,30 @@ const marketplaceSchema = yup
// 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'),
}),
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
......
......@@ -5,3 +5,6 @@ NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://example.com
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://example.com
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=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:
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_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_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
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
| 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_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
......
......@@ -19,6 +19,7 @@ export enum EventTypes {
EXPERIMENT_STARTED = 'Experiment started',
FILTERS = 'Filters',
BUTTON_CLICK = 'Button click',
PROMO_BANNER = 'Promo banner',
}
/* eslint-disable @typescript-eslint/indent */
......@@ -100,7 +101,7 @@ Type extends EventTypes.PAGE_WIDGET ? (
} | {
'Type': 'Favorite app' | 'More button' | 'Security score' | 'Total contracts' | 'Verified contracts' | 'Analyzed contracts';
'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';
'Source': 'Analyzed contracts popup';
......@@ -122,5 +123,9 @@ Type extends EventTypes.BUTTON_CLICK ? {
'Content': 'Swap button';
'Source': string;
} :
Type extends EventTypes.PROMO_BANNER ? {
'Source': 'Marketplace';
'Link': string;
} :
undefined;
/* 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 React, { useCallback } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import MarketplaceAppCardLink from './MarketplaceAppCardLink';
......@@ -13,9 +12,10 @@ import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
interface Props extends MarketplaceAppPreview {
onInfoClick: (id: string) => void;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Discovery view') => void;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
isLoading: boolean;
onAppClick: (event: MouseEvent, id: string) => void;
className?: string;
}
const MarketplaceAppCard = ({
......@@ -33,23 +33,24 @@ const MarketplaceAppCard = ({
isLoading,
internalWallet,
onAppClick,
className,
}: Props) => {
const categoriesLabel = categories.join(', ');
const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
onInfoClick(id);
}, [ onInfoClick, id ]);
const handleFavoriteClick = useCallback(() => {
onFavoriteClick(id, isFavorite, 'Discovery view');
onFavoriteClick(id, isFavorite);
}, [ onFavoriteClick, id, isFavorite ]);
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
return (
<LinkBox
className={ className }
_hover={{
boxShadow: isLoading ? 'none' : 'md',
}}
......@@ -57,103 +58,116 @@ const MarketplaceAppCard = ({
boxShadow: isLoading ? 'none' : 'md',
}}
borderRadius="md"
height="100%"
padding={{ base: 3, sm: '20px' }}
border="1px"
borderColor={ useColorModeValue('gray.200', 'gray.600') }
role="group"
>
<Box
display={{ base: 'grid', sm: 'flex' }}
flexDirection="column"
gridTemplateColumns={{ base: '64px 1fr', sm: '1fr' }}
gridTemplateRows={{ base: 'none', sm: 'none' }}
gridRowGap={{ base: 2, sm: 0 }}
gridColumnGap={{ base: 4, sm: 0 }}
<Flex
flexDirection={{ base: 'row', sm: 'column' }}
height="100%"
alignContent="start"
gap={{ base: 4, sm: 0 }}
>
<Skeleton
isLoaded={ !isLoading }
gridRow={{ base: '1 / 4', sm: 'auto' }}
marginBottom={ 4 }
w={{ base: '64px', sm: '96px' }}
h={{ base: '64px', sm: '96px' }}
display="flex"
<Flex
display={{ base: 'flex', sm: 'contents' }}
flexDirection="column"
alignItems="center"
justifyContent="center"
justifyContent="space-between"
>
<Image
src={ isLoading ? undefined : logoUrl }
alt={ `${ title } app icon` }
borderRadius="8px"
/>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
marginBottom={ 4 }
w={{ base: '64px', sm: '96px' }}
h={{ base: '64px', sm: '96px' }}
display="flex"
alignItems="center"
justifyContent="center"
order={{ base: 'auto', sm: 1 }}
>
<Image
src={ isLoading ? undefined : logoUrl }
alt={ `${ title } app icon` }
borderRadius="8px"
/>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
gridColumn={{ base: 2, sm: 'auto' }}
as="h3"
marginBottom={{ base: 0, sm: 2 }}
fontSize={{ base: 'sm', sm: 'lg' }}
fontWeight="semibold"
fontFamily="heading"
display="inline-block"
>
<MarketplaceAppCardLink
id={ id }
url={ url }
external={ external }
title={ title }
onClick={ onAppClick }
/>
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
</Skeleton>
{ !isLoading && (
<Box
display="flex"
marginTop={{ base: 0, sm: 'auto' }}
paddingTop={{ base: 0, sm: 4 }}
order={{ base: 'auto', sm: 5 }}
>
<Link
fontSize={{ base: 'xs', sm: 'sm' }}
fontWeight="500"
paddingRight={{ sm: 2 }}
href="#"
onClick={ handleInfoClick }
>
More info
</Link>
</Box>
) }
</Flex>
<Skeleton
isLoaded={ !isLoading }
marginBottom={{ base: 0, sm: 2 }}
color="text_secondary"
fontSize="xs"
<Flex
display={{ base: 'flex', sm: 'contents' }}
flexDirection="column"
gap={ 2 }
>
<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
isLoaded={ !isLoading }
fontSize={{ base: 'xs', sm: 'sm' }}
lineHeight="20px"
noOfLines={ 3 }
>
{ shortDescription }
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
marginBottom={{ base: 0, sm: 2 }}
color="text_secondary"
fontSize="xs"
lineHeight="16px"
order={{ base: 'auto', sm: 3 }}
>
<span>{ categoriesLabel }</span>
</Skeleton>
{ !isLoading && (
<Box
display="flex"
position={{ base: 'absolute', sm: 'relative' }}
bottom={{ base: 3, sm: 0 }}
left={{ base: 3, sm: 0 }}
marginTop={{ base: 0, sm: 'auto' }}
paddingTop={{ base: 0, sm: 4 }}
<Skeleton
isLoaded={ !isLoading }
fontSize={{ base: 'xs', sm: 'sm' }}
lineHeight="20px"
noOfLines={ 3 }
order={{ base: 'auto', sm: 4 }}
>
<Link
fontSize={{ base: 'xs', sm: 'sm' }}
paddingRight={{ sm: 2 }}
href="#"
onClick={ handleInfoClick }
>
More info
</Link>
</Box>
) }
{ shortDescription }
</Skeleton>
</Flex>
{ !isLoading && (
<IconButton
display="block"
display="flex"
alignItems="center"
justifyContent="center"
position="absolute"
right={{ base: 3, sm: '10px' }}
top={{ base: 3, sm: '14px' }}
right={{ base: 1, sm: '10px' }}
top={{ base: 1, sm: '10px' }}
aria-label="Mark as favorite"
title="Mark as favorite"
variant="ghost"
......@@ -167,9 +181,9 @@ const MarketplaceAppCard = ({
}
/>
) }
</Box>
</Flex>
</LinkBox>
);
};
export default React.memo(MarketplaceAppCard);
export default React.memo(chakra(MarketplaceAppCard));
......@@ -42,7 +42,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => {
position="relative"
cursor="pointer"
verticalAlign="middle"
marginBottom={ 1 }
mb={ 1 }
/>
</Tooltip>
);
......
import { Grid } from '@chakra-ui/react';
import React from 'react';
import React, { useCallback } from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard';
......@@ -18,6 +20,15 @@ type 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 ? (
<Grid
templateColumns={{
......@@ -30,7 +41,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
{ apps.map((app, index) => (
<MarketplaceAppCard
key={ app.id + (isLoading ? index : '') }
onInfoClick={ showAppInfo }
onInfoClick={ handleInfoClick }
id={ app.id }
external={ app.external }
url={ app.url }
......@@ -40,7 +51,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
shortDescription={ app.shortDescription }
categories={ app.categories }
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick }
onFavoriteClick={ handleFavoriteClick }
isLoading={ isLoading }
internalWallet={ app.internalWallet }
onAppClick={ onAppClick }
......
......@@ -43,7 +43,7 @@ export default function useMarketplace() {
const [ contractListModalType, setContractListModalType ] = React.useState<ContractListTypes | null>(null);
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 });
const favoriteApps = getFavoriteApps();
......
......@@ -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_SECURITY_REPORTS_URL =
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({
context: contextWithEnvs([
......@@ -21,7 +24,7 @@ const test = base.extend({
]) 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({
status: 200,
body: JSON.stringify(appsMock),
......@@ -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(
<TestApp>
<Marketplace/>
......@@ -43,8 +51,31 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
);
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({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', value: MARKETPLACE_CONFIG_URL },
......
......@@ -9,6 +9,7 @@ import config from 'configs/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile';
import Banner from 'ui/marketplace/Banner';
import ContractListModal from 'ui/marketplace/ContractListModal';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceDisclaimerModal from 'ui/marketplace/MarketplaceDisclaimerModal';
......@@ -167,6 +168,16 @@ const Marketplace = () => {
</Flex>
) }
/>
<Banner
apps={ displayedApps }
favoriteApps={ favoriteApps }
isLoading={ isPlaceholderData }
onInfoClick={ showAppInfo }
onFavoriteClick={ onFavoriteClick }
onAppClick={ handleAppClick }
/>
<Box marginTop={{ base: 0, lg: 8 }}>
{ (isCategoriesPlaceholderData) ? (
<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