Commit 2d7ce289 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #1356 from blockscout/marketplace-improvements

Marketplace improvements
parents 84ed9b9b 68fb687b
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 1.593A8.407 8.407 0 0 0 3.332 15.12c1.465-2.162 3.89-3.556 6.668-3.556 2.784 0 5.2 1.461 6.66 3.567A8.407 8.407 0 0 0 10 1.593ZM0 10C0 4.477 4.477 0 10 0s10 4.477 10 10-4.477 10-10 10S0 15.523 0 10Zm4.476 6.287c1.443 1.295 3.412 2.039 5.524 2.039a8.529 8.529 0 0 0 5.51-2.045c-1.17-1.85-3.2-3.123-5.51-3.123-2.333 0-4.363 1.225-5.524 3.129Zm3.388-8.975a2.136 2.136 0 1 1 4.272 0 2.136 2.136 0 0 1-4.272 0ZM10 3.584a3.729 3.729 0 1 0 0 7.457 3.729 3.729 0 0 0 0-7.457Z" fill="currentColor"/>
</svg>
......@@ -61,6 +61,7 @@ Type extends EventTypes.VERIFY_TOKEN ? {
'Action': 'Form opened' | 'Submit';
} :
Type extends EventTypes.WALLET_CONNECT ? {
'Source': 'Header' | 'Smart contracts';
'Status': 'Started' | 'Connected';
} :
Type extends EventTypes.CONTRACT_INTERACTION ? {
......
......@@ -17,6 +17,7 @@ import theme from 'theme';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import GoogleAnalytics from 'ui/shared/GoogleAnalytics';
import Layout from 'ui/shared/layout/Layout';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
import 'lib/setLocale';
......@@ -52,17 +53,19 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
{ ...ERROR_SCREEN_STYLES }
onError={ handleError }
>
<AppContextProvider pageProps={ pageProps }>
<QueryClientProvider client={ queryClient }>
<ScrollDirectionProvider>
<SocketProvider url={ `${ config.api.socket }${ config.api.basePath }/socket/v2` }>
{ getLayout(<Component { ...pageProps }/>) }
</SocketProvider>
</ScrollDirectionProvider>
<ReactQueryDevtools buttonPosition="bottom-left" position="left"/>
<GoogleAnalytics/>
</QueryClientProvider>
</AppContextProvider>
<Web3ModalProvider>
<AppContextProvider pageProps={ pageProps }>
<QueryClientProvider client={ queryClient }>
<ScrollDirectionProvider>
<SocketProvider url={ `${ config.api.socket }${ config.api.basePath }/socket/v2` }>
{ getLayout(<Component { ...pageProps }/>) }
</SocketProvider>
</ScrollDirectionProvider>
<ReactQueryDevtools buttonPosition="bottom-left" position="left"/>
<GoogleAnalytics/>
</QueryClientProvider>
</AppContextProvider>
</Web3ModalProvider>
</AppErrorBoundary>
</ChakraProvider>
);
......
......@@ -6,6 +6,8 @@ import type { NextPageWithLayout } from 'nextjs/types';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import LayoutApp from 'ui/shared/layout/LayoutApp';
const MarketplaceApp = dynamic(() => import('ui/pages/MarketplaceApp'), { ssr: false });
const Page: NextPageWithLayout<Props> = (props: Props) => {
......@@ -16,6 +18,14 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
);
};
Page.getLayout = function getLayout(page: React.ReactElement) {
return (
<LayoutApp>
{ page }
</LayoutApp>
);
};
export default Page;
export { marketplace as getServerSideProps } from 'nextjs/getServerSideProps';
......@@ -12,7 +12,7 @@ const Page: NextPage = () => {
return (
<PageNextJs pathname="/apps">
<>
<PageTitle title="Marketplace"/>
<PageTitle title="DAppscout"/>
<Marketplace/>
</>
</PageNextJs>
......
......@@ -17,11 +17,11 @@ const ContractConnectWallet = () => {
setIsModalOpening(true);
await open();
setIsModalOpening(false);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Status: 'Started' });
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Smart contracts', Status: 'Started' });
}, [ open ]);
const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => {
!isReconnected && mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Status: 'Connected' });
!isReconnected && mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Smart contracts', Status: 'Connected' });
}, []);
const handleDisconnect = React.useCallback(() => {
......
......@@ -15,6 +15,7 @@ interface Props extends MarketplaceAppPreview {
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
isLoading: boolean;
showDisclaimer: (id: string) => void;
}
const MarketplaceAppCard = ({
......@@ -30,9 +31,18 @@ const MarketplaceAppCard = ({
isFavorite,
onFavoriteClick,
isLoading,
showDisclaimer,
}: Props) => {
const categoriesLabel = categories.join(', ');
const handleClick = useCallback((event: MouseEvent) => {
const isShown = window.localStorage.getItem('marketplace-disclaimer-shown');
if (!isShown) {
event.preventDefault();
showDisclaimer(id);
}
}, [ showDisclaimer, id ]);
const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault();
onInfoClick(id);
......@@ -100,6 +110,7 @@ const MarketplaceAppCard = ({
url={ url }
external={ external }
title={ title }
onClick={ handleClick }
/>
</Skeleton>
......
import { LinkOverlay } from '@chakra-ui/react';
import NextLink from 'next/link';
import React from 'react';
import type { MouseEvent } from 'react';
type Props = {
id: string;
url: string;
external?: boolean;
title: string;
onClick?: (event: MouseEvent) => void;
}
const MarketplaceAppCardLink = ({ url, external, id, title }: Props) => {
const MarketplaceAppCardLink = ({ url, external, id, title, onClick }: Props) => {
return external ? (
<LinkOverlay href={ url } isExternal={ true }>
{ title }
</LinkOverlay>
) : (
<NextLink href={{ pathname: '/apps/[id]', query: { id } }} passHref legacyBehavior>
<LinkOverlay>
<LinkOverlay onClick={ onClick }>
{ title }
</LinkOverlay>
</NextLink>
......
import { Heading, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Text, Button, useColorModeValue } from '@chakra-ui/react';
import NextLink from 'next/link';
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
type Props = {
isOpen: boolean;
onClose: () => void;
appId: string;
}
const MarketplaceDisclaimerModal = ({ isOpen, onClose, appId }: Props) => {
const isMobile = useIsMobile();
const handleContinueClick = React.useCallback(() => {
window.localStorage.setItem('marketplace-disclaimer-shown', 'true');
}, [ ]);
return (
<Modal
isOpen={ isOpen }
onClose={ onClose }
size={ isMobile ? 'full' : 'md' }
isCentered
>
<ModalOverlay/>
<ModalContent>
<ModalHeader>
<Heading
as="h2"
fontSize="2xl"
fontWeight="medium"
lineHeight={ 1 }
color={ useColorModeValue('blackAlpha.800', 'whiteAlpha.800') }
>
Disclaimer
</Heading>
</ModalHeader>
<ModalBody>
<Text color={ useColorModeValue('gray.800', 'whiteAlpha.800') }>
You are now accessing a third-party app. Blockscout does not own, control, maintain, or audit 3rd party apps,{ ' ' }
and is not liable for any losses associated with these interactions. Please do so at your own risk.
<br/><br/>
By clicking continue, you agree that you understand the risks and have read the Disclaimer.
</Text>
</ModalBody>
<ModalFooter
display="flex"
flexDirection="row"
alignItems="center"
>
<NextLink href={{ pathname: '/apps/[id]', query: { id: appId } }} passHref legacyBehavior>
<Button
variant="solid"
colorScheme="blue"
mr={ 6 }
py="10px"
onClick={ handleContinueClick }
>
Continue to app
</Button>
</NextLink>
<Button
variant="outline"
colorScheme="blue"
onClick={ onClose }
>
Cancel
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default MarketplaceDisclaimerModal;
......@@ -14,9 +14,10 @@ type Props = {
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
isLoading: boolean;
showDisclaimer: (id: string) => void;
}
const MarketplaceList = ({ apps, onAppClick, favoriteApps, onFavoriteClick, isLoading }: Props) => {
const MarketplaceList = ({ apps, onAppClick, favoriteApps, onFavoriteClick, isLoading, showDisclaimer }: Props) => {
return apps.length > 0 ? (
<Grid
templateColumns={{
......@@ -41,6 +42,7 @@ const MarketplaceList = ({ apps, onAppClick, favoriteApps, onFavoriteClick, isLo
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick }
isLoading={ isLoading }
showDisclaimer={ showDisclaimer }
/>
)) }
</Grid>
......
......@@ -29,6 +29,8 @@ export default function useMarketplace() {
const [ selectedCategoryId, setSelectedCategoryId ] = React.useState<string>(MarketplaceCategory.ALL);
const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery);
const [ favoriteApps, setFavoriteApps ] = React.useState<Array<string>>([]);
const [ isAppInfoModalOpen, setIsAppInfoModalOpen ] = React.useState<boolean>(false);
const [ isDisclaimerModalOpen, setIsDisclaimerModalOpen ] = React.useState<boolean>(false);
const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean) => {
const favoriteApps = getFavoriteApps();
......@@ -46,10 +48,20 @@ export default function useMarketplace() {
const showAppInfo = React.useCallback((id: string) => {
setSelectedAppId(id);
setIsAppInfoModalOpen(true);
}, []);
const showDisclaimer = React.useCallback((id: string) => {
setSelectedAppId(id);
setIsDisclaimerModalOpen(true);
}, []);
const debouncedFilterQuery = useDebounce(filterQuery, 500);
const clearSelectedAppId = React.useCallback(() => setSelectedAppId(null), []);
const clearSelectedAppId = React.useCallback(() => {
setSelectedAppId(null);
setIsAppInfoModalOpen(false);
setIsDisclaimerModalOpen(false);
}, []);
const handleCategoryChange = React.useCallback((newCategory: string) => {
setSelectedCategoryId(newCategory);
......@@ -104,6 +116,9 @@ export default function useMarketplace() {
clearSelectedAppId,
favoriteApps,
onFavoriteClick: handleFavoriteClick,
isAppInfoModalOpen,
isDisclaimerModalOpen,
showDisclaimer,
}), [
selectedCategoryId,
categories,
......@@ -118,5 +133,8 @@ export default function useMarketplace() {
isPlaceholderData,
showAppInfo,
debouncedFilterQuery,
isAppInfoModalOpen,
isDisclaimerModalOpen,
showDisclaimer,
]);
}
import type { TypedData } from 'abitype';
import { useCallback } from 'react';
import type { Account, SignTypedDataParameters } from 'viem';
import { useAccount, useSendTransaction, useSwitchNetwork, useNetwork, useSignMessage, useSignTypedData } from 'wagmi';
import config from 'configs/app';
type SendTransactionArgs = {
chainId?: number;
mode?: 'prepared';
to: string;
};
export type SignTypedDataArgs<
TTypedData extends
| TypedData
| {
[key: string]: unknown;
} = TypedData,
TPrimaryType extends string = string,
> = SignTypedDataParameters<TTypedData, TPrimaryType, Account>;
export default function useMarketplaceWallet() {
const { address } = useAccount();
const { chain } = useNetwork();
const { sendTransactionAsync } = useSendTransaction();
const { signMessageAsync } = useSignMessage();
const { signTypedDataAsync } = useSignTypedData();
const { switchNetworkAsync } = useSwitchNetwork({ chainId: Number(config.chain.id) });
const switchNetwork = useCallback(async() => {
if (Number(config.chain.id) !== chain?.id) {
await switchNetworkAsync?.();
}
}, [ chain, switchNetworkAsync ]);
const sendTransaction = useCallback(async(transaction: SendTransactionArgs) => {
await switchNetwork();
const tx = await sendTransactionAsync(transaction);
return tx.hash;
}, [ sendTransactionAsync, switchNetwork ]);
const signMessage = useCallback(async(message: string) => {
await switchNetwork();
const signature = await signMessageAsync({ message });
return signature;
}, [ signMessageAsync, switchNetwork ]);
const signTypedData = useCallback(async(typedData: SignTypedDataArgs) => {
await switchNetwork();
if (typedData.domain) {
typedData.domain.chainId = Number(typedData.domain.chainId);
}
const signature = await signTypedDataAsync(typedData);
return signature;
}, [ signTypedDataAsync, switchNetwork ]);
return {
address,
sendTransaction,
signMessage,
signTypedData,
};
}
......@@ -10,6 +10,7 @@ import Transactions from 'ui/home/Transactions';
import AdBanner from 'ui/shared/ad/AdBanner';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
const Home = () => {
return (
......@@ -22,7 +23,7 @@ const Home = () => {
minW={{ base: 'unset', lg: '900px' }}
data-label="hero plate"
>
<Flex mb={{ base: 6, lg: 8 }} justifyContent="space-between">
<Flex mb={{ base: 6, lg: 8 }} justifyContent="space-between" alignItems="center">
<Heading
as="h1"
size={{ base: 'md', lg: 'xl' }}
......@@ -32,8 +33,9 @@ const Home = () => {
>
{ config.chain.name } explorer
</Heading>
<Box display={{ base: 'none', lg: 'block' }}>
{ config.features.account.isEnabled && <ProfileMenuDesktop/> }
<Box display={{ base: 'none', lg: 'flex' }}>
{ config.features.account.isEnabled && <ProfileMenuDesktop isHomePage/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop isHomePage/> }
</Box>
</Flex>
<SearchBar isHomepage/>
......
......@@ -5,6 +5,7 @@ import config from 'configs/app';
import PlusIcon from 'icons/plus.svg';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceCategoriesMenu from 'ui/marketplace/MarketplaceCategoriesMenu';
import MarketplaceDisclaimerModal from 'ui/marketplace/MarketplaceDisclaimerModal';
import MarketplaceList from 'ui/marketplace/MarketplaceList';
import FilterInput from 'ui/shared/filters/FilterInput';
......@@ -27,6 +28,9 @@ const Marketplace = () => {
clearSelectedAppId,
favoriteApps,
onFavoriteClick,
isAppInfoModalOpen,
isDisclaimerModalOpen,
showDisclaimer,
} = useMarketplace();
if (isError) {
......@@ -68,9 +72,10 @@ const Marketplace = () => {
favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick }
isLoading={ isPlaceholderData }
showDisclaimer={ showDisclaimer }
/>
{ selectedApp && (
{ (selectedApp && isAppInfoModalOpen) && (
<MarketplaceAppModal
onClose={ clearSelectedAppId }
isFavorite={ favoriteApps.includes(selectedApp.id) }
......@@ -79,6 +84,14 @@ const Marketplace = () => {
/>
) }
{ (selectedApp && isDisclaimerModalOpen) && (
<MarketplaceDisclaimerModal
isOpen={ isDisclaimerModalOpen }
onClose={ clearSelectedAppId }
appId={ selectedApp.id }
/>
) }
<Skeleton
isLoaded={ !isPlaceholderData }
marginTop={{ base: 8, sm: 16 }}
......
import { Box, Center, useColorMode } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { DappscoutIframeProvider, useDappscoutIframe } from 'dappscout-iframe';
import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
......@@ -9,12 +10,12 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import { useAppContext } from 'lib/contexts/app';
import useApiFetch from 'lib/hooks/useFetch';
import * as metadata from 'lib/metadata';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader';
import PageTitle from 'ui/shared/Page/PageTitle';
import useMarketplaceWallet from '../marketplace/useMarketplaceWallet';
const feature = config.features.marketplace;
const configUrl = feature.isEnabled ? feature.configUrl : '';
......@@ -26,35 +27,23 @@ const IFRAME_SANDBOX_ATTRIBUTE = 'allow-forms allow-orientation-lock ' +
const IFRAME_ALLOW_ATTRIBUTE = 'clipboard-read; clipboard-write;';
const MarketplaceApp = () => {
const ref = useRef<HTMLIFrameElement>(null);
const apiFetch = useApiFetch();
const appProps = useAppContext();
const router = useRouter();
const id = getQueryParamString(router.query.id);
const { isPending, isError, error, data } = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({
queryKey: [ 'marketplace-apps', id ],
queryFn: async() => {
const result = await apiFetch<Array<MarketplaceAppOverview>, unknown>(configUrl, undefined, { resource: 'marketplace-apps' });
if (!Array.isArray(result)) {
throw result;
}
const item = result.find((app: MarketplaceAppOverview) => app.id === id);
if (!item) {
throw { status: 404 };
}
type Props = {
address: string | undefined;
data: MarketplaceAppOverview | undefined;
isPending: boolean;
};
return item;
},
enabled: feature.isEnabled,
});
const MarketplaceAppContent = ({ address, data, isPending }: Props) => {
const { iframeRef, isReady } = useDappscoutIframe();
const [ iframeKey, setIframeKey ] = useState(0);
const [ isFrameLoading, setIsFrameLoading ] = useState(isPending);
const { colorMode } = useColorMode();
useEffect(() => {
setIframeKey((key) => key + 1);
}, [ address ]);
const handleIframeLoad = useCallback(() => {
setIsFrameLoading(false);
}, []);
......@@ -72,9 +61,61 @@ const MarketplaceApp = () => {
blockscoutNetworkRpc: config.chain.rpcUrl,
};
ref?.current?.contentWindow?.postMessage(message, data.url);
iframeRef?.current?.contentWindow?.postMessage(message, data.url);
}
}, [ isFrameLoading, data, colorMode, ref ]);
}, [ isFrameLoading, data, colorMode, iframeRef ]);
return (
<Center
h="100vh"
mx={{ base: -4, lg: -6 }}
>
{ (isFrameLoading) && (
<ContentLoader/>
) }
{ (data && isReady) && (
<Box
key={ iframeKey }
allow={ IFRAME_ALLOW_ATTRIBUTE }
ref={ iframeRef }
sandbox={ IFRAME_SANDBOX_ATTRIBUTE }
as="iframe"
h="100%"
w="100%"
display={ isFrameLoading ? 'none' : 'block' }
src={ data.url }
title={ data.title }
onLoad={ handleIframeLoad }
/>
) }
</Center>
);
};
const MarketplaceApp = () => {
const { address, sendTransaction, signMessage, signTypedData } = useMarketplaceWallet();
const apiFetch = useApiFetch();
const router = useRouter();
const id = getQueryParamString(router.query.id);
const { isPending, isError, error, data } = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({
queryKey: [ 'marketplace-apps', id ],
queryFn: async() => {
const result = await apiFetch<Array<MarketplaceAppOverview>, unknown>(configUrl, undefined, { resource: 'marketplace-apps' });
if (!Array.isArray(result)) {
throw result;
}
const item = result.find((app: MarketplaceAppOverview) => app.id === id);
if (!item) {
throw { status: 404 };
}
return item;
},
enabled: feature.isEnabled,
});
useEffect(() => {
if (data) {
......@@ -89,46 +130,17 @@ const MarketplaceApp = () => {
throw new Error('Unable to load app', { cause: error });
}
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer.includes('/apps');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to marketplace',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<>
{ !isPending && <PageTitle title={ data.title } backLink={ backLink }/> }
<Center
h="100vh"
mx={{ base: -4, lg: -12 }}
>
{ (isFrameLoading) && (
<ContentLoader/>
) }
{ 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>
</>
<DappscoutIframeProvider
address={ address }
appUrl={ data?.url }
rpcUrl={ config.chain.rpcUrl }
sendTransaction={ sendTransaction }
signMessage={ signMessage }
signTypedData={ signTypedData }
>
<MarketplaceAppContent address={ address } data={ data } isPending={ isPending }/>
</DappscoutIframeProvider>
);
};
......
import { SkeletonCircle, Image } from '@chakra-ui/react';
import { SkeletonCircle, Image, Icon } from '@chakra-ui/react';
import React from 'react';
import profileIcon from 'icons/profile.svg';
import { useAppContext } from 'lib/contexts/app';
import * as cookies from 'lib/cookies';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import IdenticonGithub from 'ui/shared/IdenticonGithub';
interface Props {
size: number;
......@@ -34,7 +34,7 @@ const UserAvatar = ({ size }: Props) => {
boxSize={ `${ size }px` }
borderRadius="full"
overflow="hidden"
fallback={ isImageLoadError || !data?.avatar ? <IdenticonGithub size={ size } seed={ data?.email || 'randomness' } flexShrink={ 0 }/> : undefined }
fallback={ isImageLoadError || !data?.avatar ? <Icon as={ profileIcon } boxSize={ 5 }/> : undefined }
onError={ handleImageLoadError }
/>
);
......
......@@ -75,7 +75,7 @@ const Web3ModalProvider = ({ children, fallback }: Props) => {
const web3ModalTheme = useColorModeValue('light', 'dark');
if (!wagmiConfig || !ethereumClient || !feature.isEnabled) {
return typeof fallback === 'function' ? fallback() : (fallback || null);
return typeof fallback === 'function' ? fallback() : (fallback || <>{ children }</>); // eslint-disable-line react/jsx-no-useless-fragment
}
return (
......
import React from 'react';
import type { Props } from './types';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import HeaderAlert from 'ui/snippets/header/HeaderAlert';
import HeaderDesktop from 'ui/snippets/header/HeaderDesktop';
import HeaderMobile from 'ui/snippets/header/HeaderMobile';
import * as Layout from './components';
const LayoutDefault = ({ children }: Props) => {
return (
<Layout.Container>
<HeaderMobile/>
<Layout.MainArea>
<Layout.MainColumn
paddingTop={{ base: '138px', lg: 6 }}
paddingX={{ base: 4, lg: 6 }}
>
<HeaderAlert/>
<HeaderDesktop isMarketplaceAppPage/>
<AppErrorBoundary>
<Layout.Content pt={{ base: 0, lg: 6 }}>
{ children }
</Layout.Content>
</AppErrorBoundary>
</Layout.MainColumn>
</Layout.MainArea>
<Layout.Footer/>
</Layout.Container>
);
};
export default LayoutDefault;
import { Box } from '@chakra-ui/react';
import { Box, chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
className?: string;
children: React.ReactNode;
}
const Content = ({ children }: Props) => {
const Content = ({ children, className }: Props) => {
return (
<Box pt={{ base: 0, lg: '52px' }} as="main">
<Box pt={{ base: 0, lg: '52px' }} as="main" className={ className }>
{ children }
</Box>
);
};
export default React.memo(Content);
export default React.memo(chakra(Content));
......@@ -10,7 +10,11 @@ import NetworkMenuButton from 'ui/snippets/networkMenu/NetworkMenuButton';
import NetworkMenuContentMobile from 'ui/snippets/networkMenu/NetworkMenuContentMobile';
import useNetworkMenu from 'ui/snippets/networkMenu/useNetworkMenu';
const Burger = () => {
interface Props {
isMarketplaceAppPage?: boolean;
}
const Burger = ({ isMarketplaceAppPage }: Props) => {
const iconColor = useColorModeValue('gray.600', 'white');
const { isOpen, onOpen, onClose } = useDisclosure();
const networkMenu = useNetworkMenu();
......@@ -26,7 +30,7 @@ const Burger = () => {
return (
<>
<Box padding={ 2 } onClick={ onOpen }>
<Box padding={ 2 } onClick={ onOpen } cursor="pointer">
<Icon
as={ burgerIcon }
boxSize={ 6 }
......@@ -57,7 +61,7 @@ const Burger = () => {
</Flex>
{ networkMenu.isOpen ?
<NetworkMenuContentMobile tabs={ networkMenu.availableTabs } items={ networkMenu.data }/> :
<NavigationMobile onNavLinkClick={ onClose }/>
<NavigationMobile onNavLinkClick={ onClose } isMarketplaceAppPage={ isMarketplaceAppPage }/>
}
</DrawerBody>
</DrawerContent>
......
......@@ -2,14 +2,19 @@ import { HStack, Box } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import Burger from './Burger';
type Props = {
renderSearchBar?: () => React.ReactNode;
isMarketplaceAppPage?: boolean;
}
const HeaderDesktop = ({ renderSearchBar }: Props) => {
const HeaderDesktop = ({ renderSearchBar, isMarketplaceAppPage }: Props) => {
const searchBar = renderSearchBar ? renderSearchBar() : <SearchBar/>;
......@@ -22,10 +27,19 @@ const HeaderDesktop = ({ renderSearchBar }: Props) => {
justifyContent="center"
gap={ 12 }
>
{ isMarketplaceAppPage && (
<Box display="flex" alignItems="center" gap={ 3 }>
<Burger isMarketplaceAppPage/>
<NetworkLogo isCollapsed/>
</Box>
) }
<Box width="100%">
{ searchBar }
</Box>
{ config.features.account.isEnabled && <ProfileMenuDesktop/> }
<Box display="flex">
{ config.features.account.isEnabled && <ProfileMenuDesktop/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop/> }
</Box>
</HStack>
);
};
......
......@@ -7,6 +7,7 @@ import { useScrollDirection } from 'lib/contexts/scrollDirection';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import ProfileMenuMobile from 'ui/snippets/profileMenu/ProfileMenuMobile';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
import WalletMenuMobile from 'ui/snippets/walletMenu/WalletMenuMobile';
import Burger from './Burger';
......@@ -47,7 +48,10 @@ const HeaderMobile = ({ isHomePage, renderSearchBar }: Props) => {
>
<Burger/>
<NetworkLogo/>
{ config.features.account.isEnabled ? <ProfileMenuMobile/> : <Box boxSize={ 10 }/> }
<Flex columnGap={ 2 }>
{ config.features.account.isEnabled ? <ProfileMenuMobile/> : <Box boxSize={ 10 }/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuMobile/> }
</Flex>
</Flex>
{ !isHomePage && searchBar }
</Box>
......
......@@ -17,10 +17,11 @@ import useNavLinkStyleProps from './useNavLinkStyleProps';
type Props = {
item: NavGroupItem;
onClick: () => void;
isExpanded?: boolean;
}
const NavLinkGroup = ({ item, onClick }: Props) => {
const styleProps = useNavLinkStyleProps({ isActive: item.isActive });
const NavLinkGroup = ({ item, onClick, isExpanded }: Props) => {
const styleProps = useNavLinkStyleProps({ isActive: item.isActive, isExpanded });
return (
<Box as="li" listStyleType="none" w="100%" onClick={ onClick }>
......
......@@ -11,9 +11,10 @@ import NavLinkGroupMobile from './NavLinkGroupMobile';
interface Props {
onNavLinkClick?: () => void;
isMarketplaceAppPage?: boolean;
}
const NavigationMobile = ({ onNavLinkClick }: Props) => {
const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => {
const { mainNavItems, accountNavItems } = useNavItems();
const [ openedGroupIndex, setOpenedGroupIndex ] = React.useState(-1);
......@@ -38,6 +39,8 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => {
const openedItem = mainNavItems[openedGroupIndex];
const isCollapsed = isMarketplaceAppPage ? false : undefined;
return (
<Flex position="relative" flexDirection="column" flexGrow={ 1 }>
<Box
......@@ -61,9 +64,9 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => {
>
{ mainNavItems.map((item, index) => {
if (isGroupItem(item)) {
return <NavLinkGroupMobile key={ item.text } item={ item } onClick={ onGroupItemOpen(index) }/>;
return <NavLinkGroupMobile key={ item.text } item={ item } onClick={ onGroupItemOpen(index) } isExpanded={ isMarketplaceAppPage }/>;
} else {
return <NavLink key={ item.text } item={ item } onClick={ onNavLinkClick }/>;
return <NavLink key={ item.text } item={ item } onClick={ onNavLinkClick } isCollapsed={ isCollapsed }/>;
}
}) }
</VStack>
......@@ -77,7 +80,7 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => {
borderColor="divider"
>
<VStack as="ul" spacing="1" alignItems="flex-start">
{ accountNavItems.map((item) => <NavLink key={ item.text } item={ item } onClick={ onNavLinkClick }/>) }
{ accountNavItems.map((item) => <NavLink key={ item.text } item={ item } onClick={ onNavLinkClick } isCollapsed={ isCollapsed }/>) }
</VStack>
</Box>
) }
......@@ -113,10 +116,10 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => {
borderColor: 'divider',
}}
>
{ item.map(subItem => <NavLink key={ subItem.text } item={ subItem } onClick={ onNavLinkClick }/>) }
{ item.map(subItem => <NavLink key={ subItem.text } item={ subItem } onClick={ onNavLinkClick } isCollapsed={ isCollapsed }/>) }
</Box>
) :
<NavLink key={ item.text } item={ item } mb={ 1 } onClick={ onNavLinkClick }/>,
<NavLink key={ item.text } item={ item } mb={ 1 } onClick={ onNavLinkClick } isCollapsed={ isCollapsed }/>,
) }
</Box>
</Box>
......
......@@ -23,7 +23,7 @@ test('no auth', async({ mount, page }) => {
{ hooksConfig },
);
await component.locator('.identicon').click();
await component.locator('a').click();
expect(page.url()).toBe(`${ app.url }/auth/auth0?path=%2F`);
});
......
import type { ButtonProps } from '@chakra-ui/react';
import { Popover, PopoverContent, PopoverBody, PopoverTrigger, Button } from '@chakra-ui/react';
import type { IconButtonProps } from '@chakra-ui/react';
import { Popover, PopoverContent, PopoverBody, PopoverTrigger, IconButton, Tooltip, Box } from '@chakra-ui/react';
import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
......@@ -8,9 +8,16 @@ import * as mixpanel from 'lib/mixpanel/index';
import UserAvatar from 'ui/shared/UserAvatar';
import ProfileMenuContent from 'ui/snippets/profileMenu/ProfileMenuContent';
const ProfileMenuDesktop = () => {
import useMenuButtonColors from '../useMenuButtonColors';
type Props = {
isHomePage?: boolean;
};
const ProfileMenuDesktop = ({ isHomePage }: Props) => {
const { data, error, isPending } = useFetchProfileInfo();
const loginUrl = useLoginUrl();
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
const [ hasMenu, setHasMenu ] = React.useState(false);
React.useEffect(() => {
......@@ -27,7 +34,7 @@ const ProfileMenuDesktop = () => {
);
}, []);
const buttonProps: Partial<ButtonProps> = (() => {
const iconButtonProps: Partial<IconButtonProps> = (() => {
if (hasMenu || !loginUrl) {
return {};
}
......@@ -39,19 +46,53 @@ const ProfileMenuDesktop = () => {
};
})();
const variant = React.useMemo(() => {
if (hasMenu) {
return 'subtle';
}
return isHomePage ? 'solid' : 'outline';
}, [ hasMenu, isHomePage ]);
let iconButtonStyles: Partial<IconButtonProps> = {};
if (hasMenu) {
iconButtonStyles = {
bg: isHomePage ? 'blue.50' : themedBackground,
};
} else if (isHomePage) {
iconButtonStyles = {
color: 'white',
};
} else {
iconButtonStyles = {
borderColor: themedBorderColor,
color: themedColor,
};
}
return (
<Popover openDelay={ 300 } placement="bottom-end" gutter={ 10 } isLazy>
<PopoverTrigger>
<Button
variant="unstyled"
display="block"
boxSize="50px"
flexShrink={ 0 }
{ ...buttonProps }
>
<UserAvatar size={ 50 }/>
</Button>
</PopoverTrigger>
<Tooltip
label={ <span>Sign in to My Account to add tags,<br/>create watchlists, access API keys and more</span> }
textAlign="center"
padding={ 2 }
isDisabled={ hasMenu }
openDelay={ 300 }
>
<Box>
<PopoverTrigger>
<IconButton
aria-label="profile menu"
icon={ <UserAvatar size={ 20 }/> }
variant={ variant }
colorScheme="blue"
boxSize="40px"
flexShrink={ 0 }
{ ...iconButtonProps }
{ ...iconButtonStyles }
/>
</PopoverTrigger>
</Box>
</Tooltip>
{ hasMenu && (
<PopoverContent w="212px">
<PopoverBody padding="24px 16px 16px 16px">
......
......@@ -23,7 +23,7 @@ test('no auth', async({ mount, page }) => {
{ hooksConfig },
);
await component.locator('.identicon').click();
await component.locator('a').click();
expect(page.url()).toBe(`${ app.url }/auth/auth0?path=%2F`);
});
......
import { Box, Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, Button } from '@chakra-ui/react';
import type { ButtonProps } from '@chakra-ui/react';
import { Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, IconButton } from '@chakra-ui/react';
import type { IconButtonProps } from '@chakra-ui/react';
import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
......@@ -8,11 +8,13 @@ import * as mixpanel from 'lib/mixpanel/index';
import UserAvatar from 'ui/shared/UserAvatar';
import ProfileMenuContent from 'ui/snippets/profileMenu/ProfileMenuContent';
import useMenuButtonColors from '../useMenuButtonColors';
const ProfileMenuMobile = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { data, error, isPending } = useFetchProfileInfo();
const loginUrl = useLoginUrl();
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
const [ hasMenu, setHasMenu ] = React.useState(false);
const handleSignInClick = React.useCallback(() => {
......@@ -29,7 +31,7 @@ const ProfileMenuMobile = () => {
}
}, [ data, error?.status, isPending ]);
const buttonProps: Partial<ButtonProps> = (() => {
const iconButtonProps: Partial<IconButtonProps> = (() => {
if (hasMenu || !loginUrl) {
return {};
}
......@@ -43,17 +45,19 @@ const ProfileMenuMobile = () => {
return (
<>
<Box padding={ 2 } onClick={ hasMenu ? onOpen : undefined }>
<Button
variant="unstyled"
display="block"
boxSize="24px"
flexShrink={ 0 }
{ ...buttonProps }
>
<UserAvatar size={ 24 }/>
</Button>
</Box>
<IconButton
aria-label="profile menu"
icon={ <UserAvatar size={ 20 }/> }
variant={ data?.avatar ? 'subtle' : 'outline' }
colorScheme="gray"
boxSize="40px"
flexShrink={ 0 }
bg={ data?.avatar ? themedBackground : undefined }
color={ themedColor }
borderColor={ !data?.avatar ? themedBorderColor : undefined }
onClick={ hasMenu ? onOpen : undefined }
{ ...iconButtonProps }
/>
{ hasMenu && (
<Drawer
isOpen={ isOpen }
......
import { useColorModeValue } from '@chakra-ui/react';
export default function useMenuColors() {
const themedBackground = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const themedBorderColor = useColorModeValue('gray.300', 'gray.700');
const themedColor = useColorModeValue('blackAlpha.800', 'gray.400');
return { themedBackground, themedBorderColor, themedColor };
}
import { Box, Button, Text } from '@chakra-ui/react';
import React from 'react';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
type Props = {
address?: string;
disconnect?: () => void;
};
const WalletMenuContent = ({ address, disconnect }: Props) => (
<Box>
<Text
fontSize="sm"
fontWeight={ 600 }
mb={ 1 }
{ ...getDefaultTransitionProps() }
>
My wallet
</Text>
<Text
fontSize="sm"
mb={ 5 }
fontWeight={ 400 }
color="text_secondary"
{ ...getDefaultTransitionProps() }
>
Your wallet is used to interact with apps and contracts in the explorer.
</Text>
<AddressEntity
address={{ hash: address }}
noTooltip
truncation="dynamic"
fontSize="sm"
fontWeight={ 700 }
color="text"
mb={ 6 }
/>
<Button size="sm" width="full" variant="outline" onClick={ disconnect }>
Disconnect
</Button>
</Box>
);
export default WalletMenuContent;
import type { ButtonProps } from '@chakra-ui/react';
import { Popover, PopoverContent, PopoverBody, PopoverTrigger, Button, Box, useBoolean } from '@chakra-ui/react';
import React from 'react';
import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon';
import HashStringShorten from 'ui/shared/HashStringShorten';
import useWallet from 'ui/snippets/walletMenu/useWallet';
import WalletMenuContent from 'ui/snippets/walletMenu/WalletMenuContent';
import useMenuButtonColors from '../useMenuButtonColors';
import WalletTooltip from './WalletTooltip';
type Props = {
isHomePage?: boolean;
};
const WalletMenuDesktop = ({ isHomePage }: Props) => {
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet();
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
const [ isPopoverOpen, setIsPopoverOpen ] = useBoolean(false);
const variant = React.useMemo(() => {
if (isWalletConnected) {
return 'subtle';
}
return isHomePage ? 'solid' : 'outline';
}, [ isWalletConnected, isHomePage ]);
let buttonStyles: Partial<ButtonProps> = {};
if (isWalletConnected) {
buttonStyles = {
bg: isHomePage ? 'blue.50' : themedBackground,
color: isHomePage ? 'blackAlpha.800' : themedColor,
_hover: {
color: isHomePage ? 'blackAlpha.800' : themedColor,
},
};
} else if (isHomePage) {
buttonStyles = {
color: 'white',
};
} else {
buttonStyles = {
borderColor: themedBorderColor,
color: themedColor,
};
}
return (
<Popover
openDelay={ 300 }
placement="bottom-end"
gutter={ 10 }
isLazy
isOpen={ isPopoverOpen }
onClose={ setIsPopoverOpen.off }
>
<WalletTooltip isDisabled={ isWalletConnected }>
<Box ml={ 2 }>
<PopoverTrigger>
<Button
variant={ variant }
colorScheme="blue"
flexShrink={ 0 }
isLoading={ isModalOpening || isModalOpen }
loadingText="Connect wallet"
onClick={ isWalletConnected ? setIsPopoverOpen.on : connect }
fontSize="sm"
{ ...buttonStyles }
>
{ isWalletConnected ? (
<>
<Box mr={ 2 }>
<AddressIdenticon size={ 20 } hash={ address }/>
</Box>
<HashStringShorten hash={ address } isTooltipDisabled/>
</>
) : 'Connect wallet' }
</Button>
</PopoverTrigger>
</Box>
</WalletTooltip>
{ isWalletConnected && (
<PopoverContent w="235px">
<PopoverBody padding="24px 16px 16px 16px">
<WalletMenuContent address={ address } disconnect={ disconnect }/>
</PopoverBody>
</PopoverContent>
) }
</Popover>
);
};
export default WalletMenuDesktop;
import { Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, IconButton, Icon } from '@chakra-ui/react';
import React from 'react';
import walletIcon from 'icons/wallet.svg';
import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon';
import useWallet from 'ui/snippets/walletMenu/useWallet';
import WalletMenuContent from 'ui/snippets/walletMenu/WalletMenuContent';
import useMenuButtonColors from '../useMenuButtonColors';
import WalletTooltip from './WalletTooltip';
const WalletMenuMobile = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet();
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
return (
<>
<WalletTooltip isDisabled={ isWalletConnected } isMobile>
<IconButton
aria-label="wallet menu"
icon={ isWalletConnected ?
<AddressIdenticon size={ 20 } hash={ address }/> :
<Icon as={ walletIcon } boxSize={ 6 }/>
}
variant={ isWalletConnected ? 'subtle' : 'outline' }
colorScheme="gray"
boxSize="40px"
flexShrink={ 0 }
bg={ isWalletConnected ? themedBackground : undefined }
color={ themedColor }
borderColor={ !isWalletConnected ? themedBorderColor : undefined }
onClick={ isWalletConnected ? onOpen : connect }
isLoading={ isModalOpening || isModalOpen }
/>
</WalletTooltip>
{ isWalletConnected && (
<Drawer
isOpen={ isOpen }
placement="right"
onClose={ onClose }
autoFocus={ false }
>
<DrawerOverlay/>
<DrawerContent maxWidth="260px">
<DrawerBody p={ 6 }>
<WalletMenuContent address={ address } disconnect={ disconnect }/>
</DrawerBody>
</DrawerContent>
</Drawer>
) }
</>
);
};
export default WalletMenuMobile;
import { Tooltip, useBoolean } from '@chakra-ui/react';
import React from 'react';
type Props = {
children: React.ReactNode;
isDisabled?: boolean;
isMobile?: boolean;
};
const WalletTooltip = ({ children, isDisabled, isMobile }: Props) => {
const [ isTooltipShown, setIsTooltipShown ] = useBoolean(false);
React.useEffect(() => {
const key = `wallet-connect-tooltip-shown-${ isMobile ? 'mobile' : 'desktop' }`;
const wasShown = window.localStorage.getItem(key);
if (!wasShown) {
setIsTooltipShown.on();
window.localStorage.setItem(key, 'true');
}
}, [ setIsTooltipShown, isMobile ]);
return (
<Tooltip
label={ <span>Your wallet is used to interact with<br/>apps and contracts in the explorer</span> }
textAlign="center"
padding={ 2 }
isDisabled={ isDisabled }
openDelay={ 300 }
isOpen={ isTooltipShown || (isMobile ? false : undefined) }
onClose={ setIsTooltipShown.off }
display={ isMobile ? { base: 'flex', lg: 'none' } : { base: 'none', lg: 'flex' } }
>
{ children }
</Tooltip>
);
};
export default WalletTooltip;
import { useWeb3Modal } from '@web3modal/react';
import React from 'react';
import { useAccount, useDisconnect } from 'wagmi';
import * as mixpanel from 'lib/mixpanel/index';
export default function useWallet() {
const { open, isOpen } = useWeb3Modal();
const { disconnect } = useDisconnect();
const [ isModalOpening, setIsModalOpening ] = React.useState(false);
const [ isClientLoaded, setIsClientLoaded ] = React.useState(false);
React.useEffect(() => {
setIsClientLoaded(true);
}, []);
const handleConnect = React.useCallback(async() => {
setIsModalOpening(true);
await open();
setIsModalOpening(false);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Header', Status: 'Started' });
}, [ open ]);
const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => {
!isReconnected && mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Header', Status: 'Connected' });
}, []);
const handleDisconnect = React.useCallback(() => {
disconnect();
}, [ disconnect ]);
const { address, isDisconnected } = useAccount({ onConnect: handleAccountConnected });
const isWalletConnected = isClientLoaded && !isDisconnected && address !== undefined;
return {
isWalletConnected,
address: address || '',
connect: handleConnect,
disconnect: handleDisconnect,
isModalOpening,
isModalOpen: isOpen,
};
}
This diff is collapsed.
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