Graph integration
@@ -16,6 +16,7 @@ const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNE
|
||||
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 graphLinksUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL');
|
||||
|
||||
const title = 'Marketplace';
|
||||
|
||||
@@ -30,6 +31,7 @@ const config: Feature<(
|
||||
featuredApp: string | undefined;
|
||||
banner: { contentUrl: string; linkUrl: string } | undefined;
|
||||
rating: { airtableApiKey: string; airtableBaseId: string } | undefined;
|
||||
graphLinksUrl: string | undefined;
|
||||
}> = (() => {
|
||||
if (enabled === 'true' && chain.rpcUrl && submitFormUrl) {
|
||||
const props = {
|
||||
@@ -46,6 +48,7 @@ const config: Feature<(
|
||||
airtableApiKey: ratingAirtableApiKey,
|
||||
airtableBaseId: ratingAirtableBaseId,
|
||||
} : undefined,
|
||||
graphLinksUrl,
|
||||
};
|
||||
|
||||
if (configUrl) {
|
||||
|
||||
@@ -42,6 +42,7 @@ NEXT_PUBLIC_MARKETPLACE_ENABLED=true
|
||||
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_GRAPH_LINKS_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/refs/heads/marketplace-graph-test/test-configs/marketplace-graph-links.json
|
||||
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'}
|
||||
|
||||
@@ -18,6 +18,7 @@ ASSETS_ENVS=(
|
||||
"NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL"
|
||||
"NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL"
|
||||
"NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL"
|
||||
"NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL"
|
||||
"NEXT_PUBLIC_FEATURED_NETWORKS"
|
||||
"NEXT_PUBLIC_FOOTER_LINKS"
|
||||
"NEXT_PUBLIC_NETWORK_LOGO"
|
||||
|
||||
@@ -39,6 +39,7 @@ async function validateEnvs(appEnvs: Record<string, string>) {
|
||||
'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL',
|
||||
'NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL',
|
||||
'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL',
|
||||
'NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL',
|
||||
'NEXT_PUBLIC_FOOTER_LINKS',
|
||||
];
|
||||
|
||||
|
||||
@@ -243,6 +243,14 @@ const marketplaceSchema = yup
|
||||
// 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'),
|
||||
}),
|
||||
NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL: 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_GRAPH_LINKS_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
|
||||
}),
|
||||
});
|
||||
|
||||
const beaconChainSchema = yup
|
||||
|
||||
@@ -505,6 +505,7 @@ This feature is **always enabled**, but you can disable it by passing `none` val
|
||||
| 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+ |
|
||||
| NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL | `string` | URL of the file (`.json` format only) which contains the list of The Graph links to be displayed on the Marketplace page | - | - | `https://example.com/graph_links.json` | v1.36.0+ |
|
||||
|
||||
#### Marketplace app configuration properties
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path fill="#6747ED" d="M10 20c5.523 0 10-4.477 10-10S15.523 0 10 0 0 4.477 0 10s4.477 10 10 10"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M9.854 11.292a2.66 2.66 0 0 1-2.666-2.667 2.66 2.66 0 0 1 2.666-2.667 2.66 2.66 0 0 1 2.667 2.667 2.66 2.66 0 0 1-2.667 2.667m0-6.667a4.001 4.001 0 0 1 0 8 4.001 4.001 0 0 1 0-8m3.813 8.208c.27.271.27.688 0 .938L11 16.437c-.27.271-.687.271-.937 0-.271-.27-.271-.687 0-.937l2.666-2.667c.25-.27.688-.27.938 0m1.541-7.541a.66.66 0 0 1-.666.666.66.66 0 0 1-.667-.666c0-.375.292-.667.667-.667.354 0 .666.292.666.667" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 658 B |
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import config from 'configs/app';
|
||||
import type { ResourceError } from 'lib/api/resources';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
|
||||
const feature = config.features.marketplace;
|
||||
|
||||
export default function useGraphLinks() {
|
||||
const fetch = useFetch();
|
||||
|
||||
return useQuery<unknown, ResourceError<unknown>, Record<string, Array<{text: string; url: string}>>>({
|
||||
queryKey: [ 'graph-links' ],
|
||||
queryFn: async() => fetch((feature.isEnabled && feature.graphLinksUrl) ? feature.graphLinksUrl : '', undefined, { resource: 'graph-links' }),
|
||||
enabled: feature.isEnabled && Boolean(feature.graphLinksUrl),
|
||||
staleTime: Infinity,
|
||||
placeholderData: {},
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
| "block"
|
||||
| "brands/blockscout"
|
||||
| "brands/celenium"
|
||||
| "brands/graph"
|
||||
| "brands/safe"
|
||||
| "brands/solidity_scan"
|
||||
| "burger"
|
||||
|
||||
@@ -11,6 +11,7 @@ import CopyToClipboard from 'ui/shared/CopyToClipboard';
|
||||
import AppSecurityReport from './AppSecurityReport';
|
||||
import FavoriteIcon from './FavoriteIcon';
|
||||
import MarketplaceAppCardLink from './MarketplaceAppCardLink';
|
||||
import MarketplaceAppGraphLinks from './MarketplaceAppGraphLinks';
|
||||
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
|
||||
import Rating from './Rating/Rating';
|
||||
import type { RateFunction } from './Rating/useRatings';
|
||||
@@ -28,6 +29,7 @@ interface Props extends MarketplaceAppWithSecurityReport {
|
||||
isRatingSending: boolean;
|
||||
isRatingLoading: boolean;
|
||||
canRate: boolean | undefined;
|
||||
graphLinks: Array<{text: string; url: string}>;
|
||||
}
|
||||
|
||||
const MarketplaceAppCard = ({
|
||||
@@ -54,6 +56,7 @@ const MarketplaceAppCard = ({
|
||||
isRatingSending,
|
||||
isRatingLoading,
|
||||
canRate,
|
||||
graphLinks,
|
||||
}: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
const categoriesLabel = categories.join(', ');
|
||||
@@ -118,11 +121,7 @@ const MarketplaceAppCard = ({
|
||||
>
|
||||
<Skeleton
|
||||
isLoaded={ !isLoading }
|
||||
fontSize={{ base: 'sm', md: 'lg' }}
|
||||
lineHeight={{ base: '20px', md: '28px' }}
|
||||
paddingRight={{ base: '40px', md: 0 }}
|
||||
fontWeight="semibold"
|
||||
fontFamily="heading"
|
||||
display="inline-block"
|
||||
>
|
||||
<MarketplaceAppCardLink
|
||||
@@ -131,8 +130,18 @@ const MarketplaceAppCard = ({
|
||||
external={ external }
|
||||
title={ title }
|
||||
onClick={ onAppClick }
|
||||
fontWeight="semibold"
|
||||
fontFamily="heading"
|
||||
fontSize={{ base: 'sm', md: 'lg' }}
|
||||
lineHeight={{ base: '20px', md: '28px' }}
|
||||
/>
|
||||
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
|
||||
<MarketplaceAppGraphLinks
|
||||
links={ graphLinks }
|
||||
ml={ 2 }
|
||||
verticalAlign="middle"
|
||||
mb={{ base: 0, md: 1 }}
|
||||
/>
|
||||
</Skeleton>
|
||||
|
||||
<Skeleton
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LinkOverlay } from '@chakra-ui/react';
|
||||
import { LinkOverlay, chakra } from '@chakra-ui/react';
|
||||
import NextLink from 'next/link';
|
||||
import React from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
@@ -9,24 +9,25 @@ type Props = {
|
||||
external?: boolean;
|
||||
title: string;
|
||||
onClick?: (event: MouseEvent, id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MarketplaceAppCardLink = ({ url, external, id, title, onClick }: Props) => {
|
||||
const MarketplaceAppCardLink = ({ url, external, id, title, onClick, className }: Props) => {
|
||||
const handleClick = React.useCallback((event: MouseEvent) => {
|
||||
onClick?.(event, id);
|
||||
}, [ onClick, id ]);
|
||||
|
||||
return external ? (
|
||||
<LinkOverlay href={ url } isExternal={ true } marginRight={ 2 }>
|
||||
<LinkOverlay href={ url } isExternal={ true } marginRight={ 2 } className={ className }>
|
||||
{ title }
|
||||
</LinkOverlay>
|
||||
) : (
|
||||
<NextLink href={{ pathname: '/apps/[id]', query: { id } }} passHref legacyBehavior>
|
||||
<LinkOverlay onClick={ handleClick } marginRight={ 2 }>
|
||||
<LinkOverlay onClick={ handleClick } marginRight={ 2 } className={ className }>
|
||||
{ title }
|
||||
</LinkOverlay>
|
||||
</NextLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketplaceAppCardLink;
|
||||
export default chakra(MarketplaceAppCardLink);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Text,
|
||||
PopoverTrigger,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
chakra,
|
||||
Box,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import Popover from 'ui/shared/chakra/Popover';
|
||||
import IconSvg from 'ui/shared/IconSvg';
|
||||
import LinkExternal from 'ui/shared/links/LinkExternal';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
links?: Array<{ title: string; url: string }>;
|
||||
}
|
||||
|
||||
const MarketplaceAppGraphLinks = ({ className, links }: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleButtonClick = React.useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
if (!links || links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box position="relative" className={ className } display="inline-flex" alignItems="center" height={ 7 } onClick={ handleButtonClick }>
|
||||
<Popover
|
||||
placement={ isMobile ? 'bottom-end' : 'bottom' }
|
||||
isLazy
|
||||
trigger="hover"
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<IconSvg name="brands/graph" boxSize={ 5 } onClick={ handleButtonClick }/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w="260px">
|
||||
<PopoverBody fontSize="sm">
|
||||
<VStack gap={ 4 } align="start">
|
||||
<Text>{ `This dapp uses ${ links.length > 1 ? 'several subgraphs' : 'a subgraph' } provided by The Graph` }</Text>
|
||||
{ links.map(link => (
|
||||
<LinkExternal key={ link.url } href={ link.url }>{ link.title }</LinkExternal>
|
||||
)) }
|
||||
</VStack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(chakra(MarketplaceAppGraphLinks));
|
||||
@@ -18,6 +18,8 @@ import IconSvg from 'ui/shared/IconSvg';
|
||||
|
||||
import AppSecurityReport from './AppSecurityReport';
|
||||
import FavoriteIcon from './FavoriteIcon';
|
||||
import MarketplaceAppGraphLinks from './MarketplaceAppGraphLinks';
|
||||
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
|
||||
import MarketplaceAppModalLink from './MarketplaceAppModalLink';
|
||||
import Rating from './Rating/Rating';
|
||||
import type { RateFunction } from './Rating/useRatings';
|
||||
@@ -36,6 +38,7 @@ type Props = {
|
||||
isRatingSending: boolean;
|
||||
isRatingLoading: boolean;
|
||||
canRate: boolean | undefined;
|
||||
graphLinks?: Array<{text: string; url: string}>;
|
||||
}
|
||||
|
||||
const MarketplaceAppModal = ({
|
||||
@@ -49,6 +52,7 @@ const MarketplaceAppModal = ({
|
||||
isRatingSending,
|
||||
isRatingLoading,
|
||||
canRate,
|
||||
graphLinks,
|
||||
}: Props) => {
|
||||
const {
|
||||
id,
|
||||
@@ -67,6 +71,7 @@ const MarketplaceAppModal = ({
|
||||
categories,
|
||||
securityReport,
|
||||
rating,
|
||||
internalWallet,
|
||||
} = data;
|
||||
|
||||
const socialLinks = [
|
||||
@@ -148,16 +153,19 @@ const MarketplaceAppModal = ({
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Heading
|
||||
as="h2"
|
||||
gridColumn={ 2 }
|
||||
fontSize={{ base: '2xl', md: '32px' }}
|
||||
fontWeight="medium"
|
||||
lineHeight={{ md: 10 }}
|
||||
mb={{ md: 2 }}
|
||||
>
|
||||
{ title }
|
||||
</Heading>
|
||||
<Flex alignItems="center" mb={{ md: 2 }} gridColumn={ 2 }>
|
||||
<Heading
|
||||
as="h2"
|
||||
fontSize={{ base: '2xl', md: '32px' }}
|
||||
fontWeight="medium"
|
||||
lineHeight={{ md: 10 }}
|
||||
mr={ 2 }
|
||||
>
|
||||
{ title }
|
||||
</Heading>
|
||||
<MarketplaceAppIntegrationIcon external={ external } internalWallet={ internalWallet }/>
|
||||
<MarketplaceAppGraphLinks links={ graphLinks } ml={ 2 }/>
|
||||
</Flex>
|
||||
|
||||
<Text
|
||||
variant="secondary"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Grid, Box } from '@chakra-ui/react';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import React, { useCallback } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
@@ -25,11 +26,13 @@ type Props = {
|
||||
isRatingSending: boolean;
|
||||
isRatingLoading: boolean;
|
||||
canRate: boolean | undefined;
|
||||
graphLinksQuery: UseQueryResult<Record<string, Array<{text: string; url: string}>>, unknown>;
|
||||
}
|
||||
|
||||
const MarketplaceList = ({
|
||||
apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId,
|
||||
onAppClick, showContractList, userRatings, rateApp, isRatingSending, isRatingLoading, canRate,
|
||||
graphLinksQuery,
|
||||
}: Props) => {
|
||||
const { cutRef, renderedItemsNum } = useLazyRenderedList(apps, !isLoading, 16);
|
||||
|
||||
@@ -75,6 +78,7 @@ const MarketplaceList = ({
|
||||
isRatingSending={ isRatingSending }
|
||||
isRatingLoading={ isRatingLoading }
|
||||
canRate={ canRate }
|
||||
graphLinks={ graphLinksQuery.data?.[app.id] }
|
||||
/>
|
||||
)) }
|
||||
</Grid>
|
||||
|
||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 37 KiB |
@@ -7,6 +7,7 @@ import type { TabItem } from 'ui/shared/Tabs/types';
|
||||
|
||||
import config from 'configs/app';
|
||||
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
|
||||
import useGraphLinks from 'lib/hooks/useGraphLinks';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import Banner from 'ui/marketplace/Banner';
|
||||
import ContractListModal from 'ui/marketplace/ContractListModal';
|
||||
@@ -80,6 +81,8 @@ const Marketplace = () => {
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const graphLinksQuery = useGraphLinks();
|
||||
|
||||
const categoryTabs = React.useMemo(() => {
|
||||
const tabs: Array<TabItem> = categories.map(category => ({
|
||||
id: category.name,
|
||||
@@ -236,6 +239,7 @@ const Marketplace = () => {
|
||||
isRatingSending={ isRatingSending }
|
||||
isRatingLoading={ isRatingLoading }
|
||||
canRate={ canRate }
|
||||
graphLinksQuery={ graphLinksQuery }
|
||||
/>
|
||||
|
||||
{ (selectedApp && isAppInfoModalOpen) && (
|
||||
@@ -250,6 +254,7 @@ const Marketplace = () => {
|
||||
isRatingSending={ isRatingSending }
|
||||
isRatingLoading={ isRatingLoading }
|
||||
canRate={ canRate }
|
||||
graphLinks={ graphLinksQuery.data?.[selectedApp.id] }
|
||||
/>
|
||||
) }
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 78 KiB |