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 {
VERIFIED = 'Verified',
}
export enum MarketplaceDisplayType {
DEFAULT = 'default',
SCORES = 'scores',
}
export type MarketplaceAppSecurityReport = {
overallInfo: {
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 type { MarketplaceAppSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
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 * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
......@@ -14,13 +19,17 @@ import SolidityscanReportScore from 'ui/shared/solidityscanReport/SolidityscanRe
type Props = {
id: string;
securityReport?: MarketplaceAppSecurityReport;
showContractList: () => void;
showContractList: (id: string, type: ContractListTypes) => void;
isLoading?: 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 handleButtonClick = React.useCallback(() => {
......@@ -28,14 +37,15 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on
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' });
showContractList();
}, [ id, showContractList ]);
showContractList(id, ContractListTypes.ANALYZED);
}, [ showContractList, id ]);
if (!securityReport && !isLoading) {
return null;
}
const showAllContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Total contracts', Info: id, Source: 'Security score popup' });
showContractList(id, ContractListTypes.ALL);
}, [ showContractList, id ]);
const {
securityScore = 0,
......@@ -44,8 +54,12 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on
totalIssues = 0,
} = securityReport?.overallInfo || {};
if ((!securityReport || !securityScore) && !isLoading) {
return null;
}
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<Popover isOpen={ isOpen } onClose={ onClose } placement={ popoverPlacement } isLazy>
<PopoverTrigger>
<SolidityscanReportButton
score={ securityScore }
......@@ -53,14 +67,30 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on
onClick={ handleButtonClick }
isActive={ isOpen }
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>
<PopoverContent w={{ base: '100vw', lg: '328px' }}>
<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 }>
{ 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>
<SolidityscanReportScore score={ securityScore } mb={ 5 }/>
{ issueSeverityDistribution && totalIssues > 0 && (
......@@ -69,9 +99,8 @@ const AppSecurityReport = ({ id, securityReport, showContractList, isLoading, on
<SolidityscanReportDetails vulnerabilities={ issueSeverityDistribution } vulnerabilitiesCount={ totalIssues }/>
</Box>
) }
<Link onClick={ handleLinkClick } display="inline-flex" alignItems="center">
<Link onClick={ showAnalyzedContracts } display="inline-flex" alignItems="center">
Analyzed contracts
<IconSvg name="arrows/north-east" boxSize={ 5 } color="gray.400"/>
</Link>
</PopoverBody>
</PopoverContent>
......@@ -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) => {
switch (type) {
default:
case ContractListTypes.ALL:
return contracts;
return contracts.sort((a) => a.isVerified ? -1 : 1);
case ContractListTypes.ANALYZED:
return contracts
.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 type { SolidityscanReport } from 'types/api/contract';
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 LinkExternal from 'ui/shared/links/LinkExternal';
import SolidityscanReportButton from 'ui/shared/solidityscanReport/SolidityscanReportButton';
......@@ -46,7 +50,11 @@ const ContractSecurityReport = ({ securityReport }: Props) => {
<PopoverContent w={{ base: '100vw', lg: '328px' }}>
<PopoverBody px="26px" py="20px" fontSize="sm">
<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>
<SolidityscanReportScore score={ parseFloat(securityScore) } mb={ 5 }/>
{ issueSeverityDistribution && totalIssues > 0 && (
......
......@@ -2,20 +2,23 @@ import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, cha
import type { MouseEvent } 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 AppSecurityReport from './AppSecurityReport';
import MarketplaceAppCardLink from './MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
interface Props extends MarketplaceAppPreview {
interface Props extends MarketplaceAppWithSecurityReport {
onInfoClick: (id: string) => void;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
isLoading: boolean;
onAppClick: (event: MouseEvent, id: string) => void;
className?: string;
showContractList: (id: string, type: ContractListTypes) => void;
}
const MarketplaceAppCard = ({
......@@ -33,8 +36,11 @@ const MarketplaceAppCard = ({
isLoading,
internalWallet,
onAppClick,
securityReport,
className,
showContractList,
}: Props) => {
const isMobile = useIsMobile();
const categoriesLabel = categories.join(', ');
const handleInfoClick = useCallback((event: MouseEvent) => {
......@@ -58,32 +64,29 @@ const MarketplaceAppCard = ({
boxShadow: isLoading ? 'none' : 'md',
}}
borderRadius="md"
padding={{ base: 3, sm: '20px' }}
padding={{ base: 3, md: '20px' }}
border="1px"
borderColor={ useColorModeValue('gray.200', 'gray.600') }
role="group"
>
<Flex
flexDirection={{ base: 'row', sm: 'column' }}
flexDirection="column"
height="100%"
alignContent="start"
gap={{ base: 4, sm: 0 }}
gap={ 2 }
>
<Flex
display={{ base: 'flex', sm: 'contents' }}
flexDirection="column"
alignItems="center"
justifyContent="space-between"
display={{ base: 'flex', md: 'contents' }}
gap={ 4 }
>
<Skeleton
isLoaded={ !isLoading }
marginBottom={ 4 }
w={{ base: '64px', sm: '96px' }}
h={{ base: '64px', sm: '96px' }}
w={{ base: '64px', md: '96px' }}
h={{ base: '64px', md: '96px' }}
display="flex"
alignItems="center"
justifyContent="center"
order={{ base: 'auto', sm: 1 }}
mb={{ base: 0, md: 2 }}
>
<Image
src={ isLoading ? undefined : logoUrl }
......@@ -92,93 +95,96 @@ const MarketplaceAppCard = ({
/>
</Skeleton>
{ !isLoading && (
<Box
display="flex"
marginTop={{ base: 0, sm: 'auto' }}
paddingTop={{ base: 0, sm: 4 }}
order={{ base: 'auto', sm: 5 }}
<Flex
display={{ base: 'flex', md: 'contents' }}
flexDirection="column"
gap={ 2 }
pt={ 1 }
>
<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
fontSize={{ base: 'xs', sm: 'sm' }}
fontWeight="500"
paddingRight={{ sm: 2 }}
href="#"
onClick={ handleInfoClick }
>
More info
</Link>
</Box>
) }
<span>{ categoriesLabel }</span>
</Skeleton>
</Flex>
</Flex>
<Flex
display={{ base: 'flex', sm: 'contents' }}
flexDirection="column"
gap={ 2 }
<Skeleton
isLoaded={ !isLoading }
fontSize="sm"
lineHeight="20px"
noOfLines={{ base: 2, md: 3 }}
>
<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 }
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>
{ shortDescription }
</Skeleton>
{ !isLoading && (
<IconButton
<Box
display="flex"
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"
right={{ base: 1, sm: '10px' }}
top={{ base: 1, sm: '10px' }}
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"/>
}
right={{ base: 3, md: 5 }}
top={{ base: '10px', md: 5 }}
border={ 0 }
padding={ 0 }
/>
) }
</Flex>
......
......@@ -42,7 +42,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => {
position="relative"
cursor="pointer"
verticalAlign="middle"
mb={ 1 }
mb={{ base: 0, md: 1 }}
/>
</Tooltip>
);
......
......@@ -3,6 +3,7 @@ import React from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { apps as appsMock } from 'mocks/apps/apps';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import { test, expect, devices } from 'playwright/lib';
import MarketplaceAppModal from './MarketplaceAppModal';
......@@ -11,7 +12,10 @@ const props = {
onClose: () => {},
onFavoriteClick: () => {},
showContractList: () => {},
data: appsMock[0] as MarketplaceAppWithSecurityReport,
data: {
...appsMock[0],
securityReport: securityReportsMock[0].chainsData['1'],
} as MarketplaceAppWithSecurityReport,
isFavorite: false,
};
......
import {
Box, Flex, Heading, IconButton, Image, Link, List, Modal, ModalBody,
Box, Flex, Heading, IconButton, Image, Link, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalOverlay, Tag, Text, useColorModeValue,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
......@@ -14,7 +14,6 @@ import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport';
import ContractListButton, { ContractListButtonVariants } from './ContractListButton';
import MarketplaceAppModalLink from './MarketplaceAppModalLink';
type Props = {
......@@ -79,25 +78,16 @@ const MarketplaceAppModal = ({
onFavoriteClick(id, isFavorite, 'App modal');
}, [ onFavoriteClick, id, isFavorite ]);
const showContractList = useCallback((type: ContractListTypes) => {
const showContractList = useCallback((id: string, type: ContractListTypes) => {
onClose();
showContractListProp(id, type, true);
}, [ onClose, showContractListProp, id ]);
}, [ onClose, showContractListProp ]);
const showAllContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Total contracts', Info: id, Source: 'App modal' });
showContractList(ContractListTypes.ALL);
showContractList(id, ContractListTypes.ALL);
}, [ showContractList, id ]);
const showVerifiedContracts = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Verified contracts', Info: id, Source: 'App modal' });
showContractList(ContractListTypes.VERIFIED);
}, [ showContractList, id ]);
const showAnalyzedContracts = React.useCallback(() => {
showContractList(ContractListTypes.ANALYZED);
}, [ showContractList ]);
const isMobile = useIsMobile();
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
......@@ -120,16 +110,16 @@ const MarketplaceAppModal = ({
<Box
display="grid"
gridTemplateColumns={{ base: 'auto 1fr' }}
paddingRight={{ sm: 12 }}
marginBottom={{ base: 6, sm: 8 }}
paddingRight={{ md: 12 }}
marginBottom={{ base: 6, md: 8 }}
>
<Flex
alignItems="center"
justifyContent="center"
w={{ base: '72px', sm: '144px' }}
h={{ base: '72px', sm: '144px' }}
marginRight={{ base: 6, sm: 8 }}
gridRow={{ base: '1 / 3', sm: '1 / 4' }}
w={{ base: '72px', md: '144px' }}
h={{ base: '72px', md: '144px' }}
marginRight={{ base: 6, md: 8 }}
gridRow={{ base: '1 / 3', md: '1 / 4' }}
>
<Image
src={ logoUrl }
......@@ -141,7 +131,7 @@ const MarketplaceAppModal = ({
<Heading
as="h2"
gridColumn={ 2 }
fontSize={{ base: '2xl', sm: '3xl' }}
fontSize={{ base: '2xl', md: '3xl' }}
fontWeight="medium"
lineHeight={ 1 }
color="blue.600"
......@@ -160,11 +150,11 @@ const MarketplaceAppModal = ({
</Text>
<Box
gridColumn={{ base: '1 / 3', sm: 2 }}
marginTop={{ base: 6, sm: 0 }}
gridColumn={{ base: '1 / 3', md: 2 }}
marginTop={{ base: 6, md: 0 }}
>
<Flex flexWrap="wrap" gap={ 6 }>
<Flex width={{ base: '100%', sm: 'auto' }}>
<Flex width={{ base: '100%', md: 'auto' }}>
<MarketplaceAppModalLink
id={ data.id }
url={ url }
......@@ -185,126 +175,115 @@ const MarketplaceAppModal = ({
<IconSvg name="star_outline" w={ 5 } h={ 5 } color={ starOutlineIconColor }/> }
/>
</Flex>
{ securityReport && (
<Flex alignItems="center" gap={ 3 }>
<AppSecurityReport
id={ id }
securityReport={ securityReport }
showContractList={ showAnalyzedContracts }
source="App modal"
/>
<ContractListButton
onClick={ showAllContracts }
variant={ ContractListButtonVariants.ALL_CONTRACTS }
>
{ securityReport.overallInfo.totalContractsNumber }
</ContractListButton>
<ContractListButton
onClick={ showVerifiedContracts }
variant={ ContractListButtonVariants.VERIFIED_CONTRACTS }
>
{ securityReport.overallInfo.verifiedNumber }
</ContractListButton>
</Flex>
) }
</Flex>
</Box>
</Box>
<ModalCloseButton/>
<ModalBody>
<Heading
as="h3"
fontSize="2xl"
marginBottom={ 4 }
>
Overview
</Heading>
<ModalBody mb={ 6 }>
{ securityReport && (
<Flex
direction={{ base: 'column', md: 'row' }}
justifyContent={{ base: 'flex-start', md: 'space-between' }}
gap={ 3 }
fontSize="sm"
mb={ 6 }
>
<Flex alignItems="center" gap={ 2 } flexWrap="wrap">
<IconSvg name="contracts_verified" boxSize={ 5 } color="green.500"/>
<Text>Verified contracts</Text>
<Text fontWeight="500">
{ securityReport?.overallInfo.verifiedNumber ?? 0 } of { securityReport?.overallInfo.totalContractsNumber ?? 0 }
</Text>
<Link onClick={ showAllContracts } ml={ 1 }>
View all contracts
</Link>
</Flex>
<Flex alignItems="center" gap={ 2 }>
<Text>Security level</Text>
<AppSecurityReport
id={ id }
securityReport={ securityReport }
showContractList={ showContractList }
source="App modal"
popoverPlacement={ isMobile ? 'bottom-start' : 'left' }
/>
</Flex>
</Flex>
) }
<Text>{ description }</Text>
</ModalBody>
<Box marginBottom={ 2 }>
<ModalFooter
display="flex"
flexDirection={{ base: 'column', md: 'row' }}
justifyContent={{ base: 'flex-start', md: 'space-between' }}
alignItems={{ base: 'flex-start', md: 'center' }}
gap={ 3 }
>
<Flex gap={ 2 }>
{ categories.map((category) => (
<Tag
colorScheme="blue"
marginRight={ 2 }
marginBottom={ 2 }
key={ category }
>
{ category }
</Tag>
)) }
</Box>
<Text>{ description }</Text>
</ModalBody>
<ModalFooter
display="flex"
flexDirection={{ base: 'column', sm: 'row' }}
alignItems={{ base: 'flex-start', sm: 'center' }}
>
{ site && (
<Link
isExternal
href={ site }
display="flex"
alignItems="center"
paddingRight={{ sm: 2 }}
marginBottom={{ base: 3, sm: 0 }}
maxW="100%"
overflow="hidden"
>
<IconSvg
name="link"
display="inline"
verticalAlign="baseline"
boxSize="18px"
marginRight={ 2 }
/>
</Flex>
<Text
color="inherit"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
<Flex alignItems="center" gap={ 3 }>
{ site && (
<Link
isExternal
href={ site }
display="flex"
alignItems="center"
fontSize="sm"
>
{ getHostname(site) }
</Text>
</Link>
) }
<IconSvg
name="link"
display="inline"
verticalAlign="baseline"
boxSize="18px"
marginRight={ 2 }
/>
{ socialLinks.length > 0 && (
<List
marginLeft={{ sm: 'auto' }}
display="grid"
gridAutoFlow="column"
columnGap={ 2 }
>
{ socialLinks.map(({ icon, url }) => (
<Link
aria-label={ `Link to ${ url }` }
title={ url }
key={ url }
href={ url }
display="flex"
alignItems="center"
justifyContent="center"
isExternal
w={ 10 }
h={ 10 }
<Text
color="inherit"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
<IconSvg
name={ icon }
w="20px"
h="20px"
display="block"
color="text_secondary"
/>
</Link>
)) }
</List>
) }
{ getHostname(site) }
</Text>
</Link>
) }
{ socialLinks.map(({ icon, url }) => (
<Link
aria-label={ `Link to ${ url }` }
title={ url }
key={ url }
href={ url }
display="flex"
alignItems="center"
justifyContent="center"
isExternal
w={ 5 }
h={ 5 }
>
<IconSvg
name={ icon }
w="20px"
h="20px"
display="block"
color="text_secondary"
/>
</Link>
)) }
</Flex>
</ModalFooter>
</ModalContent>
</Modal>
......
import { chakra, Flex, Tooltip, Skeleton, useBoolean } from '@chakra-ui/react';
import { chakra, Flex, Tooltip, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { MarketplaceAppOverview, MarketplaceAppSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import type { MarketplaceAppOverview, MarketplaceAppSecurityReport, ContractListTypes } from 'types/client/marketplace';
import { route } from 'nextjs-routes';
......@@ -27,7 +26,7 @@ type Props = {
}
const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
const [ showContractList, setShowContractList ] = useBoolean(false);
const [ contractListType, setContractListType ] = React.useState<ContractListTypes>();
const appProps = useAppContext();
const isMobile = useIsMobile();
......@@ -44,6 +43,9 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
} catch (err) {}
}
const showContractList = React.useCallback((id: string, type: ContractListTypes) => setContractListType(type), []);
const hideContractList = React.useCallback(() => setContractListType(undefined), []);
return (
<>
<Flex alignItems="center" flexWrap="wrap" mb={{ base: 3, md: 2 }} rowGap={ 3 } columnGap={ 2 }>
......@@ -74,7 +76,7 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
<AppSecurityReport
id={ data?.id || '' }
securityReport={ securityReport }
showContractList={ setShowContractList.on }
showContractList={ showContractList }
isLoading={ isLoading }
onlyIcon={ isMobile }
source="App page"
......@@ -87,11 +89,11 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
</Flex>
) }
</Flex>
{ showContractList && (
{ contractListType && (
<ContractListModal
type={ ContractListTypes.ANALYZED }
type={ contractListType }
contracts={ securityReport?.contractsData }
onClose={ setShowContractList.off }
onClose={ hideContractList }
/>
) }
</>
......
......@@ -2,7 +2,7 @@ import { Grid } from '@chakra-ui/react';
import React, { useCallback } 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';
......@@ -10,16 +10,17 @@ import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard';
type Props = {
apps: Array<MarketplaceAppPreview>;
apps: Array<MarketplaceAppWithSecurityReport>;
showAppInfo: (id: string) => void;
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean, source: 'Discovery view') => void;
isLoading: boolean;
selectedCategoryId?: string;
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) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
showAppInfo(id);
......@@ -32,11 +33,11 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
return apps.length > 0 ? (
<Grid
templateColumns={{
sm: 'repeat(auto-fill, minmax(178px, 1fr))',
md: 'repeat(auto-fill, minmax(230px, 1fr))',
lg: 'repeat(auto-fill, minmax(260px, 1fr))',
}}
autoRows="1fr"
gap={{ base: '16px', sm: '24px' }}
gap={{ base: '16px', md: '24px' }}
>
{ apps.map((app, index) => (
<MarketplaceAppCard
......@@ -55,6 +56,8 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
isLoading={ isLoading }
internalWallet={ app.internalWallet }
onAppClick={ onAppClick }
securityReport={ app.securityReport }
showContractList={ showContractList }
/>
)) }
</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';
import React from 'react';
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 * as mixpanel from 'lib/mixpanel/index';
......@@ -26,15 +26,9 @@ export default function useMarketplace() {
const router = useRouter();
const defaultCategoryId = getQueryParamString(router.query.category);
const defaultFilterQuery = getQueryParamString(router.query.filter);
const defaultDisplayType = getQueryParamString(router.query.tab);
const [ selectedAppId, setSelectedAppId ] = React.useState<string | null>(null);
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 [ favoriteApps, setFavoriteApps ] = React.useState<Array<string>>([]);
const [ isFavoriteAppsLoaded, setIsFavoriteAppsLoaded ] = React.useState<boolean>(false);
......@@ -91,12 +85,8 @@ export default function useMarketplace() {
setSelectedCategoryId(newCategory);
}, []);
const handleDisplayTypeChange = React.useCallback((newDisplayType: MarketplaceDisplayType) => {
setSelectedDisplayType(newDisplayType);
}, []);
const {
isPlaceholderData, isError, error, data, displayedApps,
isPlaceholderData, isError, error, data, displayedApps, setSorting,
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded);
const {
isPlaceholderData: isCategoriesPlaceholderData, data: categories,
......@@ -120,7 +110,6 @@ export default function useMarketplace() {
const query = _pickBy({
category: selectedCategoryId === MarketplaceCategory.ALL ? undefined : selectedCategoryId,
filter: debouncedFilterQuery,
tab: selectedDisplayType === MarketplaceDisplayType.DEFAULT ? undefined : selectedDisplayType,
}, Boolean);
if (debouncedFilterQuery.length > 0) {
......@@ -135,7 +124,7 @@ export default function useMarketplace() {
// omit router in the deps because router.push() somehow modifies it
// and we get infinite re-renders then
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ debouncedFilterQuery, selectedCategoryId, selectedDisplayType ]);
}, [ debouncedFilterQuery, selectedCategoryId ]);
return React.useMemo(() => ({
selectedCategoryId,
......@@ -160,9 +149,8 @@ export default function useMarketplace() {
isCategoriesPlaceholderData,
showContractList,
contractListModalType,
selectedDisplayType,
onDisplayTypeChange: handleDisplayTypeChange,
hasPreviousStep,
setSorting,
}), [
selectedCategoryId,
categories,
......@@ -184,8 +172,7 @@ export default function useMarketplace() {
isCategoriesPlaceholderData,
showContractList,
contractListModalType,
selectedDisplayType,
handleDisplayTypeChange,
hasPreviousStep,
setSorting,
]);
}
......@@ -11,6 +11,7 @@ import useFetch from 'lib/hooks/useFetch';
import { MARKETPLACE_APP } from 'stubs/marketplace';
import useSecurityReports from './useSecurityReports';
import type { SortValue } from './utils';
const feature = config.features.marketplace;
......@@ -88,13 +89,22 @@ export default function useMarketplaceApps(
enabled: feature.isEnabled && Boolean(snapshotFavoriteApps),
});
const [ sorting, setSorting ] = React.useState<SortValue>();
const appsWithSecurityReports = React.useMemo(() =>
data?.map((app) => ({ ...app, securityReport: securityReports?.[app.id] })),
[ data, securityReports ]);
const displayedApps = React.useMemo(() => {
return appsWithSecurityReports?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps)) || [];
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps ]);
return appsWithSecurityReports
?.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(() => ({
data,
......@@ -102,6 +112,7 @@ export default function useMarketplaceApps(
error,
isError,
isPlaceholderData: isPlaceholderData || isSecurityReportsPlaceholderData,
setSorting,
}), [
data,
displayedApps,
......@@ -109,5 +120,6 @@ export default function useMarketplaceApps(
isError,
isPlaceholderData,
isSecurityReportsPlaceholderData,
setSorting,
]);
}
......@@ -2,6 +2,14 @@ import type { NextRouter } from 'next/router';
import getQueryParamString from 'lib/router/getQueryParamString';
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) {
if (!url) {
......
......@@ -136,9 +136,10 @@ const NameDomainsActionBar = ({
const sortButton = (
<Sort
name="name_domains_sorting"
defaultValue={ sort }
options={ SORT_OPTIONS }
sort={ sort }
setSort={ onSortChange }
onChange={ onSortChange }
isLoading={ isInitialLoading }
/>
);
......
import type { EnsLookupSorting } from 'types/api/ens';
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 Sort = `${ EnsLookupSorting['sort'] }-${ EnsLookupSorting['order'] }`;
export const SORT_OPTIONS: Array<Option<Sort>> = [
export const SORT_OPTIONS: Array<TOption<Sort>> = [
{ title: 'Default', id: undefined },
{ title: 'Registered on descending', id: 'registration_date-DESC' },
{ title: 'Registered on ascending', id: 'registration_date-ASC' },
......
......@@ -7,13 +7,16 @@ import { test, expect, devices } from 'playwright/lib';
import Marketplace from './Marketplace';
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 }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ '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_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
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 })
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
// separate run for mobile tests fixes it
test.describe('mobile', () => {
......@@ -91,16 +82,4 @@ test.describe('mobile', () => {
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 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 config from 'configs/app';
......@@ -13,13 +13,13 @@ import ContractListModal from 'ui/marketplace/ContractListModal';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceDisclaimerModal from 'ui/marketplace/MarketplaceDisclaimerModal';
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 IconSvg from 'ui/shared/IconSvg';
import type { IconName } from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/links/LinkExternal';
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 useMarketplace from '../marketplace/useMarketplace';
......@@ -67,9 +67,8 @@ const Marketplace = () => {
isCategoriesPlaceholderData,
showContractList,
contractListModalType,
selectedDisplayType,
onDisplayTypeChange,
hasPreviousStep,
setSorting,
} = useMarketplace();
const isMobile = useIsMobile();
......@@ -186,38 +185,14 @@ const Marketplace = () => {
/>
</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 && (
<Skeleton isLoaded={ !isPlaceholderData }>
<RadioButtonGroup<MarketplaceDisplayType>
onChange={ onDisplayTypeChange }
defaultValue={ selectedDisplayType }
name="type"
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>
<Sort
name="dapps_sorting"
options={ SORT_OPTIONS }
onChange={ setSorting }
isLoading={ isPlaceholderData }
/>
) }
<FilterInput
initialValue={ filterQuery }
......@@ -229,28 +204,16 @@ const Marketplace = () => {
/>
</Flex>
{ (selectedDisplayType === MarketplaceDisplayType.SCORES && feature.securityReportsUrl) ? (
<MarketplaceListWithScores
apps={ displayedApps }
showAppInfo={ showAppInfo }
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
isLoading={ isPlaceholderData }
selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick }
showContractList={ showContractList }
/>
) : (
<MarketplaceList
apps={ displayedApps }
showAppInfo={ showAppInfo }
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
isLoading={ isPlaceholderData }
selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick }
/>
) }
<MarketplaceList
apps={ displayedApps }
showAppInfo={ showAppInfo }
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
isLoading={ isPlaceholderData }
selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick }
showContractList={ showContractList }
/>
{ (selectedApp && isAppInfoModalOpen) && (
<MarketplaceAppModal
......
......@@ -97,9 +97,10 @@ const Validators = () => {
const sortButton = (
<Sort
name="validators_sorting"
defaultValue={ sort }
options={ SORT_OPTIONS }
sort={ sort }
setSort={ handleSortChange }
onChange={ handleSortChange }
/>
);
......
......@@ -97,9 +97,11 @@ const VerifiedContracts = () => {
const sortButton = (
<Sort
name="verified_contracts_sorting"
defaultValue={ sort }
options={ SORT_OPTIONS }
sort={ sort }
setSort={ handleSortChange }
onChange={ 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 useIsMobile from 'lib/hooks/useIsMobile';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import IconSvg from 'ui/shared/IconSvg';
import useScoreLevelAndColor from './useScoreLevelAndColor';
......@@ -11,21 +12,24 @@ interface Props {
isLoading?: boolean;
onlyIcon?: boolean;
onClick?: () => void;
label?: string;
label?: string | React.ReactElement;
isActive: boolean;
className?: string;
}
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>,
) => {
const { scoreColor } = useScoreLevelAndColor(score);
const colorLoading = useColorModeValue('gray.300', 'gray.600');
const isMobile = useIsMobile();
const onFocusCapture = usePreventFocusAfterModalClosing();
return (
<Tooltip label={ label } isDisabled={ isMobile } openDelay={ 100 }>
<Tooltip label={ label } isDisabled={ isMobile } openDelay={ 100 } textAlign="center">
<Button
className={ className }
ref={ ref }
color={ isLoading ? colorLoading : scoreColor }
size="sm"
......@@ -45,6 +49,7 @@ const SolidityscanReportButton = (
color: colorLoading,
},
}}
onFocusCapture={ onFocusCapture }
>
<IconSvg name={ score < 80 ? 'score/score-not-ok' : 'score/score-ok' } boxSize={ 5 }/>
{ isLoading && <Spinner size="sm"/> }
......@@ -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 = {
isLoading?: boolean;
}
const SortButton = ({ onClick, isActive, className, isLoading }: Props) => {
const ButtonMobile = ({ onClick, isActive, className, isLoading }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
if (isLoading) {
return <Skeleton className={ className } w="36px" h="32px" borderRadius="base"/>;
}
return (
<IconButton
ref={ ref }
icon={ <IconSvg name="arrows/up-down" boxSize={ 5 }/> }
aria-label="sort"
size="sm"
......@@ -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 {
chakra,
Menu,
MenuButton,
MenuList,
MenuOptionGroup,
MenuItemOption,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
useDisclosure,
useRadioGroup,
chakra,
} from '@chakra-ui/react';
import React from 'react';
import SortButton from './SortButton';
import useIsMobile from 'lib/hooks/useIsMobile';
export interface Option<Sort extends string> {
title: string;
id: Sort | undefined;
}
import SortButtonDesktop from './ButtonDesktop';
import SortButtonMobile from './ButtonMobile';
import Option from './Option';
import type { TOption } from './Option';
interface Props<Sort extends string> {
options: Array<Option<Sort>>;
sort: Sort | undefined;
setSort: (value: Sort | undefined) => void;
name: string;
options: Array<TOption<Sort>>;
defaultValue?: Sort;
isLoading?: boolean;
onChange: (value: Sort | undefined) => void;
}
const Sort = <Sort extends string>({ sort, setSort, options, isLoading }: Props<Sort>) => {
const { isOpen, onToggle } = useDisclosure();
const Sort = <Sort extends string>({ name, options, isLoading, onChange, defaultValue }: Props<Sort>) => {
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 value = val as Sort | Array<Sort>;
setSort(Array.isArray(value) ? value[0] : value);
}, [ setSort ]);
const root = getRootProps();
return (
<Menu>
<MenuButton as="div">
<SortButton
isActive={ isOpen || Boolean(sort) }
onClick={ onToggle }
isLoading={ isLoading }
/>
</MenuButton>
<MenuList minWidth="240px" zIndex="popover">
<MenuOptionGroup value={ sort } title="Sort by" type="radio" onChange={ setSortingFromMenu }>
{ options.map((option) => (
<MenuItemOption
key={ option.id || 'default' }
value={ option.id }
>
{ option.title }
</MenuItemOption>
)) }
</MenuOptionGroup>
</MenuList>
</Menu>
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
{ isMobile ? (
<SortButtonMobile isActive={ isOpen || Boolean(value) } onClick={ onToggle } isLoading={ isLoading }/>
) : (
<SortButtonDesktop isActive={ isOpen } isLoading={ isLoading } onClick={ onToggle }>
{ options.find((option: TOption<Sort>) => option.id === value || (!option.id && !value))?.title }
</SortButtonDesktop>
) }
</PopoverTrigger>
<PopoverContent w="fit-content" minW="165px">
<PopoverBody { ...root } py={ 2 } px={ 0 } display="flex" flexDir="column">
{ options.map((option, index) => {
const radio = getRadioProps({ value: option.id });
return (
<Option key={ index } { ...radio } isChecked={ radio.isChecked || (!option.id && !value) }>
{ option.title }
</Option>
);
}) }
</PopoverBody>
</PopoverContent>
</Popover>
);
};
......
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) {
return undefined;
}
......
......@@ -45,9 +45,10 @@ const TokensActionBar = ({
<HStack spacing={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ filter }
<Sort
name="tokens_sorting"
defaultValue={ sort }
options={ SORT_OPTIONS }
setSort={ onSortChange }
sort={ sort }
onChange={ onSortChange }
/>
{ searchInput }
</HStack>
......
......@@ -4,9 +4,9 @@ import type { TokensSortingValue } from 'types/api/tokens';
import config from 'configs/app';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
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: 'Price ascending', id: 'fiat_value-asc' },
{ title: 'Price descending', id: 'fiat_value-desc' },
......
......@@ -30,9 +30,10 @@ const TxsHeaderMobile = ({ filterComponent, sorting, setSorting, paginationProps
<HStack>
{ filterComponent }
<Sort
name="transactions_sorting"
defaultValue={ sorting }
options={ SORT_OPTIONS }
setSort={ setSorting }
sort={ sorting }
onChange={ setSorting }
isLoading={ paginationProps.isLoading }
/>
{ /* api is not implemented */ }
......
......@@ -5,11 +5,11 @@ import type { TransactionsSortingValue, TxsResponse } from 'types/api/transactio
import type { ResourceError } from 'lib/api/resources';
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';
export const SORT_OPTIONS: Array<Option<TransactionsSortingValue>> = [
export const SORT_OPTIONS: Array<TOption<TransactionsSortingValue>> = [
{ title: 'Default', id: undefined },
{ title: 'Value ascending', id: 'value-asc' },
{ title: 'Value descending', id: 'value-desc' },
......
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: 'Status descending', id: 'state-desc' },
{ title: 'Status ascending', id: 'state-asc' },
......
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: 'Balance descending', id: 'balance-desc' },
{ 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