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 ? { ...@@ -61,6 +61,7 @@ Type extends EventTypes.VERIFY_TOKEN ? {
'Action': 'Form opened' | 'Submit'; 'Action': 'Form opened' | 'Submit';
} : } :
Type extends EventTypes.WALLET_CONNECT ? { Type extends EventTypes.WALLET_CONNECT ? {
'Source': 'Header' | 'Smart contracts';
'Status': 'Started' | 'Connected'; 'Status': 'Started' | 'Connected';
} : } :
Type extends EventTypes.CONTRACT_INTERACTION ? { Type extends EventTypes.CONTRACT_INTERACTION ? {
......
...@@ -17,6 +17,7 @@ import theme from 'theme'; ...@@ -17,6 +17,7 @@ import theme from 'theme';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary'; import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import GoogleAnalytics from 'ui/shared/GoogleAnalytics'; import GoogleAnalytics from 'ui/shared/GoogleAnalytics';
import Layout from 'ui/shared/layout/Layout'; import Layout from 'ui/shared/layout/Layout';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
import 'lib/setLocale'; import 'lib/setLocale';
...@@ -52,17 +53,19 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { ...@@ -52,17 +53,19 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
{ ...ERROR_SCREEN_STYLES } { ...ERROR_SCREEN_STYLES }
onError={ handleError } onError={ handleError }
> >
<AppContextProvider pageProps={ pageProps }> <Web3ModalProvider>
<QueryClientProvider client={ queryClient }> <AppContextProvider pageProps={ pageProps }>
<ScrollDirectionProvider> <QueryClientProvider client={ queryClient }>
<SocketProvider url={ `${ config.api.socket }${ config.api.basePath }/socket/v2` }> <ScrollDirectionProvider>
{ getLayout(<Component { ...pageProps }/>) } <SocketProvider url={ `${ config.api.socket }${ config.api.basePath }/socket/v2` }>
</SocketProvider> { getLayout(<Component { ...pageProps }/>) }
</ScrollDirectionProvider> </SocketProvider>
<ReactQueryDevtools buttonPosition="bottom-left" position="left"/> </ScrollDirectionProvider>
<GoogleAnalytics/> <ReactQueryDevtools buttonPosition="bottom-left" position="left"/>
</QueryClientProvider> <GoogleAnalytics/>
</AppContextProvider> </QueryClientProvider>
</AppContextProvider>
</Web3ModalProvider>
</AppErrorBoundary> </AppErrorBoundary>
</ChakraProvider> </ChakraProvider>
); );
......
...@@ -6,6 +6,8 @@ import type { NextPageWithLayout } from 'nextjs/types'; ...@@ -6,6 +6,8 @@ import type { NextPageWithLayout } from 'nextjs/types';
import type { Props } from 'nextjs/getServerSideProps'; import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs'; import PageNextJs from 'nextjs/PageNextJs';
import LayoutApp from 'ui/shared/layout/LayoutApp';
const MarketplaceApp = dynamic(() => import('ui/pages/MarketplaceApp'), { ssr: false }); const MarketplaceApp = dynamic(() => import('ui/pages/MarketplaceApp'), { ssr: false });
const Page: NextPageWithLayout<Props> = (props: Props) => { const Page: NextPageWithLayout<Props> = (props: Props) => {
...@@ -16,6 +18,14 @@ 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 default Page;
export { marketplace as getServerSideProps } from 'nextjs/getServerSideProps'; export { marketplace as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -12,7 +12,7 @@ const Page: NextPage = () => { ...@@ -12,7 +12,7 @@ const Page: NextPage = () => {
return ( return (
<PageNextJs pathname="/apps"> <PageNextJs pathname="/apps">
<> <>
<PageTitle title="Marketplace"/> <PageTitle title="DAppscout"/>
<Marketplace/> <Marketplace/>
</> </>
</PageNextJs> </PageNextJs>
......
...@@ -17,11 +17,11 @@ const ContractConnectWallet = () => { ...@@ -17,11 +17,11 @@ const ContractConnectWallet = () => {
setIsModalOpening(true); setIsModalOpening(true);
await open(); await open();
setIsModalOpening(false); setIsModalOpening(false);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Status: 'Started' }); mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Smart contracts', Status: 'Started' });
}, [ open ]); }, [ open ]);
const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => { 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(() => { const handleDisconnect = React.useCallback(() => {
......
...@@ -15,6 +15,7 @@ interface Props extends MarketplaceAppPreview { ...@@ -15,6 +15,7 @@ interface Props extends MarketplaceAppPreview {
isFavorite: boolean; isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean) => void; onFavoriteClick: (id: string, isFavorite: boolean) => void;
isLoading: boolean; isLoading: boolean;
showDisclaimer: (id: string) => void;
} }
const MarketplaceAppCard = ({ const MarketplaceAppCard = ({
...@@ -30,9 +31,18 @@ const MarketplaceAppCard = ({ ...@@ -30,9 +31,18 @@ const MarketplaceAppCard = ({
isFavorite, isFavorite,
onFavoriteClick, onFavoriteClick,
isLoading, isLoading,
showDisclaimer,
}: Props) => { }: Props) => {
const categoriesLabel = categories.join(', '); 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) => { const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
onInfoClick(id); onInfoClick(id);
...@@ -100,6 +110,7 @@ const MarketplaceAppCard = ({ ...@@ -100,6 +110,7 @@ const MarketplaceAppCard = ({
url={ url } url={ url }
external={ external } external={ external }
title={ title } title={ title }
onClick={ handleClick }
/> />
</Skeleton> </Skeleton>
......
import { LinkOverlay } from '@chakra-ui/react'; import { LinkOverlay } from '@chakra-ui/react';
import NextLink from 'next/link'; import NextLink from 'next/link';
import React from 'react'; import React from 'react';
import type { MouseEvent } from 'react';
type Props = { type Props = {
id: string; id: string;
url: string; url: string;
external?: boolean; external?: boolean;
title: string; title: string;
onClick?: (event: MouseEvent) => void;
} }
const MarketplaceAppCardLink = ({ url, external, id, title }: Props) => { const MarketplaceAppCardLink = ({ url, external, id, title, onClick }: Props) => {
return external ? ( return external ? (
<LinkOverlay href={ url } isExternal={ true }> <LinkOverlay href={ url } isExternal={ true }>
{ title } { title }
</LinkOverlay> </LinkOverlay>
) : ( ) : (
<NextLink href={{ pathname: '/apps/[id]', query: { id } }} passHref legacyBehavior> <NextLink href={{ pathname: '/apps/[id]', query: { id } }} passHref legacyBehavior>
<LinkOverlay> <LinkOverlay onClick={ onClick }>
{ title } { title }
</LinkOverlay> </LinkOverlay>
</NextLink> </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 = { ...@@ -14,9 +14,10 @@ type Props = {
favoriteApps: Array<string>; favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean) => void; onFavoriteClick: (id: string, isFavorite: boolean) => void;
isLoading: boolean; 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 ? ( return apps.length > 0 ? (
<Grid <Grid
templateColumns={{ templateColumns={{
...@@ -41,6 +42,7 @@ const MarketplaceList = ({ apps, onAppClick, favoriteApps, onFavoriteClick, isLo ...@@ -41,6 +42,7 @@ const MarketplaceList = ({ apps, onAppClick, favoriteApps, onFavoriteClick, isLo
isFavorite={ favoriteApps.includes(app.id) } isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ onFavoriteClick } onFavoriteClick={ onFavoriteClick }
isLoading={ isLoading } isLoading={ isLoading }
showDisclaimer={ showDisclaimer }
/> />
)) } )) }
</Grid> </Grid>
......
...@@ -29,6 +29,8 @@ export default function useMarketplace() { ...@@ -29,6 +29,8 @@ export default function useMarketplace() {
const [ selectedCategoryId, setSelectedCategoryId ] = React.useState<string>(MarketplaceCategory.ALL); const [ selectedCategoryId, setSelectedCategoryId ] = React.useState<string>(MarketplaceCategory.ALL);
const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery); const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery);
const [ favoriteApps, setFavoriteApps ] = React.useState<Array<string>>([]); 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 handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean) => {
const favoriteApps = getFavoriteApps(); const favoriteApps = getFavoriteApps();
...@@ -46,10 +48,20 @@ export default function useMarketplace() { ...@@ -46,10 +48,20 @@ export default function useMarketplace() {
const showAppInfo = React.useCallback((id: string) => { const showAppInfo = React.useCallback((id: string) => {
setSelectedAppId(id); setSelectedAppId(id);
setIsAppInfoModalOpen(true);
}, []);
const showDisclaimer = React.useCallback((id: string) => {
setSelectedAppId(id);
setIsDisclaimerModalOpen(true);
}, []); }, []);
const debouncedFilterQuery = useDebounce(filterQuery, 500); 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) => { const handleCategoryChange = React.useCallback((newCategory: string) => {
setSelectedCategoryId(newCategory); setSelectedCategoryId(newCategory);
...@@ -104,6 +116,9 @@ export default function useMarketplace() { ...@@ -104,6 +116,9 @@ export default function useMarketplace() {
clearSelectedAppId, clearSelectedAppId,
favoriteApps, favoriteApps,
onFavoriteClick: handleFavoriteClick, onFavoriteClick: handleFavoriteClick,
isAppInfoModalOpen,
isDisclaimerModalOpen,
showDisclaimer,
}), [ }), [
selectedCategoryId, selectedCategoryId,
categories, categories,
...@@ -118,5 +133,8 @@ export default function useMarketplace() { ...@@ -118,5 +133,8 @@ export default function useMarketplace() {
isPlaceholderData, isPlaceholderData,
showAppInfo, showAppInfo,
debouncedFilterQuery, 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'; ...@@ -10,6 +10,7 @@ import Transactions from 'ui/home/Transactions';
import AdBanner from 'ui/shared/ad/AdBanner'; import AdBanner from 'ui/shared/ad/AdBanner';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop'; import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import SearchBar from 'ui/snippets/searchBar/SearchBar'; import SearchBar from 'ui/snippets/searchBar/SearchBar';
import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
const Home = () => { const Home = () => {
return ( return (
...@@ -22,7 +23,7 @@ const Home = () => { ...@@ -22,7 +23,7 @@ const Home = () => {
minW={{ base: 'unset', lg: '900px' }} minW={{ base: 'unset', lg: '900px' }}
data-label="hero plate" 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 <Heading
as="h1" as="h1"
size={{ base: 'md', lg: 'xl' }} size={{ base: 'md', lg: 'xl' }}
...@@ -32,8 +33,9 @@ const Home = () => { ...@@ -32,8 +33,9 @@ const Home = () => {
> >
{ config.chain.name } explorer { config.chain.name } explorer
</Heading> </Heading>
<Box display={{ base: 'none', lg: 'block' }}> <Box display={{ base: 'none', lg: 'flex' }}>
{ config.features.account.isEnabled && <ProfileMenuDesktop/> } { config.features.account.isEnabled && <ProfileMenuDesktop isHomePage/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop isHomePage/> }
</Box> </Box>
</Flex> </Flex>
<SearchBar isHomepage/> <SearchBar isHomepage/>
......
...@@ -5,6 +5,7 @@ import config from 'configs/app'; ...@@ -5,6 +5,7 @@ import config from 'configs/app';
import PlusIcon from 'icons/plus.svg'; import PlusIcon from 'icons/plus.svg';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal'; import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceCategoriesMenu from 'ui/marketplace/MarketplaceCategoriesMenu'; import MarketplaceCategoriesMenu from 'ui/marketplace/MarketplaceCategoriesMenu';
import MarketplaceDisclaimerModal from 'ui/marketplace/MarketplaceDisclaimerModal';
import MarketplaceList from 'ui/marketplace/MarketplaceList'; import MarketplaceList from 'ui/marketplace/MarketplaceList';
import FilterInput from 'ui/shared/filters/FilterInput'; import FilterInput from 'ui/shared/filters/FilterInput';
...@@ -27,6 +28,9 @@ const Marketplace = () => { ...@@ -27,6 +28,9 @@ const Marketplace = () => {
clearSelectedAppId, clearSelectedAppId,
favoriteApps, favoriteApps,
onFavoriteClick, onFavoriteClick,
isAppInfoModalOpen,
isDisclaimerModalOpen,
showDisclaimer,
} = useMarketplace(); } = useMarketplace();
if (isError) { if (isError) {
...@@ -68,9 +72,10 @@ const Marketplace = () => { ...@@ -68,9 +72,10 @@ const Marketplace = () => {
favoriteApps={ favoriteApps } favoriteApps={ favoriteApps }
onFavoriteClick={ onFavoriteClick } onFavoriteClick={ onFavoriteClick }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
showDisclaimer={ showDisclaimer }
/> />
{ selectedApp && ( { (selectedApp && isAppInfoModalOpen) && (
<MarketplaceAppModal <MarketplaceAppModal
onClose={ clearSelectedAppId } onClose={ clearSelectedAppId }
isFavorite={ favoriteApps.includes(selectedApp.id) } isFavorite={ favoriteApps.includes(selectedApp.id) }
...@@ -79,6 +84,14 @@ const Marketplace = () => { ...@@ -79,6 +84,14 @@ const Marketplace = () => {
/> />
) } ) }
{ (selectedApp && isDisclaimerModalOpen) && (
<MarketplaceDisclaimerModal
isOpen={ isDisclaimerModalOpen }
onClose={ clearSelectedAppId }
appId={ selectedApp.id }
/>
) }
<Skeleton <Skeleton
isLoaded={ !isPlaceholderData } isLoaded={ !isPlaceholderData }
marginTop={{ base: 8, sm: 16 }} marginTop={{ base: 8, sm: 16 }}
......
import { Box, Center, useColorMode } from '@chakra-ui/react'; import { Box, Center, useColorMode } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { DappscoutIframeProvider, useDappscoutIframe } from 'dappscout-iframe';
import { useRouter } from 'next/router'; 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'; import type { MarketplaceAppOverview } from 'types/client/marketplace';
...@@ -9,12 +10,12 @@ import { route } from 'nextjs-routes'; ...@@ -9,12 +10,12 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import { useAppContext } from 'lib/contexts/app';
import useApiFetch from 'lib/hooks/useFetch'; import useApiFetch from 'lib/hooks/useFetch';
import * as metadata from 'lib/metadata'; import * as metadata from 'lib/metadata';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import PageTitle from 'ui/shared/Page/PageTitle';
import useMarketplaceWallet from '../marketplace/useMarketplaceWallet';
const feature = config.features.marketplace; const feature = config.features.marketplace;
const configUrl = feature.isEnabled ? feature.configUrl : ''; const configUrl = feature.isEnabled ? feature.configUrl : '';
...@@ -26,35 +27,23 @@ const IFRAME_SANDBOX_ATTRIBUTE = 'allow-forms allow-orientation-lock ' + ...@@ -26,35 +27,23 @@ const IFRAME_SANDBOX_ATTRIBUTE = 'allow-forms allow-orientation-lock ' +
const IFRAME_ALLOW_ATTRIBUTE = 'clipboard-read; clipboard-write;'; const IFRAME_ALLOW_ATTRIBUTE = 'clipboard-read; clipboard-write;';
const MarketplaceApp = () => { type Props = {
const ref = useRef<HTMLIFrameElement>(null); address: string | undefined;
data: MarketplaceAppOverview | undefined;
const apiFetch = useApiFetch(); isPending: boolean;
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 };
}
return item; const MarketplaceAppContent = ({ address, data, isPending }: Props) => {
}, const { iframeRef, isReady } = useDappscoutIframe();
enabled: feature.isEnabled,
});
const [ iframeKey, setIframeKey ] = useState(0);
const [ isFrameLoading, setIsFrameLoading ] = useState(isPending); const [ isFrameLoading, setIsFrameLoading ] = useState(isPending);
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
useEffect(() => {
setIframeKey((key) => key + 1);
}, [ address ]);
const handleIframeLoad = useCallback(() => { const handleIframeLoad = useCallback(() => {
setIsFrameLoading(false); setIsFrameLoading(false);
}, []); }, []);
...@@ -72,9 +61,61 @@ const MarketplaceApp = () => { ...@@ -72,9 +61,61 @@ const MarketplaceApp = () => {
blockscoutNetworkRpc: config.chain.rpcUrl, 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(() => { useEffect(() => {
if (data) { if (data) {
...@@ -89,46 +130,17 @@ const MarketplaceApp = () => { ...@@ -89,46 +130,17 @@ const MarketplaceApp = () => {
throw new Error('Unable to load app', { cause: error }); 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 ( return (
<> <DappscoutIframeProvider
{ !isPending && <PageTitle title={ data.title } backLink={ backLink }/> } address={ address }
<Center appUrl={ data?.url }
h="100vh" rpcUrl={ config.chain.rpcUrl }
mx={{ base: -4, lg: -12 }} sendTransaction={ sendTransaction }
> signMessage={ signMessage }
{ (isFrameLoading) && ( signTypedData={ signTypedData }
<ContentLoader/> >
) } <MarketplaceAppContent address={ address } data={ data } isPending={ isPending }/>
</DappscoutIframeProvider>
{ 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>
</>
); );
}; };
......
import { SkeletonCircle, Image } from '@chakra-ui/react'; import { SkeletonCircle, Image, Icon } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import profileIcon from 'icons/profile.svg';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import * as cookies from 'lib/cookies'; import * as cookies from 'lib/cookies';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo'; import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import IdenticonGithub from 'ui/shared/IdenticonGithub';
interface Props { interface Props {
size: number; size: number;
...@@ -34,7 +34,7 @@ const UserAvatar = ({ size }: Props) => { ...@@ -34,7 +34,7 @@ const UserAvatar = ({ size }: Props) => {
boxSize={ `${ size }px` } boxSize={ `${ size }px` }
borderRadius="full" borderRadius="full"
overflow="hidden" 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 } onError={ handleImageLoadError }
/> />
); );
......
...@@ -75,7 +75,7 @@ const Web3ModalProvider = ({ children, fallback }: Props) => { ...@@ -75,7 +75,7 @@ const Web3ModalProvider = ({ children, fallback }: Props) => {
const web3ModalTheme = useColorModeValue('light', 'dark'); const web3ModalTheme = useColorModeValue('light', 'dark');
if (!wagmiConfig || !ethereumClient || !feature.isEnabled) { 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 ( 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'; import React from 'react';
interface Props { interface Props {
className?: string;
children: React.ReactNode; children: React.ReactNode;
} }
const Content = ({ children }: Props) => { const Content = ({ children, className }: Props) => {
return ( return (
<Box pt={{ base: 0, lg: '52px' }} as="main"> <Box pt={{ base: 0, lg: '52px' }} as="main" className={ className }>
{ children } { children }
</Box> </Box>
); );
}; };
export default React.memo(Content); export default React.memo(chakra(Content));
...@@ -10,7 +10,11 @@ import NetworkMenuButton from 'ui/snippets/networkMenu/NetworkMenuButton'; ...@@ -10,7 +10,11 @@ import NetworkMenuButton from 'ui/snippets/networkMenu/NetworkMenuButton';
import NetworkMenuContentMobile from 'ui/snippets/networkMenu/NetworkMenuContentMobile'; import NetworkMenuContentMobile from 'ui/snippets/networkMenu/NetworkMenuContentMobile';
import useNetworkMenu from 'ui/snippets/networkMenu/useNetworkMenu'; import useNetworkMenu from 'ui/snippets/networkMenu/useNetworkMenu';
const Burger = () => { interface Props {
isMarketplaceAppPage?: boolean;
}
const Burger = ({ isMarketplaceAppPage }: Props) => {
const iconColor = useColorModeValue('gray.600', 'white'); const iconColor = useColorModeValue('gray.600', 'white');
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const networkMenu = useNetworkMenu(); const networkMenu = useNetworkMenu();
...@@ -26,7 +30,7 @@ const Burger = () => { ...@@ -26,7 +30,7 @@ const Burger = () => {
return ( return (
<> <>
<Box padding={ 2 } onClick={ onOpen }> <Box padding={ 2 } onClick={ onOpen } cursor="pointer">
<Icon <Icon
as={ burgerIcon } as={ burgerIcon }
boxSize={ 6 } boxSize={ 6 }
...@@ -57,7 +61,7 @@ const Burger = () => { ...@@ -57,7 +61,7 @@ const Burger = () => {
</Flex> </Flex>
{ networkMenu.isOpen ? { networkMenu.isOpen ?
<NetworkMenuContentMobile tabs={ networkMenu.availableTabs } items={ networkMenu.data }/> : <NetworkMenuContentMobile tabs={ networkMenu.availableTabs } items={ networkMenu.data }/> :
<NavigationMobile onNavLinkClick={ onClose }/> <NavigationMobile onNavLinkClick={ onClose } isMarketplaceAppPage={ isMarketplaceAppPage }/>
} }
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
......
...@@ -2,14 +2,19 @@ import { HStack, Box } from '@chakra-ui/react'; ...@@ -2,14 +2,19 @@ import { HStack, Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop'; import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import SearchBar from 'ui/snippets/searchBar/SearchBar'; import SearchBar from 'ui/snippets/searchBar/SearchBar';
import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import Burger from './Burger';
type Props = { type Props = {
renderSearchBar?: () => React.ReactNode; renderSearchBar?: () => React.ReactNode;
isMarketplaceAppPage?: boolean;
} }
const HeaderDesktop = ({ renderSearchBar }: Props) => { const HeaderDesktop = ({ renderSearchBar, isMarketplaceAppPage }: Props) => {
const searchBar = renderSearchBar ? renderSearchBar() : <SearchBar/>; const searchBar = renderSearchBar ? renderSearchBar() : <SearchBar/>;
...@@ -22,10 +27,19 @@ const HeaderDesktop = ({ renderSearchBar }: Props) => { ...@@ -22,10 +27,19 @@ const HeaderDesktop = ({ renderSearchBar }: Props) => {
justifyContent="center" justifyContent="center"
gap={ 12 } gap={ 12 }
> >
{ isMarketplaceAppPage && (
<Box display="flex" alignItems="center" gap={ 3 }>
<Burger isMarketplaceAppPage/>
<NetworkLogo isCollapsed/>
</Box>
) }
<Box width="100%"> <Box width="100%">
{ searchBar } { searchBar }
</Box> </Box>
{ config.features.account.isEnabled && <ProfileMenuDesktop/> } <Box display="flex">
{ config.features.account.isEnabled && <ProfileMenuDesktop/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop/> }
</Box>
</HStack> </HStack>
); );
}; };
......
...@@ -7,6 +7,7 @@ import { useScrollDirection } from 'lib/contexts/scrollDirection'; ...@@ -7,6 +7,7 @@ import { useScrollDirection } from 'lib/contexts/scrollDirection';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo'; import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import ProfileMenuMobile from 'ui/snippets/profileMenu/ProfileMenuMobile'; import ProfileMenuMobile from 'ui/snippets/profileMenu/ProfileMenuMobile';
import SearchBar from 'ui/snippets/searchBar/SearchBar'; import SearchBar from 'ui/snippets/searchBar/SearchBar';
import WalletMenuMobile from 'ui/snippets/walletMenu/WalletMenuMobile';
import Burger from './Burger'; import Burger from './Burger';
...@@ -47,7 +48,10 @@ const HeaderMobile = ({ isHomePage, renderSearchBar }: Props) => { ...@@ -47,7 +48,10 @@ const HeaderMobile = ({ isHomePage, renderSearchBar }: Props) => {
> >
<Burger/> <Burger/>
<NetworkLogo/> <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> </Flex>
{ !isHomePage && searchBar } { !isHomePage && searchBar }
</Box> </Box>
......
...@@ -17,10 +17,11 @@ import useNavLinkStyleProps from './useNavLinkStyleProps'; ...@@ -17,10 +17,11 @@ import useNavLinkStyleProps from './useNavLinkStyleProps';
type Props = { type Props = {
item: NavGroupItem; item: NavGroupItem;
onClick: () => void; onClick: () => void;
isExpanded?: boolean;
} }
const NavLinkGroup = ({ item, onClick }: Props) => { const NavLinkGroup = ({ item, onClick, isExpanded }: Props) => {
const styleProps = useNavLinkStyleProps({ isActive: item.isActive }); const styleProps = useNavLinkStyleProps({ isActive: item.isActive, isExpanded });
return ( return (
<Box as="li" listStyleType="none" w="100%" onClick={ onClick }> <Box as="li" listStyleType="none" w="100%" onClick={ onClick }>
......
...@@ -11,9 +11,10 @@ import NavLinkGroupMobile from './NavLinkGroupMobile'; ...@@ -11,9 +11,10 @@ import NavLinkGroupMobile from './NavLinkGroupMobile';
interface Props { interface Props {
onNavLinkClick?: () => void; onNavLinkClick?: () => void;
isMarketplaceAppPage?: boolean;
} }
const NavigationMobile = ({ onNavLinkClick }: Props) => { const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => {
const { mainNavItems, accountNavItems } = useNavItems(); const { mainNavItems, accountNavItems } = useNavItems();
const [ openedGroupIndex, setOpenedGroupIndex ] = React.useState(-1); const [ openedGroupIndex, setOpenedGroupIndex ] = React.useState(-1);
...@@ -38,6 +39,8 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => { ...@@ -38,6 +39,8 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => {
const openedItem = mainNavItems[openedGroupIndex]; const openedItem = mainNavItems[openedGroupIndex];
const isCollapsed = isMarketplaceAppPage ? false : undefined;
return ( return (
<Flex position="relative" flexDirection="column" flexGrow={ 1 }> <Flex position="relative" flexDirection="column" flexGrow={ 1 }>
<Box <Box
...@@ -61,9 +64,9 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => { ...@@ -61,9 +64,9 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => {
> >
{ mainNavItems.map((item, index) => { { mainNavItems.map((item, index) => {
if (isGroupItem(item)) { 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 { } else {
return <NavLink key={ item.text } item={ item } onClick={ onNavLinkClick }/>; return <NavLink key={ item.text } item={ item } onClick={ onNavLinkClick } isCollapsed={ isCollapsed }/>;
} }
}) } }) }
</VStack> </VStack>
...@@ -77,7 +80,7 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => { ...@@ -77,7 +80,7 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => {
borderColor="divider" borderColor="divider"
> >
<VStack as="ul" spacing="1" alignItems="flex-start"> <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> </VStack>
</Box> </Box>
) } ) }
...@@ -113,10 +116,10 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => { ...@@ -113,10 +116,10 @@ const NavigationMobile = ({ onNavLinkClick }: Props) => {
borderColor: 'divider', 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> </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>
</Box> </Box>
......
...@@ -23,7 +23,7 @@ test('no auth', async({ mount, page }) => { ...@@ -23,7 +23,7 @@ test('no auth', async({ mount, page }) => {
{ hooksConfig }, { hooksConfig },
); );
await component.locator('.identicon').click(); await component.locator('a').click();
expect(page.url()).toBe(`${ app.url }/auth/auth0?path=%2F`); expect(page.url()).toBe(`${ app.url }/auth/auth0?path=%2F`);
}); });
......
import type { ButtonProps } from '@chakra-ui/react'; import type { IconButtonProps } from '@chakra-ui/react';
import { Popover, PopoverContent, PopoverBody, PopoverTrigger, Button } from '@chakra-ui/react'; import { Popover, PopoverContent, PopoverBody, PopoverTrigger, IconButton, Tooltip, Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo'; import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
...@@ -8,9 +8,16 @@ import * as mixpanel from 'lib/mixpanel/index'; ...@@ -8,9 +8,16 @@ import * as mixpanel from 'lib/mixpanel/index';
import UserAvatar from 'ui/shared/UserAvatar'; import UserAvatar from 'ui/shared/UserAvatar';
import ProfileMenuContent from 'ui/snippets/profileMenu/ProfileMenuContent'; 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 { data, error, isPending } = useFetchProfileInfo();
const loginUrl = useLoginUrl(); const loginUrl = useLoginUrl();
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
const [ hasMenu, setHasMenu ] = React.useState(false); const [ hasMenu, setHasMenu ] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
...@@ -27,7 +34,7 @@ const ProfileMenuDesktop = () => { ...@@ -27,7 +34,7 @@ const ProfileMenuDesktop = () => {
); );
}, []); }, []);
const buttonProps: Partial<ButtonProps> = (() => { const iconButtonProps: Partial<IconButtonProps> = (() => {
if (hasMenu || !loginUrl) { if (hasMenu || !loginUrl) {
return {}; return {};
} }
...@@ -39,19 +46,53 @@ const ProfileMenuDesktop = () => { ...@@ -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 ( return (
<Popover openDelay={ 300 } placement="bottom-end" gutter={ 10 } isLazy> <Popover openDelay={ 300 } placement="bottom-end" gutter={ 10 } isLazy>
<PopoverTrigger> <Tooltip
<Button label={ <span>Sign in to My Account to add tags,<br/>create watchlists, access API keys and more</span> }
variant="unstyled" textAlign="center"
display="block" padding={ 2 }
boxSize="50px" isDisabled={ hasMenu }
flexShrink={ 0 } openDelay={ 300 }
{ ...buttonProps } >
> <Box>
<UserAvatar size={ 50 }/> <PopoverTrigger>
</Button> <IconButton
</PopoverTrigger> aria-label="profile menu"
icon={ <UserAvatar size={ 20 }/> }
variant={ variant }
colorScheme="blue"
boxSize="40px"
flexShrink={ 0 }
{ ...iconButtonProps }
{ ...iconButtonStyles }
/>
</PopoverTrigger>
</Box>
</Tooltip>
{ hasMenu && ( { hasMenu && (
<PopoverContent w="212px"> <PopoverContent w="212px">
<PopoverBody padding="24px 16px 16px 16px"> <PopoverBody padding="24px 16px 16px 16px">
......
...@@ -23,7 +23,7 @@ test('no auth', async({ mount, page }) => { ...@@ -23,7 +23,7 @@ test('no auth', async({ mount, page }) => {
{ hooksConfig }, { hooksConfig },
); );
await component.locator('.identicon').click(); await component.locator('a').click();
expect(page.url()).toBe(`${ app.url }/auth/auth0?path=%2F`); expect(page.url()).toBe(`${ app.url }/auth/auth0?path=%2F`);
}); });
......
import { Box, Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, Button } from '@chakra-ui/react'; import { Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, IconButton } from '@chakra-ui/react';
import type { ButtonProps } from '@chakra-ui/react'; import type { IconButtonProps } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo'; import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
...@@ -8,11 +8,13 @@ import * as mixpanel from 'lib/mixpanel/index'; ...@@ -8,11 +8,13 @@ import * as mixpanel from 'lib/mixpanel/index';
import UserAvatar from 'ui/shared/UserAvatar'; import UserAvatar from 'ui/shared/UserAvatar';
import ProfileMenuContent from 'ui/snippets/profileMenu/ProfileMenuContent'; import ProfileMenuContent from 'ui/snippets/profileMenu/ProfileMenuContent';
import useMenuButtonColors from '../useMenuButtonColors';
const ProfileMenuMobile = () => { const ProfileMenuMobile = () => {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const { data, error, isPending } = useFetchProfileInfo(); const { data, error, isPending } = useFetchProfileInfo();
const loginUrl = useLoginUrl(); const loginUrl = useLoginUrl();
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
const [ hasMenu, setHasMenu ] = React.useState(false); const [ hasMenu, setHasMenu ] = React.useState(false);
const handleSignInClick = React.useCallback(() => { const handleSignInClick = React.useCallback(() => {
...@@ -29,7 +31,7 @@ const ProfileMenuMobile = () => { ...@@ -29,7 +31,7 @@ const ProfileMenuMobile = () => {
} }
}, [ data, error?.status, isPending ]); }, [ data, error?.status, isPending ]);
const buttonProps: Partial<ButtonProps> = (() => { const iconButtonProps: Partial<IconButtonProps> = (() => {
if (hasMenu || !loginUrl) { if (hasMenu || !loginUrl) {
return {}; return {};
} }
...@@ -43,17 +45,19 @@ const ProfileMenuMobile = () => { ...@@ -43,17 +45,19 @@ const ProfileMenuMobile = () => {
return ( return (
<> <>
<Box padding={ 2 } onClick={ hasMenu ? onOpen : undefined }> <IconButton
<Button aria-label="profile menu"
variant="unstyled" icon={ <UserAvatar size={ 20 }/> }
display="block" variant={ data?.avatar ? 'subtle' : 'outline' }
boxSize="24px" colorScheme="gray"
flexShrink={ 0 } boxSize="40px"
{ ...buttonProps } flexShrink={ 0 }
> bg={ data?.avatar ? themedBackground : undefined }
<UserAvatar size={ 24 }/> color={ themedColor }
</Button> borderColor={ !data?.avatar ? themedBorderColor : undefined }
</Box> onClick={ hasMenu ? onOpen : undefined }
{ ...iconButtonProps }
/>
{ hasMenu && ( { hasMenu && (
<Drawer <Drawer
isOpen={ isOpen } 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