Commit 87036c39 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Bridged tokens (#1249)

* tag "Bridged"

* base implementation of bridged tokens table

* - add tags
- reset search term and sort when switching between tabs
- fix skeleton
- add descriptioin

* - save search and filter in query
- add link to original token on token page

* add envs

* fix ts

* pin host for demo

* adjust envs schema

* fix schema

* fix review env configs

* tests

* rollback api config

* refactor query

* [skip ci] refactor filters

* update description text and disable reset link if there are no filters active

* fix poppover filter and tabs resize issues

* fix tests
parent 2ffc1e57
......@@ -321,6 +321,7 @@
"eth",
"rootstock",
"polygon",
"gnosis",
"localhost",
],
"default": "main"
......
import type { Feature } from './types';
import type { BridgedTokenChain, TokenBridge } from 'types/client/token';
import { getEnvValue, parseEnvJson } from '../utils';
const title = 'Bridged tokens';
const config: Feature<{ chains: Array<BridgedTokenChain>; bridges: Array<TokenBridge> }> = (() => {
const chains = parseEnvJson<Array<BridgedTokenChain>>(getEnvValue('NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS'));
const bridges = parseEnvJson<Array<TokenBridge>>(getEnvValue('NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES'));
if (chains && chains.length > 0 && bridges && bridges.length > 0) {
return Object.freeze({
title,
isEnabled: true,
chains,
bridges,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
......@@ -3,6 +3,7 @@ export { default as addressVerification } from './addressVerification';
export { default as adsBanner } from './adsBanner';
export { default as adsText } from './adsText';
export { default as beaconChain } from './beaconChain';
export { default as bridgedTokens } from './bridgedTokens';
export { default as blockchainInteraction } from './blockchainInteraction';
export { default as csvExport } from './csvExport';
export { default as googleAnalytics } from './googleAnalytics';
......
# Set of ENVs for Gnosis network explorer
# https://gnosis.blockscout.com/
# app configuration
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
# blockchain parameters
NEXT_PUBLIC_NETWORK_NAME=Gnosis
NEXT_PUBLIC_NETWORK_SHORT_NAME=Gnosis
NEXT_PUBLIC_NETWORK_ID=100
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=xDAI
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=xDAI
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_NETWORK_RPC_URL=https://rpc.gnosischain.com
# api configuration
NEXT_PUBLIC_API_HOST=gnosis.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND="rgb(46, 74, 60)"
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR="rgb(255, 255, 255)"
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/gnosis-chain-mainnet.json
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/gnosis.svg
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/gnosis.svg
## footer
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/gnosis.json
## views
## misc
# app features
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x082762f95047d39d612daafec832f88163f3815fde4ddd8944f2a5198a396e0f
# NEXT_PUBLIC_BEACON_CHAIN_CURRENCY_SYMBOL=GNO
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace/gnosis-chain.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrmiO9mDGJoPNmJe
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS=[{'id':'1','title':'Ethereum','short_title':'ETH','base_url':'https://eth.blockscout.com/token/'},{'id':'56','title':'Binance Smart Chain','short_title':'BSC','base_url':'https://bscscan.com/token/'},{'id':'99','title':'POA','short_title':'POA','base_url':'https://blockscout.com/poa/core/token/'}]
NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES=[{'type':'omni','title':'OmniBridge','short_title':'OMNI'},{'type':'amb','title':'Arbitrary Message Bridge','short_title':'AMB'}]
#meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/polygon-mainnet.png?raw=true
......@@ -13,6 +13,7 @@ import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS } from '../.
import type { AdTextProviders, AdBannerProviders } from '../../../types/client/adProviders';
import type { MarketplaceAppOverview } from '../../../types/client/marketplace';
import type { NavItemExternal } from '../../../types/client/navigation-items';
import type { BridgedTokenChain, TokenBridge } from '../../../types/client/token';
import type { WalletType } from '../../../types/client/wallets';
import { SUPPORTED_WALLETS } from '../../../types/client/wallets';
import type { CustomLink, CustomLinksGroup } from '../../../types/footerLinks';
......@@ -247,6 +248,41 @@ const networkExplorerSchema: yup.ObjectSchema<NetworkExplorer> = yup
}),
});
const bridgedTokenChainSchema: yup.ObjectSchema<BridgedTokenChain> = yup
.object({
id: yup.string().required(),
title: yup.string().required(),
short_title: yup.string().required(),
base_url: yup.string().test(urlTest).required(),
});
const tokenBridgeSchema: yup.ObjectSchema<TokenBridge> = yup
.object({
type: yup.string().required(),
title: yup.string().required(),
short_title: yup.string().required(),
});
const bridgedTokensSchema = yup
.object()
.shape({
NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS: yup
.array()
.transform(replaceQuotes)
.json()
.of(bridgedTokenChainSchema),
NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES: yup
.array()
.transform(replaceQuotes)
.json()
.of(tokenBridgeSchema)
.when('NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS', {
is: (value: Array<unknown>) => value && value.length > 0,
then: (schema) => schema.required(),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES cannot not be used without NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS'),
}),
});
const schema = yup
.object()
.noUnknown(true, (params) => {
......@@ -374,6 +410,7 @@ const schema = yup
.concat(marketplaceSchema)
.concat(rollupSchema)
.concat(beaconChainSchema)
.concat(bridgedTokensSchema)
.concat(sentrySchema);
export default schema;
......@@ -17,6 +17,7 @@ frontend:
exact:
# - "/(apps|auth/profile|account)"
- "/"
- "/envs.js"
prefix:
# - "/(apps|auth/profile|account)"
- "/_next"
......@@ -25,6 +26,7 @@ frontend:
- "/apps"
- "/static"
- "/favicon"
- "/assets"
- "/auth/profile"
- "/auth/unverified-email"
- "/txs"
......
......@@ -26,6 +26,7 @@ frontend:
- "/static"
- "/assets"
- "/favicon"
- "/assets"
- "/auth/profile"
- "/auth/unverified-email"
- "/txs"
......@@ -134,3 +135,5 @@ frontend:
_default: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE:
_default: gradient_avatar
NEXT_PUBLIC_USE_NEXT_JS_PROXY:
_default: true
\ No newline at end of file
......@@ -35,6 +35,8 @@ The app instance could be customized by passing following variables to NodeJS en
- [Blockchain statistics](ENVS.md#blockchain-statistics)
- [Web3 wallet integration](ENVS.md#web3-wallet-integration-add-token-or-network-to-the-wallet) (add token or network to the wallet)
- [Verified tokens info](ENVS.md#verified-tokens-info)
- [Bridged tokens](ENVS.md#bridged-tokens)
- [Safe{Core} address tags](ENVS.md#safecore-address-tags)
- [SUAVE chain](ENVS.md#suave-chain)
- [Sentry error monitoring](ENVS.md#sentry-error-monitoring)
- [3rd party services configuration](ENVS.md#external-services-configuration)
......@@ -406,6 +408,36 @@ This feature is **enabled by default** with the `['metamask']` value. To switch
&nbsp;
### Bridged tokens
This feature allows users to view tokens that have been bridged from other EVM chains. Additional tab "Bridged" will be added to the tokens page and the link to original token will be displayed on the token page.
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS | `Array<BridgedTokenChain>` where `BridgedTokenChain` can have following [properties](#bridged-token-chain-configuration-properties) | Used for displaying filter by the chain from which token where bridged. Also, used for creating links to original tokens in other explorers. | Required | - | `[{'id':'1','title':'Ethereum','short_title':'ETH','base_url':'https://eth.blockscout.com/token'}]` |
| NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES | `Array<TokenBridge>` where `TokenBridge` can have following [properties](#token-bridge-configuration-properties) | Used for displaying text about bridges types on the tokens page. | Required | - | `[{'type':'omni','title':'OmniBridge','short_title':'OMNI'}]` |
#### Bridged token chain configuration properties
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| id | `string` | Base chain id, see [https://chainlist.org](https://chainlist.org) for the reference | Required | - | `1` |
| title | `string` | Displayed name of the chain | Required | - | `Ethereum` |
| short_title | `string` | Used for displaying chain name in the list view as tag | Required | - | `ETH` |
| base_url | `string` | Base url to original token in base chain explorer | Required | - | `https://eth.blockscout.com/token` |
*Note* The url to original token will be constructed as `<base_url>/<token_hash>`, e.g `https://eth.blockscout.com/token/<token_hash>`
#### Token bridge configuration properties
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| type | `string` | Bridge type; should be matched to `bridge_type` field in API response | Required | - | `omni` |
| title | `string` | Bridge title | Required | - | `OmniBridge` |
| short_title | `string` | Bridge short title for displaying in the tags | Required | - | `OMNI` |
&nbsp;
### Safe{Core} address tags
For the smart contract addresses which are [Safe{Core} accounts](https://safe.global/) public tag "Multisig: Safe" will be displayed in the address page header along side to Safe logo. The Safe service is available only for certain networks, see full list [here](https://docs.safe.global/safe-core-api/available-services). Based on provided value of `NEXT_PUBLIC_NETWORK_ID`, the feature will be enabled or disabled.
......
......@@ -52,7 +52,7 @@ import type {
TokenInstanceTransfersCount,
TokenVerifiedInfo,
} from 'types/api/token';
import type { TokensResponse, TokensFilters, TokensSorting, TokenInstanceTransferResponse } from 'types/api/tokens';
import type { TokensResponse, TokensFilters, TokensSorting, TokenInstanceTransferResponse, TokensBridgedFilters } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
import type { TransactionsResponseValidated, TransactionsResponsePending, Transaction, TransactionsResponseWatchlist } from 'types/api/transaction';
import type { TTxsFilters } from 'types/api/txsFilters';
......@@ -382,6 +382,10 @@ export const RESOURCES = {
path: '/api/v2/tokens',
filterFields: [ 'q' as const, 'type' as const ],
},
tokens_bridged: {
path: '/api/v2/tokens/bridged',
filterFields: [ 'q' as const, 'chain_ids' as const ],
},
// TOKEN INSTANCE
token_instance: {
......@@ -544,7 +548,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' |
'address_logs' | 'address_tokens' |
'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens' |
'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens' | 'tokens_bridged' |
'token_instance_transfers' | 'token_instance_holders' |
'verified_contracts' |
'l2_output_roots' | 'l2_withdrawals' | 'l2_txn_batches' | 'l2_deposits' |
......@@ -613,6 +617,7 @@ Q extends 'token_instance_transfers' ? TokenInstanceTransferResponse :
Q extends 'token_instance_holders' ? TokenHolders :
Q extends 'token_inventory' ? TokenInventoryResponse :
Q extends 'tokens' ? TokensResponse :
Q extends 'tokens_bridged' ? TokensResponse :
Q extends 'quick_search' ? Array<SearchResultItem> :
Q extends 'search' ? SearchResult :
Q extends 'search_check_redirect' ? SearchRedirectResult :
......@@ -650,6 +655,7 @@ Q extends 'address_token_transfers' ? AddressTokenTransferFilters :
Q extends 'address_tokens' ? AddressTokensFilter :
Q extends 'search' ? SearchResultFilters :
Q extends 'tokens' ? TokensFilters :
Q extends 'tokens_bridged' ? TokensBridgedFilters :
Q extends 'verified_contracts' ? VerifiedContractsFilters :
never;
/* eslint-enable @typescript-eslint/indent */
......@@ -657,5 +663,6 @@ never;
/* eslint-disable @typescript-eslint/indent */
export type PaginationSorting<Q extends PaginatedResources> =
Q extends 'tokens' ? TokensSorting :
Q extends 'tokens_bridged' ? TokensSorting :
never;
/* eslint-enable @typescript-eslint/indent */
import type { TokenType } from 'types/api/token';
const TOKEN_TYPE: Array<{ title: string; id: TokenType }> = [
export const TOKEN_TYPES: Array<{ title: string; id: TokenType }> = [
{ title: 'ERC-20', id: 'ERC-20' },
{ title: 'ERC-721', id: 'ERC-721' },
{ title: 'ERC-1155', id: 'ERC-1155' },
];
export default TOKEN_TYPE;
export const TOKEN_TYPE_IDS = TOKEN_TYPES.map(i => i.id);
......@@ -45,7 +45,7 @@ export const tokenInfoERC20b: TokenInfo<'ERC-20'> = {
};
export const tokenInfoERC20c: TokenInfo<'ERC-20'> = {
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A7',
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A8',
circulating_market_cap: null,
decimals: '18',
exchange_rate: '1328.89',
......@@ -58,7 +58,7 @@ export const tokenInfoERC20c: TokenInfo<'ERC-20'> = {
};
export const tokenInfoERC20d: TokenInfo<'ERC-20'> = {
address: '0xCc7bb2D219A0FC08033E130629C2B854b7bA9195',
address: '0xCc7bb2D219A0FC08033E130629C2B854b7bA9196',
circulating_market_cap: null,
decimals: '18',
exchange_rate: null,
......@@ -71,7 +71,7 @@ export const tokenInfoERC20d: TokenInfo<'ERC-20'> = {
};
export const tokenInfoERC20LongSymbol: TokenInfo<'ERC-20'> = {
address: '0xCc7bb2D219A0FC08033E130629C2B854b7bA9195',
address: '0xCc7bb2D219A0FC08033E130629C2B854b7bA9197',
circulating_market_cap: '112855875.75888918',
decimals: '18',
exchange_rate: '1328.89',
......@@ -123,7 +123,7 @@ export const tokenInfoERC721c: TokenInfo<'ERC-721'> = {
};
export const tokenInfoERC721LongSymbol: TokenInfo<'ERC-721'> = {
address: '0x47646F1d7dc4Dd2Db5a41D092e2Cf966e27A4992',
address: '0x47646F1d7dc4Dd2Db5a41D092e2Cf966e27A4993',
circulating_market_cap: null,
decimals: null,
exchange_rate: null,
......@@ -162,7 +162,7 @@ export const tokenInfoERC1155b: TokenInfo<'ERC-1155'> = {
};
export const tokenInfoERC1155WithoutName: TokenInfo<'ERC-1155'> = {
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8e',
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8a',
circulating_market_cap: null,
decimals: null,
exchange_rate: null,
......@@ -173,3 +173,27 @@ export const tokenInfoERC1155WithoutName: TokenInfo<'ERC-1155'> = {
type: 'ERC-1155',
icon_url: null,
};
export const bridgedTokenA: TokenInfo<'ERC-20'> = {
...tokenInfoERC20a,
is_bridged: true,
origin_chain_id: '1',
bridge_type: 'omni',
foreign_address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8b',
};
export const bridgedTokenB: TokenInfo<'ERC-20'> = {
...tokenInfoERC20b,
is_bridged: true,
origin_chain_id: '56',
bridge_type: 'omni',
foreign_address: '0xf4b71b179132ad457f6bcae2a55efa9e4b26eefd',
};
export const bridgedTokenC: TokenInfo<'ERC-20'> = {
...tokenInfoERC20d,
is_bridged: true,
origin_chain_id: '99',
bridge_type: 'amb',
foreign_address: '0x47646F1d7dc4Dd2Db5a41D092e2Cf966e27A4994',
};
/* eslint-disable max-len */
import { devices } from '@playwright/test';
export const viewport = {
......@@ -18,6 +19,16 @@ export const featureEnvs = {
{ name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' },
{ name: 'NEXT_PUBLIC_L2_WITHDRAWAL_URL', value: 'https://localhost:3102' },
],
bridgedTokens: [
{
name: 'NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS',
value: '[{"id":"1","title":"Ethereum","short_title":"ETH","base_url":"https://eth.blockscout.com/token/"},{"id":"56","title":"Binance Smart Chain","short_title":"BSC","base_url":"https://bscscan.com/token/"},{"id":"99","title":"POA","short_title":"POA","base_url":"https://blockscout.com/poa/core/token/"}]',
},
{
name: 'NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES',
value: '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]',
},
],
};
export const viewsEnvs = {
......
......@@ -14,6 +14,11 @@ export interface TokenInfo<T extends TokenType = TokenType> {
total_supply: string | null;
icon_url: string | null;
circulating_market_cap: string | null;
// bridged token fields
is_bridged?: boolean | null;
bridge_type?: string | null;
origin_chain_id?: string | null;
foreign_address?: string | null;
}
export interface TokenCounters {
......
......@@ -13,6 +13,8 @@ export type TokensResponse = {
export type TokensFilters = { q: string; type: Array<TokenType> | undefined };
export type TokensBridgedFilters = { q: string; chain_ids: Array<string> | undefined };
export interface TokenInstanceTransferResponse {
items: Array<TokenTransfer>;
next_page_params: TokenInstanceTransferPagination | null;
......@@ -29,3 +31,7 @@ export interface TokensSorting {
sort: 'fiat_value' | 'holder_count' | 'circulating_market_cap';
order: 'asc' | 'desc';
}
export type TokensSortingField = TokensSorting['sort'];
export type TokensSortingValue = `${ TokensSortingField }-${ TokensSorting['order'] }`;
......@@ -9,3 +9,16 @@ export interface MetadataAttributes {
trait_type: string;
value_type?: 'URL';
}
export interface BridgedTokenChain {
id: string;
title: string;
short_title: string;
base_url: string;
}
export interface TokenBridge {
type: string;
title: string;
short_title: string;
}
......@@ -18,7 +18,7 @@ import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import TOKEN_TYPE from 'lib/token/tokenTypes';
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import { getTokenTransfersStub } from 'stubs/token';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
......@@ -38,9 +38,7 @@ type Filters = {
filter: AddressFromToFilter | undefined;
}
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS);
const getAddressFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
const OVERLOAD_COUNT = 75;
......
......@@ -3,7 +3,8 @@ import React from 'react';
import * as verifiedAddressesMocks from 'mocks/account/verifiedAddresses';
import { token as contract } from 'mocks/address/address';
import { tokenInfo, tokenCounters } from 'mocks/tokens/tokenInfo';
import { tokenInfo, tokenCounters, bridgedTokenA } from 'mocks/tokens/tokenInfo';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
......@@ -103,6 +104,65 @@ test('with verified info', async({ mount, page, createSocket }) => {
});
});
const bridgedTokenTest = base.extend<socketServer.SocketServerFixture>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.bridgedTokens) as any,
createSocket: socketServer.createSocket,
});
bridgedTokenTest('bridged token', async({ mount, page, createSocket }) => {
const VERIFIED_INFO_URL = buildApiUrl('token_verified_info', { chainId: '1', hash: '1' });
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(TOKEN_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(bridgedTokenA),
}));
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),
}));
await page.route(TOKEN_TRANSFERS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({}),
}));
await page.route(VERIFIED_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED),
}));
await page.route(tokenInfo.icon_url as string, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
});
});
const component = await mount(
<TestApp withSocket>
<Token/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor,
});
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page, createSocket }) => {
......
......@@ -24,6 +24,7 @@ import * as tokenStubs from 'stubs/token';
import { generateListStub } from 'stubs/utils';
import AddressContract from 'ui/address/AddressContract';
import TextAd from 'ui/shared/ad/TextAd';
import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
......@@ -32,7 +33,6 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import TokenContractInfo from 'ui/token/TokenContractInfo';
import TokenDetails from 'ui/token/TokenDetails';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenInventory from 'ui/token/TokenInventory';
......@@ -252,6 +252,9 @@ const TokenPageContent = () => {
isLoading={ tokenQuery.isPlaceholderData || contractQuery.isPlaceholderData }
tagsBefore={ [
tokenQuery.data ? { label: tokenQuery.data?.type, display_name: tokenQuery.data?.type } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{ label: 'bridged', display_name: 'Bridged', colorScheme: 'blue', variant: 'solid' } :
undefined,
] }
tagsAfter={
verifiedInfoQuery.data?.projectSector ?
......@@ -282,7 +285,11 @@ const TokenPageContent = () => {
) : null }
contentAfter={ titleContentAfter }
/>
<TokenContractInfo tokenQuery={ tokenQuery } contractQuery={ contractQuery }/>
<AddressHeadingInfo
address={ contractQuery.data }
token={ tokenQuery.data }
isLoading={ tokenQuery.isPlaceholderData || contractQuery.isPlaceholderData }
/>
<TokenVerifiedInfo verifiedInfoQuery={ verifiedInfoQuery }/>
<TokenDetails tokenQuery={ tokenQuery }/>
{ /* should stay before tabs to scroll up with pagination */ }
......
import { Box } from '@chakra-ui/react';
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as textAdMock from 'mocks/ad/textAd';
import * as tokens from 'mocks/tokens/tokenInfo';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import Tokens from './Tokens';
base.beforeEach(async({ page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
});
});
});
base('base view +@mobile +@dark-mode', async({ mount, page }) => {
const allTokens = {
items: [
tokens.tokenInfoERC20a, tokens.tokenInfoERC20b, tokens.tokenInfoERC20c, tokens.tokenInfoERC20d,
tokens.tokenInfoERC721a, tokens.tokenInfoERC721b, tokens.tokenInfoERC721c,
tokens.tokenInfoERC1155a, tokens.tokenInfoERC1155b, tokens.tokenInfoERC1155WithoutName,
],
next_page_params: {
holder_count: 1,
items_count: 1,
name: 'a',
},
};
const filteredTokens = {
items: [
tokens.tokenInfoERC20a, tokens.tokenInfoERC20b, tokens.tokenInfoERC20c,
],
next_page_params: null,
};
const ALL_TOKENS_API_URL = buildApiUrl('tokens');
const FILTERED_TOKENS_API_URL = buildApiUrl('tokens') + '?q=foo';
await page.route(ALL_TOKENS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(allTokens),
}));
await page.route(FILTERED_TOKENS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(filteredTokens),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<Tokens/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
await component.getByRole('textbox', { name: 'Token name or symbol' }).focus();
await component.getByRole('textbox', { name: 'Token name or symbol' }).type('foo');
await expect(component).toHaveScreenshot();
});
base.describe('bridged tokens', async() => {
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.bridgedTokens) as any,
});
const bridgedTokens = {
items: [
tokens.bridgedTokenA,
tokens.bridgedTokenB,
tokens.bridgedTokenC,
],
next_page_params: {
holder_count: 1,
items_count: 1,
name: 'a',
},
};
const bridgedFilteredTokens = {
items: [
tokens.bridgedTokenC,
],
next_page_params: null,
};
const hooksConfig = {
router: {
query: { tab: 'bridged' },
},
};
const BRIDGED_TOKENS_API_URL = buildApiUrl('tokens_bridged');
test.beforeEach(async({ page }) => {
await page.route(BRIDGED_TOKENS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(bridgedTokens),
}));
});
test('base view', async({ mount, page }) => {
await page.route(BRIDGED_TOKENS_API_URL + '?chain_ids=99', (route) => route.fulfill({
status: 200,
body: JSON.stringify(bridgedFilteredTokens),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<Tokens/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
await component.getByRole('button', { name: /filter/i }).click();
await component.locator('label').filter({ hasText: /poa/i }).click();
await page.click('body');
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/token';
import type { TokensSortingValue } from 'types/api/tokens';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import useDebounce from 'lib/hooks/useDebounce';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TOKEN_INFO_ERC_20 } from 'stubs/token';
import { generateListStub } from 'stubs/utils';
import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import TokenTypeFilter from 'ui/shared/filters/TokenTypeFilter';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TokensList from 'ui/tokens/Tokens';
import TokensActionBar from 'ui/tokens/TokensActionBar';
import TokensBridgedChainsFilter from 'ui/tokens/TokensBridgedChainsFilter';
import { getSortParamsFromValue, getSortValueFromQuery, getTokenFilterValue, getBridgedChainsFilterValue } from 'ui/tokens/utils';
const TAB_LIST_PROPS = {
marginBottom: 0,
py: 5,
marginTop: -5,
alignItems: 'center',
};
const TABS_RIGHT_SLOT_PROPS = {
ml: 8,
flexGrow: 1,
};
const bridgedTokensFeature = config.features.bridgedTokens;
const Tokens = () => {
const router = useRouter();
const isMobile = useIsMobile();
const tab = getQueryParamString(router.query.tab);
const q = getQueryParamString(router.query.q);
const [ searchTerm, setSearchTerm ] = React.useState<string>(q ?? '');
const [ sort, setSort ] = React.useState<TokensSortingValue | undefined>(getSortValueFromQuery(router.query));
const [ tokenTypes, setTokenTypes ] = React.useState<Array<TokenType> | undefined>(getTokenFilterValue(router.query.type));
const [ bridgeChains, setBridgeChains ] = React.useState<Array<string> | undefined>(getBridgedChainsFilterValue(router.query.chain_ids));
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const tokensQuery = useQueryWithPages({
resourceName: tab === 'bridged' ? 'tokens_bridged' : 'tokens',
filters: tab === 'bridged' ? { q: debouncedSearchTerm, chain_ids: bridgeChains } : { q: debouncedSearchTerm, type: tokenTypes },
sorting: getSortParamsFromValue(sort),
options: {
placeholderData: generateListStub<'tokens'>(
TOKEN_INFO_ERC_20,
50,
{
next_page_params: {
holder_count: 81528,
items_count: 50,
name: '',
market_cap: null,
},
},
),
},
});
const handleSearchTermChange = React.useCallback((value: string) => {
tab === 'bridged' ?
tokensQuery.onFilterChange({ q: value, chain_ids: bridgeChains }) :
tokensQuery.onFilterChange({ q: value, type: tokenTypes });
setSearchTerm(value);
}, [ bridgeChains, tab, tokenTypes, tokensQuery ]);
const handleTokenTypesChange = React.useCallback((value: Array<TokenType>) => {
tokensQuery.onFilterChange({ q: debouncedSearchTerm, type: value });
setTokenTypes(value);
}, [ debouncedSearchTerm, tokensQuery ]);
const handleBridgeChainsChange = React.useCallback((value: Array<string>) => {
tokensQuery.onFilterChange({ q: debouncedSearchTerm, chain_ids: value });
setBridgeChains(value);
}, [ debouncedSearchTerm, tokensQuery ]);
const handleSortChange = React.useCallback((value?: TokensSortingValue) => {
setSort(value);
tokensQuery.onSortingChange(getSortParamsFromValue(value));
}, [ tokensQuery ]);
const handleTabChange = React.useCallback(() => {
setSearchTerm('');
setSort(undefined);
setTokenTypes(undefined);
setBridgeChains(undefined);
}, []);
const filter = tab === 'bridged' ? (
<PopoverFilter isActive={ bridgeChains && bridgeChains.length > 0 } contentProps={{ maxW: '350px' }} appliedFiltersNum={ bridgeChains?.length }>
<TokensBridgedChainsFilter onChange={ handleBridgeChainsChange } defaultValue={ bridgeChains }/>
</PopoverFilter>
) : (
<PopoverFilter isActive={ tokenTypes && tokenTypes.length > 0 } contentProps={{ w: '200px' }} appliedFiltersNum={ tokenTypes?.length }>
<TokenTypeFilter onChange={ handleTokenTypesChange } defaultValue={ tokenTypes }/>
</PopoverFilter>
);
const actionBar = (
<TokensActionBar
key={ tab }
pagination={ tokensQuery.pagination }
filter={ filter }
searchTerm={ searchTerm }
onSearchChange={ handleSearchTermChange }
sort={ sort }
onSortChange={ handleSortChange }
inTabsSlot={ !isMobile && bridgedTokensFeature.isEnabled }
/>
);
const description = (() => {
if (!bridgedTokensFeature.isEnabled) {
return null;
}
const bridgesListText = bridgedTokensFeature.bridges.map((item, index, array) => {
return item.title + (index < array.length - 2 ? ', ' : '') + (index === array.length - 2 ? ' and ' : '');
});
return (
<Box fontSize="sm" mb={ 4 } mt={ 1 } whiteSpace="pre-wrap" flexWrap="wrap">
List of the tokens bridged through { bridgesListText } extensions
</Box>
);
})();
const tabs: Array<RoutedTab> = [
{
id: 'all',
title: 'All',
component: (
<TokensList
query={ tokensQuery }
sort={ sort }
onSortChange={ handleSortChange }
actionBar={ isMobile ? actionBar : null }
hasActiveFilters={ Boolean(searchTerm || tokenTypes) }
/>
),
},
bridgedTokensFeature.isEnabled ? {
id: 'bridged',
title: 'Bridged',
component: (
<TokensList
query={ tokensQuery }
sort={ sort }
onSortChange={ handleSortChange }
actionBar={ isMobile ? actionBar : null }
hasActiveFilters={ Boolean(searchTerm || bridgeChains) }
description={ description }
/>
),
} : undefined,
].filter(Boolean);
return (
<>
<PageTitle title="Tokens" withTextAd/>
<TokensList/>
{ tabs.length === 1 && !isMobile && actionBar }
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
rightSlot={ !isMobile ? actionBar : null }
rightSlotProps={ !isMobile ? TABS_RIGHT_SLOT_PROPS : undefined }
stickyEnabled={ !isMobile }
onTabChange={ handleTabChange }
/>
</>
);
};
......
......@@ -29,7 +29,7 @@ const AddressActions = ({ isLoading }: Props) => {
return (
<Menu>
<Skeleton isLoaded={ !isLoading } ml={ 2 } borderRadius="base">
<Skeleton isLoaded={ !isLoading } borderRadius="base">
<MenuButton
as={ Button }
size="sm"
......
......@@ -6,24 +6,54 @@ import type { TokenInfo } from 'types/api/token';
import config from 'configs/app';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import stripTrailingSlash from 'lib/stripTrailingSlash';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressActionsMenu from 'ui/shared/AddressActions/Menu';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import LinkExternal from 'ui/shared/LinkExternal';
interface Props {
address: Pick<Address, 'hash' | 'is_contract' | 'implementation_name' | 'watchlist_names' | 'watchlist_address_id'>;
address?: Pick<Address, 'hash' | 'is_contract' | 'implementation_name' | 'watchlist_names' | 'watchlist_address_id'>;
token?: TokenInfo | null;
isLinkDisabled?: boolean;
isLoading?: boolean;
}
const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props) => {
const isSafeAddress = useIsSafeAddress(!isLoading && address.is_contract ? address.hash : undefined);
const isSafeAddress = useIsSafeAddress(!isLoading && address?.is_contract ? address.hash : undefined);
if (!address) {
return null;
}
const tokenOriginalLink = (() => {
const feature = config.features.bridgedTokens;
if (!token?.foreign_address || !token.origin_chain_id || !feature.isEnabled) {
return null;
}
const chainBaseUrl = feature.chains.find(({ id }) => id === token.origin_chain_id)?.base_url;
if (!chainBaseUrl) {
return null;
}
try {
const url = new URL(stripTrailingSlash(chainBaseUrl) + '/' + token.foreign_address);
return (
<LinkExternal href={ url } variant="subtle" ml="auto">
Original token
</LinkExternal>
);
} catch (error) {
return null;
}
})();
return (
<Flex alignItems="center">
<Flex alignItems="center" flexWrap={{ base: tokenOriginalLink ? 'wrap' : 'nowrap', lg: 'nowrap' }} rowGap={ 3 } columnGap={ 2 }>
<AddressEntity
address={{ ...address, name: '' }}
isLoading={ isLoading }
......@@ -33,12 +63,13 @@ const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props
noLink={ isLinkDisabled }
isSafeAddress={ isSafeAddress }
/>
{ !isLoading && address.is_contract && token && <AddressAddToWallet ml={ 2 } token={ token }/> }
{ !isLoading && address?.is_contract && token && <AddressAddToWallet token={ token }/> }
{ !isLoading && !address.is_contract && config.features.account.isEnabled && (
<AddressFavoriteButton hash={ address.hash } watchListId={ address.watchlist_address_id } ml={ 3 }/>
<AddressFavoriteButton hash={ address.hash } watchListId={ address.watchlist_address_id }/>
) }
<AddressQrCode address={ address } ml={ 2 } isLoading={ isLoading } flexShrink={ 0 }/>
<AddressQrCode address={ address } isLoading={ isLoading } flexShrink={ 0 }/>
{ config.features.account.isEnabled && <AddressActionsMenu isLoading={ isLoading }/> }
{ tokenOriginalLink }
</Flex>
);
};
......
import type { ThemingProps } from '@chakra-ui/react';
import { Flex, chakra, useDisclosure, Popover, PopoverTrigger, PopoverContent, PopoverBody } from '@chakra-ui/react';
import React from 'react';
......@@ -9,6 +10,8 @@ import Tag from 'ui/shared/chakra/Tag';
interface TagData {
label: string;
display_name: string;
colorScheme?: ThemingProps<'Tag'>['colorScheme'];
variant?: ThemingProps<'Tag'>['variant'];
}
interface Props {
......@@ -24,7 +27,7 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
const tags = [
const tags: Array<TagData> = [
...tagsBefore,
...(data?.private_tags || []),
...(data?.public_tags || []),
......@@ -45,7 +48,14 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
tags
.slice(0, 2)
.map((tag) => (
<Tag key={ tag.label } isLoading={ isLoading } isTruncated maxW={{ base: '115px', lg: 'initial' }}>
<Tag
key={ tag.label }
isLoading={ isLoading }
isTruncated
maxW={{ base: '115px', lg: 'initial' }}
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
))
......@@ -60,7 +70,15 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
{
tags
.slice(2)
.map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>)
.map((tag) => (
<Tag
key={ tag.label }
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
))
}
</Flex>
</PopoverBody>
......@@ -71,7 +89,14 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
}
return tags.map((tag) => (
<Tag key={ tag.label } isLoading={ isLoading } isTruncated maxW={{ base: '115px', lg: 'initial' }}>
<Tag
key={ tag.label }
isLoading={ isLoading }
isTruncated
maxW={{ base: '115px', lg: 'initial' }}
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
));
......
import { Link, Icon, chakra, Box, Skeleton } from '@chakra-ui/react';
import type { ChakraProps } from '@chakra-ui/react';
import { Link, Icon, chakra, Box, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import arrowIcon from 'icons/arrows/north-east.svg';
......@@ -8,12 +9,32 @@ interface Props {
className?: string;
children: React.ReactNode;
isLoading?: boolean;
variant?: 'subtle';
}
const LinkExternal = ({ href, children, className, isLoading }: Props) => {
const LinkExternal = ({ href, children, className, isLoading, variant }: Props) => {
const subtleLinkBg = useColorModeValue('gray.100', 'gray.700');
const styleProps: ChakraProps = (() => {
switch (variant) {
case 'subtle': {
return {
px: '10px',
py: '5px',
bgColor: subtleLinkBg,
borderRadius: 'base',
};
}
default:{
return {};
}
}
})();
if (isLoading) {
return (
<Box className={ className } fontSize="sm" lineHeight={ 5 } display="inline-block" alignItems="center">
<Box className={ className } { ...styleProps } fontSize="sm" lineHeight={ 5 } display="inline-block" alignItems="center">
{ children }
<Skeleton boxSize={ 4 } verticalAlign="middle" display="inline-block"/>
</Box>
......@@ -21,7 +42,7 @@ const LinkExternal = ({ href, children, className, isLoading }: Props) => {
}
return (
<Link className={ className } fontSize="sm" lineHeight={ 5 } display="inline-block" alignItems="center" target="_blank" href={ href }>
<Link className={ className } { ...styleProps } fontSize="sm" lineHeight={ 5 } display="inline-block" alignItems="center" target="_blank" href={ href }>
{ children }
<Icon as={ arrowIcon } boxSize={ 4 } verticalAlign="middle" color="gray.400"/>
</Link>
......
......@@ -13,11 +13,13 @@ interface Props extends ThemingProps<'Tabs'> {
tabs: Array<RoutedTab>;
tabListProps?: ChakraProps | (({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => ChakraProps);
rightSlot?: React.ReactNode;
rightSlotProps?: ChakraProps;
stickyEnabled?: boolean;
className?: string;
onTabChange?: (index: number) => void;
}
const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, ...themeProps }: Props) => {
const RoutedTabs = ({ tabs, tabListProps, rightSlot, rightSlotProps, stickyEnabled, className, onTabChange, ...themeProps }: Props) => {
const router = useRouter();
const tabIndex = useTabIndexFromQuery(tabs);
const tabsRef = useRef<HTMLDivElement>(null);
......@@ -31,7 +33,9 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
undefined,
{ shallow: true },
);
}, [ tabs, router ]);
onTabChange?.(index);
}, [ tabs, router, onTabChange ]);
useEffect(() => {
if (router.query.scroll_to_tabs) {
......@@ -55,6 +59,7 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
tabs={ tabs }
tabListProps={ tabListProps }
rightSlot={ rightSlot }
rightSlotProps={ rightSlotProps }
stickyEnabled={ stickyEnabled }
onTabChange={ handleTabChange }
defaultTabIndex={ tabIndex }
......
......@@ -22,6 +22,7 @@ import useIsSticky from 'lib/hooks/useIsSticky';
import TabCounter from './TabCounter';
import TabsMenu from './TabsMenu';
import useAdaptiveTabs from './useAdaptiveTabs';
import { menuButton } from './utils';
const TAB_CLASSNAME = 'tab-item';
......@@ -37,6 +38,7 @@ interface Props extends ThemingProps<'Tabs'> {
lazyBehavior?: LazyMode;
tabListProps?: ChakraProps | (({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => ChakraProps);
rightSlot?: React.ReactNode;
rightSlotProps?: ChakraProps;
stickyEnabled?: boolean;
onTabChange?: (index: number) => void;
defaultTabIndex?: number;
......@@ -48,6 +50,7 @@ const TabsWithScroll = ({
lazyBehavior,
tabListProps,
rightSlot,
rightSlotProps,
stickyEnabled,
onTabChange,
defaultTabIndex,
......@@ -58,7 +61,12 @@ const TabsWithScroll = ({
const [ activeTabIndex, setActiveTabIndex ] = useState<number>(defaultTabIndex || 0);
const isMobile = useIsMobile();
const tabsRef = useRef<HTMLDivElement>(null);
const { tabsCut, tabsList, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabs, isMobile);
const tabsList = React.useMemo(() => {
return [ ...tabs, menuButton ];
}, [ tabs ]);
const { tabsCut, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabsList, isMobile);
const isSticky = useIsSticky(listRef, 5, stickyEnabled);
const listBgColor = useColorModeValue('white', 'black');
......@@ -114,8 +122,7 @@ const TabsWithScroll = ({
flexWrap="nowrap"
whiteSpace="nowrap"
ref={ listRef }
overflowY="hidden"
overflowX={{ base: 'auto', lg: undefined }}
overflowX={{ base: 'auto', lg: 'initial' }}
overscrollBehaviorX="contain"
css={{
'scroll-snap-type': 'x mandatory',
......@@ -177,7 +184,7 @@ const TabsWithScroll = ({
</Tab>
);
}) }
{ rightSlot ? <Box ref={ rightSlotRef } ml="auto" > { rightSlot } </Box> : null }
{ rightSlot && tabsCut > 0 ? <Box ref={ rightSlotRef } ml="auto" { ...rightSlotProps }> { rightSlot } </Box> : null }
</TabList>
<TabPanels>
{ tabsList.map((tab) => <TabPanel padding={ 0 } key={ tab.id }>{ tab.component }</TabPanel>) }
......
import _debounce from 'lodash/debounce';
import React from 'react';
import type { RoutedTab } from './types';
import type { MenuButton, RoutedTab } from './types';
import { menuButton } from './utils';
export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boolean) {
export default function useAdaptiveTabs(tabs: Array<RoutedTab | MenuButton>, disabled?: boolean) {
// to avoid flickering we set initial value to 0
// so there will be no displayed tabs initially
const [ tabsCut, setTabsCut ] = React.useState(disabled ? tabs.length : 0);
......@@ -51,16 +49,8 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
return visibleNum;
}, [ tabs.length, tabsRefs ]);
const tabsList = React.useMemo(() => {
if (disabled) {
return tabs;
}
return [ ...tabs, menuButton ];
}, [ tabs, disabled ]);
React.useEffect(() => {
setTabsRefs(tabsList.map((_, index) => tabsRefs[index] || React.createRef()));
setTabsRefs(tabs.map((_, index) => tabsRefs[index] || React.createRef()));
setTabsCut(disabled ? tabs.length : 0);
// update refs only when disabled prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps
......@@ -91,10 +81,9 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
return React.useMemo(() => {
return {
tabsCut,
tabsList,
tabsRefs,
listRef,
rightSlotRef,
};
}, [ tabsList, tabsCut, tabsRefs, listRef ]);
}, [ tabsCut, tabsRefs ]);
}
......@@ -16,9 +16,10 @@ interface Props {
className?: string;
token: TokenInfo;
isLoading?: boolean;
iconSize?: number;
}
const AddressAddToWallet = ({ className, token, isLoading }: Props) => {
const AddressAddToWallet = ({ className, token, isLoading, iconSize = 6 }: Props) => {
const toast = useToast();
const { provider, wallet } = useProvider();
const addOrSwitchChain = useAddOrSwitchChain();
......@@ -78,7 +79,7 @@ const AddressAddToWallet = ({ className, token, isLoading }: Props) => {
}
if (isLoading) {
return <Skeleton className={ className } boxSize={ 6 } borderRadius="base"/>;
return <Skeleton className={ className } boxSize={ iconSize } borderRadius="base"/>;
}
if (!feature.isEnabled) {
......@@ -88,7 +89,7 @@ const AddressAddToWallet = ({ className, token, isLoading }: Props) => {
return (
<Tooltip label={ `Add token to ${ WALLETS_INFO[wallet].name }` }>
<Box className={ className } display="inline-flex" cursor="pointer" onClick={ handleClick }>
<Icon as={ WALLETS_INFO[wallet].icon } boxSize={ 6 }/>
<Icon as={ WALLETS_INFO[wallet].icon } boxSize={ iconSize }/>
</Box>
</Tooltip>
);
......
import { CheckboxGroup, Checkbox, Text } from '@chakra-ui/react';
import { CheckboxGroup, Checkbox, Text, Flex, Link, useCheckboxGroup } from '@chakra-ui/react';
import React from 'react';
import type { TokenType } from 'types/api/token';
import TOKEN_TYPE from 'lib/token/tokenTypes';
import { TOKEN_TYPES } from 'lib/token/tokenTypes';
type Props = {
onChange: (nextValue: Array<TokenType>) => void;
......@@ -11,14 +11,43 @@ type Props = {
}
const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
const { value, setValue } = useCheckboxGroup({ defaultValue });
const handleReset = React.useCallback(() => {
if (value.length === 0) {
return;
}
setValue([]);
onChange([]);
}, [ onChange, setValue, value.length ]);
const handleChange = React.useCallback((nextValue: Array<TokenType>) => {
setValue(nextValue);
onChange(nextValue);
}, [ onChange, setValue ]);
return (
<CheckboxGroup size="lg" onChange={ onChange } defaultValue={ defaultValue }>
{ TOKEN_TYPE.map(({ title, id }) => (
<>
<Flex justifyContent="space-between" fontSize="sm">
<Text fontWeight={ 600 } variant="secondary">Type</Text>
<Link
onClick={ handleReset }
color={ value.length > 0 ? 'link' : 'text_secondary' }
_hover={{
color: value.length > 0 ? 'link_hovered' : 'text_secondary',
}}
>
Reset
</Link>
</Flex>
<CheckboxGroup size="lg" onChange={ handleChange } value={ value }>
{ TOKEN_TYPES.map(({ title, id }) => (
<Checkbox key={ id } value={ id }>
<Text fontSize="md">{ title }</Text>
</Checkbox>
)) }
</CheckboxGroup>
</>
);
};
......
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { Address } from 'types/api/address';
import type { TokenInfo } from 'types/api/token';
import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
interface Props {
tokenQuery: UseQueryResult<TokenInfo>;
contractQuery: UseQueryResult<Address>;
}
const TokenContractInfo = ({ tokenQuery, contractQuery }: Props) => {
// we show error in parent component, this is only for TS
if (tokenQuery.isError) {
return null;
}
const address = {
hash: tokenQuery.data?.address || '',
is_contract: true,
implementation_name: null,
watchlist_names: [],
watchlist_address_id: null,
};
return (
<AddressHeadingInfo
address={ address }
token={ contractQuery.data?.token }
isLoading={ tokenQuery.isPlaceholderData || contractQuery.isPlaceholderData }
/>
);
};
export default React.memo(TokenContractInfo);
import { Flex, Skeleton, useColorModeValue } from '@chakra-ui/react';
import { Flex, Skeleton } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
......@@ -16,7 +16,6 @@ interface Props {
const TokenVerifiedInfo = ({ verifiedInfoQuery }: Props) => {
const { data, isLoading, isError } = verifiedInfoQuery;
const websiteLinkBg = useColorModeValue('gray.100', 'gray.700');
const content = (() => {
if (!config.features.verifiedTokens.isEnabled) {
......@@ -41,7 +40,7 @@ const TokenVerifiedInfo = ({ verifiedInfoQuery }: Props) => {
try {
const url = new URL(data.projectWebsite);
return (
<LinkExternal href={ data.projectWebsite } px="10px" py="5px" bgColor={ websiteLinkBg } borderRadius="base">{ url.host }</LinkExternal>
<LinkExternal href={ data.projectWebsite } variant="subtle">{ url.host }</LinkExternal>
);
} catch (error) {
return null;
......
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as tokens from 'mocks/tokens/tokenInfo';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import Tokens from './Tokens';
const API_URL_TOKENS = buildApiUrl('tokens');
const tokensResponse = {
items: [
tokens.tokenInfoERC20a, tokens.tokenInfoERC20b, tokens.tokenInfoERC20c, tokens.tokenInfoERC20d,
tokens.tokenInfoERC721a, tokens.tokenInfoERC721b, tokens.tokenInfoERC721c,
tokens.tokenInfoERC1155a, tokens.tokenInfoERC1155b, tokens.tokenInfoERC1155WithoutName,
],
next_page_params: {
holder_count: 1,
items_count: 1,
name: 'a',
},
};
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(API_URL_TOKENS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(tokensResponse),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<Tokens/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Hide, HStack, Show } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React, { useCallback } from 'react';
import { Hide, Show } from '@chakra-ui/react';
import React from 'react';
import type { TokenType } from 'types/api/token';
import type { TokensSorting } from 'types/api/tokens';
import type { TokensSortingValue } from 'types/api/tokens';
import type { Query } from 'nextjs-routes';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useDebounce from 'lib/hooks/useDebounce';
import { apos } from 'lib/html-entities';
import TOKEN_TYPE from 'lib/token/tokenTypes';
import { TOKEN_INFO_ERC_20 } from 'stubs/token';
import { generateListStub } from 'stubs/utils';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DataListDisplay from 'ui/shared/DataListDisplay';
import FilterInput from 'ui/shared/filters/FilterInput';
import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import TokenTypeFilter from 'ui/shared/filters/TokenTypeFilter';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import type { Option } from 'ui/shared/sort/Sort';
import Sort from 'ui/shared/sort/Sort';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import TokensListItem from './TokensListItem';
import TokensTable from './TokensTable';
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
export type TokensSortingField = TokensSorting['sort'];
export type TokensSortingValue = `${ TokensSortingField }-${ TokensSorting['order'] }`;
const SORT_OPTIONS: Array<Option<TokensSortingValue>> = [
{ title: 'Default', id: undefined },
{ title: 'Price ascending', id: 'fiat_value-asc' },
{ title: 'Price descending', id: 'fiat_value-desc' },
{ title: 'Holders ascending', id: 'holder_count-asc' },
{ title: 'Holders descending', id: 'holder_count-desc' },
{ title: 'On-chain market cap ascending', id: 'circulating_market_cap-asc' },
{ title: 'On-chain market cap descending', id: 'circulating_market_cap-desc' },
];
const getSortValueFromQuery = (query: Query): TokensSortingValue | undefined => {
if (!query.sort || !query.order) {
return undefined;
}
const str = query.sort + '-' + query.order;
if (SORT_OPTIONS.map(option => option.id).includes(str)) {
return str as TokensSortingValue;
}
};
const getSortParamsFromValue = (val?: TokensSortingValue): TokensSorting | undefined => {
if (!val) {
return undefined;
}
const sortingChunks = val.split('-') as [ TokensSortingField, TokensSorting['order'] ];
return { sort: sortingChunks[0], order: sortingChunks[1] };
};
const Tokens = () => {
const router = useRouter();
const [ filter, setFilter ] = React.useState<string>(router.query.q?.toString() || '');
const [ sorting, setSorting ] = React.useState<TokensSortingValue | undefined>(getSortValueFromQuery(router.query));
const [ type, setType ] = React.useState<Array<TokenType> | undefined>(getTokenFilterValue(router.query.type));
const debouncedFilter = useDebounce(filter, 300);
interface Props {
query: QueryWithPagesResult<'tokens'> | QueryWithPagesResult<'tokens_bridged'>;
onSortChange: () => void;
sort: TokensSortingValue | undefined;
actionBar?: React.ReactNode;
hasActiveFilters: boolean;
description?: React.ReactNode;
}
const { isError, isPlaceholderData, data, pagination, onFilterChange, onSortingChange } = useQueryWithPages({
resourceName: 'tokens',
filters: { q: debouncedFilter, type },
sorting: getSortParamsFromValue(sorting),
options: {
placeholderData: generateListStub<'tokens'>(
TOKEN_INFO_ERC_20,
50,
{
next_page_params: {
holder_count: 81528,
items_count: 50,
name: '',
market_cap: null,
},
},
),
},
});
const Tokens = ({ query, onSortChange, sort, actionBar, description, hasActiveFilters }: Props) => {
const onSearchChange = useCallback((value: string) => {
onFilterChange({ q: value, type });
setFilter(value);
}, [ type, onFilterChange ]);
const onTypeChange = useCallback((value: Array<TokenType>) => {
onFilterChange({ q: debouncedFilter, type: value });
setType(value);
}, [ debouncedFilter, onFilterChange ]);
const onSort = useCallback((value?: TokensSortingValue) => {
setSorting(value);
onSortingChange(getSortParamsFromValue(value));
}, [ setSorting, onSortingChange ]);
const { isError, isPlaceholderData, data, pagination } = query;
if (isError) {
return <DataFetchAlert/>;
}
const typeFilter = (
<PopoverFilter isActive={ type && type.length > 0 } contentProps={{ w: '200px' }}>
<TokenTypeFilter onChange={ onTypeChange } defaultValue={ type }/>
</PopoverFilter>
);
const filterInput = (
<FilterInput
w="100%"
size="xs"
onChange={ onSearchChange }
placeholder="Token name or symbol"
initialValue={ filter }
/>
);
const actionBar = (
<>
<HStack spacing={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ typeFilter }
<Sort
options={ SORT_OPTIONS }
setSort={ onSort }
sort={ sorting }
/>
{ filterInput }
</HStack>
<ActionBar mt={ -6 }>
<HStack spacing={ 3 } display={{ base: 'none', lg: 'flex' }}>
{ typeFilter }
{ filterInput }
</HStack>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
</>
);
const content = data?.items ? (
<>
<Show below="lg" ssr={ false }>
{ description }
{ data.items.map((item, index) => (
<TokensListItem
key={ item.address + (isPlaceholderData ? index : '') }
......@@ -160,12 +43,13 @@ const Tokens = () => {
)) }
</Show>
<Hide below="lg" ssr={ false }>
{ description }
<TokensTable
items={ data.items }
page={ pagination.page }
isLoading={ isPlaceholderData }
setSorting={ onSort }
sorting={ sorting }
setSorting={ onSortChange }
sorting={ sort }
/>
</Hide>
</>
......@@ -178,10 +62,10 @@ const Tokens = () => {
emptyText="There are no tokens."
filterProps={{
emptyFilteredText: `Couldn${ apos }t find token that matches your filter query.`,
hasActiveFilters: Boolean(debouncedFilter || type),
hasActiveFilters,
}}
content={ content }
actionBar={ actionBar }
actionBar={ query.pagination.isVisible || hasActiveFilters ? actionBar : null }
/>
);
};
......
import { HStack } from '@chakra-ui/react';
import React from 'react';
import type { TokensSortingValue } from 'types/api/tokens';
import type { PaginationParams } from 'ui/shared/pagination/types';
import ActionBar from 'ui/shared/ActionBar';
import FilterInput from 'ui/shared/filters/FilterInput';
import Pagination from 'ui/shared/pagination/Pagination';
import Sort from 'ui/shared/sort/Sort';
import { SORT_OPTIONS } from 'ui/tokens/utils';
interface Props {
pagination: PaginationParams;
searchTerm: string | undefined;
onSearchChange: (value: string) => void;
sort: TokensSortingValue | undefined;
onSortChange: () => void;
filter: React.ReactNode;
inTabsSlot?: boolean;
}
const TokensActionBar = ({
sort,
onSortChange,
searchTerm,
onSearchChange,
pagination,
filter,
inTabsSlot,
}: Props) => {
const searchInput = (
<FilterInput
w={{ base: '100%', lg: '360px' }}
size="xs"
onChange={ onSearchChange }
placeholder="Token name or symbol"
initialValue={ searchTerm }
/>
);
return (
<>
<HStack spacing={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ filter }
<Sort
options={ SORT_OPTIONS }
setSort={ onSortChange }
sort={ sort }
/>
{ searchInput }
</HStack>
<ActionBar
mt={ inTabsSlot ? 0 : -6 }
py={ inTabsSlot ? 0 : undefined }
justifyContent={ inTabsSlot ? 'space-between' : undefined }
display={{ base: pagination.isVisible ? 'flex' : 'none', lg: 'flex' }}
>
<HStack spacing={ 3 } display={{ base: 'none', lg: 'flex' }}>
{ filter }
{ searchInput }
</HStack>
<Pagination { ...pagination } ml={ inTabsSlot ? 8 : 'auto' }/>
</ActionBar>
</>
);
};
export default React.memo(TokensActionBar);
import { CheckboxGroup, Checkbox, Text, Flex, Link, useCheckboxGroup, chakra } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
const feature = config.features.bridgedTokens;
interface Props {
onChange: (nextValue: Array<string>) => void;
defaultValue?: Array<string>;
}
const TokensBridgedChainsFilter = ({ onChange, defaultValue }: Props) => {
const { value, setValue } = useCheckboxGroup({ defaultValue });
const handleReset = React.useCallback(() => {
if (value.length === 0) {
return;
}
setValue([]);
onChange([]);
}, [ onChange, setValue, value ]);
const handleChange = React.useCallback((nextValue: Array<string>) => {
setValue(nextValue);
onChange(nextValue);
}, [ onChange, setValue ]);
if (!feature.isEnabled) {
return null;
}
return (
<>
<Flex justifyContent="space-between" fontSize="sm">
<Text fontWeight={ 600 } variant="secondary">Show bridged tokens from</Text>
<Link
onClick={ handleReset }
color={ value.length > 0 ? 'link' : 'text_secondary' }
_hover={{
color: value.length > 0 ? 'link_hovered' : 'text_secondary',
}}
>
Reset
</Link>
</Flex>
<CheckboxGroup size="lg" onChange={ handleChange } value={ value }>
{ feature.chains.map(({ title, id, short_title: shortTitle }) => (
<Checkbox key={ id } value={ id } fontSize="md" whiteSpace="pre-wrap">
<span>{ title }</span>
<chakra.span color="text_secondary"> ({ shortTitle })</chakra.span>
</Checkbox>
)) }
</CheckboxGroup>
</>
);
};
export default React.memo(TokensBridgedChainsFilter);
......@@ -4,6 +4,7 @@ import React from 'react';
import type { TokenInfo } from 'types/api/token';
import config from 'configs/app';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
......@@ -19,6 +20,8 @@ type Props = {
const PAGE_SIZE = 50;
const bridgedTokensFeature = config.features.bridgedTokens;
const TokensTableItem = ({
token,
page,
......@@ -32,8 +35,13 @@ const TokensTableItem = ({
type,
holders,
circulating_market_cap: marketCap,
origin_chain_id: originalChainId,
} = token;
const bridgedChainTag = bridgedTokensFeature.isEnabled ?
bridgedTokensFeature.chains.find(({ id }) => id === originalChainId)?.short_title :
undefined;
return (
<ListItemMobile rowGap={ 3 }>
<Grid
......@@ -50,7 +58,10 @@ const TokensTableItem = ({
fontSize="sm"
fontWeight="700"
/>
<Tag flexShrink={ 0 } isLoading={ isLoading } ml={ 3 }>{ type }</Tag>
<Flex ml={ 3 } flexShrink={ 0 } columnGap={ 1 }>
<Tag isLoading={ isLoading }>{ type }</Tag>
{ bridgedChainTag && <Tag isLoading={ isLoading }>{ bridgedChainTag }</Tag> }
</Flex>
<Skeleton isLoaded={ !isLoading } fontSize="sm" ml="auto" color="text_secondary" minW="24px" textAlign="right" lineHeight={ 6 }>
<span>{ (page - 1) * PAGE_SIZE + index + 1 }</span>
</Skeleton>
......
......@@ -2,12 +2,12 @@ import { Icon, Link, Table, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import type { TokensSortingField, TokensSortingValue } from 'types/api/tokens';
import rightArrowIcon from 'icons/arrows/east.svg';
import { default as getNextSortValueShared } from 'ui/shared/sort/getNextSortValue';
import { default as Thead } from 'ui/shared/TheadSticky';
import type { TokensSortingValue, TokensSortingField } from './Tokens';
import TokensTableItem from './TokensTableItem';
const SORT_SEQUENCE: Record<TokensSortingField, Array<TokensSortingValue | undefined>> = {
......
import { Box, Flex, Td, Tr, Skeleton } from '@chakra-ui/react';
import { Flex, Td, Tr, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import config from 'configs/app';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import Tag from 'ui/shared/chakra/Tag';
import type { EntityProps as AddressEntityProps } from 'ui/shared/entities/address/AddressEntity';
......@@ -19,6 +20,8 @@ type Props = {
const PAGE_SIZE = 50;
const bridgedTokensFeature = config.features.bridgedTokens;
const TokensTableItem = ({
token,
page,
......@@ -32,8 +35,13 @@ const TokensTableItem = ({
type,
holders,
circulating_market_cap: marketCap,
origin_chain_id: originalChainId,
} = token;
const bridgedChainTag = bridgedTokensFeature.isEnabled ?
bridgedTokensFeature.chains.find(({ id }) => id === originalChainId)?.short_title :
undefined;
const tokenAddress: AddressEntityProps['address'] = {
hash: address,
name: '',
......@@ -56,7 +64,7 @@ const TokensTableItem = ({
>
{ (page - 1) * PAGE_SIZE + index + 1 }
</Skeleton>
<Box overflow="hidden">
<Flex overflow="hidden" flexDir="column" rowGap={ 2 }>
<TokenEntity
token={ token }
isLoading={ isLoading }
......@@ -65,23 +73,21 @@ const TokensTableItem = ({
fontSize="sm"
fontWeight="700"
/>
<Box ml={ 7 } mt={ 2 }>
<Flex>
<Flex columnGap={ 2 } py="5px" alignItems="center">
<AddressEntity
address={ tokenAddress }
isLoading={ isLoading }
noIcon
truncation="constant"
fontSize="sm"
fontWeight={ 500 }
/>
<AddressAddToWallet token={ token } ml={ 2 } isLoading={ isLoading }/>
<AddressAddToWallet token={ token } isLoading={ isLoading } iconSize={ 5 }/>
</Flex>
<Box mt={ 3 } >
<Flex columnGap={ 1 }>
<Tag isLoading={ isLoading }>{ type }</Tag>
</Box>
</Box>
</Box>
{ bridgedChainTag && <Tag isLoading={ isLoading }>{ bridgedChainTag }</Tag> }
</Flex>
</Flex>
</Flex>
</Td>
<Td isNumeric>
......
import type { TokenType } from 'types/api/token';
import type { TokensSortingField, TokensSortingValue, TokensSorting } from 'types/api/tokens';
import type { Query } from 'nextjs-routes';
import config from 'configs/app';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import type { Option } from 'ui/shared/sort/Sort';
export const SORT_OPTIONS: Array<Option<TokensSortingValue>> = [
{ title: 'Default', id: undefined },
{ title: 'Price ascending', id: 'fiat_value-asc' },
{ title: 'Price descending', id: 'fiat_value-desc' },
{ title: 'Holders ascending', id: 'holder_count-asc' },
{ title: 'Holders descending', id: 'holder_count-desc' },
{ title: 'On-chain market cap ascending', id: 'circulating_market_cap-asc' },
{ title: 'On-chain market cap descending', id: 'circulating_market_cap-desc' },
];
export const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS);
const bridgedTokensChainIds = (() => {
const feature = config.features.bridgedTokens;
if (!feature.isEnabled) {
return [];
}
return feature.chains.map(chain => chain.id);
})();
export const getBridgedChainsFilterValue = (getFilterValuesFromQuery<string>).bind(null, bridgedTokensChainIds);
export const getSortValueFromQuery = (query: Query): TokensSortingValue | undefined => {
if (!query.sort || !query.order) {
return undefined;
}
const str = query.sort + '-' + query.order;
if (SORT_OPTIONS.map(option => option.id).includes(str)) {
return str as TokensSortingValue;
}
};
export const getSortParamsFromValue = (val?: TokensSortingValue): TokensSorting | undefined => {
if (!val) {
return undefined;
}
const sortingChunks = val.split('-') as [ TokensSortingField, TokensSorting['order'] ];
return { sort: sortingChunks[0], order: sortingChunks[1] };
};
......@@ -7,7 +7,7 @@ import type { TokenType } from 'types/api/token';
import { SECOND } from 'lib/consts';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import { apos } from 'lib/html-entities';
import TOKEN_TYPE from 'lib/token/tokenTypes';
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import { getTokenTransfersStub } from 'stubs/token';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
......@@ -21,9 +21,7 @@ import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS);
const TxTokenTransfer = () => {
const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
......
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