Commit 6df4ec6f authored by tom's avatar tom

use next.js api for marketplace

parent c3e2495d
......@@ -3,15 +3,14 @@
"author": "Blockscout",
"id": "token-approval-tracker",
"title": "Token Approval Tracker",
"logo": "https://approval-tracker.vercel.app/icon-192.png",
"logo": "https://approval-tracker.apps.blockscout.com/icon-192.png",
"categories": [
"security",
"tools"
],
"shortDescription": "Token Approval Tracker shows all approvals for any ERC20-compliant tokens and NFTs and lets to revoke them or adjust the approved amount.",
"site": "https://docs.blockscout.com/for-users/blockscout-apps/token-approval-tracker",
"description": "Token Approval Tracker shows all approvals for any ERC20-compliant tokens and NFTs and lets to revoke them or adjust the approved amount.",
"url": "https://approval-tracker.vercel.app/"
"url": "https://approval-tracker.apps.blockscout.com/"
},
{
"author": "Revoke",
......
......@@ -12,16 +12,7 @@ const MAIN_DOMAINS = [
// eslint-disable-next-line no-restricted-properties
const REPORT_URI = process.env.SENTRY_CSP_REPORT_URI;
function getMarketplaceAppsHosts() {
return {
frames: appConfig.marketplaceAppList.map(({ url }) => new URL(url).host),
logos: appConfig.marketplaceAppList.map(({ logo }) => new URL(logo).host),
};
}
export function app(): CspDev.DirectiveDescriptor {
const marketplaceAppsHosts = getMarketplaceAppsHosts();
return {
'default-src': [
KEY_WORDS.NONE,
......@@ -108,7 +99,8 @@ export function app(): CspDev.DirectiveDescriptor {
],
'frame-src': [
...marketplaceAppsHosts.frames,
// improve: allow only frames from marketplace config
'*',
],
...(REPORT_URI && !appConfig.isDev ? {
......
import type { NextApiRequest, NextApiResponse } from 'next';
import config from 'configs/marketplace/eth-goerli.json';
import { httpLogger } from 'lib/api/logger';
export default async function marketplaceAppIdHandler(_req: NextApiRequest, res: NextApiResponse) {
httpLogger(_req, res);
const id = _req.query.id;
const app = config.find(app => app.id === id);
if (!app) {
return res.status(404).json({ error: 'Not found' });
}
res.status(200).json(app);
}
import type { NextApiRequest, NextApiResponse } from 'next';
import config from 'configs/marketplace/eth-goerli.json';
import { httpLogger } from 'lib/api/logger';
export default async function marketplaceIndexHandler(_req: NextApiRequest, res: NextApiResponse) {
httpLogger(_req, res);
res.status(200).json(config);
}
import type { NextPage } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import React from 'react';
import type { AppItemOverview } from 'types/client/apps';
import appConfig from 'configs/app/config';
import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import MarketplaceApp from 'ui/pages/MarketplaceApp';
import Page from 'ui/shared/Page/Page';
const AppPage: NextPage = () => {
const router = useRouter();
const [ isLoading, setIsLoading ] = useState(true);
const [ app, setApp ] = useState<AppItemOverview | undefined>(undefined);
const id = router.query.id;
useEffect(() => {
if (!id) {
return;
}
const app = appConfig.marketplaceAppList.find((app) => app.id === id);
setApp(app);
setIsLoading(false);
}, [ id ]);
if (app || isLoading) {
return (
<>
<Head><title>{ app ? `Blockscout | ${ app.title }` : 'Loading app..' }</title></Head>
<MarketplaceApp app={ app } isLoading={ isLoading }/>
</>
);
}
return (
<Page>
<Head><title>Blockscout | No app found</title></Head>
<EmptySearchResult text={ `Couldn${ apos }t find an app.` }/>
<Page wrapChildren={ false }>
<Head><title>Blockscout | Marketplace</title></Head>
<MarketplaceApp/>
</Page>
);
};
......
......@@ -10,7 +10,7 @@ const AppsPage: NextPage = () => {
return (
<Page>
<PageTitle text="Apps"/>
<Head><title>Apps</title></Head>
<Head><title>Blockscout | Marketplace</title></Head>
<Apps/>
</Page>
......
......@@ -15,6 +15,8 @@ declare module "nextjs-routes" {
| DynamicRoute<"/address/[hash]/contract_verification", { "hash": string }>
| DynamicRoute<"/address/[hash]", { "hash": string }>
| StaticRoute<"/api/csrf">
| DynamicRoute<"/api/marketplace/[id]", { "id": string }>
| StaticRoute<"/api/marketplace">
| StaticRoute<"/api/media-type">
| StaticRoute<"/api/proxy">
| StaticRoute<"/api-docs">
......
import { Grid, GridItem, Heading, VisuallyHidden } from '@chakra-ui/react';
import { Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import type { AppItemPreview } from 'types/client/apps';
......@@ -7,65 +7,44 @@ import { apos } from 'lib/html-entities';
import AppCard from 'ui/apps/AppCard';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import AppModal from './AppModal';
type Props = {
apps: Array<AppItemPreview>;
onAppClick: (id: string) => void;
displayedAppId: string | null;
onModalClose: () => void;
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
}
const AppList = ({ apps, onAppClick, displayedAppId, onModalClose, favoriteApps, onFavoriteClick }: Props) => {
return (
<>
<VisuallyHidden>
<Heading as="h2">App list</Heading>
</VisuallyHidden>
{ apps.length > 0 ? (
<Grid
templateColumns={{
sm: 'repeat(auto-fill, minmax(178px, 1fr))',
lg: 'repeat(auto-fill, minmax(260px, 1fr))',
}}
autoRows="1fr"
gap={{ base: '16px', sm: '24px' }}
const AppList = ({ apps, onAppClick, favoriteApps, onFavoriteClick }: Props) => {
return apps.length > 0 ? (
<Grid
templateColumns={{
sm: 'repeat(auto-fill, minmax(178px, 1fr))',
lg: 'repeat(auto-fill, minmax(260px, 1fr))',
}}
autoRows="1fr"
gap={{ base: '16px', sm: '24px' }}
>
{ apps.map((app) => (
<GridItem
key={ app.id }
>
{ apps.map((app) => (
<GridItem
key={ app.id }
>
<AppCard
onInfoClick={ onAppClick }
id={ app.id }
external={ app.external }
url={ app.url }
title={ app.title }
logo={ app.logo }
shortDescription={ app.shortDescription }
categories={ app.categories }
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick }
/>
</GridItem>
)) }
</Grid>
) : (
<EmptySearchResult text={ `Couldn${ apos }t find an app that matches your filter query.` }/>
) }
{ displayedAppId && (
<AppModal
id={ displayedAppId }
onClose={ onModalClose }
isFavorite={ favoriteApps.includes(displayedAppId) }
onFavoriteClick={ onFavoriteClick }
/>
) }
</>
<AppCard
onInfoClick={ onAppClick }
id={ app.id }
external={ app.external }
url={ app.url }
title={ app.title }
logo={ app.logo }
shortDescription={ app.shortDescription }
categories={ app.categories }
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick }
/>
</GridItem>
)) }
</Grid>
) : (
<EmptySearchResult text={ `Couldn${ apos }t find an app that matches your filter query.` }/>
);
};
......
......@@ -6,7 +6,6 @@ import React, { useCallback } from 'react';
import type { AppItemOverview, MarketplaceCategoriesIds } from 'types/client/apps';
import appConfig from 'configs/app/config';
import linkIcon from 'icons/link.svg';
import ghIcon from 'icons/social/git.svg';
import tgIcon from 'icons/social/telega.svg';
......@@ -20,17 +19,17 @@ import AppModalLink from './AppModalLink';
import { APP_CATEGORIES } from './constants';
type Props = {
id: string;
onClose: () => void;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
data: AppItemOverview;
}
const AppModal = ({
id,
onClose,
isFavorite,
onFavoriteClick,
data,
}: Props) => {
const {
title,
......@@ -44,7 +43,7 @@ const AppModal = ({
twitter,
logo,
categories,
} = appConfig.marketplaceAppList.find(app => app.id === id) as AppItemOverview;
} = data;
const socialLinks = [
telegram ? {
......@@ -62,14 +61,14 @@ const AppModal = ({
].filter(Boolean);
const handleFavoriteClick = useCallback(() => {
onFavoriteClick(id, isFavorite);
}, [ onFavoriteClick, id, isFavorite ]);
onFavoriteClick(data.id, isFavorite);
}, [ onFavoriteClick, data.id, isFavorite ]);
const isMobile = useIsMobile();
return (
<Modal
isOpen={ Boolean(id) }
isOpen={ Boolean(data.id) }
onClose={ onClose }
size={ isMobile ? 'full' : 'md' }
isCentered
......@@ -123,7 +122,7 @@ const AppModal = ({
>
<Box display="flex">
<AppModalLink
id={ id }
id={ data.id }
url={ url }
external={ external }
title={ title }
......
......@@ -3,7 +3,6 @@ import React from 'react';
import type { MarketplaceCategoriesIds, MarketplaceCategory } from 'types/client/apps';
import appConfig from 'configs/app/config';
import eastMiniArrowIcon from 'icons/arrows/east-mini.svg';
import CategoriesMenuItem from './CategoriesMenuItem';
......@@ -15,16 +14,16 @@ const categoriesList = Object.keys(APP_CATEGORIES).map((id: string) => ({
})) as Array<MarketplaceCategory>;
type Props = {
categories: Array<MarketplaceCategoriesIds>;
selectedCategoryId: MarketplaceCategoriesIds;
onSelect: (category: MarketplaceCategoriesIds) => void;
}
const CategoriesMenu = ({ selectedCategoryId, onSelect }: Props) => {
const CategoriesMenu = ({ selectedCategoryId, onSelect, categories }: Props) => {
const selectedCategory = categoriesList.find(category => category.id === selectedCategoryId);
const actualCategories = appConfig.marketplaceAppList.map(app => app.categories).flat();
const displayedCategories = categoriesList.filter(category => category.id === 'all' ||
category.id === 'favorites' ||
actualCategories.includes(category.id));
categories.includes(category.id));
return (
<Menu>
......
import debounce from 'lodash/debounce';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useEffect, useState } from 'react';
import type { AppItemOverview, MarketplaceCategoriesIds } from 'types/client/apps';
import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import useDebounce from 'lib/hooks/useDebounce';
import useApiFetch from 'lib/hooks/useFetch';
const favoriteAppsLocalStorageKey = 'favoriteApps';
......@@ -26,14 +28,19 @@ function isAppCategoryMatches(category: MarketplaceCategoriesIds, app: AppItemOv
}
export default function useMarketplaceApps() {
const [ isLoading, setIsLoading ] = useState(true);
const [ defaultAppList, setDefaultAppList ] = useState<Array<AppItemOverview>>();
const [ displayedApps, setDisplayedApps ] = useState<Array<AppItemOverview>>([]);
const [ displayedAppId, setDisplayedAppId ] = useState<string | null>(null);
const [ category, setCategory ] = useState<MarketplaceCategoriesIds>('all');
const [ selectedAppId, setSelectedAppId ] = useState<string | null>(null);
const [ selectedCategoryId, setSelectedCategoryId ] = useState<MarketplaceCategoriesIds>('all');
const [ filterQuery, setFilterQuery ] = useState('');
const [ favoriteApps, setFavoriteApps ] = useState<Array<string>>([]);
const apiFetch = useApiFetch();
const { isLoading, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<AppItemOverview>>(
[ 'marketplace-apps' ],
async() => apiFetch('/node-api/marketplace'),
{
select: (data) => (data as Array<AppItemOverview>).sort((a, b) => a.title.localeCompare(b.title)),
});
const handleFavoriteClick = useCallback((id: string, isFavorite: boolean) => {
const favoriteApps = getFavoriteApps();
......@@ -49,61 +56,53 @@ export default function useMarketplaceApps() {
}, [ ]);
const showAppInfo = useCallback((id: string) => {
setDisplayedAppId(id);
setSelectedAppId(id);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debounceFilterApps = useCallback(debounce(q => setFilterQuery(q), 500), []);
const clearDisplayedAppId = useCallback(() => setDisplayedAppId(null), []);
const filterApps = useCallback((q: string, category: MarketplaceCategoriesIds) => {
const apps = defaultAppList
?.filter(app => {
return isAppNameMatches(q, app) && isAppCategoryMatches(category, app, favoriteApps);
});
setDisplayedApps(apps || []);
}, [ defaultAppList, favoriteApps ]);
const debouncedFilterQuery = useDebounce(filterQuery, 500);
const clearSelectedAppId = useCallback(() => setSelectedAppId(null), []);
const handleCategoryChange = useCallback((newCategory: MarketplaceCategoriesIds) => {
setCategory(newCategory);
setSelectedCategoryId(newCategory);
}, []);
useEffect(() => {
setFavoriteApps(getFavoriteApps());
}, [ ]);
const displayedApps = React.useMemo(() => {
return data?.filter(app => isAppNameMatches(debouncedFilterQuery, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps)) || [];
}, [ selectedCategoryId, data, debouncedFilterQuery, favoriteApps ]);
useEffect(() => {
filterApps(filterQuery, category);
}, [ filterQuery, category, filterApps ]);
const categories = React.useMemo(() => {
return data?.map(app => app.categories).flat() || [];
}, [ data ]);
useEffect(() => {
const defaultDisplayedApps = [ ...appConfig.marketplaceAppList ]
.sort((a, b) => a.title.localeCompare(b.title));
setDefaultAppList(defaultDisplayedApps);
setDisplayedApps(defaultDisplayedApps);
setIsLoading(false);
setFavoriteApps(getFavoriteApps());
}, [ ]);
return React.useMemo(() => ({
category,
handleCategoryChange,
debounceFilterApps,
selectedCategoryId,
onCategoryChange: handleCategoryChange,
onSearchInputChange: setFilterQuery,
isLoading,
isError,
error,
categories,
displayedApps,
showAppInfo,
displayedAppId,
clearDisplayedAppId,
selectedAppId,
clearSelectedAppId,
favoriteApps,
handleFavoriteClick,
}), [ category,
clearDisplayedAppId,
debounceFilterApps,
displayedAppId, displayedApps,
onFavoriteClick: handleFavoriteClick,
}), [
selectedCategoryId,
categories,
clearSelectedAppId,
selectedAppId,
displayedApps,
error,
favoriteApps,
handleCategoryChange,
handleFavoriteClick,
isError,
isLoading,
showAppInfo,
]);
......
......@@ -5,6 +5,7 @@ import config from 'configs/app/config';
import PlusIcon from 'icons/plus.svg';
import AppList from 'ui/apps/AppList';
import AppListSkeleton from 'ui/apps/AppListSkeleton';
import AppModal from 'ui/apps/AppModal';
import CategoriesMenu from 'ui/apps/CategoriesMenu';
import FilterInput from 'ui/shared/filters/FilterInput';
......@@ -13,17 +14,26 @@ import useMarketplaceApps from '../apps/useMarketplaceApps';
const Apps = () => {
const {
isLoading,
category,
handleCategoryChange,
debounceFilterApps,
isError,
error,
selectedCategoryId,
categories,
onCategoryChange,
onSearchInputChange,
showAppInfo,
displayedApps,
displayedAppId,
clearDisplayedAppId,
selectedAppId,
clearSelectedAppId,
favoriteApps,
handleFavoriteClick,
onFavoriteClick,
} = useMarketplaceApps();
if (isError) {
throw new Error('Unable to get apps list', { cause: error });
}
const selectedApp = displayedApps.find(app => app.id === selectedAppId);
return (
<>
<Box
......@@ -31,21 +41,29 @@ const Apps = () => {
flexDirection={{ base: 'column', sm: 'row' }}
>
<CategoriesMenu
selectedCategoryId={ category }
onSelect={ handleCategoryChange }
categories={ categories }
selectedCategoryId={ selectedCategoryId }
onSelect={ onCategoryChange }
/>
<FilterInput onChange={ debounceFilterApps } marginBottom={{ base: '4', lg: '6' }} placeholder="Find app"/>
<FilterInput onChange={ onSearchInputChange } marginBottom={{ base: '4', lg: '6' }} placeholder="Find app"/>
</Box>
{ isLoading ? <AppListSkeleton/> : (
<AppList
apps={ displayedApps }
onAppClick={ showAppInfo }
displayedAppId={ displayedAppId }
onModalClose={ clearDisplayedAppId }
favoriteApps={ favoriteApps }
onFavoriteClick={ handleFavoriteClick }
onFavoriteClick={ onFavoriteClick }
/>
) }
{ selectedApp && (
<AppModal
onClose={ clearSelectedAppId }
isFavorite={ favoriteApps.includes(selectedApp.id) }
onFavoriteClick={ onFavoriteClick }
data={ selectedApp }
/>
) }
......
import { Box, Center, useColorMode } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import { route } from 'nextjs-routes';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { AppItemOverview } from 'types/client/apps';
import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/hooks/useFetch';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader';
import Page from 'ui/shared/Page/Page';
type Props = {
app?: AppItemOverview;
isLoading: boolean;
}
const IFRAME_SANDBOX_ATTRIBUTE = 'allow-forms allow-orientation-lock ' +
'allow-pointer-lock allow-popups-to-escape-sandbox ' +
'allow-same-origin allow-scripts ' +
'allow-top-navigation-by-user-activation allow-popups';
const IFRAME_ALLOW_ATTRIBUTE = 'clipboard-read; clipboard-write;';
const MarketplaceApp = () => {
const ref = useRef<HTMLIFrameElement>(null);
const apiFetch = useApiFetch();
const router = useRouter();
const id = getQueryParamString(router.query.id);
const { isLoading, isError, error, data } = useQuery<unknown, ResourceError<unknown>, AppItemOverview>(
[ 'marketplace-app', { id } ],
async() => apiFetch(`/node-api/marketplace/${ id }`),
);
const MarketplaceApp = ({ app, isLoading }: Props) => {
const [ isFrameLoading, setIsFrameLoading ] = useState(isLoading);
const { colorMode } = useColorMode();
const ref = useRef<HTMLIFrameElement>(null);
const handleIframeLoad = useCallback(() => {
setIsFrameLoading(false);
}, []);
useEffect(() => {
if (app && !isFrameLoading) {
if (data && !isFrameLoading) {
const message = {
blockscoutColorMode: colorMode,
blockscoutRootUrl: appConfig.baseUrl + route({ pathname: '/' }),
......@@ -35,45 +51,40 @@ const MarketplaceApp = ({ app, isLoading }: Props) => {
blockscoutNetworkRpc: appConfig.network.rpcUrl,
};
ref?.current?.contentWindow?.postMessage(message, app.url);
ref?.current?.contentWindow?.postMessage(message, data.url);
}
}, [ isFrameLoading, app, colorMode, ref ]);
const sandboxAttributeValue = 'allow-forms allow-orientation-lock ' +
'allow-pointer-lock allow-popups-to-escape-sandbox ' +
'allow-same-origin allow-scripts ' +
'allow-top-navigation-by-user-activation allow-popups';
}, [ isFrameLoading, data, colorMode, ref ]);
const allowAttributeValue = 'clipboard-read; clipboard-write;';
if (isError) {
throw new Error('Unable to load app', { cause: error });
}
return (
<Page wrapChildren={ false }>
<Center
as="main"
h="100vh"
pt={{ base: '138px', lg: 0 }}
pb={{ base: 0, lg: 10 }}
>
{ (isFrameLoading) && (
<ContentLoader/>
) }
<Center
as="main"
h="100vh"
pt={{ base: '138px', lg: 0 }}
pb={{ base: 0, lg: 10 }}
>
{ (isFrameLoading) && (
<ContentLoader/>
) }
{ app && (
<Box
allow={ allowAttributeValue }
ref={ ref }
sandbox={ sandboxAttributeValue }
as="iframe"
h="100%"
w="100%"
display={ isFrameLoading ? 'none' : 'block' }
src={ app.url }
title={ app.title }
onLoad={ handleIframeLoad }
/>
) }
</Center>
</Page>
{ data && (
<Box
allow={ IFRAME_ALLOW_ATTRIBUTE }
ref={ ref }
sandbox={ IFRAME_SANDBOX_ATTRIBUTE }
as="iframe"
h="100%"
w="100%"
display={ isFrameLoading ? 'none' : 'block' }
src={ data.url }
title={ data.title }
onLoad={ handleIframeLoad }
/>
) }
</Center>
);
};
......
......@@ -33,13 +33,9 @@ const Page = ({
const statusCode = getErrorStatusCode(error) || 500;
const isInvalidTxHash = error?.message.includes('Invalid tx hash');
if (wrapChildren) {
const content = isInvalidTxHash ? <ErrorInvalidTxHash/> : <AppError statusCode={ statusCode } mt="50px"/>;
return <PageContent isHomePage={ isHomePage }>{ content }</PageContent>;
}
return isInvalidTxHash ? <ErrorInvalidTxHash/> : <AppError statusCode={ statusCode }/>;
}, [ isHomePage, wrapChildren ]);
const content = isInvalidTxHash ? <ErrorInvalidTxHash/> : <AppError statusCode={ statusCode } mt="50px"/>;
return <PageContent isHomePage={ isHomePage }>{ content }</PageContent>;
}, [ isHomePage ]);
const renderedChildren = wrapChildren ? (
<PageContent isHomePage={ isHomePage }>{ children }</PageContent>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment