Commit a481f3bf authored by isstuev's avatar isstuev

token details

parent bf706c90
...@@ -20,6 +20,7 @@ import type { JsonRpcUrlResponse } from 'types/api/jsonRpcUrl'; ...@@ -20,6 +20,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 { 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';
...@@ -162,6 +163,14 @@ export const RESOURCES = { ...@@ -162,6 +163,14 @@ export const RESOURCES = {
filterFields: [ ], filterFields: [ ],
}, },
// TOKEN
token: {
path: '/api/v2/tokens/:hash',
},
token_counters: {
path: '/api/v2/tokens/:hash/counters',
},
// HOMEPAGE // HOMEPAGE
homepage_stats: { homepage_stats: {
path: '/api/v2/stats', path: '/api/v2/stats',
...@@ -258,6 +267,8 @@ Q extends 'address_blocks_validated' ? AddressBlocksValidatedResponse : ...@@ -258,6 +267,8 @@ 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 'token' ? TokenInfo :
Q extends 'token_counters' ? TokenCounters :
Q extends 'config_json_rpc' ? JsonRpcUrlResponse : Q extends 'config_json_rpc' ? JsonRpcUrlResponse :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
......
import type { PageParams } from './types';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
export default function getSeo(params: PageParams) {
const networkTitle = getNetworkTitle();
return {
title: params ? `${ params.hash } - ${ networkTitle }` : '',
description: params ?
`${ params.hash }, balances, and analytics on the on the ${ networkTitle }` :
'',
};
}
import type { GetServerSideProps, GetServerSidePropsResult } from 'next';
export type Props = {
cookies: string;
referrer: string;
hash?: string;
}
export const getServerSideProps: GetServerSideProps = async({ req, query }): Promise<GetServerSidePropsResult<Props>> => {
return {
props: {
cookies: req.headers.cookie || '',
referrer: req.headers.referer || '',
hash: query.hash?.toString() || '',
},
};
};
export type PageParams = {
hash: string;
}
import type { Address } from 'types/api/address';
import type { AddressParam } from 'types/api/addressParams'; import type { AddressParam } from 'types/api/addressParams';
import { tokenInfo } from 'mocks/tokens/tokenInfo';
export const withName: AddressParam = { export const withName: AddressParam = {
hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859', hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
implementation_name: null, implementation_name: null,
...@@ -21,3 +24,21 @@ export const withoutName: AddressParam = { ...@@ -21,3 +24,21 @@ export const withoutName: AddressParam = {
watchlist_names: [], watchlist_names: [],
public_tags: [], public_tags: [],
}; };
export const withToken: Address = {
hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
implementation_name: null,
is_contract: true,
is_verified: false,
name: null,
private_tags: [],
watchlist_names: [],
public_tags: [],
token: tokenInfo,
block_number_balance_updated_at: 8201413,
coin_balance: '1',
creation_tx_hash: null,
creator_address_hash: null,
exchange_rate: null,
implementation_address: null,
};
import type { TokenCounters, TokenInfo } from 'types/api/tokenInfo';
export const tokenInfo: TokenInfo = {
address: '0x55d536e4d6c1993d8ef2e2a4ef77f02088419420',
decimals: '18',
exchange_rate: '2.0101',
holders: '46554',
name: 'ARIANEE',
symbol: 'ARIA',
type: 'ERC-20',
total_supply: '1235',
};
export const tokenCounters: TokenCounters = {
token_holders_count: '8838883',
transfers_count: '88282281',
};
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import type { PageParams } from 'lib/next/token/types';
import getSeo from 'lib/next/token/getSeo';
import Token from 'ui/pages/Token';
const TokenPage: NextPage<PageParams> = ({ hash }: PageParams) => {
const { title, description } = getSeo({ hash });
return (
<>
<Head>
<title>{ title }</title>
<meta name="description" content={ description }/>
</Head>
<Token/>
</>
);
};
export default TokenPage;
export { getServerSideProps } from 'lib/next/token/getServerSideProps';
...@@ -11,4 +11,9 @@ export interface TokenInfo { ...@@ -11,4 +11,9 @@ export interface TokenInfo {
total_supply: string | null; total_supply: string | null;
} }
export interface TokenCounters {
token_holders_count: string;
transfers_count: string;
}
export type TokenInfoGeneric<Type extends TokenType> = Omit<TokenInfo, 'type'> & { type: Type }; export type TokenInfoGeneric<Type extends TokenType> = Omit<TokenInfo, 'type'> & { type: Type };
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { withToken as contract } from 'mocks/address/address';
import { tokenInfo, tokenCounters } from 'mocks/tokens/tokenInfo';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import Token from './Token';
const TOKEN_API_URL = buildApiUrl('token', { hash: '1' });
const TOKEN_COUNTERS_API_URL = buildApiUrl('token_counters', { hash: '1' });
const ADDRESS_API_URL = buildApiUrl('address', { id: '1' });
const hooksConfig = {
router: {
query: { hash: 1, tab: 'token_transfers' },
isReady: true,
},
};
// FIXME: idk why mobile test doesn't work (it's ok locally)
// test('base view +@mobile +@dark-mode', async({ mount, page }) => {
test('base view +@dark-mode', async({ mount, page }) => {
await page.route(TOKEN_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(tokenInfo),
}));
await page.route(ADDRESS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contract),
}));
await page.route(TOKEN_COUNTERS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(tokenCounters),
}));
const component = await mount(
<TestApp>
<Token/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
import { Skeleton } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { Element } from 'react-scroll';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import TokenContractInfo from 'ui/token/TokenContractInfo';
import TokenDetails from 'ui/token/TokenDetails';
export type TokenTabs = 'token_transfers' | 'holders'
const TokenPageContent = () => {
const router = useRouter();
const tokenQuery = useApiQuery('token', {
pathParams: { hash: router.query.hash?.toString() },
queryOptions: { enabled: Boolean(router.query.hash) },
});
const tabs: Array<RoutedTab> = [
{ id: 'token_transfers', title: 'Token transfers', component: null },
{ id: 'holders', title: 'Holders', component: null },
];
if (tokenQuery.isError) {
return <DataFetchAlert/>;
}
return (
<Page>
{ tokenQuery.isLoading ?
<Skeleton w="500px" h={ 10 } mb={ 6 }/> :
<PageTitle text={ `${ tokenQuery.data.name } (${ tokenQuery.data.symbol }) token` }/> }
<TokenContractInfo tokenQuery={ tokenQuery }/>
<TokenDetails tokenQuery={ tokenQuery }/>
<Element name="token-tabs"><RoutedTabs tabs={ tabs } tabListProps={{ mt: 8 }}/></Element>
</Page>
);
};
export default TokenPageContent;
import { Flex, Skeleton, SkeletonCircle } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo';
import useApiQuery from 'lib/api/useApiQuery';
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 AddressContractIcon from 'ui/shared/address/AddressContractIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
tokenQuery: UseQueryResult<TokenInfo>;
}
const TokenContractInfo = ({ tokenQuery }: Props) => {
const router = useRouter();
const isMobile = useIsMobile();
const contractQuery = useApiQuery('address', {
pathParams: { id: router.query.hash?.toString() },
queryOptions: { enabled: Boolean(router.query.hash) },
});
if (tokenQuery.isLoading || contractQuery.isLoading) {
return (
<Flex alignItems="center">
<SkeletonCircle boxSize={ 6 }/>
<Skeleton w="400px" h={ 5 } ml={ 2 }/>
<Skeleton w={ 5 } h={ 5 } ml={ 1 }/>
<Skeleton w={ 9 } h={ 8 } ml={ 2 }/>
<Skeleton w={ 9 } h={ 8 } ml={ 3 }/>
<Skeleton w={ 9 } h={ 8 } ml={ 2 }/>
</Flex>
);
}
// we show error in parent component, this is only for TS
if (tokenQuery.isError) {
return null;
}
const hash = tokenQuery.data.address;
return (
<Flex alignItems="center">
<AddressContractIcon/>
<AddressLink hash={ hash } ml={ 2 } truncation={ isMobile ? 'constant' : 'none' }/>
<CopyToClipboard text={ hash } ml={ 1 }/>
{ contractQuery.data?.token && <AddressAddToMetaMask token={ contractQuery.data?.token } ml={ 2 }/> }
<AddressFavoriteButton hash={ hash } isAdded={ Boolean(contractQuery.data?.watchlist_names?.length) } ml={ 3 }/>
<AddressQrCode hash={ hash } ml={ 2 }/>
</Flex>
);
};
export default React.memo(TokenContractInfo);
import { Grid, Link, Skeleton } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React, { useCallback } from 'react';
import { scroller } from 'react-scroll';
import type { TokenInfo } from 'types/api/tokenInfo';
import useApiQuery from 'lib/api/useApiQuery';
import getCurrencyValue from 'lib/getCurrencyValue';
import link from 'lib/link/link';
import type { TokenTabs } from 'ui/pages/Token';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
interface Props {
tokenQuery: UseQueryResult<TokenInfo>;
}
const TokenDetails = ({ tokenQuery }: Props) => {
const router = useRouter();
const tokenCountersQuery = useApiQuery('token_counters', {
pathParams: { hash: router.query.hash?.toString() },
queryOptions: { enabled: Boolean(router.query.hash) },
});
const changeUrlAndScroll = useCallback((tab: TokenTabs) => () => {
const newLink = link('token_index', { hash: router.query.hash }, { tab: tab });
router.push(
newLink,
undefined,
{ shallow: true },
);
scroller.scrollTo('token-tabs', {
duration: 500,
smooth: true,
});
}, [ router ]);
const countersItem = useCallback((item: 'token_holders_count' | 'transfers_count') => {
const itemValue = tokenCountersQuery.data?.[item];
if (!itemValue) {
return 'N/A';
}
if (itemValue === '0') {
return itemValue;
}
const tab: TokenTabs = item === 'token_holders_count' ? 'holders' : 'token_transfers';
return <Link onClick={ changeUrlAndScroll(tab) }>{ itemValue }</Link>;
}, [ tokenCountersQuery.data, changeUrlAndScroll ]);
if (tokenQuery.isLoading) {
return (
<Grid mt={ 10 } columnGap={ 8 } rowGap={{ base: 5, lg: 7 }} templateColumns={{ base: '1fr', lg: '210px 1fr' }} maxW="1000px">
<DetailsSkeletonRow w="10%"/>
<DetailsSkeletonRow w="30%"/>
<DetailsSkeletonRow w="30%"/>
<DetailsSkeletonRow w="20%"/>
<DetailsSkeletonRow w="20%"/>
<DetailsSkeletonRow w="10%"/>
</Grid>
);
}
// we show error in parent component, this is only for TS
if (tokenQuery.isError) {
return null;
}
const {
exchange_rate: exchangeRate,
total_supply: totalSupply,
decimals,
symbol,
} = tokenQuery.data;
const totalValue = totalSupply !== null ? getCurrencyValue({ value: totalSupply, accuracy: 3, accuracyUsd: 2, exchangeRate, decimals }) : undefined;
return (
<Grid
mt={ 8 }
columnGap={ 8 }
rowGap={{ base: 1, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: 'auto minmax(0, 1fr)' }} overflow="hidden"
>
{ exchangeRate && (
<DetailsInfoItem
title="Price"
hint="Price per token on the exchanges."
alignSelf="center"
>
{ `$${ exchangeRate }` }
</DetailsInfoItem>
) }
{ totalValue?.usd && (
<DetailsInfoItem
title="Fully diluted market cap"
hint="Total supply * Price."
alignSelf="center"
>
{ `$${ totalValue?.usd }` }
</DetailsInfoItem>
) }
{ totalValue?.valueStr && (
<DetailsInfoItem
title="Max total supply"
hint="The total amount of tokens issued."
alignSelf="center"
>
{ `${ totalValue.valueStr } ${ symbol }` }
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Holders"
hint="Number of accounts holding the token."
alignSelf="center"
>
{ tokenCountersQuery.isLoading && <Skeleton w={ 20 } h={ 4 }/> }
{ !tokenCountersQuery.isLoading && countersItem('token_holders_count') }
</DetailsInfoItem>
<DetailsInfoItem
title="Transfers"
hint="Number of transfer for the token."
alignSelf="center"
>
{ tokenCountersQuery.isLoading && <Skeleton w={ 20 } h={ 4 }/> }
{ !tokenCountersQuery.isLoading && countersItem('transfers_count') }
</DetailsInfoItem>
{ decimals && (
<DetailsInfoItem
title="Decimals"
hint="Number of digits that come after the decimal place when displaying token value."
alignSelf="center"
>
{ decimals }
</DetailsInfoItem>
) }
</Grid>
);
};
export default React.memo(TokenDetails);
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