Commit 035256aa authored by Max Alekseenko's avatar Max Alekseenko Committed by GitHub

Merge pull request #2089 from blockscout/dapp-rating

Dapp ratings
parents 9e6c0784 cef5fca1
......@@ -14,6 +14,8 @@ const securityReportsUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SEC
const featuredApp = getEnvValue('NEXT_PUBLIC_MARKETPLACE_FEATURED_APP');
const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL');
const bannerLinkUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL');
const ratingAirtableApiKey = getEnvValue('NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY');
const ratingAirtableBaseId = getEnvValue('NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID');
const title = 'Marketplace';
......@@ -27,6 +29,7 @@ const config: Feature<(
securityReportsUrl: string | undefined;
featuredApp: string | undefined;
banner: { contentUrl: string; linkUrl: string } | undefined;
rating: { airtableApiKey: string; airtableBaseId: string } | undefined;
}> = (() => {
if (enabled === 'true' && chain.rpcUrl && submitFormUrl) {
const props = {
......@@ -39,6 +42,10 @@ const config: Feature<(
contentUrl: bannerContentUrl,
linkUrl: bannerLinkUrl,
} : undefined,
rating: ratingAirtableApiKey && ratingAirtableBaseId ? {
airtableApiKey: ratingAirtableApiKey,
airtableBaseId: ratingAirtableBaseId,
} : undefined,
};
if (configUrl) {
......
......@@ -36,6 +36,7 @@ NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=gearbox-protocol
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-security-reports/default.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=appGkvtmKI7fXE4Vs
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_METASUITES_ENABLED=true
NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG={'name': 'zerion', 'dapp_id': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'}
......
......@@ -223,6 +223,22 @@ const marketplaceSchema = yup
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema,
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema,
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
});
const beaconChainSchema = yup
......
......@@ -8,3 +8,5 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=aave
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/maxaleks/36f779fd7d74877b57ec7a25a9a3a6c9/raw/746a8a59454c0537235ee44616c4690ce3bbf3c8/banner.html
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://www.basename.app
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY=test
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=test
......@@ -86,3 +86,4 @@ frontend:
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_OG_IMAGE_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/base-mainnet.png
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: ref+vault://deployment-values/blockscout/dev/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY
......@@ -96,3 +96,4 @@ frontend:
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: ref+vault://deployment-values/blockscout/dev/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY
......@@ -474,6 +474,8 @@ This feature is **always enabled**, but you can configure its behavior by passin
| NEXT_PUBLIC_MARKETPLACE_FEATURED_APP | `string` | ID of the featured application to be displayed on the banner on the Marketplace page | - | - | `uniswap` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL | `string` | URL of the banner HTML content | - | - | `https://example.com/banner` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL | `string` | URL of the page the banner leads to | - | - | `https://example.com` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY | `string` | Airtable API key | - | - | - | v1.33.0+ |
| NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID | `string` | Airtable base ID with dapp ratings | - | - | - | v1.33.0+ |
#### Marketplace app configuration properties
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<path d="m10 3.557-.555-.577c-.947-.946-2.205-.989-3.508-.97a4.876 4.876 0 0 0-3.469 1.547A5.443 5.443 0 0 0 1.001 7.22a5.46 5.46 0 0 0 1.363 3.709L10 18.5l7.636-7.58a5.46 5.46 0 0 0 1.363-3.709 5.443 5.443 0 0 0-1.467-3.664 4.876 4.876 0 0 0-3.47-1.546c-1.302-.02-2.56.023-3.507.969L10 3.557Z" fill="currentColor"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 20" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.671 3.458a5.332 5.332 0 0 1 7.542 7.532l-.007.008-7.54 7.548-7.539-7.54-.007-.008a5.332 5.332 0 0 1 7.542-7.532l.002-.001.008-.007Zm1.017 1.06-1.017 1.018L9.647 4.53A3.862 3.862 0 0 0 4.18 9.983l6.485 6.484 6.485-6.493a3.863 3.863 0 0 0-5.463-5.455Z" fill="currentColor" stroke="currentColor" stroke-width=".4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.671 3.458a5.332 5.332 0 0 1 7.542 7.532l-.007.008-7.54 7.548-7.539-7.54-.007-.008a5.332 5.332 0 0 1 7.542-7.532l.002-.001.008-.007Zm1.017 1.06-1.017 1.018L9.647 4.53A3.862 3.862 0 0 0 4.18 9.983l6.485 6.484 6.485-6.493a3.863 3.863 0 0 0-5.463-5.455Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.76 18.333a.603.603 0 0 1-.294-.075L10 15.798l-4.467 2.46a.607.607 0 0 1-.663-.052.657.657 0 0 1-.213-.285.69.69 0 0 1-.038-.36l.853-5.21-3.615-3.69a.69.69 0 0 1-.16-.677.663.663 0 0 1 .194-.3.615.615 0 0 1 .315-.149l4.995-.76 2.233-4.74a.65.65 0 0 1 .233-.269.61.61 0 0 1 .666 0c.1.065.18.158.232.269l2.234 4.74 4.994.76c.116.018.226.07.316.149.09.079.157.183.193.3a.69.69 0 0 1-.16.678l-3.615 3.69.854 5.21a.692.692 0 0 1-.14.537.636.636 0 0 1-.216.173.607.607 0 0 1-.266.061h.001Z" fill="currentColor"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<path d="M15.713 20a.724.724 0 0 1-.354-.09L10 16.956 4.64 19.91a.728.728 0 0 1-.796-.061.788.788 0 0 1-.256-.342.827.827 0 0 1-.045-.432l1.024-6.252L.229 8.394a.802.802 0 0 1-.207-.377.829.829 0 0 1 .015-.435.795.795 0 0 1 .232-.361.741.741 0 0 1 .379-.179L6.64 6.13 9.321.442a.78.78 0 0 1 .28-.323.731.731 0 0 1 .798 0 .78.78 0 0 1 .28.323l2.68 5.688 5.993.912a.74.74 0 0 1 .379.178c.108.096.188.22.232.361a.828.828 0 0 1-.192.813l-4.338 4.428 1.024 6.252a.83.83 0 0 1-.167.644.762.762 0 0 1-.26.208.728.728 0 0 1-.319.074h.002Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.76 18.333a.603.603 0 0 1-.294-.075l.293.075Zm.003 0a.6.6 0 0 0 .262-.061c.083-.04.157-.1.216-.173a.674.674 0 0 0 .14-.538l-.854-5.21 3.616-3.69a.69.69 0 0 0 .16-.677.662.662 0 0 0-.194-.3.617.617 0 0 0-.316-.149l-4.884-.743a.208.208 0 0 1-.158-.117l-2.186-4.64a.651.651 0 0 0-.232-.269.61.61 0 0 0-.666 0 .65.65 0 0 0-.233.269l-2.186 4.64a.208.208 0 0 1-.157.117l-4.885.743a.617.617 0 0 0-.315.149.663.663 0 0 0-.194.3.69.69 0 0 0 .16.678L5.4 12.276a.208.208 0 0 1 .056.18L4.62 17.56a.69.69 0 0 0 .038.36.657.657 0 0 0 .213.285.613.613 0 0 0 .663.052L9.9 15.852a.208.208 0 0 1 .201 0l4.366 2.405m-7.795-2.915c-.028.172.154.3.306.216L9.9 13.95a.208.208 0 0 1 .201 0l2.922 1.61a.208.208 0 0 0 .306-.216l-.565-3.452a.208.208 0 0 1 .057-.18l2.485-2.536a.208.208 0 0 0-.117-.351l-3.409-.52a.208.208 0 0 1-.157-.116l-1.434-3.044a.208.208 0 0 0-.377 0L8.377 8.189a.208.208 0 0 1-.157.117l-3.408.518a.208.208 0 0 0-.118.352l2.486 2.537a.208.208 0 0 1 .057.18l-.566 3.45Zm8.092 2.99h-.003.003Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.711 20a.724.724 0 0 1-.352-.09Zm.005 0a.73.73 0 0 0 .314-.074.77.77 0 0 0 .26-.208.806.806 0 0 0 .167-.644l-1.024-6.252 4.338-4.428a.828.828 0 0 0 .192-.813.796.796 0 0 0-.232-.36.74.74 0 0 0-.38-.179l-5.86-.892a.25.25 0 0 1-.19-.14L10.679.442A.78.78 0 0 0 10.4.119a.732.732 0 0 0-.798 0 .78.78 0 0 0-.28.323L6.699 6.01a.25.25 0 0 1-.188.14l-5.862.892a.741.741 0 0 0-.38.179.795.795 0 0 0-.231.36.829.829 0 0 0-.015.436.802.802 0 0 0 .207.377l4.25 4.338a.25.25 0 0 1 .068.215l-1.004 6.127a.827.827 0 0 0 .045.432.788.788 0 0 0 .256.342.728.728 0 0 0 .796.061l5.24-2.886a.25.25 0 0 1 .24 0l5.24 2.886m-9.354-3.497a.25.25 0 0 0 .367.26L9.88 14.74a.25.25 0 0 1 .24 0l3.507 1.932a.25.25 0 0 0 .367-.26l-.678-4.141a.25.25 0 0 1 .068-.216l2.982-3.043a.25.25 0 0 0-.14-.423l-4.09-.622a.25.25 0 0 1-.189-.14l-1.72-3.653a.25.25 0 0 0-.453 0L8.053 7.826a.25.25 0 0 1-.189.141l-4.09.622a.25.25 0 0 0-.14.423l2.982 3.043a.25.25 0 0 1 .068.216l-.678 4.14ZM15.716 20h-.003Z" fill="currentColor"/>
</svg>
......@@ -5,12 +5,12 @@ import { useInView } from 'react-intersection-observer';
const STEP = 10;
const MIN_ITEMS_NUM = 50;
export default function useLazyRenderedList(list: Array<unknown>, isEnabled: boolean) {
const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(MIN_ITEMS_NUM);
export default function useLazyRenderedList(list: Array<unknown>, isEnabled: boolean, minItemsNum: number = MIN_ITEMS_NUM) {
const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(minItemsNum);
const { ref, inView } = useInView({
rootMargin: '200px',
triggerOnce: false,
skip: !isEnabled || list.length <= MIN_ITEMS_NUM,
skip: !isEnabled || list.length <= minItemsNum,
});
React.useEffect(() => {
......
......@@ -12,7 +12,8 @@ const defaultOptions: UseToastOptions & { toastComponent?: React.FC<ToastProps>
position: 'top-right',
isClosable: true,
containerStyle: {
margin: 8,
margin: 3,
marginBottom: 0,
},
variant: 'subtle',
};
......
......@@ -20,6 +20,7 @@ export enum EventTypes {
FILTERS = 'Filters',
BUTTON_CLICK = 'Button click',
PROMO_BANNER = 'Promo banner',
APP_FEEDBACK = 'App feedback',
}
/* eslint-disable @typescript-eslint/indent */
......@@ -135,5 +136,11 @@ Type extends EventTypes.PROMO_BANNER ? {
'Source': 'Marketplace';
'Link': string;
} :
Type extends EventTypes.APP_FEEDBACK ? {
'Action': 'Rating';
'Source': 'Discovery' | 'App modal' | 'App page';
'AppId': string;
'Score': number;
} :
undefined;
/* eslint-enable @typescript-eslint/indent */
import { apps } from './apps';
export const ratings = {
records: [
{
fields: {
appId: apps[0].id,
rating: 4.3,
},
},
],
};
import { apps } from './apps';
export const securityReports = [
{
appName: 'token-approval-tracker',
appName: apps[0].id,
doc: 'http://docs.li.fi/smart-contracts/deployments#mainnet',
chainsData: {
'1': {
......
......@@ -10,6 +10,7 @@ function generateCspPolicy() {
descriptors.googleFonts(),
descriptors.googleReCaptcha(),
descriptors.growthBook(),
descriptors.marketplace(),
descriptors.mixpanel(),
descriptors.monaco(),
descriptors.safe(),
......
......@@ -31,8 +31,6 @@ const getCspReportUrl = () => {
};
export function app(): CspDev.DirectiveDescriptor {
const marketplaceFeaturePayload = getFeaturePayload(config.features.marketplace);
return {
'default-src': [
// KEY_WORDS.NONE,
......@@ -57,7 +55,6 @@ export function app(): CspDev.DirectiveDescriptor {
getFeaturePayload(config.features.addressVerification)?.api.endpoint,
getFeaturePayload(config.features.nameService)?.api.endpoint,
getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
marketplaceFeaturePayload && 'api' in marketplaceFeaturePayload ? marketplaceFeaturePayload.api.endpoint : '',
// chain RPC server
config.chain.rpcUrl,
......
......@@ -5,6 +5,7 @@ export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha';
export { growthBook } from './growthBook';
export { marketplace } from './marketplace';
export { mixpanel } from './mixpanel';
export { monaco } from './monaco';
export { safe } from './safe';
......
import type CspDev from 'csp-dev';
import config from 'configs/app';
const feature = config.features.marketplace;
export function marketplace(): CspDev.DirectiveDescriptor {
if (!feature.isEnabled) {
return {};
}
return {
'connect-src': [
'api' in feature ? feature.api.endpoint : '',
feature.rating ? 'https://api.airtable.com' : '',
],
'frame-src': [
'*',
],
};
}
......@@ -71,6 +71,8 @@
| "globe-b"
| "globe"
| "graphQL"
| "heart_filled"
| "heart_outline"
| "hourglass"
| "info"
| "integration/full"
......
......@@ -35,7 +35,8 @@ const baseStyle = defineStyle((props) => {
[$bg.variable]: `colors.${ bg }`,
[$fg.variable]: `colors.${ fg }`,
[$arrowBg.variable]: $bg.reference,
maxWidth: props.maxWidth || props.maxW || 'unset',
maxWidth: props.maxWidth || props.maxW || 'calc(100vw - 8px)',
marginX: '4px',
};
});
......
......@@ -26,8 +26,14 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia
site?: string;
}
export type AppRating = {
recordId: string;
value: number | undefined;
}
export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & {
securityReport?: MarketplaceAppSecurityReport;
rating?: AppRating;
}
export enum MarketplaceCategory {
......
......@@ -72,7 +72,7 @@ const AppSecurityReport = ({
className={ className }
/>
</PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '328px' }}>
<PopoverContent w={{ base: 'calc(100vw - 24px)', lg: '328px' }} mx={{ base: 3, lg: 0 }}>
<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 }>
......
......@@ -7,8 +7,8 @@ import type { MarketplaceAppPreview } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import FavoriteIcon from '../FavoriteIcon';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
import FeaturedAppMobile from './FeaturedAppMobile';
......@@ -135,10 +135,7 @@ const FeaturedApp = ({
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"/>
}
icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
/>
) }
</Flex>
......
......@@ -4,8 +4,7 @@ import React from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import IconSvg from 'ui/shared/IconSvg';
import FavoriteIcon from '../FavoriteIcon';
import MarketplaceAppCardLink from '../MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
......@@ -144,10 +143,7 @@ const FeaturedAppMobile = ({
w={ 9 }
h={ 8 }
onClick={ onFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
/>
) }
</Flex>
......
......@@ -21,7 +21,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => (
(selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? (
<>
You don{ apos }t have any favorite apps.<br/>
Click on the <IconSvg name="star_outline" w={ 4 } h={ 4 } mb={ -0.5 }/> icon on the app{ apos }s card to add it to Favorites.
Click on the <IconSvg name="heart_outline" boxSize={ 5 } mb={ -1 } color="gray.400"/> icon on the app{ apos }s card to add it to Favorites.
</>
) : (
<>
......
import { useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
isFavorite: boolean;
color?: string;
}
const FavoriteIcon = ({ isFavorite, color }: Props) => {
const heartFilledColor = useColorModeValue('blue.700', 'gray.400');
const defaultColor = isFavorite ? heartFilledColor : 'gray.400';
return (
<IconSvg
name={ isFavorite ? 'heart_filled' : 'heart_outline' }
color={ color || defaultColor }
boxSize={ 5 }
/>
);
};
export default FavoriteIcon;
import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, chakra, Flex } from '@chakra-ui/react';
import { IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, chakra, Flex } from '@chakra-ui/react';
import type { MouseEvent } from 'react';
import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport';
import FavoriteIcon from './FavoriteIcon';
import MarketplaceAppCardLink from './MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
import Rating from './Rating/Rating';
import type { RateFunction } from './Rating/useRatings';
interface Props extends MarketplaceAppWithSecurityReport {
onInfoClick: (id: string) => void;
......@@ -19,6 +21,11 @@ interface Props extends MarketplaceAppWithSecurityReport {
onAppClick: (event: MouseEvent, id: string) => void;
className?: string;
showContractList: (id: string, type: ContractListTypes) => void;
userRating?: AppRating;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
}
const MarketplaceAppCard = ({
......@@ -39,6 +46,12 @@ const MarketplaceAppCard = ({
securityReport,
className,
showContractList,
rating,
userRating,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}: Props) => {
const isMobile = useIsMobile();
const categoriesLabel = categories.join(', ');
......@@ -141,8 +154,7 @@ const MarketplaceAppCard = ({
</Skeleton>
{ !isLoading && (
<Box
display="flex"
<Flex
alignItems="center"
justifyContent="space-between"
marginTop="auto"
......@@ -156,6 +168,17 @@ const MarketplaceAppCard = ({
>
More info
</Link>
<Flex alignItems="center" gap={ 3 }>
<Rating
appId={ id }
rating={ rating }
userRating={ userRating }
rate={ rateApp }
isSending={ isRatingSending }
isLoading={ isRatingLoading }
canRate={ canRate }
source="Discovery"
/>
<IconButton
aria-label="Mark as favorite"
title="Mark as favorite"
......@@ -164,12 +187,10 @@ const MarketplaceAppCard = ({
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"/>
}
icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
/>
</Box>
</Flex>
</Flex>
) }
{ securityReport && (
......
......@@ -36,7 +36,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => {
textAlign="center"
padding={ 2 }
openDelay={ 300 }
maxW={ 400 }
maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}
>
<IconSvg
name={ icon }
......
......@@ -15,13 +15,27 @@ const props = {
data: {
...appsMock[0],
securityReport: securityReportsMock[0].chainsData['1'],
rating: {
recordId: 'test',
value: 4.3,
},
} as MarketplaceAppWithSecurityReport,
isFavorite: false,
userRating: undefined,
rateApp: () => {},
isRatingSending: false,
isRatingLoading: false,
canRate: undefined,
};
const testFn: Parameters<typeof test>[1] = async({ render, page, mockAssetResponse }) => {
const testFn: Parameters<typeof test>[1] = async({ render, page, mockAssetResponse, mockEnvs }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ],
]);
await mockAssetResponse(appsMock[0].logo, './playwright/mocks/image_s.jpg');
await render(<MarketplaceAppModal { ...props }/>);
await page.getByText('Launch app').focus();
await expect(page).toHaveScreenshot();
};
......
......@@ -4,9 +4,10 @@ import {
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, AppRating } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index';
......@@ -14,7 +15,13 @@ import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport';
import FavoriteIcon from './FavoriteIcon';
import MarketplaceAppModalLink from './MarketplaceAppModalLink';
import Rating from './Rating/Rating';
import type { RateFunction } from './Rating/useRatings';
const feature = config.features.marketplace;
const isRatingEnabled = feature.isEnabled && feature.rating;
type Props = {
onClose: () => void;
......@@ -22,6 +29,11 @@ type Props = {
onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void;
data: MarketplaceAppWithSecurityReport;
showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void;
userRating?: AppRating;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
}
const MarketplaceAppModal = ({
......@@ -30,9 +42,12 @@ const MarketplaceAppModal = ({
onFavoriteClick,
data,
showContractList: showContractListProp,
userRating,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}: Props) => {
const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300');
const {
id,
title,
......@@ -49,6 +64,7 @@ const MarketplaceAppModal = ({
logoDarkMode,
categories,
securityReport,
rating,
} = data;
const socialLinks = [
......@@ -119,7 +135,7 @@ const MarketplaceAppModal = ({
w={{ base: '72px', md: '144px' }}
h={{ base: '72px', md: '144px' }}
marginRight={{ base: 6, md: 8 }}
gridRow={{ base: '1 / 3', md: '1 / 4' }}
gridRow={{ base: '1 / 3', md: '1 / 5' }}
>
<Image
src={ logoUrl }
......@@ -131,10 +147,10 @@ const MarketplaceAppModal = ({
<Heading
as="h2"
gridColumn={ 2 }
fontSize={{ base: '2xl', md: '3xl' }}
fontSize={{ base: '2xl', md: '32px' }}
fontWeight="medium"
lineHeight={ 1 }
color="blue.600"
lineHeight={{ md: 10 }}
mb={{ md: 2 }}
>
{ title }
</Heading>
......@@ -142,16 +158,37 @@ const MarketplaceAppModal = ({
<Text
variant="secondary"
gridColumn={ 2 }
fontSize="sm"
fontSize={{ base: 'sm', md: 'md' }}
fontWeight="normal"
lineHeight={ 1 }
lineHeight={{ md: 6 }}
>
By{ nbsp }{ author }
</Text>
{ isRatingEnabled && (
<Box
gridColumn={{ base: '1 / 3', md: 2 }}
marginTop={{ base: 6, md: 3 }}
py={{ base: 0, md: 1.5 }}
width="fit-content"
>
<Rating
appId={ id }
rating={ rating }
userRating={ userRating }
rate={ rateApp }
isSending={ isRatingSending }
isLoading={ isRatingLoading }
fullView
canRate={ canRate }
source="App modal"
/>
</Box>
) }
<Box
gridColumn={{ base: '1 / 3', md: 2 }}
marginTop={{ base: 6, md: 0 }}
marginTop={{ base: 6, md: 3 }}
>
<Flex flexWrap="wrap" gap={ 6 }>
<Flex width={{ base: '100%', md: 'auto' }}>
......@@ -170,9 +207,7 @@ const MarketplaceAppModal = ({
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={ starOutlineIconColor }/> }
icon={ <FavoriteIcon isFavorite={ isFavorite } color={ useColorModeValue('blue.700', 'gray.400') }/> }
/>
</Flex>
</Flex>
......
......@@ -18,18 +18,23 @@ import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import AppSecurityReport from './AppSecurityReport';
import ContractListModal from './ContractListModal';
import MarketplaceAppInfo from './MarketplaceAppInfo';
import Rating from './Rating/Rating';
import useRatings from './Rating/useRatings';
type Props = {
appId: string;
data: MarketplaceAppOverview | undefined;
isLoading: boolean;
securityReport?: MarketplaceAppSecurityReport;
}
const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props) => {
const [ contractListType, setContractListType ] = React.useState<ContractListTypes>();
const appProps = useAppContext();
const isMobile = useIsMobile();
const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings();
const goBackUrl = React.useMemo(() => {
if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) {
return appProps.referrer;
......@@ -82,6 +87,16 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
source="App page"
/>
) }
<Rating
appId={ appId }
rating={ ratings[appId] }
userRating={ userRatings[appId] }
rate={ rateApp }
isSending={ isRatingSending }
isLoading={ isRatingLoading }
canRate={ canRate }
source="App page"
/>
{ !isMobile && (
<Flex flex="1" justifyContent="flex-end">
{ config.features.account.isEnabled && <ProfileMenuDesktop boxSize="32px" fallbackIconSize={ 16 }/> }
......
import { Grid } from '@chakra-ui/react';
import { Grid, Box } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import * as mixpanel from 'lib/mixpanel/index';
import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard';
import type { RateFunction } from './Rating/useRatings';
type Props = {
apps: Array<MarketplaceAppWithSecurityReport>;
......@@ -18,9 +20,19 @@ type Props = {
selectedCategoryId?: string;
onAppClick: (event: MouseEvent, id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void;
userRatings: Record<string, AppRating>;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
}
const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, onAppClick, showContractList }: Props) => {
const MarketplaceList = ({
apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId,
onAppClick, showContractList, userRatings, rateApp, isRatingSending, isRatingLoading, canRate,
}: Props) => {
const { cutRef, renderedItemsNum } = useLazyRenderedList(apps, !isLoading, 16);
const handleInfoClick = useCallback((id: string) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
showAppInfo(id);
......@@ -31,6 +43,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
}, [ onFavoriteClick ]);
return apps.length > 0 ? (
<>
<Grid
templateColumns={{
md: 'repeat(auto-fill, minmax(230px, 1fr))',
......@@ -39,7 +52,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
autoRows="1fr"
gap={{ base: '16px', md: '24px' }}
>
{ apps.map((app, index) => (
{ apps.slice(0, renderedItemsNum).map((app, index) => (
<MarketplaceAppCard
key={ app.id + (isLoading ? index : '') }
onInfoClick={ handleInfoClick }
......@@ -58,9 +71,17 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
onAppClick={ onAppClick }
securityReport={ app.securityReport }
showContractList={ showContractList }
rating={ app.rating }
userRating={ userRatings[app.id] }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/>
)) }
</Grid>
<Box ref={ cutRef } h={ 0 }/>
</>
) : (
<EmptySearchResult selectedCategoryId={ selectedCategoryId } favoriteApps={ favoriteApps }/>
);
......
import { Text, Flex, Spinner } from '@chakra-ui/react';
import React from 'react';
import type { AppRating } from 'types/client/marketplace';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import Stars from './Stars';
import type { RateFunction } from './useRatings';
const ratingDescriptions = [ 'Very bad', 'Bad', 'Average', 'Good', 'Excellent' ];
type Props = {
appId: string;
rating?: AppRating;
userRating?: AppRating;
rate: RateFunction;
isSending?: boolean;
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'];
};
const PopoverContent = ({ appId, rating, userRating, rate, isSending, source }: Props) => {
const [ hovered, setHovered ] = React.useState(-1);
const filledIndex = React.useMemo(() => {
if (hovered >= 0) {
return hovered;
}
return userRating?.value ? userRating?.value - 1 : -1;
}, [ userRating, hovered ]);
const handleMouseOverFactory = React.useCallback((index: number) => () => {
setHovered(index);
}, []);
const handleMouseOut = React.useCallback(() => {
setHovered(-1);
}, []);
const handleRateFactory = React.useCallback((index: number) => () => {
rate(appId, rating?.recordId, userRating?.recordId, index + 1, source);
}, [ appId, rating, rate, userRating, source ]);
if (isSending) {
return (
<Flex alignItems="center">
<Spinner size="md"/>
<Text fontSize="md" ml={ 3 }>Sending your feedback</Text>
</Flex>
);
}
return (
<>
<Flex alignItems="center">
{ userRating && (
<IconSvg name="verified" color="green.400" boxSize="30px" mr={ 1 } ml="-5px"/>
) }
<Text fontWeight="500" fontSize="xs" lineHeight="30px" variant="secondary">
{ userRating ? 'App is already rated by you' : 'How was your experience?' }
</Text>
</Flex>
<Flex alignItems="center" h="32px">
<Stars
filledIndex={ filledIndex }
onMouseOverFactory={ handleMouseOverFactory }
onMouseOut={ handleMouseOut }
onClickFactory={ handleRateFactory }
/>
{ (filledIndex >= 0) && (
<Text fontSize="md" ml={ 3 }>
{ ratingDescriptions[filledIndex] }
</Text>
) }
</Flex>
</>
);
};
export default PopoverContent;
import { Text, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure, Skeleton, useOutsideClick, Box } from '@chakra-ui/react';
import React from 'react';
import type { AppRating } from 'types/client/marketplace';
import config from 'configs/app';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import Popover from 'ui/shared/chakra/Popover';
import Content from './PopoverContent';
import Stars from './Stars';
import TriggerButton from './TriggerButton';
import type { RateFunction } from './useRatings';
const feature = config.features.marketplace;
const isEnabled = feature.isEnabled && feature.rating;
type Props = {
appId: string;
rating?: AppRating;
userRating?: AppRating;
rate: RateFunction;
isSending?: boolean;
isLoading?: boolean;
fullView?: boolean;
canRate: boolean | undefined;
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'];
};
const Rating = ({
appId, rating, userRating, rate,
isSending, isLoading, fullView, canRate, source,
}: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
// have to implement this solution because popover loses focus on button click inside it (issue: https://github.com/chakra-ui/chakra-ui/issues/7359)
const popoverRef = React.useRef(null);
useOutsideClick({ ref: popoverRef, handler: onClose });
if (!isEnabled) {
return null;
}
return (
<Skeleton
display="flex"
alignItems="center"
isLoaded={ !isLoading }
w={ (isLoading && !fullView) ? '40px' : 'auto' }
>
{ fullView && (
<>
<Stars filledIndex={ (rating?.value || 0) - 1 }/>
<Text fontSize="md" ml={ 2 }>{ rating?.value }</Text>
</>
) }
<Box ref={ popoverRef }>
<Popover isOpen={ isOpen } placement="bottom" isLazy>
<PopoverTrigger>
<TriggerButton
rating={ rating?.value }
fullView={ fullView }
isActive={ isOpen }
onClick={ onToggle }
canRate={ canRate }
/>
</PopoverTrigger>
<PopoverContent w="250px" mx={ 3 }>
<PopoverBody p={ 4 }>
<Content
appId={ appId }
rating={ rating }
userRating={ userRating }
rate={ rate }
isSending={ isSending }
source={ source }
/>
</PopoverBody>
</PopoverContent>
</Popover>
</Box>
</Skeleton>
);
};
export default Rating;
import { Flex, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { MouseEventHandler } from 'react';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
filledIndex: number;
onMouseOverFactory?: (index: number) => MouseEventHandler<HTMLDivElement>;
onMouseOut?: () => void;
onClickFactory?: (index: number) => MouseEventHandler<HTMLDivElement>;
};
const Stars = ({ filledIndex, onMouseOverFactory, onMouseOut, onClickFactory }: Props) => {
const disabledStarColor = useColorModeValue('gray.200', 'gray.700');
const outlineStartColor = onMouseOverFactory ? 'gray.400' : disabledStarColor;
return (
<Flex>
{ Array(5).fill(null).map((_, index) => (
<IconSvg
key={ index }
name={ filledIndex >= index ? 'star_filled' : 'star_outline' }
color={ filledIndex >= index ? 'yellow.400' : outlineStartColor }
w={ 6 } // 5 + 1 padding
h={ 5 }
pr={ 1 } // use padding intead of margin so that there are no empty spaces between stars without hover effect
_last={{ w: 5, pr: 0 }}
cursor={ onMouseOverFactory ? 'pointer' : 'default' }
onMouseOver={ onMouseOverFactory?.(index) }
onMouseOut={ onMouseOut }
onClick={ onClickFactory?.(index) }
/>
)) }
</Flex>
);
};
export default Stars;
import { Button, chakra, useColorModeValue, Tooltip, useDisclosure } 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';
type Props = {
rating?: number;
fullView?: boolean;
isActive: boolean;
onClick: () => void;
canRate: boolean | undefined;
};
const getTooltipText = (canRate: boolean | undefined) => {
if (canRate === undefined) {
return <>Please connect your wallet to Blockscout to rate this DApp.<br/>Only wallets with 5+ transactions are eligible</>;
}
if (!canRate) {
return <>Brand new wallets cannot leave ratings.<br/>Please connect a wallet with 5 or more transactions on this chain</>;
}
return <>Ratings come from verified users.<br/>Click here to rate!</>;
};
const TriggerButton = (
{ rating, fullView, isActive, onClick, canRate }: Props,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
const textColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
const onFocusCapture = usePreventFocusAfterModalClosing();
// have to implement controlled tooltip on mobile because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107
const { isOpen, onToggle, onClose } = useDisclosure();
const isMobile = useIsMobile();
const handleClick = React.useCallback(() => {
if (canRate) {
onClick();
} else if (isMobile) {
onToggle();
}
}, [ canRate, isMobile, onToggle, onClick ]);
return (
<Tooltip
label={ getTooltipText(canRate) }
openDelay={ 100 }
textAlign="center"
closeOnClick={ Boolean(canRate) || isMobile }
isOpen={ isMobile ? isOpen : undefined }
>
<Button
ref={ ref }
size="xs"
variant="outline"
border={ 0 }
p={ 0 }
onClick={ handleClick }
fontSize={ fullView ? 'md' : 'sm' }
fontWeight={ fullView ? '400' : '500' }
lineHeight="21px"
ml={ fullView ? 3 : 0 }
isActive={ isActive }
onFocusCapture={ onFocusCapture }
cursor={ canRate ? 'pointer' : 'default' }
onMouseLeave={ isMobile ? onClose : undefined }
>
{ !fullView && (
<IconSvg
name={ rating ? 'star_filled' : 'star_outline' }
color={ rating ? 'yellow.400' : 'gray.400' }
boxSize={ 5 }
mr={ 1 }
/>
) }
{ (rating && !fullView) ? (
<chakra.span color={ textColor } transition="inherit">
{ rating }
</chakra.span>
) : (
'Rate it!'
) }
</Button>
</Tooltip>
);
};
export default React.forwardRef(TriggerButton);
import { renderHook, wrapper } from 'jest/lib';
import useRatings from './useRatings';
const useAccount = jest.fn();
const useApiQuery = jest.fn();
jest.mock('lib/hooks/useToast', () => jest.fn());
jest.mock('wagmi', () => ({ useAccount: () => useAccount() }));
jest.mock('lib/api/useApiQuery', () => () => useApiQuery());
beforeEach(() => {
jest.clearAllMocks();
});
it('should set canRate to true if address is defined and transactions_count is 5 or more', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: { transactions_count: 5 },
});
const { result } = renderHook(() => useRatings(), { wrapper });
expect(result.current.canRate).toBe(true);
});
it('should set canRate to undefined if address is undefined', async() => {
useAccount.mockReturnValue({ address: undefined });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: { transactions_count: 5 },
});
const { result } = renderHook(() => useRatings(), { wrapper });
expect(result.current.canRate).toBe(undefined);
});
it('should set canRate to false if transactions_count is less than 5', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: { transactions_count: 4 },
});
const { result } = renderHook(() => useRatings(), { wrapper });
expect(result.current.canRate).toBe(false);
});
it('should set canRate to false if isPlaceholderData is true', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: true,
data: { transactions_count: 5 },
});
const { result } = renderHook(() => useRatings(), { wrapper });
expect(result.current.canRate).toBe(false);
});
it('should set canRate to false if data is undefined', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: undefined,
});
const { result } = renderHook(() => useRatings());
expect(result.current.canRate).toBe(false);
});
it('should set canRate to false if transactions_count is undefined', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: {},
});
const { result } = renderHook(() => useRatings());
expect(result.current.canRate).toBe(false);
});
import Airtable from 'airtable';
import { useEffect, useState, useCallback } from 'react';
import { useAccount } from 'wagmi';
import type { AppRating } from 'types/client/marketplace';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import useToast from 'lib/hooks/useToast';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import * as mixpanel from 'lib/mixpanel/index';
import { ADDRESS_COUNTERS } from 'stubs/address';
const MIN_TRANSACTION_COUNT = 5;
const feature = config.features.marketplace;
const airtable = (feature.isEnabled && feature.rating) ?
new Airtable({ apiKey: feature.rating.airtableApiKey }).base(feature.rating.airtableBaseId) :
undefined;
export type RateFunction = (
appId: string,
appRecordId: string | undefined,
userRecordId: string | undefined,
rating: number,
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'],
) => void;
function formatRatings(data: Airtable.Records<Airtable.FieldSet>) {
return data.reduce((acc: Record<string, AppRating>, record) => {
const fields = record.fields as { appId: string | Array<string>; rating: number | undefined };
const appId = Array.isArray(fields.appId) ? fields.appId[0] : fields.appId;
acc[appId] = {
recordId: record.id,
value: fields.rating,
};
return acc;
}, {});
}
export default function useRatings() {
const { address } = useAccount();
const toast = useToast();
const addressCountersQuery = useApiQuery<'address_counters', { status: number }>('address_counters', {
pathParams: { hash: address },
queryOptions: {
enabled: Boolean(address),
placeholderData: ADDRESS_COUNTERS,
refetchOnMount: false,
},
});
const [ ratings, setRatings ] = useState<Record<string, AppRating>>({});
const [ userRatings, setUserRatings ] = useState<Record<string, AppRating>>({});
const [ isRatingLoading, setIsRatingLoading ] = useState<boolean>(false);
const [ isUserRatingLoading, setIsUserRatingLoading ] = useState<boolean>(false);
const [ isSending, setIsSending ] = useState<boolean>(false);
const [ canRate, setCanRate ] = useState<boolean | undefined>(undefined);
const fetchRatings = useCallback(async() => {
if (!airtable) {
return;
}
try {
const data = await airtable('apps_ratings').select({ fields: [ 'appId', 'rating' ] }).all();
const ratings = formatRatings(data);
setRatings(ratings);
} catch (error) {
toast({
status: 'error',
title: 'Error loading ratings',
description: 'Please try again later',
});
}
}, [ toast ]);
useEffect(() => {
async function fetch() {
setIsRatingLoading(true);
await fetchRatings();
setIsRatingLoading(false);
}
fetch();
}, [ fetchRatings ]);
useEffect(() => {
async function fetchUserRatings() {
setIsUserRatingLoading(true);
let userRatings = {} as Record<string, AppRating>;
if (address && airtable) {
try {
const data = await airtable('users_ratings').select({
filterByFormula: `address = "${ address }"`,
fields: [ 'appId', 'rating' ],
}).all();
userRatings = formatRatings(data);
} catch (error) {
toast({
status: 'error',
title: 'Error loading user ratings',
description: 'Please try again later',
});
}
}
setUserRatings(userRatings);
setIsUserRatingLoading(false);
}
fetchUserRatings();
}, [ address, toast ]);
useEffect(() => {
const { isPlaceholderData, data } = addressCountersQuery;
const canRate = address && !isPlaceholderData && Number(data?.transactions_count) >= MIN_TRANSACTION_COUNT;
setCanRate(canRate);
}, [ address, addressCountersQuery ]);
const rateApp = useCallback(async(
appId: string,
appRecordId: string | undefined,
userRecordId: string | undefined,
rating: number,
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'],
) => {
setIsSending(true);
try {
if (!address || !airtable) {
throw new Error('Address is missing');
}
if (!appRecordId) {
const records = await airtable('apps_ratings').create([ { fields: { appId } } ]);
appRecordId = records[0].id;
if (!appRecordId) {
throw new Error('Record ID is missing');
}
}
if (!userRecordId) {
const userRecords = await airtable('users_ratings').create([
{
fields: {
address,
appRecordId: [ appRecordId ],
rating,
},
},
]);
userRecordId = userRecords[0].id;
} else {
await airtable('users_ratings').update(userRecordId, { rating });
}
setUserRatings({
...userRatings,
[appId]: {
recordId: userRecordId,
value: rating,
},
});
fetchRatings();
toast({
status: 'success',
title: 'Awesome! Thank you 💜',
description: 'Your rating improves the service',
});
mixpanel.logEvent(
mixpanel.EventTypes.APP_FEEDBACK,
{ Action: 'Rating', Source: source, AppId: appId, Score: rating },
);
} catch (error) {
toast({
status: 'error',
title: 'Ooops! Something went wrong',
description: 'Please try again later',
});
}
setIsSending(false);
}, [ address, userRatings, fetchRatings, toast ]);
return {
ratings,
userRatings,
rateApp,
isRatingSending: isSending,
isRatingLoading,
isUserRatingLoading,
canRate,
};
}
......@@ -9,6 +9,7 @@ import useDebounce from 'lib/hooks/useDebounce';
import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString';
import useRatings from './Rating/useRatings';
import useMarketplaceApps from './useMarketplaceApps';
import useMarketplaceCategories from './useMarketplaceCategories';
......@@ -85,9 +86,10 @@ export default function useMarketplace() {
setSelectedCategoryId(newCategory);
}, []);
const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings();
const {
isPlaceholderData, isError, error, data, displayedApps, setSorting,
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded);
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded, ratings);
const {
isPlaceholderData: isCategoriesPlaceholderData, data: categories,
} = useMarketplaceCategories(data, isPlaceholderData);
......@@ -151,6 +153,11 @@ export default function useMarketplace() {
contractListModalType,
hasPreviousStep,
setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}), [
selectedCategoryId,
categories,
......@@ -174,5 +181,10 @@ export default function useMarketplace() {
contractListModalType,
hasPreviousStep,
setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
]);
}
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, AppRating } from 'types/client/marketplace';
import { MarketplaceCategory } from 'types/client/marketplace';
import config from 'configs/app';
......@@ -55,6 +55,7 @@ export default function useMarketplaceApps(
selectedCategoryId: string = MarketplaceCategory.ALL,
favoriteApps: Array<string> | undefined = undefined,
isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types
ratings: Record<string, AppRating> | undefined = undefined,
) {
const fetch = useFetch();
const apiFetch = useApiFetch();
......@@ -91,20 +92,27 @@ export default function useMarketplaceApps(
const [ sorting, setSorting ] = React.useState<SortValue>();
const appsWithSecurityReports = React.useMemo(() =>
data?.map((app) => ({ ...app, securityReport: securityReports?.[app.id] })),
[ data, securityReports ]);
const appsWithSecurityReportsAndRating = React.useMemo(() =>
data?.map((app) => ({
...app,
securityReport: securityReports?.[app.id],
rating: ratings?.[app.id],
})),
[ data, securityReports, ratings ]);
const displayedApps = React.useMemo(() => {
return appsWithSecurityReports
return appsWithSecurityReportsAndRating
?.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);
}
if (sorting === 'rating') {
return (b.rating?.value || 0) - (a.rating?.value || 0);
}
return 0;
}) || [];
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps, sorting ]);
}, [ selectedCategoryId, appsWithSecurityReportsAndRating, filter, favoriteApps, sorting ]);
return React.useMemo(() => ({
data,
......
......@@ -4,10 +4,11 @@ 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 type SortValue = 'rating' | 'security_score';
export const SORT_OPTIONS: Array<TOption<SortValue>> = [
{ title: 'Default', id: undefined },
{ title: 'Rating', id: 'rating' },
{ title: 'Security score', id: 'security_score' },
];
......
import React from 'react';
import { apps as appsMock } from 'mocks/apps/apps';
import { ratings as ratingsMock } from 'mocks/apps/ratings';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import { test, expect, devices } from 'playwright/lib';
......@@ -9,15 +10,21 @@ 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 }) => {
test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse, page }) => {
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 ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ],
]);
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 page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({
status: 200,
body: JSON.stringify(ratingsMock),
}));
});
test('base view +@dark-mode', async({ render }) => {
......
......@@ -70,6 +70,11 @@ const Marketplace = () => {
contractListModalType,
hasPreviousStep,
setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
} = useMarketplace();
const isMobile = useIsMobile();
......@@ -91,13 +96,13 @@ const Marketplace = () => {
tabs.unshift({
id: MarketplaceCategory.FAVORITES,
title: () => <IconSvg name="star_outline" w={ 5 } h={ 5 } display="flex"/>,
count: null,
title: () => <IconSvg name="heart_filled" boxSize={ 5 } verticalAlign="middle" mt={ -1 }/>,
count: favoriteApps.length,
component: null,
});
return tabs;
}, [ categories, appsTotal ]);
}, [ categories, appsTotal, favoriteApps.length ]);
const selectedCategoryIndex = React.useMemo(() => {
const index = categoryTabs.findIndex(c => c.id === selectedCategoryId);
......@@ -214,6 +219,11 @@ const Marketplace = () => {
selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick }
showContractList={ showContractList }
userRatings={ userRatings }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/>
{ (selectedApp && isAppInfoModalOpen) && (
......@@ -223,6 +233,11 @@ const Marketplace = () => {
onFavoriteClick={ onFavoriteClick }
data={ selectedApp }
showContractList={ showContractList }
userRating={ userRatings[selectedApp.id] }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/>
) }
......
......@@ -4,6 +4,8 @@ import { numberToHex } from 'viem';
import config from 'configs/app';
import { apps as appsMock } from 'mocks/apps/apps';
import { ratings as ratingsMock } from 'mocks/apps/ratings';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import { test, expect, devices } from 'playwright/lib';
import MarketplaceApp from './MarketplaceApp';
......@@ -16,18 +18,27 @@ const hooksConfig = {
};
const MARKETPLACE_CONFIG_URL = 'https://marketplace-config.json';
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse }) => {
const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse, page }) => {
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 ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ],
]);
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 mockAssetResponse(appsMock[0].url, './mocks/apps/app.html');
await mockRpcResponse({
Method: 'eth_chainId',
ReturnType: numberToHex(Number(config.chain.id)),
});
await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({
status: 200,
body: JSON.stringify(ratingsMock),
}));
const component = await render(
<Flex flexDirection="column" mx={{ base: 4, lg: 6 }} h="100vh">
......
......@@ -151,6 +151,7 @@ const MarketplaceApp = () => {
return (
<Flex flexDirection="column" h="100%">
<MarketplaceAppTopBar
appId={ id }
data={ data }
isLoading={ isPending || isSecurityReportsLoading }
securityReport={ securityReports?.[id] }
......
......@@ -95,7 +95,7 @@ const HashStringShortenDynamic = ({ hash, fontWeight = '400', isTooltipDisabled,
if (isTruncated) {
return (
<Tooltip label={ hash } isDisabled={ isTooltipDisabled } maxW={{ base: '100vw', lg: '400px' }}>{ content }</Tooltip>
<Tooltip label={ hash } isDisabled={ isTooltipDisabled } maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}>{ content }</Tooltip>
);
}
......
......@@ -28,7 +28,7 @@ const Hint = ({ label, className, tooltipProps, isLoading }: Props) => {
<Tooltip
label={ label }
placement="top"
maxW="320px"
maxW={{ base: 'calc(100vw - 8px)', lg: '320px' }}
isOpen={ isOpen }
{ ...tooltipProps }
>
......
......@@ -79,7 +79,7 @@ const TruncatedTextTooltip = ({ children, label, placement }: Props) => {
return (
<Tooltip
label={ label }
maxW={{ base: '100vw', lg: '400px' }}
maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}
placement={ placement }
isOpen={ isOpen }
>
......
......@@ -108,7 +108,7 @@ const Content = chakra((props: ContentProps) => {
);
return (
<Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}>
<Tooltip label={ label } maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}>
<Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span">
{ nameText }
</Skeleton>
......
......@@ -76,7 +76,7 @@ const FileSnippet = ({ file, className, index, onRemove, isDisabled, error }: Pr
<Tooltip
label={ error }
placement="top"
maxW="320px"
maxW={{ base: 'calc(100vw - 8px)', lg: '320px' }}
>
<Box cursor="pointer" display="inherit" onClick={ handleErrorHintIconClick } ml={ 1 }>
<IconSvg name="info" boxSize={ 5 } color="error"/>
......
......@@ -64,6 +64,7 @@ const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState
variant="nav"
gutter={ 20 }
color={ isInternalLink && item.isActive ? colors.text.active : colors.text.hover }
margin={ 0 }
>
<HStack spacing={ 0 } overflow="hidden">
<NavLinkIcon item={ item }/>
......
......@@ -6229,6 +6229,11 @@
dependencies:
undici-types "~5.26.4"
"@types/node@>=8.0.0 <15":
version "14.18.63"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b"
integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==
"@types/papaparse@^5.3.5":
version "5.3.5"
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.5.tgz#e5ad94b1fe98e2a8ea0b03284b83d2cb252bbf39"
......@@ -7116,6 +7121,11 @@ abort-controller@^3.0.0:
dependencies:
event-target-shim "^5.0.0"
abortcontroller-polyfill@^1.4.0:
version "1.7.5"
resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz#6738495f4e901fbb57b6c0611d0c75f76c485bed"
integrity sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==
acorn-globals@^7.0.0:
version "7.0.1"
resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3"
......@@ -7179,6 +7189,17 @@ aggregate-error@^3.0.0:
clean-stack "^2.0.0"
indent-string "^4.0.0"
airtable@^0.12.2:
version "0.12.2"
resolved "https://registry.yarnpkg.com/airtable/-/airtable-0.12.2.tgz#e53e66db86744f9bc684faa58881d6c9c12f0e6f"
integrity sha512-HS3VytUBTKj8A0vPl7DDr5p/w3IOGv6RXL0fv7eczOWAtj9Xe8ri4TAiZRXoOyo+Z/COADCj+oARFenbxhmkIg==
dependencies:
"@types/node" ">=8.0.0 <15"
abort-controller "^3.0.0"
abortcontroller-polyfill "^1.4.0"
lodash "^4.17.21"
node-fetch "^2.6.7"
ajv@^6.12.4:
version "6.12.6"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
......
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