diff --git a/configs/app/features/marketplace.ts b/configs/app/features/marketplace.ts index 937397be6a..72f5d1ebde 100644 --- a/configs/app/features/marketplace.ts +++ b/configs/app/features/marketplace.ts @@ -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) { diff --git a/configs/envs/.env.eth b/configs/envs/.env.eth index 09150ec6a5..b56eec0740 100644 --- a/configs/envs/.env.eth +++ b/configs/envs/.env.eth @@ -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'} @@ -59,4 +60,4 @@ NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s-prod-1.blockscout.com NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true -NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com \ No newline at end of file +NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 12c35f6392..b8adbc940a 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -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 diff --git a/deploy/tools/envs-validator/test/.env.marketplace b/deploy/tools/envs-validator/test/.env.marketplace index 01eab57086..6cc6b1f839 100644 --- a/deploy/tools/envs-validator/test/.env.marketplace +++ b/deploy/tools/envs-validator/test/.env.marketplace @@ -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 diff --git a/deploy/values/review-l2/values.yaml.gotmpl b/deploy/values/review-l2/values.yaml.gotmpl index 09a609ed13..5647779fde 100644 --- a/deploy/values/review-l2/values.yaml.gotmpl +++ b/deploy/values/review-l2/values.yaml.gotmpl @@ -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 diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index ef5a6388c2..c37f4ee6c6 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -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 diff --git a/docs/ENVS.md b/docs/ENVS.md index 1d7cfe7829..706a9fd58e 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -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 diff --git a/icons/heart_filled.svg b/icons/heart_filled.svg new file mode 100644 index 0000000000..80926b1668 --- /dev/null +++ b/icons/heart_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/heart_outline.svg b/icons/heart_outline.svg new file mode 100644 index 0000000000..8bf7ce3e36 --- /dev/null +++ b/icons/heart_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/star_filled.svg b/icons/star_filled.svg index 2bdea23a41..5cddde5142 100644 --- a/icons/star_filled.svg +++ b/icons/star_filled.svg @@ -1,3 +1,3 @@ - - + + diff --git a/icons/star_outline.svg b/icons/star_outline.svg index bf2eca9845..05286fa1d5 100644 --- a/icons/star_outline.svg +++ b/icons/star_outline.svg @@ -1,3 +1,3 @@ - + diff --git a/lib/hooks/useLazyRenderedList.tsx b/lib/hooks/useLazyRenderedList.tsx index 245d8a0b0b..3f2d828fe0 100644 --- a/lib/hooks/useLazyRenderedList.tsx +++ b/lib/hooks/useLazyRenderedList.tsx @@ -5,12 +5,12 @@ import { useInView } from 'react-intersection-observer'; const STEP = 10; const MIN_ITEMS_NUM = 50; -export default function useLazyRenderedList(list: Array, isEnabled: boolean) { - const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(MIN_ITEMS_NUM); +export default function useLazyRenderedList(list: Array, 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(() => { diff --git a/lib/hooks/useToast.tsx b/lib/hooks/useToast.tsx index c9e0f3d63f..7afc6f3e11 100644 --- a/lib/hooks/useToast.tsx +++ b/lib/hooks/useToast.tsx @@ -12,7 +12,8 @@ const defaultOptions: UseToastOptions & { toastComponent?: React.FC position: 'top-right', isClosable: true, containerStyle: { - margin: 8, + margin: 3, + marginBottom: 0, }, variant: 'subtle', }; diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts index bc69e5e9dc..f769c3e29a 100644 --- a/lib/mixpanel/utils.ts +++ b/lib/mixpanel/utils.ts @@ -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 */ diff --git a/mocks/apps/ratings.ts b/mocks/apps/ratings.ts new file mode 100644 index 0000000000..3eeca5b693 --- /dev/null +++ b/mocks/apps/ratings.ts @@ -0,0 +1,12 @@ +import { apps } from './apps'; + +export const ratings = { + records: [ + { + fields: { + appId: apps[0].id, + rating: 4.3, + }, + }, + ], +}; diff --git a/mocks/apps/securityReports.ts b/mocks/apps/securityReports.ts index 824a6fbe13..33457ddf2f 100644 --- a/mocks/apps/securityReports.ts +++ b/mocks/apps/securityReports.ts @@ -1,6 +1,8 @@ +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': { diff --git a/nextjs/csp/generateCspPolicy.ts b/nextjs/csp/generateCspPolicy.ts index bf7b1236ef..758612f3f6 100644 --- a/nextjs/csp/generateCspPolicy.ts +++ b/nextjs/csp/generateCspPolicy.ts @@ -10,6 +10,7 @@ function generateCspPolicy() { descriptors.googleFonts(), descriptors.googleReCaptcha(), descriptors.growthBook(), + descriptors.marketplace(), descriptors.mixpanel(), descriptors.monaco(), descriptors.safe(), diff --git a/nextjs/csp/policies/app.ts b/nextjs/csp/policies/app.ts index 5734f85a76..cadb26363e 100644 --- a/nextjs/csp/policies/app.ts +++ b/nextjs/csp/policies/app.ts @@ -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, diff --git a/nextjs/csp/policies/index.ts b/nextjs/csp/policies/index.ts index af8e24b2db..1cbe44f1bc 100644 --- a/nextjs/csp/policies/index.ts +++ b/nextjs/csp/policies/index.ts @@ -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'; diff --git a/nextjs/csp/policies/marketplace.ts b/nextjs/csp/policies/marketplace.ts new file mode 100644 index 0000000000..08474a4bc1 --- /dev/null +++ b/nextjs/csp/policies/marketplace.ts @@ -0,0 +1,22 @@ +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': [ + '*', + ], + }; +} diff --git a/package.json b/package.json index f127afd7e5..f8468e02a9 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,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", diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 535a4c1d3e..333de6e887 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -71,6 +71,8 @@ | "globe-b" | "globe" | "graphQL" + | "heart_filled" + | "heart_outline" | "hourglass" | "info" | "integration/full" diff --git a/theme/components/Tooltip/Tooltip.ts b/theme/components/Tooltip/Tooltip.ts index a98dd867ce..d0bbae57e2 100644 --- a/theme/components/Tooltip/Tooltip.ts +++ b/theme/components/Tooltip/Tooltip.ts @@ -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', }; }); diff --git a/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_dark-color-mode_base-view-dark-mode-1.png index 44b6eff06f..c80b68fb63 100644 Binary files a/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and b/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_default_base-view-dark-mode-1.png b/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_default_base-view-dark-mode-1.png index 4667657549..c09ef160cb 100644 Binary files a/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_default_base-view-dark-mode-1.png and b/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/types/client/marketplace.ts b/types/client/marketplace.ts index 5f9255abec..9b34c46206 100644 --- a/types/client/marketplace.ts +++ b/types/client/marketplace.ts @@ -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 { diff --git a/ui/marketplace/AppSecurityReport.tsx b/ui/marketplace/AppSecurityReport.tsx index badbf181a3..740a7f09ae 100644 --- a/ui/marketplace/AppSecurityReport.tsx +++ b/ui/marketplace/AppSecurityReport.tsx @@ -72,7 +72,7 @@ const AppSecurityReport = ({ className={ className } /> - + Smart contracts info diff --git a/ui/marketplace/Banner/FeaturedApp.tsx b/ui/marketplace/Banner/FeaturedApp.tsx index c9c03021e7..0971a18f42 100644 --- a/ui/marketplace/Banner/FeaturedApp.tsx +++ b/ui/marketplace/Banner/FeaturedApp.tsx @@ -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 ? - : - - } + icon={ } /> ) } diff --git a/ui/marketplace/Banner/FeaturedAppMobile.tsx b/ui/marketplace/Banner/FeaturedAppMobile.tsx index 0c9bb99f69..317a946590 100644 --- a/ui/marketplace/Banner/FeaturedAppMobile.tsx +++ b/ui/marketplace/Banner/FeaturedAppMobile.tsx @@ -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 ? - : - - } + icon={ } /> ) } diff --git a/ui/marketplace/EmptySearchResult.tsx b/ui/marketplace/EmptySearchResult.tsx index 7d3185019d..171a68cc78 100644 --- a/ui/marketplace/EmptySearchResult.tsx +++ b/ui/marketplace/EmptySearchResult.tsx @@ -21,7 +21,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => ( (selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? ( <> You don{ apos }t have any favorite apps.
- Click on the icon on the app{ apos }s card to add it to Favorites. + Click on the icon on the app{ apos }s card to add it to Favorites. ) : ( <> diff --git a/ui/marketplace/FavoriteIcon.tsx b/ui/marketplace/FavoriteIcon.tsx new file mode 100644 index 0000000000..e2b589b98d --- /dev/null +++ b/ui/marketplace/FavoriteIcon.tsx @@ -0,0 +1,24 @@ +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 ( + + ); +}; + +export default FavoriteIcon; diff --git a/ui/marketplace/MarketplaceAppCard.tsx b/ui/marketplace/MarketplaceAppCard.tsx index 7db60d3ec4..834255eff6 100644 --- a/ui/marketplace/MarketplaceAppCard.tsx +++ b/ui/marketplace/MarketplaceAppCard.tsx @@ -1,15 +1,17 @@ -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 = ({ { !isLoading && ( - More info - : - - } - /> - + + + } + /> + + ) } { securityReport && ( diff --git a/ui/marketplace/MarketplaceAppIntegrationIcon.tsx b/ui/marketplace/MarketplaceAppIntegrationIcon.tsx index 07d2dd14ff..378c7c0c95 100644 --- a/ui/marketplace/MarketplaceAppIntegrationIcon.tsx +++ b/ui/marketplace/MarketplaceAppIntegrationIcon.tsx @@ -36,7 +36,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => { textAlign="center" padding={ 2 } openDelay={ 300 } - maxW={ 400 } + maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }} > {}, + isRatingSending: false, + isRatingLoading: false, + canRate: undefined, }; -const testFn: Parameters[1] = async({ render, page, mockAssetResponse }) => { +const testFn: Parameters[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(); + await page.getByText('Launch app').focus(); await expect(page).toHaveScreenshot(); }; diff --git a/ui/marketplace/MarketplaceAppModal.tsx b/ui/marketplace/MarketplaceAppModal.tsx index 03e3885f45..0eb9906480 100644 --- a/ui/marketplace/MarketplaceAppModal.tsx +++ b/ui/marketplace/MarketplaceAppModal.tsx @@ -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' }} > { title } @@ -142,16 +158,37 @@ const MarketplaceAppModal = ({ By{ nbsp }{ author } + { isRatingEnabled && ( + + + + ) } + @@ -170,9 +207,7 @@ const MarketplaceAppModal = ({ w={ 9 } h={ 8 } onClick={ handleFavoriteClick } - icon={ isFavorite ? - : - } + icon={ } /> diff --git a/ui/marketplace/MarketplaceAppTopBar.tsx b/ui/marketplace/MarketplaceAppTopBar.tsx index f7e5235f2d..5cfc939208 100644 --- a/ui/marketplace/MarketplaceAppTopBar.tsx +++ b/ui/marketplace/MarketplaceAppTopBar.tsx @@ -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(); 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" /> ) } + { !isMobile && ( { config.features.account.isEnabled && } diff --git a/ui/marketplace/MarketplaceList.tsx b/ui/marketplace/MarketplaceList.tsx index 896ca99df5..91224ff8fe 100644 --- a/ui/marketplace/MarketplaceList.tsx +++ b/ui/marketplace/MarketplaceList.tsx @@ -1,13 +1,15 @@ -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; @@ -18,9 +20,19 @@ type Props = { selectedCategoryId?: string; onAppClick: (event: MouseEvent, id: string) => void; showContractList: (id: string, type: ContractListTypes) => void; + userRatings: Record; + 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,36 +43,45 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL }, [ onFavoriteClick ]); return apps.length > 0 ? ( - - { apps.map((app, index) => ( - - )) } - + <> + + { apps.slice(0, renderedItemsNum).map((app, index) => ( + + )) } + + + ) : ( ); diff --git a/ui/marketplace/Rating/PopoverContent.tsx b/ui/marketplace/Rating/PopoverContent.tsx new file mode 100644 index 0000000000..d86d31179d --- /dev/null +++ b/ui/marketplace/Rating/PopoverContent.tsx @@ -0,0 +1,81 @@ +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['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 ( + + + Sending your feedback + + ); + } + + return ( + <> + + { userRating && ( + + ) } + + { userRating ? 'App is already rated by you' : 'How was your experience?' } + + + + + { (filledIndex >= 0) && ( + + { ratingDescriptions[filledIndex] } + + ) } + + + ); +}; + +export default PopoverContent; diff --git a/ui/marketplace/Rating/Rating.tsx b/ui/marketplace/Rating/Rating.tsx new file mode 100644 index 0000000000..21d5bcf9b4 --- /dev/null +++ b/ui/marketplace/Rating/Rating.tsx @@ -0,0 +1,85 @@ +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['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 ( + + { fullView && ( + <> + + { rating?.value } + + ) } + + + + + + + + + + + + + + ); +}; + +export default Rating; diff --git a/ui/marketplace/Rating/Stars.tsx b/ui/marketplace/Rating/Stars.tsx new file mode 100644 index 0000000000..ca2e8d0be4 --- /dev/null +++ b/ui/marketplace/Rating/Stars.tsx @@ -0,0 +1,38 @@ +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; + onMouseOut?: () => void; + onClickFactory?: (index: number) => MouseEventHandler; +}; + +const Stars = ({ filledIndex, onMouseOverFactory, onMouseOut, onClickFactory }: Props) => { + const disabledStarColor = useColorModeValue('gray.200', 'gray.700'); + const outlineStartColor = onMouseOverFactory ? 'gray.400' : disabledStarColor; + return ( + + { Array(5).fill(null).map((_, index) => ( + = 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) } + /> + )) } + + ); +}; + +export default Stars; diff --git a/ui/marketplace/Rating/TriggerButton.tsx b/ui/marketplace/Rating/TriggerButton.tsx new file mode 100644 index 0000000000..309447a1b8 --- /dev/null +++ b/ui/marketplace/Rating/TriggerButton.tsx @@ -0,0 +1,89 @@ +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.
Only wallets with 5+ transactions are eligible; + } + if (!canRate) { + return <>Brand new wallets cannot leave ratings.
Please connect a wallet with 5 or more transactions on this chain; + } + return <>Ratings come from verified users.
Click here to rate!; +}; + +const TriggerButton = ( + { rating, fullView, isActive, onClick, canRate }: Props, + ref: React.ForwardedRef, +) => { + 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 ( + + + + ); +}; + +export default React.forwardRef(TriggerButton); diff --git a/ui/marketplace/Rating/useRatings.test.tsx b/ui/marketplace/Rating/useRatings.test.tsx new file mode 100644 index 0000000000..ee8d71695d --- /dev/null +++ b/ui/marketplace/Rating/useRatings.test.tsx @@ -0,0 +1,74 @@ +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); +}); diff --git a/ui/marketplace/Rating/useRatings.tsx b/ui/marketplace/Rating/useRatings.tsx new file mode 100644 index 0000000000..7c4ff1aa9f --- /dev/null +++ b/ui/marketplace/Rating/useRatings.tsx @@ -0,0 +1,193 @@ +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['Source'], +) => void; + +function formatRatings(data: Airtable.Records) { + return data.reduce((acc: Record, record) => { + const fields = record.fields as { appId: string | Array; 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>({}); + const [ userRatings, setUserRatings ] = useState>({}); + const [ isRatingLoading, setIsRatingLoading ] = useState(false); + const [ isUserRatingLoading, setIsUserRatingLoading ] = useState(false); + const [ isSending, setIsSending ] = useState(false); + const [ canRate, setCanRate ] = useState(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; + 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['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, + }; +} diff --git a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png index df220dabd8..e917e49cca 100644 Binary files a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png index f2e12cad51..b714324fa3 100644 Binary files a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png and b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png index 925173b47b..11b310da3c 100644 Binary files a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png and b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/marketplace/useMarketplace.tsx b/ui/marketplace/useMarketplace.tsx index c7db6567ab..8e4b793aa1 100644 --- a/ui/marketplace/useMarketplace.tsx +++ b/ui/marketplace/useMarketplace.tsx @@ -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, ]); } diff --git a/ui/marketplace/useMarketplaceApps.tsx b/ui/marketplace/useMarketplaceApps.tsx index f3c952b539..7d6730046c 100644 --- a/ui/marketplace/useMarketplaceApps.tsx +++ b/ui/marketplace/useMarketplaceApps.tsx @@ -1,7 +1,7 @@ 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 | undefined = undefined, isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types + ratings: Record | undefined = undefined, ) { const fetch = useFetch(); const apiFetch = useApiFetch(); @@ -91,20 +92,27 @@ export default function useMarketplaceApps( const [ sorting, setSorting ] = React.useState(); - 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, diff --git a/ui/marketplace/utils.ts b/ui/marketplace/utils.ts index 09cb0ec28f..3f0fb9538b 100644 --- a/ui/marketplace/utils.ts +++ b/ui/marketplace/utils.ts @@ -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> = [ { title: 'Default', id: undefined }, + { title: 'Rating', id: 'rating' }, { title: 'Security score', id: 'security_score' }, ]; diff --git a/ui/pages/Marketplace.pw.tsx b/ui/pages/Marketplace.pw.tsx index b25d67f47c..a8f1ce2f2a 100644 --- a/ui/pages/Marketplace.pw.tsx +++ b/ui/pages/Marketplace.pw.tsx @@ -1,6 +1,7 @@ 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 }) => { diff --git a/ui/pages/Marketplace.tsx b/ui/pages/Marketplace.tsx index 41ff0d59d2..043d175938 100644 --- a/ui/pages/Marketplace.tsx +++ b/ui/pages/Marketplace.tsx @@ -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: () => , - count: null, + title: () => , + 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 } /> ) } diff --git a/ui/pages/MarketplaceApp.pw.tsx b/ui/pages/MarketplaceApp.pw.tsx index 94b0811089..bebab02457 100644 --- a/ui/pages/MarketplaceApp.pw.tsx +++ b/ui/pages/MarketplaceApp.pw.tsx @@ -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[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse }) => { +const testFn: Parameters[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( diff --git a/ui/pages/MarketplaceApp.tsx b/ui/pages/MarketplaceApp.tsx index b1e1893785..78e14ca4e3 100644 --- a/ui/pages/MarketplaceApp.tsx +++ b/ui/pages/MarketplaceApp.tsx @@ -151,6 +151,7 @@ const MarketplaceApp = () => { return ( { content } + { content } ); } diff --git a/ui/shared/Hint.tsx b/ui/shared/Hint.tsx index e7fa1f8590..5aac2b52e5 100644 --- a/ui/shared/Hint.tsx +++ b/ui/shared/Hint.tsx @@ -28,7 +28,7 @@ const Hint = ({ label, className, tooltipProps, isLoading }: Props) => { diff --git a/ui/shared/TruncatedTextTooltip.tsx b/ui/shared/TruncatedTextTooltip.tsx index e1f333d5e1..eaf7dfdd66 100644 --- a/ui/shared/TruncatedTextTooltip.tsx +++ b/ui/shared/TruncatedTextTooltip.tsx @@ -79,7 +79,7 @@ const TruncatedTextTooltip = ({ children, label, placement }: Props) => { return ( diff --git a/ui/shared/entities/address/AddressEntity.tsx b/ui/shared/entities/address/AddressEntity.tsx index 1519571bb8..704285e0ff 100644 --- a/ui/shared/entities/address/AddressEntity.tsx +++ b/ui/shared/entities/address/AddressEntity.tsx @@ -108,7 +108,7 @@ const Content = chakra((props: ContentProps) => { ); return ( - + { nameText } diff --git a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_contract-unverified-1.png b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_contract-unverified-1.png index 5af8e38ece..a18893d2ea 100644 Binary files a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_contract-unverified-1.png and b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_contract-unverified-1.png differ diff --git a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_hover-1.png b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_hover-1.png index 0d77e3eb86..bf2243601a 100644 Binary files a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_hover-1.png and b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_hover-1.png differ diff --git a/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-1.png b/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-1.png index 038508436e..5534d9df03 100644 Binary files a/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-1.png and b/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-1.png differ diff --git a/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-2.png b/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-2.png index d311239584..9d8d7e86d8 100644 Binary files a/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-2.png and b/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-2.png differ diff --git a/ui/shared/forms/FileSnippet.tsx b/ui/shared/forms/FileSnippet.tsx index 172c045ed9..73656e46d8 100644 --- a/ui/shared/forms/FileSnippet.tsx +++ b/ui/shared/forms/FileSnippet.tsx @@ -76,7 +76,7 @@ const FileSnippet = ({ file, className, index, onRemove, isDisabled, error }: Pr diff --git a/ui/snippets/navigation/vertical/NavLink.tsx b/ui/snippets/navigation/vertical/NavLink.tsx index 95b3a3a210..4dfaf67e43 100644 --- a/ui/snippets/navigation/vertical/NavLink.tsx +++ b/ui/snippets/navigation/vertical/NavLink.tsx @@ -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 } > diff --git a/yarn.lock b/yarn.lock index c21c0d2cbf..448bdcc3b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"