Commit 70d95e92 authored by tom's avatar tom

skeletons for token instance

parent dd1daf82
...@@ -7,54 +7,63 @@ export const erc20a: AddressTokenBalance = { ...@@ -7,54 +7,63 @@ export const erc20a: AddressTokenBalance = {
token: tokens.tokenInfoERC20a, token: tokens.tokenInfoERC20a,
token_id: null, token_id: null,
value: '1169320000000000000000000', value: '1169320000000000000000000',
token_instance: null,
}; };
export const erc20b: AddressTokenBalance = { export const erc20b: AddressTokenBalance = {
token: tokens.tokenInfoERC20b, token: tokens.tokenInfoERC20b,
token_id: null, token_id: null,
value: '872500000000', value: '872500000000',
token_instance: null,
}; };
export const erc20c: AddressTokenBalance = { export const erc20c: AddressTokenBalance = {
token: tokens.tokenInfoERC20c, token: tokens.tokenInfoERC20c,
token_id: null, token_id: null,
value: '9852000000000000000000', value: '9852000000000000000000',
token_instance: null,
}; };
export const erc20d: AddressTokenBalance = { export const erc20d: AddressTokenBalance = {
token: tokens.tokenInfoERC20d, token: tokens.tokenInfoERC20d,
token_id: null, token_id: null,
value: '39000000000000000000', value: '39000000000000000000',
token_instance: null,
}; };
export const erc20LongSymbol: AddressTokenBalance = { export const erc20LongSymbol: AddressTokenBalance = {
token: tokens.tokenInfoERC20LongSymbol, token: tokens.tokenInfoERC20LongSymbol,
token_id: null, token_id: null,
value: '39000000000000000000', value: '39000000000000000000',
token_instance: null,
}; };
export const erc721a: AddressTokenBalance = { export const erc721a: AddressTokenBalance = {
token: tokens.tokenInfoERC721a, token: tokens.tokenInfoERC721a,
token_id: null, token_id: null,
value: '51', value: '51',
token_instance: null,
}; };
export const erc721b: AddressTokenBalance = { export const erc721b: AddressTokenBalance = {
token: tokens.tokenInfoERC721b, token: tokens.tokenInfoERC721b,
token_id: null, token_id: null,
value: '1', value: '1',
token_instance: null,
}; };
export const erc721c: AddressTokenBalance = { export const erc721c: AddressTokenBalance = {
token: tokens.tokenInfoERC721c, token: tokens.tokenInfoERC721c,
token_id: null, token_id: null,
value: '5', value: '5',
token_instance: null,
}; };
export const erc721LongSymbol: AddressTokenBalance = { export const erc721LongSymbol: AddressTokenBalance = {
token: tokens.tokenInfoERC721LongSymbol, token: tokens.tokenInfoERC721LongSymbol,
token_id: null, token_id: null,
value: '5', value: '5',
token_instance: null,
}; };
export const erc1155a: AddressTokenBalance = { export const erc1155a: AddressTokenBalance = {
......
import type { NextPage } from 'next'; import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import type { PageParams } from 'lib/next/token/types'; import type { PageParams } from 'lib/next/token/types';
import getNetworkTitle from 'lib/networks/getNetworkTitle'; import getNetworkTitle from 'lib/networks/getNetworkTitle';
import TokenInstance from 'ui/pages/TokenInstance'; import Page from 'ui/shared/Page/Page';
const TokenInstance = dynamic(() => import('ui/pages/TokenInstance'), { ssr: false });
const TokenInstancePage: NextPage<PageParams> = () => { const TokenInstancePage: NextPage<PageParams> = () => {
const title = getNetworkTitle(); const title = getNetworkTitle();
...@@ -15,7 +17,9 @@ const TokenInstancePage: NextPage<PageParams> = () => { ...@@ -15,7 +17,9 @@ const TokenInstancePage: NextPage<PageParams> = () => {
<Head> <Head>
<title>{ title }</title> <title>{ title }</title>
</Head> </Head>
<TokenInstance/> <Page>
<TokenInstance/>
</Page>
</> </>
); );
}; };
......
...@@ -9,7 +9,7 @@ export const ADDRESS_INFO: Address = { ...@@ -9,7 +9,7 @@ export const ADDRESS_INFO: Address = {
block_number_balance_updated_at: 8774377, block_number_balance_updated_at: 8774377,
coin_balance: '810941268802273085757', coin_balance: '810941268802273085757',
creation_tx_hash: null, creation_tx_hash: null,
creator_address_hash: null, creator_address_hash: ADDRESS_HASH,
exchange_rate: null, exchange_rate: null,
has_custom_methods_read: false, has_custom_methods_read: false,
has_custom_methods_write: false, has_custom_methods_write: false,
......
import type { TokenCounters, TokenHolder, TokenHolders, TokenInfo, TokenInstance, TokenType } from 'types/api/token'; import type { TokenCounters, TokenHolder, TokenInfo, TokenInstance, TokenType } from 'types/api/token';
import type { TokenTransfer, TokenTransferPagination, TokenTransferResponse } from 'types/api/tokenTransfer'; import type { TokenTransfer, TokenTransferPagination, TokenTransferResponse } from 'types/api/tokenTransfer';
import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams'; import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams';
...@@ -38,8 +38,6 @@ export const TOKEN_HOLDER: TokenHolder = { ...@@ -38,8 +38,6 @@ export const TOKEN_HOLDER: TokenHolder = {
value: '1021378038331138520', value: '1021378038331138520',
}; };
export const TOKEN_HOLDERS: TokenHolders = { items: Array(50).fill(TOKEN_HOLDER), next_page_params: null };
export const TOKEN_TRANSFER_ERC_20: TokenTransfer = { export const TOKEN_TRANSFER_ERC_20: TokenTransfer = {
block_hash: BLOCK_HASH, block_hash: BLOCK_HASH,
from: ADDRESS_PARAMS, from: ADDRESS_PARAMS,
...@@ -92,7 +90,7 @@ export const TOKEN_INSTANCE: TokenInstance = { ...@@ -92,7 +90,7 @@ export const TOKEN_INSTANCE: TokenInstance = {
image_url: 'https://ipfs.vipsland.com/nft/collections/genesis/188882.gif', image_url: 'https://ipfs.vipsland.com/nft/collections/genesis/188882.gif',
is_unique: true, is_unique: true,
metadata: { metadata: {
attributes: Array(3).fill({ trait_type: 'skin', value: '6' }), attributes: Array(3).fill({ trait_type: 'skin tone', value: 'very light skin tone' }),
description: '**GENESIS #188882**, **8a77ca1bcaa4036f** :: *844th* generation of *#57806 and #57809* :: **eGenetic Hash Code (eDNA)** = *2822355e953a462d*', description: '**GENESIS #188882**, **8a77ca1bcaa4036f** :: *844th* generation of *#57806 and #57809* :: **eGenetic Hash Code (eDNA)** = *2822355e953a462d*',
external_url: 'https://vipsland.com/nft/collections/genesis/188882', external_url: 'https://vipsland.com/nft/collections/genesis/188882',
image: 'https://ipfs.vipsland.com/nft/collections/genesis/188882.gif', image: 'https://ipfs.vipsland.com/nft/collections/genesis/188882.gif',
......
...@@ -136,7 +136,7 @@ const TokenPageContent = () => { ...@@ -136,7 +136,7 @@ const TokenPageContent = () => {
scrollRef, scrollRef,
options: { options: {
enabled: Boolean(router.query.hash && router.query.tab === 'holders' && hasData), enabled: Boolean(router.query.hash && router.query.tab === 'holders' && hasData),
placeholderData: tokenStubs.TOKEN_HOLDERS, placeholderData: generateListStub<'token_holders'>(tokenStubs.TOKEN_HOLDER, 50, { next_page_params: null }),
}, },
}); });
......
import { Box, Icon, Skeleton } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import Page from 'ui/shared/Page/Page'; import type { RoutedTab } from 'ui/shared/Tabs/types';
import TokenInstanceContent from 'ui/tokenInstance/TokenInstanceContent';
import nftIcon from 'icons/nft_shield.svg';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { TOKEN_INSTANCE } from 'stubs/token';
import * as tokenStubs from 'stubs/token';
import { generateListStub } from 'stubs/utils';
import TextAd from 'ui/shared/ad/TextAd';
import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import Tag from 'ui/shared/chakra/Tag';
import LinkExternal from 'ui/shared/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/Pagination';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import TokenInstanceDetails from 'ui/tokenInstance/TokenInstanceDetails';
import TokenInstanceMetadata from 'ui/tokenInstance/TokenInstanceMetadata';
export type TokenTabs = 'token_transfers' | 'holders' export type TokenTabs = 'token_transfers' | 'holders'
const TokenInstance = () => { const TokenInstanceContent = () => {
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const hash = router.query.hash?.toString();
const id = router.query.id?.toString();
const tab = router.query.tab?.toString();
const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenInstanceQuery = useApiQuery('token_instance', {
pathParams: { hash, id },
queryOptions: {
enabled: Boolean(hash && id),
placeholderData: TOKEN_INSTANCE,
},
});
const transfersQuery = useQueryWithPages({
resourceName: 'token_instance_transfers',
pathParams: { hash, id },
scrollRef,
options: {
enabled: Boolean(hash && id && (!tab || tab === 'token_transfers') && !tokenInstanceQuery.isPlaceholderData && tokenInstanceQuery.data),
placeholderData: generateListStub<'token_instance_transfers'>(
tokenInstanceQuery.data?.token.type === 'ERC-1155' ? tokenStubs.TOKEN_TRANSFER_ERC_1155 : tokenStubs.TOKEN_TRANSFER_ERC_721,
10,
{ next_page_params: null },
),
},
});
const shouldFetchHolders = !tokenInstanceQuery.isPlaceholderData && tokenInstanceQuery.data && !tokenInstanceQuery.data.is_unique;
const holdersQuery = useQueryWithPages({
resourceName: 'token_instance_holders',
pathParams: { hash, id },
scrollRef,
options: {
enabled: Boolean(hash && tab === 'holders' && shouldFetchHolders),
placeholderData: generateListStub<'token_instance_holders'>(tokenStubs.TOKEN_HOLDER, 10, { next_page_params: null }),
},
});
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to token page',
url: appProps.referrer,
};
}, [ appProps.referrer, hash ]);
const tabs: Array<RoutedTab> = [
{
id: 'token_transfers',
title: 'Token transfers',
component: <TokenTransfer transfersQuery={ transfersQuery } tokenId={ id } token={ tokenInstanceQuery.data?.token }/>,
},
shouldFetchHolders ?
{ id: 'holders', title: 'Holders', component: <TokenHolders holdersQuery={ holdersQuery } token={ tokenInstanceQuery.data?.token }/> } :
undefined,
{ id: 'metadata', title: 'Metadata', component: (
<TokenInstanceMetadata
data={ tokenInstanceQuery.data?.metadata }
isPlaceholderData={ tokenInstanceQuery.isPlaceholderData }
/>
) },
].filter(Boolean);
if (tokenInstanceQuery.isError) {
throw Error('Token instance fetch failed', { cause: tokenInstanceQuery.error });
}
const nftShieldIcon = tokenInstanceQuery.isPlaceholderData ?
<Skeleton boxSize={ 6 } display="inline-block" borderRadius="base" mr={ 2 } my={ 2 } verticalAlign="text-bottom"/> :
<Icon as={ nftIcon } boxSize={ 6 } mr={ 2 }/>;
const tokenTag = <Tag isLoading={ tokenInstanceQuery.isPlaceholderData }>{ tokenInstanceQuery.data?.token.type }</Tag>;
const address = {
hash: hash || '',
is_contract: true,
implementation_name: null,
watchlist_names: [],
watchlist_address_id: null,
};
const appLink = (() => {
if (!tokenInstanceQuery.data?.external_app_url) {
return null;
}
try {
const url = new URL(tokenInstanceQuery.data.external_app_url);
return (
<Skeleton isLoaded={ !tokenInstanceQuery.isPlaceholderData } display="inline-block" fontSize="sm" mt={ 6 }>
<span>View in app </span>
<LinkExternal href={ tokenInstanceQuery.data.external_app_url }>
{ url.hostname }
</LinkExternal>
</Skeleton>
);
} catch (error) {
return (
<Box fontSize="sm" mt={ 6 }>
<LinkExternal href={ tokenInstanceQuery.data.external_app_url }>
View in app
</LinkExternal>
</Box>
);
}
})();
let pagination: PaginationProps | undefined;
let isPaginationVisible;
if (tab === 'token_transfers') {
pagination = transfersQuery.pagination;
isPaginationVisible = transfersQuery.isPaginationVisible;
} else if (tab === 'holders') {
pagination = holdersQuery.pagination;
isPaginationVisible = holdersQuery.isPaginationVisible;
}
return ( return (
<Page> <>
<TokenInstanceContent/> <TextAd mb={ 6 }/>
</Page> <PageTitle
title={ `${ tokenInstanceQuery.data?.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data?.id }` }
backLink={ backLink }
beforeTitle={ nftShieldIcon }
contentAfter={ tokenTag }
isLoading={ tokenInstanceQuery.isPlaceholderData }
/>
<AddressHeadingInfo address={ address } token={ tokenInstanceQuery.data?.token } isLoading={ tokenInstanceQuery.isPlaceholderData }/>
{ appLink }
<TokenInstanceDetails data={ tokenInstanceQuery?.data } isLoading={ tokenInstanceQuery.isPlaceholderData } scrollRef={ scrollRef }/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box>
{ tokenInstanceQuery.isPlaceholderData ? <SkeletonTabs tabs={ tabs }/> : (
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? { mt: 8 } : { mt: 3, py: 5, marginBottom: 0 } }
rightSlot={ !isMobile && isPaginationVisible && pagination ? <Pagination { ...pagination }/> : null }
stickyEnabled={ !isMobile }
/>
) }
{ !tokenInstanceQuery.isLoading && !tokenInstanceQuery.isError && <Box h={{ base: 0, lg: '40vh' }}/> }
</>
); );
}; };
export default TokenInstance; export default React.memo(TokenInstanceContent);
...@@ -24,7 +24,7 @@ const BackLink = (props: BackLinkProp & { isLoading?: boolean }) => { ...@@ -24,7 +24,7 @@ const BackLink = (props: BackLinkProp & { isLoading?: boolean }) => {
} }
if (props.isLoading) { if (props.isLoading) {
return <Skeleton boxSize={ 6 } display="inline-block" borderRadius="base" mr={ 3 } isLoaded={ !props.isLoading }/>; return <Skeleton boxSize={ 6 } display="inline-block" borderRadius="base" mr={ 3 } my={ 2 } verticalAlign="text-bottom" isLoaded={ !props.isLoading }/>;
} }
const icon = <Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" margin="auto"/>; const icon = <Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" margin="auto"/>;
......
import { Flex, Text, chakra } from '@chakra-ui/react'; import { Flex, chakra, Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
...@@ -21,7 +21,11 @@ const TokenSnippet = ({ data, className, logoSize = 6, isDisabled, hideSymbol, i ...@@ -21,7 +21,11 @@ const TokenSnippet = ({ data, className, logoSize = 6, isDisabled, hideSymbol, i
<Flex className={ className } alignItems="center" columnGap={ 2 } w="100%"> <Flex className={ className } alignItems="center" columnGap={ 2 } w="100%">
<TokenLogo boxSize={ logoSize } data={ data } isLoading={ isLoading }/> <TokenLogo boxSize={ logoSize } data={ data } isLoading={ isLoading }/>
<AddressLink hash={ data?.address || '' } alias={ data?.name || 'Unnamed token' } type="token" isDisabled={ isDisabled } isLoading={ isLoading }/> <AddressLink hash={ data?.address || '' } alias={ data?.name || 'Unnamed token' } type="token" isDisabled={ isDisabled } isLoading={ isLoading }/>
{ data?.symbol && !hideSymbol && <Text variant="secondary">({ trimTokenSymbol(data.symbol) })</Text> } { data?.symbol && !hideSymbol && (
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>({ trimTokenSymbol(data.symbol) })</span>
</Skeleton>
) }
</Flex> </Flex>
); );
}; };
......
...@@ -87,8 +87,11 @@ const TokenDetails = ({ tokenQuery }: Props) => { ...@@ -87,8 +87,11 @@ const TokenDetails = ({ tokenQuery }: Props) => {
title="Price" title="Price"
hint="Price per token on the exchanges" hint="Price per token on the exchanges"
alignSelf="center" alignSelf="center"
isLoading={ tokenQuery.isPlaceholderData }
> >
{ `$${ exchangeRate }` } <Skeleton isLoaded={ !tokenQuery.isPlaceholderData } display="inline-block">
<span>{ `$${ exchangeRate }` }</span>
</Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ marketcap && ( { marketcap && (
...@@ -96,8 +99,11 @@ const TokenDetails = ({ tokenQuery }: Props) => { ...@@ -96,8 +99,11 @@ const TokenDetails = ({ tokenQuery }: Props) => {
title="Fully diluted market cap" title="Fully diluted market cap"
hint="Total supply * Price" hint="Total supply * Price"
alignSelf="center" alignSelf="center"
isLoading={ tokenQuery.isPlaceholderData }
> >
{ `$${ marketcap }` } <Skeleton isLoaded={ !tokenQuery.isPlaceholderData } display="inline-block">
<span>{ `$${ marketcap }` }</span>
</Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
<DetailsInfoItem <DetailsInfoItem
......
...@@ -31,7 +31,7 @@ type Props = { ...@@ -31,7 +31,7 @@ type Props = {
const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => { const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const router = useRouter(); const router = useRouter();
const { isError, isLoading, isPlaceholderData, data, pagination, isPaginationVisible } = transfersQuery; const { isError, isPlaceholderData, data, pagination, isPaginationVisible } = transfersQuery;
const [ newItemsCount, setNewItemsCount ] = useGradualIncrement(0); const [ newItemsCount, setNewItemsCount ] = useGradualIncrement(0);
const [ socketAlert, setSocketAlert ] = React.useState(''); const [ socketAlert, setSocketAlert ] = React.useState('');
...@@ -52,7 +52,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => { ...@@ -52,7 +52,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => {
topic: `tokens:${ router.query.hash?.toString().toLowerCase() }`, topic: `tokens:${ router.query.hash?.toString().toLowerCase() }`,
onSocketClose: handleSocketClose, onSocketClose: handleSocketClose,
onSocketError: handleSocketError, onSocketError: handleSocketError,
isDisabled: isLoading || isError || pagination.page !== 1, isDisabled: isPlaceholderData || isError || pagination.page !== 1,
}); });
useSocketMessage({ useSocketMessage({
channel, channel,
...@@ -99,7 +99,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => { ...@@ -99,7 +99,7 @@ const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => {
return ( return (
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
isLoading={ !isPlaceholderData && isLoading } isLoading={ false }
items={ data?.items } items={ data?.items }
skeletonProps={{ skeletonProps={{
isLongSkeleton: true, isLongSkeleton: true,
......
import { Box, Tag, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import nftIcon from 'icons/nft_shield.svg';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import TextAd from 'ui/shared/ad/TextAd';
import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import LinkExternal from 'ui/shared/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/Pagination';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import TokenInstanceDetails from './TokenInstanceDetails';
import TokenInstanceMetadata from './TokenInstanceMetadata';
import TokenInstanceSkeleton from './TokenInstanceSkeleton';
export type TokenTabs = 'token_transfers' | 'holders'
const TokenInstanceContent = () => {
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const hash = router.query.hash?.toString();
const id = router.query.id?.toString();
const tab = router.query.tab?.toString();
const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenInstanceQuery = useApiQuery('token_instance', {
pathParams: { hash, id },
queryOptions: { enabled: Boolean(hash && id) },
});
const transfersQuery = useQueryWithPages({
resourceName: 'token_instance_transfers',
pathParams: { hash, id },
scrollRef,
options: {
enabled: Boolean(hash && (!tab || tab === 'token_transfers') && tokenInstanceQuery.data),
},
});
const shouldFetchHolders = tokenInstanceQuery.data && !tokenInstanceQuery.data.is_unique;
const holdersQuery = useQueryWithPages({
resourceName: 'token_instance_holders',
pathParams: { hash, id },
scrollRef,
options: {
enabled: Boolean(hash && (!tab || tab === 'holders') && shouldFetchHolders),
},
});
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to token page',
url: appProps.referrer,
};
}, [ appProps.referrer, hash ]);
const tabs: Array<RoutedTab> = [
{
id: 'token_transfers',
title: 'Token transfers',
component: <TokenTransfer transfersQuery={ transfersQuery } tokenId={ id } token={ tokenInstanceQuery.data?.token }/>,
},
shouldFetchHolders ?
{ id: 'holders', title: 'Holders', component: <TokenHolders holdersQuery={ holdersQuery } token={ tokenInstanceQuery.data?.token }/> } :
undefined,
{ id: 'metadata', title: 'Metadata', component: <TokenInstanceMetadata data={ tokenInstanceQuery.data?.metadata }/> },
].filter(Boolean);
if (tokenInstanceQuery.isError) {
throw Error('Token instance fetch failed', { cause: tokenInstanceQuery.error });
}
if (tokenInstanceQuery.isLoading) {
return <TokenInstanceSkeleton/>;
}
const nftShieldIcon = <Icon as={ nftIcon } boxSize={ 6 } mr={ 2 }/>;
const tokenTag = <Tag>{ tokenInstanceQuery.data.token.type }</Tag>;
const address = {
hash: hash || '',
is_contract: true,
implementation_name: null,
watchlist_names: [],
watchlist_address_id: null,
};
const appLink = (() => {
if (!tokenInstanceQuery.data.external_app_url) {
return null;
}
try {
const url = new URL(tokenInstanceQuery.data.external_app_url);
return (
<Box fontSize="sm" mt={ 6 }>
<span>View in app </span>
<LinkExternal href={ tokenInstanceQuery.data.external_app_url }>
{ url.hostname }
</LinkExternal>
</Box>
);
} catch (error) {
return (
<Box fontSize="sm" mt={ 6 }>
<LinkExternal href={ tokenInstanceQuery.data.external_app_url }>
View in app
</LinkExternal>
</Box>
);
}
})();
let pagination: PaginationProps | undefined;
let isPaginationVisible;
if (tab === 'token_transfers') {
pagination = transfersQuery.pagination;
isPaginationVisible = transfersQuery.isPaginationVisible;
} else if (tab === 'holders') {
pagination = holdersQuery.pagination;
isPaginationVisible = holdersQuery.isPaginationVisible;
}
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` }
backLink={ backLink }
beforeTitle={ nftShieldIcon }
contentAfter={ tokenTag }
/>
<AddressHeadingInfo address={ address } token={ tokenInstanceQuery.data.token }/>
{ appLink }
<TokenInstanceDetails data={ tokenInstanceQuery.data } scrollRef={ scrollRef }/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box>
{ tokenInstanceQuery.isLoading ? <SkeletonTabs/> : (
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? { mt: 8 } : { mt: 3, py: 5, marginBottom: 0 } }
rightSlot={ !isMobile && isPaginationVisible && pagination ? <Pagination { ...pagination }/> : null }
stickyEnabled={ !isMobile }
/>
) }
{ !tokenInstanceQuery.isLoading && !tokenInstanceQuery.isError && <Box h={{ base: 0, lg: '40vh' }}/> }
</>
);
};
export default React.memo(TokenInstanceContent);
import { Box, Flex, Grid, GridItem, useColorModeValue } from '@chakra-ui/react'; import { Flex, Grid, GridItem, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token'; import type { TokenInstance } from 'types/api/token';
...@@ -18,11 +18,12 @@ import TokenInstanceCreatorAddress from './details/TokenInstanceCreatorAddress'; ...@@ -18,11 +18,12 @@ import TokenInstanceCreatorAddress from './details/TokenInstanceCreatorAddress';
import TokenInstanceTransfersCount from './details/TokenInstanceTransfersCount'; import TokenInstanceTransfersCount from './details/TokenInstanceTransfersCount';
interface Props { interface Props {
data: TokenInstance; data?: TokenInstance;
isLoading?: boolean;
scrollRef?: React.RefObject<HTMLDivElement>; scrollRef?: React.RefObject<HTMLDivElement>;
} }
const TokenInstanceDetails = ({ data, scrollRef }: Props) => { const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
const handleCounterItemClick = React.useCallback(() => { const handleCounterItemClick = React.useCallback(() => {
window.setTimeout(() => { window.setTimeout(() => {
// cannot do scroll instantly, have to wait a little // cannot do scroll instantly, have to wait a little
...@@ -30,7 +31,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -30,7 +31,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
}, 500); }, 500);
}, [ scrollRef ]); }, [ scrollRef ]);
const metadata = parseMetadata(data.metadata); const metadata = parseMetadata(data?.metadata);
const hasMetadata = metadata && Boolean((metadata.name || metadata.description || metadata.attributes)); const hasMetadata = metadata && Boolean((metadata.name || metadata.description || metadata.attributes));
const attributeBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50'); const attributeBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
...@@ -44,6 +45,10 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -44,6 +45,10 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
/> />
); );
if (!data) {
return null;
}
return ( return (
<> <>
<Flex alignItems="flex-start" mt={ 8 } flexDir={{ base: 'column-reverse', lg: 'row' }} columnGap={ 6 } rowGap={ 6 }> <Flex alignItems="flex-start" mt={ 8 } flexDir={{ base: 'column-reverse', lg: 'row' }} columnGap={ 6 } rowGap={ 6 }>
...@@ -57,34 +62,37 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -57,34 +62,37 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
<DetailsInfoItem <DetailsInfoItem
title="Token" title="Token"
hint="Token name" hint="Token name"
isLoading={ isLoading }
> >
<TokenSnippet data={ data.token }/> <TokenSnippet data={ data.token } isLoading={ isLoading }/>
</DetailsInfoItem> </DetailsInfoItem>
{ data.is_unique && data.owner && ( { data.is_unique && data.owner && (
<DetailsInfoItem <DetailsInfoItem
title="Owner" title="Owner"
hint="Current owner of this token instance" hint="Current owner of this token instance"
isLoading={ isLoading }
> >
<Address> <Address>
<AddressIcon address={ data.owner }/> <AddressIcon address={ data.owner } isLoading={ isLoading }/>
<AddressLink type="address" hash={ data.owner.hash } ml={ 2 }/> <AddressLink type="address" hash={ data.owner.hash } ml={ 2 } isLoading={ isLoading }/>
<CopyToClipboard text={ data.owner.hash }/> <CopyToClipboard text={ data.owner.hash } isLoading={ isLoading }/>
</Address> </Address>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
<TokenInstanceCreatorAddress hash={ data.token.address }/> <TokenInstanceCreatorAddress hash={ isLoading ? '' : data.token.address }/>
<DetailsInfoItem <DetailsInfoItem
title="Token ID" title="Token ID"
hint="This token instance unique token ID" hint="This token instance unique token ID"
isLoading={ isLoading }
> >
<Flex alignItems="center" overflow="hidden"> <Flex alignItems="center" overflow="hidden">
<Box overflow="hidden" display="inline-block" w="100%"> <Skeleton isLoaded={ !isLoading } overflow="hidden" display="inline-block" w="100%">
<HashStringShortenDynamic hash={ data.id }/> <HashStringShortenDynamic hash={ data.id }/>
</Box> </Skeleton>
<CopyToClipboard text={ data.id } ml={ 1 }/> <CopyToClipboard text={ data.id } isLoading={ isLoading }/>
</Flex> </Flex>
</DetailsInfoItem> </DetailsInfoItem>
<TokenInstanceTransfersCount hash={ data.token.address } id={ data.id } onClick={ handleCounterItemClick }/> <TokenInstanceTransfersCount hash={ isLoading ? '' : data.token.address } id={ isLoading ? '' : data.id } onClick={ handleCounterItemClick }/>
</Grid> </Grid>
<NftMedia <NftMedia
imageUrl={ data.image_url } imageUrl={ data.image_url }
...@@ -92,6 +100,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -92,6 +100,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
w="250px" w="250px"
flexShrink={ 0 } flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }} alignSelf={{ base: 'center', lg: 'flex-start' }}
isLoading={ isLoading }
/> />
</Flex> </Flex>
<Grid <Grid
...@@ -110,8 +119,11 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -110,8 +119,11 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
hint="NFT name" hint="NFT name"
whiteSpace="normal" whiteSpace="normal"
wordBreak="break-word" wordBreak="break-word"
isLoading={ isLoading }
> >
{ metadata.name } <Skeleton isLoaded={ !isLoading }>
{ metadata.name }
</Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ metadata?.description && ( { metadata?.description && (
...@@ -120,8 +132,11 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -120,8 +132,11 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
hint="NFT description" hint="NFT description"
whiteSpace="normal" whiteSpace="normal"
wordBreak="break-word" wordBreak="break-word"
isLoading={ isLoading }
> >
{ metadata.description } <Skeleton isLoaded={ !isLoading }>
{ metadata.description }
</Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ metadata?.attributes && ( { metadata?.attributes && (
...@@ -129,6 +144,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -129,6 +144,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
title="Attributes" title="Attributes"
hint="NFT attributes" hint="NFT attributes"
whiteSpace="normal" whiteSpace="normal"
isLoading={ isLoading }
> >
<Grid gap={ 2 } templateColumns="repeat(auto-fill,minmax(160px, 1fr))" w="100%"> <Grid gap={ 2 } templateColumns="repeat(auto-fill,minmax(160px, 1fr))" w="100%">
{ metadata.attributes.map((attribute, index) => ( { metadata.attributes.map((attribute, index) => (
...@@ -138,9 +154,16 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -138,9 +154,16 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
borderRadius="md" borderRadius="md"
px={ 4 } px={ 4 }
py={ 2 } py={ 2 }
display="flex"
flexDir="column"
alignItems="flex-start"
> >
<Box fontSize="xs" color="text_secondary" fontWeight={ 500 }>{ attribute.trait_type }</Box> <Skeleton isLoaded={ !isLoading } fontSize="xs" lineHeight={ 4 } color="text_secondary" fontWeight={ 500 } mb={ 1 }>
<Box fontSize="sm">{ attribute.value }</Box> <span>{ attribute.trait_type }</span>
</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm">
<span>{ attribute.value }</span>
</Skeleton>
</GridItem> </GridItem>
)) } )) }
</Grid> </Grid>
...@@ -149,7 +172,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -149,7 +172,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
</> </>
) } ) }
{ divider } { divider }
<DetailsSponsoredItem/> <DetailsSponsoredItem isLoading={ isLoading }/>
</Grid> </Grid>
</> </>
); );
......
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import type { TokenInstance } from 'types/api/token'; import type { TokenInstance } from 'types/api/token';
import ContentLoader from 'ui/shared/ContentLoader';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import RawDataSnippet from 'ui/shared/RawDataSnippet'; import RawDataSnippet from 'ui/shared/RawDataSnippet';
...@@ -12,15 +13,20 @@ type Format = 'JSON' | 'Table' ...@@ -12,15 +13,20 @@ type Format = 'JSON' | 'Table'
interface Props { interface Props {
data: TokenInstance['metadata'] | undefined; data: TokenInstance['metadata'] | undefined;
isPlaceholderData?: boolean;
} }
const TokenInstanceMetadata = ({ data }: Props) => { const TokenInstanceMetadata = ({ data, isPlaceholderData }: Props) => {
const [ format, setFormat ] = React.useState<Format>('Table'); const [ format, setFormat ] = React.useState<Format>('Table');
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => { const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setFormat(event.target.value as Format); setFormat(event.target.value as Format);
}, []); }, []);
if (isPlaceholderData) {
return <ContentLoader/>;
}
if (!data) { if (!data) {
return <Box>There is no metadata for this NFT</Box>; return <Box>There is no metadata for this NFT</Box>;
} }
......
import { Box, Flex, Grid, Skeleton, SkeletonCircle } from '@chakra-ui/react';
import React from 'react';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
const TokenInstanceSkeleton = () => {
return (
<Box>
<Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/>
<Skeleton h={ 10 } maxW="400px" w="100%" mb={ 6 }/>
<Flex align="center">
<SkeletonCircle boxSize={ 6 } flexShrink={ 0 }/>
<Skeleton h={ 6 } w={{ base: '100px', lg: '420px' }} ml={ 2 } borderRadius="full"/>
<Skeleton h={ 8 } w="36px" ml={ 3 } flexShrink={ 0 }/>
<Skeleton h={ 8 } w="36px" ml={ 3 } flexShrink={ 0 }/>
</Flex>
<Flex columnGap={ 6 } rowGap={ 6 } alignItems="flex-start" flexDir={{ base: 'column-reverse', lg: 'row' }} mt={ 8 }>
<Grid
columnGap={ 8 }
rowGap={{ base: 5, lg: 7 }}
templateColumns={{ base: '1fr', lg: '200px 1fr' }}
flexGrow={ 1 }
w="100%"
>
<DetailsSkeletonRow w="30%"/>
<DetailsSkeletonRow w="100%" maxW="450px"/>
<DetailsSkeletonRow w="100%" maxW="450px"/>
<DetailsSkeletonRow w="10%"/>
<DetailsSkeletonRow w="10%"/>
<DetailsSkeletonRow w="10%"/>
</Grid>
<Skeleton h="250px" w="250px" flexShrink={ 0 } alignSelf="center"/>
</Flex>
<SkeletonTabs/>
</Box>
);
};
export default TokenInstanceSkeleton;
import React from 'react'; import React from 'react';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { ADDRESS_INFO } from 'stubs/address';
import Address from 'ui/shared/address/Address'; import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon'; import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
interface Props { interface Props {
hash: string; hash: string;
...@@ -15,17 +15,17 @@ interface Props { ...@@ -15,17 +15,17 @@ interface Props {
const TokenInstanceCreatorAddress = ({ hash }: Props) => { const TokenInstanceCreatorAddress = ({ hash }: Props) => {
const addressQuery = useApiQuery('address', { const addressQuery = useApiQuery('address', {
pathParams: { hash }, pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: ADDRESS_INFO,
},
}); });
if (addressQuery.isError) { if (addressQuery.isError) {
return null; return null;
} }
if (addressQuery.isLoading) { if (!addressQuery.data?.creator_address_hash) {
return <DetailsSkeletonRow w="30%"/>;
}
if (!addressQuery.data.creator_address_hash) {
return null; return null;
} }
...@@ -39,11 +39,12 @@ const TokenInstanceCreatorAddress = ({ hash }: Props) => { ...@@ -39,11 +39,12 @@ const TokenInstanceCreatorAddress = ({ hash }: Props) => {
<DetailsInfoItem <DetailsInfoItem
title="Creator" title="Creator"
hint="Address that deployed this token contract" hint="Address that deployed this token contract"
isLoading={ addressQuery.isPlaceholderData }
> >
<Address> <Address>
<AddressIcon address={ creatorAddress }/> <AddressIcon address={ creatorAddress } isLoading={ addressQuery.isPlaceholderData }/>
<AddressLink type="address" hash={ creatorAddress.hash } ml={ 2 }/> <AddressLink type="address" hash={ creatorAddress.hash } ml={ 2 } isLoading={ addressQuery.isPlaceholderData }/>
<CopyToClipboard text={ creatorAddress.hash }/> <CopyToClipboard text={ creatorAddress.hash } isLoading={ addressQuery.isPlaceholderData }/>
</Address> </Address>
</DetailsInfoItem> </DetailsInfoItem>
); );
......
import { Skeleton } from '@chakra-ui/react';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import React from 'react'; import React from 'react';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
interface Props { interface Props {
hash: string; hash: string;
...@@ -15,17 +15,19 @@ interface Props { ...@@ -15,17 +15,19 @@ interface Props {
const TokenInstanceTransfersCount = ({ hash, id, onClick }: Props) => { const TokenInstanceTransfersCount = ({ hash, id, onClick }: Props) => {
const transfersCountQuery = useApiQuery('token_instance_transfers_count', { const transfersCountQuery = useApiQuery('token_instance_transfers_count', {
pathParams: { hash, id }, pathParams: { hash, id },
queryOptions: {
enabled: Boolean(hash && id),
placeholderData: {
transfers_count: 420,
},
},
}); });
if (transfersCountQuery.isError) { if (transfersCountQuery.isError) {
return null; return null;
} }
if (transfersCountQuery.isLoading) { if (!transfersCountQuery.data?.transfers_count) {
return <DetailsSkeletonRow w="30%"/>;
}
if (!transfersCountQuery.data.transfers_count) {
return null; return null;
} }
...@@ -37,13 +39,16 @@ const TokenInstanceTransfersCount = ({ hash, id, onClick }: Props) => { ...@@ -37,13 +39,16 @@ const TokenInstanceTransfersCount = ({ hash, id, onClick }: Props) => {
<DetailsInfoItem <DetailsInfoItem
title="Transfers" title="Transfers"
hint="Number of transfer for the token instance" hint="Number of transfer for the token instance"
isLoading={ transfersCountQuery.isPlaceholderData }
> >
<LinkInternal <Skeleton isLoaded={ !transfersCountQuery.isPlaceholderData } display="inline-block">
href={ url } <LinkInternal
onClick={ transfersCountQuery.data.transfers_count > 0 ? onClick : undefined } href={ url }
> onClick={ transfersCountQuery.data.transfers_count > 0 ? onClick : undefined }
{ transfersCountQuery.data.transfers_count.toLocaleString() } >
</LinkInternal> { transfersCountQuery.data.transfers_count.toLocaleString() }
</LinkInternal>
</Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
); );
}; };
......
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