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

Merge pull request #2031 from blockscout/dapp-card-redesign

Combine default and security score view on the Marketplace page
parents 51f593c4 9b359f20
...@@ -41,11 +41,6 @@ export enum ContractListTypes { ...@@ -41,11 +41,6 @@ export enum ContractListTypes {
VERIFIED = 'Verified', VERIFIED = 'Verified',
} }
export enum MarketplaceDisplayType {
DEFAULT = 'default',
SCORES = 'scores',
}
export type MarketplaceAppSecurityReport = { export type MarketplaceAppSecurityReport = {
overallInfo: { overallInfo: {
verifiedNumber: number; verifiedNumber: number;
......
import { Box, Text, Link, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure } from '@chakra-ui/react'; import { Box, Text, Link, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure, chakra, Flex, Divider, Icon } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { MarketplaceAppSecurityReport } from 'types/client/marketplace'; import type { MarketplaceAppSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import config from 'configs/app'; import config from 'configs/app';
// This icon doesn't work properly when it is in the sprite
// Probably because of the gradient
// eslint-disable-next-line no-restricted-imports
import solidityScanIcon from 'icons/brands/solidity_scan.svg';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
...@@ -14,13 +19,17 @@ import SolidityscanReportScore from 'ui/shared/solidityscanReport/SolidityscanRe ...@@ -14,13 +19,17 @@ import SolidityscanReportScore from 'ui/shared/solidityscanReport/SolidityscanRe
type Props = { type Props = {
id: string; id: string;
securityReport?: MarketplaceAppSecurityReport; securityReport?: MarketplaceAppSecurityReport;
showContractList: () => void; showContractList: (id: string, type: ContractListTypes) => void;
isLoading?: boolean; isLoading?: boolean;
onlyIcon?: boolean; onlyIcon?: boolean;
source: 'Security view' | 'App modal' | 'App page'; source: 'Discovery view' | 'App modal' | 'App page';
className?: string;
popoverPlacement?: 'bottom-start' | 'bottom-end' | 'left';
} }
const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, onlyIcon, source }: Props) => { const AppSecurityReport = ({
id, securityReport, showContractList, isLoading, onlyIcon, source, className, popoverPlacement = 'bottom-start',
}: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure(); const { isOpen, onToggle, onClose } = useDisclosure();
const handleButtonClick = React.useCallback(() => { const handleButtonClick = React.useCallback(() => {
...@@ -28,14 +37,15 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on ...@@ -28,14 +37,15 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on
onToggle(); onToggle();
}, [ id, source, onToggle ]); }, [ id, source, onToggle ]);
const handleLinkClick = React.useCallback(() => { const showAnalyzedContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Analyzed contracts', Info: id, Source: 'Security score popup' }); mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Analyzed contracts', Info: id, Source: 'Security score popup' });
showContractList(); showContractList(id, ContractListTypes.ANALYZED);
}, [ id, showContractList ]); }, [ showContractList, id ]);
if (!securityReport && !isLoading) { const showAllContracts = React.useCallback(() => {
return null; mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Total contracts', Info: id, Source: 'Security score popup' });
} showContractList(id, ContractListTypes.ALL);
}, [ showContractList, id ]);
const { const {
securityScore = 0, securityScore = 0,
...@@ -44,8 +54,12 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on ...@@ -44,8 +54,12 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on
totalIssues = 0, totalIssues = 0,
} = securityReport?.overallInfo || {}; } = securityReport?.overallInfo || {};
if ((!securityReport || !securityScore) && !isLoading) {
return null;
}
return ( return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy> <Popover isOpen={ isOpen } onClose={ onClose } placement={ popoverPlacement } isLazy>
<PopoverTrigger> <PopoverTrigger>
<SolidityscanReportButton <SolidityscanReportButton
score={ securityScore } score={ securityScore }
...@@ -53,14 +67,30 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on ...@@ -53,14 +67,30 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on
onClick={ handleButtonClick } onClick={ handleButtonClick }
isActive={ isOpen } isActive={ isOpen }
onlyIcon={ onlyIcon } onlyIcon={ onlyIcon }
label="The security score is based on analysis of a DApp's smart contracts." label={ <>The security score is based on analysis<br/>of a DApp{ apos }s smart contracts.</> }
className={ className }
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '328px' }}> <PopoverContent w={{ base: '100vw', lg: '328px' }}>
<PopoverBody px="26px" py="20px" fontSize="sm"> <PopoverBody px="26px" py="20px" fontSize="sm">
<Text fontWeight="500" fontSize="xs" mb={ 2 } variant="secondary">Smart contracts info</Text>
<Flex alignItems="center" justifyContent="space-between" py={ 1.5 }>
<Flex alignItems="center">
<IconSvg name="contracts_verified" boxSize={ 5 } color="green.500" mr={ 1 }/>
<Text>Verified contracts</Text>
</Flex>
<Link fontSize="sm" fontWeight="500" onClick={ showAllContracts }>
{ securityReport?.overallInfo.verifiedNumber ?? 0 } of { securityReport?.overallInfo.totalContractsNumber ?? 0 }
</Link>
</Flex>
<Divider my={ 3 }/>
<Box mb={ 5 }> <Box mb={ 5 }>
{ solidityScanContractsNumber } smart contract{ solidityScanContractsNumber === 1 ? ' was' : 's were' } evaluated to determine { solidityScanContractsNumber } smart contract{ solidityScanContractsNumber === 1 ? ' was' : 's were' } evaluated to determine
this protocol{ apos }s overall security score on the { config.chain.name } network. this protocol{ apos }s overall security score on the { config.chain.name } network by { ' ' }
<Box>
<Icon as={ solidityScanIcon } mr={ 1 } w="23px" h="20px" display="inline-block" verticalAlign="middle"/>
<Text fontWeight={ 600 } display="inline-block">SolidityScan</Text>
</Box>
</Box> </Box>
<SolidityscanReportScore score={ securityScore } mb={ 5 }/> <SolidityscanReportScore score={ securityScore } mb={ 5 }/>
{ issueSeverityDistribution && totalIssues > 0 && ( { issueSeverityDistribution && totalIssues > 0 && (
...@@ -69,9 +99,8 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on ...@@ -69,9 +99,8 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on
<SolidityscanReportDetails vulnerabilities={ issueSeverityDistribution } vulnerabilitiesCount={ totalIssues }/> <SolidityscanReportDetails vulnerabilities={ issueSeverityDistribution } vulnerabilitiesCount={ totalIssues }/>
</Box> </Box>
) } ) }
<Link onClick={ handleLinkClick } display="inline-flex" alignItems="center"> <Link onClick={ showAnalyzedContracts } display="inline-flex" alignItems="center">
Analyzed contracts Analyzed contracts
<IconSvg name="arrows/north-east" boxSize={ 5 } color="gray.400"/>
</Link> </Link>
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
...@@ -79,4 +108,4 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on ...@@ -79,4 +108,4 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on
); );
}; };
export default AppSecurityReport; export default chakra(AppSecurityReport);
import { Link, Tooltip, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import config from 'configs/app';
import IconSvg from 'ui/shared/IconSvg';
export enum ContractListButtonVariants {
ALL_CONTRACTS = 'all contracts',
VERIFIED_CONTRACTS = 'verified contracts',
}
const values = {
[ContractListButtonVariants.ALL_CONTRACTS]: {
icon: 'contracts' as const,
iconColor: 'gray.500',
tooltip: `Total number of contracts deployed by the protocol on ${ config.chain.name }`,
},
[ContractListButtonVariants.VERIFIED_CONTRACTS]: {
icon: 'contracts_verified' as const,
iconColor: 'green.500',
tooltip: `Number of verified contracts on ${ config.chain.name }`,
},
};
interface Props {
children: string | number;
onClick: (event: MouseEvent) => void;
variant: ContractListButtonVariants;
isLoading?: boolean;
}
const ContractListButton = ({ children, onClick, variant, isLoading }: Props) => {
const { icon, iconColor, tooltip } = values[variant];
return (
<Tooltip
label={ tooltip }
textAlign="center"
padding={ 2 }
isDisabled={ !tooltip }
openDelay={ 500 }
width="250px"
>
<Skeleton
isLoaded={ !isLoading }
display="inline-flex"
alignItems="center"
width={ isLoading ? '40px' : 'auto' }
height="30px"
borderRadius="base"
>
<Link
fontSize="sm"
onClick={ onClick }
fontWeight="500"
display="inline-flex"
>
{ icon && <IconSvg name={ icon } boxSize={ 5 } color={ iconColor } mr={ 1 }/> }
{ children }
</Link>
</Skeleton>
</Tooltip>
);
};
export default ContractListButton;
...@@ -37,7 +37,7 @@ const ContractListModal = ({ onClose, onBack, type, contracts }: Props) => { ...@@ -37,7 +37,7 @@ const ContractListModal = ({ onClose, onBack, type, contracts }: Props) => {
switch (type) { switch (type) {
default: default:
case ContractListTypes.ALL: case ContractListTypes.ALL:
return contracts; return contracts.sort((a) => a.isVerified ? -1 : 1);
case ContractListTypes.ANALYZED: case ContractListTypes.ANALYZED:
return contracts return contracts
.filter((contract) => Boolean(contract.solidityScanReport)) .filter((contract) => Boolean(contract.solidityScanReport))
......
import { Box, Text, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure } from '@chakra-ui/react'; import { Box, Text, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure, Icon } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SolidityscanReport } from 'types/api/contract'; import type { SolidityscanReport } from 'types/api/contract';
import config from 'configs/app'; import config from 'configs/app';
// This icon doesn't work properly when it is in the sprite
// Probably because of the gradient
// eslint-disable-next-line no-restricted-imports
import solidityScanIcon from 'icons/brands/solidity_scan.svg';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import LinkExternal from 'ui/shared/links/LinkExternal'; import LinkExternal from 'ui/shared/links/LinkExternal';
import SolidityscanReportButton from 'ui/shared/solidityscanReport/SolidityscanReportButton'; import SolidityscanReportButton from 'ui/shared/solidityscanReport/SolidityscanReportButton';
...@@ -46,7 +50,11 @@ const ContractSecurityReport = ({ securityReport }: Props) => { ...@@ -46,7 +50,11 @@ const ContractSecurityReport = ({ securityReport }: Props) => {
<PopoverContent w={{ base: '100vw', lg: '328px' }}> <PopoverContent w={{ base: '100vw', lg: '328px' }}>
<PopoverBody px="26px" py="20px" fontSize="sm"> <PopoverBody px="26px" py="20px" fontSize="sm">
<Box mb={ 5 }> <Box mb={ 5 }>
The security score was derived from evaluating the smart contracts of a protocol on the { config.chain.name } network. The security score was derived from evaluating the smart contracts of a protocol on the { config.chain.name } network by { ' ' }
<Box>
<Icon as={ solidityScanIcon } mr={ 1 } w="23px" h="20px" display="inline-block" verticalAlign="middle"/>
<Text fontWeight={ 600 } display="inline-block">SolidityScan</Text>
</Box>
</Box> </Box>
<SolidityscanReportScore score={ parseFloat(securityScore) } mb={ 5 }/> <SolidityscanReportScore score={ parseFloat(securityScore) } mb={ 5 }/>
{ issueSeverityDistribution && totalIssues > 0 && ( { issueSeverityDistribution && totalIssues > 0 && (
......
...@@ -2,20 +2,23 @@ import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, cha ...@@ -2,20 +2,23 @@ import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, cha
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 { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport';
import MarketplaceAppCardLink from './MarketplaceAppCardLink'; import MarketplaceAppCardLink from './MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon'; import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
interface Props extends MarketplaceAppPreview { interface Props extends MarketplaceAppWithSecurityReport {
onInfoClick: (id: string) => void; onInfoClick: (id: string) => void;
isFavorite: boolean; isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean) => 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; className?: string;
showContractList: (id: string, type: ContractListTypes) => void;
} }
const MarketplaceAppCard = ({ const MarketplaceAppCard = ({
...@@ -33,8 +36,11 @@ const MarketplaceAppCard = ({ ...@@ -33,8 +36,11 @@ const MarketplaceAppCard = ({
isLoading, isLoading,
internalWallet, internalWallet,
onAppClick, onAppClick,
securityReport,
className, className,
showContractList,
}: Props) => { }: Props) => {
const isMobile = useIsMobile();
const categoriesLabel = categories.join(', '); const categoriesLabel = categories.join(', ');
const handleInfoClick = useCallback((event: MouseEvent) => { const handleInfoClick = useCallback((event: MouseEvent) => {
...@@ -58,32 +64,29 @@ const MarketplaceAppCard = ({ ...@@ -58,32 +64,29 @@ const MarketplaceAppCard = ({
boxShadow: isLoading ? 'none' : 'md', boxShadow: isLoading ? 'none' : 'md',
}} }}
borderRadius="md" borderRadius="md"
padding={{ base: 3, sm: '20px' }} padding={{ base: 3, md: '20px' }}
border="1px" border="1px"
borderColor={ useColorModeValue('gray.200', 'gray.600') } borderColor={ useColorModeValue('gray.200', 'gray.600') }
role="group" role="group"
> >
<Flex <Flex
flexDirection={{ base: 'row', sm: 'column' }} flexDirection="column"
height="100%" height="100%"
alignContent="start" alignContent="start"
gap={{ base: 4, sm: 0 }} gap={ 2 }
> >
<Flex <Flex
display={{ base: 'flex', sm: 'contents' }} display={{ base: 'flex', md: 'contents' }}
flexDirection="column" gap={ 4 }
alignItems="center"
justifyContent="space-between"
> >
<Skeleton <Skeleton
isLoaded={ !isLoading } isLoaded={ !isLoading }
marginBottom={ 4 } w={{ base: '64px', md: '96px' }}
w={{ base: '64px', sm: '96px' }} h={{ base: '64px', md: '96px' }}
h={{ base: '64px', sm: '96px' }}
display="flex" display="flex"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
order={{ base: 'auto', sm: 1 }} mb={{ base: 0, md: 2 }}
> >
<Image <Image
src={ isLoading ? undefined : logoUrl } src={ isLoading ? undefined : logoUrl }
...@@ -92,93 +95,96 @@ const MarketplaceAppCard = ({ ...@@ -92,93 +95,96 @@ const MarketplaceAppCard = ({
/> />
</Skeleton> </Skeleton>
{ !isLoading && ( <Flex
<Box display={{ base: 'flex', md: 'contents' }}
display="flex" flexDirection="column"
marginTop={{ base: 0, sm: 'auto' }} gap={ 2 }
paddingTop={{ base: 0, sm: 4 }} pt={ 1 }
order={{ base: 'auto', sm: 5 }} >
<Skeleton
isLoaded={ !isLoading }
fontSize={{ base: 'sm', md: 'lg' }}
lineHeight={{ base: '20px', md: '28px' }}
paddingRight={{ base: '40px', md: 0 }}
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"
> >
<Link <span>{ categoriesLabel }</span>
fontSize={{ base: 'xs', sm: 'sm' }} </Skeleton>
fontWeight="500" </Flex>
paddingRight={{ sm: 2 }}
href="#"
onClick={ handleInfoClick }
>
More info
</Link>
</Box>
) }
</Flex> </Flex>
<Flex <Skeleton
display={{ base: 'flex', sm: 'contents' }} isLoaded={ !isLoading }
flexDirection="column" fontSize="sm"
gap={ 2 } lineHeight="20px"
noOfLines={{ base: 2, md: 3 }}
> >
<Skeleton { shortDescription }
isLoaded={ !isLoading } </Skeleton>
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 }
marginBottom={{ base: 0, sm: 2 }}
color="text_secondary"
fontSize="xs"
lineHeight="16px"
order={{ base: 'auto', sm: 3 }}
>
<span>{ categoriesLabel }</span>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
fontSize={{ base: 'xs', sm: 'sm' }}
lineHeight="20px"
noOfLines={ 3 }
order={{ base: 'auto', sm: 4 }}
>
{ shortDescription }
</Skeleton>
</Flex>
{ !isLoading && ( { !isLoading && (
<IconButton <Box
display="flex" display="flex"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="space-between"
marginTop="auto"
>
<Link
fontSize="sm"
fontWeight="500"
paddingRight={{ md: 2 }}
href="#"
onClick={ handleInfoClick }
>
More info
</Link>
<IconButton
aria-label="Mark as favorite"
title="Mark as favorite"
variant="ghost"
colorScheme="gray"
w={{ base: 6, md: '30px' }}
h={{ base: 6, md: '30px' }}
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"/>
}
/>
</Box>
) }
{ securityReport && (
<AppSecurityReport
id={ id }
securityReport={ securityReport }
showContractList={ showContractList }
isLoading={ isLoading }
source="Discovery view"
popoverPlacement={ isMobile ? 'bottom-end' : 'bottom-start' }
position="absolute" position="absolute"
right={{ base: 1, sm: '10px' }} right={{ base: 3, md: 5 }}
top={{ base: 1, sm: '10px' }} top={{ base: '10px', md: 5 }}
aria-label="Mark as favorite" border={ 0 }
title="Mark as favorite" padding={ 0 }
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> </Flex>
......
...@@ -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"
mb={ 1 } mb={{ base: 0, md: 1 }}
/> />
</Tooltip> </Tooltip>
); );
......
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace'; import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { apps as appsMock } from 'mocks/apps/apps'; import { apps as appsMock } from 'mocks/apps/apps';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
import MarketplaceAppModal from './MarketplaceAppModal'; import MarketplaceAppModal from './MarketplaceAppModal';
...@@ -11,7 +12,10 @@ const props = { ...@@ -11,7 +12,10 @@ const props = {
onClose: () => {}, onClose: () => {},
onFavoriteClick: () => {}, onFavoriteClick: () => {},
showContractList: () => {}, showContractList: () => {},
data: appsMock[0] as MarketplaceAppWithSecurityReport, data: {
...appsMock[0],
securityReport: securityReportsMock[0].chainsData['1'],
} as MarketplaceAppWithSecurityReport,
isFavorite: false, isFavorite: false,
}; };
......
This diff is collapsed.
import { chakra, Flex, Tooltip, Skeleton, useBoolean } from '@chakra-ui/react'; import { chakra, Flex, Tooltip, Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { MarketplaceAppOverview, MarketplaceAppSecurityReport } from 'types/client/marketplace'; import type { MarketplaceAppOverview, MarketplaceAppSecurityReport, ContractListTypes } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -27,7 +26,7 @@ type Props = { ...@@ -27,7 +26,7 @@ type Props = {
} }
const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
const [ showContractList, setShowContractList ] = useBoolean(false); const [ contractListType, setContractListType ] = React.useState<ContractListTypes>();
const appProps = useAppContext(); const appProps = useAppContext();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -44,6 +43,9 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { ...@@ -44,6 +43,9 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
} catch (err) {} } catch (err) {}
} }
const showContractList = React.useCallback((id: string, type: ContractListTypes) => setContractListType(type), []);
const hideContractList = React.useCallback(() => setContractListType(undefined), []);
return ( return (
<> <>
<Flex alignItems="center" flexWrap="wrap" mb={{ base: 3, md: 2 }} rowGap={ 3 } columnGap={ 2 }> <Flex alignItems="center" flexWrap="wrap" mb={{ base: 3, md: 2 }} rowGap={ 3 } columnGap={ 2 }>
...@@ -74,7 +76,7 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { ...@@ -74,7 +76,7 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
<AppSecurityReport <AppSecurityReport
id={ data?.id || '' } id={ data?.id || '' }
securityReport={ securityReport } securityReport={ securityReport }
showContractList={ setShowContractList.on } showContractList={ showContractList }
isLoading={ isLoading } isLoading={ isLoading }
onlyIcon={ isMobile } onlyIcon={ isMobile }
source="App page" source="App page"
...@@ -87,11 +89,11 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { ...@@ -87,11 +89,11 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
</Flex> </Flex>
) } ) }
</Flex> </Flex>
{ showContractList && ( { contractListType && (
<ContractListModal <ContractListModal
type={ ContractListTypes.ANALYZED } type={ contractListType }
contracts={ securityReport?.contractsData } contracts={ securityReport?.contractsData }
onClose={ setShowContractList.off } onClose={ hideContractList }
/> />
) } ) }
</> </>
......
...@@ -2,7 +2,7 @@ import { Grid } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Grid } from '@chakra-ui/react';
import React, { useCallback } 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 { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
...@@ -10,16 +10,17 @@ import EmptySearchResult from './EmptySearchResult'; ...@@ -10,16 +10,17 @@ import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard'; import MarketplaceAppCard from './MarketplaceAppCard';
type Props = { type Props = {
apps: Array<MarketplaceAppPreview>; apps: Array<MarketplaceAppWithSecurityReport>;
showAppInfo: (id: string) => void; showAppInfo: (id: string) => void;
favoriteApps: Array<string>; favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Discovery view') => void; onFavoriteClick: (id: string, isFavorite: boolean, source: 'Discovery view') => void;
isLoading: boolean; isLoading: boolean;
selectedCategoryId?: string; selectedCategoryId?: string;
onAppClick: (event: MouseEvent, id: string) => void; onAppClick: (event: MouseEvent, id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
} }
const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, onAppClick }: Props) => { const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, onAppClick, showContractList }: Props) => {
const handleInfoClick = useCallback((id: string) => { const handleInfoClick = useCallback((id: string) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' }); mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
showAppInfo(id); showAppInfo(id);
...@@ -32,11 +33,11 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL ...@@ -32,11 +33,11 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
return apps.length > 0 ? ( return apps.length > 0 ? (
<Grid <Grid
templateColumns={{ templateColumns={{
sm: 'repeat(auto-fill, minmax(178px, 1fr))', md: 'repeat(auto-fill, minmax(230px, 1fr))',
lg: 'repeat(auto-fill, minmax(260px, 1fr))', lg: 'repeat(auto-fill, minmax(260px, 1fr))',
}} }}
autoRows="1fr" autoRows="1fr"
gap={{ base: '16px', sm: '24px' }} gap={{ base: '16px', md: '24px' }}
> >
{ apps.map((app, index) => ( { apps.map((app, index) => (
<MarketplaceAppCard <MarketplaceAppCard
...@@ -55,6 +56,8 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL ...@@ -55,6 +56,8 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
isLoading={ isLoading } isLoading={ isLoading }
internalWallet={ app.internalWallet } internalWallet={ app.internalWallet }
onAppClick={ onAppClick } onAppClick={ onAppClick }
securityReport={ app.securityReport }
showContractList={ showContractList }
/> />
)) } )) }
</Grid> </Grid>
......
import { Hide, Show } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import DataListDisplay from 'ui/shared/DataListDisplay';
import EmptySearchResult from './EmptySearchResult';
import ListItem from './MarketplaceListWithScores/ListItem';
import Table from './MarketplaceListWithScores/Table';
interface Props {
apps: Array<MarketplaceAppWithSecurityReport>;
showAppInfo: (id: string) => void;
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Security view') => void;
isLoading: boolean;
selectedCategoryId?: string;
onAppClick: (event: MouseEvent, id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
}
const MarketplaceListWithScores = ({
apps,
showAppInfo,
favoriteApps,
onFavoriteClick,
isLoading,
selectedCategoryId,
onAppClick,
showContractList,
}: Props) => {
const displayedApps = React.useMemo(() => [ ...apps ].sort((a, b) => {
if (!a.securityReport) {
return 1;
} else if (!b.securityReport) {
return -1;
}
return b.securityReport.overallInfo.securityScore - a.securityReport.overallInfo.securityScore;
}), [ apps ]);
const content = apps.length > 0 ? (
<>
<Show below="lg" ssr={ false }>
{ displayedApps.map((app, index) => (
<ListItem
key={ app.id + (isLoading ? index : '') }
app={ app }
onInfoClick={ showAppInfo }
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick }
isLoading={ isLoading }
onAppClick={ onAppClick }
showContractList={ showContractList }
/>
)) }
</Show>
<Hide below="lg" ssr={ false }>
<Table
apps={ displayedApps }
isLoading={ isLoading }
onAppClick={ onAppClick }
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
onInfoClick={ showAppInfo }
showContractList={ showContractList }
/>
</Hide>
</>
) : null;
return apps.length > 0 ? (
<DataListDisplay
isError={ false }
items={ apps }
emptyText="No apps found."
content={ content }
/>
) : (
<EmptySearchResult selectedCategoryId={ selectedCategoryId } favoriteApps={ favoriteApps }/>
);
};
export default MarketplaceListWithScores;
import { Flex, Skeleton, LinkBox, Image, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import MarketplaceAppCardLink from '../MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
interface Props {
app: MarketplaceAppPreview;
isLoading: boolean | undefined;
onAppClick: (event: MouseEvent, id: string) => void;
isLarge?: boolean;
}
const AppLink = ({ app, isLoading, onAppClick, isLarge = false }: Props) => {
const { id, url, external, title, logo, logoDarkMode, internalWallet, categories } = app;
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
const categoriesLabel = categories.join(', ');
return (
<LinkBox display="flex" height="100%" width="100%" role="group" alignItems="center" mb={ isLarge ? 0 : 4 }>
<Skeleton
isLoaded={ !isLoading }
w={ isLarge ? '56px' : '48px' }
h={ isLarge ? '56px' : '48px' }
display="flex"
alignItems="center"
justifyContent="center"
mr={ isLarge ? 3 : 4 }
flexShrink={ 0 }
>
<Image
src={ isLoading ? undefined : logoUrl }
alt={ `${ title } app icon` }
borderRadius="8px"
/>
</Skeleton>
<Flex direction="column">
<Skeleton
isLoaded={ !isLoading }
marginBottom={ 0 }
fontSize="sm"
fontWeight="semibold"
fontFamily="heading"
display="inline-block"
mb={ 1 }
>
<MarketplaceAppCardLink
id={ id }
url={ url }
external={ external }
title={ title }
onClick={ onAppClick }
/>
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
color="text_secondary"
fontSize={ isLarge ? 'sm' : 'xs' }
>
<span>{ categoriesLabel }</span>
</Skeleton>
</Flex>
</LinkBox>
);
};
export default AppLink;
import { Flex, IconButton, Text } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import AppSecurityReport from '../AppSecurityReport';
import ContractListButton, { ContractListButtonVariants } from '../ContractListButton';
import AppLink from './AppLink';
import MoreInfoButton from './MoreInfoButton';
type Props = {
app: MarketplaceAppWithSecurityReport;
onInfoClick: (id: string) => void;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Security view') => void;
isLoading: boolean;
onAppClick: (event: MouseEvent, id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
}
const ListItem = ({ app, onInfoClick, isFavorite, onFavoriteClick, isLoading, onAppClick, showContractList }: Props) => {
const { id, securityReport } = app;
const handleInfoClick = React.useCallback((event: MouseEvent) => {
event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Security view' });
onInfoClick(id);
}, [ onInfoClick, id ]);
const handleFavoriteClick = React.useCallback(() => {
onFavoriteClick(id, isFavorite, 'Security view');
}, [ onFavoriteClick, id, isFavorite ]);
const showAllContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Total contracts', Info: id, Source: 'Security view' });
showContractList(id, ContractListTypes.ALL);
}, [ showContractList, id ]);
const showVerifiedContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Verified contracts', Info: id, Source: 'Security view' });
showContractList(id, ContractListTypes.VERIFIED);
}, [ showContractList, id ]);
const showAnalyzedContracts = React.useCallback(() => {
showContractList(id, ContractListTypes.ANALYZED);
}, [ showContractList, id ]);
return (
<ListItemMobile
rowGap={ 3 }
py={ 3 }
_first={{ borderTop: 'none', paddingTop: 0 }}
_last={{ borderBottom: 'none', paddingBottom: 0 }}
>
<Flex
direction="column"
justifyContent="stretch"
padding={ 3 }
width="100%"
>
<Flex position="relative">
<AppLink app={ app } isLoading={ isLoading } onAppClick={ onAppClick }/>
{ !isLoading && (
<IconButton
position="absolute"
right={ -1 }
top={ -1 }
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>
<Flex alignItems="center">
<Flex flex={ 1 } gap={ 3 } alignItems="center">
{ (securityReport || isLoading) ? (
<>
<AppSecurityReport
id={ id }
isLoading={ isLoading }
securityReport={ securityReport }
showContractList={ showAnalyzedContracts }
source="Security view"
/>
<ContractListButton
onClick={ showAllContracts }
variant={ ContractListButtonVariants.ALL_CONTRACTS }
isLoading={ isLoading }
>
{ securityReport?.overallInfo.totalContractsNumber ?? 0 }
</ContractListButton>
<ContractListButton
onClick={ showVerifiedContracts }
variant={ ContractListButtonVariants.VERIFIED_CONTRACTS }
isLoading={ isLoading }
>
{ securityReport?.overallInfo.verifiedNumber ?? 0 }
</ContractListButton>
</>
) : (
<Text variant="secondary" fontSize="sm" fontWeight={ 500 }>Data will be available soon</Text>
) }
</Flex>
<MoreInfoButton onClick={ handleInfoClick } isLoading={ isLoading }/>
</Flex>
</Flex>
</ListItemMobile>
);
};
export default ListItem;
import { Link, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
interface Props {
onClick: (event: MouseEvent) => void;
isLoading?: boolean;
}
const MoreInfoButton = ({ onClick, isLoading }: Props) => (
<Skeleton
isLoaded={ !isLoading }
display="inline-flex"
alignItems="center"
height="30px"
borderRadius="base"
>
<Link
fontSize="sm"
onClick={ onClick }
fontWeight="500"
display="inline-flex"
>
More info
</Link>
</Skeleton>
);
export default MoreInfoButton;
import { Table as ChakraTable, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import { default as Thead } from 'ui/shared/TheadSticky';
import TableItem from './TableItem';
type Props = {
apps: Array<MarketplaceAppWithSecurityReport>;
isLoading?: boolean;
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Security view') => void;
onAppClick: (event: MouseEvent, id: string) => void;
onInfoClick: (id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
}
const Table = ({ apps, isLoading, favoriteApps, onFavoriteClick, onAppClick, onInfoClick, showContractList }: Props) => {
return (
<ChakraTable>
<Thead top={ 0 }>
<Tr>
<Th w="5%"></Th>
<Th w="40%">App</Th>
<Th w="15%">Contracts score</Th>
<Th w="10%">Total</Th>
<Th w="10%">Verified</Th>
<Th w="20%"></Th>
</Tr>
</Thead>
<Tbody>
{ apps.map((app, index) => (
<TableItem
key={ app.id + (isLoading ? index : '') }
app={ app }
isLoading={ isLoading }
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick }
onAppClick={ onAppClick }
onInfoClick={ onInfoClick }
showContractList={ showContractList }
/>
)) }
</Tbody>
</ChakraTable>
);
};
export default Table;
import { Td, Tr, IconButton, Skeleton, Text } from '@chakra-ui/react';
import React from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from '../AppSecurityReport';
import ContractListButton, { ContractListButtonVariants } from '../ContractListButton';
import AppLink from './AppLink';
import MoreInfoButton from './MoreInfoButton';
type Props = {
app: MarketplaceAppWithSecurityReport;
isLoading?: boolean;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Security view') => void;
onAppClick: (event: MouseEvent, id: string) => void;
onInfoClick: (id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
}
const TableItem = ({
app,
isLoading,
isFavorite,
onFavoriteClick,
onAppClick,
onInfoClick,
showContractList,
}: Props) => {
const { id, securityReport } = app;
const handleInfoClick = React.useCallback((event: MouseEvent) => {
event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Security view' });
onInfoClick(id);
}, [ onInfoClick, id ]);
const handleFavoriteClick = React.useCallback(() => {
onFavoriteClick(id, isFavorite, 'Security view');
}, [ onFavoriteClick, id, isFavorite ]);
const showAllContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Total contracts', Info: id, Source: 'Security view' });
showContractList(id, ContractListTypes.ALL);
}, [ showContractList, id ]);
const showVerifiedContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Verified contracts', Info: id, Source: 'Security view' });
showContractList(id, ContractListTypes.VERIFIED);
}, [ showContractList, id ]);
const showAnalyzedContracts = React.useCallback(() => {
showContractList(id, ContractListTypes.ANALYZED);
}, [ showContractList, id ]);
return (
<Tr>
<Td verticalAlign="middle" px={ 2 }>
<Skeleton isLoaded={ !isLoading }>
<IconButton
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"/>
}
/>
</Skeleton>
</Td>
<Td verticalAlign="middle">
<AppLink app={ app } isLoading={ isLoading } onAppClick={ onAppClick } isLarge/>
</Td>
{ (securityReport || isLoading) ? (
<>
<Td verticalAlign="middle">
<AppSecurityReport
id={ id }
securityReport={ securityReport }
showContractList={ showAnalyzedContracts }
isLoading={ isLoading }
source="Security view"
/>
</Td>
<Td verticalAlign="middle">
<ContractListButton
onClick={ showAllContracts }
variant={ ContractListButtonVariants.ALL_CONTRACTS }
isLoading={ isLoading }
>
{ securityReport?.overallInfo.totalContractsNumber ?? 0 }
</ContractListButton>
</Td>
<Td verticalAlign="middle">
<ContractListButton
onClick={ showVerifiedContracts }
variant={ ContractListButtonVariants.VERIFIED_CONTRACTS }
isLoading={ isLoading }
>
{ securityReport?.overallInfo.verifiedNumber ?? 0 }
</ContractListButton>
</Td>
</>
) : (
<Td verticalAlign="middle" colSpan={ 3 }>
<Text variant="secondary" fontSize="sm" fontWeight={ 500 }>Data will be available soon</Text>
</Td>
) }
<Td verticalAlign="middle" isNumeric>
<MoreInfoButton onClick={ handleInfoClick } isLoading={ isLoading }/>
</Td>
</Tr>
);
};
export default TableItem;
...@@ -3,7 +3,7 @@ import { useRouter } from 'next/router'; ...@@ -3,7 +3,7 @@ import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { ContractListTypes } from 'types/client/marketplace'; import type { ContractListTypes } from 'types/client/marketplace';
import { MarketplaceCategory, MarketplaceDisplayType } from 'types/client/marketplace'; import { MarketplaceCategory } from 'types/client/marketplace';
import useDebounce from 'lib/hooks/useDebounce'; import useDebounce from 'lib/hooks/useDebounce';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
...@@ -26,15 +26,9 @@ export default function useMarketplace() { ...@@ -26,15 +26,9 @@ export default function useMarketplace() {
const router = useRouter(); const router = useRouter();
const defaultCategoryId = getQueryParamString(router.query.category); const defaultCategoryId = getQueryParamString(router.query.category);
const defaultFilterQuery = getQueryParamString(router.query.filter); const defaultFilterQuery = getQueryParamString(router.query.filter);
const defaultDisplayType = getQueryParamString(router.query.tab);
const [ selectedAppId, setSelectedAppId ] = React.useState<string | null>(null); const [ selectedAppId, setSelectedAppId ] = React.useState<string | null>(null);
const [ selectedCategoryId, setSelectedCategoryId ] = React.useState<string>(MarketplaceCategory.ALL); const [ selectedCategoryId, setSelectedCategoryId ] = React.useState<string>(MarketplaceCategory.ALL);
const [ selectedDisplayType, setSelectedDisplayType ] = React.useState<string>(
Object.values(MarketplaceDisplayType).includes(defaultDisplayType as MarketplaceDisplayType) ?
defaultDisplayType :
MarketplaceDisplayType.DEFAULT,
);
const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery); const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery);
const [ favoriteApps, setFavoriteApps ] = React.useState<Array<string>>([]); const [ favoriteApps, setFavoriteApps ] = React.useState<Array<string>>([]);
const [ isFavoriteAppsLoaded, setIsFavoriteAppsLoaded ] = React.useState<boolean>(false); const [ isFavoriteAppsLoaded, setIsFavoriteAppsLoaded ] = React.useState<boolean>(false);
...@@ -91,12 +85,8 @@ export default function useMarketplace() { ...@@ -91,12 +85,8 @@ export default function useMarketplace() {
setSelectedCategoryId(newCategory); setSelectedCategoryId(newCategory);
}, []); }, []);
const handleDisplayTypeChange = React.useCallback((newDisplayType: MarketplaceDisplayType) => {
setSelectedDisplayType(newDisplayType);
}, []);
const { const {
isPlaceholderData, isError, error, data, displayedApps, isPlaceholderData, isError, error, data, displayedApps, setSorting,
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded); } = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded);
const { const {
isPlaceholderData: isCategoriesPlaceholderData, data: categories, isPlaceholderData: isCategoriesPlaceholderData, data: categories,
...@@ -120,7 +110,6 @@ export default function useMarketplace() { ...@@ -120,7 +110,6 @@ export default function useMarketplace() {
const query = _pickBy({ const query = _pickBy({
category: selectedCategoryId === MarketplaceCategory.ALL ? undefined : selectedCategoryId, category: selectedCategoryId === MarketplaceCategory.ALL ? undefined : selectedCategoryId,
filter: debouncedFilterQuery, filter: debouncedFilterQuery,
tab: selectedDisplayType === MarketplaceDisplayType.DEFAULT ? undefined : selectedDisplayType,
}, Boolean); }, Boolean);
if (debouncedFilterQuery.length > 0) { if (debouncedFilterQuery.length > 0) {
...@@ -135,7 +124,7 @@ export default function useMarketplace() { ...@@ -135,7 +124,7 @@ export default function useMarketplace() {
// omit router in the deps because router.push() somehow modifies it // omit router in the deps because router.push() somehow modifies it
// and we get infinite re-renders then // and we get infinite re-renders then
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ debouncedFilterQuery, selectedCategoryId, selectedDisplayType ]); }, [ debouncedFilterQuery, selectedCategoryId ]);
return React.useMemo(() => ({ return React.useMemo(() => ({
selectedCategoryId, selectedCategoryId,
...@@ -160,9 +149,8 @@ export default function useMarketplace() { ...@@ -160,9 +149,8 @@ export default function useMarketplace() {
isCategoriesPlaceholderData, isCategoriesPlaceholderData,
showContractList, showContractList,
contractListModalType, contractListModalType,
selectedDisplayType,
onDisplayTypeChange: handleDisplayTypeChange,
hasPreviousStep, hasPreviousStep,
setSorting,
}), [ }), [
selectedCategoryId, selectedCategoryId,
categories, categories,
...@@ -184,8 +172,7 @@ export default function useMarketplace() { ...@@ -184,8 +172,7 @@ export default function useMarketplace() {
isCategoriesPlaceholderData, isCategoriesPlaceholderData,
showContractList, showContractList,
contractListModalType, contractListModalType,
selectedDisplayType,
handleDisplayTypeChange,
hasPreviousStep, hasPreviousStep,
setSorting,
]); ]);
} }
...@@ -11,6 +11,7 @@ import useFetch from 'lib/hooks/useFetch'; ...@@ -11,6 +11,7 @@ import useFetch from 'lib/hooks/useFetch';
import { MARKETPLACE_APP } from 'stubs/marketplace'; import { MARKETPLACE_APP } from 'stubs/marketplace';
import useSecurityReports from './useSecurityReports'; import useSecurityReports from './useSecurityReports';
import type { SortValue } from './utils';
const feature = config.features.marketplace; const feature = config.features.marketplace;
...@@ -88,13 +89,22 @@ export default function useMarketplaceApps( ...@@ -88,13 +89,22 @@ export default function useMarketplaceApps(
enabled: feature.isEnabled && Boolean(snapshotFavoriteApps), enabled: feature.isEnabled && Boolean(snapshotFavoriteApps),
}); });
const [ sorting, setSorting ] = React.useState<SortValue>();
const appsWithSecurityReports = React.useMemo(() => const appsWithSecurityReports = React.useMemo(() =>
data?.map((app) => ({ ...app, securityReport: securityReports?.[app.id] })), data?.map((app) => ({ ...app, securityReport: securityReports?.[app.id] })),
[ data, securityReports ]); [ data, securityReports ]);
const displayedApps = React.useMemo(() => { const displayedApps = React.useMemo(() => {
return appsWithSecurityReports?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps)) || []; return appsWithSecurityReports
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps ]); ?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps))
.sort((a, b) => {
if (sorting === 'security_score') {
return (b.securityReport?.overallInfo.securityScore || 0) - (a.securityReport?.overallInfo.securityScore || 0);
}
return 0;
}) || [];
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps, sorting ]);
return React.useMemo(() => ({ return React.useMemo(() => ({
data, data,
...@@ -102,6 +112,7 @@ export default function useMarketplaceApps( ...@@ -102,6 +112,7 @@ export default function useMarketplaceApps(
error, error,
isError, isError,
isPlaceholderData: isPlaceholderData || isSecurityReportsPlaceholderData, isPlaceholderData: isPlaceholderData || isSecurityReportsPlaceholderData,
setSorting,
}), [ }), [
data, data,
displayedApps, displayedApps,
...@@ -109,5 +120,6 @@ export default function useMarketplaceApps( ...@@ -109,5 +120,6 @@ export default function useMarketplaceApps(
isError, isError,
isPlaceholderData, isPlaceholderData,
isSecurityReportsPlaceholderData, isSecurityReportsPlaceholderData,
setSorting,
]); ]);
} }
...@@ -2,6 +2,14 @@ import type { NextRouter } from 'next/router'; ...@@ -2,6 +2,14 @@ import type { NextRouter } from 'next/router';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import removeQueryParam from 'lib/router/removeQueryParam'; import removeQueryParam from 'lib/router/removeQueryParam';
import type { TOption } from 'ui/shared/sort/Option';
export type SortValue = 'security_score';
export const SORT_OPTIONS: Array<TOption<SortValue>> = [
{ title: 'Default', id: undefined },
{ title: 'Security score', id: 'security_score' },
];
export function getAppUrl(url: string | undefined, router: NextRouter) { export function getAppUrl(url: string | undefined, router: NextRouter) {
if (!url) { if (!url) {
......
...@@ -136,9 +136,10 @@ const NameDomainsActionBar = ({ ...@@ -136,9 +136,10 @@ const NameDomainsActionBar = ({
const sortButton = ( const sortButton = (
<Sort <Sort
name="name_domains_sorting"
defaultValue={ sort }
options={ SORT_OPTIONS } options={ SORT_OPTIONS }
sort={ sort } onChange={ onSortChange }
setSort={ onSortChange }
isLoading={ isInitialLoading } isLoading={ isInitialLoading }
/> />
); );
......
import type { EnsLookupSorting } from 'types/api/ens'; import type { EnsLookupSorting } from 'types/api/ens';
import getNextSortValueShared from 'ui/shared/sort/getNextSortValue'; import getNextSortValueShared from 'ui/shared/sort/getNextSortValue';
import type { Option } from 'ui/shared/sort/Sort'; import type { TOption } from 'ui/shared/sort/Option';
export type SortField = EnsLookupSorting['sort']; export type SortField = EnsLookupSorting['sort'];
export type Sort = `${ EnsLookupSorting['sort'] }-${ EnsLookupSorting['order'] }`; export type Sort = `${ EnsLookupSorting['sort'] }-${ EnsLookupSorting['order'] }`;
export const SORT_OPTIONS: Array<Option<Sort>> = [ export const SORT_OPTIONS: Array<TOption<Sort>> = [
{ title: 'Default', id: undefined }, { title: 'Default', id: undefined },
{ title: 'Registered on descending', id: 'registration_date-DESC' }, { title: 'Registered on descending', id: 'registration_date-DESC' },
{ title: 'Registered on ascending', id: 'registration_date-ASC' }, { title: 'Registered on ascending', id: 'registration_date-ASC' },
......
...@@ -7,13 +7,16 @@ import { test, expect, devices } from 'playwright/lib'; ...@@ -7,13 +7,16 @@ import { test, expect, devices } from 'playwright/lib';
import Marketplace from './Marketplace'; import Marketplace from './Marketplace';
const MARKETPLACE_CONFIG_URL = 'http://localhost/marketplace-config.json'; const MARKETPLACE_CONFIG_URL = 'http://localhost/marketplace-config.json';
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse }) => { test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ], [ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ], [ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
]); ]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock)); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock));
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
await Promise.all(appsMock.map(app => mockAssetResponse(app.logo, './playwright/mocks/image_s.jpg'))); await Promise.all(appsMock.map(app => mockAssetResponse(app.logo, './playwright/mocks/image_s.jpg')));
}); });
...@@ -46,18 +49,6 @@ test('with banner +@dark-mode', async({ render, mockEnvs, mockConfigResponse }) ...@@ -46,18 +49,6 @@ test('with banner +@dark-mode', async({ render, mockEnvs, mockConfigResponse })
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with scores +@dark-mode', async({ render, mockConfigResponse, mockEnvs }) => {
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
const component = await render(<Marketplace/>);
await component.getByText('Apps scores').click();
await expect(component).toHaveScreenshot();
});
// I had a memory error while running tests in GH actions // I had a memory error while running tests in GH actions
// separate run for mobile tests fixes it // separate run for mobile tests fixes it
test.describe('mobile', () => { test.describe('mobile', () => {
...@@ -91,16 +82,4 @@ test.describe('mobile', () => { ...@@ -91,16 +82,4 @@ test.describe('mobile', () => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with scores', async({ render, mockConfigResponse, mockEnvs }) => {
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
const component = await render(<Marketplace/>);
await component.getByText('Apps scores').click();
await expect(component).toHaveScreenshot();
});
}); });
import { Box, Menu, MenuButton, MenuItem, MenuList, Flex, IconButton, Skeleton } from '@chakra-ui/react'; import { Box, Menu, MenuButton, MenuItem, MenuList, Flex, IconButton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import { MarketplaceCategory, MarketplaceDisplayType } from 'types/client/marketplace'; import { MarketplaceCategory } from 'types/client/marketplace';
import type { TabItem } from 'ui/shared/Tabs/types'; import type { TabItem } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
...@@ -13,13 +13,13 @@ import ContractListModal from 'ui/marketplace/ContractListModal'; ...@@ -13,13 +13,13 @@ 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';
import MarketplaceList from 'ui/marketplace/MarketplaceList'; import MarketplaceList from 'ui/marketplace/MarketplaceList';
import MarketplaceListWithScores from 'ui/marketplace/MarketplaceListWithScores'; import { SORT_OPTIONS } from 'ui/marketplace/utils';
import FilterInput from 'ui/shared/filters/FilterInput'; import FilterInput from 'ui/shared/filters/FilterInput';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import type { IconName } from 'ui/shared/IconSvg'; import type { IconName } from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/links/LinkExternal'; import LinkExternal from 'ui/shared/links/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import RadioButtonGroup from 'ui/shared/radioButtonGroup/RadioButtonGroup'; import Sort from 'ui/shared/sort/Sort';
import TabsWithScroll from 'ui/shared/Tabs/TabsWithScroll'; import TabsWithScroll from 'ui/shared/Tabs/TabsWithScroll';
import useMarketplace from '../marketplace/useMarketplace'; import useMarketplace from '../marketplace/useMarketplace';
...@@ -67,9 +67,8 @@ const Marketplace = () => { ...@@ -67,9 +67,8 @@ const Marketplace = () => {
isCategoriesPlaceholderData, isCategoriesPlaceholderData,
showContractList, showContractList,
contractListModalType, contractListModalType,
selectedDisplayType,
onDisplayTypeChange,
hasPreviousStep, hasPreviousStep,
setSorting,
} = useMarketplace(); } = useMarketplace();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -186,38 +185,14 @@ const Marketplace = () => { ...@@ -186,38 +185,14 @@ const Marketplace = () => {
/> />
</Box> </Box>
<Flex direction={{ base: 'column', lg: 'row' }} mb={{ base: 4, lg: 6 }} gap={{ base: 4, lg: 3 }}> <Flex mb={{ base: 4, lg: 6 }} gap={{ base: 2, lg: 3 }}>
{ feature.securityReportsUrl && ( { feature.securityReportsUrl && (
<Skeleton isLoaded={ !isPlaceholderData }> <Sort
<RadioButtonGroup<MarketplaceDisplayType> name="dapps_sorting"
onChange={ onDisplayTypeChange } options={ SORT_OPTIONS }
defaultValue={ selectedDisplayType } onChange={ setSorting }
name="type" isLoading={ isPlaceholderData }
options={ [ />
{
title: 'Discovery',
value: MarketplaceDisplayType.DEFAULT,
icon: 'apps_xs',
onlyIcon: false,
},
{
title: 'Apps scores',
value: MarketplaceDisplayType.SCORES,
icon: 'apps_list',
onlyIcon: false,
contentAfter: (
<IconSvg
name={ isMobile ? 'beta_xs' : 'beta' }
h={ 3 }
w={ isMobile ? 3 : 7 }
ml={ 1 }
/>
),
},
] }
autoWidth
/>
</Skeleton>
) } ) }
<FilterInput <FilterInput
initialValue={ filterQuery } initialValue={ filterQuery }
...@@ -229,28 +204,16 @@ const Marketplace = () => { ...@@ -229,28 +204,16 @@ const Marketplace = () => {
/> />
</Flex> </Flex>
{ (selectedDisplayType === MarketplaceDisplayType.SCORES && feature.securityReportsUrl) ? ( <MarketplaceList
<MarketplaceListWithScores apps={ displayedApps }
apps={ displayedApps } showAppInfo={ showAppInfo }
showAppInfo={ showAppInfo } favoriteApps={ favoriteApps }
favoriteApps={ favoriteApps } onFavoriteClick={ onFavoriteClick }
onFavoriteClick={ onFavoriteClick } isLoading={ isPlaceholderData }
isLoading={ isPlaceholderData } selectedCategoryId={ selectedCategoryId }
selectedCategoryId={ selectedCategoryId } onAppClick={ handleAppClick }
onAppClick={ handleAppClick } showContractList={ showContractList }
showContractList={ showContractList } />
/>
) : (
<MarketplaceList
apps={ displayedApps }
showAppInfo={ showAppInfo }
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
isLoading={ isPlaceholderData }
selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick }
/>
) }
{ (selectedApp && isAppInfoModalOpen) && ( { (selectedApp && isAppInfoModalOpen) && (
<MarketplaceAppModal <MarketplaceAppModal
......
...@@ -97,9 +97,10 @@ const Validators = () => { ...@@ -97,9 +97,10 @@ const Validators = () => {
const sortButton = ( const sortButton = (
<Sort <Sort
name="validators_sorting"
defaultValue={ sort }
options={ SORT_OPTIONS } options={ SORT_OPTIONS }
sort={ sort } onChange={ handleSortChange }
setSort={ handleSortChange }
/> />
); );
......
...@@ -97,9 +97,11 @@ const VerifiedContracts = () => { ...@@ -97,9 +97,11 @@ const VerifiedContracts = () => {
const sortButton = ( const sortButton = (
<Sort <Sort
name="verified_contracts_sorting"
defaultValue={ sort }
options={ SORT_OPTIONS } options={ SORT_OPTIONS }
sort={ sort } onChange={ handleSortChange }
setSort={ handleSortChange } isLoading={ isPlaceholderData }
/> />
); );
......
import { Button, Spinner, Tooltip, useColorModeValue } from '@chakra-ui/react'; import { Button, Spinner, Tooltip, useColorModeValue, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import useScoreLevelAndColor from './useScoreLevelAndColor'; import useScoreLevelAndColor from './useScoreLevelAndColor';
...@@ -11,21 +12,24 @@ interface Props { ...@@ -11,21 +12,24 @@ interface Props {
isLoading?: boolean; isLoading?: boolean;
onlyIcon?: boolean; onlyIcon?: boolean;
onClick?: () => void; onClick?: () => void;
label?: string; label?: string | React.ReactElement;
isActive: boolean; isActive: boolean;
className?: string;
} }
const SolidityscanReportButton = ( const SolidityscanReportButton = (
{ score, isLoading, onlyIcon, onClick, label = 'Security score', isActive }: Props, { score, isLoading, onlyIcon, onClick, label = 'Security score', isActive, className }: Props,
ref: React.ForwardedRef<HTMLButtonElement>, ref: React.ForwardedRef<HTMLButtonElement>,
) => { ) => {
const { scoreColor } = useScoreLevelAndColor(score); const { scoreColor } = useScoreLevelAndColor(score);
const colorLoading = useColorModeValue('gray.300', 'gray.600'); const colorLoading = useColorModeValue('gray.300', 'gray.600');
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const onFocusCapture = usePreventFocusAfterModalClosing();
return ( return (
<Tooltip label={ label } isDisabled={ isMobile } openDelay={ 100 }> <Tooltip label={ label } isDisabled={ isMobile } openDelay={ 100 } textAlign="center">
<Button <Button
className={ className }
ref={ ref } ref={ ref }
color={ isLoading ? colorLoading : scoreColor } color={ isLoading ? colorLoading : scoreColor }
size="sm" size="sm"
...@@ -45,6 +49,7 @@ const SolidityscanReportButton = ( ...@@ -45,6 +49,7 @@ const SolidityscanReportButton = (
color: colorLoading, color: colorLoading,
}, },
}} }}
onFocusCapture={ onFocusCapture }
> >
<IconSvg name={ score < 80 ? 'score/score-not-ok' : 'score/score-ok' } boxSize={ 5 }/> <IconSvg name={ score < 80 ? 'score/score-not-ok' : 'score/score-ok' } boxSize={ 5 }/>
{ isLoading && <Spinner size="sm"/> } { isLoading && <Spinner size="sm"/> }
...@@ -54,4 +59,4 @@ const SolidityscanReportButton = ( ...@@ -54,4 +59,4 @@ const SolidityscanReportButton = (
); );
}; };
export default React.forwardRef(SolidityscanReportButton); export default chakra(React.forwardRef(SolidityscanReportButton));
import {
Box,
useColorModeValue,
Button,
Skeleton,
chakra,
} from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
type ButtonProps = {
isActive: boolean;
onClick: () => void;
isLoading?: boolean;
children: React.ReactNode;
className?: string;
};
const ButtonDesktop = ({ children, isActive, onClick, isLoading, className }: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
const primaryColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
const secondaryColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
return (
<Skeleton isLoaded={ !isLoading }>
<Button
className={ className }
ref={ ref }
size="sm"
variant="outline"
onClick={ onClick }
color={ primaryColor }
fontWeight="600"
borderColor="transparent"
px={ 2 }
data-selected={ isActive }
>
<Box
as={ isActive ? 'div' : 'span' }
color={ isActive ? 'inherit' : secondaryColor }
fontWeight="400"
mr={ 1 }
transition={ isActive ? 'none' : 'inherit' }
>Sort by</Box>
{ children }
<IconSvg
name="arrows/east-mini"
boxSize={ 5 }
ml={ 1 }
transform={ isActive ? 'rotate(90deg)' : 'rotate(-90deg)' }
/>
</Button>
</Skeleton>
);
};
export default chakra(React.forwardRef(ButtonDesktop));
...@@ -10,13 +10,14 @@ type Props = { ...@@ -10,13 +10,14 @@ type Props = {
isLoading?: boolean; isLoading?: boolean;
} }
const SortButton = ({ onClick, isActive, className, isLoading }: Props) => { const ButtonMobile = ({ onClick, isActive, className, isLoading }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
if (isLoading) { if (isLoading) {
return <Skeleton className={ className } w="36px" h="32px" borderRadius="base"/>; return <Skeleton className={ className } w="36px" h="32px" borderRadius="base"/>;
} }
return ( return (
<IconButton <IconButton
ref={ ref }
icon={ <IconSvg name="arrows/up-down" boxSize={ 5 }/> } icon={ <IconSvg name="arrows/up-down" boxSize={ 5 }/> }
aria-label="sort" aria-label="sort"
size="sm" size="sm"
...@@ -31,4 +32,4 @@ const SortButton = ({ onClick, isActive, className, isLoading }: Props) => { ...@@ -31,4 +32,4 @@ const SortButton = ({ onClick, isActive, className, isLoading }: Props) => {
); );
}; };
export default chakra(SortButton); export default chakra(React.forwardRef(ButtonMobile));
import {
useRadio,
Box,
useColorModeValue,
} from '@chakra-ui/react';
import type { useRadioGroup } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
export interface TOption<Sort extends string> {
id: Sort | undefined;
title: string;
}
type OptionProps = ReturnType<ReturnType<typeof useRadioGroup>['getRadioProps']>;
const Option = (props: OptionProps) => {
const { getInputProps, getRadioProps } = useRadio(props);
const input = getInputProps();
const checkbox = getRadioProps();
const bgColorHover = useColorModeValue('blue.50', 'whiteAlpha.100');
return (
<Box
as="label"
px={ 4 }
py={ 2 }
cursor="pointer"
display="flex"
columnGap={ 2 }
alignItems="center"
_hover={{
bgColor: bgColorHover,
}}
>
<input { ...input }/>
<Box { ...checkbox }>
{ props.children }
</Box>
{ props.isChecked && <IconSvg name="check" boxSize={ 4 } color="blue.600"/> }
</Box>
);
};
export default Option;
import { import {
chakra, Popover,
Menu, PopoverTrigger,
MenuButton, PopoverContent,
MenuList, PopoverBody,
MenuOptionGroup,
MenuItemOption,
useDisclosure, useDisclosure,
useRadioGroup,
chakra,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import SortButton from './SortButton'; import useIsMobile from 'lib/hooks/useIsMobile';
export interface Option<Sort extends string> { import SortButtonDesktop from './ButtonDesktop';
title: string; import SortButtonMobile from './ButtonMobile';
id: Sort | undefined; import Option from './Option';
} import type { TOption } from './Option';
interface Props<Sort extends string> { interface Props<Sort extends string> {
options: Array<Option<Sort>>; name: string;
sort: Sort | undefined; options: Array<TOption<Sort>>;
setSort: (value: Sort | undefined) => void; defaultValue?: Sort;
isLoading?: boolean; isLoading?: boolean;
onChange: (value: Sort | undefined) => void;
} }
const Sort = <Sort extends string>({ sort, setSort, options, isLoading }: Props<Sort>) => { const Sort = <Sort extends string>({ name, options, isLoading, onChange, defaultValue }: Props<Sort>) => {
const { isOpen, onToggle } = useDisclosure(); const isMobile = useIsMobile(false);
const { isOpen, onToggle, onClose } = useDisclosure();
const handleChange = (value: Sort) => {
onChange(value);
onClose();
};
const { value, getRootProps, getRadioProps } = useRadioGroup({
name,
defaultValue,
onChange: handleChange,
});
const setSortingFromMenu = React.useCallback((val: string | Array<string>) => { const root = getRootProps();
const value = val as Sort | Array<Sort>;
setSort(Array.isArray(value) ? value[0] : value);
}, [ setSort ]);
return ( return (
<Menu> <Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<MenuButton as="div"> <PopoverTrigger>
<SortButton { isMobile ? (
isActive={ isOpen || Boolean(sort) } <SortButtonMobile isActive={ isOpen || Boolean(value) } onClick={ onToggle } isLoading={ isLoading }/>
onClick={ onToggle } ) : (
isLoading={ isLoading } <SortButtonDesktop isActive={ isOpen } isLoading={ isLoading } onClick={ onToggle }>
/> { options.find((option: TOption<Sort>) => option.id === value || (!option.id && !value))?.title }
</MenuButton> </SortButtonDesktop>
<MenuList minWidth="240px" zIndex="popover"> ) }
<MenuOptionGroup value={ sort } title="Sort by" type="radio" onChange={ setSortingFromMenu }> </PopoverTrigger>
{ options.map((option) => ( <PopoverContent w="fit-content" minW="165px">
<MenuItemOption <PopoverBody { ...root } py={ 2 } px={ 0 } display="flex" flexDir="column">
key={ option.id || 'default' } { options.map((option, index) => {
value={ option.id } const radio = getRadioProps({ value: option.id });
> return (
{ option.title } <Option key={ index } { ...radio } isChecked={ radio.isChecked || (!option.id && !value) }>
</MenuItemOption> { option.title }
)) } </Option>
</MenuOptionGroup> );
</MenuList> }) }
</Menu> </PopoverBody>
</PopoverContent>
</Popover>
); );
}; };
......
import type { Query } from 'nextjs-routes'; import type { Query } from 'nextjs-routes';
import type { Option } from 'ui/shared/sort/Sort'; import type { TOption } from 'ui/shared/sort/Option';
export default function getSortValueFromQuery<SortValue extends string>(query: Query, sortOptions: Array<Option<SortValue>>) { export default function getSortValueFromQuery<SortValue extends string>(query: Query, sortOptions: Array<TOption<SortValue>>) {
if (!query.sort || !query.order) { if (!query.sort || !query.order) {
return undefined; return undefined;
} }
......
...@@ -45,9 +45,10 @@ const TokensActionBar = ({ ...@@ -45,9 +45,10 @@ const TokensActionBar = ({
<HStack spacing={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}> <HStack spacing={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ filter } { filter }
<Sort <Sort
name="tokens_sorting"
defaultValue={ sort }
options={ SORT_OPTIONS } options={ SORT_OPTIONS }
setSort={ onSortChange } onChange={ onSortChange }
sort={ sort }
/> />
{ searchInput } { searchInput }
</HStack> </HStack>
......
...@@ -4,9 +4,9 @@ import type { TokensSortingValue } from 'types/api/tokens'; ...@@ -4,9 +4,9 @@ import type { TokensSortingValue } from 'types/api/tokens';
import config from 'configs/app'; import config from 'configs/app';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery'; import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes'; import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import type { Option } from 'ui/shared/sort/Sort'; import type { TOption } from 'ui/shared/sort/Option';
export const SORT_OPTIONS: Array<Option<TokensSortingValue>> = [ export const SORT_OPTIONS: Array<TOption<TokensSortingValue>> = [
{ title: 'Default', id: undefined }, { title: 'Default', id: undefined },
{ title: 'Price ascending', id: 'fiat_value-asc' }, { title: 'Price ascending', id: 'fiat_value-asc' },
{ title: 'Price descending', id: 'fiat_value-desc' }, { title: 'Price descending', id: 'fiat_value-desc' },
......
...@@ -30,9 +30,10 @@ const TxsHeaderMobile = ({ filterComponent, sorting, setSorting, paginationProps ...@@ -30,9 +30,10 @@ const TxsHeaderMobile = ({ filterComponent, sorting, setSorting, paginationProps
<HStack> <HStack>
{ filterComponent } { filterComponent }
<Sort <Sort
name="transactions_sorting"
defaultValue={ sorting }
options={ SORT_OPTIONS } options={ SORT_OPTIONS }
setSort={ setSorting } onChange={ setSorting }
sort={ sorting }
isLoading={ paginationProps.isLoading } isLoading={ paginationProps.isLoading }
/> />
{ /* api is not implemented */ } { /* api is not implemented */ }
......
...@@ -5,11 +5,11 @@ import type { TransactionsSortingValue, TxsResponse } from 'types/api/transactio ...@@ -5,11 +5,11 @@ import type { TransactionsSortingValue, TxsResponse } from 'types/api/transactio
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import * as cookies from 'lib/cookies'; import * as cookies from 'lib/cookies';
import type { Option } from 'ui/shared/sort/Sort'; import type { TOption } from 'ui/shared/sort/Option';
import sortTxs from './sortTxs'; import sortTxs from './sortTxs';
export const SORT_OPTIONS: Array<Option<TransactionsSortingValue>> = [ export const SORT_OPTIONS: Array<TOption<TransactionsSortingValue>> = [
{ title: 'Default', id: undefined }, { title: 'Default', id: undefined },
{ title: 'Value ascending', id: 'value-asc' }, { title: 'Value ascending', id: 'value-asc' },
{ title: 'Value descending', id: 'value-desc' }, { title: 'Value descending', id: 'value-desc' },
......
import type { ValidatorsSortingValue, ValidatorsSortingField } from 'types/api/validators'; import type { ValidatorsSortingValue, ValidatorsSortingField } from 'types/api/validators';
import type { Option } from 'ui/shared/sort/Sort'; import type { TOption } from 'ui/shared/sort/Option';
export const SORT_OPTIONS: Array<Option<ValidatorsSortingValue>> = [ export const SORT_OPTIONS: Array<TOption<ValidatorsSortingValue>> = [
{ title: 'Default', id: undefined }, { title: 'Default', id: undefined },
{ title: 'Status descending', id: 'state-desc' }, { title: 'Status descending', id: 'state-desc' },
{ title: 'Status ascending', id: 'state-asc' }, { title: 'Status ascending', id: 'state-asc' },
......
import type { VerifiedContractsSortingValue, VerifiedContractsSortingField } from 'types/api/verifiedContracts'; import type { VerifiedContractsSortingValue, VerifiedContractsSortingField } from 'types/api/verifiedContracts';
import type { Option } from 'ui/shared/sort/Sort'; import type { TOption } from 'ui/shared/sort/Option';
export const SORT_OPTIONS: Array<Option<VerifiedContractsSortingValue>> = [ export const SORT_OPTIONS: Array<TOption<VerifiedContractsSortingValue>> = [
{ title: 'Default', id: undefined }, { title: 'Default', id: undefined },
{ title: 'Balance descending', id: 'balance-desc' }, { title: 'Balance descending', id: 'balance-desc' },
{ title: 'Balance ascending', id: 'balance-asc' }, { title: 'Balance ascending', id: 'balance-asc' },
......
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