diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 54eaa73d76..1a957c87c2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -321,6 +321,7 @@ "eth", "rootstock", "polygon", + "gnosis", "localhost", ], "default": "main" diff --git a/configs/app/features/bridgedTokens.ts b/configs/app/features/bridgedTokens.ts new file mode 100644 index 0000000000..ae275c5397 --- /dev/null +++ b/configs/app/features/bridgedTokens.ts @@ -0,0 +1,27 @@ +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; bridges: Array }> = (() => { + const chains = parseEnvJson>(getEnvValue('NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS')); + const bridges = parseEnvJson>(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; diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index eba8a1cf56..1b7acd949b 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -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'; diff --git a/configs/envs/.env.gnosis b/configs/envs/.env.gnosis new file mode 100644 index 0000000000..1e20f6c303 --- /dev/null +++ b/configs/envs/.env.gnosis @@ -0,0 +1,53 @@ +# 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 diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 47d3ad60df..c6aa4a5ef9 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -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 = yup }), }); +const bridgedTokenChainSchema: yup.ObjectSchema = 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 = 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) => 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; diff --git a/deploy/values/review-l2/values.yaml.gotmpl b/deploy/values/review-l2/values.yaml.gotmpl index 1fbd2b8629..3efc4bc89a 100644 --- a/deploy/values/review-l2/values.yaml.gotmpl +++ b/deploy/values/review-l2/values.yaml.gotmpl @@ -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" diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index 591686dc9a..1a1c10e98d 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -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 diff --git a/docs/ENVS.md b/docs/ENVS.md index 52175ac070..7dc2fe0ba6 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -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   +### 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` 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` 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 `/`, e.g `https://eth.blockscout.com/token/` + +#### 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` | + +  + ### 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. diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 5f37871638..68ca913f74 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -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 : 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 'tokens' ? TokensSorting : +Q extends 'tokens_bridged' ? TokensSorting : never; /* eslint-enable @typescript-eslint/indent */ diff --git a/lib/token/tokenTypes.ts b/lib/token/tokenTypes.ts index 2891ff6307..c8f258b4fb 100644 --- a/lib/token/tokenTypes.ts +++ b/lib/token/tokenTypes.ts @@ -1,9 +1,9 @@ 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); diff --git a/mocks/tokens/tokenInfo.ts b/mocks/tokens/tokenInfo.ts index be4a6b564c..1034b9bc64 100644 --- a/mocks/tokens/tokenInfo.ts +++ b/mocks/tokens/tokenInfo.ts @@ -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', +}; diff --git a/playwright/utils/configs.ts b/playwright/utils/configs.ts index b6b9af657f..a2dd7943d8 100644 --- a/playwright/utils/configs.ts +++ b/playwright/utils/configs.ts @@ -1,3 +1,4 @@ +/* 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 = { diff --git a/types/api/token.ts b/types/api/token.ts index a103f890a3..ebc5198d92 100644 --- a/types/api/token.ts +++ b/types/api/token.ts @@ -14,6 +14,11 @@ export interface TokenInfo { 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 { diff --git a/types/api/tokens.ts b/types/api/tokens.ts index 53923826d4..adeca8d768 100644 --- a/types/api/tokens.ts +++ b/types/api/tokens.ts @@ -13,6 +13,8 @@ export type TokensResponse = { export type TokensFilters = { q: string; type: Array | undefined }; +export type TokensBridgedFilters = { q: string; chain_ids: Array | undefined }; + export interface TokenInstanceTransferResponse { items: Array; 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'] }`; diff --git a/types/client/token.ts b/types/client/token.ts index f939b76661..06aaaa47b2 100644 --- a/types/client/token.ts +++ b/types/client/token.ts @@ -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; +} diff --git a/ui/address/AddressTokenTransfers.tsx b/ui/address/AddressTokenTransfers.tsx index 4ffcf8e8b0..0355618015 100644 --- a/ui/address/AddressTokenTransfers.tsx +++ b/ui/address/AddressTokenTransfers.tsx @@ -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).bind(null, TOKEN_TYPES); +const getTokenFilterValue = (getFilterValuesFromQuery).bind(null, TOKEN_TYPE_IDS); const getAddressFilterValue = (getFilterValueFromQuery).bind(null, AddressFromToFilterValues); const OVERLOAD_COUNT = 75; diff --git a/ui/address/__screenshots__/AddressDetails.pw.tsx_default_validator-mobile-1.png b/ui/address/__screenshots__/AddressDetails.pw.tsx_default_validator-mobile-1.png index c98b97805d..9f776fde0e 100644 Binary files a/ui/address/__screenshots__/AddressDetails.pw.tsx_default_validator-mobile-1.png and b/ui/address/__screenshots__/AddressDetails.pw.tsx_default_validator-mobile-1.png differ diff --git a/ui/address/__screenshots__/AddressDetails.pw.tsx_mobile_validator-mobile-1.png b/ui/address/__screenshots__/AddressDetails.pw.tsx_mobile_validator-mobile-1.png index 649233c2c1..e00a04ecbb 100644 Binary files a/ui/address/__screenshots__/AddressDetails.pw.tsx_mobile_validator-mobile-1.png and b/ui/address/__screenshots__/AddressDetails.pw.tsx_mobile_validator-mobile-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc20-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc20-dark-mode-1.png index a42174aa2e..5c9933e717 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc20-dark-mode-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc20-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc20-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc20-dark-mode-1.png index c729e82e85..405d4f9ef4 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc20-dark-mode-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc20-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc20-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc20-1.png index 5a9707d8df..4be344fc59 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc20-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc20-1.png differ diff --git a/ui/pages/Token.pw.tsx b/ui/pages/Token.pw.tsx index c8fce1e416..16db22015a 100644 --- a/ui/pages/Token.pw.tsx +++ b/ui/pages/Token.pw.tsx @@ -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({ + // 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( + + + , + { 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 }) => { diff --git a/ui/pages/Token.tsx b/ui/pages/Token.tsx index 84f94e3a33..860eaa9cc6 100644 --- a/ui/pages/Token.tsx +++ b/ui/pages/Token.tsx @@ -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 } /> - + { /* should stay before tabs to scroll up with pagination */ } diff --git a/ui/pages/Tokens.pw.tsx b/ui/pages/Tokens.pw.tsx new file mode 100644 index 0000000000..ce714a2fee --- /dev/null +++ b/ui/pages/Tokens.pw.tsx @@ -0,0 +1,135 @@ +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( + + + + , + ); + + 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( + + + + , + { 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(); + }); +}); diff --git a/ui/pages/Tokens.tsx b/ui/pages/Tokens.tsx index 7a597881c3..2442470b37 100644 --- a/ui/pages/Tokens.tsx +++ b/ui/pages/Tokens.tsx @@ -1,13 +1,185 @@ +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(q ?? ''); + const [ sort, setSort ] = React.useState(getSortValueFromQuery(router.query)); + const [ tokenTypes, setTokenTypes ] = React.useState | undefined>(getTokenFilterValue(router.query.type)); + const [ bridgeChains, setBridgeChains ] = React.useState | 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) => { + tokensQuery.onFilterChange({ q: debouncedSearchTerm, type: value }); + setTokenTypes(value); + }, [ debouncedSearchTerm, tokensQuery ]); + + const handleBridgeChainsChange = React.useCallback((value: Array) => { + 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' ? ( + 0 } contentProps={{ maxW: '350px' }} appliedFiltersNum={ bridgeChains?.length }> + + + ) : ( + 0 } contentProps={{ w: '200px' }} appliedFiltersNum={ tokenTypes?.length }> + + + ); + + const actionBar = ( + + ); + + 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 ( + + List of the tokens bridged through { bridgesListText } extensions + + ); + })(); + + const tabs: Array = [ + { + id: 'all', + title: 'All', + component: ( + + ), + }, + bridgedTokensFeature.isEnabled ? { + id: 'bridged', + title: 'Bridged', + component: ( + + ), + } : undefined, + ].filter(Boolean); + return ( <> - + { tabs.length === 1 && !isMobile && actionBar } + ); }; diff --git a/ui/pages/__screenshots__/Token.pw.tsx_default_base-view-1.png b/ui/pages/__screenshots__/Token.pw.tsx_default_base-view-1.png index 6d1b01b56c..82421444ee 100644 Binary files a/ui/pages/__screenshots__/Token.pw.tsx_default_base-view-1.png and b/ui/pages/__screenshots__/Token.pw.tsx_default_base-view-1.png differ diff --git a/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png b/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png new file mode 100644 index 0000000000..c65a292d52 Binary files /dev/null and b/ui/pages/__screenshots__/Token.pw.tsx_default_bridged-token-1.png differ diff --git a/ui/pages/__screenshots__/Token.pw.tsx_default_mobile-base-view-1.png b/ui/pages/__screenshots__/Token.pw.tsx_default_mobile-base-view-1.png index 46cc87e6cf..8022377d15 100644 Binary files a/ui/pages/__screenshots__/Token.pw.tsx_default_mobile-base-view-1.png and b/ui/pages/__screenshots__/Token.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/pages/__screenshots__/Token.pw.tsx_default_mobile-with-verified-info-1.png b/ui/pages/__screenshots__/Token.pw.tsx_default_mobile-with-verified-info-1.png index 18bab404a0..f12a0c52de 100644 Binary files a/ui/pages/__screenshots__/Token.pw.tsx_default_mobile-with-verified-info-1.png and b/ui/pages/__screenshots__/Token.pw.tsx_default_mobile-with-verified-info-1.png differ diff --git a/ui/pages/__screenshots__/Token.pw.tsx_default_with-verified-info-1.png b/ui/pages/__screenshots__/Token.pw.tsx_default_with-verified-info-1.png index 30cb71646b..32e8e0c8d3 100644 Binary files a/ui/pages/__screenshots__/Token.pw.tsx_default_with-verified-info-1.png and b/ui/pages/__screenshots__/Token.pw.tsx_default_with-verified-info-1.png differ diff --git a/ui/pages/__screenshots__/Tokens.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png b/ui/pages/__screenshots__/Tokens.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png new file mode 100644 index 0000000000..285d04eb9d Binary files /dev/null and b/ui/pages/__screenshots__/Tokens.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png differ diff --git a/ui/pages/__screenshots__/Tokens.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png b/ui/pages/__screenshots__/Tokens.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png new file mode 100644 index 0000000000..68fc0464e7 Binary files /dev/null and b/ui/pages/__screenshots__/Tokens.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png differ diff --git a/ui/pages/__screenshots__/Tokens.pw.tsx_default_base-view-mobile-dark-mode-1.png b/ui/pages/__screenshots__/Tokens.pw.tsx_default_base-view-mobile-dark-mode-1.png new file mode 100644 index 0000000000..b4594c098b Binary files /dev/null and b/ui/pages/__screenshots__/Tokens.pw.tsx_default_base-view-mobile-dark-mode-1.png differ diff --git a/ui/pages/__screenshots__/Tokens.pw.tsx_default_base-view-mobile-dark-mode-2.png b/ui/pages/__screenshots__/Tokens.pw.tsx_default_base-view-mobile-dark-mode-2.png new file mode 100644 index 0000000000..db9057c17a Binary files /dev/null and b/ui/pages/__screenshots__/Tokens.pw.tsx_default_base-view-mobile-dark-mode-2.png differ diff --git a/ui/pages/__screenshots__/Tokens.pw.tsx_default_bridged-tokens-base-view-1.png b/ui/pages/__screenshots__/Tokens.pw.tsx_default_bridged-tokens-base-view-1.png new file mode 100644 index 0000000000..edf354b688 Binary files /dev/null and b/ui/pages/__screenshots__/Tokens.pw.tsx_default_bridged-tokens-base-view-1.png differ diff --git a/ui/pages/__screenshots__/Tokens.pw.tsx_default_bridged-tokens-base-view-2.png b/ui/pages/__screenshots__/Tokens.pw.tsx_default_bridged-tokens-base-view-2.png new file mode 100644 index 0000000000..630e5f8737 Binary files /dev/null and b/ui/pages/__screenshots__/Tokens.pw.tsx_default_bridged-tokens-base-view-2.png differ diff --git a/ui/pages/__screenshots__/Tokens.pw.tsx_mobile_base-view-mobile-dark-mode-1.png b/ui/pages/__screenshots__/Tokens.pw.tsx_mobile_base-view-mobile-dark-mode-1.png new file mode 100644 index 0000000000..a5d0f10c5b Binary files /dev/null and b/ui/pages/__screenshots__/Tokens.pw.tsx_mobile_base-view-mobile-dark-mode-1.png differ diff --git a/ui/pages/__screenshots__/Tokens.pw.tsx_mobile_base-view-mobile-dark-mode-2.png b/ui/pages/__screenshots__/Tokens.pw.tsx_mobile_base-view-mobile-dark-mode-2.png new file mode 100644 index 0000000000..5dee58eb04 Binary files /dev/null and b/ui/pages/__screenshots__/Tokens.pw.tsx_mobile_base-view-mobile-dark-mode-2.png differ diff --git a/ui/shared/AddressActions/Menu.tsx b/ui/shared/AddressActions/Menu.tsx index 0d4e74746d..a7b29d00f2 100644 --- a/ui/shared/AddressActions/Menu.tsx +++ b/ui/shared/AddressActions/Menu.tsx @@ -29,7 +29,7 @@ const AddressActions = ({ isLoading }: Props) => { return ( - + ; + address?: Pick; 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 ( + + Original token + + ); + } catch (error) { + return null; + } + })(); return ( - + - { !isLoading && address.is_contract && token && } + { !isLoading && address?.is_contract && token && } { !isLoading && !address.is_contract && config.features.account.isEnabled && ( - + ) } - + { config.features.account.isEnabled && } + { tokenOriginalLink } ); }; diff --git a/ui/shared/EntityTags.tsx b/ui/shared/EntityTags.tsx index caed74f5f7..72da69d3ff 100644 --- a/ui/shared/EntityTags.tsx +++ b/ui/shared/EntityTags.tsx @@ -1,3 +1,4 @@ +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 = [ ...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.display_name } )) @@ -60,7 +70,15 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin { tags .slice(2) - .map((tag) => { tag.display_name }) + .map((tag) => ( + + { tag.display_name } + + )) } @@ -71,7 +89,14 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin } return tags.map((tag) => ( - + { tag.display_name } )); diff --git a/ui/shared/LinkExternal.tsx b/ui/shared/LinkExternal.tsx index 9c7cf6bf2f..329a66aed7 100644 --- a/ui/shared/LinkExternal.tsx +++ b/ui/shared/LinkExternal.tsx @@ -1,4 +1,5 @@ -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 ( - + { children } @@ -21,7 +42,7 @@ const LinkExternal = ({ href, children, className, isLoading }: Props) => { } return ( - + { children } diff --git a/ui/shared/Tabs/RoutedTabs.tsx b/ui/shared/Tabs/RoutedTabs.tsx index c7e48b695e..a57b68f222 100644 --- a/ui/shared/Tabs/RoutedTabs.tsx +++ b/ui/shared/Tabs/RoutedTabs.tsx @@ -13,11 +13,13 @@ interface Props extends ThemingProps<'Tabs'> { tabs: Array; 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(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 } diff --git a/ui/shared/Tabs/TabsWithScroll.tsx b/ui/shared/Tabs/TabsWithScroll.tsx index 459d48dbe4..9c19ffa598 100644 --- a/ui/shared/Tabs/TabsWithScroll.tsx +++ b/ui/shared/Tabs/TabsWithScroll.tsx @@ -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(defaultTabIndex || 0); const isMobile = useIsMobile(); const tabsRef = useRef(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 = ({ ); }) } - { rightSlot ? { rightSlot } : null } + { rightSlot && tabsCut > 0 ? { rightSlot } : null } { tabsList.map((tab) => { tab.component }) } diff --git a/ui/shared/Tabs/useAdaptiveTabs.tsx b/ui/shared/Tabs/useAdaptiveTabs.tsx index b775d3fa55..f9d8ca7fb9 100644 --- a/ui/shared/Tabs/useAdaptiveTabs.tsx +++ b/ui/shared/Tabs/useAdaptiveTabs.tsx @@ -1,11 +1,9 @@ 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, disabled?: boolean) { +export default function useAdaptiveTabs(tabs: Array, 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, 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, disabled?: boole return React.useMemo(() => { return { tabsCut, - tabsList, tabsRefs, listRef, rightSlotRef, }; - }, [ tabsList, tabsCut, tabsRefs, listRef ]); + }, [ tabsCut, tabsRefs ]); } diff --git a/ui/shared/address/AddressAddToWallet.tsx b/ui/shared/address/AddressAddToWallet.tsx index ecb59d2ccf..e1eb02225d 100644 --- a/ui/shared/address/AddressAddToWallet.tsx +++ b/ui/shared/address/AddressAddToWallet.tsx @@ -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 ; + return ; } if (!feature.isEnabled) { @@ -88,7 +89,7 @@ const AddressAddToWallet = ({ className, token, isLoading }: Props) => { return ( - + ); diff --git a/ui/shared/filters/TokenTypeFilter.tsx b/ui/shared/filters/TokenTypeFilter.tsx index 7706dcc52f..3aec39fa48 100644 --- a/ui/shared/filters/TokenTypeFilter.tsx +++ b/ui/shared/filters/TokenTypeFilter.tsx @@ -1,9 +1,9 @@ -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) => 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) => { + setValue(nextValue); + onChange(nextValue); + }, [ onChange, setValue ]); + return ( - - { TOKEN_TYPE.map(({ title, id }) => ( - - { title } - - )) } - + <> + + Type + 0 ? 'link' : 'text_secondary' } + _hover={{ + color: value.length > 0 ? 'link_hovered' : 'text_secondary', + }} + > + Reset + + + + { TOKEN_TYPES.map(({ title, id }) => ( + + { title } + + )) } + + ); }; diff --git a/ui/token/TokenContractInfo.tsx b/ui/token/TokenContractInfo.tsx deleted file mode 100644 index 0d1e8bdf49..0000000000 --- a/ui/token/TokenContractInfo.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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; - contractQuery: UseQueryResult
; -} - -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 ( - - ); -}; - -export default React.memo(TokenContractInfo); diff --git a/ui/token/TokenVerifiedInfo.tsx b/ui/token/TokenVerifiedInfo.tsx index 8c397e9241..5e15db2c6d 100644 --- a/ui/token/TokenVerifiedInfo.tsx +++ b/ui/token/TokenVerifiedInfo.tsx @@ -1,4 +1,4 @@ -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 ( - { url.host } + { url.host } ); } catch (error) { return null; diff --git a/ui/tokens/Tokens.pw.tsx b/ui/tokens/Tokens.pw.tsx deleted file mode 100644 index 0ebd702b17..0000000000 --- a/ui/tokens/Tokens.pw.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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( - - - - , - ); - - await expect(component).toHaveScreenshot(); -}); diff --git a/ui/tokens/Tokens.tsx b/ui/tokens/Tokens.tsx index 732a2c8512..e0d66b2ac7 100644 --- a/ui/tokens/Tokens.tsx +++ b/ui/tokens/Tokens.tsx @@ -1,154 +1,37 @@ -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).bind(null, TOKEN_TYPES); - -export type TokensSortingField = TokensSorting['sort']; -export type TokensSortingValue = `${ TokensSortingField }-${ TokensSorting['order'] }`; - -const SORT_OPTIONS: Array> = [ - { 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(router.query.q?.toString() || ''); - const [ sorting, setSorting ] = React.useState(getSortValueFromQuery(router.query)); - const [ type, setType ] = React.useState | 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) => { - 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 ; } - const typeFilter = ( - 0 } contentProps={{ w: '200px' }}> - - - ); - - const filterInput = ( - - ); - - const actionBar = ( - <> - - { typeFilter } - - { filterInput } - - - - { typeFilter } - { filterInput } - - - - - ); - const content = data?.items ? ( <> + { description } { data.items.map((item, index) => ( { )) } + { description } @@ -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 } /> ); }; diff --git a/ui/tokens/TokensActionBar.tsx b/ui/tokens/TokensActionBar.tsx new file mode 100644 index 0000000000..497dcc9ca4 --- /dev/null +++ b/ui/tokens/TokensActionBar.tsx @@ -0,0 +1,70 @@ +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 = ( + + ); + + return ( + <> + + { filter } + + { searchInput } + + + + { filter } + { searchInput } + + + + + ); +}; + +export default React.memo(TokensActionBar); diff --git a/ui/tokens/TokensBridgedChainsFilter.tsx b/ui/tokens/TokensBridgedChainsFilter.tsx new file mode 100644 index 0000000000..d15e7aee17 --- /dev/null +++ b/ui/tokens/TokensBridgedChainsFilter.tsx @@ -0,0 +1,59 @@ +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) => void; + defaultValue?: Array; +} + +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) => { + setValue(nextValue); + onChange(nextValue); + }, [ onChange, setValue ]); + + if (!feature.isEnabled) { + return null; + } + + return ( + <> + + Show bridged tokens from + 0 ? 'link' : 'text_secondary' } + _hover={{ + color: value.length > 0 ? 'link_hovered' : 'text_secondary', + }} + > + Reset + + + + { feature.chains.map(({ title, id, short_title: shortTitle }) => ( + + { title } + ({ shortTitle }) + + )) } + + + ); +}; + +export default React.memo(TokensBridgedChainsFilter); diff --git a/ui/tokens/TokensListItem.tsx b/ui/tokens/TokensListItem.tsx index bdbff9a3b3..0d8570ecd2 100644 --- a/ui/tokens/TokensListItem.tsx +++ b/ui/tokens/TokensListItem.tsx @@ -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 ( - { type } + + { type } + { bridgedChainTag && { bridgedChainTag } } + { (page - 1) * PAGE_SIZE + index + 1 } diff --git a/ui/tokens/TokensTable.tsx b/ui/tokens/TokensTable.tsx index 872a1796f6..f14de2de2a 100644 --- a/ui/tokens/TokensTable.tsx +++ b/ui/tokens/TokensTable.tsx @@ -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> = { diff --git a/ui/tokens/TokensTableItem.tsx b/ui/tokens/TokensTableItem.tsx index 8df4933aeb..937f03ac82 100644 --- a/ui/tokens/TokensTableItem.tsx +++ b/ui/tokens/TokensTableItem.tsx @@ -1,9 +1,10 @@ -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 } - + - - - - - - - { type } - - - + + + + + + { type } + { bridgedChainTag && { bridgedChainTag } } + + diff --git a/ui/tokens/__screenshots__/Tokens.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png b/ui/tokens/__screenshots__/Tokens.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png deleted file mode 100644 index 08e48b2317..0000000000 Binary files a/ui/tokens/__screenshots__/Tokens.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png and /dev/null differ diff --git a/ui/tokens/__screenshots__/Tokens.pw.tsx_default_base-view-mobile-dark-mode-1.png b/ui/tokens/__screenshots__/Tokens.pw.tsx_default_base-view-mobile-dark-mode-1.png deleted file mode 100644 index afd886699c..0000000000 Binary files a/ui/tokens/__screenshots__/Tokens.pw.tsx_default_base-view-mobile-dark-mode-1.png and /dev/null differ diff --git a/ui/tokens/__screenshots__/Tokens.pw.tsx_mobile_base-view-mobile-dark-mode-1.png b/ui/tokens/__screenshots__/Tokens.pw.tsx_mobile_base-view-mobile-dark-mode-1.png deleted file mode 100644 index d8eb1e89c7..0000000000 Binary files a/ui/tokens/__screenshots__/Tokens.pw.tsx_mobile_base-view-mobile-dark-mode-1.png and /dev/null differ diff --git a/ui/tokens/utils.ts b/ui/tokens/utils.ts new file mode 100644 index 0000000000..dd4aea8383 --- /dev/null +++ b/ui/tokens/utils.ts @@ -0,0 +1,50 @@ +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> = [ + { 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).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).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] }; +}; diff --git a/ui/tx/TxTokenTransfer.tsx b/ui/tx/TxTokenTransfer.tsx index 87269aeca6..4249c7d305 100644 --- a/ui/tx/TxTokenTransfer.tsx +++ b/ui/tx/TxTokenTransfer.tsx @@ -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).bind(null, TOKEN_TYPES); +const getTokenFilterValue = (getFilterValuesFromQuery).bind(null, TOKEN_TYPE_IDS); const TxTokenTransfer = () => { const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });