Commit 6e48647c authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #498 from blockscout/address-tokens

Address tokens
parents 90ad298f 8fc8081b
...@@ -11,6 +11,8 @@ import type { ...@@ -11,6 +11,8 @@ import type {
AddressInternalTxsResponse, AddressInternalTxsResponse,
AddressTxsFilters, AddressTxsFilters,
AddressTokenTransferFilters, AddressTokenTransferFilters,
AddressTokensFilter,
AddressTokensResponse,
} from 'types/api/address'; } from 'types/api/address';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters } from 'types/api/block'; import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters } from 'types/api/block';
import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts'; import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
...@@ -163,6 +165,11 @@ export const RESOURCES = { ...@@ -163,6 +165,11 @@ export const RESOURCES = {
paginationFields: [ 'items_count' as const, 'transaction_index' as const, 'index' as const, 'block_number' as const ], paginationFields: [ 'items_count' as const, 'transaction_index' as const, 'index' as const, 'block_number' as const ],
filterFields: [ ], filterFields: [ ],
}, },
address_tokens: {
path: '/api/v2/addresses/:id/tokens',
paginationFields: [ 'items_count' as const, 'token_name' as const, 'token_type' as const, 'value' as const ],
filterFields: [ 'type' as const ],
},
// CONTRACT // CONTRACT
contract: { contract: {
...@@ -264,8 +271,8 @@ export type PaginatedResources = 'blocks' | 'block_txs' | ...@@ -264,8 +271,8 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'txs_validated' | 'txs_pending' | 'txs_validated' | 'txs_pending' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' |
'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' |
'address_logs' |
'search' | 'search' |
'address_logs' | 'address_tokens' |
'token_holders'; 'token_holders';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -307,6 +314,7 @@ Q extends 'address_blocks_validated' ? AddressBlocksValidatedResponse : ...@@ -307,6 +314,7 @@ Q extends 'address_blocks_validated' ? AddressBlocksValidatedResponse :
Q extends 'address_coin_balance' ? AddressCoinBalanceHistoryResponse : Q extends 'address_coin_balance' ? AddressCoinBalanceHistoryResponse :
Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart : Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart :
Q extends 'address_logs' ? LogsResponseAddress : Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'address_tokens' ? AddressTokensResponse :
Q extends 'token' ? TokenInfo : Q extends 'token' ? TokenInfo :
Q extends 'token_counters' ? TokenCounters : Q extends 'token_counters' ? TokenCounters :
Q extends 'token_holders' ? TokenHolders : Q extends 'token_holders' ? TokenHolders :
...@@ -326,6 +334,7 @@ Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters : ...@@ -326,6 +334,7 @@ Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters :
Q extends 'tx_token_transfers' ? TokenTransferFilters : Q extends 'tx_token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters : Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
Q extends 'address_token_transfers' ? AddressTokenTransferFilters : Q extends 'address_token_transfers' ? AddressTokenTransferFilters :
Q extends 'address_tokens' ? AddressTokensFilter :
Q extends 'search' ? SearchResultFilters : Q extends 'search' ? SearchResultFilters :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -48,6 +48,16 @@ export interface AddressTokenBalance { ...@@ -48,6 +48,16 @@ export interface AddressTokenBalance {
value: string; value: string;
} }
export interface AddressTokensResponse {
items: Array<AddressTokenBalance>;
next_page_params: {
items_count: number;
token_name: 'string' | null;
token_type: TokenType;
value: number;
} | null;
}
export interface AddressTransactionsResponse { export interface AddressTransactionsResponse {
items: Array<Transaction>; items: Array<Transaction>;
next_page_params: { next_page_params: {
...@@ -75,6 +85,10 @@ export type AddressTokenTransferFilters = { ...@@ -75,6 +85,10 @@ export type AddressTokenTransferFilters = {
type: Array<TokenType>; type: Array<TokenType>;
} }
export type AddressTokensFilter = {
type: TokenType;
}
export interface AddressCoinBalanceHistoryItem { export interface AddressCoinBalanceHistoryItem {
block_number: number; block_number: number;
block_timestamp: string; block_timestamp: string;
......
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { withName } from 'mocks/address/address';
import * as tokenBalanceMock from 'mocks/address/tokenBalance';
import { baseList } from 'mocks/address/tokenBalance';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AddressTokens from './AddressTokens';
const ADDRESS_HASH = withName.hash;
const API_URL_ADDRESS = buildApiUrl('address', { id: ADDRESS_HASH });
const API_URL_ADDRESS_TOKEN_BALANCES = buildApiUrl('address_token_balances', { id: ADDRESS_HASH });
const API_URL_TOKENS = buildApiUrl('address_tokens', { id: ADDRESS_HASH });
const nextPageParams = {
items_count: 50,
token_name: 'aaa',
token_type: '123',
value: 1,
};
test('erc20 +@mobile +@dark-mode', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { id: ADDRESS_HASH, tab: 'tokens_erc20' },
isReady: true,
},
};
const response20 = {
items: [ tokenBalanceMock.erc20a, tokenBalanceMock.erc20b, tokenBalanceMock.erc20c, tokenBalanceMock.erc20d ],
next_page_params: nextPageParams,
};
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withName),
}));
await page.route(API_URL_ADDRESS_TOKEN_BALANCES, (route) => route.fulfill({
status: 200,
body: JSON.stringify(baseList),
}));
await page.route(API_URL_TOKENS + '?type=ERC-20', (route) => route.fulfill({
status: 200,
body: JSON.stringify(response20),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressTokens/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('erc721 +@mobile +@dark-mode', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { id: ADDRESS_HASH, tab: 'tokens_erc721' },
isReady: true,
},
};
const response20 = {
items: [ tokenBalanceMock.erc721a, tokenBalanceMock.erc721b, tokenBalanceMock.erc721c ],
next_page_params: nextPageParams,
};
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withName),
}));
await page.route(API_URL_ADDRESS_TOKEN_BALANCES, (route) => route.fulfill({
status: 200,
body: JSON.stringify(baseList),
}));
await page.route(API_URL_TOKENS + '?type=ERC-721', (route) => route.fulfill({
status: 200,
body: JSON.stringify(response20),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressTokens/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('erc1155 +@mobile +@dark-mode', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { id: ADDRESS_HASH, tab: 'tokens_erc1155' },
isReady: true,
},
};
const response20 = {
items: [ tokenBalanceMock.erc1155a, tokenBalanceMock.erc1155b ],
next_page_params: nextPageParams,
};
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withName),
}));
await page.route(API_URL_ADDRESS_TOKEN_BALANCES, (route) => route.fulfill({
status: 200,
body: JSON.stringify(baseList),
}));
await page.route(API_URL_TOKENS + '?type=ERC-1155', (route) => route.fulfill({
status: 200,
body: JSON.stringify(response20),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressTokens/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { TokenType } from 'types/api/tokenInfo';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { tokenTabsByType } from 'ui/pages/Address';
import Pagination from 'ui/shared/Pagination';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import TokenBalances from './tokens/TokenBalances';
import TokensWithIds from './tokens/TokensWithIds';
import TokensWithoutIds from './tokens/TokensWithoutIds';
const TAB_LIST_PROPS = {
marginBottom: 0,
py: 5,
marginTop: 3,
columnGap: 3,
};
const TAB_LIST_PROPS_MOBILE = {
mt: 8,
columnGap: 3,
};
const AddressTokens = () => {
const router = useRouter();
const isMobile = useIsMobile();
const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenType: TokenType = (Object.keys(tokenTabsByType) as Array<TokenType>).find(key => tokenTabsByType[key] === router.query.tab) || 'ERC-20';
const tokensQuery = useQueryWithPages({
resourceName: 'address_tokens',
pathParams: { id: router.query.id?.toString() },
filters: { type: tokenType },
scrollRef,
});
const tabs = [
{ id: tokenTabsByType['ERC-20'], title: 'ERC-20', component: <TokensWithoutIds tokensQuery={ tokensQuery }/> },
{ id: tokenTabsByType['ERC-721'], title: 'ERC-721', component: <TokensWithoutIds tokensQuery={ tokensQuery }/> },
{ id: tokenTabsByType['ERC-1155'], title: 'ERC-1155', component: <TokensWithIds tokensQuery={ tokensQuery }/> },
];
return (
<>
<TokenBalances/>
{ /* should stay before tabs to scroll up whith pagination */ }
<Box ref={ scrollRef }></Box>
<RoutedTabs
tabs={ tabs }
variant="outline"
colorScheme="gray"
size="sm"
tabListProps={ isMobile ? TAB_LIST_PROPS_MOBILE : TAB_LIST_PROPS }
rightSlot={ tokensQuery.isPaginationVisible && !isMobile ? <Pagination { ...tokensQuery.pagination }/> : null }
stickyEnabled={ !isMobile }
/>
</>
);
};
export default AddressTokens;
import { Box, Button, Icon, Skeleton, Text, useColorModeValue } from '@chakra-ui/react'; import { Box, Button, Icon, Skeleton, Text, useColorModeValue } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import arrowIcon from 'icons/arrows/east-mini.svg'; import arrowIcon from 'icons/arrows/east-mini.svg';
import tokensIcon from 'icons/tokens.svg'; import tokensIcon from 'icons/tokens.svg';
import { ZERO } from 'lib/consts';
import type { EnhancedData } from './utils'; import type { EnhancedData } from '../utils/tokenUtils';
import { getTokenBalanceTotal } from '../utils/tokenUtils';
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
...@@ -16,7 +15,7 @@ interface Props { ...@@ -16,7 +15,7 @@ interface Props {
} }
const TokenSelectButton = ({ isOpen, isLoading, onClick, data }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => { const TokenSelectButton = ({ isOpen, isLoading, onClick, data }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
const totalBn = data.reduce((result, item) => !item.usd ? result : result.plus(BigNumber(item.usd)), ZERO); const totalBn = getTokenBalanceTotal(data);
const skeletonBgColor = useColorModeValue('white', 'black'); const skeletonBgColor = useColorModeValue('white', 'black');
const handleClick = React.useCallback(() => { const handleClick = React.useCallback(() => {
......
...@@ -6,7 +6,7 @@ import link from 'lib/link/link'; ...@@ -6,7 +6,7 @@ import link from 'lib/link/link';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import TokenLogo from 'ui/shared/TokenLogo'; import TokenLogo from 'ui/shared/TokenLogo';
import type { EnhancedData } from './utils'; import type { EnhancedData } from '../utils/tokenUtils';
interface Props { interface Props {
data: EnhancedData; data: EnhancedData;
......
...@@ -8,9 +8,9 @@ import type { TokenType } from 'types/api/tokenInfo'; ...@@ -8,9 +8,9 @@ import type { TokenType } from 'types/api/tokenInfo';
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 type { Sort, EnhancedData } from '../utils/tokenUtils';
import { sortTokenGroups, sortingFns } from '../utils/tokenUtils';
import TokenSelectItem from './TokenSelectItem'; import TokenSelectItem from './TokenSelectItem';
import type { Sort, EnhancedData } from './utils';
import { sortTokenGroups, sortingFns } from './utils';
interface Props { interface Props {
searchTerm: string; searchTerm: string;
......
...@@ -4,8 +4,8 @@ import React from 'react'; ...@@ -4,8 +4,8 @@ import React from 'react';
import type { AddressTokenBalance } from 'types/api/address'; import type { AddressTokenBalance } from 'types/api/address';
import type { Sort } from './utils'; import type { Sort } from '../utils/tokenUtils';
import { calculateUsdValue, filterTokens } from './utils'; import { calculateUsdValue, filterTokens } from '../utils/tokenUtils';
export default function useData(data: Array<AddressTokenBalance>) { export default function useData(data: Array<AddressTokenBalance>) {
const [ searchTerm, setSearchTerm ] = React.useState(''); const [ searchTerm, setSearchTerm ] = React.useState('');
......
import { Center, Flex, Icon, Link, Text, LinkBox, LinkOverlay, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import NFTIcon from 'icons/nft_shield.svg';
import link from 'lib/link/link';
import TokenLogo from 'ui/shared/TokenLogo';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
type Props = AddressTokenBalance;
const NFTItem = ({ token, token_id: tokenId }: Props) => {
const tokenLink = link('token_index', { hash: token.address });
return (
<LinkBox
w={{ base: 'calc((100% - 12px)/2)', lg: '210px' }}
h={{ base: 'auto', lg: '272px' }}
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 }/>
<Center
w={{ base: '100%', lg: '182px' }}
h={{ base: 'calc((100vw - 36px)/2 - 12px)', lg: '182px' }}
bg={ useColorModeValue('blackAlpha.50', 'whiteAlpha.50') }
mb="18px"
borderRadius="12px"
>
<Icon as={ NFTIcon } boxSize="112px" color={ useColorModeValue('blackAlpha.500', 'whiteAlpha.500') }/>
</Center>
{ tokenId && (
<Flex mb={ 2 } ml={ 1 }>
<Text whiteSpace="pre" variant="secondary">ID# </Text>
<TruncatedTextTooltip label={ tokenId }>
<Link
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
href={ link('token_instance_item', { hash: token.address, id: tokenId }) }
>
{ tokenId }
</Link>
</TruncatedTextTooltip>
</Flex>
) }
{ token.name && (
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } ml={ 1 } mr={ 1 }/>
<TruncatedTextTooltip label={ token.name }>
<Text variant="secondary" overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis">{ token.name }</Text>
</TruncatedTextTooltip>
</Flex>
) }
</LinkBox>
);
};
export default NFTItem;
import { Flex, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import { useRouter } from 'next/router';
import React from 'react';
import appConfig from 'configs/app/config';
import useApiQuery from 'lib/api/useApiQuery';
import getCurrencyValue from 'lib/getCurrencyValue';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import { getTokenBalanceTotal, calculateUsdValue } from '../utils/tokenUtils';
import TokenBalancesItem from './TokenBalancesItem';
const TokenBalances = () => {
const router = useRouter();
const addressQuery = useApiQuery('address', {
pathParams: { id: router.query.id?.toString() },
queryOptions: { enabled: Boolean(router.query.id) },
});
const balancesQuery = useApiQuery('address_token_balances', {
pathParams: { id: addressQuery.data?.hash },
queryOptions: { enabled: Boolean(addressQuery.data) },
});
if (addressQuery.isError || balancesQuery.isError) {
return <DataFetchAlert/>;
}
if (addressQuery.isLoading || balancesQuery.isLoading) {
const item = <Skeleton w={{ base: '100%', lg: '240px' }} h="82px" borderRadius="16px"/>;
return (
<Flex columnGap={ 3 } rowGap={ 3 } mt={{ base: '6px', lg: 0 }} flexDirection={{ base: 'column', lg: 'row' }}>
{ item }
{ item }
{ item }
</Flex>
);
}
const addressData = addressQuery.data;
const { valueStr: nativeValue, usd: nativeUsd } = getCurrencyValue({
value: addressData.coin_balance || '0',
accuracy: 8,
accuracyUsd: 2,
exchangeRate: addressData.exchange_rate,
decimals: String(appConfig.network.currency.decimals),
});
const tokenBalanceBn = getTokenBalanceTotal(balancesQuery.data.map(calculateUsdValue)).toFixed(2);
const totalUsd = nativeUsd ? BigNumber(nativeUsd).toNumber() + BigNumber(tokenBalanceBn).toNumber() : undefined;
return (
<Flex columnGap={ 3 } rowGap={ 3 } mt={{ base: '6px', lg: 0 }} flexDirection={{ base: 'column', lg: 'row' }}>
<TokenBalancesItem name="Net Worth" value={ totalUsd ? `$${ totalUsd } USD` : 'N/A' }/>
<TokenBalancesItem
name={ `${ appConfig.network.currency.symbol } Balance` }
value={ (nativeUsd ? `$${ nativeUsd } USD | ` : '') + `${ nativeValue } ${ appConfig.network.currency.symbol }` }
/>
<TokenBalancesItem
name="Tokens"
value={
`$${ tokenBalanceBn } USD ` +
(balancesQuery.data.length ? ` | ${ balancesQuery.data.length } ${ balancesQuery.data.length === 1 ? 'token' : 'tokens' }` : '')
}
/>
</Flex>
);
};
export default React.memo(TokenBalances);
import { Box, Flex, Icon, Text, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import walletIcon from 'icons/wallet.svg';
const TokenBalancesItem = ({ name, value }: {name: string; value: string }) => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
return (
<Flex p={ 5 } bgColor={ bgColor } borderRadius="16px" alignItems="center">
<Icon as={ walletIcon } boxSize="30px" mr={ 3 }/>
<Box>
<Text variant="secondary" fontSize="xs">{ name }</Text>
<Text fontWeight="500">{ value }</Text>
</Box>
</Flex>
);
};
export default React.memo(TokenBalancesItem);
import { Flex, HStack, Text } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import getCurrencyValue from 'lib/getCurrencyValue';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import ListItemMobile from 'ui/shared/ListItemMobile';
import TokenLogo from 'ui/shared/TokenLogo';
import AddressAddToMetaMask from '../details/AddressAddToMetaMask';
type Props = AddressTokenBalance;
const TokensListItem = ({ token, value }: Props) => {
const tokenString = [ token.name, token.symbol && `(${ token.symbol })` ].filter(Boolean).join(' ');
const {
valueStr: tokenQuantity,
usd: tokenValue,
} = getCurrencyValue({ value: value, exchangeRate: token.exchange_rate, decimals: token.decimals, accuracy: 8, accuracyUsd: 2 });
return (
<ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" width="100%">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex>
<Flex alignItems="center" pl={ 8 }>
<AddressLink hash={ token.address } type="address" truncation="constant"/>
<CopyToClipboard text={ token.address } ml={ 1 }/>
<AddressAddToMetaMask token={ token } ml={ 2 }/>
</Flex>
{ token.exchange_rate !== undefined && token.exchange_rate !== null && (
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Price</Text>
<Text fontSize="sm" variant="secondary">{ `$${ token.exchange_rate }` }</Text>
</HStack>
) }
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Quantity</Text>
<Text fontSize="sm" variant="secondary">{ tokenQuantity }</Text>
</HStack>
{ tokenValue !== undefined && (
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Value</Text>
<Text fontSize="sm" variant="secondary">{ tokenValue }</Text>
</HStack>
) }
</ListItemMobile>
);
};
export default TokensListItem;
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import { default as Thead } from 'ui/shared/TheadSticky';
import TokensTableItem from './TokensTableItem';
interface Props {
data: Array<AddressTokenBalance>;
top: number;
}
const TokensTable = ({ data, top }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ top }>
<Tr>
<Th width="30%">Asset</Th>
<Th width="30%">Contract address</Th>
<Th width="10%" isNumeric>Price</Th>
<Th width="15%" isNumeric>Quantity</Th>
<Th width="15%" isNumeric>Value</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item) => (
<TokensTableItem key={ item.token.address } { ...item }/>
)) }
</Tbody>
</Table>
);
};
export default TokensTable;
import { Tr, Td, Flex } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import getCurrencyValue from 'lib/getCurrencyValue';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import TokenLogo from 'ui/shared/TokenLogo';
import AddressAddToMetaMask from '../details/AddressAddToMetaMask';
type Props = AddressTokenBalance;
const TokensTableItem = ({
token,
value,
}: Props) => {
const tokenString = [ token.name, token.symbol && `(${ token.symbol })` ].filter(Boolean).join(' ');
const {
valueStr: tokenQuantity,
usd: tokenValue,
} = getCurrencyValue({ value: value, exchangeRate: token.exchange_rate, decimals: token.decimals, accuracy: 8, accuracyUsd: 2 });
return (
<Tr>
<Td verticalAlign="middle">
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex>
</Td>
<Td verticalAlign="middle">
<Flex alignItems="center" width="150px" justifyContent="space-between">
<Flex alignItems="center">
<AddressLink hash={ token.address } type="address" truncation="constant"/>
<CopyToClipboard text={ token.address } ml={ 1 }/>
</Flex>
<AddressAddToMetaMask token={ token } ml={ 4 }/>
</Flex>
</Td>
<Td isNumeric verticalAlign="middle">
{ token.exchange_rate ? `$${ token.exchange_rate }` : '-' }
</Td>
<Td isNumeric verticalAlign="middle">
{ tokenQuantity }
</Td>
<Td isNumeric verticalAlign="middle">
{ tokenValue ? `$${ tokenValue }` : '-' }
</Td>
</Tr>
);
};
export default React.memo(TokensTableItem);
import { Flex, Skeleton, Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { AddressTokensResponse } from 'types/api/address';
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 NFTItem from './NFTItem';
type Props = {
tokensQuery: UseQueryResult<AddressTokensResponse> & {
pagination: PaginationProps;
isPaginationVisible: boolean;
};
}
const TokensWithIds = ({ tokensQuery }: Props) => {
const isMobile = useIsMobile();
const { isError, isLoading, data, pagination, isPaginationVisible } = tokensQuery;
if (isError) {
return <DataFetchAlert/>;
}
const bar = isMobile && isPaginationVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
if (isLoading) {
return (
<>
{ bar }
<Flex columnGap={ 6 } rowGap={ 6 } flexWrap="wrap">
<Skeleton w="210px" h="272px"/>
<Skeleton w="210px" h="272px"/>
<Skeleton w="210px" h="272px"/>
</Flex>
</>
);
}
if (!data.items.length) {
return <Text as="span">There are no tokens of selected type.</Text>;
}
return (
<>
{ bar }
<Flex columnGap={{ base: 3, lg: 6 }} rowGap={{ base: 3, lg: 6 }} flexWrap="wrap">
{ data.items.map(item => <NFTItem key={ item.token.address } { ...item }/>) }
</Flex>
</>
);
};
export default TokensWithIds;
import { Text, Show, Hide } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { AddressTokensResponse } from 'types/api/address';
import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Pagination from 'ui/shared/Pagination';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import TokensListItem from './TokensListItem';
import TokensTable from './TokensTable';
type Props = {
tokensQuery: UseQueryResult<AddressTokensResponse> & {
pagination: PaginationProps;
isPaginationVisible: boolean;
};
}
const TokensWithoutIds = ({ tokensQuery }: Props) => {
const isMobile = useIsMobile();
const { isError, isLoading, data, pagination, isPaginationVisible } = tokensQuery;
if (isError) {
return <DataFetchAlert/>;
}
const bar = isMobile && isPaginationVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
if (isLoading) {
return (
<>
{ bar }
<Hide below="lg" ssr={ false }><SkeletonTable columns={ [ '30%', '30%', '10%', '20%', '10%' ] }/></Hide>
<Show below="lg" ssr={ false }><SkeletonList/></Show>
</>
);
}
if (data.items.length === 0) {
return <Text as="span">There are no tokens of selected type.</Text>;
}
return (
<>
{ bar }
<Hide below="lg" ssr={ false }><TokensTable data={ data.items } top={ isPaginationVisible ? 72 : 0 }/></Hide>
<Show below="lg" ssr={ false }>{ data.items.map(item => <TokensListItem key={ item.token.address } { ...item }/>) }</Show>
</>
);
};
export default TokensWithoutIds;
...@@ -3,6 +3,8 @@ import BigNumber from 'bignumber.js'; ...@@ -3,6 +3,8 @@ 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/tokenInfo';
import { ZERO } from 'lib/consts';
export type EnhancedData = AddressTokenBalance & { export type EnhancedData = AddressTokenBalance & {
usd?: BigNumber ; usd?: BigNumber ;
} }
...@@ -77,3 +79,7 @@ export const calculateUsdValue = (data: AddressTokenBalance): EnhancedData => { ...@@ -77,3 +79,7 @@ export const calculateUsdValue = (data: AddressTokenBalance): EnhancedData => {
usd: BigNumber(data.value).div(BigNumber(10 ** decimals)).multipliedBy(BigNumber(exchangeRate)), usd: BigNumber(data.value).div(BigNumber(10 ** decimals)).multipliedBy(BigNumber(exchangeRate)),
}; };
}; };
export const getTokenBalanceTotal = (data: Array<EnhancedData>) => {
return data.reduce((result, item) => !item.usd ? result : result.plus(BigNumber(item.usd)), ZERO);
};
...@@ -27,7 +27,6 @@ test('regular block +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -27,7 +27,6 @@ test('regular block +@mobile +@dark-mode', async({ mount, page }) => {
{ hooksConfig }, { hooksConfig },
); );
await page.waitForResponse(API_URL),
await page.getByText('View details').click(); await page.getByText('View details').click();
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
...@@ -46,7 +45,6 @@ test('genesis block', async({ mount, page }) => { ...@@ -46,7 +45,6 @@ test('genesis block', async({ mount, page }) => {
{ hooksConfig }, { hooksConfig },
); );
await page.waitForResponse(API_URL),
await page.getByText('View details').click(); await page.getByText('View details').click();
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
......
...@@ -2,6 +2,7 @@ import { Flex, Skeleton, Tag, Box } from '@chakra-ui/react'; ...@@ -2,6 +2,7 @@ import { Flex, Skeleton, Tag, 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 { RoutedTab } from 'ui/shared/RoutedTabs/types'; import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
...@@ -12,6 +13,7 @@ import AddressContract from 'ui/address/AddressContract'; ...@@ -12,6 +13,7 @@ import AddressContract from 'ui/address/AddressContract';
import AddressDetails from 'ui/address/AddressDetails'; import AddressDetails from 'ui/address/AddressDetails';
import AddressInternalTxs from 'ui/address/AddressInternalTxs'; import AddressInternalTxs from 'ui/address/AddressInternalTxs';
import AddressLogs from 'ui/address/AddressLogs'; import AddressLogs from 'ui/address/AddressLogs';
import AddressTokens from 'ui/address/AddressTokens';
import AddressTokenTransfers from 'ui/address/AddressTokenTransfers'; import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs'; import AddressTxs from 'ui/address/AddressTxs';
import ContractCode from 'ui/address/contract/ContractCode'; import ContractCode from 'ui/address/contract/ContractCode';
...@@ -23,6 +25,14 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -23,6 +25,14 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs'; import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs'; import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
export const tokenTabsByType: Record<TokenType, string> = {
'ERC-20': 'tokens_erc20',
'ERC-721': 'tokens_erc721',
'ERC-1155': 'tokens_erc1155',
} as const;
const TOKEN_TABS = Object.values(tokenTabsByType);
const AddressPageContent = () => { const AddressPageContent = () => {
const router = useRouter(); const router = useRouter();
...@@ -72,7 +82,7 @@ const AddressPageContent = () => { ...@@ -72,7 +82,7 @@ const AddressPageContent = () => {
addressQuery.data?.has_token_transfers ? addressQuery.data?.has_token_transfers ?
{ id: 'token_transfers', title: 'Token transfers', component: <AddressTokenTransfers scrollRef={ tabsScrollRef }/> } : { id: 'token_transfers', title: 'Token transfers', component: <AddressTokenTransfers scrollRef={ tabsScrollRef }/> } :
undefined, undefined,
addressQuery.data?.has_tokens ? { id: 'tokens', title: 'Tokens', component: null } : undefined, addressQuery.data?.has_tokens ? { id: 'tokens', title: 'Tokens', component: <AddressTokens/>, subTabs: TOKEN_TABS } : undefined,
{ id: 'internal_txns', title: 'Internal txns', component: <AddressInternalTxs scrollRef={ tabsScrollRef }/> }, { id: 'internal_txns', title: 'Internal txns', component: <AddressInternalTxs scrollRef={ tabsScrollRef }/> },
{ id: 'coin_balance_history', title: 'Coin balance history', component: <AddressCoinBalance/> }, { id: 'coin_balance_history', title: 'Coin balance history', component: <AddressCoinBalance/> },
addressQuery.data?.has_validated_blocks ? addressQuery.data?.has_validated_blocks ?
...@@ -83,7 +93,7 @@ const AddressPageContent = () => { ...@@ -83,7 +93,7 @@ const AddressPageContent = () => {
id: 'contract', id: 'contract',
title: 'Contract', title: 'Contract',
component: <AddressContract tabs={ contractTabs }/>, component: <AddressContract tabs={ contractTabs }/>,
subTabs: contractTabs, subTabs: contractTabs.map(tab => tab.id),
} : undefined, } : undefined,
].filter(notEmpty); ].filter(notEmpty);
}, [ addressQuery.data, contractTabs ]); }, [ addressQuery.data, contractTabs ]);
......
...@@ -62,7 +62,7 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, . ...@@ -62,7 +62,7 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
let tabIndex = 0; let tabIndex = 0;
const tabFromRoute = router.query.tab; const tabFromRoute = router.query.tab;
if (tabFromRoute) { if (tabFromRoute) {
tabIndex = tabs.findIndex(({ id, subTabs }) => id === tabFromRoute || subTabs?.some(({ id }) => id === tabFromRoute)); tabIndex = tabs.findIndex(({ id, subTabs }) => id === tabFromRoute || subTabs?.some((id) => id === tabFromRoute));
if (tabIndex < 0) { if (tabIndex < 0) {
tabIndex = 0; tabIndex = 0;
} }
......
...@@ -2,7 +2,7 @@ export interface RoutedTab { ...@@ -2,7 +2,7 @@ export interface RoutedTab {
id: string; id: string;
title: string; title: string;
component: React.ReactNode; component: React.ReactNode;
subTabs?: Array<RoutedSubTab>; subTabs?: Array<string>;
} }
export type RoutedSubTab = Omit<RoutedTab, 'subTabs'>; export type RoutedSubTab = Omit<RoutedTab, 'subTabs'>;
......
...@@ -32,9 +32,9 @@ const TxsTable = ({ txs, sort, sorting, top, showBlockInfo, showSocketInfo, curr ...@@ -32,9 +32,9 @@ const TxsTable = ({ txs, sort, sorting, top, showBlockInfo, showSocketInfo, curr
<Th width="160px">Type</Th> <Th width="160px">Type</Th>
<Th width="20%">Method</Th> <Th width="20%">Method</Th>
{ showBlockInfo && <Th width="18%">Block</Th> } { showBlockInfo && <Th width="18%">Block</Th> }
<Th width={{ xl: '128px', base: '66px' }}>From</Th> <Th width={{ xl: '132px', base: '66px' }}>From</Th>
<Th width={{ xl: currentAddress ? '48px' : '36px', base: '0' }}></Th> <Th width={{ xl: currentAddress ? '48px' : '36px', base: '0' }}></Th>
<Th width={{ xl: '128px', base: '66px' }}>To</Th> <Th width={{ xl: '132px', base: '66px' }}>To</Th>
<Th width="20%" isNumeric> <Th width="20%" isNumeric>
<Link onClick={ sort('val') } display="flex" justifyContent="end"> <Link onClick={ sort('val') } display="flex" justifyContent="end">
{ sorting === 'val-asc' && <Icon boxSize={ 5 } as={ rightArrowIcon } transform="rotate(-90deg)"/> } { sorting === 'val-asc' && <Icon boxSize={ 5 } as={ rightArrowIcon } transform="rotate(-90deg)"/> }
......
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