Commit 83c2523b authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #485 from blockscout/token-holders

token holders
parents 95bd3e72 c3953467
...@@ -21,7 +21,7 @@ import type { JsonRpcUrlResponse } from 'types/api/jsonRpcUrl'; ...@@ -21,7 +21,7 @@ import type { JsonRpcUrlResponse } from 'types/api/jsonRpcUrl';
import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log'; import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { RawTracesResponse } from 'types/api/rawTrace'; import type { RawTracesResponse } from 'types/api/rawTrace';
import type { Stats, Charts, HomeStats } from 'types/api/stats'; import type { Stats, Charts, HomeStats } from 'types/api/stats';
import type { TokenCounters, TokenInfo } from 'types/api/tokenInfo'; import type { TokenCounters, TokenInfo, TokenHolders } from 'types/api/tokenInfo';
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';
import type { TTxsFilters } from 'types/api/txsFilters'; import type { TTxsFilters } from 'types/api/txsFilters';
...@@ -176,6 +176,11 @@ export const RESOURCES = { ...@@ -176,6 +176,11 @@ export const RESOURCES = {
token_counters: { token_counters: {
path: '/api/v2/tokens/:hash/counters', path: '/api/v2/tokens/:hash/counters',
}, },
token_holders: {
path: '/api/v2/tokens/:hash/holders',
paginationFields: [ 'items_count' as const, 'value' as const ],
filterFields: [],
},
// HOMEPAGE // HOMEPAGE
homepage_stats: { homepage_stats: {
...@@ -232,7 +237,8 @@ export type PaginatedResources = 'blocks' | 'block_txs' | ...@@ -232,7 +237,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'; 'address_logs' |
'token_holders';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -275,6 +281,7 @@ Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart : ...@@ -275,6 +281,7 @@ Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart :
Q extends 'address_logs' ? LogsResponseAddress : Q extends 'address_logs' ? LogsResponseAddress :
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 'config_json_rpc' ? JsonRpcUrlResponse : Q extends 'config_json_rpc' ? JsonRpcUrlResponse :
Q extends 'contract' ? SmartContract : Q extends 'contract' ? SmartContract :
never; never;
......
...@@ -4,7 +4,7 @@ import omit from 'lodash/omit'; ...@@ -4,7 +4,7 @@ import omit from 'lodash/omit';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { animateScroll, scroller } from 'react-scroll'; import { animateScroll } from 'react-scroll';
import type { PaginatedResources, PaginatedResponse, PaginationFilters } from 'lib/api/resources'; import type { PaginatedResources, PaginatedResponse, PaginationFilters } from 'lib/api/resources';
import { RESOURCES } from 'lib/api/resources'; import { RESOURCES } from 'lib/api/resources';
...@@ -16,7 +16,7 @@ interface Params<Resource extends PaginatedResources> { ...@@ -16,7 +16,7 @@ interface Params<Resource extends PaginatedResources> {
options?: UseApiQueryParams<Resource>['queryOptions']; options?: UseApiQueryParams<Resource>['queryOptions'];
pathParams?: UseApiQueryParams<Resource>['pathParams']; pathParams?: UseApiQueryParams<Resource>['pathParams'];
filters?: PaginationFilters<Resource>; filters?: PaginationFilters<Resource>;
scroll?: { elem: string; offset: number }; scrollRef?: React.RefObject<HTMLDivElement>;
} }
export default function useQueryWithPages<Resource extends PaginatedResources>({ export default function useQueryWithPages<Resource extends PaginatedResources>({
...@@ -24,7 +24,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -24,7 +24,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
filters, filters,
options, options,
pathParams, pathParams,
scroll, scrollRef,
}: Params<Resource>) { }: Params<Resource>) {
const resource = RESOURCES[resourceName]; const resource = RESOURCES[resourceName];
const queryClient = useQueryClient(); const queryClient = useQueryClient();
...@@ -46,8 +46,8 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -46,8 +46,8 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
const queryParams = { ...filters, ...pageParams[page] }; const queryParams = { ...filters, ...pageParams[page] };
const scrollToTop = useCallback(() => { const scrollToTop = useCallback(() => {
scroll ? scroller.scrollTo(scroll.elem, { offset: scroll.offset }) : animateScroll.scrollToTop({ duration: 0 }); scrollRef?.current ? scrollRef.current.scrollIntoView(true) : animateScroll.scrollToTop({ duration: 0 });
}, [ scroll ]); }, [ scrollRef ]);
const queryResult = useApiQuery(resourceName, { const queryResult = useApiQuery(resourceName, {
pathParams, pathParams,
...@@ -77,10 +77,9 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -77,10 +77,9 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
nextPageQuery.page = String(page + 1); nextPageQuery.page = String(page + 1);
setHasPagination(true); setHasPagination(true);
router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true }) scrollToTop();
.then(() => {
scrollToTop(); router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true });
});
}, [ data?.next_page_params, page, router, scrollToTop ]); }, [ data?.next_page_params, page, router, scrollToTop ]);
const onPrevPageClick = useCallback(() => { const onPrevPageClick = useCallback(() => {
...@@ -96,9 +95,9 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -96,9 +95,9 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
nextPageQuery.page = String(page - 1); nextPageQuery.page = String(page - 1);
} }
scrollToTop();
router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true }) router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true })
.then(() => { .then(() => {
scrollToTop();
setPage(prev => prev - 1); setPage(prev => prev - 1);
page === 2 && queryClient.removeQueries({ queryKey: [ resourceName ] }); page === 2 && queryClient.removeQueries({ queryKey: [ resourceName ] });
}); });
...@@ -108,9 +107,9 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -108,9 +107,9 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
const resetPage = useCallback(() => { const resetPage = useCallback(() => {
queryClient.removeQueries({ queryKey: [ resourceName ] }); queryClient.removeQueries({ queryKey: [ resourceName ] });
scrollToTop();
router.push({ pathname: router.pathname, query: omit(router.query, resource.paginationFields, 'page') }, undefined, { shallow: true }).then(() => { router.push({ pathname: router.pathname, query: omit(router.query, resource.paginationFields, 'page') }, undefined, { shallow: true }).then(() => {
queryClient.removeQueries({ queryKey: [ resourceName ] }); queryClient.removeQueries({ queryKey: [ resourceName ] });
scrollToTop();
setPage(1); setPage(1);
setPageParams({}); setPageParams({});
canGoBackwards.current = true; canGoBackwards.current = true;
...@@ -133,6 +132,8 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -133,6 +132,8 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
} }
}); });
} }
scrollToTop();
router.push( router.push(
{ {
pathname: router.pathname, pathname: router.pathname,
...@@ -143,7 +144,6 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -143,7 +144,6 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
).then(() => { ).then(() => {
setPage(1); setPage(1);
setPageParams({}); setPageParams({});
scrollToTop();
}); });
}, [ router, resource.paginationFields, resource.filterFields, scrollToTop ]); }, [ router, resource.paginationFields, resource.filterFields, scrollToTop ]);
......
import type { TokenHolders } from 'types/api/tokenInfo';
import { withName, withoutName } from 'mocks/address/address';
export const tokenHolders: TokenHolders = {
items: [
{
address: withName,
value: '107014805905725000000',
},
{
address: withoutName,
value: '207014805905725000000',
},
],
next_page_params: {
value: '50',
items_count: 50,
},
};
import type { AddressParam } from './addressParams';
export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155'; export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155';
export interface TokenInfo { export interface TokenInfo {
...@@ -17,3 +19,18 @@ export interface TokenCounters { ...@@ -17,3 +19,18 @@ export interface TokenCounters {
} }
export type TokenInfoGeneric<Type extends TokenType> = Omit<TokenInfo, 'type'> & { type: Type }; export type TokenInfoGeneric<Type extends TokenType> = Omit<TokenInfo, 'type'> & { type: Type };
export interface TokenHolders {
items: Array<TokenHolder>;
next_page_params: TokenHoldersPagination;
}
export type TokenHolder = {
address: AddressParam;
value: string;
}
export type TokenHoldersPagination = {
items_count: number;
value: string;
}
...@@ -2,7 +2,6 @@ import { Text, Show, Hide } from '@chakra-ui/react'; ...@@ -2,7 +2,6 @@ import { Text, Show, Hide } from '@chakra-ui/react';
import castArray from 'lodash/castArray'; import castArray from 'lodash/castArray';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { Element } from 'react-scroll';
import type { AddressFromToFilter } from 'types/api/address'; import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address'; import { AddressFromToFilterValues } from 'types/api/address';
...@@ -21,12 +20,9 @@ import Pagination from 'ui/shared/Pagination'; ...@@ -21,12 +20,9 @@ import Pagination from 'ui/shared/Pagination';
import AddressTxsFilter from './AddressTxsFilter'; import AddressTxsFilter from './AddressTxsFilter';
import AddressIntTxsList from './internals/AddressIntTxsList'; import AddressIntTxsList from './internals/AddressIntTxsList';
const SCROLL_ELEM = 'address-internas-txs';
const SCROLL_OFFSET = -100;
const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues); const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
const AddressInternalTxs = () => { const AddressInternalTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
const router = useRouter(); const router = useRouter();
const [ filterValue, setFilterValue ] = React.useState<AddressFromToFilter>(getFilterValue(router.query.filter)); const [ filterValue, setFilterValue ] = React.useState<AddressFromToFilter>(getFilterValue(router.query.filter));
...@@ -38,7 +34,7 @@ const AddressInternalTxs = () => { ...@@ -38,7 +34,7 @@ const AddressInternalTxs = () => {
resourceName: 'address_internal_txs', resourceName: 'address_internal_txs',
pathParams: { id: queryIdStr }, pathParams: { id: queryIdStr },
filters: { filter: filterValue }, filters: { filter: filterValue },
scroll: { elem: SCROLL_ELEM, offset: SCROLL_OFFSET }, scrollRef,
}); });
const handleFilterChange = React.useCallback((val: string | Array<string>) => { const handleFilterChange = React.useCallback((val: string | Array<string>) => {
...@@ -83,7 +79,7 @@ const AddressInternalTxs = () => { ...@@ -83,7 +79,7 @@ const AddressInternalTxs = () => {
} }
return ( return (
<Element name={ SCROLL_ELEM }> <>
<ActionBar mt={ -6 }> <ActionBar mt={ -6 }>
<AddressTxsFilter <AddressTxsFilter
defaultFilter={ filterValue } defaultFilter={ filterValue }
...@@ -93,7 +89,7 @@ const AddressInternalTxs = () => { ...@@ -93,7 +89,7 @@ const AddressInternalTxs = () => {
{ isPaginationVisible && <Pagination ml="auto" { ...pagination }/> } { isPaginationVisible && <Pagination ml="auto" { ...pagination }/> }
</ActionBar> </ActionBar>
{ content } { content }
</Element> </>
); );
}; };
......
import { Box } from '@chakra-ui/react'; 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 { Element } from 'react-scroll';
import useQueryWithPages from 'lib/hooks/useQueryWithPages'; import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
...@@ -10,19 +9,14 @@ import LogItem from 'ui/shared/logs/LogItem'; ...@@ -10,19 +9,14 @@ import LogItem from 'ui/shared/logs/LogItem';
import LogSkeleton from 'ui/shared/logs/LogSkeleton'; import LogSkeleton from 'ui/shared/logs/LogSkeleton';
import Pagination from 'ui/shared/Pagination'; import Pagination from 'ui/shared/Pagination';
const SCROLL_PARAMS = { const AddressLogs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
elem: 'address-logs',
offset: -100,
};
const AddressLogs = () => {
const router = useRouter(); const router = useRouter();
const addressHash = String(router.query?.id); const addressHash = String(router.query?.id);
const { data, isLoading, isError, pagination, isPaginationVisible } = useQueryWithPages({ const { data, isLoading, isError, pagination, isPaginationVisible } = useQueryWithPages({
resourceName: 'address_logs', resourceName: 'address_logs',
pathParams: { id: addressHash }, pathParams: { id: addressHash },
scroll: SCROLL_PARAMS, scrollRef,
}); });
if (isError) { if (isError) {
...@@ -50,10 +44,10 @@ const AddressLogs = () => { ...@@ -50,10 +44,10 @@ const AddressLogs = () => {
} }
return ( return (
<Element name={ SCROLL_PARAMS.elem }> <>
{ bar } { bar }
{ data.items.map((item, index) => <LogItem key={ index } { ...item } type="address"/>) } { data.items.map((item, index) => <LogItem key={ index } { ...item } type="address"/>) }
</Element> </>
); );
}; };
......
...@@ -3,7 +3,7 @@ import React from 'react'; ...@@ -3,7 +3,7 @@ import React from 'react';
import TokenTransfer from 'ui/shared/TokenTransfer/TokenTransfer'; import TokenTransfer from 'ui/shared/TokenTransfer/TokenTransfer';
const AddressTokenTransfers = () => { const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
const router = useRouter(); const router = useRouter();
const hash = router.query.id; const hash = router.query.id;
...@@ -13,6 +13,7 @@ const AddressTokenTransfers = () => { ...@@ -13,6 +13,7 @@ const AddressTokenTransfers = () => {
pathParams={{ id: hash?.toString() }} pathParams={{ id: hash?.toString() }}
baseAddress={ typeof hash === 'string' ? hash : undefined } baseAddress={ typeof hash === 'string' ? hash : undefined }
enableTimeIncrement enableTimeIncrement
scrollRef={ scrollRef }
/> />
); );
}; };
......
import castArray from 'lodash/castArray'; import castArray from 'lodash/castArray';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { Element } from 'react-scroll';
import type { AddressFromToFilter } from 'types/api/address'; import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address'; import { AddressFromToFilterValues } from 'types/api/address';
...@@ -17,10 +16,7 @@ import AddressTxsFilter from './AddressTxsFilter'; ...@@ -17,10 +16,7 @@ import AddressTxsFilter from './AddressTxsFilter';
const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues); const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
const SCROLL_ELEM = 'address-txs'; const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
const SCROLL_OFFSET = -100;
const AddressTxs = () => {
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -31,7 +27,7 @@ const AddressTxs = () => { ...@@ -31,7 +27,7 @@ const AddressTxs = () => {
resourceName: 'address_txs', resourceName: 'address_txs',
pathParams: { id: castArray(router.query.id)[0] }, pathParams: { id: castArray(router.query.id)[0] },
filters: { filter: filterValue }, filters: { filter: filterValue },
scroll: { elem: SCROLL_ELEM, offset: SCROLL_OFFSET }, scrollRef,
}); });
const handleFilterChange = React.useCallback((val: string | Array<string>) => { const handleFilterChange = React.useCallback((val: string | Array<string>) => {
...@@ -50,7 +46,7 @@ const AddressTxs = () => { ...@@ -50,7 +46,7 @@ const AddressTxs = () => {
); );
return ( return (
<Element name={ SCROLL_ELEM }> <>
{ !isMobile && ( { !isMobile && (
<ActionBar mt={ -6 }> <ActionBar mt={ -6 }>
{ filter } { filter }
...@@ -64,7 +60,7 @@ const AddressTxs = () => { ...@@ -64,7 +60,7 @@ const AddressTxs = () => {
currentAddress={ typeof router.query.id === 'string' ? router.query.id : undefined } currentAddress={ typeof router.query.id === 'string' ? router.query.id : undefined }
enableTimeIncrement enableTimeIncrement
/> />
</Element> </>
); );
}; };
......
import { Flex, Skeleton, Tag } from '@chakra-ui/react'; 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';
...@@ -33,6 +33,8 @@ const CONTRACT_TABS = [ ...@@ -33,6 +33,8 @@ const CONTRACT_TABS = [
const AddressPageContent = () => { const AddressPageContent = () => {
const router = useRouter(); const router = useRouter();
const tabsScrollRef = React.useRef<HTMLDivElement>(null);
const addressQuery = useApiQuery('address', { const addressQuery = useApiQuery('address', {
pathParams: { id: router.query.id?.toString() }, pathParams: { id: router.query.id?.toString() },
queryOptions: { enabled: Boolean(router.query.id) }, queryOptions: { enabled: Boolean(router.query.id) },
...@@ -48,15 +50,15 @@ const AddressPageContent = () => { ...@@ -48,15 +50,15 @@ const AddressPageContent = () => {
const tabs: Array<RoutedTab> = React.useMemo(() => { const tabs: Array<RoutedTab> = React.useMemo(() => {
return [ return [
{ id: 'txs', title: 'Transactions', component: <AddressTxs/> }, { id: 'txs', title: 'Transactions', component: <AddressTxs scrollRef={ tabsScrollRef }/> },
{ id: 'token_transfers', title: 'Token transfers', component: <AddressTokenTransfers/> }, { id: 'token_transfers', title: 'Token transfers', component: <AddressTokenTransfers scrollRef={ tabsScrollRef }/> },
{ id: 'tokens', title: 'Tokens', component: null }, { id: 'tokens', title: 'Tokens', component: null },
{ id: 'internal_txns', title: 'Internal txns', component: <AddressInternalTxs/> }, { 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/> },
// temporary show this tab in all address // temporary show this tab in all address
// later api will return info about available tabs // later api will return info about available tabs
{ id: 'blocks_validated', title: 'Blocks validated', component: <AddressBlocksValidated/> }, { id: 'blocks_validated', title: 'Blocks validated', component: <AddressBlocksValidated/> },
isContract ? { id: 'logs', title: 'Logs', component: <AddressLogs/> } : undefined, isContract ? { id: 'logs', title: 'Logs', component: <AddressLogs scrollRef={ tabsScrollRef }/> } : undefined,
isContract ? { isContract ? {
id: 'contract', id: 'contract',
title: 'Contract', title: 'Contract',
...@@ -80,6 +82,8 @@ const AddressPageContent = () => { ...@@ -80,6 +82,8 @@ const AddressPageContent = () => {
/> />
) } ) }
<AddressDetails addressQuery={ addressQuery }/> <AddressDetails addressQuery={ addressQuery }/>
{ /* should stay before tabs to scroll up whith pagination */ }
<Box ref={ tabsScrollRef }></Box>
{ addressQuery.isLoading ? <SkeletonTabs/> : <RoutedTabs tabs={ tabs } tabListProps={{ mt: 8 }}/> } { addressQuery.isLoading ? <SkeletonTabs/> : <RoutedTabs tabs={ tabs } tabListProps={{ mt: 8 }}/> }
</Page> </Page>
); );
......
import { Skeleton } from '@chakra-ui/react'; import { Skeleton, Box } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { Element } from 'react-scroll';
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';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import Page from 'ui/shared/Page/Page'; import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import type { Props as PaginationProps } 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 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';
export type TokenTabs = 'token_transfers' | 'holders' export type TokenTabs = 'token_transfers' | 'holders'
const TokenPageContent = () => { const TokenPageContent = () => {
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile();
const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenQuery = useApiQuery('token', { const tokenQuery = useApiQuery('token', {
pathParams: { hash: router.query.hash?.toString() }, pathParams: { hash: router.query.hash?.toString() },
queryOptions: { enabled: Boolean(router.query.hash) }, queryOptions: { enabled: Boolean(router.query.hash) },
}); });
// const transfersQuery = useQueryWithPages({
// resourceName: 'token_transfers',
// pathParams: { hash: router.query.hash?.toString() },
// options: {
// enabled: Boolean(router.query.hash && router.query.tab === 'holders' && tokenQuery.data),
// },
// });
const holdersQuery = useQueryWithPages({
resourceName: 'token_holders',
pathParams: { hash: router.query.hash?.toString() },
scrollRef,
options: {
enabled: Boolean(router.query.hash && router.query.tab === 'holders' && tokenQuery.data),
},
});
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
{ id: 'token_transfers', title: 'Token transfers', component: null }, { id: 'token_transfers', title: 'Token transfers', component: null },
{ id: 'holders', title: 'Holders', component: null }, { id: 'holders', title: 'Holders', component: <TokenHolders tokenQuery={ tokenQuery } holdersQuery={ holdersQuery }/> },
]; ];
let hasPagination;
let pagination;
// if (router.query.tab === 'token_transfers') {
// hasPagination = transfersQuery.isPaginationVisible;
// pagination = transfersQuery.pagination;
// }
if (router.query.tab === 'holders') {
hasPagination = holdersQuery.isPaginationVisible;
pagination = holdersQuery.pagination;
}
return ( return (
<Page> <Page>
{ tokenQuery.isLoading ? { tokenQuery.isLoading ?
...@@ -34,7 +69,16 @@ const TokenPageContent = () => { ...@@ -34,7 +69,16 @@ const TokenPageContent = () => {
<PageTitle text={ `${ tokenQuery.data?.name } (${ tokenQuery.data?.symbol }) token` }/> } <PageTitle text={ `${ tokenQuery.data?.name } (${ tokenQuery.data?.symbol }) token` }/> }
<TokenContractInfo tokenQuery={ tokenQuery }/> <TokenContractInfo tokenQuery={ tokenQuery }/>
<TokenDetails tokenQuery={ tokenQuery }/> <TokenDetails tokenQuery={ tokenQuery }/>
<Element name="token-tabs"><RoutedTabs tabs={ tabs } tabListProps={{ mt: 8 }}/></Element>
{ /* should stay before tabs to scroll up whith pagination */ }
<Box ref={ scrollRef }></Box>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? { mt: 8 } : { mt: 3, py: 5, marginBottom: 0 } }
rightSlot={ !isMobile && hasPagination ? <Pagination { ...(pagination as PaginationProps) }/> : null }
stickyEnabled={ !isMobile }
/>
</Page> </Page>
); );
}; };
......
import { Hide, Show, Text } from '@chakra-ui/react'; 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 { Element } from 'react-scroll';
import type { AddressFromToFilter } from 'types/api/address'; import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address'; import { AddressFromToFilterValues } from 'types/api/address';
...@@ -28,9 +27,6 @@ import { TOKEN_TYPE } from './helpers'; ...@@ -28,9 +27,6 @@ import { TOKEN_TYPE } from './helpers';
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id); const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const SCROLL_ELEM = 'token-transfers';
const SCROLL_OFFSET = -100;
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES); const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
const getAddressFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues); const getAddressFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
...@@ -43,6 +39,7 @@ interface Props<Resource extends 'tx_token_transfers' | 'address_token_transfers ...@@ -43,6 +39,7 @@ interface Props<Resource extends 'tx_token_transfers' | 'address_token_transfers
txHash?: string; txHash?: string;
enableTimeIncrement?: boolean; enableTimeIncrement?: boolean;
pathParams?: UseApiQueryParams<Resource>['pathParams']; pathParams?: UseApiQueryParams<Resource>['pathParams'];
scrollRef?: React.RefObject<HTMLDivElement>;
} }
type State = { type State = {
...@@ -58,6 +55,7 @@ const TokenTransfer = <Resource extends 'tx_token_transfers' | 'address_token_tr ...@@ -58,6 +55,7 @@ const TokenTransfer = <Resource extends 'tx_token_transfers' | 'address_token_tr
showTxInfo = true, showTxInfo = true,
enableTimeIncrement, enableTimeIncrement,
pathParams, pathParams,
scrollRef,
}: Props<Resource>) => { }: Props<Resource>) => {
const router = useRouter(); const router = useRouter();
const [ filters, setFilters ] = React.useState<State>( const [ filters, setFilters ] = React.useState<State>(
...@@ -69,7 +67,7 @@ const TokenTransfer = <Resource extends 'tx_token_transfers' | 'address_token_tr ...@@ -69,7 +67,7 @@ const TokenTransfer = <Resource extends 'tx_token_transfers' | 'address_token_tr
pathParams, pathParams,
options: { enabled: !isDisabled }, options: { enabled: !isDisabled },
filters: filters as PaginationFilters<Resource>, filters: filters as PaginationFilters<Resource>,
scroll: { elem: SCROLL_ELEM, offset: SCROLL_OFFSET }, scrollRef,
}); });
const handleTypeFilterChange = React.useCallback((nextValue: Array<TokenType>) => { const handleTypeFilterChange = React.useCallback((nextValue: Array<TokenType>) => {
...@@ -129,7 +127,7 @@ const TokenTransfer = <Resource extends 'tx_token_transfers' | 'address_token_tr ...@@ -129,7 +127,7 @@ const TokenTransfer = <Resource extends 'tx_token_transfers' | 'address_token_tr
})(); })();
return ( return (
<Element name={ SCROLL_ELEM }> <>
{ !isActionBarHidden && ( { !isActionBarHidden && (
<ActionBar mt={ -6 }> <ActionBar mt={ -6 }>
<TokenTransferFilter <TokenTransferFilter
...@@ -144,7 +142,7 @@ const TokenTransfer = <Resource extends 'tx_token_transfers' | 'address_token_tr ...@@ -144,7 +142,7 @@ const TokenTransfer = <Resource extends 'tx_token_transfers' | 'address_token_tr
</ActionBar> </ActionBar>
) } ) }
{ content } { content }
</Element> </>
); );
}; };
......
import { Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { TokenHolders, TokenInfo } from 'types/api/tokenInfo';
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 TokenHoldersList from './TokenHoldersList';
import TokenHoldersTable from './TokenHoldersTable';
type Props = {
tokenQuery: UseQueryResult<TokenInfo>;
holdersQuery: UseQueryResult<TokenHolders> & {
pagination: PaginationProps;
isPaginationVisible: boolean;
};
}
const TokenHoldersContent = ({ holdersQuery, tokenQuery }: Props) => {
const isMobile = useIsMobile();
if (holdersQuery.isError || tokenQuery.isError) {
return <DataFetchAlert/>;
}
const bar = isMobile && holdersQuery.isPaginationVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...holdersQuery.pagination }/>
</ActionBar>
);
if (holdersQuery.isLoading || tokenQuery.isLoading) {
return (
<>
{ bar }
{ isMobile && <SkeletonList/> }
{ !isMobile && (
<SkeletonTable columns={ [ '100%', '300px', '175px' ] }/>
) }
</>
);
}
const items = holdersQuery.data.items;
if (!items?.length) {
return <Text as="span">There are no holders for this token.</Text>;
}
return (
<>
{ bar }
{ !isMobile && <TokenHoldersTable data={ items } token={ tokenQuery.data }/> }
{ isMobile && <TokenHoldersList data={ items } token={ tokenQuery.data }/> }
</>
);
};
export default TokenHoldersContent;
import { test, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import { tokenHolders } from 'mocks/tokens/tokenHolders';
import { tokenInfo } from 'mocks/tokens/tokenInfo';
import TestApp from 'playwright/TestApp';
import TokenHoldersList from './TokenHoldersList';
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount }) => {
const component = await mount(
<TestApp>
<TokenHoldersList data={ tokenHolders.items } token={ tokenInfo }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { TokenHolder, TokenInfo } from 'types/api/tokenInfo';
import TokenHoldersListItem from './TokenHoldersListItem';
interface Props {
data: Array<TokenHolder>;
token: TokenInfo;
}
const TokenHoldersList = ({ data, token }: Props) => {
return (
<Box>
{ data.map((item) => (
<TokenHoldersListItem
key={ item.address.hash }
token={ token }
holder={ item }
/>
)) }
</Box>
);
};
export default TokenHoldersList;
import { Flex } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenHolder, TokenInfo } from 'types/api/tokenInfo';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import ListItemMobile from 'ui/shared/ListItemMobile';
import Utilization from 'ui/shared/Utilization/Utilization';
interface Props {
holder: TokenHolder;
token: TokenInfo;
}
const TokenHoldersListItem = ({ holder, token }: Props) => {
const quantity = BigNumber(holder.value).div(BigNumber(10 ** Number(token.decimals))).dp(6).toFormat();
return (
<ListItemMobile rowGap={ 3 }>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon address={ holder.address }/>
<AddressLink ml={ 2 } fontWeight="700" hash={ holder.address.hash } alias={ holder.address.name } flexGrow={ 1 }/>
</Address>
<Flex justifyContent="space-between" alignItems="center" width="100%">
{ quantity }
{ token.total_supply && (
<Utilization
value={ BigNumber(holder.value).div(BigNumber(token.total_supply)).dp(4).toNumber() }
colorScheme="green"
ml={ 6 }
/>
) }
</Flex>
</ListItemMobile>
);
};
export default TokenHoldersListItem;
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { tokenHolders } from 'mocks/tokens/tokenHolders';
import { tokenInfo } from 'mocks/tokens/tokenInfo';
import TestApp from 'playwright/TestApp';
import TokenHoldersTable from './TokenHoldersTable';
test('base view', async({ mount }) => {
const component = await mount(
<TestApp>
<Box h="128px"/>
<TokenHoldersTable data={ tokenHolders.items } token={ tokenInfo }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { TokenHolder, TokenInfo } from 'types/api/tokenInfo';
import { default as Thead } from 'ui/shared/TheadSticky';
import TokenHoldersTableItem from 'ui/token/TokenHolders/TokenHoldersTableItem';
interface Props {
data: Array<TokenHolder>;
token: TokenInfo;
}
const TokenHoldersTable = ({ data, token }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ 80 }>
<Tr>
<Th>Holder</Th>
<Th isNumeric width="300px">Quantity</Th>
{ token.total_supply && <Th isNumeric width="175px">Percentage</Th> }
</Tr>
</Thead>
<Tbody>
{ data.map((item) => (
<TokenHoldersTableItem key={ item.address.hash } holder={ item } token={ token }/>
)) }
</Tbody>
</Table>
);
};
export default React.memo(TokenHoldersTable);
import { Tr, Td } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenHolder, TokenInfo } from 'types/api/tokenInfo';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import Utilization from 'ui/shared/Utilization/Utilization';
type Props = {
holder: TokenHolder;
token: TokenInfo;
}
const TokenTransferTableItem = ({ holder, token }: Props) => {
const quantity = BigNumber(holder.value).div(BigNumber(10 ** Number(token.decimals))).toFormat();
return (
<Tr>
<Td>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon address={ holder.address }/>
<AddressLink ml={ 2 } fontWeight="700" hash={ holder.address.hash } alias={ holder.address.name } flexGrow={ 1 }/>
</Address>
</Td>
<Td isNumeric>
{ quantity }
</Td>
{ token.total_supply && (
<Td isNumeric>
<Utilization
value={ BigNumber(holder.value).div(BigNumber(token.total_supply)).dp(4).toNumber() }
colorScheme="green"
display="inline-flex"
/>
</Td>
) }
</Tr>
);
};
export default React.memo(TokenTransferTableItem);
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