Multichain explorer v2 (#3259)
* Support redirect to a correct view from search * show domains on address page * migrate to new API version to display address domains * another api update * new design for chain widget on home page + tests * chain widget styles for mobile devices * ecosystems page * update types * add ecosystems to navigation * address portfolio: pt.1 * portfolio tokens list * filter portfolio by chain * skip redirect when navigating to search result by clicking on "show all results" link * net worth block design * add filter input to chain select and fix tokens table * calculate address portfolio * adjust card logic * support custom token types in multichain explorer * handle case when new worth is 0 * add text filter to tokens list * fixes * demo envs * portfolio tests * ecosystems test * change signature for getTokenTypes function * fix adding token to the wallet from the list * lazy load all address domains * show contract creator and implementations info * fix tests * add poor reputation tokens toggle * tweaks * dim celo native tokens * disable env validator * fix tests * remove tokens with 0 usd value from top tokens * fixes * preserve selected chain when switching tabs on address page * add hints to stats widgets on home page * update envs for demo * fix portfolio cards and home page * update styles for contract creator and implementations * fix select issues * add chains from cluster to network menu * hide csv export link on empty logs page * update screenshots * rollback select fix * update cut length calculation for portfolio cards * fix chain select * update screenshots * fix tests * more fixes * add sorting to ecosystems table * fix test * fixes * fix loading state in ecosystems page * review fixes * fix tests
@@ -357,7 +357,6 @@
|
||||
"neon_devnet",
|
||||
"numine",
|
||||
"optimism",
|
||||
"optimism_interop_0",
|
||||
"optimism_sepolia",
|
||||
"optimism_superchain",
|
||||
"polygon",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Set of ENVs for OP Interop Alpha 0 network explorer
|
||||
# https://optimism-interop-alpha-0.blockscout.com
|
||||
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=optimism_interop_0"
|
||||
|
||||
# Local ENVs
|
||||
NEXT_PUBLIC_APP_PROTOCOL=http
|
||||
NEXT_PUBLIC_APP_HOST=localhost
|
||||
NEXT_PUBLIC_APP_PORT=3000
|
||||
NEXT_PUBLIC_APP_ENV=development
|
||||
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
|
||||
|
||||
# Instance ENVs
|
||||
NEXT_PUBLIC_AD_BANNER_ENABLE_SPECIFY=true
|
||||
NEXT_PUBLIC_AD_BANNER_PROVIDER=none
|
||||
NEXT_PUBLIC_AD_TEXT_PROVIDER=none
|
||||
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol','drops','blockscoutbadges','gitpoap','efp','etherscore','webacy','humanpassport','trustblock','smartmuv','humanode','deepdao']
|
||||
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json
|
||||
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
|
||||
NEXT_PUBLIC_API_BASE_PATH=/
|
||||
NEXT_PUBLIC_API_HOST=optimism-interop-alpha-0.blockscout.com
|
||||
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
|
||||
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
|
||||
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
|
||||
NEXT_PUBLIC_FAULT_PROOF_ENABLED=true
|
||||
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/optimism.json
|
||||
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
|
||||
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
|
||||
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['linear-gradient(90deg, rgb(232, 52, 53) 0%, rgb(139, 28, 232) 100%)'],'text_color':['rgb(255, 255, 255)']}
|
||||
NEXT_PUBLIC_INTEROP_ENABLED=true
|
||||
NEXT_PUBLIC_IS_TESTNET=true
|
||||
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
|
||||
NEXT_PUBLIC_MIXPANEL_CONFIG_OVERRIDES={"record_sessions_percent": 0.5,"record_heatmap_data": true}
|
||||
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
|
||||
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
|
||||
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
|
||||
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism.svg
|
||||
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism.svg
|
||||
NEXT_PUBLIC_NETWORK_ID=420120000
|
||||
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism.svg
|
||||
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism.svg
|
||||
NEXT_PUBLIC_NETWORK_NAME=OP Interop Alpha 0
|
||||
NEXT_PUBLIC_NETWORK_RPC_URL=https://interop-alpha-0.optimism.io
|
||||
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
|
||||
NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/capyPuzzleBadge
|
||||
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-sepolia.blockscout.com/
|
||||
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://bridge.interop-alpha-0.optimism.io
|
||||
NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED=false
|
||||
NEXT_PUBLIC_ROLLUP_TYPE=optimistic
|
||||
NEXT_PUBLIC_STATS_API_BASE_PATH=/stats-service
|
||||
NEXT_PUBLIC_STATS_API_HOST=https://optimism-interop-alpha-0.blockscout.com
|
||||
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
|
||||
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
|
||||
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
|
||||
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket', 'metamask']
|
||||
@@ -8,32 +8,26 @@ NEXT_PUBLIC_APP_HOST=localhost
|
||||
NEXT_PUBLIC_APP_PORT=3000
|
||||
NEXT_PUBLIC_APP_ENV=development
|
||||
|
||||
NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=https://multichain-aggregator.k8s-dev.blockscout.com
|
||||
NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=http://multichain-search-stats.k8s-dev.blockscout.com
|
||||
NEXT_PUBLIC_MULTICHAIN_ENABLED=true
|
||||
NEXT_PUBLIC_MULTICHAIN_CLUSTER=interop
|
||||
# NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=https://multichain-aggregator.k8s-dev.blockscout.com
|
||||
# NEXT_PUBLIC_MULTICHAIN_CLUSTER=interop
|
||||
# NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=http://multichain-search-stats.k8s-dev.blockscout.com
|
||||
NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=https://multichain-aggregator.services.blockscout.com
|
||||
NEXT_PUBLIC_MULTICHAIN_CLUSTER=multichain
|
||||
NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=https://multichain-stats.k8s-prod-3.blockscout.com
|
||||
|
||||
SKIP_ENVS_VALIDATION=false
|
||||
SKIP_ENVS_VALIDATION=true
|
||||
NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG=[{'name': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'}]
|
||||
|
||||
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
|
||||
NEXT_PUBLIC_HOMEPAGE_STATS=['total_txs','wallet_addresses']
|
||||
NEXT_PUBLIC_API_DOCS_TABS=[]
|
||||
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/optimism-mainnet.json
|
||||
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/optimism.json
|
||||
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs', 'coin_price', 'market_cap', 'secondary_coin_price']
|
||||
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['linear-gradient(90deg, rgb(232, 52, 53) 0%, rgb(139, 28, 232) 100%)'],'text_color':['rgb(255, 255, 255)']}
|
||||
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism-superchain.svg
|
||||
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism-superchain.svg
|
||||
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism-superchain.svg
|
||||
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism-superchain-dark.svg
|
||||
NEXT_PUBLIC_NETWORK_NAME=OP Superchain
|
||||
NEXT_PUBLIC_NETWORK_SHORT_NAME=OP Superchain
|
||||
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/optimism-mainnet.png
|
||||
NEXT_PUBLIC_NETWORK_NAME=Blockscout
|
||||
NEXT_PUBLIC_GAS_TRACKER_ENABLED=false
|
||||
NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS=true
|
||||
NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS=true
|
||||
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=false
|
||||
NEXT_PUBLIC_IS_TESTNET=true
|
||||
NEXT_PUBLIC_IS_TESTNET=false
|
||||
NEXT_PUBLIC_USE_NEXT_JS_PROXY=true
|
||||
NEXT_PUBLIC_HAS_USER_OPS=true
|
||||
NEXT_PUBLIC_ADVANCED_FILTER_ENABLED=false
|
||||
@@ -11,6 +11,11 @@ const outputFile = resolvePath(outputDir, 'llms.txt');
|
||||
|
||||
function run() {
|
||||
try {
|
||||
if(config.features.opSuperchain.isEnabled){
|
||||
console.log('⏭️ Skipping llms.txt generation for multichain explorer');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🌀 Generating llms.txt...');
|
||||
|
||||
const chainName = config.chain.name ?? '';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.547 21.698c.65.545 1.38.996 2.171 1.334l-.165.54-.466 1.363A10.743 10.743 0 0 1 7.57 22.6l1.026-.22.056-.01.054-.018.078-.026.047-.015.047-.02a1.46 1.46 0 0 0 .668-.594Zm10.952.2c.346.587.84 1.077 1.428 1.42a10.723 10.723 0 0 1-2.716 1.616l-.467-1.363-.166-.54a8.72 8.72 0 0 0 1.92-1.133Zm5.225-8.712c.1.59.157 1.196.157 1.814 0 .46-.034.914-.09 1.36a3.96 3.96 0 0 0-1.657-.458l-.205-.004c-.031 0-.063 0-.094.002a8.816 8.816 0 0 0-.028-2.017l1.917-.697Zm-21.22.453c.443.235.935.389 1.458.442l.051.004.3.016h.053l.095-.001a8.846 8.846 0 0 0-.015 1.597l-1.973.421a10.835 10.835 0 0 1 .031-2.48Zm7.05-7.21.165.538A8.723 8.723 0 0 0 9.796 8.1 4.002 4.002 0 0 0 8.369 6.68c.815-.665 1.73-1.211 2.718-1.615l.466 1.365Zm7.657-1.364a10.734 10.734 0 0 1 3.088 1.933l-1.473.536c-.226.083-.42.216-.573.382a8.72 8.72 0 0 0-1.675-.95l.166-.536.467-1.365ZM16.842 21.766a.63.63 0 0 1 .525.268l.046.076.006.013.026.065.004.009.003.008.496 1.599.55 1.588.025.1a.626.626 0 0 1-.26.615l-1.386.966-1.353 1.003a.632.632 0 0 1-.664.054l-.087-.053-1.352-1.004-1.387-.966a.624.624 0 0 1-.234-.715l.549-1.588.497-1.599.006-.017.027-.065.006-.013a.628.628 0 0 1 .571-.344l1.692.024 1.694-.024Zm-1.664 1.682-.018.002h-.021l-.02-.002-.908-.013-.268.864-.002.004-.012.037v.002l-.296.854.747.52.004.001.03.023.002.002.732.543.733-.544.002-.001.031-.023.003-.002.745-.52-.294-.853v-.002l-.012-.037-.002-.004-.27-.864-.906.013Zm8.795-6.707c1.732 0 3.142 1.392 3.142 3.113v.01l-.015.298-.001.01c-.162 1.571-1.502 2.795-3.126 2.795h-.01l-.3-.015h-.01c-1.583-.16-2.822-1.485-2.822-3.098 0-1.72 1.409-3.112 3.142-3.113Zm-16.575-.377c.281-.003.539.19.601.477l.855 3.957.001.01.001.01.01.08.001.01v.011a.606.606 0 0 1-.365.559l-.009.004-.01.003-.078.025-.01.004-.01.002-3.996.846-.022.004-.082.007-.009.001h-.008a.612.612 0 0 1-.563-.362l-.004-.009-.003-.01-.025-.077-.003-.01-.002-.01-.855-3.955a.606.606 0 0 1 .47-.719l3.994-.846.011-.002h.01l.082-.01H7.398ZM23.973 18.4c-.814 0-1.47.652-1.47 1.453 0 .8.656 1.455 1.47 1.455s1.471-.654 1.471-1.455-.657-1.453-1.471-1.453Zm-19.303.223.408 1.895 1.918-.406-.408-1.894-1.918.405Zm20.435-11.67c.279-.04.56.116.662.392l1.397 3.8.003.01.003.011.02.08.002.008.001.01a.606.606 0 0 1-.284.605l-.008.005-.008.004-.074.035-.01.005-.01.004-3.839 1.384-.01.003-.009.002-.08.02-.01.003-.011.002a.612.612 0 0 1-.606-.282l-.006-.009-.004-.01-.035-.072-.004-.009-.004-.01-1.398-3.801a.604.604 0 0 1 .364-.776l3.84-1.383.008-.003.01-.002.079-.02.01-.004.011-.002Zm-18.783.079c1.733 0 3.142 1.391 3.143 3.112v.01l-.016.298v.01c-.162 1.571-1.502 2.795-3.127 2.795h-.01l-.3-.015-.01-.002c-1.582-.159-2.82-1.483-2.82-3.096 0-1.72 1.408-3.112 3.14-3.112Zm0 1.658c-.813 0-1.47.653-1.47 1.454l.008.148c.076.732.7 1.306 1.462 1.307.814 0 1.472-.654 1.472-1.455l-.008-.149a1.466 1.466 0 0 0-1.464-1.305Zm16.396.876.668 1.82 1.843-.663-.67-1.82-1.841.663ZM14.859 1.87a.632.632 0 0 1 .665.054l1.358 1.008 1.382.962c.227.16.325.45.234.715l-.55 1.589-.496 1.598-.003.009-.004.009-.026.064-.003.006-.004.007a.63.63 0 0 1-.57.345l-1.694-.027-1.692.027a.63.63 0 0 1-.571-.345l-.003-.006-.003-.007-.027-.064-.006-.018-.497-1.598-.55-1.59a.624.624 0 0 1 .235-.714l1.382-.962 1.357-1.008.086-.054Zm-.443 2.39-.002.001-.03.023-.004.002-.747.518.296.855.012.039.002.003.268.864.916-.013H15.17l.914.013.27-.864.002-.003.012-.038.294-.856-.745-.518-.003-.002-.031-.023h-.002l-.733-.546-.732.545Z" fill="currentColor"/>
|
||||
<path d="M15.849 11.905a1.3 1.3 0 0 0 .739.307l1.11.089.09 1.111a1.3 1.3 0 0 0 .307.74l.722.848-.722.849a1.3 1.3 0 0 0-.295.637l-.012.102-.09 1.11-1.11.09a1.3 1.3 0 0 0-.66.243l-.08.064-.848.722-.849-.722-.08-.064a1.3 1.3 0 0 0-.659-.243l-1.111-.09-.09-1.11a1.3 1.3 0 0 0-.306-.74L11.182 15l.723-.849a1.3 1.3 0 0 0 .307-.739l.089-1.111 1.111-.09a1.3 1.3 0 0 0 .74-.306l.848-.723.849.723Z" stroke="currentColor" stroke-width="1.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,6 +1,6 @@
|
||||
import type { ApiResource } from '../types';
|
||||
import type * as multichain from '@blockscout/multichain-aggregator-types';
|
||||
import type { AddressTokensResponse, TokensResponse } from 'types/client/multichain-aggregator';
|
||||
import type { AddressTokensFilter, AddressTokensResponse, TokensResponse } from 'types/client/multichain-aggregator';
|
||||
|
||||
export const MULTICHAIN_AGGREGATOR_API_RESOURCES = {
|
||||
address: {
|
||||
@@ -11,17 +11,33 @@ export const MULTICHAIN_AGGREGATOR_API_RESOURCES = {
|
||||
path: '/addresses/:hash/tokens',
|
||||
pathParams: [ 'hash' as const ],
|
||||
paginated: true,
|
||||
filterFields: [ 'chain_id' as const, 'type' as const ],
|
||||
filterFields: [ 'chain_id' as const, 'type' as const, 'query' as const ],
|
||||
},
|
||||
address_domains: {
|
||||
path: '/addresses/:hash/domains',
|
||||
pathParams: [ 'hash' as const ],
|
||||
paginated: true,
|
||||
},
|
||||
address_portfolio: {
|
||||
path: '/addresses/:hash/portfolio',
|
||||
pathParams: [ 'hash' as const ],
|
||||
filterFields: [ 'chain_id' as const, 'query' as const ],
|
||||
},
|
||||
tokens: {
|
||||
path: '/tokens',
|
||||
filterFields: [ 'chain_id' as const, 'type' as const, 'query' as const ],
|
||||
paginated: true,
|
||||
},
|
||||
domain_protocols: {
|
||||
path: '/domain-protocols',
|
||||
},
|
||||
quick_search: {
|
||||
path: '/search\\:quick',
|
||||
filterFields: [ 'q' as const ],
|
||||
},
|
||||
search_check_redirect: {
|
||||
path: '/search\\:check-redirect',
|
||||
},
|
||||
search_addresses: {
|
||||
path: '/search/addresses',
|
||||
filterFields: [ 'q' as const, 'chain_id' as const ],
|
||||
@@ -57,6 +73,9 @@ export const MULTICHAIN_AGGREGATOR_API_RESOURCES = {
|
||||
filterFields: [ 'q' as const, 'chain_id' as const ],
|
||||
paginated: true,
|
||||
},
|
||||
chain_metrics: {
|
||||
path: '/chain-metrics',
|
||||
},
|
||||
} satisfies Record<string, ApiResource>;
|
||||
|
||||
export type MultichainAggregatorApiResourceName = `multichainAggregator:${ keyof typeof MULTICHAIN_AGGREGATOR_API_RESOURCES }`;
|
||||
@@ -65,8 +84,12 @@ export type MultichainAggregatorApiResourceName = `multichainAggregator:${ keyof
|
||||
export type MultichainAggregatorApiResourcePayload<R extends MultichainAggregatorApiResourceName> =
|
||||
R extends 'multichainAggregator:address' ? multichain.GetAddressResponse :
|
||||
R extends 'multichainAggregator:address_tokens' ? AddressTokensResponse :
|
||||
R extends 'multichainAggregator:address_portfolio' ? multichain.GetAddressPortfolioResponse :
|
||||
R extends 'multichainAggregator:address_domains' ? multichain.LookupAddressDomainsResponse :
|
||||
R extends 'multichainAggregator:tokens' ? TokensResponse :
|
||||
R extends 'multichainAggregator:domain_protocols' ? multichain.ListDomainProtocolsResponse :
|
||||
R extends 'multichainAggregator:quick_search' ? multichain.ClusterQuickSearchResponse :
|
||||
R extends 'multichainAggregator:search_check_redirect' ? multichain.CheckRedirectResponse :
|
||||
R extends 'multichainAggregator:search_addresses' ? multichain.SearchAddressesResponse :
|
||||
R extends 'multichainAggregator:search_blocks' ? multichain.SearchBlocksResponse :
|
||||
R extends 'multichainAggregator:search_block_numbers' ? multichain.SearchBlockNumbersResponse :
|
||||
@@ -74,12 +97,13 @@ R extends 'multichainAggregator:search_transactions' ? multichain.SearchTransact
|
||||
R extends 'multichainAggregator:search_tokens' ? multichain.SearchTokensResponse :
|
||||
R extends 'multichainAggregator:search_nfts' ? multichain.SearchNftsResponse :
|
||||
R extends 'multichainAggregator:search_domains' ? multichain.SearchDomainsResponse :
|
||||
R extends 'multichainAggregator:chain_metrics' ? multichain.ListChainMetricsResponse :
|
||||
never;
|
||||
/* eslint-enable @stylistic/indent */
|
||||
|
||||
/* eslint-disable @stylistic/indent */
|
||||
export type MultichainAggregatorApiPaginationFilters<R extends MultichainAggregatorApiResourceName> =
|
||||
R extends 'multichainAggregator:address_tokens' ? Partial<multichain.ListAddressTokensRequest> :
|
||||
R extends 'multichainAggregator:address_tokens' ? AddressTokensFilter :
|
||||
R extends 'multichainAggregator:tokens' ? Partial<multichain.ListClusterTokensRequest> :
|
||||
never;
|
||||
/* eslint-enable @stylistic/indent */
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum NAMES {
|
||||
HIDE_ADD_TO_WALLET_BUTTON = 'hide_add_to_wallet_button',
|
||||
UUID = 'uuid',
|
||||
SHOW_SCAM_TOKENS = 'show_scam_tokens',
|
||||
SHOW_POOR_REPUTATION_TOKENS = 'show_poor_reputation_tokens',
|
||||
APP_PROFILE = 'app_profile',
|
||||
TABLE_VIEW_ON_MOBILE = 'table_view_on_mobile',
|
||||
}
|
||||
|
||||
@@ -271,6 +271,12 @@ export default function useNavItems(): ReturnType {
|
||||
icon: 'navigation/chain_stats',
|
||||
isActive: pathname.startsWith('/stats'),
|
||||
},
|
||||
config.features.opSuperchain.isEnabled && {
|
||||
text: 'Ecosystems',
|
||||
nextRoute: { pathname: '/ecosystems' as const },
|
||||
icon: 'navigation/ecosystems',
|
||||
isActive: pathname.startsWith('/ecosystems'),
|
||||
},
|
||||
megaEthFeature.isEnabled && megaEthFeature.socketUrl.metrics && {
|
||||
text: 'Uptime',
|
||||
nextRoute: { pathname: '/uptime' as const },
|
||||
|
||||
@@ -83,6 +83,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
|
||||
'/chain/[chain_slug]/token/[hash]/instance/[id]': 'Regular page',
|
||||
'/chain/[chain_slug]/tx/[hash]': 'Regular page',
|
||||
'/chain/[chain_slug]/visualize/sol2uml': 'Regular page',
|
||||
'/ecosystems': 'Root page',
|
||||
|
||||
// service routes, added only to make typescript happy
|
||||
'/login': 'Regular page',
|
||||
|
||||
@@ -86,6 +86,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/chain/[chain_slug]/token/[hash]/instance/[id]': DEFAULT_TEMPLATE,
|
||||
'/chain/[chain_slug]/tx/[hash]': DEFAULT_TEMPLATE,
|
||||
'/chain/[chain_slug]/visualize/sol2uml': DEFAULT_TEMPLATE,
|
||||
'/ecosystems': DEFAULT_TEMPLATE,
|
||||
|
||||
// service routes, added only to make typescript happy
|
||||
'/login': DEFAULT_TEMPLATE,
|
||||
|
||||
@@ -87,6 +87,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/chain/[chain_slug]/token/[hash]/instance/[id]': '%network_name% token NFT instance',
|
||||
'/chain/[chain_slug]/tx/[hash]': '%network_name% transaction %hash% details',
|
||||
'/chain/[chain_slug]/visualize/sol2uml': '%network_name% Solidity UML diagram',
|
||||
'/ecosystems': '%network_name% ecosystems',
|
||||
|
||||
// service routes, added only to make typescript happy
|
||||
'/login': '%network_name% login',
|
||||
|
||||
@@ -81,6 +81,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
|
||||
'/chain/[chain_slug]/token/[hash]/instance/[id]': 'Chain token NFT instance',
|
||||
'/chain/[chain_slug]/tx/[hash]': 'Chain transaction details',
|
||||
'/chain/[chain_slug]/visualize/sol2uml': 'Chain Solidity UML diagram',
|
||||
'/ecosystems': 'Ecosystems',
|
||||
|
||||
// service routes, added only to make typescript happy
|
||||
'/login': 'Login',
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import multichainConfig from 'configs/multichain';
|
||||
|
||||
export default function getCurrencySymbol() {
|
||||
const nativeCurrency = multichainConfig()?.chains[0]?.app_config.chain.currency;
|
||||
return nativeCurrency?.symbol;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NFTTokenType, TokenType } from 'types/api/token';
|
||||
import type { ClusterChainConfig } from 'types/multichain';
|
||||
|
||||
import config from 'configs/app';
|
||||
|
||||
@@ -11,24 +12,38 @@ export const NFT_TOKEN_TYPES: Record<NFTTokenType, string> = {
|
||||
'ERC-404': `${ tokenStandardName }-404`,
|
||||
};
|
||||
|
||||
export const TOKEN_TYPES: Record<string, string> = {
|
||||
'ERC-20': `${ tokenStandardName }-20`,
|
||||
...additionalTokenTypes.reduce((result, item) => {
|
||||
result[item.id] = item.name;
|
||||
return result;
|
||||
}, {} as Record<string, string>),
|
||||
...NFT_TOKEN_TYPES,
|
||||
export const getTokenTypes = (nftOnly: boolean, chainConfig: Array<ClusterChainConfig['app_config']> | ClusterChainConfig['app_config'] = config) => {
|
||||
if (nftOnly) {
|
||||
return NFT_TOKEN_TYPES;
|
||||
}
|
||||
|
||||
const chainConfigs = Array.isArray(chainConfig) ? chainConfig : [ chainConfig ];
|
||||
|
||||
return {
|
||||
'ERC-20': `${ tokenStandardName }-20`,
|
||||
...chainConfigs
|
||||
.map((chainConfig) => chainConfig.chain.additionalTokenTypes)
|
||||
.flat()
|
||||
.reduce((result, item) => {
|
||||
result[item.id] = item.name;
|
||||
return result;
|
||||
}, {} as Record<string, string>),
|
||||
...NFT_TOKEN_TYPES,
|
||||
};
|
||||
};
|
||||
|
||||
export const NFT_TOKEN_TYPE_IDS: Array<NFTTokenType> = Object.keys(NFT_TOKEN_TYPES) as Array<NFTTokenType>;
|
||||
export const TOKEN_TYPE_IDS = Object.keys(TOKEN_TYPES);
|
||||
|
||||
export function getTokenTypeName(typeId: string) {
|
||||
return TOKEN_TYPES[typeId] || typeId;
|
||||
export function getTokenTypeName(typeId: string, chainConfig?: Array<ClusterChainConfig['app_config']> | ClusterChainConfig['app_config']) {
|
||||
if (typeId === 'NATIVE') {
|
||||
return 'Native token';
|
||||
}
|
||||
const tokenTypes = getTokenTypes(false, chainConfig);
|
||||
return tokenTypes[typeId as keyof typeof tokenTypes] || typeId;
|
||||
}
|
||||
|
||||
export function isFungibleTokenType(typeId: TokenType): boolean {
|
||||
return typeId === 'ERC-20' || additionalTokenTypes.some((item) => item.id === typeId);
|
||||
return typeId === 'ERC-20' || typeId === 'NATIVE' || additionalTokenTypes.some((item) => item.id === typeId);
|
||||
}
|
||||
|
||||
export function hasTokenTransferValue(typeId: TokenType) {
|
||||
|
||||
@@ -27,12 +27,16 @@ function getParams(chainConfig: typeof config): AddEthereumChainParameter {
|
||||
};
|
||||
}
|
||||
|
||||
export default function useAddChain() {
|
||||
interface Params {
|
||||
chainConfig?: typeof config;
|
||||
}
|
||||
|
||||
export default function useAddChain(params?: Params) {
|
||||
const { data: { wallet, provider } = {} } = useProvider();
|
||||
const { trackUsage } = useRewardsActivity();
|
||||
const multichainContext = useMultichainContext();
|
||||
|
||||
const chainConfig = multichainContext?.chain.app_config || config;
|
||||
const chainConfig = params?.chainConfig || multichainContext?.chain.app_config || config;
|
||||
|
||||
return React.useCallback(async() => {
|
||||
if (!wallet || !provider) {
|
||||
|
||||
@@ -14,11 +14,15 @@ function getParams(chainConfig: typeof config): { chainId: string } {
|
||||
return { chainId: getHexadecimalChainId(Number(chainConfig.chain.id)) };
|
||||
}
|
||||
|
||||
export default function useSwitchChain() {
|
||||
interface Params {
|
||||
chainConfig?: typeof config;
|
||||
}
|
||||
|
||||
export default function useSwitchChain(params?: Params) {
|
||||
const { data: { wallet, provider } = {} } = useProvider();
|
||||
const multichainContext = useMultichainContext();
|
||||
|
||||
const chainConfig = multichainContext?.chain.app_config ?? config;
|
||||
const chainConfig = params?.chainConfig || multichainContext?.chain.app_config || config;
|
||||
|
||||
return React.useCallback(() => {
|
||||
if (!wallet || !provider) {
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { get } from 'es-toolkit/compat';
|
||||
import React from 'react';
|
||||
|
||||
import type config from 'configs/app';
|
||||
import getErrorObj from 'lib/errors/getErrorObj';
|
||||
|
||||
import useAddChain from './useAddChain';
|
||||
import useProvider from './useProvider';
|
||||
import useSwitchChain from './useSwitchChain';
|
||||
|
||||
export default function useSwitchOrAddChain() {
|
||||
interface Params {
|
||||
chainConfig?: typeof config;
|
||||
}
|
||||
|
||||
export default function useSwitchOrAddChain(params?: Params) {
|
||||
const { data: { wallet, provider } = {} } = useProvider();
|
||||
const addChain = useAddChain();
|
||||
const switchChain = useSwitchChain();
|
||||
const addChain = useAddChain(params);
|
||||
const switchChain = useSwitchChain(params);
|
||||
|
||||
return React.useCallback(async() => {
|
||||
if (!wallet || !provider) {
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function middleware(req: NextRequest) {
|
||||
middlewares.colorTheme(req, res);
|
||||
middlewares.addressFormat(req, res);
|
||||
middlewares.scamTokens(req, res);
|
||||
middlewares.poorReputationTokens(req, res);
|
||||
|
||||
const end = Date.now();
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type * as multichain from '@blockscout/multichain-aggregator-types';
|
||||
|
||||
import { chainA, chainB, chainC, chainD, chainE } from './chains';
|
||||
|
||||
export const domainProtocols: Array<multichain.ProtocolInfo> = [
|
||||
{
|
||||
id: 'goose',
|
||||
short_name: 'GOOSE',
|
||||
title: 'Goose Name Service',
|
||||
description: 'The Goose Name Service is a decentralized naming system for the Goose blockchain.',
|
||||
deployment_blockscout_base_url: 'https://blockscout.com',
|
||||
tld_list: [ 'goose' ],
|
||||
icon_url: 'https://goose.com/icon.jpg',
|
||||
docs_url: 'https://goose.com/docs',
|
||||
},
|
||||
{
|
||||
id: 'duck',
|
||||
short_name: 'DUCK',
|
||||
title: 'Duck Name Service',
|
||||
description: 'The Duck Name Service is a decentralized naming system for the Duck blockchain.',
|
||||
deployment_blockscout_base_url: 'https://blockscout.com',
|
||||
tld_list: [ 'duck', 'quack' ],
|
||||
icon_url: 'https://duck.com/icon.jpg',
|
||||
docs_url: 'https://duck.com/docs',
|
||||
},
|
||||
];
|
||||
|
||||
export const addressA: multichain.GetAddressResponse = {
|
||||
hash: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
|
||||
chain_infos: {
|
||||
[chainA.id]: {
|
||||
coin_balance: '33298149965862412288',
|
||||
is_contract: false,
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
[chainB.id]: {
|
||||
coin_balance: '1836931848855642237',
|
||||
is_contract: true,
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
[chainC.id]: {
|
||||
coin_balance: '0',
|
||||
is_contract: true,
|
||||
is_verified: true,
|
||||
contract_name: 'Test Contract',
|
||||
},
|
||||
[chainD.id]: {
|
||||
coin_balance: '0',
|
||||
is_contract: false,
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
[chainE.id]: {
|
||||
coin_balance: '0',
|
||||
is_contract: false,
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
},
|
||||
has_tokens: true,
|
||||
has_interop_message_transfers: false,
|
||||
coin_balance: '35135081814718054525',
|
||||
exchange_rate: '123.455',
|
||||
domains: [
|
||||
{ name: 'ga-ga-ga.goose', protocol: domainProtocols[0] },
|
||||
{ name: 'quack-quack.duck', protocol: domainProtocols[1] },
|
||||
],
|
||||
};
|
||||
|
||||
export const addressDomainsA: multichain.LookupAddressDomainsResponse = {
|
||||
items: [
|
||||
{ name: 'ga-ga-ga.goose', protocol: domainProtocols[0] },
|
||||
{ name: 'quack-quack.duck', protocol: domainProtocols[1] },
|
||||
],
|
||||
next_page_params: undefined,
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ClusterChainConfig } from 'types/multichain';
|
||||
|
||||
export const chainA = {
|
||||
slug: 'op-mainnet',
|
||||
name: 'OP Mainnet',
|
||||
id: '420',
|
||||
logo: 'https://example.com/logo_s.png',
|
||||
explorer_url: 'https://op-mainnet.com',
|
||||
app_config: {
|
||||
app: {
|
||||
baseUrl: 'https://op-mainnet.com',
|
||||
},
|
||||
chain: {
|
||||
currency: {
|
||||
name: 'Ether',
|
||||
symbol: 'ETH',
|
||||
decimals: 18,
|
||||
},
|
||||
rpcUrls: [
|
||||
'https://rpc.op-mainnet.com',
|
||||
],
|
||||
additionalTokenTypes: [ ],
|
||||
},
|
||||
apis: {
|
||||
general: {
|
||||
host: 'localhost',
|
||||
protocol: 'http',
|
||||
port: '4003',
|
||||
endpoint: 'http://localhost:4003',
|
||||
socketEndpoint: 'ws://localhost:3200',
|
||||
},
|
||||
},
|
||||
UI: {
|
||||
views: {
|
||||
address: {},
|
||||
},
|
||||
navigation: {
|
||||
icon: {},
|
||||
},
|
||||
},
|
||||
features: {
|
||||
csvExport: {
|
||||
isEnabled: true,
|
||||
reCaptcha: { siteKey: 'xxx' },
|
||||
},
|
||||
advancedFilter: {
|
||||
isEnabled: true,
|
||||
},
|
||||
web3Wallet: {
|
||||
isEnabled: true,
|
||||
wallets: [ 'metamask' ],
|
||||
},
|
||||
rollup: {
|
||||
isEnabled: true,
|
||||
type: 'optimistic',
|
||||
stageIndex: '1',
|
||||
parentChain: {
|
||||
baseUrl: 'https://eth-mainnet.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ClusterChainConfig;
|
||||
|
||||
export const chainB = {
|
||||
...chainA,
|
||||
id: '421',
|
||||
name: 'OP Testnet',
|
||||
logo: 'https://example.com/logo_md.png',
|
||||
slug: 'op-testnet',
|
||||
explorer_url: 'https://op-testnet.com',
|
||||
app_config: {
|
||||
...chainA.app_config,
|
||||
chain: {
|
||||
...chainA.app_config?.chain,
|
||||
isTestnet: true,
|
||||
},
|
||||
app: {
|
||||
baseUrl: 'https://op-testnet.com',
|
||||
},
|
||||
apis: {
|
||||
general: {
|
||||
...chainA.app_config?.apis?.general,
|
||||
port: '4004',
|
||||
endpoint: 'http://localhost:4004',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClusterChainConfig;
|
||||
|
||||
export const chainC = {
|
||||
...chainA,
|
||||
id: '422',
|
||||
name: 'OP Devnet',
|
||||
slug: 'op-devnet',
|
||||
explorer_url: 'https://op-devnet.com',
|
||||
logo: undefined,
|
||||
app_config: {
|
||||
...chainA.app_config,
|
||||
chain: {
|
||||
...chainA.app_config?.chain,
|
||||
isTestnet: true,
|
||||
},
|
||||
app: {
|
||||
baseUrl: 'https://op-devnet.com',
|
||||
},
|
||||
apis: {
|
||||
general: {
|
||||
...chainA.app_config?.apis?.general,
|
||||
port: '4005',
|
||||
endpoint: 'http://localhost:4005',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClusterChainConfig;
|
||||
|
||||
export const chainD = {
|
||||
...chainC,
|
||||
id: '423',
|
||||
name: 'Yellow rubber duck quack-quack-quack chain',
|
||||
slug: 'yellow-rubber-duck-chain',
|
||||
};
|
||||
|
||||
export const chainE = {
|
||||
...chainC,
|
||||
id: '424',
|
||||
name: 'White goose',
|
||||
slug: 'white-goose',
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type * as multichain from '@blockscout/multichain-aggregator-types';
|
||||
|
||||
import { chainA, chainB, chainC } from './chains';
|
||||
|
||||
export const chainMetrics: multichain.ListChainMetricsResponse = {
|
||||
items: [
|
||||
{
|
||||
chain_id: chainA.id,
|
||||
active_accounts: {
|
||||
current_full_week: '1000',
|
||||
previous_full_week: '900',
|
||||
wow_diff_percent: '10',
|
||||
},
|
||||
tps: '1000',
|
||||
new_addresses: {
|
||||
current_full_week: '500',
|
||||
previous_full_week: '600',
|
||||
wow_diff_percent: '-20',
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: chainB.id,
|
||||
active_accounts: undefined,
|
||||
tps: '42',
|
||||
new_addresses: {
|
||||
current_full_week: '200',
|
||||
previous_full_week: '10',
|
||||
wow_diff_percent: '900',
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: chainC.id,
|
||||
active_accounts: {
|
||||
current_full_week: '100',
|
||||
previous_full_week: '100',
|
||||
wow_diff_percent: '0',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
import type * as multichain from '@blockscout/multichain-aggregator-types';
|
||||
import type { AddressTokenItem } from 'types/client/multichain-aggregator';
|
||||
import type { ClusterChainConfig } from 'types/multichain';
|
||||
|
||||
export const chainDataA = {
|
||||
slug: 'op-mainnet',
|
||||
name: 'OP Mainnet',
|
||||
id: '420',
|
||||
logo: 'https://example.com/logo_s.png',
|
||||
explorer_url: 'https://op-mainnet.com',
|
||||
app_config: {
|
||||
app: {
|
||||
baseUrl: 'https://op-mainnet.com',
|
||||
},
|
||||
chain: {
|
||||
currency: {
|
||||
name: 'Ether',
|
||||
symbol: 'ETH',
|
||||
decimals: 18,
|
||||
},
|
||||
rpcUrls: [
|
||||
'https://rpc.op-mainnet.com',
|
||||
],
|
||||
},
|
||||
apis: {
|
||||
general: {
|
||||
host: 'localhost',
|
||||
protocol: 'http',
|
||||
port: '4003',
|
||||
endpoint: 'http://localhost:4003',
|
||||
socketEndpoint: 'ws://localhost:3200',
|
||||
},
|
||||
},
|
||||
features: {
|
||||
csvExport: {
|
||||
isEnabled: true,
|
||||
reCaptcha: { siteKey: 'xxx' },
|
||||
},
|
||||
advancedFilter: {
|
||||
isEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClusterChainConfig;
|
||||
|
||||
export const chainDataB = {
|
||||
...chainDataA,
|
||||
id: '421',
|
||||
name: 'OP Testnet',
|
||||
logo: 'https://example.com/logo_md.png',
|
||||
slug: 'op-testnet',
|
||||
explorer_url: 'https://op-testnet.com',
|
||||
app_config: {
|
||||
...chainDataA.app_config,
|
||||
app: {
|
||||
baseUrl: 'https://op-testnet.com',
|
||||
},
|
||||
apis: {
|
||||
general: {
|
||||
...chainDataA.app_config?.apis?.general,
|
||||
port: '4004',
|
||||
endpoint: 'http://localhost:4004',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClusterChainConfig;
|
||||
|
||||
export const chainDataC = {
|
||||
...chainDataA,
|
||||
id: '422',
|
||||
name: 'OP Devnet',
|
||||
slug: 'op-devnet',
|
||||
explorer_url: 'https://op-devnet.com',
|
||||
logo: undefined,
|
||||
app_config: {
|
||||
...chainDataA.app_config,
|
||||
app: {
|
||||
baseUrl: 'https://op-devnet.com',
|
||||
},
|
||||
apis: {
|
||||
general: {
|
||||
...chainDataA.app_config?.apis?.general,
|
||||
port: '4005',
|
||||
endpoint: 'http://localhost:4005',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClusterChainConfig;
|
||||
|
||||
export const addressA: multichain.GetAddressResponse = {
|
||||
hash: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
|
||||
chain_infos: {
|
||||
'420': {
|
||||
coin_balance: '33298149965862412288',
|
||||
is_contract: false,
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
'421': {
|
||||
coin_balance: '1836931848855642237',
|
||||
is_contract: true,
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
'422': {
|
||||
coin_balance: '0',
|
||||
is_contract: true,
|
||||
is_verified: true,
|
||||
contract_name: 'Test Contract',
|
||||
},
|
||||
},
|
||||
has_tokens: true,
|
||||
has_interop_message_transfers: false,
|
||||
coin_balance: '35135081814718054525',
|
||||
exchange_rate: '123.455',
|
||||
};
|
||||
|
||||
export const tokenA: AddressTokenItem = {
|
||||
token: {
|
||||
address_hash: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0',
|
||||
circulating_market_cap: '10.01',
|
||||
decimals: '6',
|
||||
holders_count: '1250',
|
||||
icon_url: null,
|
||||
name: 'USDT',
|
||||
symbol: 'USDT',
|
||||
total_supply: '284529257047384793',
|
||||
type: 'ERC-20',
|
||||
exchange_rate: '123.456',
|
||||
chain_infos: {
|
||||
'420': {
|
||||
holders_count: '1250',
|
||||
total_supply: '184529257047384791',
|
||||
is_verified: true,
|
||||
contract_name: 'TestnetERC20',
|
||||
},
|
||||
'421': {
|
||||
holders_count: '1250',
|
||||
total_supply: '100000000000000002',
|
||||
is_verified: true,
|
||||
contract_name: 'TestnetERC20',
|
||||
},
|
||||
},
|
||||
reputation: null,
|
||||
},
|
||||
token_instance: null,
|
||||
token_id: null,
|
||||
value: '9038000000',
|
||||
chain_values: {
|
||||
'420': '1038000000',
|
||||
'421': '8000000000',
|
||||
},
|
||||
};
|
||||
|
||||
export const searchAddressesA: multichain.GetAddressResponse = {
|
||||
hash: '0x0000000002C5fE54822a1eD058AE2F937Fd42769',
|
||||
chain_infos: {
|
||||
'420': {
|
||||
coin_balance: '0',
|
||||
is_contract: false,
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
},
|
||||
has_tokens: false,
|
||||
has_interop_message_transfers: false,
|
||||
coin_balance: '0',
|
||||
exchange_rate: '123.456',
|
||||
};
|
||||
|
||||
export const searchAddressesB: multichain.GetAddressResponse = {
|
||||
hash: '0x00883b68A6EcF2ea3D47BD735E5125a0B7625B53',
|
||||
chain_infos: {
|
||||
'420': {
|
||||
coin_balance: '0',
|
||||
is_contract: true,
|
||||
is_verified: true,
|
||||
contract_name: 'USDT',
|
||||
},
|
||||
},
|
||||
has_tokens: false,
|
||||
has_interop_message_transfers: false,
|
||||
coin_balance: '0',
|
||||
exchange_rate: '123.456',
|
||||
};
|
||||
|
||||
export const searchTokenA: multichain.AggregatedTokenInfo = {
|
||||
address_hash: '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58',
|
||||
circulating_market_cap: '162513452583.22',
|
||||
decimals: '6',
|
||||
holders_count: '1148927',
|
||||
icon_url: undefined,
|
||||
name: 'Tether USD',
|
||||
symbol: 'USDT',
|
||||
total_supply: '1000000',
|
||||
type: 'ERC-20' as multichain.TokenType,
|
||||
exchange_rate: '1.00',
|
||||
chain_infos: {
|
||||
'420': {
|
||||
holders_count: '1148927',
|
||||
total_supply: '1000000',
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type * as multichain from '@blockscout/multichain-aggregator-types';
|
||||
|
||||
import { chainA, chainB, chainC } from './chains';
|
||||
|
||||
export const base: multichain.GetAddressPortfolioResponse = {
|
||||
portfolio: {
|
||||
total_value: (128147.328 + 73107.04593748 + 17675.3 + 217.17267).toFixed(4),
|
||||
chain_values: {
|
||||
[chainA.id]: (128147.328 + 73107.04593748).toFixed(4),
|
||||
[chainB.id]: 17675.3.toFixed(4),
|
||||
[chainC.id]: 217.17267.toFixed(4),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const zero: multichain.GetAddressPortfolioResponse = {
|
||||
portfolio: {
|
||||
total_value: '0.0000',
|
||||
chain_values: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type * as multichain from '@blockscout/multichain-aggregator-types';
|
||||
|
||||
import { chainA } from './chains';
|
||||
|
||||
export const searchAddressesA: multichain.GetAddressResponse = {
|
||||
hash: '0x0000000002C5fE54822a1eD058AE2F937Fd42769',
|
||||
chain_infos: {
|
||||
[chainA.id]: {
|
||||
coin_balance: '0',
|
||||
is_contract: false,
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
},
|
||||
has_tokens: false,
|
||||
has_interop_message_transfers: false,
|
||||
coin_balance: '0',
|
||||
exchange_rate: '123.456',
|
||||
domains: [],
|
||||
};
|
||||
|
||||
export const searchAddressesB: multichain.GetAddressResponse = {
|
||||
hash: '0x00883b68A6EcF2ea3D47BD735E5125a0B7625B53',
|
||||
chain_infos: {
|
||||
[chainA.id]: {
|
||||
coin_balance: '0',
|
||||
is_contract: true,
|
||||
is_verified: true,
|
||||
contract_name: 'USDT',
|
||||
},
|
||||
},
|
||||
has_tokens: false,
|
||||
has_interop_message_transfers: false,
|
||||
coin_balance: '0',
|
||||
exchange_rate: '123.456',
|
||||
domains: [],
|
||||
};
|
||||
|
||||
export const searchTokenA: multichain.AggregatedTokenInfo = {
|
||||
address_hash: '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58',
|
||||
circulating_market_cap: '162513452583.22',
|
||||
decimals: '6',
|
||||
holders_count: '1148927',
|
||||
icon_url: undefined,
|
||||
name: 'Tether USD',
|
||||
symbol: 'USDT',
|
||||
total_supply: '1000000',
|
||||
type: 'ERC-20' as multichain.TokenType,
|
||||
exchange_rate: '1.00',
|
||||
chain_infos: {
|
||||
[chainA.id]: {
|
||||
holders_count: '1148927',
|
||||
total_supply: '1000000',
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type * as multichain from '@blockscout/multichain-aggregator-types';
|
||||
import type * as stats from '@blockscout/stats-types';
|
||||
|
||||
import { averageGasPrice } from 'mocks/stats/line';
|
||||
|
||||
import { chainA, chainB, chainC } from './chains';
|
||||
|
||||
export const mainPageStats: stats.MainPageMultichainStats = {
|
||||
total_multichain_txns: {
|
||||
id: 'totalMultichainTxns',
|
||||
value: '741682908',
|
||||
title: 'Total transactions',
|
||||
units: undefined,
|
||||
description: 'Number of transactions across all chains in the cluster',
|
||||
},
|
||||
total_multichain_addresses: {
|
||||
id: 'totalMultichainAddresses',
|
||||
value: '153519638',
|
||||
title: 'Total addresses',
|
||||
units: undefined,
|
||||
description: 'Number of addresses across all chains in the cluster',
|
||||
},
|
||||
new_txns_multichain_window: {
|
||||
chart: averageGasPrice.chart,
|
||||
info: {
|
||||
id: 'newTxnsMultichainWindow',
|
||||
title: 'New transactions',
|
||||
description: 'Number of new transactions across all chains in the cluster',
|
||||
resolutions: [ 'DAY' ],
|
||||
},
|
||||
},
|
||||
yesterday_txns_multichain: {
|
||||
id: 'yesterdayTxnsMultichain',
|
||||
value: '1026175',
|
||||
title: 'Yesterday txns',
|
||||
units: undefined,
|
||||
description: 'Number of transactions yesterday (0:00 - 23:59 UTC) across all chains in the cluster',
|
||||
},
|
||||
};
|
||||
|
||||
export const chainMetrics: multichain.ListChainMetricsResponse = {
|
||||
items: [
|
||||
{
|
||||
chain_id: chainA.id,
|
||||
active_accounts: {
|
||||
current_full_week: '1000',
|
||||
previous_full_week: '900',
|
||||
wow_diff_percent: '10',
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: chainB.id,
|
||||
active_accounts: undefined,
|
||||
tps: '42',
|
||||
new_addresses: {
|
||||
current_full_week: '200',
|
||||
previous_full_week: '210',
|
||||
wow_diff_percent: '-10',
|
||||
},
|
||||
},
|
||||
{
|
||||
chain_id: chainC.id,
|
||||
active_accounts: undefined,
|
||||
tps: '3.21',
|
||||
daily_transactions: {
|
||||
current_full_week: '3343480',
|
||||
previous_full_week: '3343480',
|
||||
wow_diff_percent: '0',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { AddressTokenItem } from 'types/client/multichain-aggregator';
|
||||
|
||||
import { chainA, chainB, chainC } from './chains';
|
||||
|
||||
export const tokenAA: AddressTokenItem = {
|
||||
token: {
|
||||
address_hash: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0',
|
||||
circulating_market_cap: '10.01',
|
||||
decimals: '6',
|
||||
holders_count: '1250',
|
||||
icon_url: null,
|
||||
name: 'USDT',
|
||||
symbol: 'USDT',
|
||||
total_supply: '284529257047384793',
|
||||
type: 'ERC-20',
|
||||
exchange_rate: '123.456',
|
||||
chain_infos: {
|
||||
[chainA.id]: {
|
||||
holders_count: '1250',
|
||||
total_supply: '184529257047384791',
|
||||
is_verified: true,
|
||||
contract_name: 'TestnetERC20',
|
||||
},
|
||||
},
|
||||
reputation: null,
|
||||
},
|
||||
token_instance: null,
|
||||
token_id: null,
|
||||
value: '1038000000',
|
||||
chain_values: {
|
||||
[chainA.id]: '1038000000',
|
||||
},
|
||||
};
|
||||
|
||||
export const tokenAB: AddressTokenItem = {
|
||||
token: {
|
||||
address_hash: '0x0000000000000000000000000000000000000000',
|
||||
circulating_market_cap: '274743626781.043515906',
|
||||
decimals: '18',
|
||||
holders_count: null,
|
||||
icon_url: 'https://coin-images.coingecko.com/coins/images/279/small/ethereum.png?1696501628',
|
||||
name: 'Ether',
|
||||
symbol: 'ETH',
|
||||
total_supply: null,
|
||||
type: 'NATIVE',
|
||||
exchange_rate: '2276.37',
|
||||
chain_infos: {
|
||||
[chainA.id]: {
|
||||
holders_count: undefined,
|
||||
total_supply: undefined,
|
||||
is_verified: false,
|
||||
contract_name: undefined,
|
||||
},
|
||||
},
|
||||
reputation: null,
|
||||
},
|
||||
token_id: null,
|
||||
value: '32115625288281011210',
|
||||
chain_values: {
|
||||
[chainA.id]: '32115625288281011210',
|
||||
},
|
||||
token_instance: null,
|
||||
};
|
||||
|
||||
export const tokenBA: AddressTokenItem = {
|
||||
...tokenAA,
|
||||
token: {
|
||||
...tokenAA.token,
|
||||
exchange_rate: '420.42',
|
||||
chain_infos: {
|
||||
[chainB.id]: {
|
||||
holders_count: '5021',
|
||||
total_supply: '100000000000000002',
|
||||
is_verified: true,
|
||||
contract_name: 'TestnetERC20',
|
||||
},
|
||||
},
|
||||
},
|
||||
chain_values: {
|
||||
[chainB.id]: '42042000',
|
||||
},
|
||||
value: '42042000',
|
||||
};
|
||||
|
||||
export const tokenCA: AddressTokenItem = {
|
||||
...tokenBA,
|
||||
token: {
|
||||
...tokenBA.token,
|
||||
symbol: 'DUCK',
|
||||
name: 'Duckie Duck Duck',
|
||||
exchange_rate: '242.11',
|
||||
chain_infos: {
|
||||
[chainC.id]: {
|
||||
holders_count: '1250',
|
||||
total_supply: '100000000000000002',
|
||||
is_verified: true,
|
||||
contract_name: 'Duck Duck Goose',
|
||||
},
|
||||
},
|
||||
},
|
||||
value: '897000',
|
||||
chain_values: {
|
||||
[chainC.id]: '897000',
|
||||
},
|
||||
};
|
||||
|
||||
export const tokenDA: AddressTokenItem = {
|
||||
...tokenCA,
|
||||
token: {
|
||||
...tokenCA.token,
|
||||
symbol: 'GOOSE',
|
||||
name: 'Mega Goose',
|
||||
exchange_rate: null,
|
||||
},
|
||||
};
|
||||
@@ -2,4 +2,5 @@ export { account } from './account';
|
||||
export { default as colorTheme } from './colorTheme';
|
||||
export { default as addressFormat } from './addressFormat';
|
||||
export { default as scamTokens } from './scamTokens';
|
||||
export { default as poorReputationTokens } from './poorReputationTokens';
|
||||
export { default as appProfile } from './appProfile';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import config from 'configs/app';
|
||||
import * as cookiesLib from 'lib/cookies';
|
||||
|
||||
export default function poorReputationTokensMiddleware(req: NextRequest, res: NextResponse) {
|
||||
if (config.features.opSuperchain.isEnabled) {
|
||||
const showPoorReputationTokensCookie = req.cookies.get(cookiesLib.NAMES.SHOW_POOR_REPUTATION_TOKENS);
|
||||
|
||||
if (!showPoorReputationTokensCookie) {
|
||||
res.cookies.set(cookiesLib.NAMES.SHOW_POOR_REPUTATION_TOKENS, 'false', { path: '/' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ declare module "nextjs-routes" {
|
||||
| StaticRoute<"/csv-export">
|
||||
| StaticRoute<"/deposits">
|
||||
| StaticRoute<"/dispute-games">
|
||||
| StaticRoute<"/ecosystems">
|
||||
| DynamicRoute<"/epochs/[number]", { "number": string }>
|
||||
| StaticRoute<"/epochs">
|
||||
| DynamicRoute<"/essential-dapps/[id]", { "id": string }>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"dependencies": {
|
||||
"@blockscout/bens-types": "1.4.1",
|
||||
"@blockscout/interchain-indexer-types": "0.0.10",
|
||||
"@blockscout/multichain-aggregator-types": "1.6.3-alpha.3",
|
||||
"@blockscout/multichain-aggregator-types": "2.1.2",
|
||||
"@blockscout/points-types": "1.4.0-alpha.1",
|
||||
"@blockscout/stats-types": "2.11.1",
|
||||
"@blockscout/tac-operation-lifecycle-types": "0.0.1-alpha.6",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NextPage } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import React from 'react';
|
||||
|
||||
import PageNextJs from 'nextjs/PageNextJs';
|
||||
|
||||
const OpSuperchainEcosystems = dynamic(() => import('ui/optimismSuperchain/ecosystems/OpSuperchainEcosystems'), { ssr: false });
|
||||
|
||||
const Page: NextPage = () => {
|
||||
return (
|
||||
<PageNextJs pathname="/ecosystems">
|
||||
<OpSuperchainEcosystems/>
|
||||
</PageNextJs>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { base as getServerSideProps } from 'nextjs/getServerSideProps/main';
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { TestFixture, Page } from '@playwright/test';
|
||||
|
||||
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
|
||||
import * as chainDataMock from 'mocks/multichain/chains';
|
||||
|
||||
export type MockEssentialDappsChainsConfigFixture = () => Promise<void>;
|
||||
|
||||
const fixture: TestFixture<MockEssentialDappsChainsConfigFixture, { page: Page }> = async({ page }, use) => {
|
||||
await use(async() => {
|
||||
await page.evaluate(([ opSuperchainMock ]) => {
|
||||
await page.evaluate(([ chainDataMock ]) => {
|
||||
window.__essentialDappsChains = {
|
||||
chains: [
|
||||
opSuperchainMock.chainDataA,
|
||||
chainDataMock.chainA,
|
||||
],
|
||||
};
|
||||
}, [ opSuperchainMock ]);
|
||||
}, [ chainDataMock ]);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import type { TestFixture, Page } from '@playwright/test';
|
||||
|
||||
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
|
||||
import * as chainDataMock from 'mocks/multichain/chains';
|
||||
|
||||
export type MockMultichainConfigFixture = () => Promise<void>;
|
||||
|
||||
const fixture: TestFixture<MockMultichainConfigFixture, { page: Page }> = async({ page }, use) => {
|
||||
await use(async() => {
|
||||
await page.evaluate(([ opSuperchainMock ]) => {
|
||||
await page.evaluate(([ chainDataMock ]) => {
|
||||
window.__multichainConfig = {
|
||||
chains: [
|
||||
opSuperchainMock.chainDataA,
|
||||
opSuperchainMock.chainDataB,
|
||||
opSuperchainMock.chainDataC,
|
||||
chainDataMock.chainA,
|
||||
chainDataMock.chainB,
|
||||
chainDataMock.chainC,
|
||||
chainDataMock.chainD,
|
||||
chainDataMock.chainE,
|
||||
],
|
||||
};
|
||||
}, [ opSuperchainMock ]);
|
||||
}, [ chainDataMock ]);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
| "navigation/custom_abi"
|
||||
| "navigation/deposits"
|
||||
| "navigation/dex_tracker"
|
||||
| "navigation/ecosystems"
|
||||
| "navigation/games"
|
||||
| "navigation/gas_tracker"
|
||||
| "navigation/hot_contracts"
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 34 KiB |
@@ -17,6 +17,19 @@ export const ADDRESS: multichain.GetAddressResponse = {
|
||||
has_interop_message_transfers: false,
|
||||
coin_balance: '1000000000000000000000000',
|
||||
exchange_rate: '123.456',
|
||||
domains: [],
|
||||
};
|
||||
|
||||
export const ADDRESS_PORTFOLIO: multichain.GetAddressPortfolioResponse = {
|
||||
portfolio: {
|
||||
total_value: '370373.20',
|
||||
chain_values: {
|
||||
'420120000': '1000000000000000000000000',
|
||||
'100': '9.58848343977132465600',
|
||||
'130': '56.45557826318489510620',
|
||||
'137': '54.3384235217602434',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TOKEN: AddressTokenItem = {
|
||||
@@ -71,3 +84,23 @@ export const HOMEPAGE_STATS: stats.MainPageMultichainStats = {
|
||||
description: 'Number of transactions yesterday (0:00 - 23:59 UTC) across all chains in the cluster',
|
||||
},
|
||||
};
|
||||
|
||||
export const CHAIN_METRICS: multichain.ChainMetrics = {
|
||||
chain_id: '11155111',
|
||||
tps: '5.32',
|
||||
new_addresses: {
|
||||
current_full_week: '16450',
|
||||
previous_full_week: '156832',
|
||||
wow_diff_percent: '-89.51',
|
||||
},
|
||||
daily_transactions: {
|
||||
current_full_week: '3343480',
|
||||
previous_full_week: '4228309',
|
||||
wow_diff_percent: '-20.93',
|
||||
},
|
||||
active_accounts: {
|
||||
current_full_week: '124564',
|
||||
previous_full_week: '287092',
|
||||
wow_diff_percent: '-56.61',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -65,15 +65,20 @@ interface CollapsibleListProps<T> extends FlexProps {
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
triggerProps?: LinkProps;
|
||||
cutLength?: number;
|
||||
text?: [string, string];
|
||||
text?: [React.ReactNode, React.ReactNode];
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
|
||||
const CUT_LENGTH = 3;
|
||||
|
||||
const { items, renderItem, triggerProps, cutLength = CUT_LENGTH, text: textProp, ...rest } = props;
|
||||
const { items, renderItem, triggerProps, cutLength = CUT_LENGTH, text: textProp, defaultExpanded = false, ...rest } = props;
|
||||
|
||||
const [ isExpanded, setIsExpanded ] = React.useState(false);
|
||||
const [ isExpanded, setIsExpanded ] = React.useState(defaultExpanded);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsExpanded(defaultExpanded);
|
||||
}, [ defaultExpanded ]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
setIsExpanded((flag) => !flag);
|
||||
|
||||
@@ -100,22 +100,43 @@ export const LinkBox = ChakraLinkBox;
|
||||
export const LinkOverlay = React.forwardRef<HTMLAnchorElement, LinkProps>(
|
||||
function LinkOverlay(props, ref) {
|
||||
const { chakra, next } = splitProps(props);
|
||||
const { children, external, href, ...rest } = chakra;
|
||||
const { children, external, loading, href, noIcon, iconColor, ...rest } = chakra;
|
||||
|
||||
if (external) {
|
||||
// Strip UTM parameters from external links if in private mode
|
||||
const processedHref = typeof href === 'string' ? stripUtmParams(href) : href;
|
||||
|
||||
return (
|
||||
<ChakraLinkOverlay ref={ ref } href={ processedHref } target="_blank" rel="noopener noreferrer" { ...rest }>
|
||||
{ children }
|
||||
<ChakraLinkOverlay
|
||||
ref={ ref }
|
||||
href={ loading ? undefined : processedHref }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{ ...rest }
|
||||
>
|
||||
{ (children || (!noIcon && href)) && (
|
||||
<Skeleton display="inline-flex" alignItems="center" loading={ loading } maxW="100%" h="100%">
|
||||
{ children }
|
||||
{ !noIcon && href && <LinkExternalIcon color={ iconColor }/> }
|
||||
</Skeleton>
|
||||
) }
|
||||
</ChakraLinkOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
const content = children ? (
|
||||
<Skeleton display="inline-flex" alignItems="center" loading={ loading } maxW="100%" h="100%">
|
||||
{ children }
|
||||
</Skeleton>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ChakraLinkOverlay ref={ ref } asChild={ Boolean(next.href) } { ...rest }>
|
||||
{ next.href ? <NextLink { ...next }>{ children }</NextLink> : children }
|
||||
<ChakraLinkOverlay
|
||||
ref={ ref }
|
||||
asChild={ Boolean(next.href) }
|
||||
{ ...rest }
|
||||
>
|
||||
{ next.href ? <NextLink { ...next }>{ content }</NextLink> : content }
|
||||
</ChakraLinkOverlay>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SelectOption<Value extends string = string> {
|
||||
renderLabel?: () => React.ReactNode;
|
||||
value: Value;
|
||||
icon?: React.ReactNode;
|
||||
afterElement?: React.ReactNode;
|
||||
};
|
||||
|
||||
export interface SelectControlProps extends ChakraSelect.ControlProps {
|
||||
@@ -241,11 +242,13 @@ export interface SelectProps extends SelectRootProps {
|
||||
loading?: boolean;
|
||||
errorText?: string;
|
||||
contentProps?: SelectContentProps;
|
||||
contentHeader?: React.ReactNode;
|
||||
itemFilter?: (item: SelectOption) => boolean;
|
||||
mode?: ViewMode;
|
||||
}
|
||||
|
||||
export const Select = React.forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
|
||||
const { collection, placeholder, portalled = true, loading, errorText, contentProps, mode, ...rest } = props;
|
||||
const { collection, placeholder, portalled = true, loading, errorText, contentProps, contentHeader, itemFilter, mode, ...rest } = props;
|
||||
return (
|
||||
<SelectRoot
|
||||
ref={ ref }
|
||||
@@ -263,11 +266,17 @@ export const Select = React.forwardRef<HTMLDivElement, SelectProps>((props, ref)
|
||||
/>
|
||||
</SelectControl>
|
||||
<SelectContent portalled={ portalled } { ...contentProps }>
|
||||
{ collection.items.map((item: SelectOption) => (
|
||||
<SelectItem item={ item } key={ item.value }>
|
||||
{ item.renderLabel ? item.renderLabel() : item.label }
|
||||
</SelectItem>
|
||||
)) }
|
||||
{ contentHeader }
|
||||
{ collection.items
|
||||
.filter(itemFilter ?? (() => true))
|
||||
.map((item: SelectOption) => (
|
||||
<React.Fragment key={ item.value }>
|
||||
<SelectItem item={ item }>
|
||||
{ item.renderLabel ? item.renderLabel() : item.label }
|
||||
</SelectItem>
|
||||
{ item.afterElement }
|
||||
</React.Fragment>
|
||||
)) }
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
);
|
||||
|
||||
@@ -62,8 +62,7 @@ export const TruncatedTextTooltip = React.memo(({ children, label, placement, in
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
};
|
||||
const handleClick = React.useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const handleClick = React.useCallback(() => {
|
||||
onToggle();
|
||||
}, [ onToggle ]);
|
||||
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { defineSlotRecipe } from '@chakra-ui/react';
|
||||
|
||||
export const recipe = defineSlotRecipe({
|
||||
slots: [ 'root', 'trigger', 'indicatorGroup', 'indicator', 'content', 'item', 'control', 'itemText', 'itemGroup', 'itemGroupLabel', 'label', 'valueText' ],
|
||||
slots: [
|
||||
'root',
|
||||
'trigger',
|
||||
'indicatorGroup',
|
||||
'indicator',
|
||||
'content',
|
||||
'item',
|
||||
'control',
|
||||
'itemText',
|
||||
'itemGroup',
|
||||
'itemGroupLabel',
|
||||
'label',
|
||||
'valueText',
|
||||
'positioner',
|
||||
],
|
||||
base: {
|
||||
root: {
|
||||
display: 'flex',
|
||||
|
||||
@@ -29,7 +29,7 @@ export const recipe = defineSlotRecipe({
|
||||
},
|
||||
valueText: {
|
||||
verticalAlign: 'baseline',
|
||||
fontWeight: 'semibold',
|
||||
fontWeight: '500',
|
||||
letterSpacing: 'normal',
|
||||
fontFeatureSettings: 'initial',
|
||||
fontVariantNumeric: 'initial',
|
||||
@@ -95,7 +95,7 @@ export const recipe = defineSlotRecipe({
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
size: 'sm',
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ const PRESETS = {
|
||||
neon_devnet: 'https://neon-devnet.blockscout.com',
|
||||
numine: 'https://numine.blockscout.com',
|
||||
optimism: 'https://optimism.blockscout.com',
|
||||
optimism_interop_0: 'https://optimism-interop-alpha-0.blockscout.com',
|
||||
optimism_superchain: 'https://superchain.k8s-dev.blockscout.com',
|
||||
optimism_sepolia: 'https://optimism-sepolia.blockscout.com',
|
||||
polygon: 'https://polygon.blockscout.com',
|
||||
rari_testnet: 'https://rari-testnet.cloud.blockscout.com',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
|
||||
|
||||
import type { AddressParam } from './addressParams';
|
||||
import type { TokenInfo } from './token';
|
||||
import type { TokenInfo, TokenType } from './token';
|
||||
|
||||
export const ADVANCED_FILTER_ADDRESS_RELATION = [ 'or', 'and' ] as const;
|
||||
export type AddressRelation = typeof ADVANCED_FILTER_ADDRESS_RELATION[number];
|
||||
@@ -26,8 +24,7 @@ export type AdvancedFilterParams = {
|
||||
token_contract_symbols_to_exclude?: Array<string>;
|
||||
};
|
||||
|
||||
export const ADVANCED_FILTER_TYPES = [ 'coin_transfer', ...TOKEN_TYPE_IDS, 'contract_creation', 'contract_interaction' ] as const;
|
||||
export type AdvancedFilterType = typeof ADVANCED_FILTER_TYPES[number];
|
||||
export type AdvancedFilterType = 'coin_transfer' | 'contract_creation' | 'contract_interaction' | TokenType;
|
||||
|
||||
export const ADVANCED_FILTER_AGES = [ '1h', '24h', '7d', '1m', '3m', '6m' ] as const;
|
||||
export type AdvancedFilterAge = typeof ADVANCED_FILTER_AGES[number];
|
||||
|
||||
@@ -32,6 +32,13 @@ export interface TokensResponse extends Omit<multichain.ListClusterTokensRespons
|
||||
items: Array<AggregatedTokenInfo>;
|
||||
}
|
||||
|
||||
export interface AddressTokensFilter {
|
||||
type?: string;
|
||||
chain_id?: string;
|
||||
query?: string;
|
||||
include_poor_reputation_tokens?: boolean;
|
||||
}
|
||||
|
||||
// types for quick search results
|
||||
export type QuickSearchResultBlock = {
|
||||
type: 'block';
|
||||
@@ -89,3 +96,12 @@ QuickSearchResultTransaction |
|
||||
QuickSearchResultAddress |
|
||||
QuickSearchResultToken |
|
||||
QuickSearchResultDomain;
|
||||
|
||||
export interface ChainMetricsSorting {
|
||||
sort: 'active_accounts' | 'daily_transactions' | 'new_addresses' | 'tps';
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export type ChainMetricsSortingField = ChainMetricsSorting['sort'];
|
||||
|
||||
export type ChainMetricsSortingValue = `${ ChainMetricsSortingField }-${ ChainMetricsSorting['order'] }` | 'default';
|
||||
|
||||
@@ -2,13 +2,13 @@ import { pickBy } from 'es-toolkit';
|
||||
import React from 'react';
|
||||
|
||||
import type { AddressFromToFilter } from 'types/api/address';
|
||||
import { ADVANCED_FILTER_TYPES } from 'types/api/advancedFilter';
|
||||
import type { TokenType } from 'types/api/token';
|
||||
import type { ClusterChainConfig } from 'types/multichain';
|
||||
|
||||
import config from 'configs/app';
|
||||
import { useMultichainContext } from 'lib/contexts/multichain';
|
||||
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
|
||||
import { getAdvancedFilterTypes } from 'ui/advancedFilter/constants';
|
||||
import AdvancedFilterLink from 'ui/shared/links/AdvancedFilterLink';
|
||||
|
||||
interface Props {
|
||||
@@ -29,10 +29,14 @@ const AddressAdvancedFilterLink = ({ isLoading, address, typeFilter, directionFi
|
||||
return null;
|
||||
}
|
||||
|
||||
const advancedFilterTypes = getAdvancedFilterTypes(chainConfig);
|
||||
|
||||
const queryParams = pickBy({
|
||||
to_address_hashes_to_include: !directionFilter || directionFilter === 'to' ? [ address ] : undefined,
|
||||
from_address_hashes_to_include: !directionFilter || directionFilter === 'from' ? [ address ] : undefined,
|
||||
transaction_types: typeFilter.length > 0 ? typeFilter : ADVANCED_FILTER_TYPES.filter((type) => type !== 'coin_transfer'),
|
||||
transaction_types: typeFilter.length > 0 ?
|
||||
typeFilter :
|
||||
advancedFilterTypes.filter((type) => type.id !== 'coin_transfer').map((type) => type.id),
|
||||
}, (value) => value !== undefined);
|
||||
|
||||
const routeParams = (chainData ? { chain: chainData } : undefined) ?? multichainContext;
|
||||
|
||||
@@ -52,7 +52,7 @@ const AddressContract = ({ addressData, isLoading = false, hasMudTab, ...rest }:
|
||||
|
||||
const contractTabs = useContractTabs({
|
||||
addressData,
|
||||
isEnabled: isQueryEnabled,
|
||||
isEnabled: isQueryEnabled && !isLoading,
|
||||
hasMudTab,
|
||||
channel,
|
||||
});
|
||||
@@ -82,10 +82,6 @@ const AddressContract = ({ addressData, isLoading = false, hasMudTab, ...rest }:
|
||||
useSocketMessage({ channel, event: 'smart_contract_was_verified', handler: handleContractWasVerifiedMessage });
|
||||
useSocketMessage({ channel, event: 'smart_contract_was_not_verified', handler: handleContractWasNotVerifiedMessage });
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rightSlot = autoVerificationStatus && !contractTabs.isPartiallyVerified ?
|
||||
<ContractAutoVerificationStatus status={ autoVerificationStatus } mode={ isMobile && contractTabs.tabs.length > 1 ? 'tooltip' : 'inline' }/> :
|
||||
null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
|
||||
import type { TokenType } from 'types/api/token';
|
||||
|
||||
import { useMultichainContext } from 'lib/contexts/multichain';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
|
||||
import DataListDisplay from 'ui/shared/DataListDisplay';
|
||||
@@ -31,6 +32,7 @@ interface Props {
|
||||
const TokenTransfersLocal = ({ query, filters, addressHash, onTypeFilterChange, onAddressFilterChange, overloadCount }: Props) => {
|
||||
const { isError, isPlaceholderData, data, pagination } = query;
|
||||
const isMobile = useIsMobile();
|
||||
const multichainContext = useMultichainContext();
|
||||
|
||||
const { showSocketAlert, newItemsCount } = useAddressTokenTransfersSocket({
|
||||
filters,
|
||||
@@ -88,6 +90,7 @@ const TokenTransfersLocal = ({ query, filters, addressHash, onTypeFilterChange,
|
||||
onAddressFilterChange={ onAddressFilterChange }
|
||||
defaultAddressFilter={ filters.filter }
|
||||
isLoading={ query.isPlaceholderData }
|
||||
chainConfig={ multichainContext?.chain?.app_config }
|
||||
/>
|
||||
<AddressAdvancedFilterLink
|
||||
isLoading={ isPlaceholderData }
|
||||
|
||||
@@ -58,7 +58,7 @@ const Address3rdPartyWidgetCard = ({ name, config, address, isLoading }: Props)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LinkOverlay href={ url } external onClick={ handleClick }/>
|
||||
<LinkOverlay href={ url } external onClick={ handleClick } noIcon/>
|
||||
<Skeleton loading={ isDataLoading } minW="88px" alignSelf="flex-start">
|
||||
{ data ? (
|
||||
<Text
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Grid } from '@chakra-ui/react';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { Channel } from 'phoenix';
|
||||
@@ -9,6 +10,7 @@ import type { SmartContract } from 'types/api/contract';
|
||||
|
||||
import type { ResourceError } from 'lib/api/resources';
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import { useMultichainContext } from 'lib/contexts/multichain';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import * as stubs from 'stubs/contract';
|
||||
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
|
||||
@@ -17,6 +19,8 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
|
||||
import ContractDetailsAlerts from './alerts/ContractDetailsAlerts';
|
||||
import ContractSourceAddressSelector from './ContractSourceAddressSelector';
|
||||
import ContractDetailsInfo from './info/ContractDetailsInfo';
|
||||
import ContractDetailsInfoCreator from './info/ContractDetailsInfoCreator';
|
||||
import ContractDetailsInfoImplementations from './info/ContractDetailsInfoImplementations';
|
||||
import useContractDetailsTabs from './useContractDetailsTabs';
|
||||
|
||||
const TAB_LIST_PROPS = { flexWrap: 'wrap', rowGap: 2 };
|
||||
@@ -31,6 +35,7 @@ type Props = {
|
||||
const ContractDetails = ({ addressData, channel, mainContractQuery }: Props) => {
|
||||
const router = useRouter();
|
||||
const sourceAddress = getQueryParamString(router.query.source_address);
|
||||
const multichainContext = useMultichainContext();
|
||||
|
||||
const sourceItems: Array<AddressImplementation> = React.useMemo(() => {
|
||||
const currentAddressDefaultName = addressData?.proxy_type === 'eip7702' ? 'Current address' : 'Current contract';
|
||||
@@ -95,6 +100,24 @@ const ContractDetails = ({ addressData, channel, mainContractQuery }: Props) =>
|
||||
addressData={ addressData }
|
||||
/>
|
||||
) }
|
||||
{ !mainContractQuery.data?.is_verified && multichainContext && (
|
||||
<Grid templateColumns={{ base: 'auto 1fr', lg: 'auto 1fr auto 1fr' }} rowGap={ 4 } columnGap={ 3 } mb={ 8 } _empty={{ display: 'none' }}>
|
||||
{ addressData.creator_address_hash && addressData.creation_transaction_hash && (
|
||||
<ContractDetailsInfoCreator
|
||||
addressHash={ addressData.creator_address_hash }
|
||||
txHash={ addressData.creation_transaction_hash }
|
||||
creationStatus={ addressData.creation_status }
|
||||
isLoading={ mainContractQuery.isPlaceholderData }
|
||||
/>
|
||||
) }
|
||||
{ addressData.implementations && addressData.implementations.length > 0 && !mainContractQuery.isPlaceholderData && (
|
||||
<ContractDetailsInfoImplementations
|
||||
implementations={ addressData.implementations }
|
||||
proxyType={ addressData.proxy_type }
|
||||
/>
|
||||
) }
|
||||
</Grid>
|
||||
) }
|
||||
<RoutedTabs
|
||||
tabs={ tabs }
|
||||
isLoading={ isPlaceholderData }
|
||||
|
||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
@@ -1,4 +1,4 @@
|
||||
import { Flex, Grid, Text } from '@chakra-ui/react';
|
||||
import { Flex, Grid } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { Address } from 'types/api/address';
|
||||
@@ -9,14 +9,12 @@ import { useMultichainContext } from 'lib/contexts/multichain';
|
||||
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
|
||||
import { Link } from 'toolkit/chakra/link';
|
||||
import { getGitHubOwnerAndRepo } from 'ui/contractVerification/utils';
|
||||
import ContainerWithScrollY from 'ui/shared/ContainerWithScrollY';
|
||||
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import TxEntity from 'ui/shared/entities/tx/TxEntity';
|
||||
import ContractCreationStatus from 'ui/shared/statusTag/ContractCreationStatus';
|
||||
import Time from 'ui/shared/time/Time';
|
||||
|
||||
import ContractSecurityAudits from '../audits/ContractSecurityAudits';
|
||||
import ContractDetailsInfoCreator from './ContractDetailsInfoCreator';
|
||||
import ContractDetailsInfoImplementations from './ContractDetailsInfoImplementations';
|
||||
import ContractDetailsInfoItem from './ContractDetailsInfoItem';
|
||||
|
||||
const rollupFeature = config.features.rollup;
|
||||
@@ -74,7 +72,21 @@ const ContractDetailsInfo = ({ data, isLoading, addressData }: Props) => {
|
||||
const isStylusContract = data.language === 'stylus_rust';
|
||||
|
||||
return (
|
||||
<Grid templateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 4 } columnGap={ 6 } mb={ 8 }>
|
||||
<Grid templateColumns={{ base: 'auto 1fr', lg: 'auto 1fr auto 1fr' }} rowGap={ 4 } columnGap={ 3 } mb={ 8 }>
|
||||
{ addressData.creator_address_hash && addressData.creation_transaction_hash && multichainContext && (
|
||||
<ContractDetailsInfoCreator
|
||||
addressHash={ addressData.creator_address_hash }
|
||||
txHash={ addressData.creation_transaction_hash }
|
||||
creationStatus={ addressData.creation_status }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
) }
|
||||
{ addressData.implementations && addressData.implementations.length > 0 && multichainContext && !isLoading && (
|
||||
<ContractDetailsInfoImplementations
|
||||
implementations={ addressData.implementations }
|
||||
proxyType={ addressData.proxy_type }
|
||||
/>
|
||||
) }
|
||||
{ data.name && (
|
||||
<ContractDetailsInfoItem
|
||||
label="Contract name"
|
||||
@@ -83,46 +95,6 @@ const ContractDetailsInfo = ({ data, isLoading, addressData }: Props) => {
|
||||
{ contractNameWithCertifiedIcon }
|
||||
</ContractDetailsInfoItem>
|
||||
) }
|
||||
{ multichainContext && addressData.creator_address_hash && addressData.creation_transaction_hash && (
|
||||
<ContractDetailsInfoItem
|
||||
label="Creator"
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
<Flex alignItems="center" flexWrap="wrap">
|
||||
<AddressEntity
|
||||
address={{ hash: addressData.creator_address_hash }}
|
||||
truncation="constant"
|
||||
noIcon
|
||||
/>
|
||||
<Text whiteSpace="pre" color="text.secondary"> at txn </Text>
|
||||
<TxEntity hash={ addressData.creation_transaction_hash } truncation="constant" noIcon/>
|
||||
{ addressData.creation_status && <ContractCreationStatus status={ addressData.creation_status } ml={{ base: 0, lg: 2 }}/> }
|
||||
</Flex>
|
||||
</ContractDetailsInfoItem>
|
||||
) }
|
||||
{ !isLoading && multichainContext && addressData.implementations && addressData.implementations.length > 0 && (
|
||||
<ContractDetailsInfoItem
|
||||
label={ `${ addressData.proxy_type === 'eip7702' ? 'Delegated to' : `Implementation${ addressData.implementations.length > 1 ? 's' : '' }` }` }
|
||||
isLoading={ isLoading }
|
||||
contentProps={{ maxW: 'calc(100% - 194px)', position: 'relative' }}
|
||||
>
|
||||
<ContainerWithScrollY gradientHeight={ 48 } maxH="200px">
|
||||
{ addressData.implementations.map((item) => (
|
||||
<AddressEntity
|
||||
key={ item.address_hash }
|
||||
address={{
|
||||
hash: item.address_hash,
|
||||
filecoin: { robust: item.filecoin_robust_address },
|
||||
name: item.name,
|
||||
is_contract: true,
|
||||
}}
|
||||
isLoading={ isLoading }
|
||||
noIcon
|
||||
/>
|
||||
)) }
|
||||
</ContainerWithScrollY>
|
||||
</ContractDetailsInfoItem>
|
||||
) }
|
||||
{ data.compiler_version && (
|
||||
<ContractDetailsInfoItem
|
||||
label="Compiler version"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Text, Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { SmartContractCreationStatus } from 'types/api/contract';
|
||||
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import TxEntity from 'ui/shared/entities/tx/TxEntity';
|
||||
import ContractCreationStatus from 'ui/shared/statusTag/ContractCreationStatus';
|
||||
|
||||
import ContractDetailsInfoItem from './ContractDetailsInfoItem';
|
||||
|
||||
interface Props {
|
||||
addressHash: string;
|
||||
txHash: string;
|
||||
creationStatus: SmartContractCreationStatus | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ContractDetailsInfoCreator = ({ addressHash, txHash, creationStatus, isLoading }: Props) => {
|
||||
return (
|
||||
<ContractDetailsInfoItem
|
||||
label="Creator"
|
||||
isLoading={ isLoading }
|
||||
contentProps={{ gridColumn: { lg: '2 / span 3' } }}
|
||||
>
|
||||
<Flex alignItems="center" flexWrap="wrap" columnGap={ 2 } rowGap={ 2 }>
|
||||
<AddressEntity
|
||||
address={{ hash: addressHash }}
|
||||
truncation="constant"
|
||||
noIcon
|
||||
/>
|
||||
<Text whiteSpace="pre" color="text.secondary">at txn</Text>
|
||||
<TxEntity hash={ txHash } truncation="constant" noIcon/>
|
||||
{ creationStatus && <ContractCreationStatus status={ creationStatus }/> }
|
||||
</Flex>
|
||||
</ContractDetailsInfoItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ContractDetailsInfoCreator);
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { AddressImplementation } from 'types/api/addressParams';
|
||||
import type { SmartContractProxyType } from 'types/api/contract';
|
||||
|
||||
import ContainerWithScrollY from 'ui/shared/ContainerWithScrollY';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
|
||||
import ContractDetailsInfoItem from './ContractDetailsInfoItem';
|
||||
|
||||
interface Props {
|
||||
implementations: Array<AddressImplementation>;
|
||||
proxyType?: SmartContractProxyType;
|
||||
}
|
||||
|
||||
const ContractDetailsInfoImplementations = ({ implementations, proxyType }: Props) => {
|
||||
return (
|
||||
<ContractDetailsInfoItem
|
||||
label={ `${ proxyType === 'eip7702' ? 'Delegated to' : `Implementation${ implementations.length > 1 ? 's' : '' }` }` }
|
||||
contentProps={{ gridColumn: { lg: '2 / span 3' }, position: 'relative' }}
|
||||
>
|
||||
<ContainerWithScrollY gradientHeight={ 48 } maxH="200px" w="100%">
|
||||
{ implementations.map((item) => (
|
||||
<AddressEntity
|
||||
key={ item.address_hash }
|
||||
address={{
|
||||
hash: item.address_hash,
|
||||
filecoin: { robust: item.filecoin_robust_address },
|
||||
name: item.name,
|
||||
is_contract: true,
|
||||
}}
|
||||
noIcon
|
||||
/>
|
||||
)) }
|
||||
</ContainerWithScrollY>
|
||||
</ContractDetailsInfoItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ContractDetailsInfoImplementations);
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BoxProps } from '@chakra-ui/react';
|
||||
import { chakra, Flex, GridItem } from '@chakra-ui/react';
|
||||
import { chakra, Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Skeleton } from 'toolkit/chakra/skeleton';
|
||||
@@ -8,23 +8,22 @@ import { Hint } from 'toolkit/components/Hint/Hint';
|
||||
interface Props {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isLoading: boolean;
|
||||
isLoading?: boolean;
|
||||
hint?: string;
|
||||
contentProps?: BoxProps;
|
||||
}
|
||||
|
||||
const ContractDetailsInfoItem = ({ label, children, className, isLoading, hint, contentProps }: Props) => {
|
||||
const ContractDetailsInfoItem = ({ label, children, isLoading, hint, contentProps }: Props) => {
|
||||
return (
|
||||
<GridItem display="flex" columnGap={ 6 } wordBreak="break-all" className={ className } alignItems="baseline" maxW="100%" overflow="hidden">
|
||||
<Skeleton loading={ isLoading } w="170px" flexShrink={ 0 } fontWeight={ 500 }>
|
||||
<>
|
||||
<Skeleton loading={ isLoading } flexShrink={ 0 } fontWeight={ 500 }>
|
||||
<Flex alignItems="center">
|
||||
{ label }
|
||||
{ hint && <Hint label={ hint } ml={ 2 }/> }
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
<Skeleton loading={ isLoading } { ...contentProps }>{ children }</Skeleton>
|
||||
</GridItem>
|
||||
<Skeleton loading={ isLoading } wordBreak="break-all" maxW="100%" overflow="hidden" { ...contentProps }>{ children }</Skeleton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -42,7 +42,7 @@ export default function useContractTabs({ addressData, isEnabled, hasMudTab, cha
|
||||
const contractQuery = useApiQuery('general:contract', {
|
||||
pathParams: { hash: addressData?.hash },
|
||||
queryOptions: {
|
||||
enabled: isEnabled,
|
||||
enabled: isEnabled && addressData?.is_contract,
|
||||
refetchOnMount: false,
|
||||
placeholderData: addressData?.is_verified ? stubs.CONTRACT_CODE_VERIFIED : stubs.CONTRACT_CODE_UNVERIFIED,
|
||||
},
|
||||
@@ -52,7 +52,7 @@ export default function useContractTabs({ addressData, isEnabled, hasMudTab, cha
|
||||
const mudSystemsQuery = useApiQuery('general:mud_systems', {
|
||||
pathParams: { hash: addressData?.hash },
|
||||
queryOptions: {
|
||||
enabled: Boolean(isEnabled && hasMudTab),
|
||||
enabled: Boolean(isEnabled && hasMudTab && addressData?.is_contract),
|
||||
refetchOnMount: false,
|
||||
placeholderData: stubs.MUD_SYSTEMS,
|
||||
},
|
||||
@@ -63,6 +63,21 @@ export default function useContractTabs({ addressData, isEnabled, hasMudTab, cha
|
||||
}, [ addressData?.hash, addressData?.implementations ]);
|
||||
|
||||
return React.useMemo(() => {
|
||||
|
||||
if (!addressData?.is_contract) {
|
||||
return {
|
||||
tabs: [
|
||||
{
|
||||
id: 'contract_code' as const,
|
||||
title: 'Code',
|
||||
component: <p>This address is not a contract on this chain.</p>,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isPartiallyVerified: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: [
|
||||
addressData && {
|
||||
|
||||
@@ -18,8 +18,8 @@ type Props = {
|
||||
item: MultichainProviderConfigParsed;
|
||||
addressHash: string;
|
||||
onClick?: () => void;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
const AddressMultichainButton = ({ item, addressHash, onClick, isFirst, isLast }: Props) => {
|
||||
|
||||
@@ -136,7 +136,7 @@ const AddressEnsDomains = ({ query, addressHash, mainDomainName }: Props) => {
|
||||
</Link>
|
||||
) }
|
||||
{ mainDomain && (
|
||||
<chakra.span fontSize="xs" mt={ -1 }>
|
||||
<chakra.span textStyle="xs" mt={ -1 }>
|
||||
*A domain name is not necessarily held by a person popularly associated with the name
|
||||
</chakra.span>
|
||||
) }
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -21,9 +21,10 @@ interface Props {
|
||||
enabled?: boolean;
|
||||
addressHash: string;
|
||||
isMultichain?: boolean;
|
||||
chainIds?: Array<string>;
|
||||
}
|
||||
|
||||
export default function useAddressNftQuery({ scrollRef, enabled = true, addressHash, isMultichain }: Props) {
|
||||
export default function useAddressNftQuery({ scrollRef, enabled = true, addressHash, isMultichain, chainIds }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const displayTypeCookie = cookies.get(cookies.NAMES.ADDRESS_NFT_DISPLAY_TYPE, useAppContext().cookies);
|
||||
@@ -40,6 +41,7 @@ export default function useAddressNftQuery({ scrollRef, enabled = true, addressH
|
||||
},
|
||||
filters: { type: tokenTypes },
|
||||
isMultichain,
|
||||
chainIds,
|
||||
});
|
||||
|
||||
const nftsQuery = useQueryWithPages({
|
||||
@@ -52,6 +54,7 @@ export default function useAddressNftQuery({ scrollRef, enabled = true, addressH
|
||||
},
|
||||
filters: { type: tokenTypes },
|
||||
isMultichain,
|
||||
chainIds,
|
||||
});
|
||||
|
||||
const onDisplayTypeChange = React.useCallback((val: string) => {
|
||||
|
||||
@@ -5,9 +5,11 @@ import type { AddressFromToFilter } from 'types/api/address';
|
||||
import { AddressFromToFilterValues } from 'types/api/address';
|
||||
import type { TokenType } from 'types/api/token';
|
||||
|
||||
import multichainConfig from 'configs/multichain';
|
||||
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
|
||||
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
|
||||
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import { getTokenTypes } from 'lib/token/tokenTypes';
|
||||
import { getTokenTransfersStub } from 'stubs/token';
|
||||
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
|
||||
|
||||
@@ -16,9 +18,25 @@ export type Filters = {
|
||||
filter: AddressFromToFilter | undefined;
|
||||
};
|
||||
|
||||
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS);
|
||||
const getAddressFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
|
||||
|
||||
const getFilters = (query: Record<string, string | Array<string> | undefined>, chainIds?: Array<string>) => {
|
||||
const chainIdParam = getQueryParamString(query.chain_id);
|
||||
const filterParam = getQueryParamString(query.filter);
|
||||
const typeParam = getQueryParamString(query.type);
|
||||
|
||||
const currentChainId = chainIdParam && chainIds?.includes(chainIdParam) ? chainIdParam : chainIds?.[0];
|
||||
const chainConfig = multichainConfig()?.chains.find(chain => chain.id === currentChainId);
|
||||
|
||||
return {
|
||||
filter: getAddressFilterValue(filterParam),
|
||||
type: getFilterValuesFromQuery(
|
||||
Object.keys(getTokenTypes(false, chainConfig?.app_config)),
|
||||
typeParam,
|
||||
) || [],
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {
|
||||
currentAddress: string;
|
||||
enabled?: boolean;
|
||||
@@ -29,12 +47,14 @@ interface Props {
|
||||
export default function useAddressTokenTransfersQuery({ currentAddress, enabled, isMultichain, chainIds }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [ filters, setFilters ] = React.useState<Filters>(
|
||||
{
|
||||
type: getTokenFilterValue(router.query.type) || [],
|
||||
filter: getAddressFilterValue(router.query.filter),
|
||||
},
|
||||
);
|
||||
const [ filters, setFilters ] = React.useState<Filters>(getFilters(router.query, chainIds));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enabled) {
|
||||
setFilters(getFilters(router.query, chainIds));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ enabled ]);
|
||||
|
||||
const query = useQueryWithPages({
|
||||
resourceName: 'general:address_token_transfers',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import BigNumber from 'bignumber.js';
|
||||
import { mapValues } from 'es-toolkit';
|
||||
|
||||
import type { AddressTokenBalance } from 'types/api/address';
|
||||
|
||||
@@ -143,17 +142,4 @@ export const getTokensTotalInfo = (data: TokenSelectData): TokensTotalInfo => {
|
||||
return { usd, num, isOverflow };
|
||||
};
|
||||
|
||||
export const getTokensTotalInfoByChain = (data: TokenSelectData, chainIds: Array<string>) => {
|
||||
return chainIds.reduce((result, chainId) => {
|
||||
const filteredData = mapValues(data, (item) => ({
|
||||
...item,
|
||||
items: item.items.filter((item) => item.chain_values?.[chainId]),
|
||||
}));
|
||||
|
||||
result[chainId] = getTokensTotalInfo(filteredData);
|
||||
|
||||
return result;
|
||||
}, {} as Record<string, TokensTotalInfo>);
|
||||
};
|
||||
|
||||
const usdValueReducer = (result: BigNumber, item: TokenEnhancedData) => !item.usd ? result : result.plus(BigNumber(item.usd));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { AdvancedFilterResponseItem } from 'types/api/advancedFilter';
|
||||
import type { ClusterChainConfig } from 'types/multichain';
|
||||
|
||||
import config from 'configs/app';
|
||||
import { isConfidentialTokenType } from 'lib/token/tokenTypes';
|
||||
@@ -17,20 +18,21 @@ import AssetValue from 'ui/shared/value/AssetValue';
|
||||
import ConfidentialValue from 'ui/shared/value/ConfidentialValue';
|
||||
import NativeCoinValue from 'ui/shared/value/NativeCoinValue';
|
||||
|
||||
import { ADVANCED_FILTER_TYPES } from './constants';
|
||||
import { getAdvancedFilterTypes } from './constants';
|
||||
|
||||
type Props = {
|
||||
item: AdvancedFilterResponseItem;
|
||||
column: ColumnsIds;
|
||||
isLoading?: boolean;
|
||||
chainConfig?: ClusterChainConfig['app_config'];
|
||||
};
|
||||
|
||||
const ItemByColumn = ({ item, column, isLoading }: Props) => {
|
||||
const ItemByColumn = ({ item, column, isLoading, chainConfig }: Props) => {
|
||||
switch (column) {
|
||||
case 'tx_hash':
|
||||
return <TxEntity truncation="constant" hash={ item.hash } isLoading={ isLoading } noIcon fontWeight={ 700 }/>;
|
||||
case 'type': {
|
||||
const type = ADVANCED_FILTER_TYPES.find(t => t.id === item.type);
|
||||
const type = getAdvancedFilterTypes(chainConfig).find(t => t.id === item.type);
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { TokenInfo } from 'types/api/token';
|
||||
import type { ClusterChainConfig } from 'types/multichain';
|
||||
|
||||
import config from 'configs/app';
|
||||
import { TOKEN_TYPE_IDS, TOKEN_TYPES } from 'lib/token/tokenTypes';
|
||||
import { getTokenTypes } from 'lib/token/tokenTypes';
|
||||
|
||||
export type ColumnsIds = 'tx_hash' | 'type' | 'method' | 'age' | 'from' | 'or_and' | 'to' | 'amount' | 'asset' | 'fee';
|
||||
|
||||
@@ -67,32 +68,31 @@ export const TABLE_COLUMNS: Array<TxTableColumn> = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const ADVANCED_FILTER_TYPES = [
|
||||
{
|
||||
id: 'coin_transfer',
|
||||
name: 'Coin Transfer',
|
||||
},
|
||||
...TOKEN_TYPE_IDS.map(id => ({
|
||||
id,
|
||||
name: `${ TOKEN_TYPES[id] } Transfer`,
|
||||
})),
|
||||
{
|
||||
id: 'contract_creation',
|
||||
name: 'Contract Creation',
|
||||
},
|
||||
{
|
||||
id: 'contract_interaction',
|
||||
name: 'Contract Interaction',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const ADVANCED_FILTER_TYPES_WITH_ALL = [
|
||||
{
|
||||
id: 'all',
|
||||
name: 'All',
|
||||
},
|
||||
...ADVANCED_FILTER_TYPES,
|
||||
];
|
||||
export const getAdvancedFilterTypes = (chainConfig?: Array<ClusterChainConfig['app_config']> | ClusterChainConfig['app_config'], withAll = false) => {
|
||||
return [
|
||||
...(withAll ? [ {
|
||||
id: 'all',
|
||||
name: 'All',
|
||||
} ] : []),
|
||||
{
|
||||
id: 'coin_transfer',
|
||||
name: 'Coin Transfer',
|
||||
},
|
||||
...Object.entries(getTokenTypes(false, chainConfig))
|
||||
.map(([ id, name ]) => ({
|
||||
id,
|
||||
name: `${ name } Transfer`,
|
||||
})),
|
||||
{
|
||||
id: 'contract_creation',
|
||||
name: 'Contract Creation',
|
||||
},
|
||||
{
|
||||
id: 'contract_interaction',
|
||||
name: 'Contract Interaction',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const NATIVE_TOKEN = {
|
||||
name: config.chain.currency.name || '',
|
||||
|
||||
@@ -4,10 +4,11 @@ import React from 'react';
|
||||
|
||||
import type { AdvancedFilterParams, AdvancedFilterType } from 'types/api/advancedFilter';
|
||||
|
||||
import { useMultichainContext } from 'lib/contexts/multichain';
|
||||
import { Checkbox, CheckboxGroup } from 'toolkit/chakra/checkbox';
|
||||
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
|
||||
|
||||
import { ADVANCED_FILTER_TYPES_WITH_ALL } from '../constants';
|
||||
import { getAdvancedFilterTypes } from '../constants';
|
||||
|
||||
const RESET_VALUE = 'all';
|
||||
|
||||
@@ -21,6 +22,8 @@ type Props = {
|
||||
const TypeFilter = ({ value = [ RESET_VALUE ], handleFilterChange }: Props) => {
|
||||
const [ currentValue, setCurrentValue ] = React.useState<Array<AdvancedFilterType | typeof RESET_VALUE>>([ ...value ]);
|
||||
|
||||
const multichainContext = useMultichainContext();
|
||||
|
||||
const handleChange = React.useCallback((value: Array<string>) => {
|
||||
setCurrentValue((prev) => {
|
||||
if (value.length === 0) {
|
||||
@@ -43,6 +46,10 @@ const TypeFilter = ({ value = [ RESET_VALUE ], handleFilterChange }: Props) => {
|
||||
handleFilterChange(FILTER_PARAM, value);
|
||||
}, [ handleFilterChange, currentValue ]);
|
||||
|
||||
const advancedFilterTypes = React.useMemo(() => {
|
||||
return getAdvancedFilterTypes(multichainContext?.chain?.app_config, true);
|
||||
}, [ multichainContext?.chain?.app_config ]);
|
||||
|
||||
return (
|
||||
<TableColumnFilter
|
||||
title="Transaction type"
|
||||
@@ -54,7 +61,7 @@ const TypeFilter = ({ value = [ RESET_VALUE ], handleFilterChange }: Props) => {
|
||||
>
|
||||
<Flex display="flex" flexDir="column" rowGap={ 3 }>
|
||||
<CheckboxGroup value={ currentValue } onValueChange={ handleChange } orientation="vertical">
|
||||
{ ADVANCED_FILTER_TYPES_WITH_ALL.map(type => (
|
||||
{ advancedFilterTypes.map(type => (
|
||||
<Checkbox
|
||||
key={ type.id }
|
||||
value={ type.id }
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { castArray } from 'es-toolkit/compat';
|
||||
|
||||
import type { AdvancedFilterAge, AdvancedFilterParams } from 'types/api/advancedFilter';
|
||||
import type { ClusterChainConfig } from 'types/multichain';
|
||||
|
||||
import dayjs from 'lib/date/dayjs';
|
||||
import { HOUR, DAY, MONTH } from 'toolkit/utils/consts';
|
||||
|
||||
import { ADVANCED_FILTER_TYPES } from './constants';
|
||||
import { getAdvancedFilterTypes } from './constants';
|
||||
|
||||
export function getDurationFromAge(age: AdvancedFilterAge) {
|
||||
switch (age) {
|
||||
@@ -55,7 +56,7 @@ const filterParamNames: Record<keyof AdvancedFilterParams, string> = {
|
||||
transaction_types: 'Type',
|
||||
};
|
||||
|
||||
export function getFilterTags(filters: AdvancedFilterParams) {
|
||||
export function getFilterTags(filters: AdvancedFilterParams, chainConfig?: ClusterChainConfig['app_config']) {
|
||||
const filtersToShow = { ...filters };
|
||||
if (filtersToShow.age) {
|
||||
filtersToShow.age_from = undefined;
|
||||
@@ -77,7 +78,8 @@ export function getFilterTags(filters: AdvancedFilterParams) {
|
||||
break;
|
||||
}
|
||||
case 'transaction_types': {
|
||||
valueStr = castArray(value).map(i => ADVANCED_FILTER_TYPES.find(t => t.id === i)?.name).filter(Boolean).join(', ');
|
||||
const advancedFilterTypes = getAdvancedFilterTypes(chainConfig);
|
||||
valueStr = castArray(value).map(i => advancedFilterTypes.find(t => t.id === i)?.name).filter(Boolean).join(', ');
|
||||
break;
|
||||
}
|
||||
case 'token_contract_address_hashes_to_exclude': {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TimeChartData, TimeChartDataItem, TimeChartItemRaw, TimeChartItem
|
||||
import type { ChainIndicatorId } from 'types/homepage';
|
||||
|
||||
import config from 'configs/app';
|
||||
import getCurrencySymbol from 'lib/multichain/getCurrencySymbol';
|
||||
import { sortByDateDesc } from 'ui/shared/chart/utils';
|
||||
|
||||
const CHART_ITEMS: Record<ChainIndicatorId, Pick<TimeChartDataItem, 'name' | 'valueFormatter'>> = {
|
||||
@@ -15,7 +14,7 @@ const CHART_ITEMS: Record<ChainIndicatorId, Pick<TimeChartDataItem, 'name' | 'va
|
||||
valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }),
|
||||
},
|
||||
coin_price: {
|
||||
name: `${ getCurrencySymbol() || config.chain.currency.symbol } price`,
|
||||
name: `${ config.features.opSuperchain.isEnabled ? 'ETH' : config.chain.currency.symbol } price`,
|
||||
valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
|
||||
},
|
||||
secondary_coin_price: {
|
||||
|
||||
@@ -27,6 +27,7 @@ const MarketplaceAppCardLink = ({ url, external, id, title, onClick, className }
|
||||
className={ className }
|
||||
external={ external }
|
||||
onClick={ handleClick }
|
||||
noIcon
|
||||
>
|
||||
{ title }
|
||||
</LinkOverlay>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
|
||||
import * as chainDataMock from 'mocks/multichain/chains';
|
||||
import { test, expect } from 'playwright/lib';
|
||||
|
||||
import Revoke from './Revoke';
|
||||
|
||||
const ESSENTIAL_DAPPS_CONFIG = JSON.stringify({
|
||||
revoke: { chains: [ opSuperchainMock.chainDataA.id ] },
|
||||
revoke: { chains: [ chainDataMock.chainA.id ] },
|
||||
});
|
||||
|
||||
test('base view +@dark-mode +@mobile', async({ render, mockEnvs, mockEssentialDappsChainsConfig, mockAssetResponse }) => {
|
||||
@@ -15,7 +15,7 @@ test('base view +@dark-mode +@mobile', async({ render, mockEnvs, mockEssentialDa
|
||||
[ 'NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG', ESSENTIAL_DAPPS_CONFIG ],
|
||||
]);
|
||||
await mockEssentialDappsChainsConfig();
|
||||
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
|
||||
const component = await render(<Revoke/>);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { hash as addressHash } from 'mocks/address/address';
|
||||
import * as revokeMock from 'mocks/essentialDapps/revoke';
|
||||
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
|
||||
import * as chainDataMock from 'mocks/multichain/chains';
|
||||
import type { TestFnArgs } from 'playwright/lib';
|
||||
import { test, expect } from 'playwright/lib';
|
||||
|
||||
@@ -11,7 +11,7 @@ import Content from './Content';
|
||||
|
||||
test('base view +@dark-mode +@mobile', async({ render, mockAssetResponse }: TestFnArgs) => {
|
||||
const props = {
|
||||
selectedChain: opSuperchainMock.chainDataA,
|
||||
selectedChain: chainDataMock.chainA,
|
||||
searchAddress: addressHash,
|
||||
isAddressMatch: false,
|
||||
coinBalanceQuery: {
|
||||
@@ -23,7 +23,7 @@ test('base view +@dark-mode +@mobile', async({ render, mockAssetResponse }: Test
|
||||
};
|
||||
|
||||
await mockAssetResponse(revokeMock.allowances[0].tokenIcon as string, './playwright/mocks/image_s.jpg');
|
||||
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_md.jpg');
|
||||
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_md.jpg');
|
||||
|
||||
const component = await render(<Content { ...props }/>);
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
|
||||
import * as addressMock from 'mocks/multichain/address';
|
||||
import * as chainDataMock from 'mocks/multichain/chains';
|
||||
import * as portfolioMock from 'mocks/multichain/portfolio';
|
||||
import * as tokensMock from 'mocks/multichain/tokens';
|
||||
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
|
||||
import { test, expect } from 'playwright/lib';
|
||||
import * as pwConfig from 'playwright/utils/config';
|
||||
|
||||
import OpSuperchainAddress from './OpSuperchainAddress';
|
||||
|
||||
const CURRENT_ADDRESS = opSuperchainMock.addressA.hash;
|
||||
const CURRENT_ADDRESS = addressMock.addressA.hash;
|
||||
|
||||
const hooksConfig = {
|
||||
router: {
|
||||
@@ -21,26 +24,21 @@ test('base view', async({ mockApiResponse, render, page, mockMultichainConfig, m
|
||||
await mockEnvs(ENVS_MAP.opSuperchain);
|
||||
await mockTextAd();
|
||||
|
||||
await mockApiResponse('multichainAggregator:address', opSuperchainMock.addressA, { pathParams: { hash: CURRENT_ADDRESS } });
|
||||
await mockApiResponse('multichainAggregator:address', addressMock.addressA, { pathParams: { hash: CURRENT_ADDRESS } });
|
||||
await mockApiResponse('multichainAggregator:address_domains', addressMock.addressDomainsA, { pathParams: { hash: CURRENT_ADDRESS } });
|
||||
await mockApiResponse('multichainAggregator:address_portfolio', portfolioMock.base, {
|
||||
pathParams: { hash: CURRENT_ADDRESS },
|
||||
queryParams: { include_poor_reputation_tokens: false },
|
||||
});
|
||||
await mockApiResponse('multichainAggregator:address_tokens', {
|
||||
items: [ opSuperchainMock.tokenA ],
|
||||
next_page_params: undefined,
|
||||
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-20' } });
|
||||
await mockApiResponse('multichainAggregator:address_tokens', {
|
||||
items: [ ],
|
||||
next_page_params: undefined,
|
||||
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-721' } });
|
||||
await mockApiResponse('multichainAggregator:address_tokens', {
|
||||
items: [ ],
|
||||
next_page_params: undefined,
|
||||
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-1155' } });
|
||||
await mockApiResponse('multichainAggregator:address_tokens', {
|
||||
items: [ ],
|
||||
next_page_params: undefined,
|
||||
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-404' } });
|
||||
items: [ tokensMock.tokenAA, tokensMock.tokenAB, tokensMock.tokenBA, tokensMock.tokenCA, tokensMock.tokenDA ],
|
||||
next_page_params: { page_token: '1', page_size: 10 },
|
||||
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-20,NATIVE', include_poor_reputation_tokens: false } });
|
||||
|
||||
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
await mockAssetResponse(opSuperchainMock.chainDataB.logo as string, './playwright/mocks/image_md.jpg');
|
||||
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
await mockAssetResponse(chainDataMock.chainB.logo as string, './playwright/mocks/image_md.jpg');
|
||||
await mockAssetResponse(addressMock.domainProtocols[0].icon_url as string, './playwright/mocks/goose.png');
|
||||
await mockAssetResponse(addressMock.domainProtocols[1].icon_url as string, './playwright/mocks/duck.png');
|
||||
|
||||
const component = await render(
|
||||
<OpSuperchainAddress/>,
|
||||
@@ -51,10 +49,4 @@ test('base view', async({ mockApiResponse, render, page, mockMultichainConfig, m
|
||||
mask: [ page.locator(pwConfig.adsBannerSelector) ],
|
||||
maskColor: pwConfig.maskColor,
|
||||
});
|
||||
|
||||
await component.getByText('By chain').nth(0).click();
|
||||
await expect(page.locator('div[data-scope="popover"][data-part="content"]').nth(0)).toHaveScreenshot();
|
||||
|
||||
await component.getByText('By chain').nth(1).click();
|
||||
await expect(page.locator('div[data-scope="popover"][data-part="content"]').nth(0)).toHaveScreenshot();
|
||||
});
|
||||
|
||||
@@ -16,17 +16,20 @@ import AddressQrCode from 'ui/address/details/AddressQrCode';
|
||||
import ClusterChainsPopover from 'ui/optimismSuperchain/components/ClusterChainsPopover';
|
||||
import TextAd from 'ui/shared/ad/TextAd';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
|
||||
import PageTitle from 'ui/shared/Page/PageTitle';
|
||||
|
||||
import OpSuperchainAddressEnsDomains from './header/OpSuperchainAddressEnsDomains';
|
||||
import OpSuperchainAddressCoinBalanceHistory from './OpSuperchainAddressCoinBalanceHistory';
|
||||
import OpSuperchainAddressContract from './OpSuperchainAddressContract';
|
||||
import OpSuperchainAddressDetails from './OpSuperchainAddressDetails';
|
||||
import OpSuperchainAddressInternalTxs from './OpSuperchainAddressInternalTxs';
|
||||
import OpSuperchainAddressLogs from './OpSuperchainAddressLogs';
|
||||
import OpSuperchainAddressTokens, { ADDRESS_OP_SUPERCHAIN_TOKENS_TAB_IDS } from './OpSuperchainAddressTokens';
|
||||
import OpSuperchainAddressPortfolio from './OpSuperchainAddressPortfolio';
|
||||
import OpSuperchainAddressTokenTransfers, { ADDRESS_OP_SUPERCHAIN_TOKEN_TRANSFERS_TAB_IDS } from './OpSuperchainAddressTokenTransfers';
|
||||
import OpSuperchainAddressTxs, { ADDRESS_OP_SUPERCHAIN_TXS_TAB_IDS } from './OpSuperchainAddressTxs';
|
||||
|
||||
const TABS_PRESERVED_PARAMS = [ 'chain_id' ];
|
||||
|
||||
const OpSuperchainAddress = () => {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -57,9 +60,9 @@ const OpSuperchainAddress = () => {
|
||||
const tabs: Array<TabItemRegular> = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'index',
|
||||
title: 'Details',
|
||||
component: <OpSuperchainAddressDetails addressHash={ checkSummedHash } data={ addressQuery.data } isLoading={ isLoading }/>,
|
||||
id: 'portfolio',
|
||||
title: 'Portfolio',
|
||||
component: <OpSuperchainAddressPortfolio addressData={ addressQuery.data } isLoading={ isLoading }/>,
|
||||
},
|
||||
isContractSomewhere && {
|
||||
id: 'contract',
|
||||
@@ -79,12 +82,6 @@ const OpSuperchainAddress = () => {
|
||||
component: <OpSuperchainAddressTokenTransfers addressData={ addressQuery.data } isLoading={ isLoading }/>,
|
||||
subTabs: ADDRESS_OP_SUPERCHAIN_TOKEN_TRANSFERS_TAB_IDS,
|
||||
},
|
||||
addressQuery.data?.has_tokens && {
|
||||
id: 'tokens',
|
||||
title: 'Tokens',
|
||||
component: isLoading ? null : <OpSuperchainAddressTokens addressData={ addressQuery.data }/>,
|
||||
subTabs: ADDRESS_OP_SUPERCHAIN_TOKENS_TAB_IDS,
|
||||
},
|
||||
{
|
||||
id: 'internal_txs',
|
||||
title: 'Internal txns',
|
||||
@@ -105,6 +102,17 @@ const OpSuperchainAddress = () => {
|
||||
|
||||
const titleSecondRow = (
|
||||
<Flex alignItems="center" w="100%" columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
|
||||
{ addressQuery.data?.domains?.[0] && (
|
||||
<EnsEntity
|
||||
domain={ addressQuery.data?.domains[0].name }
|
||||
protocol={ addressQuery.data?.domains[0].protocol }
|
||||
isLoading={ isLoading }
|
||||
variant="subheading"
|
||||
noLink
|
||||
mr={ 1 }
|
||||
maxW="300px"
|
||||
/>
|
||||
) }
|
||||
<AddressEntity
|
||||
address={{
|
||||
...addressQuery.data,
|
||||
@@ -124,6 +132,11 @@ const OpSuperchainAddress = () => {
|
||||
/>
|
||||
<AddressQrCode hash={ checkSummedHash } isLoading={ isLoading }/>
|
||||
<Box ml="auto"/>
|
||||
<OpSuperchainAddressEnsDomains
|
||||
mainDomain={ addressQuery.data?.domains?.[0] }
|
||||
isLoading={ isLoading }
|
||||
hash={ checkSummedHash }
|
||||
/>
|
||||
<ClusterChainsPopover addressHash={ checkSummedHash } data={ addressQuery.data } isLoading={ isLoading }/>
|
||||
</Flex>
|
||||
);
|
||||
@@ -136,7 +149,7 @@ const OpSuperchainAddress = () => {
|
||||
isLoading={ isLoading }
|
||||
secondRow={ titleSecondRow }
|
||||
/>
|
||||
<RoutedTabs tabs={ tabs } isLoading={ isLoading }/>
|
||||
<RoutedTabs tabs={ tabs } isLoading={ isLoading } preservedParams={ TABS_PRESERVED_PARAMS }/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,16 +6,18 @@ import multichainConfig from 'configs/multichain';
|
||||
import getSocketUrl from 'lib/api/getSocketUrl';
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import { MultichainProvider } from 'lib/contexts/multichain';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import useRoutedChainSelect from 'lib/multichain/useRoutedChainSelect';
|
||||
import { SocketProvider } from 'lib/socket/context';
|
||||
import { ADDRESS_INFO } from 'stubs/address';
|
||||
import AddressContract from 'ui/address/AddressContract';
|
||||
import ChainSelect from 'ui/optimismSuperchain/components/ChainSelect';
|
||||
|
||||
import getContractChainIds from './getContractChainIds';
|
||||
import getAvailableChainIds from './getAvailableChainIds';
|
||||
|
||||
const LEFT_SLOT_PROPS = {
|
||||
mr: 6,
|
||||
flexShrink: 0,
|
||||
};
|
||||
const QUERY_PRESERVED_PARAMS = [ 'tab', 'hash' ];
|
||||
|
||||
@@ -26,8 +28,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const OpSuperchainAddressContract = ({ addressHash, isLoading, data }: Props) => {
|
||||
const chainIds = React.useMemo(() => getContractChainIds(data), [ data ]);
|
||||
const chainIds = React.useMemo(() => getAvailableChainIds(data), [ data ]);
|
||||
const chainSelect = useRoutedChainSelect({ persistedParams: QUERY_PRESERVED_PARAMS, isLoading, chainIds });
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const chain = React.useMemo(() => {
|
||||
return multichainConfig()?.chains.find(({ id }) => id === chainSelect.value?.[0]);
|
||||
@@ -48,11 +51,12 @@ const OpSuperchainAddressContract = ({ addressHash, isLoading, data }: Props) =>
|
||||
onValueChange={ chainSelect.onValueChange }
|
||||
loading={ isLoading }
|
||||
chainIds={ chainIds }
|
||||
mode={ isMobile ? 'compact' : 'default' }
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<MultichainProvider chainId={ chainSelect.value?.[0] }>
|
||||
<MultichainProvider key={ chainSelect.value?.[0] } chainId={ chainSelect.value?.[0] }>
|
||||
<SocketProvider url={ getSocketUrl(chain?.app_config) }>
|
||||
<AddressContract
|
||||
addressData={ addressQuery.data }
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import type * as multichain from '@blockscout/multichain-aggregator-types';
|
||||
|
||||
import { route } from 'nextjs/routes';
|
||||
|
||||
import multichainConfig from 'configs/multichain';
|
||||
import getCurrencySymbol from 'lib/multichain/getCurrencySymbol';
|
||||
import { Link } from 'toolkit/chakra/link';
|
||||
import { Skeleton } from 'toolkit/chakra/skeleton';
|
||||
import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo';
|
||||
import DetailedInfoSponsoredItem from 'ui/shared/DetailedInfo/DetailedInfoSponsoredItem';
|
||||
import ChainIcon from 'ui/shared/externalChains/ChainIcon';
|
||||
|
||||
import OpSuperchainAddressCoinBalance from './details/OpSuperchainAddressCoinBalance';
|
||||
import OpSuperchainAddressContractName from './details/OpSuperchainAddressContractName';
|
||||
import OpSuperchainAddressNetWorth from './details/OpSuperchainAddressNetWorth';
|
||||
import OpSuperchainTokenSelect from './tokens/OpSuperchainTokenSelect';
|
||||
import useFetchTokens from './tokens/useFetchTokens';
|
||||
|
||||
interface Props {
|
||||
data: multichain.GetAddressResponse | undefined;
|
||||
addressHash: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const OpSuperchainAddressDetails = ({ data, addressHash, isLoading }: Props) => {
|
||||
const chains = multichainConfig()?.chains;
|
||||
const activeChainsIds = Object.keys(data?.chain_infos ?? {});
|
||||
const activeChains = chains?.filter((chain) => activeChainsIds.includes(String(chain.id))) ?? [];
|
||||
|
||||
const currencySymbol = getCurrencySymbol();
|
||||
|
||||
const tokensInfo = useFetchTokens({ hash: addressHash, enabled: data?.has_tokens && !isLoading });
|
||||
|
||||
if (!data && !isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isContract = Object.values(data?.chain_infos ?? {}).some((chainInfo) => chainInfo.is_contract);
|
||||
|
||||
return (
|
||||
<DetailedInfo.Container templateColumns={{ base: 'minmax(0, 1fr)', lg: 'auto minmax(0, 1fr)' }} >
|
||||
{ (isLoading || activeChains.length > 0) && (
|
||||
<>
|
||||
<DetailedInfo.ItemLabel
|
||||
hint="Chains this address has interacted with"
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
Chain{ activeChains.length > 1 ? 's' : '' }
|
||||
</DetailedInfo.ItemLabel>
|
||||
<DetailedInfo.ItemValue columnGap={ 3 } multiRow>
|
||||
{ activeChains.map((chain) => (
|
||||
<Link
|
||||
key={ chain.id }
|
||||
href={ route({
|
||||
pathname: '/address/[hash]',
|
||||
query: {
|
||||
hash: addressHash,
|
||||
utm_source: 'multichain-explorer',
|
||||
utm_medium: 'address',
|
||||
},
|
||||
}, { chain, external: true }) }
|
||||
external
|
||||
loading={ isLoading }
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
color="text.primary"
|
||||
_hover={{ color: 'link.primary.hover' }}
|
||||
>
|
||||
<ChainIcon data={ chain } mr={ 2 }/>
|
||||
<span>{ chain.name }</span>
|
||||
</Link>
|
||||
)) }
|
||||
</DetailedInfo.ItemValue>
|
||||
</>
|
||||
) }
|
||||
|
||||
{ isContract && (
|
||||
<>
|
||||
<DetailedInfo.ItemLabel
|
||||
hint="The name found in the source code of the Contract"
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
Contract name
|
||||
</DetailedInfo.ItemLabel>
|
||||
<DetailedInfo.ItemValue>
|
||||
<OpSuperchainAddressContractName data={ data } isLoading={ isLoading }/>
|
||||
</DetailedInfo.ItemValue>
|
||||
</>
|
||||
) }
|
||||
|
||||
<DetailedInfo.ItemLabel
|
||||
hint={ `${ currencySymbol } balance` }
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
{ currencySymbol } balance
|
||||
</DetailedInfo.ItemLabel>
|
||||
<DetailedInfo.ItemValue>
|
||||
<OpSuperchainAddressCoinBalance data={ data } isLoading={ isLoading }/>
|
||||
</DetailedInfo.ItemValue>
|
||||
|
||||
{ data?.has_tokens && (
|
||||
<>
|
||||
<DetailedInfo.ItemLabel
|
||||
hint="All tokens in the account and total value"
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
Tokens
|
||||
</DetailedInfo.ItemLabel>
|
||||
<DetailedInfo.ItemValue>
|
||||
<OpSuperchainTokenSelect isLoading={ isLoading || tokensInfo.isPending } isError={ tokensInfo.isError } data={ tokensInfo.data }/>
|
||||
</DetailedInfo.ItemValue>
|
||||
</>
|
||||
) }
|
||||
|
||||
<DetailedInfo.ItemLabel
|
||||
hint="Total net worth in USD of native coin and all tokens for the address"
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
Net worth
|
||||
</DetailedInfo.ItemLabel>
|
||||
<DetailedInfo.ItemValue>
|
||||
<OpSuperchainAddressNetWorth
|
||||
addressData={ data }
|
||||
tokensData={ tokensInfo.data }
|
||||
isLoading={ isLoading || Boolean(tokensInfo.isPending && data?.has_tokens) }
|
||||
isError={ tokensInfo.isError }
|
||||
/>
|
||||
</DetailedInfo.ItemValue>
|
||||
|
||||
<DetailedInfo.ItemLabel
|
||||
hint="Number of transactions related to this address"
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
Transactions
|
||||
</DetailedInfo.ItemLabel>
|
||||
<DetailedInfo.ItemValue whiteSpace="pre-wrap">
|
||||
<Skeleton loading={ isLoading }>
|
||||
<Link href={ route({ pathname: '/address/[hash]', query: { hash: addressHash, tab: 'txs_local' } }) }>View by chain</Link>
|
||||
</Skeleton>
|
||||
</DetailedInfo.ItemValue>
|
||||
|
||||
<DetailedInfo.ItemLabel
|
||||
hint="Number of transfers to/from this address"
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
Transfers
|
||||
</DetailedInfo.ItemLabel>
|
||||
<DetailedInfo.ItemValue whiteSpace="pre-wrap">
|
||||
<Skeleton loading={ isLoading }>
|
||||
<Link href={ route({ pathname: '/address/[hash]', query: { hash: addressHash, tab: 'token_transfers_local' } }) }>View by chain</Link>
|
||||
</Skeleton>
|
||||
</DetailedInfo.ItemValue>
|
||||
|
||||
<DetailedInfoSponsoredItem isLoading={ isLoading }/>
|
||||
|
||||
</DetailedInfo.Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(OpSuperchainAddressDetails);
|
||||
@@ -5,6 +5,7 @@ import type * as multichain from '@blockscout/multichain-aggregator-types';
|
||||
|
||||
import multichainConfig from 'configs/multichain';
|
||||
import { MultichainProvider } from 'lib/contexts/multichain';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import { LOG } from 'stubs/log';
|
||||
import { generateListStub } from 'stubs/utils';
|
||||
@@ -26,6 +27,7 @@ interface Props {
|
||||
const OpSuperchainAddressLogs = ({ addressData, isLoading }: Props) => {
|
||||
const router = useRouter();
|
||||
const chainIds = React.useMemo(() => getAvailableChainIds(addressData), [ addressData ]);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const hash = getQueryParamString(router.query.hash);
|
||||
const { data, isPlaceholderData, isError, pagination, chainValue, onChainValueChange } = useQueryWithPages({
|
||||
@@ -50,20 +52,23 @@ const OpSuperchainAddressLogs = ({ addressData, isLoading }: Props) => {
|
||||
}, [ chainValue ]);
|
||||
|
||||
const actionBar = (
|
||||
<ActionBar mt={ -6 } showShadow>
|
||||
<ActionBar mt={ -6 }>
|
||||
<ChainSelect
|
||||
value={ chainValue }
|
||||
onValueChange={ onChainValueChange }
|
||||
chainIds={ chainIds }
|
||||
loading={ isLoading }
|
||||
mode={ isMobile ? 'compact' : 'default' }
|
||||
/>
|
||||
<AddressCsvExportLink
|
||||
address={ hash }
|
||||
isLoading={ pagination.isLoading }
|
||||
params={{ type: 'logs' }}
|
||||
ml={{ base: 2, lg: 'auto' }}
|
||||
chainData={ chainData }
|
||||
/>
|
||||
{ (data?.items.length ?? 0) > 0 && (
|
||||
<AddressCsvExportLink
|
||||
address={ hash }
|
||||
isLoading={ pagination.isLoading }
|
||||
params={{ type: 'logs' }}
|
||||
ml={{ base: 2, lg: 'auto' }}
|
||||
chainData={ chainData }
|
||||
/>
|
||||
) }
|
||||
<Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/>
|
||||
</ActionBar>
|
||||
);
|
||||
@@ -84,6 +89,7 @@ const OpSuperchainAddressLogs = ({ addressData, isLoading }: Props) => {
|
||||
itemsNum={ data?.items?.length }
|
||||
emptyText="There are no logs for this address."
|
||||
showActionBarIfEmpty
|
||||
showActionBarIfError
|
||||
actionBar={ actionBar }
|
||||
>
|
||||
<MultichainProvider chainId={ chainValue?.[0] }>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import type { Locator } from '@playwright/test';
|
||||
import React from 'react';
|
||||
|
||||
import * as tokensMock from 'mocks/address/tokens';
|
||||
import * as addressMock from 'mocks/multichain/address';
|
||||
import * as chainDataMock from 'mocks/multichain/chains';
|
||||
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
|
||||
import { test, expect } from 'playwright/lib';
|
||||
import * as pwConfig from 'playwright/utils/config';
|
||||
|
||||
import OpSuperchainAddressPortfolio from './OpSuperchainAddressPortfolio';
|
||||
|
||||
const CURRENT_ADDRESS = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859';
|
||||
|
||||
test.beforeEach(async({ mockMultichainConfig, mockEnvs }) => {
|
||||
await mockMultichainConfig();
|
||||
await mockEnvs(ENVS_MAP.opSuperchain);
|
||||
});
|
||||
|
||||
test.describe('nfts', () => {
|
||||
const hooksConfig = {
|
||||
router: {
|
||||
query: { hash: CURRENT_ADDRESS, tab: 'portfolio_nfts' },
|
||||
},
|
||||
};
|
||||
let component: Locator;
|
||||
|
||||
test.beforeEach(async({ render, mockApiResponse, mockAssetResponse }) => {
|
||||
await mockApiResponse(
|
||||
'general:address_collections',
|
||||
{ ...tokensMock.collections, items: [ tokensMock.collections.items[2] ] },
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: [] }, chainConfig: chainDataMock.chainA },
|
||||
);
|
||||
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
|
||||
component = await render(
|
||||
<Box pt={{ base: 0, lg: '30px' }}>
|
||||
<OpSuperchainAddressPortfolio addressData={ addressMock.addressA } isLoading={ false }/>
|
||||
</Box>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
});
|
||||
|
||||
test('base view', async() => {
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test.describe('mobile', () => {
|
||||
test.use({ viewport: pwConfig.viewport.mobile });
|
||||
|
||||
test('base view', async() => {
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,24 +8,19 @@ import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
|
||||
import { MultichainProvider } from 'lib/contexts/multichain';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import { TOKEN } from 'stubs/optimismSuperchain';
|
||||
import { generateListStub } from 'stubs/utils';
|
||||
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
|
||||
import AddressCollections from 'ui/address/tokens/AddressCollections';
|
||||
import AddressNftDisplayTypeRadio from 'ui/address/tokens/AddressNftDisplayTypeRadio';
|
||||
import AddressNFTs from 'ui/address/tokens/AddressNFTs';
|
||||
import AddressNftTypeFilter from 'ui/address/tokens/AddressNftTypeFilter';
|
||||
import ERC20Tokens from 'ui/address/tokens/ERC20Tokens';
|
||||
import useAddressNftQuery from 'ui/address/tokens/useAddressNftQuery';
|
||||
import ChainSelect from 'ui/optimismSuperchain/components/ChainSelect';
|
||||
import Pagination from 'ui/shared/pagination/Pagination';
|
||||
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
|
||||
|
||||
import getAvailableChainIds from './getAvailableChainIds';
|
||||
import OpSuperchainTokenBalances from './tokens/OpSuperchainTokenBalances';
|
||||
import useChainSelectErc20 from './useChainSelectErc20';
|
||||
import OpSuperchainAddressPortfolioTokens from './portfolio/OpSuperchainAddressPortfolioTokens';
|
||||
|
||||
export const ADDRESS_OP_SUPERCHAIN_TOKENS_TAB_IDS = [ 'tokens_erc20' as const, 'tokens_nfts' as const ];
|
||||
export const ADDRESS_OP_PORTFOLIO_TAB_IDS = [ 'portfolio_tokens' as const, 'portfolio_nfts' as const ];
|
||||
const TABS_RIGHT_SLOT_PROPS = {
|
||||
display: 'flex',
|
||||
justifyContent: { base: 'flex-end', lg: 'flex-start' },
|
||||
@@ -35,53 +30,42 @@ const TABS_RIGHT_SLOT_PROPS = {
|
||||
const TAB_LIST_PROPS = {
|
||||
marginBottom: 0,
|
||||
pt: 6,
|
||||
pb: 3,
|
||||
pb: 2,
|
||||
marginTop: -6,
|
||||
};
|
||||
const TABS_PRESERVED_PARAMS = [ 'chain_id' ];
|
||||
|
||||
interface Props {
|
||||
addressData: multichain.GetAddressResponse | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const OpSuperchainAddressTokens = ({ addressData }: Props) => {
|
||||
const chainIds = React.useMemo(() => getAvailableChainIds(addressData), [ addressData ]);
|
||||
|
||||
const OpSuperchainAddressPortfolio = ({ addressData, isLoading }: Props) => {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const router = useRouter();
|
||||
|
||||
const tab = getQueryParamString(router.query.tab) as typeof ADDRESS_OP_SUPERCHAIN_TOKENS_TAB_IDS[number] | 'tokens' | undefined;
|
||||
const tab = getQueryParamString(router.query.tab) as typeof ADDRESS_OP_PORTFOLIO_TAB_IDS[number] | 'portfolio' | undefined;
|
||||
const hash = getQueryParamString(router.query.hash);
|
||||
const [ chainSelectErc20Value, setChainSelectErc20Value ] = useChainSelectErc20({ chainIds });
|
||||
|
||||
const chainId = chainSelectErc20Value?.filter(Boolean);
|
||||
|
||||
const erc20Query = useQueryWithPages({
|
||||
resourceName: 'multichainAggregator:address_tokens',
|
||||
pathParams: { hash },
|
||||
filters: chainId?.length ? { type: 'ERC-20', chain_id: chainId.includes('all') ? undefined : chainId.filter(Boolean) } : { type: 'ERC-20' },
|
||||
scrollRef,
|
||||
options: {
|
||||
enabled: (tab === 'tokens' || tab === 'tokens_erc20'),
|
||||
placeholderData: generateListStub<'multichainAggregator:address_tokens'>(TOKEN, 10, { next_page_params: undefined }),
|
||||
},
|
||||
});
|
||||
const chainIds = React.useMemo(() => getAvailableChainIds(addressData), [ addressData ]);
|
||||
|
||||
const { nftsQuery, collectionsQuery, displayType: nftDisplayType, tokenTypes: nftTokenTypes, onDisplayTypeChange, onTokenTypesChange } = useAddressNftQuery({
|
||||
scrollRef,
|
||||
enabled: tab === 'tokens_nfts',
|
||||
enabled: !isLoading && tab === 'portfolio_nfts' && chainIds.length > 0,
|
||||
addressHash: hash,
|
||||
isMultichain: true,
|
||||
chainIds,
|
||||
});
|
||||
|
||||
const handelChainSelectErc20ValueChange = React.useCallback(({ value }: { value: Array<string> }) => {
|
||||
erc20Query.onFilterChange({ chain_id: value.includes('all') ? undefined : value.filter(Boolean) });
|
||||
setChainSelectErc20Value(value);
|
||||
}, [ setChainSelectErc20Value, erc20Query ]);
|
||||
const handleChainChange = React.useCallback((chainId: string | null) => {
|
||||
const chainValue = chainId ? [ chainId ] : [ chainIds[0] ];
|
||||
nftsQuery.onChainValueChange({ value: chainValue });
|
||||
collectionsQuery.onChainValueChange({ value: chainValue });
|
||||
}, [ nftsQuery, collectionsQuery, chainIds ]);
|
||||
|
||||
const hasActiveFilters = (() => {
|
||||
if (tab === 'tokens_nfts') {
|
||||
if (tab === 'portfolio_nfts') {
|
||||
return Boolean(nftTokenTypes?.length);
|
||||
}
|
||||
|
||||
@@ -89,13 +73,14 @@ const OpSuperchainAddressTokens = ({ addressData }: Props) => {
|
||||
})();
|
||||
|
||||
const rightSlot = (() => {
|
||||
if (tab === 'tokens_nfts') {
|
||||
if (tab === 'portfolio_nfts') {
|
||||
const query = nftDisplayType === 'list' ? nftsQuery : collectionsQuery;
|
||||
const chainSelect = (
|
||||
<ChainSelect
|
||||
loading={ query.pagination.isLoading }
|
||||
value={ query.chainValue }
|
||||
onValueChange={ query.onChainValueChange }
|
||||
chainIds={ chainIds }
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -117,37 +102,16 @@ const OpSuperchainAddressTokens = ({ addressData }: Props) => {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChainSelect
|
||||
loading={ erc20Query.pagination.isLoading }
|
||||
value={ chainSelectErc20Value }
|
||||
onValueChange={ handelChainSelectErc20ValueChange }
|
||||
chainIds={ chainIds }
|
||||
withAllOption
|
||||
/>
|
||||
{ erc20Query.pagination.isVisible && !isMobile && <Pagination { ...erc20Query.pagination } ml="auto"/> }
|
||||
</>
|
||||
);
|
||||
})();
|
||||
|
||||
const tabs: Array<TabItemRegular> = [
|
||||
{
|
||||
id: 'tokens_erc20',
|
||||
title: 'ERC-20',
|
||||
component: (
|
||||
<ERC20Tokens
|
||||
items={ erc20Query.data?.items }
|
||||
isLoading={ erc20Query.isPlaceholderData }
|
||||
pagination={ erc20Query.pagination }
|
||||
isError={ erc20Query.isError }
|
||||
top={ erc20Query.pagination.isVisible ? 68 : 0 }
|
||||
/>
|
||||
),
|
||||
id: 'portfolio_tokens',
|
||||
title: 'Tokens',
|
||||
component: <OpSuperchainAddressPortfolioTokens addressData={ addressData } isLoading={ isLoading } onChainChange={ handleChainChange }/>,
|
||||
},
|
||||
{
|
||||
id: 'tokens_nfts',
|
||||
id: 'portfolio_nfts',
|
||||
title: 'NFT',
|
||||
component: nftDisplayType === 'list' ? (
|
||||
<MultichainProvider chainId={ nftsQuery.chainValue?.[0] }>
|
||||
@@ -163,7 +127,6 @@ const OpSuperchainAddressTokens = ({ addressData }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpSuperchainTokenBalances/>
|
||||
<Box ref={ scrollRef }/>
|
||||
<RoutedTabs
|
||||
variant="secondary"
|
||||
@@ -173,9 +136,10 @@ const OpSuperchainAddressTokens = ({ addressData }: Props) => {
|
||||
rightSlotProps={ TABS_RIGHT_SLOT_PROPS }
|
||||
listProps={ isMobile ? undefined : TAB_LIST_PROPS }
|
||||
stickyEnabled={ !isMobile }
|
||||
preservedParams={ TABS_PRESERVED_PARAMS }
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(OpSuperchainAddressTokens);
|
||||
export default React.memo(OpSuperchainAddressPortfolio);
|
||||
@@ -3,7 +3,8 @@ import type { Locator } from '@playwright/test';
|
||||
import React from 'react';
|
||||
|
||||
import * as countersMock from 'mocks/address/counters';
|
||||
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
|
||||
import * as addressMock from 'mocks/multichain/address';
|
||||
import * as chainDataMock from 'mocks/multichain/chains';
|
||||
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
|
||||
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
|
||||
import { test, expect } from 'playwright/lib';
|
||||
@@ -29,18 +30,18 @@ test.describe('local transfers', () => {
|
||||
await mockApiResponse(
|
||||
'general:address_token_transfers',
|
||||
{ items: [ tokenTransferMock.erc20 ], next_page_params: DEFAULT_PAGINATION },
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: [] }, chainConfig: opSuperchainMock.chainDataA },
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: [] }, chainConfig: chainDataMock.chainA },
|
||||
);
|
||||
await mockApiResponse(
|
||||
'general:address_counters',
|
||||
countersMock.forAddress,
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: opSuperchainMock.chainDataA },
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: chainDataMock.chainA },
|
||||
);
|
||||
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
|
||||
component = await render(
|
||||
<Box pt={{ base: '134px', lg: 6 }}>
|
||||
<OpSuperchainAddressTokenTransfers addressData={ opSuperchainMock.addressA } isLoading={ false }/>
|
||||
<OpSuperchainAddressTokenTransfers addressData={ addressMock.addressA } isLoading={ false }/>
|
||||
</Box>,
|
||||
{ hooksConfig },
|
||||
{ withSocket: true },
|
||||
|
||||
@@ -21,6 +21,7 @@ import useAddressCountersQuery from 'ui/address/utils/useAddressCountersQuery';
|
||||
import ChainSelect from 'ui/optimismSuperchain/components/ChainSelect';
|
||||
import Pagination from 'ui/shared/pagination/Pagination';
|
||||
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
|
||||
import { getTokenFilterValue } from 'ui/tokens/utils';
|
||||
|
||||
import ListCounterText from '../components/ListCounterText';
|
||||
import getAvailableChainIds from './getAvailableChainIds';
|
||||
@@ -71,6 +72,18 @@ const OpSuperchainAddressTokenTransfers = ({ addressData, isLoading }: Props) =>
|
||||
chain: chainData,
|
||||
});
|
||||
|
||||
const handleChainValueChange = React.useCallback(({ value }: { value: Array<string> }) => {
|
||||
transfersQueryLocal.query.onChainValueChange({ value });
|
||||
const chainConfig = multichainConfig()?.chains.find(chain => chain.id === value[0]);
|
||||
const tokenTypes = getTokenFilterValue(router.query.type, chainConfig?.app_config);
|
||||
if (tokenTypes) {
|
||||
const chainsTokenTypes = transfersQueryLocal.filters.type.filter(type => tokenTypes.includes(type));
|
||||
if (chainsTokenTypes.length < transfersQueryLocal.filters.type.length) {
|
||||
transfersQueryLocal.onTypeFilterChange(chainsTokenTypes);
|
||||
}
|
||||
}
|
||||
}, [ transfersQueryLocal, router.query.type ]);
|
||||
|
||||
const countersText = (() => {
|
||||
if (isLocalTab) {
|
||||
return (
|
||||
@@ -96,7 +109,7 @@ const OpSuperchainAddressTokenTransfers = ({ addressData, isLoading }: Props) =>
|
||||
<ChainSelect
|
||||
loading={ transfersQueryLocal.query.pagination.isLoading }
|
||||
value={ transfersQueryLocal.query.chainValue }
|
||||
onValueChange={ transfersQueryLocal.query.onChainValueChange }
|
||||
onValueChange={ handleChainValueChange }
|
||||
chainIds={ chainIds }
|
||||
/>
|
||||
);
|
||||
@@ -111,11 +124,12 @@ const OpSuperchainAddressTokenTransfers = ({ addressData, isLoading }: Props) =>
|
||||
<TokenTransferFilter
|
||||
defaultTypeFilters={ transfersQueryLocal.filters.type }
|
||||
onTypeFilterChange={ transfersQueryLocal.onTypeFilterChange }
|
||||
appliedFiltersNum={ transfersQueryLocal.filters.type.length }
|
||||
appliedFiltersNum={ transfersQueryLocal.filters.type.length + (transfersQueryLocal.filters.filter ? 1 : 0) }
|
||||
withAddressFilter
|
||||
onAddressFilterChange={ transfersQueryLocal.onAddressFilterChange }
|
||||
defaultAddressFilter={ transfersQueryLocal.filters.filter }
|
||||
isLoading={ transfersQueryLocal.query.isPlaceholderData }
|
||||
chainConfig={ chainData?.app_config }
|
||||
/>
|
||||
{ chainSelect }
|
||||
{ countersText }
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
import React from 'react';
|
||||
|
||||
import * as tokensMock from 'mocks/address/tokens';
|
||||
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
|
||||
import * as statsMock from 'mocks/stats';
|
||||
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
|
||||
import { test, expect } from 'playwright/lib';
|
||||
import * as pwConfig from 'playwright/utils/config';
|
||||
|
||||
import OpSuperchainAddressTokens from './OpSuperchainAddressTokens';
|
||||
|
||||
const CURRENT_ADDRESS = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859';
|
||||
|
||||
test.beforeEach(async({ mockApiResponse, mockMultichainConfig, mockEnvs, page }) => {
|
||||
await mockApiResponse('multichainAggregator:address', opSuperchainMock.addressA, { pathParams: { hash: CURRENT_ADDRESS } });
|
||||
await mockApiResponse('multichainAggregator:address_tokens', {
|
||||
items: [ opSuperchainMock.tokenA ],
|
||||
next_page_params: undefined,
|
||||
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-20' } });
|
||||
await mockApiResponse('multichainAggregator:address_tokens', {
|
||||
items: [ ],
|
||||
next_page_params: undefined,
|
||||
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-721' } });
|
||||
await mockApiResponse('multichainAggregator:address_tokens', {
|
||||
items: [ ],
|
||||
next_page_params: undefined,
|
||||
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-1155' } });
|
||||
await mockApiResponse('multichainAggregator:address_tokens', {
|
||||
items: [ ],
|
||||
next_page_params: undefined,
|
||||
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-404' } });
|
||||
await page.route('https://eth.blockscout.com/api/v2/stats', (route) => route.fulfill({
|
||||
status: 200,
|
||||
json: { ...statsMock.base, coin_image: null },
|
||||
}));
|
||||
await mockMultichainConfig();
|
||||
await mockEnvs(ENVS_MAP.opSuperchain);
|
||||
});
|
||||
|
||||
test.describe('tokens', () => {
|
||||
const hooksConfig = {
|
||||
router: {
|
||||
query: { hash: CURRENT_ADDRESS, tab: 'tokens_erc20' },
|
||||
},
|
||||
};
|
||||
let component: Locator;
|
||||
|
||||
test.beforeEach(async({ render, mockAssetResponse }) => {
|
||||
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
await mockAssetResponse(opSuperchainMock.chainDataB.logo as string, './playwright/mocks/image_md.jpg');
|
||||
|
||||
component = await render(
|
||||
<OpSuperchainAddressTokens addressData={ opSuperchainMock.addressA }/>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
});
|
||||
|
||||
test('base view', async({ page }) => {
|
||||
await expect(component).toHaveScreenshot();
|
||||
|
||||
await component.getByText('By chain').nth(0).click();
|
||||
await expect(page.locator('div[data-scope="popover"][data-part="content"]').first()).toHaveScreenshot();
|
||||
|
||||
await component.getByText('By chain').nth(1).click();
|
||||
await expect(page.locator('div[data-scope="popover"][data-part="content"]').first()).toHaveScreenshot();
|
||||
|
||||
await component.getByText('By chain').nth(2).click();
|
||||
await expect(page.locator('div[data-scope="popover"][data-part="content"]').first()).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test.describe('mobile', () => {
|
||||
test.use({ viewport: pwConfig.viewport.mobile });
|
||||
|
||||
test('base view', async() => {
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('nfts', () => {
|
||||
const hooksConfig = {
|
||||
router: {
|
||||
query: { hash: CURRENT_ADDRESS, tab: 'tokens_nfts' },
|
||||
},
|
||||
};
|
||||
let component: Locator;
|
||||
|
||||
test.beforeEach(async({ render, mockApiResponse, mockAssetResponse }) => {
|
||||
await mockApiResponse(
|
||||
'general:address_collections',
|
||||
{ ...tokensMock.collections, items: [ tokensMock.collections.items[2] ] },
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: [] }, chainConfig: opSuperchainMock.chainDataA },
|
||||
);
|
||||
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
|
||||
component = await render(
|
||||
<OpSuperchainAddressTokens addressData={ opSuperchainMock.addressA }/>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
});
|
||||
|
||||
test('base view', async() => {
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test.describe('mobile', () => {
|
||||
test.use({ viewport: pwConfig.viewport.mobile });
|
||||
|
||||
test('base view', async() => {
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import type { Locator } from '@playwright/test';
|
||||
import React from 'react';
|
||||
|
||||
import * as countersMock from 'mocks/address/counters';
|
||||
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
|
||||
import * as addressMock from 'mocks/multichain/address';
|
||||
import * as chainDataMock from 'mocks/multichain/chains';
|
||||
import * as txMock from 'mocks/txs/tx';
|
||||
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
|
||||
import { test, expect } from 'playwright/lib';
|
||||
@@ -29,18 +30,18 @@ test.describe('local txs', () => {
|
||||
await mockApiResponse(
|
||||
'general:address_txs',
|
||||
{ items: [ txMock.base ], next_page_params: DEFAULT_PAGINATION },
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: opSuperchainMock.chainDataA },
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: chainDataMock.chainA },
|
||||
);
|
||||
await mockApiResponse(
|
||||
'general:address_counters',
|
||||
countersMock.forContract,
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: opSuperchainMock.chainDataA },
|
||||
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: chainDataMock.chainA },
|
||||
);
|
||||
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
|
||||
|
||||
component = await render(
|
||||
<Box pt={{ base: '134px', lg: 6 }}>
|
||||
<OpSuperchainAddressTxs addressData={ opSuperchainMock.addressA } isLoading={ false }/>
|
||||
<OpSuperchainAddressTxs addressData={ addressMock.addressA } isLoading={ false }/>
|
||||
</Box>,
|
||||
{ hooksConfig },
|
||||
{ withSocket: true },
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 28 KiB |