Commit 36524cc0 authored by Yuri Mikhin's avatar Yuri Mikhin Committed by Yuri Mikhin

Add marketplace app iframe integration.

Signed-off-by: default avatarYuri Mikhin <urasergeevich@mail.ru>
parent dd09f361
This diff is collapsed.
This diff is collapsed.
declare module 'react-identicons'
declare module 'data/marketplaceApps.json' {
import type { AppItemOverview } from './types/client/apps';
const value: Array<AppItemOverview>;
export default value;
}
const getMarketplaceApps = require('../getMarketplaceApps');
const parseNetworkConfig = require('../networks/parseNetworkConfig');
const KEY_WORDS = {
......@@ -23,6 +24,14 @@ function getNetworksExternalAssets() {
return icons;
}
function getMarketplaceAppsOrigins() {
return getMarketplaceApps().map(({ url }) => url);
}
function getMarketplaceAppsLogosOrigins() {
return getMarketplaceApps().map(({ logo }) => logo);
}
function makePolicyMap() {
const networkExternalAssets = getNetworksExternalAssets();
......@@ -80,6 +89,9 @@ function makePolicyMap() {
// network assets
...networkExternalAssets.map((url) => url.host),
// marketplace apps logos
...getMarketplaceAppsLogosOrigins(),
],
'font-src': [
......@@ -101,6 +113,8 @@ function makePolicyMap() {
'report-uri': [
process.env.SENTRY_CSP_REPORT_URI,
],
'frame-src': getMarketplaceAppsOrigins(),
};
}
......
// should be CommonJS module since it used for next.config.js
const data = require('../data/marketplaceApps.json');
function getMarketplaceApps() {
return data;
}
module.exports = getMarketplaceApps;
import React from 'react';
import React, { useMemo } from 'react';
import marketplaceApps from 'data/marketplaceApps.json';
import abiIcon from 'icons/ABI.svg';
import apiKeysIcon from 'icons/API.svg';
import appsIcon from 'icons/apps.svg';
......@@ -13,8 +14,18 @@ import transactionsIcon from 'icons/transactions.svg';
import watchlistIcon from 'icons/watchlist.svg';
import useCurrentRoute from 'lib/link/useCurrentRoute';
import useLink from 'lib/link/useLink';
import notEmpty from 'lib/notEmpty';
import useNetwork from './useNetwork';
export default function useNavItems() {
const selectedNetwork = useNetwork();
const isMarketplaceFilled = useMemo(() =>
marketplaceApps.filter(item => item.chainId === selectedNetwork?.chainId),
[ selectedNetwork?.chainId ])
.length > 0;
const link = useLink();
const currentRoute = useCurrentRoute()();
......@@ -23,12 +34,13 @@ export default function useNavItems() {
{ text: 'Blocks', url: link('blocks'), icon: blocksIcon, isActive: currentRoute.startsWith('block') },
{ text: 'Transactions', url: link('txs_validated'), icon: transactionsIcon, isActive: currentRoute.startsWith('tx') },
{ text: 'Tokens', url: link('tokens'), icon: tokensIcon, isActive: currentRoute === 'tokens' },
{ text: 'Apps', url: link('apps'), icon: appsIcon, isActive: currentRoute === 'apps' },
isMarketplaceFilled ?
{ text: 'Apps', url: link('apps'), icon: appsIcon, isActive: currentRoute === 'apps' } : null,
// there should be custom site sections like Stats, Faucet, More, etc but never an 'other'
// examples https://explorer-edgenet.polygon.technology/ and https://explorer.celo.org/
// at this stage custom menu items is under development, we will implement it later
// { text: 'Other', url: link('other'), icon: gearIcon, isActive: currentRoute === 'other' },
];
].filter(notEmpty);
const accountNavItems = [
{ text: 'Watchlist', url: link('watchlist'), icon: watchlistIcon, isActive: currentRoute === 'watchlist' },
......@@ -41,5 +53,5 @@ export default function useNavItems() {
const profileItem = { text: 'My profile', url: link('profile'), icon: profileIcon, isActive: currentRoute === 'profile' };
return { mainNavItems, accountNavItems, profileItem };
}, [ link, currentRoute ]);
}, [ isMarketplaceFilled, link, currentRoute ]);
}
......@@ -109,6 +109,9 @@ export const ROUTES = {
apps: {
pattern: `${ BASE_PATH }/apps`,
},
app_index: {
pattern: `${ BASE_PATH }/apps/[id]`,
},
// SEARCH
search_results: {
......
export default function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
return value !== null && value !== undefined;
}
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import App from 'ui/pages/App';
import type { AppItemOverview } from 'types/client/apps';
import marketplaceApps from 'data/marketplaceApps.json';
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 }: { id?: string } = router.query;
useEffect(() => {
if (!id) {
return;
}
const app = marketplaceApps.find((app) => app.id === id);
setApp(app);
setIsLoading(false);
}, [ id ]);
if (app || isLoading) {
return (
<>
<Head><title>App Card Page</title></Head>
<App/>
<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>
);
};
export default AppPage;
......
export type AppCategory = {
id: string;
name: string;
export enum MarketplaceCategoryNames {
'defi',
'exchanges',
'finance',
'games',
'marketplaces',
'nft',
'security',
'social',
'tools',
'yieldFarming',
}
export type AppItemPreview = {
......@@ -8,10 +16,11 @@ export type AppItemPreview = {
title: string;
logo: string;
shortDescription: string;
categories: Array<AppCategory>;
categories: Array<keyof typeof MarketplaceCategoryNames>;
}
export type AppItemOverview = AppItemPreview & {
chainId: number;
author: string;
url: string;
description: string;
......
import { Box, Heading, Icon, IconButton, Image, Link, LinkBox, LinkOverlay, Text, useColorModeValue } from '@chakra-ui/react';
import NextLink from 'next/link';
import type { MouseEvent } from 'react';
import React, { useCallback } from 'react';
......@@ -7,6 +8,10 @@ import type { AppItemPreview } from 'types/client/apps';
import northEastIcon from 'icons/arrows/north-east.svg';
import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import useLink from 'lib/link/useLink';
import notEmpty from 'lib/notEmpty';
import { APP_CATEGORIES } from './constants';
interface Props extends AppItemPreview {
onInfoClick: (id: string) => void;
......@@ -24,7 +29,7 @@ const AppCard = ({ id,
onFavoriteClick,
}: Props) => {
const categoriesLabel = categories.map(c => c.name).join(', ');
const categoriesLabel = categories.map(c => APP_CATEGORIES[c]).filter(notEmpty).join(', ');
const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault();
......@@ -35,6 +40,8 @@ const AppCard = ({ id,
onFavoriteClick(id, isFavorite);
}, [ onFavoriteClick, id, isFavorite ]);
const link = useLink();
return (
<LinkBox
_hover={{
......@@ -76,11 +83,11 @@ const AppCard = ({ id,
fontSize={{ base: 'sm', sm: 'lg' }}
fontWeight="semibold"
>
<LinkOverlay
href="#"
>
<NextLink href={ link('app_index', { id: id }) } passHref>
<LinkOverlay>
{ title }
</LinkOverlay>
</NextLink>
</Heading>
<Text
......
import { Box, Heading, Skeleton, SkeletonCircle, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
export const AppCardSkeleton = () => {
return (
<Box
borderRadius="md"
height="100%"
padding={{ base: 3, sm: '20px' }}
border="1px"
borderColor={ useColorModeValue('gray.200', 'gray.600') }
>
<Box
display={{ base: 'grid', sm: 'block' }}
gridTemplateColumns={{ base: '64px 1fr', sm: '1fr' }}
gridTemplateRows={{ base: '20px 20px auto', sm: 'none' }}
gridRowGap={{ base: 2, sm: 'none' }}
gridColumnGap={{ base: 4, sm: 'none' }}
height="100%"
>
<Box
gridRow={{ base: '1 / 4', sm: 'auto' }}
marginBottom={ 4 }
w={{ base: '64px', sm: '96px' }}
h={{ base: '64px', sm: '96px' }}
>
<SkeletonCircle w="100%" h="100%"/>
</Box>
<Heading
gridColumn={{ base: 2, sm: 'auto' }}
marginBottom={ 2 }
>
<Skeleton h={ 4 } w="50%"/>
</Heading>
<Box>
<Skeleton h={ 4 } mb={ 1 }/>
<Skeleton h={ 4 } mb={ 1 }/>
<Skeleton h={ 4 } w="50%"/>
</Box>
</Box>
</Box>
);
};
import { Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import { AppCardSkeleton } from 'ui/apps/AppCardSkeleton';
const applicationStubs = [ ...Array(12) ];
export const AppListSkeleton = () => {
return (
<Grid
templateColumns={{
sm: 'repeat(auto-fill, minmax(170px, 1fr))',
lg: 'repeat(auto-fill, minmax(260px, 1fr))',
}}
autoRows="1fr"
gap={{ base: '16px', sm: '24px' }}
>
{ applicationStubs.map((app, index) => (
<GridItem
key={ index }
>
<AppCardSkeleton/>
</GridItem>
)) }
</Grid>
);
};
......@@ -2,12 +2,12 @@ import {
Box, Button, Heading, Icon, IconButton, Image, Link, List, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Tag, Text,
} from '@chakra-ui/react';
import type { FunctionComponent } from 'react';
import NextLink from 'next/link';
import React, { useCallback } from 'react';
import type { AppCategory, AppItemOverview } from 'types/client/apps';
import type { AppItemOverview, MarketplaceCategoryNames } from 'types/client/apps';
import { TEMPORARY_DEMO_APPS } from 'data/apps';
import marketplaceApps from 'data/marketplaceApps.json';
import linkIcon from 'icons/link.svg';
import ghIcon from 'icons/social/git.svg';
import tgIcon from 'icons/social/telega.svg';
......@@ -15,6 +15,10 @@ import twIcon from 'icons/social/tweet.svg';
import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import { nbsp } from 'lib/html-entities';
import useLink from 'lib/link/useLink';
import notEmpty from 'lib/notEmpty';
import { APP_CATEGORIES } from './constants';
type Props = {
id: string;
......@@ -33,29 +37,30 @@ const AppModal = ({
title,
author,
description,
url,
site,
github,
telegram,
twitter,
logo,
categories,
} = TEMPORARY_DEMO_APPS.find(app => app.id === id) as AppItemOverview;
} = marketplaceApps.find(app => app.id === id) as AppItemOverview;
const link = useLink();
const socialLinks = [
Boolean(telegram) && {
telegram ? {
icon: tgIcon,
url: telegram,
},
Boolean(twitter) && {
} : null,
twitter ? {
icon: twIcon,
url: twitter,
},
Boolean(github) && {
} : null,
github ? {
icon: ghIcon,
url: github,
},
].filter(Boolean) as Array<{ icon: FunctionComponent; url: string }>;
} : null,
].filter(notEmpty);
const handleFavoriteClick = useCallback(() => {
onFavoriteClick(id, isFavorite);
......@@ -117,8 +122,8 @@ const AppModal = ({
marginTop={{ base: 6, sm: 0 }}
>
<Box display="flex">
<NextLink href={ link('app_index', { id: id }) } passHref>
<Button
href={ url }
as="a"
size="sm"
marginRight={ 2 }
......@@ -126,6 +131,7 @@ const AppModal = ({
>
Launch app
</Button>
</NextLink>
<IconButton
aria-label="Mark as favorite"
......@@ -155,14 +161,14 @@ const AppModal = ({
</Heading>
<Box marginBottom={ 2 }>
{ categories.map((category: AppCategory) => (
{ categories.map((category: keyof typeof MarketplaceCategoryNames) => APP_CATEGORIES[category] && (
<Tag
colorScheme="blue"
marginRight={ 2 }
marginBottom={ 2 }
key={ category.id }
key={ category }
>
{ category.name }
{ APP_CATEGORIES[category] }
</Tag>
)) }
</Box>
......
import type { AppCategory } from 'types/client/apps';
import type { MarketplaceCategoryNames } from 'types/client/apps';
export const APP_CATEGORIES: Array<AppCategory> = [
{
id: 'defi',
name: 'DeFi',
},
{
id: 'exchanges',
name: 'Exchanges',
},
{
id: 'finance',
name: 'Finance',
},
{
id: 'games',
name: 'Games',
},
{
id: 'marketplaces',
name: 'Marketplaces',
},
{
id: 'nft',
name: 'NFT',
},
{
id: 'security',
name: 'Security',
},
{
id: 'social',
name: 'Social',
},
{
id: 'tools',
name: 'Tools',
},
{
id: 'Yield-farming',
name: 'yield-farming',
},
];
export const APP_CATEGORIES: {[key in keyof typeof MarketplaceCategoryNames]: string} = {
defi: 'DeFi',
exchanges: 'Exchanges',
finance: 'Finance',
games: 'Games',
marketplaces: 'Marketplaces',
nft: 'NFT',
security: 'Security',
social: 'Social',
tools: 'Tools',
yieldFarming: 'Yield farming',
};
import { Center, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import Page from 'ui/shared/Page/Page';
const App = () => {
return (
<Page wrapChildren={ false }>
<Center as="main" bgColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') } h="100%" paddingTop={{ base: '138px', lg: 0 }}>
3rd party app content
</Center>
</Page>
);
};
export default App;
import { Icon, Link } from '@chakra-ui/react';
import debounce from 'lodash/debounce';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import type { AppItemOverview } from 'types/client/apps';
import { TEMPORARY_DEMO_APPS } from 'data/apps';
import marketplaceApps from 'data/marketplaceApps.json';
import PlusIcon from 'icons/plus.svg';
import useNetwork from 'lib/hooks/useNetwork';
import AppList from 'ui/apps/AppList';
import FilterInput from 'ui/shared/FilterInput';
const defaultDisplayedApps = [ ...TEMPORARY_DEMO_APPS ]
.sort((a, b) => a.title.localeCompare(b.title));
import { AppListSkeleton } from '../apps/AppListSkeleton';
const Apps = () => {
const [ displayedApps, setDisplayedApps ] = useState<Array<AppItemOverview>>(defaultDisplayedApps);
const selectedNetwork = useNetwork();
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 showAppInfo = useCallback((id: string) => {
setDisplayedAppId(id);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debounceFilterApps = useCallback(debounce(q => filterApps(q), 500), []);
const clearDisplayedAppId = useCallback(() => setDisplayedAppId(null), []);
const filterApps = (q: string) => {
const apps = displayedApps
.filter(app => app.title.toLowerCase().includes(q.toLowerCase()));
function filterApps(q: string) {
const apps = defaultAppList
?.filter(app => app.title.toLowerCase().includes(q.toLowerCase()));
setDisplayedApps(apps);
};
setDisplayedApps(apps || []);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const debounceFilterApps = useCallback(debounce(q => filterApps(q), 500), []);
useEffect(() => {
if (!selectedNetwork) {
return;
}
const clearDisplayedAppId = useCallback(() => setDisplayedAppId(null), []);
const defaultDisplayedApps = [ ...marketplaceApps ]
.filter(item => item.chainId === selectedNetwork?.chainId)
.sort((a, b) => a.title.localeCompare(b.title));
setDefaultAppList(defaultDisplayedApps);
setDisplayedApps(defaultDisplayedApps);
setIsLoading(false);
}, [ selectedNetwork ]);
return (
<>
<FilterInput onChange={ debounceFilterApps } marginBottom={{ base: '4', lg: '6' }} placeholder="Find app"/>
{ isLoading ? <AppListSkeleton/> : (
<AppList
apps={ displayedApps }
onAppClick={ showAppInfo }
displayedAppId={ displayedAppId }
onModalClose={ clearDisplayedAppId }
/>
) }
{ process.env.NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM && (
<Link
......
import { Box, Center, useColorMode } from '@chakra-ui/react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { AppItemOverview } from 'types/client/apps';
import useNetwork from 'lib/hooks/useNetwork';
import Page from 'ui/shared/Page/Page';
type Props = {
app?: AppItemOverview;
isLoading: boolean;
}
const MarketplaceApp = ({ app, isLoading }: Props) => {
const [ isFrameLoading, setIsFrameLoading ] = useState(isLoading);
const ref = useRef<HTMLIFrameElement>(null);
const { colorMode } = useColorMode();
const network = useNetwork();
const handleIframeLoad = useCallback(() => {
setIsFrameLoading(false);
}, []);
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';
useEffect(() => {
if (app) {
ref?.current?.contentWindow?.postMessage({ colorMode, chaindId: network?.chainId }, app.url);
}
}, [ app, colorMode, network, ref ]);
return (
<Page wrapChildren={ false }>
<Center
as="main"
h="100%"
paddingTop={{ base: '138px', lg: 0 }}
>
{ (isFrameLoading) && (
<Center
h="100%"
w="100%"
>
Loading...
</Center>
) }
{ app && (
<Box
ref={ ref }
sandbox={ sandboxAttributeValue }
as="iframe"
h="100%"
w="100%"
display={ isFrameLoading ? 'none' : 'block' }
src={ app.url }
title={ app.title }
onLoad={ handleIframeLoad }
/>
) }
</Center>
</Page>
);
};
export default MarketplaceApp;
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