add dapp ratings
@@ -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) {
|
||||
|
||||
@@ -57,6 +57,7 @@ NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blocksc
|
||||
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-security-reports/default.json
|
||||
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
|
||||
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
|
||||
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=app4iqrpmtJ5NrbjP
|
||||
NEXT_PUBLIC_HAS_USER_OPS=true
|
||||
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
|
||||
# rollup
|
||||
|
||||
@@ -472,6 +472,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
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M16 31a15 15 0 1 1 0-30 15 15 0 0 1 0 30Zm0-28a13 13 0 1 0 0 26 13 13 0 0 0 0-26Z" fill="currentColor"/>
|
||||
<path d="M13.67 22a1 1 0 0 1-.73-.32l-4.67-5a1 1 0 0 1 1.46-1.36l3.94 4.21 8.6-9.21a1 1 0 1 1 1.46 1.36l-9.33 10a1 1 0 0 1-.73.32Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 399 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 776 B |
@@ -1,3 +1,3 @@
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 601 B After Width: | Height: | Size: 636 B |
@@ -1,3 +1,3 @@
|
||||
<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"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 20" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.568 20a.724.724 0 0 1-.352-.09l.352.09Zm.005 0a.73.73 0 0 0 .314-.074c.1-.049.189-.12.26-.208a.806.806 0 0 0 .167-.644l-1.024-6.252 4.338-4.428a.803.803 0 0 0 .207-.377.83.83 0 0 0-.015-.436.795.795 0 0 0-.232-.36.74.74 0 0 0-.38-.179l-5.86-.892a.25.25 0 0 1-.19-.14L11.537.442a.78.78 0 0 0-.28-.323.732.732 0 0 0-.799 0 .78.78 0 0 0-.279.323L7.555 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.215L4.4 19.074a.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.26l3.506-1.932a.25.25 0 0 1 .242 0l3.506 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.91 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.14ZM16.573 20h-.004.004Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -65,6 +65,9 @@ export function app(): CspDev.DirectiveDescriptor {
|
||||
|
||||
// github (spec for api-docs page)
|
||||
'raw.githubusercontent.com',
|
||||
|
||||
// airtable (for dapps ratings)
|
||||
'api.airtable.com',
|
||||
].filter(Boolean),
|
||||
|
||||
'script-src': [
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"@types/papaparse": "^5.3.5",
|
||||
"@types/react-scroll": "^1.8.4",
|
||||
"@web3modal/wagmi": "4.2.1",
|
||||
"airtable": "^0.12.2",
|
||||
"bignumber.js": "^9.1.0",
|
||||
"blo": "^1.1.1",
|
||||
"chakra-react-select": "^4.4.3",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
| "brands/solidity_scan"
|
||||
| "burger"
|
||||
| "certified"
|
||||
| "check_circle"
|
||||
| "check"
|
||||
| "clock-light"
|
||||
| "clock"
|
||||
@@ -69,6 +70,8 @@
|
||||
| "globe-b"
|
||||
| "globe"
|
||||
| "graphQL"
|
||||
| "heart_filled"
|
||||
| "heart_outline"
|
||||
| "info"
|
||||
| "integration/full"
|
||||
| "integration/partial"
|
||||
|
||||
@@ -28,6 +28,8 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia
|
||||
|
||||
export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & {
|
||||
securityReport?: MarketplaceAppSecurityReport;
|
||||
rating?: number;
|
||||
ratingRecordId?: string;
|
||||
}
|
||||
|
||||
export enum MarketplaceCategory {
|
||||
@@ -63,3 +65,14 @@ export type MarketplaceAppSecurityReportRaw = {
|
||||
[chainId: string]: MarketplaceAppSecurityReport;
|
||||
};
|
||||
}
|
||||
|
||||
export type UserRatings = {
|
||||
[appId: string]: number;
|
||||
};
|
||||
|
||||
export type AppRatings = {
|
||||
[appId: string]: {
|
||||
recordId: string;
|
||||
rating: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -10,6 +10,7 @@ import IconSvg from 'ui/shared/IconSvg';
|
||||
import AppSecurityReport from './AppSecurityReport';
|
||||
import MarketplaceAppCardLink from './MarketplaceAppCardLink';
|
||||
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
|
||||
import Rating from './Rating';
|
||||
|
||||
interface Props extends MarketplaceAppWithSecurityReport {
|
||||
onInfoClick: (id: string) => void;
|
||||
@@ -19,6 +20,10 @@ interface Props extends MarketplaceAppWithSecurityReport {
|
||||
onAppClick: (event: MouseEvent, id: string) => void;
|
||||
className?: string;
|
||||
showContractList: (id: string, type: ContractListTypes) => void;
|
||||
isRatedByUser: boolean;
|
||||
rateApp: (appId: string, recordId: string | undefined, rating: number) => void;
|
||||
isSendingRating: boolean;
|
||||
isRatingLoading: boolean;
|
||||
}
|
||||
|
||||
const MarketplaceAppCard = ({
|
||||
@@ -39,10 +44,18 @@ const MarketplaceAppCard = ({
|
||||
securityReport,
|
||||
className,
|
||||
showContractList,
|
||||
rating,
|
||||
ratingRecordId,
|
||||
isRatedByUser,
|
||||
rateApp,
|
||||
isSendingRating,
|
||||
isRatingLoading,
|
||||
}: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
const categoriesLabel = categories.join(', ');
|
||||
|
||||
const heartFilledColor = useColorModeValue('blue.700', 'gray.400');
|
||||
|
||||
const handleInfoClick = useCallback((event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onInfoClick(id);
|
||||
@@ -141,8 +154,7 @@ const MarketplaceAppCard = ({
|
||||
</Skeleton>
|
||||
|
||||
{ !isLoading && (
|
||||
<Box
|
||||
display="flex"
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
marginTop="auto"
|
||||
@@ -156,20 +168,34 @@ const MarketplaceAppCard = ({
|
||||
>
|
||||
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>
|
||||
<Flex alignItems="center" gap={ 3 }>
|
||||
<Rating
|
||||
appId={ id }
|
||||
rating={ rating }
|
||||
recordId={ ratingRecordId }
|
||||
isRatedByUser={ isRatedByUser }
|
||||
rate={ rateApp }
|
||||
isSending={ isSendingRating }
|
||||
isLoading={ isRatingLoading }
|
||||
/>
|
||||
<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={ (
|
||||
<IconSvg
|
||||
name={ isFavorite ? 'heart_filled' : 'heart_outline' }
|
||||
color={ isFavorite ? heartFilledColor : 'gray.400' }
|
||||
boxSize={ 5 }
|
||||
/>
|
||||
) }
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
) }
|
||||
|
||||
{ securityReport && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import IconSvg from 'ui/shared/IconSvg';
|
||||
|
||||
import AppSecurityReport from './AppSecurityReport';
|
||||
import MarketplaceAppModalLink from './MarketplaceAppModalLink';
|
||||
import Rating from './Rating';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
@@ -22,6 +23,10 @@ type Props = {
|
||||
onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void;
|
||||
data: MarketplaceAppWithSecurityReport;
|
||||
showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void;
|
||||
isRatedByUser: boolean;
|
||||
rateApp: (appId: string, recordId: string | undefined, rating: number) => void;
|
||||
isSendingRating: boolean;
|
||||
isRatingLoading: boolean;
|
||||
}
|
||||
|
||||
const MarketplaceAppModal = ({
|
||||
@@ -30,9 +35,11 @@ const MarketplaceAppModal = ({
|
||||
onFavoriteClick,
|
||||
data,
|
||||
showContractList: showContractListProp,
|
||||
isRatedByUser,
|
||||
rateApp,
|
||||
isSendingRating,
|
||||
isRatingLoading,
|
||||
}: Props) => {
|
||||
const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
@@ -49,6 +56,8 @@ const MarketplaceAppModal = ({
|
||||
logoDarkMode,
|
||||
categories,
|
||||
securityReport,
|
||||
rating,
|
||||
ratingRecordId,
|
||||
} = data;
|
||||
|
||||
const socialLinks = [
|
||||
@@ -119,7 +128,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 +140,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={{ base: 1, md: 10 }}
|
||||
mb={{ md: 2 }}
|
||||
>
|
||||
{ title }
|
||||
</Heading>
|
||||
@@ -142,16 +151,33 @@ const MarketplaceAppModal = ({
|
||||
<Text
|
||||
variant="secondary"
|
||||
gridColumn={ 2 }
|
||||
fontSize="sm"
|
||||
fontSize={{ base: 'sm', md: 'md' }}
|
||||
fontWeight="normal"
|
||||
lineHeight={ 1 }
|
||||
lineHeight={{ base: 1, md: 6 }}
|
||||
>
|
||||
By{ nbsp }{ author }
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
gridColumn={{ base: '1 / 3', md: 2 }}
|
||||
marginTop={{ base: 6, md: 0 }}
|
||||
marginTop={{ base: 6, md: 3 }}
|
||||
py={{ base: 0, md: 1.5 }}
|
||||
>
|
||||
<Rating
|
||||
appId={ id }
|
||||
rating={ rating }
|
||||
recordId={ ratingRecordId }
|
||||
isRatedByUser={ isRatedByUser }
|
||||
rate={ rateApp }
|
||||
isSending={ isSendingRating }
|
||||
isLoading={ isRatingLoading }
|
||||
fullView
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
gridColumn={{ base: '1 / 3', md: 2 }}
|
||||
marginTop={{ base: 6, md: 3 }}
|
||||
>
|
||||
<Flex flexWrap="wrap" gap={ 6 }>
|
||||
<Flex width={{ base: '100%', md: 'auto' }}>
|
||||
@@ -170,9 +196,13 @@ 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={ (
|
||||
<IconSvg
|
||||
name={ isFavorite ? 'heart_filled' : 'heart_outline' }
|
||||
color={ useColorModeValue('blue.700', 'gray.400') }
|
||||
boxSize={ 5 }
|
||||
/>
|
||||
) }
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Grid } 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, UserRatings } from 'types/client/marketplace';
|
||||
|
||||
import * as mixpanel from 'lib/mixpanel/index';
|
||||
|
||||
@@ -18,9 +18,16 @@ type Props = {
|
||||
selectedCategoryId?: string;
|
||||
onAppClick: (event: MouseEvent, id: string) => void;
|
||||
showContractList: (id: string, type: ContractListTypes) => void;
|
||||
userRatings: UserRatings;
|
||||
rateApp: (appId: string, recordId: string, rating: number) => void;
|
||||
isSendingRating: boolean;
|
||||
isRatingLoading: boolean;
|
||||
}
|
||||
|
||||
const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, onAppClick, showContractList }: Props) => {
|
||||
const MarketplaceList = ({
|
||||
apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId,
|
||||
onAppClick, showContractList, userRatings, rateApp, isSendingRating, isRatingLoading,
|
||||
}: Props) => {
|
||||
const handleInfoClick = useCallback((id: string) => {
|
||||
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
|
||||
showAppInfo(id);
|
||||
@@ -58,6 +65,12 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
|
||||
onAppClick={ onAppClick }
|
||||
securityReport={ app.securityReport }
|
||||
showContractList={ showContractList }
|
||||
rating={ app.rating }
|
||||
ratingRecordId={ app.ratingRecordId }
|
||||
isRatedByUser={ Boolean(userRatings[app.id]) }
|
||||
rateApp={ rateApp }
|
||||
isSendingRating={ isSendingRating }
|
||||
isRatingLoading={ isRatingLoading }
|
||||
/>
|
||||
)) }
|
||||
</Grid>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Text, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure,
|
||||
Button, Flex, Spinner, Skeleton, chakra, useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
|
||||
import IconSvg from 'ui/shared/IconSvg';
|
||||
|
||||
const ratingDescriptions = [ 'Terrible', 'Poor', 'Average', 'Very good', 'Outstanding' ];
|
||||
|
||||
type StarsProps = {
|
||||
filledIndex: number;
|
||||
onMouseOverFactory?: (index: number) => MouseEventHandler<HTMLDivElement>;
|
||||
onMouseOut?: () => void;
|
||||
onClickFactory?: (index: number) => MouseEventHandler<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const Stars = ({ filledIndex, onMouseOverFactory, onMouseOut, onClickFactory }: StarsProps) => (
|
||||
<>
|
||||
{ Array(5).fill(null).map((_, index) => (
|
||||
<IconSvg
|
||||
key={ index }
|
||||
name={ filledIndex >= index ? 'star_filled' : 'star_outline' }
|
||||
color={ filledIndex >= index ? 'yellow.400' : 'gray.400' }
|
||||
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
|
||||
cursor={ onMouseOverFactory ? 'pointer' : 'default' }
|
||||
onMouseOver={ onMouseOverFactory?.(index) }
|
||||
onMouseOut={ onMouseOut }
|
||||
onClick={ onClickFactory?.(index) }
|
||||
/>
|
||||
)) }
|
||||
</>
|
||||
);
|
||||
|
||||
type ContentProps = {
|
||||
appId: string;
|
||||
recordId?: string;
|
||||
isRatedByUser?: boolean;
|
||||
rate: (appId: string, recordId: string | undefined, rating: number) => void;
|
||||
isSending?: boolean;
|
||||
};
|
||||
|
||||
const Content = ({ appId, recordId, isRatedByUser, rate, isSending }: ContentProps) => {
|
||||
const [ hovered, setHovered ] = React.useState(-1);
|
||||
|
||||
const handleMouseOverFactory = React.useCallback((index: number) => () => {
|
||||
setHovered(index);
|
||||
}, []);
|
||||
|
||||
const handleMouseOut = React.useCallback(() => {
|
||||
setHovered(-1);
|
||||
}, []);
|
||||
|
||||
const handleRateFactory = React.useCallback((index: number) => () => {
|
||||
rate(appId, recordId, index + 1);
|
||||
}, [ appId, recordId, rate ]);
|
||||
|
||||
if (isRatedByUser) {
|
||||
return (
|
||||
<Flex alignItems="center">
|
||||
<IconSvg name="check_circle" color="green.500" boxSize={ 8 }/>
|
||||
<Text fontSize="md" ml={ 3 }>App is already rated</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSending) {
|
||||
return (
|
||||
<Flex alignItems="center">
|
||||
<Spinner size="md"/>
|
||||
<Text fontSize="md" ml={ 3 }>Sending your feedback</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text fontWeight="500" fontSize="xs" lineHeight="30px" variant="secondary">
|
||||
How was your experience?
|
||||
</Text>
|
||||
<Flex alignItems="center" h="32px">
|
||||
<Stars
|
||||
filledIndex={ hovered }
|
||||
onMouseOverFactory={ handleMouseOverFactory }
|
||||
onMouseOut={ handleMouseOut }
|
||||
onClickFactory={ handleRateFactory }
|
||||
/>
|
||||
{ hovered >= 0 && (
|
||||
<Text fontSize="md" ml={ 2 }>
|
||||
{ ratingDescriptions[ hovered ] }
|
||||
</Text>
|
||||
) }
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type RatingProps = {
|
||||
appId: string;
|
||||
rating?: number;
|
||||
recordId?: string;
|
||||
isRatedByUser?: boolean;
|
||||
rate: (appId: string, recordId: string | undefined, rating: number) => void;
|
||||
isSending?: boolean;
|
||||
isLoading?: boolean;
|
||||
fullView?: boolean;
|
||||
};
|
||||
|
||||
const Rating = ({ appId, rating, recordId, isRatedByUser, rate, isSending, isLoading, fullView }: RatingProps) => {
|
||||
const { isOpen, onToggle, onClose } = useDisclosure();
|
||||
|
||||
const textColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
|
||||
|
||||
return (
|
||||
<Skeleton display="flex" alignItems="center" isLoaded={ !isLoading } minW={ isLoading ? '40px' : 'auto' }>
|
||||
{ fullView && (
|
||||
<>
|
||||
<Stars filledIndex={ (rating || 0) - 1 }/>
|
||||
<Text fontSize="md" ml={ 1 }>{ rating }</Text>
|
||||
</>
|
||||
) }
|
||||
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom" isLazy>
|
||||
<PopoverTrigger>
|
||||
{ fullView ? (
|
||||
<Button
|
||||
size="sx"
|
||||
variant="outline"
|
||||
border={ 0 }
|
||||
p={ 0 }
|
||||
onClick={ onToggle }
|
||||
fontSize="md"
|
||||
fontWeight="400"
|
||||
ml={ 3 }
|
||||
>
|
||||
Rate it!
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
border={ 0 }
|
||||
p={ 0 }
|
||||
onClick={ onToggle }
|
||||
fontSize="sm"
|
||||
fontWeight="500"
|
||||
lineHeight="21px"
|
||||
isActive={ isOpen }
|
||||
>
|
||||
<IconSvg
|
||||
name={ rating ? 'star_filled' : 'star_outline' }
|
||||
color={ rating ? 'yellow.400' : 'gray.400' }
|
||||
boxSize={ 5 }
|
||||
mr={ 1 }
|
||||
/>
|
||||
{ rating ? (
|
||||
<chakra.span color={ textColor } transition="inherit">{ rating }</chakra.span>
|
||||
) : (
|
||||
'Rate it!'
|
||||
) }
|
||||
</Button>
|
||||
) }
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w="274px">
|
||||
<PopoverBody p={ 4 }>
|
||||
<Content
|
||||
appId={ appId }
|
||||
recordId={ recordId }
|
||||
isRatedByUser={ isRatedByUser }
|
||||
rate={ rate }
|
||||
isSending={ isSending }
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default Rating;
|
||||
@@ -87,6 +87,7 @@ export default function useMarketplace() {
|
||||
|
||||
const {
|
||||
isPlaceholderData, isError, error, data, displayedApps, setSorting,
|
||||
userRatings, rateApp, isSendingRating, isRatingLoading,
|
||||
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded);
|
||||
const {
|
||||
isPlaceholderData: isCategoriesPlaceholderData, data: categories,
|
||||
@@ -151,6 +152,10 @@ export default function useMarketplace() {
|
||||
contractListModalType,
|
||||
hasPreviousStep,
|
||||
setSorting,
|
||||
userRatings,
|
||||
rateApp,
|
||||
isSendingRating,
|
||||
isRatingLoading,
|
||||
}), [
|
||||
selectedCategoryId,
|
||||
categories,
|
||||
@@ -174,5 +179,9 @@ export default function useMarketplace() {
|
||||
contractListModalType,
|
||||
hasPreviousStep,
|
||||
setSorting,
|
||||
userRatings,
|
||||
rateApp,
|
||||
isSendingRating,
|
||||
isRatingLoading,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import useApiFetch from 'lib/api/useApiFetch';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { MARKETPLACE_APP } from 'stubs/marketplace';
|
||||
|
||||
import useRatings from './useRatings';
|
||||
import useSecurityReports from './useSecurityReports';
|
||||
import type { SortValue } from './utils';
|
||||
|
||||
@@ -60,6 +61,7 @@ export default function useMarketplaceApps(
|
||||
const apiFetch = useApiFetch();
|
||||
|
||||
const { data: securityReports, isPlaceholderData: isSecurityReportsPlaceholderData } = useSecurityReports();
|
||||
const { ratings, userRatings, rateApp, isSendingRating, isRatingLoading } = useRatings();
|
||||
|
||||
// Set the value only 1 time to avoid unnecessary useQuery calls and re-rendering of all applications
|
||||
const [ snapshotFavoriteApps, setSnapshotFavoriteApps ] = React.useState<Array<string> | undefined>();
|
||||
@@ -91,12 +93,17 @@ 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]?.rating,
|
||||
ratingRecordId: ratings?.[app.id]?.recordId,
|
||||
})),
|
||||
[ 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') {
|
||||
@@ -104,7 +111,7 @@ export default function useMarketplaceApps(
|
||||
}
|
||||
return 0;
|
||||
}) || [];
|
||||
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps, sorting ]);
|
||||
}, [ selectedCategoryId, appsWithSecurityReportsAndRating, filter, favoriteApps, sorting ]);
|
||||
|
||||
return React.useMemo(() => ({
|
||||
data,
|
||||
@@ -113,6 +120,10 @@ export default function useMarketplaceApps(
|
||||
isError,
|
||||
isPlaceholderData: isPlaceholderData || isSecurityReportsPlaceholderData,
|
||||
setSorting,
|
||||
userRatings,
|
||||
rateApp,
|
||||
isSendingRating,
|
||||
isRatingLoading,
|
||||
}), [
|
||||
data,
|
||||
displayedApps,
|
||||
@@ -121,5 +132,9 @@ export default function useMarketplaceApps(
|
||||
isPlaceholderData,
|
||||
isSecurityReportsPlaceholderData,
|
||||
setSorting,
|
||||
userRatings,
|
||||
rateApp,
|
||||
isSendingRating,
|
||||
isRatingLoading,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import Airtable from 'airtable';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useAccount } from 'wagmi';
|
||||
|
||||
import type { UserRatings, AppRatings } from 'types/client/marketplace';
|
||||
|
||||
import config from 'configs/app';
|
||||
|
||||
const feature = config.features.marketplace;
|
||||
const base = (feature.isEnabled && feature.rating) ?
|
||||
new Airtable({ apiKey: feature.rating.airtableApiKey }).base(feature.rating.airtableBaseId) :
|
||||
undefined;
|
||||
|
||||
export default function useRatings() {
|
||||
const { address } = useAccount();
|
||||
|
||||
const [ ratings, setRatings ] = useState<AppRatings>({});
|
||||
const [ userRatings, setUserRatings ] = useState<UserRatings>({});
|
||||
const [ isLoading, setIsLoading ] = useState<boolean>(false);
|
||||
const [ isSending, setIsSending ] = useState<boolean>(false);
|
||||
|
||||
const fetchRatings = useCallback(async() => {
|
||||
if (!base) {
|
||||
return;
|
||||
}
|
||||
const data = await base('apps_ratings').select({ fields: [ 'appId', 'rating' ] }).all();
|
||||
const ratings = data.reduce((acc: AppRatings, record) => {
|
||||
const fields = record.fields as { appId: string; rating: number };
|
||||
acc[fields.appId] = {
|
||||
recordId: record.id,
|
||||
rating: fields.rating,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
setRatings(ratings);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
setIsLoading(true);
|
||||
await fetchRatings();
|
||||
setIsLoading(false);
|
||||
}
|
||||
fetch();
|
||||
}, [ fetchRatings ]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchUserRatings() {
|
||||
let userRatings = {} as UserRatings;
|
||||
if (address && base) {
|
||||
const data = await base('users_ratings').select({
|
||||
filterByFormula: `address = "${ address }"`,
|
||||
fields: [ 'appId', 'rating' ],
|
||||
}).all();
|
||||
userRatings = data.reduce((acc: UserRatings, record) => {
|
||||
const fields = record.fields as { appId: string; rating: number };
|
||||
if (!fields.appId || typeof fields.rating !== 'number') {
|
||||
return acc;
|
||||
}
|
||||
acc[fields.appId] = fields.rating;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
setUserRatings(userRatings);
|
||||
}
|
||||
fetchUserRatings();
|
||||
}, [ address ]);
|
||||
|
||||
const rateApp = useCallback(async(appId: string, recordId: string | undefined, rating: number) => {
|
||||
if (!address || !base || !recordId) {
|
||||
return;
|
||||
}
|
||||
setIsSending(true);
|
||||
try {
|
||||
await base('users_ratings').create([
|
||||
{
|
||||
fields: {
|
||||
address,
|
||||
appRecordId: [ recordId ],
|
||||
rating,
|
||||
},
|
||||
},
|
||||
]);
|
||||
setUserRatings({ ...userRatings, [appId]: rating });
|
||||
fetchRatings();
|
||||
} catch (error) {}
|
||||
setIsSending(false);
|
||||
}, [ address, userRatings, fetchRatings ]);
|
||||
|
||||
return {
|
||||
ratings,
|
||||
userRatings,
|
||||
rateApp,
|
||||
isSendingRating: isSending,
|
||||
isRatingLoading: isLoading,
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,10 @@ const Marketplace = () => {
|
||||
contractListModalType,
|
||||
hasPreviousStep,
|
||||
setSorting,
|
||||
userRatings,
|
||||
rateApp,
|
||||
isSendingRating,
|
||||
isRatingLoading,
|
||||
} = useMarketplace();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -90,13 +94,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);
|
||||
@@ -213,6 +217,10 @@ const Marketplace = () => {
|
||||
selectedCategoryId={ selectedCategoryId }
|
||||
onAppClick={ handleAppClick }
|
||||
showContractList={ showContractList }
|
||||
userRatings={ userRatings }
|
||||
rateApp={ rateApp }
|
||||
isSendingRating={ isSendingRating }
|
||||
isRatingLoading={ isRatingLoading }
|
||||
/>
|
||||
|
||||
{ (selectedApp && isAppInfoModalOpen) && (
|
||||
@@ -222,6 +230,10 @@ const Marketplace = () => {
|
||||
onFavoriteClick={ onFavoriteClick }
|
||||
data={ selectedApp }
|
||||
showContractList={ showContractList }
|
||||
isRatedByUser={ Boolean(userRatings[selectedApp.id]) }
|
||||
rateApp={ rateApp }
|
||||
isSendingRating={ isSendingRating }
|
||||
isRatingLoading={ isRatingLoading }
|
||||
/>
|
||||
) }
|
||||
|
||||
|
||||
@@ -6224,6 +6224,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"
|
||||
@@ -7111,6 +7116,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"
|
||||
@@ -7174,6 +7184,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"
|
||||
|
||||