Commit a4335107 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #587 from blockscout/token-instance-page

token instance page
parents 27974d19 6aae1e3d
...@@ -24,7 +24,7 @@ import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log'; ...@@ -24,7 +24,7 @@ import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { RawTracesResponse } from 'types/api/rawTrace'; import type { RawTracesResponse } from 'types/api/rawTrace';
import type { SearchResult, SearchResultFilters } from 'types/api/search'; import type { SearchResult, SearchResultFilters } from 'types/api/search';
import type { Counters, StatsCharts, StatsChart, HomeStats } from 'types/api/stats'; import type { Counters, StatsCharts, StatsChart, HomeStats } from 'types/api/stats';
import type { TokenCounters, TokenInfo, TokenHolders } from 'types/api/tokenInfo'; import type { TokenCounters, TokenInfo, TokenHolders, TokenInventoryResponse, TokenInstance, TokenInstanceTransfersCount } from 'types/api/token';
import type { TokensResponse, TokensFilters } from 'types/api/tokens'; import type { TokensResponse, TokensFilters } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer'; import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
import type { TransactionsResponseValidated, TransactionsResponsePending, Transaction } from 'types/api/transaction'; import type { TransactionsResponseValidated, TransactionsResponsePending, Transaction } from 'types/api/transaction';
...@@ -236,12 +236,25 @@ export const RESOURCES = { ...@@ -236,12 +236,25 @@ export const RESOURCES = {
paginationFields: [ 'block_number' as const, 'items_count' as const, 'index' as const ], paginationFields: [ 'block_number' as const, 'items_count' as const, 'index' as const ],
filterFields: [], filterFields: [],
}, },
token_inventory: {
path: '/api/v2/tokens/:hash/instances',
paginationFields: [ 'unique_token' as const ],
filterFields: [],
},
tokens: { tokens: {
path: '/api/v2/tokens', path: '/api/v2/tokens',
paginationFields: [ 'holder_count' as const, 'items_count' as const, 'name' as const ], paginationFields: [ 'holder_count' as const, 'items_count' as const, 'name' as const ],
filterFields: [ 'filter' as const, 'type' as const ], filterFields: [ 'filter' as const, 'type' as const ],
}, },
// TOKEN INSTANCE
token_instance: {
path: '/api/v2/tokens/:hash/instances/:id',
},
token_instance_transfers_count: {
path: '/api/v2/tokens/:hash/instances/:id/transfers-count',
},
// HOMEPAGE // HOMEPAGE
homepage_stats: { homepage_stats: {
path: '/api/v2/stats', path: '/api/v2/stats',
...@@ -315,7 +328,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | ...@@ -315,7 +328,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' | 'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' | 'search' |
'address_logs' | 'address_tokens' | 'address_logs' | 'address_tokens' |
'token_transfers' | 'token_holders' | 'tokens'; 'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -363,6 +376,9 @@ Q extends 'token' ? TokenInfo : ...@@ -363,6 +376,9 @@ Q extends 'token' ? TokenInfo :
Q extends 'token_counters' ? TokenCounters : Q extends 'token_counters' ? TokenCounters :
Q extends 'token_transfers' ? TokenTransferResponse : Q extends 'token_transfers' ? TokenTransferResponse :
Q extends 'token_holders' ? TokenHolders : Q extends 'token_holders' ? TokenHolders :
Q extends 'token_instance' ? TokenInstance :
Q extends 'token_instance_transfers_count' ? TokenInstanceTransfersCount :
Q extends 'token_inventory' ? TokenInventoryResponse :
Q extends 'tokens' ? TokensResponse : Q extends 'tokens' ? TokensResponse :
Q extends 'search' ? SearchResult : Q extends 'search' ? SearchResult :
Q extends 'contract' ? SmartContract : Q extends 'contract' ? SmartContract :
......
...@@ -141,6 +141,9 @@ function makePolicyMap() { ...@@ -141,6 +141,9 @@ function makePolicyMap() {
// walletconnect // walletconnect
'*.walletconnect.com', '*.walletconnect.com',
// token's media
'ipfs.io',
], ],
'font-src': [ 'font-src': [
......
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
const TOKEN_TYPE: Array<{ title: string; id: TokenType }> = [ const TOKEN_TYPE: Array<{ title: string; id: TokenType }> = [
{ title: 'ERC-20', id: 'ERC-20' }, { title: 'ERC-20', id: 'ERC-20' },
......
import type { TokenHolders } from 'types/api/tokenInfo'; import type { TokenHolders } from 'types/api/token';
import { withName, withoutName } from 'mocks/address/address'; import { withName, withoutName } from 'mocks/address/address';
......
import type { TokenCounters, TokenInfo } from 'types/api/tokenInfo'; import type { TokenCounters, TokenInfo } from 'types/api/token';
export const tokenInfo: TokenInfo = { export const tokenInfo: TokenInfo = {
address: '0x55d536e4d6c1993d8ef2e2a4ef77f02088419420', address: '0x55d536e4d6c1993d8ef2e2a4ef77f02088419420',
......
import type { TokenInstance } from 'types/api/token';
import * as addressMock from '../address/address';
import { tokenInfoERC721a } from './tokenInfo';
export const base: TokenInstance = {
animation_url: null,
external_app_url: null,
id: '32925298983216553915666621415831103694597106215670571463977478984525997408266',
image_url: null,
is_unique: false,
holder_address_hash: null,
metadata: {
animation_url: null,
description: 'Sign for you!',
external_link: null,
image: 'https://i.seadn.io/gcs/files/1ee1c5e1ead058322615e3206abb8ba3.png?w=500&auto=format',
name: 'Sign4U',
},
owner: addressMock.withName,
token: tokenInfoERC721a,
};
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import type { PageParams } from 'lib/next/token/types';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import TokenInstance from 'ui/pages/TokenInstance';
const TokenInstancePage: NextPage<PageParams> = () => {
const title = getNetworkTitle();
return (
<>
<Head>
<title>{ title }</title>
</Head>
<TokenInstance/>
</>
);
};
export default TokenInstancePage;
export { getServerSideProps } from 'lib/next/token/getServerSideProps';
...@@ -3,7 +3,7 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -3,7 +3,7 @@ import type { Transaction } from 'types/api/transaction';
import type { AddressTag, WatchlistName } from './addressParams'; import type { AddressTag, WatchlistName } from './addressParams';
import type { Block } from './block'; import type { Block } from './block';
import type { InternalTransaction } from './internalTransaction'; import type { InternalTransaction } from './internalTransaction';
import type { TokenInfo, TokenType } from './tokenInfo'; import type { TokenInfo, TokenType } from './token';
import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer'; import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer';
export interface Address { export interface Address {
......
...@@ -34,3 +34,28 @@ export type TokenHoldersPagination = { ...@@ -34,3 +34,28 @@ export type TokenHoldersPagination = {
items_count: number; items_count: number;
value: string; value: string;
} }
export interface TokenInstance {
is_unique: boolean;
id: string;
holder_address_hash: string | null;
image_url: string | null;
animation_url: string | null;
external_app_url: string | null;
metadata: unknown;
owner: AddressParam;
token: TokenInfo;
}
export interface TokenInstanceTransfersCount {
transfers_count: number;
}
export interface TokenInventoryResponse {
items: Array<TokenInstance>;
next_page_params: TokenInventoryPagination;
}
export type TokenInventoryPagination = {
unique_token: number;
}
import type { AddressParam } from './addressParams'; import type { AddressParam } from './addressParams';
import type { TokenInfoGeneric, TokenType } from './tokenInfo'; import type { TokenInfoGeneric, TokenType } from './token';
export type Erc20TotalPayload = { export type Erc20TotalPayload = {
decimals: string | null; decimals: string | null;
......
export type Tokenlist = {
message: string;
result: Array<TokenlistItem> | string;
}
export type TokenlistItem = {
balance: number;
contractAddress: string;
decimals: number | null;
id: number;
name: string;
symbol: string;
type: string;
}
import type { TokenInfo, TokenType } from './tokenInfo'; import type { TokenInfo, TokenType } from './token';
export type TokensResponse = { export type TokensResponse = {
items: Array<TokenInfo>; items: Array<TokenInfo>;
......
...@@ -9,25 +9,19 @@ import appConfig from 'configs/app/config'; ...@@ -9,25 +9,19 @@ import appConfig from 'configs/app/config';
import blockIcon from 'icons/block.svg'; import blockIcon from 'icons/block.svg';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import link from 'lib/link/link'; import link from 'lib/link/link';
import AddressCounterItem from 'ui/address/details/AddressCounterItem'; import AddressCounterItem from 'ui/address/details/AddressCounterItem';
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 AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import HashStringShorten from 'ui/shared/HashStringShorten';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import LinkExternal from 'ui/shared/LinkExternal'; import LinkExternal from 'ui/shared/LinkExternal';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import AddressAddToMetaMask from './details/AddressAddToMetaMask';
import AddressBalance from './details/AddressBalance'; import AddressBalance from './details/AddressBalance';
import AddressDetailsSkeleton from './details/AddressDetailsSkeleton'; import AddressDetailsSkeleton from './details/AddressDetailsSkeleton';
import AddressFavoriteButton from './details/AddressFavoriteButton';
import AddressNameInfo from './details/AddressNameInfo'; import AddressNameInfo from './details/AddressNameInfo';
import AddressQrCode from './details/AddressQrCode';
import TokenSelect from './tokenSelect/TokenSelect'; import TokenSelect from './tokenSelect/TokenSelect';
interface Props { interface Props {
...@@ -37,7 +31,6 @@ interface Props { ...@@ -37,7 +31,6 @@ interface Props {
const AddressDetails = ({ addressQuery, scrollRef }: Props) => { const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile();
const addressHash = router.query.id?.toString(); const addressHash = router.query.id?.toString();
...@@ -92,18 +85,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => { ...@@ -92,18 +85,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
return ( return (
<Box> <Box>
<Flex alignItems="center"> <AddressHeadingInfo address={ data } token={ data.token } isLinkDisabled/>
<AddressIcon address={ data }/>
<Text ml={ 2 } fontFamily="heading" fontWeight={ 500 }>
{ isMobile ? <HashStringShorten hash={ data.hash }/> : data.hash }
</Text>
<CopyToClipboard text={ data.hash }/>
{ data.is_contract && data.token && <AddressAddToMetaMask ml={ 2 } token={ data.token }/> }
{ !data.is_contract && (
<AddressFavoriteButton hash={ data.hash } isAdded={ Boolean(data.watchlist_names?.length) } ml={ 3 }/>
) }
<AddressQrCode hash={ data.hash } ml={ 2 }/>
</Flex>
{ explorers.length > 0 && ( { explorers.length > 0 && (
<Flex mt={ 8 } columnGap={ 4 } flexWrap="wrap"> <Flex mt={ 8 } columnGap={ 4 } flexWrap="wrap">
<Text fontSize="sm">Verify with other explorers</Text> <Text fontSize="sm">Verify with other explorers</Text>
......
...@@ -6,7 +6,7 @@ import React from 'react'; ...@@ -6,7 +6,7 @@ import React from 'react';
import type { SocketMessage } from 'lib/socket/types'; import type { SocketMessage } from 'lib/socket/types';
import { AddressFromToFilterValues } from 'types/api/address'; import { AddressFromToFilterValues } from 'types/api/address';
import type { AddressFromToFilter, AddressTokenTransferResponse } from 'types/api/address'; import type { AddressFromToFilter, AddressTokenTransferResponse } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import crossIcon from 'icons/cross.svg'; import crossIcon from 'icons/cross.svg';
......
...@@ -2,7 +2,7 @@ import { Box } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages'; import useQueryWithPages from 'lib/hooks/useQueryWithPages';
......
import { Box, chakra, Icon, Tooltip } from '@chakra-ui/react'; import { Box, chakra, Icon, Tooltip } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo'; import type { TokenInfo } from 'types/api/token';
import metamaskIcon from 'icons/metamask.svg'; import metamaskIcon from 'icons/metamask.svg';
import useToast from 'lib/hooks/useToast'; import useToast from 'lib/hooks/useToast';
......
...@@ -3,7 +3,7 @@ import type { Dictionary } from 'lodash'; ...@@ -3,7 +3,7 @@ import type { Dictionary } from 'lodash';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
import arrowIcon from 'icons/arrows/east.svg'; import arrowIcon from 'icons/arrows/east.svg';
import searchIcon from 'icons/search.svg'; import searchIcon from 'icons/search.svg';
......
import { Center, Flex, Icon, Link, Text, LinkBox, LinkOverlay, useColorModeValue } from '@chakra-ui/react'; import { Flex, Link, Text, LinkBox, LinkOverlay, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { AddressTokenBalance } from 'types/api/address'; import type { AddressTokenBalance } from 'types/api/address';
import NFTIcon from 'icons/nft_shield.svg';
import link from 'lib/link/link'; import link from 'lib/link/link';
import NftImage from 'ui/shared/nft/NftImage';
import TokenLogo from 'ui/shared/TokenLogo'; import TokenLogo from 'ui/shared/TokenLogo';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
...@@ -15,8 +15,7 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => { ...@@ -15,8 +15,7 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => {
return ( return (
<LinkBox <LinkBox
w={{ base: 'calc((100% - 12px)/2)', lg: '210px' }} w={{ base: '100%', lg: '210px' }}
h={{ base: 'auto', lg: '272px' }}
border="1px solid" border="1px solid"
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') } borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
borderRadius="12px" borderRadius="12px"
...@@ -26,16 +25,14 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => { ...@@ -26,16 +25,14 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => {
fontWeight={ 500 } fontWeight={ 500 }
lineHeight="20px" lineHeight="20px"
> >
<LinkOverlay href={ tokenLink }/> <LinkOverlay href={ tokenLink }>
<Center <NftImage
w={{ base: '100%', lg: '182px' }} mb="18px"
h={{ base: 'calc((100vw - 36px)/2 - 12px)', lg: '182px' }} url={ null }
bg={ useColorModeValue('blackAlpha.50', 'whiteAlpha.50') } fallbackPadding="30px"
mb="18px" cursor="pointer"
borderRadius="12px" />
> </LinkOverlay>
<Icon as={ NFTIcon } boxSize="112px" color={ useColorModeValue('blackAlpha.500', 'whiteAlpha.500') }/>
</Center>
{ tokenId && ( { tokenId && (
<Flex mb={ 2 } ml={ 1 }> <Flex mb={ 2 } ml={ 1 }>
<Text whiteSpace="pre" variant="secondary">ID# </Text> <Text whiteSpace="pre" variant="secondary">ID# </Text>
......
import { Flex, Skeleton, Text } from '@chakra-ui/react'; import { Grid, Skeleton, Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
...@@ -38,11 +38,18 @@ const TokensWithIds = ({ tokensQuery }: Props) => { ...@@ -38,11 +38,18 @@ const TokensWithIds = ({ tokensQuery }: Props) => {
return ( return (
<> <>
{ bar } { bar }
<Flex columnGap={ 6 } rowGap={ 6 } flexWrap="wrap"> <Grid
<Skeleton w="210px" h="272px"/> w="100%"
<Skeleton w="210px" h="272px"/> columnGap={{ base: 3, lg: 6 }}
<Skeleton w="210px" h="272px"/> rowGap={{ base: 3, lg: 6 }}
</Flex> gridTemplateColumns={{ base: 'repeat(2, calc((100% - 12px)/2))', lg: 'repeat(auto-fill, minmax(210px, 1fr))' }}
>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
</Grid>
</> </>
); );
} }
...@@ -54,9 +61,14 @@ const TokensWithIds = ({ tokensQuery }: Props) => { ...@@ -54,9 +61,14 @@ const TokensWithIds = ({ tokensQuery }: Props) => {
return ( return (
<> <>
{ bar } { bar }
<Flex columnGap={{ base: 3, lg: 6 }} rowGap={{ base: 3, lg: 6 }} flexWrap="wrap"> <Grid
w="100%"
columnGap={{ base: 3, lg: 6 }}
rowGap={{ base: 3, lg: 6 }}
gridTemplateColumns={{ base: 'repeat(2, calc((100% - 12px)/2))', lg: 'repeat(auto-fill, minmax(210px, 1fr))' }}
>
{ data.items.map(item => <NFTItem key={ item.token.address } { ...item }/>) } { data.items.map(item => <NFTItem key={ item.token.address } { ...item }/>) }
</Flex> </Grid>
</> </>
); );
}; };
......
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import type { AddressTokenBalance } from 'types/api/address'; import type { AddressTokenBalance } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
import { ZERO } from 'lib/consts'; import { ZERO } from 'lib/consts';
......
...@@ -2,7 +2,7 @@ import { Flex, Skeleton, Tag, Box, Icon } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Flex, Skeleton, Tag, Box, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types'; import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import iconSuccess from 'icons/status/success.svg'; import iconSuccess from 'icons/status/success.svg';
......
...@@ -16,13 +16,15 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -16,13 +16,15 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import type { Props as PaginationProps } from 'ui/shared/Pagination'; import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination'; import Pagination from 'ui/shared/Pagination';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs'; import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
import TokenLogo from 'ui/shared/TokenLogo'; import TokenLogo from 'ui/shared/TokenLogo';
import TokenContractInfo from 'ui/token/TokenContractInfo'; import TokenContractInfo from 'ui/token/TokenContractInfo';
import TokenDetails from 'ui/token/TokenDetails'; import TokenDetails from 'ui/token/TokenDetails';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders'; import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenInventory from 'ui/token/TokenInventory';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer'; import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
export type TokenTabs = 'token_transfers' | 'holders' export type TokenTabs = 'token_transfers' | 'holders' | 'inventory';
const TokenPageContent = () => { const TokenPageContent = () => {
const router = useRouter(); const router = useRouter();
...@@ -72,11 +74,24 @@ const TokenPageContent = () => { ...@@ -72,11 +74,24 @@ const TokenPageContent = () => {
}, },
}); });
const inventoryQuery = useQueryWithPages({
resourceName: 'token_inventory',
pathParams: { hash: router.query.hash?.toString() },
scrollRef,
options: {
enabled: Boolean(router.query.hash && router.query.tab === 'inventory' && tokenQuery.data),
},
});
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
{ id: 'token_transfers', title: 'Token transfers', component: <TokenTransfer transfersQuery={ transfersQuery } token={ tokenQuery.data }/> }, { id: 'token_transfers', title: 'Token transfers', component: <TokenTransfer transfersQuery={ transfersQuery } token={ tokenQuery.data }/> },
{ id: 'holders', title: 'Holders', component: <TokenHolders tokenQuery={ tokenQuery } holdersQuery={ holdersQuery }/> }, { id: 'holders', title: 'Holders', component: <TokenHolders tokenQuery={ tokenQuery } holdersQuery={ holdersQuery }/> },
]; ];
if (tokenQuery.data?.type === 'ERC-1155' || tokenQuery.data?.type === 'ERC-721') {
tabs.push({ id: 'inventory', title: 'Inventory', component: <TokenInventory inventoryQuery={ inventoryQuery }/> });
}
let hasPagination; let hasPagination;
let pagination; let pagination;
...@@ -90,6 +105,11 @@ const TokenPageContent = () => { ...@@ -90,6 +105,11 @@ const TokenPageContent = () => {
pagination = holdersQuery.pagination; pagination = holdersQuery.pagination;
} }
if (router.query.tab === 'inventory') {
hasPagination = inventoryQuery.isPaginationVisible;
pagination = inventoryQuery.pagination;
}
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ trimTokenSymbol(tokenQuery.data.symbol) })` : ''; const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ trimTokenSymbol(tokenQuery.data.symbol) })` : '';
return ( return (
...@@ -118,12 +138,14 @@ const TokenPageContent = () => { ...@@ -118,12 +138,14 @@ const TokenPageContent = () => {
{ /* should stay before tabs to scroll up whith pagination */ } { /* should stay before tabs to scroll up whith pagination */ }
<Box ref={ scrollRef }></Box> <Box ref={ scrollRef }></Box>
<RoutedTabs { tokenQuery.isLoading ? <SkeletonTabs/> : (
tabs={ tabs } <RoutedTabs
tabListProps={ isMobile ? { mt: 8 } : { mt: 3, py: 5, marginBottom: 0 } } tabs={ tabs }
rightSlot={ !isMobile && hasPagination ? <Pagination { ...(pagination as PaginationProps) }/> : null } tabListProps={ isMobile ? { mt: 8 } : { mt: 3, py: 5, marginBottom: 0 } }
stickyEnabled={ !isMobile } rightSlot={ !isMobile && hasPagination ? <Pagination { ...(pagination as PaginationProps) }/> : null }
/> stickyEnabled={ !isMobile }
/>
) }
</Page> </Page>
); );
}; };
......
import React from 'react';
import Page from 'ui/shared/Page/Page';
import TokenInstanceContent from 'ui/tokenInstance/TokenInstanceContent';
export type TokenTabs = 'token_transfers' | 'holders'
const TokenInstance = () => {
return (
<Page>
<TokenInstanceContent/>
</Page>
);
};
export default TokenInstance;
import { Flex } from '@chakra-ui/react';
import React from 'react';
import type { AddressParam } from 'types/api/addressParams';
import type { TokenInfo } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
address: Pick<AddressParam, 'hash' | 'is_contract' | 'implementation_name' | 'watchlist_names'>;
token?: TokenInfo | null;
isLinkDisabled?: boolean;
}
const AddressHeadingInfo = ({ address, token, isLinkDisabled }: Props) => {
const isMobile = useIsMobile();
return (
<Flex alignItems="center">
<AddressIcon address={ address }/>
<AddressLink
type="address"
hash={ address.hash }
ml={ 2 }
fontFamily="heading"
fontWeight={ 500 }
truncation={ isMobile ? 'constant' : 'none' }
isDisabled={ isLinkDisabled }
/>
<CopyToClipboard text={ address.hash }/>
{ address.is_contract && token && <AddressAddToMetaMask ml={ 2 } token={ token }/> }
{ !address.is_contract && (
<AddressFavoriteButton hash={ address.hash } isAdded={ Boolean(address.watchlist_names?.length) } ml={ 3 }/>
) }
<AddressQrCode hash={ address.hash } ml={ 2 }/>
</Flex>
);
};
export default AddressHeadingInfo;
...@@ -21,6 +21,7 @@ const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLi ...@@ -21,6 +21,7 @@ const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLi
as="h1" as="h1"
size="lg" size="lg"
flex="none" flex="none"
wordBreak="break-word"
> >
{ text } { text }
</Heading> </Heading>
......
...@@ -7,7 +7,7 @@ import { ...@@ -7,7 +7,7 @@ import {
import React from 'react'; import React from 'react';
import type { AddressFromToFilter } from 'types/api/address'; import type { AddressFromToFilter } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
import PopoverFilter from 'ui/shared/filters/PopoverFilter'; import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import TokenTypeFilter from 'ui/shared/filters/TokenTypeFilter'; import TokenTypeFilter from 'ui/shared/filters/TokenTypeFilter';
......
import { CheckboxGroup, Checkbox, Text } from '@chakra-ui/react'; import { CheckboxGroup, Checkbox, Text } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
import TOKEN_TYPE from 'lib/token/tokenTypes'; import TOKEN_TYPE from 'lib/token/tokenTypes';
......
import type { ResponsiveValue } from '@chakra-ui/react';
import { AspectRatio, chakra, Icon, Image, shouldForwardProp, Skeleton, useColorModeValue } from '@chakra-ui/react';
import type { Property } from 'csstype';
import React from 'react';
import nftIcon from 'icons/nft_shield.svg';
interface Props {
url: string | null;
className?: string;
fallbackPadding?: string;
objectFit: ResponsiveValue<Property.ObjectFit>;
}
interface FallbackProps {
className?: string;
padding?: string;
}
const Fallback = ({ className, padding }: FallbackProps) => {
return (
<Icon
className={ className }
as={ nftIcon }
p={ padding ?? '50px' }
color={ useColorModeValue('blackAlpha.500', 'whiteAlpha.500') }
/>
);
};
const NftImage = ({ url, className, fallbackPadding, objectFit }: Props) => {
const [ isLoading, setIsLoading ] = React.useState(true);
const handleLoad = React.useCallback(() => {
setIsLoading(false);
}, []);
const handleLoadError = React.useCallback(() => {
setIsLoading(false);
}, []);
const _objectFit = objectFit || 'contain';
return (
<AspectRatio
className={ className }
ratio={ 1 / 1 }
bgColor={ useColorModeValue('blackAlpha.50', 'whiteAlpha.50') }
overflow="hidden"
borderRadius="md"
sx={{
'&>img': {
objectFit: _objectFit,
},
}}
>
<Image
w="100%"
h="100%"
objectFit={ _objectFit }
src={ url || undefined }
alt="Token instance image"
fallback={ url && isLoading ? <Skeleton/> : <Fallback className={ className } padding={ fallbackPadding }/> }
onError={ handleLoadError }
onLoad={ handleLoad }
/>
</AspectRatio>
);
};
const NftImageChakra = chakra(NftImage, {
shouldForwardProp: (prop) => {
const isChakraProp = !shouldForwardProp(prop);
if (isChakraProp && prop !== 'objectFit') {
return false;
}
return true;
},
});
export default NftImageChakra;
import { AspectRatio, chakra, Skeleton } from '@chakra-ui/react';
import React from 'react';
import NftImage from './NftImage';
import NftVideo from './NftVideo';
interface Props {
imageUrl: string | null;
animationUrl: string | null;
className?: string;
}
const NftMedia = ({ imageUrl, animationUrl, className }: Props) => {
const [ type, setType ] = React.useState<'image' | 'video' | undefined>(!animationUrl ? 'image' : undefined);
React.useEffect(() => {
if (!animationUrl) {
return;
}
// media could be either gif or video
// so we pre-fetch the resources in order to get its content type
fetch(animationUrl, { method: 'HEAD' })
.then((response) => {
const contentType = response.headers.get('content-type');
setType(contentType?.startsWith('video') ? 'video' : 'image');
});
}, [ animationUrl ]);
if (!type) {
return (
<AspectRatio
className={ className }
ratio={ 1 / 1 }
overflow="hidden"
borderRadius="md"
>
<Skeleton/>
</AspectRatio>
);
}
if (animationUrl && type === 'video') {
return <NftVideo className={ className } src={ animationUrl }/>;
}
return <NftImage className={ className } url={ animationUrl || imageUrl }/>;
};
export default chakra(NftMedia);
import { AspectRatio, Skeleton, chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
className?: string;
src: string;
}
const NftVideo = ({ className, src }: Props) => {
const [ isLoading, setIsLoading ] = React.useState(true);
const handleCanPlay = React.useCallback(() => {
setIsLoading(false);
}, []);
return (
<AspectRatio
className={ className }
ratio={ 1 / 1 }
overflow="hidden"
borderRadius="md"
>
<>
<chakra.video
src={ src }
autoPlay
disablePictureInPicture
loop
muted
playsInline
onCanPlayThrough={ handleCanPlay }
borderRadius="md"
/>
{ isLoading && <Skeleton position="absolute" w="100%" h="100%" left={ 0 } top={ 0 }/> }
</>
</AspectRatio>
);
};
export default chakra(NftVideo);
import { GridItem, Skeleton, SkeletonCircle } from '@chakra-ui/react'; import { GridItem, Skeleton, SkeletonCircle } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
const DetailsSkeletonRow = ({ w = '100%' }: { w?: string }) => { const DetailsSkeletonRow = ({ w = '100%', maxW }: { w?: string; maxW?: string }) => {
return ( return (
<> <>
<GridItem display="flex" columnGap={ 2 } w={{ base: '50%', lg: 'auto' }} _notFirst={{ mt: { base: 3, lg: 0 } }}> <GridItem display="flex" columnGap={ 2 } w={{ base: '50%', lg: 'auto' }} _notFirst={{ mt: { base: 3, lg: 0 } }}>
...@@ -9,7 +9,7 @@ const DetailsSkeletonRow = ({ w = '100%' }: { w?: string }) => { ...@@ -9,7 +9,7 @@ const DetailsSkeletonRow = ({ w = '100%' }: { w?: string }) => {
<Skeleton flexGrow={ 1 } h={ 5 } borderRadius="full"/> <Skeleton flexGrow={ 1 } h={ 5 } borderRadius="full"/>
</GridItem> </GridItem>
<GridItem pl={{ base: 7, lg: 0 }}> <GridItem pl={{ base: 7, lg: 0 }}>
<Skeleton h={ 5 } borderRadius="full" w={{ base: '100%', lg: w }}/> <Skeleton h={ 5 } borderRadius="full" w={{ base: '100%', lg: w }} maxW={ maxW }/>
</GridItem> </GridItem>
</> </>
); );
......
...@@ -3,15 +3,10 @@ import type { UseQueryResult } from '@tanstack/react-query'; ...@@ -3,15 +3,10 @@ import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo'; import type { TokenInfo } from 'types/api/token';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile'; import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AddressContractIcon from 'ui/shared/address/AddressContractIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props { interface Props {
tokenQuery: UseQueryResult<TokenInfo>; tokenQuery: UseQueryResult<TokenInfo>;
...@@ -19,7 +14,6 @@ interface Props { ...@@ -19,7 +14,6 @@ interface Props {
const TokenContractInfo = ({ tokenQuery }: Props) => { const TokenContractInfo = ({ tokenQuery }: Props) => {
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile();
const contractQuery = useApiQuery('address', { const contractQuery = useApiQuery('address', {
pathParams: { id: router.query.hash?.toString() }, pathParams: { id: router.query.hash?.toString() },
...@@ -43,17 +37,14 @@ const TokenContractInfo = ({ tokenQuery }: Props) => { ...@@ -43,17 +37,14 @@ const TokenContractInfo = ({ tokenQuery }: Props) => {
return null; return null;
} }
const hash = tokenQuery.data.address; const address = {
hash: tokenQuery.data.address,
return ( is_contract: true,
<Flex alignItems="center"> implementation_name: null,
<AddressContractIcon/> watchlist_names: [],
<AddressLink type="address" hash={ hash } ml={ 2 } truncation={ isMobile ? 'constant' : 'none' }/> };
<CopyToClipboard text={ hash } ml={ 1 }/>
{ contractQuery.data?.token && <AddressAddToMetaMask token={ contractQuery.data?.token } ml={ 2 }/> } return <AddressHeadingInfo address={ address } token={ contractQuery.data?.token }/>;
<AddressQrCode hash={ hash } ml={ 2 }/>
</Flex>
);
}; };
export default React.memo(TokenContractInfo); export default React.memo(TokenContractInfo);
...@@ -4,7 +4,7 @@ import { useRouter } from 'next/router'; ...@@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { scroller } from 'react-scroll'; import { scroller } from 'react-scroll';
import type { TokenInfo } from 'types/api/tokenInfo'; import type { TokenInfo } from 'types/api/token';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
......
...@@ -2,7 +2,7 @@ import { Text } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { TokenHolders, TokenInfo } from 'types/api/tokenInfo'; import type { TokenHolders, TokenInfo } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
......
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenHolder, TokenInfo } from 'types/api/tokenInfo'; import type { TokenHolder, TokenInfo } from 'types/api/token';
import TokenHoldersListItem from './TokenHoldersListItem'; import TokenHoldersListItem from './TokenHoldersListItem';
......
...@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import type { TokenHolder, TokenInfo } from 'types/api/tokenInfo'; import type { TokenHolder, TokenInfo } from 'types/api/token';
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 { Table, Tbody, Tr, Th } from '@chakra-ui/react'; import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenHolder, TokenInfo } from 'types/api/tokenInfo'; import type { TokenHolder, TokenInfo } from 'types/api/token';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
import TokenHoldersTableItem from 'ui/token/TokenHolders/TokenHoldersTableItem'; import TokenHoldersTableItem from 'ui/token/TokenHolders/TokenHoldersTableItem';
......
...@@ -2,7 +2,7 @@ import { Tr, Td } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Tr, Td } from '@chakra-ui/react';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import type { TokenHolder, TokenInfo } from 'types/api/tokenInfo'; import type { TokenHolder, TokenInfo } from 'types/api/token';
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 { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { base as tokenInstanse } from 'mocks/tokens/tokenInstance';
import TestApp from 'playwright/TestApp';
import TokenInventory from './TokenInventory';
test('base view +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 0 }}/>
<TokenInventory
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
inventoryQuery={{
data: {
items: [ tokenInstanse, tokenInstanse, tokenInstanse ],
next_page_params: { unique_token: 1 },
},
isPaginationVisible: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
pagination: { page: 1 },
}}
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Grid, Text, Skeleton } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { TokenInventoryResponse } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination';
import TokenInventoryItem from './TokenInventoryItem';
type Props = {
inventoryQuery: UseQueryResult<TokenInventoryResponse> & {
pagination: PaginationProps;
isPaginationVisible: boolean;
};
}
const TokenInventory = ({ inventoryQuery }: Props) => {
const isMobile = useIsMobile();
if (inventoryQuery.isError) {
return <DataFetchAlert/>;
}
const bar = isMobile && inventoryQuery.isPaginationVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...inventoryQuery.pagination }/>
</ActionBar>
);
if (inventoryQuery.isLoading) {
return (
<>
{ bar }
<Grid
w="100%"
columnGap={{ base: 3, lg: 6 }}
rowGap={{ base: 3, lg: 6 }}
gridTemplateColumns={{ base: 'repeat(2, calc((100% - 12px)/2))', lg: 'repeat(auto-fill, minmax(210px, 1fr))' }}
>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
<Skeleton w={{ base: '100%', lg: '210px' }} h="272px"/>
</Grid>
</>
);
}
const items = inventoryQuery.data.items;
if (!items?.length) {
return <Text as="span">There are no tokens.</Text>;
}
return (
<>
{ bar }
<Grid
w="100%"
columnGap={{ base: 3, lg: 6 }}
rowGap={{ base: 3, lg: 6 }}
gridTemplateColumns={{ base: 'repeat(2, calc((100% - 12px)/2))', lg: 'repeat(auto-fill, minmax(210px, 1fr))' }}
>
{ items.map((item) => <TokenInventoryItem key={ item.token.address } item={ item }/>) }
</Grid></>
);
};
export default TokenInventory;
import { Flex, Link, Text, LinkBox, LinkOverlay, useColorModeValue, Hide } from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import link from 'lib/link/link';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import NftMedia from 'ui/shared/nft/NftMedia';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
type Props = { item: TokenInstance };
const NFTItem = ({ item }: Props) => {
const tokenLink = link('token_instance_item', { hash: item.token.address, id: item.id });
return (
<LinkBox
w={{ base: '100%', lg: '210px' }}
border="1px solid"
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
borderRadius="12px"
p="10px"
_hover={{ boxShadow: 'md' }}
fontSize="sm"
fontWeight={ 500 }
lineHeight="20px"
>
<LinkOverlay href={ tokenLink }>
<NftMedia
mb="18px"
imageUrl={ item.image_url }
animationUrl={ item.animation_url }
/>
</LinkOverlay>
{ item.id && (
<Flex mb={ 2 } ml={ 1 }>
<Text whiteSpace="pre" variant="secondary">ID# </Text>
<TruncatedTextTooltip label={ item.id }>
<Link
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
{ item.id }
</Link>
</TruncatedTextTooltip>
</Flex>
) }
{ item.owner && (
<Flex mb={ 2 } ml={ 1 }>
<Text whiteSpace="pre" variant="secondary" mr={ 2 } lineHeight="24px">Owner</Text>
<Address>
<Hide below="lg" ssr={ false }><AddressIcon address={ item.owner } mr={ 1 }/></Hide>
<AddressLink hash={ item.owner.hash } alias={ item.owner.name } type="address" truncation="constant"/>
</Address>
</Flex>
) }
</LinkBox>
);
};
export default NFTItem;
...@@ -4,7 +4,7 @@ import { useRouter } from 'next/router'; ...@@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { SocketMessage } from 'lib/socket/types'; import type { SocketMessage } from 'lib/socket/types';
import type { TokenInfo } from 'types/api/tokenInfo'; import type { TokenInfo } from 'types/api/token';
import type { TokenTransferResponse } from 'types/api/tokenTransfer'; import type { TokenTransferResponse } from 'types/api/tokenTransfer';
import useGradualIncrement from 'lib/hooks/useGradualIncrement'; import useGradualIncrement from 'lib/hooks/useGradualIncrement';
......
import { Table, Tbody, Tr, Th, Td } from '@chakra-ui/react'; import { Table, Tbody, Tr, Th, Td } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo'; import type { TokenInfo } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import trimTokenSymbol from 'lib/token/trimTokenSymbol'; import trimTokenSymbol from 'lib/token/trimTokenSymbol';
......
import { Box, Tag } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useIsMobile from 'lib/hooks/useIsMobile';
import AdBanner from 'ui/shared/ad/AdBanner';
import TextAd from 'ui/shared/ad/TextAd';
import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import TokenLogo from 'ui/shared/TokenLogo';
import TokenInstanceDetails from 'ui/tokenInstance/TokenInstanceDetails';
import TokenInstanceSkeleton from 'ui/tokenInstance/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 hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenInstanceQuery = useApiQuery('token_instance', {
pathParams: { hash, id },
queryOptions: { enabled: Boolean(hash && id) },
});
const tabs: Array<RoutedTab> = [
{ id: 'token_transfers', title: 'Token transfers', component: <span>Token transfers</span> },
{ id: 'holders', title: 'Holders', component: <span>Holders</span> },
{ id: 'metadata', title: 'Metadata', component: <span>Metadata</span> },
];
if (tokenInstanceQuery.isError) {
throw Error('Token instance fetch failed', { cause: tokenInstanceQuery.error });
}
if (tokenInstanceQuery.isLoading) {
return <TokenInstanceSkeleton/>;
}
const tokenLogo = <TokenLogo hash={ tokenInstanceQuery.data.token.address } name={ tokenInstanceQuery.data.token.name } boxSize={ 6 }/>;
const tokenTag = <Tag>{ tokenInstanceQuery.data.token.type }</Tag>;
const address = {
hash: hash || '',
is_contract: true,
implementation_name: null,
watchlist_names: [],
};
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
text={ `${ tokenInstanceQuery.data.token.name } #${ tokenInstanceQuery.data.id }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to token page"
additionalsLeft={ tokenLogo }
additionalsRight={ tokenTag }
/>
<AddressHeadingInfo address={ address } token={ tokenInstanceQuery.data.token }/>
<TokenInstanceDetails data={ tokenInstanceQuery.data } scrollRef={ scrollRef }/>
<AdBanner mt={{ base: 6, lg: 8 }} justifyContent="center"/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? { mt: 8 } : { mt: 3, py: 5, marginBottom: 0 } }
/>
</>
);
};
export default React.memo(TokenInstanceContent);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TokenInstanceDetails from './TokenInstanceDetails';
const API_URL_ADDRESS = buildApiUrl('address', { id: tokenInstanceMock.base.token.address });
const API_URL_TOKEN_TRANSFERS_COUNT = buildApiUrl('token_instance_transfers_count', {
id: tokenInstanceMock.base.id,
hash: tokenInstanceMock.base.token.address,
});
test('base view +@dark-mode', async({ mount, page }) => {
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addressMock.contract),
}));
await page.route(API_URL_TOKEN_TRANSFERS_COUNT, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ transfers_count: 42 }),
}));
const component = await mount(
<TestApp>
<TokenInstanceDetails data={ tokenInstanceMock.base }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Box, Flex, Grid } from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import NftMedia from 'ui/shared/nft/NftMedia';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
import TokenInstanceCreatorAddress from './details/TokenInstanceCreatorAddress';
import TokenInstanceTransfersCount from './details/TokenInstanceTransfersCount';
interface Props {
data: TokenInstance;
scrollRef?: React.RefObject<HTMLDivElement>;
}
const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
const handleCounterItemClick = React.useCallback(() => {
window.setTimeout(() => {
// cannot do scroll instantly, have to wait a little
scrollRef?.current?.scrollIntoView({ behavior: 'smooth' });
}, 500);
}, [ scrollRef ]);
return (
<Flex alignItems="flex-start" mt={ 8 } flexDir={{ base: 'column-reverse', lg: 'row' }} columnGap={ 6 } rowGap={ 6 }>
<Grid
flexGrow={ 1 }
columnGap={ 8 }
rowGap={{ base: 1, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: '200px minmax(0, 1fr)' }} overflow="hidden"
>
<DetailsInfoItem
title="Token"
hint="Token name."
>
<TokenSnippet hash={ data.token.address } name={ data.token.name }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Owner"
hint="Current owner of this token instance."
>
<Address>
<AddressIcon address={ data.owner }/>
<AddressLink type="address" hash={ data.owner.hash } ml={ 2 }/>
<CopyToClipboard text={ data.owner.hash }/>
</Address>
</DetailsInfoItem>
<TokenInstanceCreatorAddress hash={ data.token.address }/>
<DetailsInfoItem
title="Token ID"
hint="This token instance unique token ID."
>
<Flex alignItems="center" overflow="hidden">
<Box overflow="hidden" display="inline-block" w="100%">
<HashStringShortenDynamic hash={ data.id }/>
</Box>
<CopyToClipboard text={ data.id } ml={ 1 }/>
</Flex>
</DetailsInfoItem>
<TokenInstanceTransfersCount hash={ data.token.address } id={ data.id } onClick={ handleCounterItemClick }/>
</Grid>
<NftMedia
imageUrl={ data.image_url }
animationUrl={ data.animation_url }
w="250px"
flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }}
/>
</Flex>
);
};
export default React.memo(TokenInstanceDetails);
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={ 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 useApiQuery from 'lib/api/useApiQuery';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
interface Props {
hash: string;
}
const TokenInstanceCreatorAddress = ({ hash }: Props) => {
const addressQuery = useApiQuery('address', {
pathParams: { id: hash },
});
if (addressQuery.isError) {
return null;
}
if (addressQuery.isLoading) {
return <DetailsSkeletonRow w="30%"/>;
}
if (!addressQuery.data.creator_address_hash) {
return null;
}
const creatorAddress = {
hash: addressQuery.data.creator_address_hash,
is_contract: false,
implementation_name: null,
};
return (
<DetailsInfoItem
title="Creator"
hint="Address that deployed this token contract"
>
<Address>
<AddressIcon address={ creatorAddress }/>
<AddressLink type="address" hash={ creatorAddress.hash } ml={ 2 }/>
<CopyToClipboard text={ creatorAddress.hash }/>
</Address>
</DetailsInfoItem>
);
};
export default TokenInstanceCreatorAddress;
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import link from 'lib/link/link';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import LinkInternal from 'ui/shared/LinkInternal';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
interface Props {
hash: string;
id: string;
onClick: () => void;
}
const TokenInstanceTransfersCount = ({ hash, id, onClick }: Props) => {
const transfersCountQuery = useApiQuery('token_instance_transfers_count', {
pathParams: { hash, id },
});
if (transfersCountQuery.isError) {
return null;
}
if (transfersCountQuery.isLoading) {
return <DetailsSkeletonRow w="30%"/>;
}
if (!transfersCountQuery.data.transfers_count) {
return null;
}
const url = transfersCountQuery.data.transfers_count > 0 ?
link('token_instance_item', { id, hash }, { tab: 'token_transfers' }) :
undefined;
return (
<DetailsInfoItem
title="Transfers"
hint="Number of transfer for the token instance."
>
<LinkInternal
href={ url }
onClick={ transfersCountQuery.data.transfers_count > 0 ? onClick : undefined }
>
{ transfersCountQuery.data.transfers_count }
</LinkInternal>
</DetailsInfoItem>
);
};
export default TokenInstanceTransfersCount;
...@@ -2,7 +2,7 @@ import { Hide, HStack, Show, Text } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Hide, HStack, Show, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery'; import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useDebounce from 'lib/hooks/useDebounce'; import useDebounce from 'lib/hooks/useDebounce';
......
import { Flex, Text, Tag, HStack, Grid, GridItem } from '@chakra-ui/react'; import { Flex, Text, Tag, HStack, Grid, GridItem } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo'; import type { TokenInfo } from 'types/api/token';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask'; import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask';
......
import { Table, Tbody, Th, Tr } from '@chakra-ui/react'; import { Table, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo'; import type { TokenInfo } from 'types/api/token';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
......
import { Box, Flex, Td, Tr, Text, Tag } from '@chakra-ui/react'; import { Box, Flex, Td, Tr, Text, Tag } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo'; import type { TokenInfo } from 'types/api/token';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask'; import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask';
......
...@@ -2,7 +2,7 @@ import { Hide, Show, Text } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Hide, Show, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/tokenInfo'; import type { TokenType } from 'types/api/token';
import { SECOND } from 'lib/consts'; import { SECOND } from 'lib/consts';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery'; import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
......
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