Commit 09df0fd1 authored by Max Alekseenko's avatar Max Alekseenko

Merge branch 'main' into rework-marketplace-app-header

parents fb7aff5b d7e984b4
......@@ -2,6 +2,25 @@ name: Deploy review environment
on:
workflow_dispatch:
inputs:
envs_preset:
description: ENVs preset
required: false
default: ""
type: choice
options:
- none
- gnosis
- eth
- eth_sepolia
- eth_goerli
- optimism
- optimism_sepolia
- polygon
- rootstock
- stability
- zkevm
- zksync
jobs:
make_slug:
......@@ -23,6 +42,7 @@ jobs:
uses: './.github/workflows/publish-image.yml'
with:
tags: ghcr.io/blockscout/frontend:review-${{ needs.make_slug.outputs.REF_SLUG }}
build_args: ENVS_PRESET=${{ inputs.envs_preset }}
secrets: inherit
deploy_review:
......
......@@ -7,6 +7,10 @@ on:
description: Image tags
required: false
type: string
build_args:
description: Build-time variables
required: false
type: string
platforms:
description: Image platforms (you can specify multiple platforms separated by comma)
required: false
......@@ -18,6 +22,10 @@ on:
description: Image tags
required: false
type: string
build_args:
description: Build-time variables
required: false
type: string
platforms:
description: Image platforms (you can specify multiple platforms separated by comma)
required: false
......@@ -72,4 +80,5 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT_SHA=${{ env.SHORT_SHA }}
GIT_TAG=${{ github.ref_type == 'tag' && github.ref_name || '' }}
\ No newline at end of file
GIT_TAG=${{ github.ref_type == 'tag' && github.ref_name || '' }}
${{ inputs.build_args }}
\ No newline at end of file
......@@ -338,17 +338,18 @@
"options": [
"main",
"main.L2",
"localhost",
"gnosis",
"eth",
"eth_goerli",
"sepolia",
"eth_sepolia",
"optimism",
"optimism_sepolia",
"polygon",
"zkevm",
"zksync",
"gnosis",
"rootstock",
"stability",
"poa_core",
"localhost",
"zkevm",
"zksync",
],
"default": "main"
},
......
......@@ -119,6 +119,11 @@ RUN ["chmod", "-R", "777", "./public"]
COPY --from=builder /app/.env.registry .
COPY --from=builder /app/.env .
# Copy ENVs presets
ARG ENVS_PRESET
ENV ENVS_PRESET=$ENVS_PRESET
COPY ./configs/envs ./configs/envs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
......
......@@ -12,8 +12,8 @@ const chain = Object.freeze({
symbol: getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL'),
decimals: Number(getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS')) || DEFAULT_CURRENCY_DECIMALS,
},
governanceToken: {
symbol: getEnvValue('NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL'),
secondaryCoin: {
symbol: getEnvValue('NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL'),
},
rpcUrl: getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL'),
isTestnet: getEnvValue('NEXT_PUBLIC_IS_TESTNET') === 'true',
......
......@@ -8,6 +8,7 @@ const meta = Object.freeze({
og: {
description: getEnvValue('NEXT_PUBLIC_OG_DESCRIPTION') || '',
imageUrl: app.baseUrl + (getExternalAssetFilePath('NEXT_PUBLIC_OG_IMAGE_URL') || defaultImageUrl),
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED') === 'true',
},
});
......
......@@ -22,21 +22,21 @@ NEXT_PUBLIC_API_BASE_PATH=/
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs', 'coin_price', 'market_cap']
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth.json
## footer
##views
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'https://opensea.io/static/images/logos/opensea-logo.svg'},{'name':'LooksRare','collection_url':'https://looksrare.org/collections/{hash}','instance_url':'https://looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]
## misc
NEXT_PUBLIC_NETWORK_EXPLORERS="[{'title':'Etherscan','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/etherscan.png?raw=true','baseUrl':'https://etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}, {'title':'Blockchair','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/blockchair.png?raw=true','baseUrl':'https://blockchair.com/','paths':{'tx':'/ethereum/transaction','address':'/ethereum/address','token':'/ethereum/erc-20/token','block':'/ethereum/block'}},{'title':'Sentio','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/sentio.png?raw=true','baseUrl':'https://app.sentio.xyz/','paths':{'tx':'/tx/1','address':'/contract/1'}}, {'title':'Tenderly','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/tenderly.png?raw=true','baseUrl':'https://dashboard.tenderly.co','paths':{'tx':'/tx/mainnet'}}, {'title':'0xPPL','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/0xPPl.png?raw=true','baseUrl':'https://0xppl.com','paths':{'tx':'/Ethereum/tx','address':'/','token':'/c/Ethereum'}}, {'title':'3xpl','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/3xpl.png?raw=true','baseUrl':'https://3xpl.com/','paths':{'tx':'/ethereum/transaction','address':'/ethereum/address'}} ]"
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Etherscan','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/etherscan.png?raw=true','baseUrl':'https://etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}},{'title':'Blockchair','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/blockchair.png?raw=true','baseUrl':'https://blockchair.com/','paths':{'tx':'/ethereum/transaction','address':'/ethereum/address','token':'/ethereum/erc-20/token','block':'/ethereum/block'}},{'title':'Sentio','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/sentio.png?raw=true','baseUrl':'https://app.sentio.xyz/','paths':{'tx':'/tx/1','address':'/contract/1'}},{'title':'Tenderly','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/tenderly.png?raw=true','baseUrl':'https://dashboard.tenderly.co','paths':{'tx':'/tx/mainnet'}},{'title':'0xPPL','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/0xPPl.png?raw=true','baseUrl':'https://0xppl.com','paths':{'tx':'/Ethereum/tx','address':'/','token':'/c/Ethereum'}},{'title':'3xpl','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/3xpl.png?raw=true','baseUrl':'https://3xpl.com/','paths':{'tx':'/ethereum/transaction','address':'/ethereum/address'}}]
# app features
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_HAS_BEACON_CHAIN=true
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_AUTH_URL=http://localhost:3000/login
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s-prod-1.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
......
......@@ -44,7 +44,7 @@ NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Etherscan','baseUrl':'https://sepolia.e
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xbf69c7abc4fee283b59a9633dadfdaedde5c5ee0fba3e80a08b5b8a3acbd4363
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_AUTH_URL=http://localhost:3000/login
NEXT_PUBLIC_LOGOUT_URL=https://blockscout-goerli.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/eth-goerli.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C
......
......@@ -13,6 +13,7 @@ NEXT_PUBLIC_NETWORK_ID=100
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=xDAI
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=xDAI
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL=GNO
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_NETWORK_RPC_URL=https://rpc.gnosischain.com
......@@ -22,9 +23,9 @@ NEXT_PUBLIC_API_BASE_PATH=/
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND="rgb(46, 74, 60)"
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR="rgb(255, 255, 255)"
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','secondary_coin_price','market_cap']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=rgb(46,74,60)
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=rgb(255,255,255)
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/gnosis-chain-mainnet.json
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/gnosis.svg
......@@ -42,6 +43,7 @@ NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace/gnosis-chain.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrmiO9mDGJoPNmJe
NEXT_PUBLIC_STATS_API_HOST=https://stats-gnosis-mainnet.k8s-prod-1.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
......
......@@ -13,7 +13,6 @@ NEXT_PUBLIC_NETWORK_ID=5
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_RPC_URL=https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_IS_TESTNET=true
......
# Set of ENVs for Optimism (dev only)
# https://optimism.blockscout.com/
# app configuration
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
# blockchain parameters
NEXT_PUBLIC_NETWORK_NAME=OP Mainnet
NEXT_PUBLIC_NETWORK_SHORT_NAME=OP
NEXT_PUBLIC_NETWORK_ID=10
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL=OP
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_NETWORK_RPC_URL=https://mainnet.optimism.io
# api configuration
NEXT_PUBLIC_API_HOST=optimism.blockscout.com
NEXT_PUBLIC_API_PORT=80
NEXT_PUBLIC_API_PROTOCOL=http
NEXT_PUBLIC_API_BASE_PATH=/
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap','secondary_coin_price']
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=rgb(255,255,255)
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=linear-gradient(90deg,rgb(232,52,53)0%,rgb(139,28,232)100%)
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/optimism-mainnet.json
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_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_OTHER_LINKS=[{'url':'https://optimism.drpc.org?ref=559183','text':'Public RPC'}]
## footer
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/optimism.json
## views
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'}]
## misc
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/optimism-mainnet.png
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Tenderly','baseUrl':'https://dashboard.tenderly.co','paths':{'tx':'/tx/optimistic'}},{'title':'3xpl','baseUrl':'https://3xpl.com/','paths':{'tx':'/optimism/transaction','address':'/optimism/address'}}]
# app features
NEXT_PUBLIC_APP_INSTANCE=local
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_AUTH_URL=http://localhost:3000/login
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_STATS_API_HOST=https://stats-optimism-mainnet.k8s-prod-1.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-security-reports/default.json
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_SWAP_BUTTON_URL=uniswap
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
# rollup
NEXT_PUBLIC_ROLLUP_TYPE=optimistic
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://app.optimism.io/bridge/withdraw
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth.blockscout.com/
\ No newline at end of file
# Set of ENVs for zkevm (dev only)
# https://eth.blockscout.com/
# app configuration
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
# blockchain parameters
NEXT_PUBLIC_NETWORK_NAME='OP Goerli'
NEXT_PUBLIC_NETWORK_SHORT_NAME='OP Goerli'
NEXT_PUBLIC_NETWORK_ID=420
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_NETWORK_RPC_URL=https://goerli.optimism.io
# api configuration
NEXT_PUBLIC_API_HOST=optimism-goerli.blockscout.com
NEXT_PUBLIC_API_PORT=80
NEXT_PUBLIC_API_PROTOCOL=http
NEXT_PUBLIC_API_BASE_PATH=/
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/polygon-mainnet.json
## footer
## misc
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Etherscan','baseUrl':'https://etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]
# app features
NEXT_PUBLIC_APP_INSTANCE=local
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
# NEXT_PUBLIC_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
# rollup
NEXT_PUBLIC_ROLLUP_TYPE='optimistic'
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://app.optimism.io/bridge/withdraw
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-goerli.blockscout.com/
\ No newline at end of file
# Set of ENVs for Optimism (dev only)
# https://optimism.blockscout.com/
# app configuration
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
# blockchain parameters
NEXT_PUBLIC_NETWORK_NAME=OP Sepolia
NEXT_PUBLIC_NETWORK_SHORT_NAME=OP Sepolia
NEXT_PUBLIC_NETWORK_ID=11155420
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL=OP
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_NETWORK_RPC_URL=https://sepolia.optimism.io
# api configuration
NEXT_PUBLIC_API_HOST=optimism-sepolia.blockscout.com
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=rgb(255,255,255)
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=linear-gradient(90deg,rgb(232,52,53)0%,rgb(139,28,232)100%)
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/optimism-sepolia.json
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_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_OTHER_LINKS=[{'url':'https://optimism.drpc.org?ref=559183','text':'Public RPC'}]
## footer
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/optimism.json
## views
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'}]
## misc
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/optimism-mainnet.png
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Tenderly','baseUrl':'https://dashboard.tenderly.co','paths':{'tx':'/tx/optimistic-sepolia'}}]
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE=<p>Build faster with the <a href='https://console.optimism.io' target='_blank'>Superchain Dev Console</a>: Get testnet ETH and tools to help you build,launch,and grow your app on the Superchain</p>
# app features
NEXT_PUBLIC_APP_INSTANCE=local
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x59d26836041ab35169bdce431d68d070b7b8acb589fa52e126e6c828b6ece5e9
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_AUTH_URL=http://localhost:3000/login
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_STATS_API_HOST=https://stats-optimism-sepolia.k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_MARKETPLACE_ENABLED=false
NEXT_PUBLIC_SWAP_BUTTON_URL=uniswap
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
# rollup
NEXT_PUBLIC_ROLLUP_TYPE=optimistic
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://app.optimism.io/bridge/withdraw
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-sepolia.blockscout.com/
# Set of ENVs for POA network explorer
# https://blockscout.com/poa/core/
# app configuration
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
# blockchain parameters
NEXT_PUBLIC_NETWORK_NAME=POA
NEXT_PUBLIC_NETWORK_SHORT_NAME=POA
NEXT_PUBLIC_NETWORK_ID=99
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=POA
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=POA
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
# api configuration
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/poa/core
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND='no-repeat bottom 20% right 0px/100% url(https://neon-labs.org/images/index/banner.jpg)'
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=\#DCFE76
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/network-logos/poa.svg
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/network-icons/poa.svg
## footer
## misc
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address','block':'/ethereum/poa/core/block'}}]
# app features
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_NETWORK_RPC_URL=https://core.poa.network
......@@ -23,8 +23,8 @@ NEXT_PUBLIC_API_BASE_PATH=/
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND="linear-gradient(122deg, rgba(162, 41, 197, 1) 0%, rgba(123, 63, 228, 1) 100%)"
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR="rgba(255, 255, 255, 1)"
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=linear-gradient(122deg,rgba(162,41,197,1)0%,rgba(123,63,228,1)100%)
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=rgba(255,255,255,1)
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/polygon-mainnet.json
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/polygon.svg
......@@ -35,9 +35,7 @@ NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-c
# app features
NEXT_PUBLIC_APP_ENV=development
# NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x97fa753626b8d44011d0b9f9a947c735f20b6e895efdee49d7cda76a50001017
NEXT_PUBLIC_HAS_BEACON_CHAIN=false
# NEXT_PUBLIC_STATS_API_HOST=https://stats-rsk-testnet.k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask']
......
......@@ -23,7 +23,7 @@ NEXT_PUBLIC_API_BASE_PATH=/
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND="rgb(255, 145, 0)"
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=rgb(255,145,0)
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/rsk-testnet.json
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/rootstock.svg
......
......@@ -24,9 +24,8 @@ NEXT_PUBLIC_API_BASE_PATH=/
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND="rgb(255, 145, 0)"
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND="rgba(46, 51, 81, 1)"
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR="rgba(122, 235, 246, 1)"
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=rgba(46,51,81,1)
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=rgba(122,235,246,1)
## sidebar
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/rsk-testnet.json
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/stability.svg
......@@ -35,10 +34,10 @@ NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-c
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/stability-short-dark.svg
## footer
## views
NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS="['top_accounts']"
NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS="['value','fee_currency','gas_price','gas_fees','burnt_fees']"
NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS="['fee_per_gas']"
NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS="['burnt_fees','total_reward']"
NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS=['top_accounts']
NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS=['value','fee_currency','gas_price','gas_fees','burnt_fees']
NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS=['fee_per_gas']
NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS=['burnt_fees','total_reward']
## misc
# app features
......@@ -47,14 +46,16 @@ NEXT_PUBLIC_HAS_BEACON_CHAIN=false
NEXT_PUBLIC_STATS_API_HOST=https://stats-stability-testnet.k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_CONTRACT_CODE_IDES="[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com/
NEXT_PUBLIC_GAS_TRACKER_ENABLED=false
NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE='stability'
NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE=stability
#meta
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/stability.png
......@@ -29,8 +29,8 @@ NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/polygon-mainnet.json
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/polygon.svg
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/polygon-short.svg
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND='linear-gradient(122deg, rgba(162, 41, 197, 1) 0%, rgba(123, 63, 228, 1) 100%)'
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR='rgba(255, 255, 255, 1)'
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=linear-gradient(122deg,rgba(162,41,197,1)0%,rgba(123,63,228,1)100%)
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=rgba(255,255,255,1)
## footer
## misc
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Etherscan','baseUrl':'https://etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]
......@@ -39,7 +39,6 @@ NEXT_PUBLIC_APP_INSTANCE=local
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
# NEXT_PUBLIC_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s.blockscout.com
......
......@@ -31,8 +31,8 @@ NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-c
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/zksync-dark.svg
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zksync-short.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zksync-short-dark.svg
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND='rgba(53, 103, 246, 1)'
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR='rgba(255, 255, 255, 1)'
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=rgba(53,103,246,1)
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=rgba(255,255,255,1)
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://zksync.drpc.org?ref=559183','text':'Public RPC'}]
## footer
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/zksync.json
......@@ -46,9 +46,8 @@ NEXT_PUBLIC_APP_INSTANCE=local
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x79c7802ccdf3be5a49c47cc751aad351b0027e8275f6f54878eda50ee559a648
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
# NEXT_PUBLIC_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_LOGOUT_URL=https://zksync.us.auth0.com/v2/logout
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
......
#!/bin/bash
export_envs_from_preset() {
if [ -z "$ENVS_PRESET" ]; then
return
fi
if [ "$ENVS_PRESET" = "none" ]; then
return
fi
local preset_file="./configs/envs/.env.$ENVS_PRESET"
if [ ! -f "$preset_file" ]; then
return
fi
local blacklist=(
"NEXT_PUBLIC_APP_PROTOCOL"
"NEXT_PUBLIC_APP_HOST"
"NEXT_PUBLIC_APP_PORT"
"NEXT_PUBLIC_APP_ENV"
)
while IFS='=' read -r name value; do
name="${name#"${name%%[![:space:]]*}"}" # Trim leading whitespace
if [[ -n $name && $name == "NEXT_PUBLIC_"* && ! "${blacklist[*]}" =~ "$name" ]]; then
export "$name"="$value"
fi
done < <(grep "^[^#;]" "$preset_file")
}
# If there is a preset, load the environment variables from the its file
export_envs_from_preset
# Download external assets
./download_assets.sh ./public/assets
......
......@@ -25,6 +25,7 @@ import type { ValidatorsChainType } from '../../../types/client/validators';
import type { WalletType } from '../../../types/client/wallets';
import { SUPPORTED_WALLETS } from '../../../types/client/wallets';
import type { CustomLink, CustomLinksGroup } from '../../../types/footerLinks';
import { CHAIN_INDICATOR_IDS } from '../../../types/homepage';
import type { ChainIndicatorId } from '../../../types/homepage';
import { type NetworkVerificationType, type NetworkExplorer, type FeaturedNetwork, NETWORK_GROUPS } from '../../../types/networks';
import type { AddressViewId } from '../../../types/views/address';
......@@ -481,7 +482,7 @@ const schema = yup
NEXT_PUBLIC_NETWORK_CURRENCY_WEI_NAME: yup.string(),
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL: yup.string(),
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS: yup.number().integer().positive(),
NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL: yup.string(),
NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL: yup.string(),
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE: yup.string<NetworkVerificationType>().oneOf([ 'validation', 'mining' ]),
NEXT_PUBLIC_IS_TESTNET: yup.boolean(),
......@@ -498,7 +499,7 @@ const schema = yup
.array()
.transform(replaceQuotes)
.json()
.of(yup.string<ChainIndicatorId>().oneOf([ 'daily_txs', 'coin_price', 'market_cap', 'tvl' ])),
.of(yup.string<ChainIndicatorId>().oneOf(CHAIN_INDICATOR_IDS)),
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR: yup.string(),
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND: yup.string(),
NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME: yup.boolean(),
......@@ -601,6 +602,7 @@ const schema = yup
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE: yup.boolean(),
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: yup.boolean(),
NEXT_PUBLIC_SAFE_TX_SERVICE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),
......
......@@ -30,7 +30,7 @@ NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Explorer','baseUrl':'https://example.com/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]
NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL=gETH
NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL=GNO
NEXT_PUBLIC_NETWORK_ICON=https://example.com/icon.png
NEXT_PUBLIC_NETWORK_ICON_DARK=https://example.com/icon.png
NEXT_PUBLIC_NETWORK_LOGO=https://example.com/logo.png
......@@ -40,6 +40,7 @@ NEXT_PUBLIC_NETWORK_SHORT_NAME=Test
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_OG_DESCRIPTION='Hello world!'
NEXT_PUBLIC_OG_IMAGE_URL=https://example.com/image.png
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://blockscout.com','text':'Blockscout'}]
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE=true
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global
......
......@@ -85,7 +85,7 @@ frontend:
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_HAS_USER_OPS: true
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: noves
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: blockscout
NEXT_PUBLIC_SWAP_BUTTON_URL: uniswap
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS: true
NEXT_PUBLIC_AD_BANNER_PROVIDER: getit
......@@ -93,7 +93,7 @@ frontend:
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP: "{ \"id\": \"632019\", \"width\": \"728\", \"height\": \"90\" }"
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE: "{ \"id\": \"632018\", \"width\": \"320\", \"height\": \"100\" }"
NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED: true
PROMETHEUS_METRICS_ENABLED: true
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: true
envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
......
......@@ -6,4 +6,5 @@
| NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK | `boolean` | Set to true for zkevm L2 solutions | Required | - | `true` | v1.24.0 | Replaced by NEXT_PUBLIC_ROLLUP_TYPE |
| NEXT_PUBLIC_OPTIMISTIC_L2_WITHDRAWAL_URL | `string` | URL for optimistic L2 -> L1 withdrawals | Required | - | `https://app.optimism.io/bridge/withdraw` | v1.24.0 | Renamed to NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL |
| NEXT_PUBLIC_L1_BASE_URL | `string` | Blockscout base URL for L1 network | Required | - | `'http://eth-goerli.blockscout.com'` | v1.24.0 | Renamed to NEXT_PUBLIC_ROLLUP_L1_BASE_URL |
| NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER | `boolean` | Set to false if network doesn't have gas tracker | - | `true` | `false` | v1.25.0 | Replaced by NEXT_PUBLIC_GAS_TRACKER_ENABLED |
\ No newline at end of file
| NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER | `boolean` | Set to false if network doesn't have gas tracker | - | `true` | `false` | v1.25.0 | Replaced by NEXT_PUBLIC_GAS_TRACKER_ENABLED |
| NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL | `string` | Network governance token symbol | - | - | `GNO` | v1.29.0 | Replaced by NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL |
\ No newline at end of file
......@@ -73,6 +73,8 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
## Blockchain parameters
*Note!* The `NEXT_PUBLIC_NETWORK_CURRENCY` variables represent the blockchain's native token used for paying transaction fees. `NEXT_PUBLIC_NETWORK_SECONDARY_COIN` variables refer to tokens like protocol-specific tokens (e.g., OP token on Optimism chain) or governance tokens (e.g., GNO on Gnosis chain).
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_NETWORK_NAME | `string` | Displayed name of the network | Required | - | `Gnosis Chain` |
......@@ -83,7 +85,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
| NEXT_PUBLIC_NETWORK_CURRENCY_WEI_NAME | `string` | Name of network currency subdenomination | - | `wei` | `duck` |
| NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL | `string` | Network currency symbol | - | - | `ETH` |
| NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS | `string` | Network currency decimals | - | `18` | `6` |
| NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL | `string` | Network governance token symbol | - | - | `GNO` |
| NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL | `string` | Network secondary coin symbol. | - | - | `GNO` |
| NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE | `validation` or `mining` | Verification type in the network | - | `mining` | `validation` |
| NEXT_PUBLIC_IS_TESTNET | `boolean`| Set to true if network is testnet | - | `false` | `true` |
......@@ -107,7 +109,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'coin_price' \| 'market_cap' \| 'tvl'>` | List of charts displayed on the home page | - | - | `['daily_txs','coin_price','market_cap']` |
| NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'coin_price' \| 'secondary_coin_price' \| 'market_cap' \| 'tvl'>` | List of charts displayed on the home page | - | - | `['daily_txs','coin_price','market_cap']` |
| NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR | `string` | Text color of the hero plate on the homepage (escape "#" symbol if you use HEX color codes or use rgba-value instead) | - | `white` | `\#DCFE76` |
| NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND | `string` | Background css value for hero plate on the homepage (escape "#" symbol if you use HEX color codes or use rgba-value instead) | - | `radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)` | `radial-gradient(at 15% 86%, hsla(350,65%,70%,1) 0px, transparent 50%)` \| `no-repeat bottom 20% right 0px/100% url(https://placekitten/1400/200)` |
| NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME | `boolean` | Set to false if average block time is useless for the network | - | `true` | `false` |
......@@ -176,6 +178,7 @@ Settings for meta tags and OG tags
| NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE | `boolean` | Set to `true` to promote Blockscout in meta and OG titles | - | `true` | `true` |
| NEXT_PUBLIC_OG_DESCRIPTION | `string` | Custom OG description | - | - | `Blockscout is the #1 open-source blockchain explorer available today. 100+ chains and counting rely on Blockscout data availability, APIs, and ecosystem tools to support their networks.` |
| NEXT_PUBLIC_OG_IMAGE_URL | `string` | OG image url. Minimum image size is 200 x 20 pixels (recommended: 1200 x 600); maximum supported file size is 8 MB; 2:1 aspect ratio; supported formats: image/jpeg, image/gif, image/png | - | `static/og_placeholder.png` | `https://placekitten.com/1200/600` |
| NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED | `boolean` | Set to `true` to populate OG tags (title, description) with API data for social preview robot requests | - | `false` | `true` |
&nbsp;
......
<svg viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 4.167c0-.92.746-1.667 1.667-1.667H8c.92 0 1.667.746 1.667 1.667V7.5c0 .92-.747 1.667-1.667 1.667H4.667C3.747 9.167 3 8.42 3 7.5V4.167Zm1.667.5a.5.5 0 0 1 .5-.5H7.5a.5.5 0 0 1 .5.5V7a.5.5 0 0 1-.5.5H5.167a.5.5 0 0 1-.5-.5V4.667ZM3 12.5c0-.92.746-1.667 1.667-1.667H8c.92 0 1.667.746 1.667 1.667v3.333c0 .92-.747 1.667-1.667 1.667H4.667C3.747 17.5 3 16.754 3 15.833V12.5Zm1.667.5a.5.5 0 0 1 .5-.5H7.5a.5.5 0 0 1 .5.5v2.333a.5.5 0 0 1-.5.5H5.167a.5.5 0 0 1-.5-.5V13ZM13 2.5c-.92 0-1.667.746-1.667 1.667V7.5c0 .92.746 1.667 1.667 1.667h3.333C17.253 9.167 18 8.42 18 7.5V4.167c0-.92-.746-1.667-1.667-1.667H13Zm3.333 2.167a.5.5 0 0 0-.5-.5H13.5a.5.5 0 0 0-.5.5V7a.5.5 0 0 0 .5.5h2.333a.5.5 0 0 0 .5-.5V4.667ZM11.333 12.5c0-.92.746-1.667 1.667-1.667h3.333c.92 0 1.667.746 1.667 1.667v3.333c0 .92-.746 1.667-1.667 1.667H13c-.92 0-1.667-.746-1.667-1.667V12.5ZM13 13a.5.5 0 0 1 .5-.5h2.333a.5.5 0 0 1 .5.5v2.333a.5.5 0 0 1-.5.5H13.5a.5.5 0 0 1-.5-.5V13Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 4.167c0-.92.746-1.667 1.667-1.667H8c.92 0 1.667.746 1.667 1.667V7.5c0 .92-.747 1.667-1.667 1.667H4.667C3.747 9.167 3 8.42 3 7.5V4.167Zm1.667.5a.5.5 0 0 1 .5-.5H7.5a.5.5 0 0 1 .5.5V7a.5.5 0 0 1-.5.5H5.167a.5.5 0 0 1-.5-.5V4.667ZM3 12.5c0-.92.746-1.667 1.667-1.667H8c.92 0 1.667.746 1.667 1.667v3.333c0 .92-.747 1.667-1.667 1.667H4.667C3.747 17.5 3 16.754 3 15.833V12.5Zm1.667.5a.5.5 0 0 1 .5-.5H7.5a.5.5 0 0 1 .5.5v2.333a.5.5 0 0 1-.5.5H5.167a.5.5 0 0 1-.5-.5V13ZM13 2.5c-.92 0-1.667.746-1.667 1.667V7.5c0 .92.746 1.667 1.667 1.667h3.333C17.253 9.167 18 8.42 18 7.5V4.167c0-.92-.746-1.667-1.667-1.667H13Zm3.333 2.167a.5.5 0 0 0-.5-.5H13.5a.5.5 0 0 0-.5.5V7a.5.5 0 0 0 .5.5h2.333a.5.5 0 0 0 .5-.5V4.667Zm-5 7.833c0-.92.746-1.667 1.667-1.667h3.333c.92 0 1.667.746 1.667 1.667v3.333c0 .92-.746 1.667-1.667 1.667H13c-.92 0-1.667-.746-1.667-1.667V12.5ZM13 13a.5.5 0 0 1 .5-.5h2.333a.5.5 0 0 1 .5.5v2.333a.5.5 0 0 1-.5.5H13.5a.5.5 0 0 1-.5-.5V13Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 26 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="26" height="12" rx="2" fill="#F56565"/>
<path d="M3.028 9V1.727h1.061V4.43h.064c.062-.114.15-.245.267-.394.116-.15.277-.28.483-.391.205-.114.478-.17.816-.17.44 0 .834.11 1.18.333.345.223.616.544.812.963.2.419.299.923.299 1.512 0 .59-.098 1.095-.295 1.517a2.3 2.3 0 0 1-.81.97 2.097 2.097 0 0 1-1.175.337c-.332 0-.603-.056-.813-.167a1.54 1.54 0 0 1-.49-.391 2.957 2.957 0 0 1-.274-.398h-.089V9H3.028Zm1.04-2.727c0 .383.056.72.167 1.008.111.29.272.515.483.679.21.16.469.241.774.241.317 0 .582-.084.795-.252.214-.17.375-.401.483-.693.112-.29.167-.619.167-.983 0-.36-.054-.683-.163-.97a1.484 1.484 0 0 0-.483-.678c-.213-.166-.48-.249-.799-.249-.308 0-.568.08-.781.238-.21.159-.37.38-.48.664a2.77 2.77 0 0 0-.163.995Zm7.485 2.837c-.538 0-1-.115-1.389-.344a2.336 2.336 0 0 1-.894-.977c-.209-.421-.313-.915-.313-1.48 0-.56.104-1.052.313-1.478.21-.426.504-.759.88-.998.379-.239.822-.359 1.328-.359.308 0 .606.051.895.153.29.102.548.262.778.48.23.217.41.5.543.848.133.346.2.766.2 1.26v.377H9.556v-.795h3.296c0-.28-.057-.527-.17-.742a1.29 1.29 0 0 0-1.198-.703c-.298 0-.558.073-.78.22-.221.144-.391.334-.512.568a1.641 1.641 0 0 0-.178.756v.622c0 .364.064.674.192.93.13.256.311.451.543.586.232.133.503.199.814.199.2 0 .384-.028.55-.085a1.143 1.143 0 0 0 .707-.692l1.005.18a1.82 1.82 0 0 1-.434.778 2.1 2.1 0 0 1-.777.515 2.91 2.91 0 0 1-1.062.181Zm6.064-5.565v.853h-2.979v-.853h2.98Zm-2.18-1.306h1.062v5.16c0 .205.03.36.092.465.062.101.14.171.238.21.1.035.207.052.323.052.085 0 .16-.005.224-.017l.149-.029.192.877a2.08 2.08 0 0 1-.689.114 1.87 1.87 0 0 1-.781-.15 1.34 1.34 0 0 1-.586-.482c-.15-.218-.224-.491-.224-.82v-5.38Zm4.942 6.882c-.345 0-.658-.064-.937-.192a1.58 1.58 0 0 1-.664-.565c-.161-.246-.242-.548-.242-.905 0-.308.06-.561.178-.76a1.31 1.31 0 0 1 .48-.472c.2-.116.425-.204.674-.263.248-.06.502-.104.76-.135l.795-.092c.204-.027.352-.068.444-.125.092-.057.139-.149.139-.277V5.31c0-.31-.088-.55-.263-.72-.173-.171-.431-.256-.774-.256-.358 0-.64.08-.845.238-.204.156-.345.33-.423.522l-.998-.228a1.92 1.92 0 0 1 .519-.802c.23-.206.493-.355.791-.448.299-.094.612-.142.941-.142.218 0 .45.026.693.079.246.05.476.142.689.277.215.134.392.327.53.578.136.249.205.572.205.97V9h-1.037v-.746h-.043a1.512 1.512 0 0 1-.308.405 1.642 1.642 0 0 1-.53.33 2.053 2.053 0 0 1-.774.132Zm.231-.853c.294 0 .545-.058.753-.174.21-.116.37-.267.48-.454.11-.19.166-.392.166-.607V6.33a.552.552 0 0 1-.22.106 3.43 3.43 0 0 1-.366.082l-.401.06c-.13.017-.24.03-.327.043a2.63 2.63 0 0 0-.564.131.97.97 0 0 0-.405.266.665.665 0 0 0-.15.455c0 .263.098.462.292.597.194.132.441.198.742.198Z" fill="#fff"/>
<path d="M3.028 9V1.727h1.061V4.43h.064c.062-.114.15-.245.267-.394.116-.15.277-.28.483-.391.205-.114.478-.17.816-.17.44 0 .834.11 1.18.333.345.223.616.544.812.963.2.419.299.923.299 1.512 0 .59-.098 1.095-.295 1.517a2.3 2.3 0 0 1-.81.97 2.097 2.097 0 0 1-1.175.337c-.332 0-.603-.056-.813-.167a1.54 1.54 0 0 1-.49-.391 2.957 2.957 0 0 1-.274-.398h-.089V9H3.028Zm1.04-2.727c0 .383.056.72.167 1.008.111.29.272.515.483.679.21.16.469.241.774.241.317 0 .582-.084.795-.252.214-.17.375-.401.483-.693.112-.29.167-.619.167-.983 0-.36-.054-.683-.163-.97a1.484 1.484 0 0 0-.483-.678c-.213-.166-.48-.249-.799-.249-.308 0-.568.08-.781.238-.21.159-.37.38-.48.664a2.77 2.77 0 0 0-.163.995Zm7.485 2.837c-.538 0-1-.115-1.389-.344a2.336 2.336 0 0 1-.894-.977c-.209-.421-.313-.915-.313-1.48 0-.56.104-1.052.313-1.478.21-.426.504-.759.88-.998.379-.239.822-.359 1.328-.359.308 0 .606.051.895.153.29.102.548.262.778.48.23.217.41.5.543.848.133.346.2.766.2 1.26v.377H9.556v-.795h3.296c0-.28-.057-.527-.17-.742a1.29 1.29 0 0 0-1.198-.703 1.38 1.38 0 0 0-.78.22 1.474 1.474 0 0 0-.512.568 1.641 1.641 0 0 0-.178.756v.622c0 .364.064.674.192.93.13.256.311.451.543.586.232.133.503.199.814.199.2 0 .384-.028.55-.085a1.143 1.143 0 0 0 .707-.692l1.005.18a1.82 1.82 0 0 1-.434.778 2.1 2.1 0 0 1-.777.515 2.91 2.91 0 0 1-1.062.181Zm6.064-5.565v.853h-2.979v-.853h2.98Zm-2.18-1.306h1.062v5.16c0 .205.03.36.092.465a.49.49 0 0 0 .238.21c.1.035.207.052.323.052.085 0 .16-.005.224-.017l.149-.029.192.877a2.08 2.08 0 0 1-.689.114 1.87 1.87 0 0 1-.781-.15 1.34 1.34 0 0 1-.586-.482c-.15-.218-.224-.491-.224-.82v-5.38Zm4.942 6.882c-.345 0-.658-.064-.937-.192a1.58 1.58 0 0 1-.664-.565c-.161-.246-.242-.548-.242-.905 0-.308.06-.561.178-.76a1.31 1.31 0 0 1 .48-.472c.2-.116.425-.204.674-.263a6.56 6.56 0 0 1 .76-.135l.795-.092c.204-.027.352-.068.444-.125.092-.057.139-.149.139-.277V5.31c0-.31-.088-.55-.263-.72-.173-.171-.431-.256-.774-.256-.358 0-.64.08-.845.238-.204.156-.345.33-.423.522l-.998-.228a1.92 1.92 0 0 1 .519-.802c.23-.206.493-.355.791-.448a3.116 3.116 0 0 1 1.634-.063c.246.05.476.142.689.277.215.134.392.327.53.578.136.249.205.572.205.97V9h-1.037v-.746h-.043a1.512 1.512 0 0 1-.308.405 1.642 1.642 0 0 1-.53.33 2.053 2.053 0 0 1-.774.132Zm.231-.853c.294 0 .545-.058.753-.174a1.192 1.192 0 0 0 .646-1.061V6.33a.552.552 0 0 1-.22.106 3.43 3.43 0 0 1-.366.082l-.401.06c-.13.017-.24.03-.327.043a2.63 2.63 0 0 0-.564.131.97.97 0 0 0-.405.266.665.665 0 0 0-.15.455c0 .263.098.462.292.597.194.132.441.198.742.198Z" fill="#fff"/>
</svg>
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="12" height="12" rx="2" fill="#F56565"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.42 2.494c-.373-.313-.896-.49-1.5-.49a2.028 2.028 0 0 0-1.52.567 1.948 1.948 0 0 0-.453.685c-.1.257-.143.531-.127.805V10h.79V8.508a2.46 2.46 0 0 0 1.59.549c.3.01.6-.04.88-.147a2.21 2.21 0 0 0 .751-.48c.215-.208.383-.457.495-.733.112-.274.165-.568.157-.863a1.925 1.925 0 0 0-.309-1.09 1.978 1.978 0 0 0-.717-.664c.157-.147.287-.32.381-.514.12-.245.18-.515.176-.787 0-.523-.22-.97-.594-1.285Zm-2.023.36c.163-.06.338-.085.512-.074h.008c.413 0 .741.105.964.28.22.171.343.417.343.72v.004a.944.944 0 0 1-.288.713.99.99 0 0 1-.732.283l-.115-.004v.781h.112c.467 0 .84.13 1.094.351.253.219.398.534.398.927v.004a1.357 1.357 0 0 1-.418 1.04 1.422 1.422 0 0 1-1.069.4h-.012a1.532 1.532 0 0 1-1.109-.393 1.437 1.437 0 0 1-.475-1.052V4.05a1.165 1.165 0 0 1 .352-.923c.123-.12.272-.214.435-.274Z" fill="#fff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.42 2.494c-.373-.313-.896-.49-1.5-.49a2.028 2.028 0 0 0-1.52.567 1.948 1.948 0 0 0-.453.685 1.91 1.91 0 0 0-.127.805V10h.79V8.508a2.46 2.46 0 0 0 1.59.549c.3.01.6-.04.88-.147a2.21 2.21 0 0 0 .751-.48 2.136 2.136 0 0 0 .652-1.596 1.925 1.925 0 0 0-.309-1.09 1.978 1.978 0 0 0-.717-.664 1.75 1.75 0 0 0 .557-1.301c0-.523-.22-.97-.594-1.285Zm-2.023.36c.163-.06.338-.085.512-.074h.008c.413 0 .741.105.964.28.22.171.343.417.343.72v.004a.944.944 0 0 1-.288.713.99.99 0 0 1-.732.283l-.115-.004v.781h.112c.467 0 .84.13 1.094.351.253.219.398.534.398.927v.004a1.357 1.357 0 0 1-.418 1.04 1.422 1.422 0 0 1-1.069.4h-.012a1.532 1.532 0 0 1-1.109-.393 1.437 1.437 0 0 1-.475-1.052V4.05a1.165 1.165 0 0 1 .352-.923c.123-.12.272-.214.435-.274Z" fill="#fff"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.4 2a1.8 1.8 0 0 0-1.8 1.8v14.8A1.4 1.4 0 0 0 3 20h1.6v-1.326H3V3.516h1.6V2H3.4Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.992.45C4.244.163 4.585 0 4.94 0h8.038a.63.63 0 0 1 .474.225L18.14 5.61a.83.83 0 0 1 .196.544v12.308c0 .408-.141.799-.392 1.087-.252.289-.593.451-.948.451H4.94c-.356 0-.696-.162-.948-.45a1.661 1.661 0 0 1-.392-1.088V1.538c0-.408.141-.799.392-1.087Zm.948 1.088h6.87v4.497c0 .388.315.702.702.702h4.485v11.725H4.94V1.538Zm8.274.59 2.791 3.205h-2.79V2.128Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.992.45C4.244.163 4.585 0 4.94 0h8.038a.63.63 0 0 1 .474.225L18.14 5.61a.83.83 0 0 1 .196.544v12.308c0 .408-.141.799-.392 1.087a1.26 1.26 0 0 1-.948.451H4.94c-.356 0-.696-.162-.948-.45a1.661 1.661 0 0 1-.392-1.088V1.538c0-.408.141-.799.392-1.087Zm.948 1.088h6.87v4.497c0 .388.315.702.702.702h4.485v11.725H4.94V1.538Zm8.274.59 2.791 3.205h-2.79V2.128Z" fill="currentColor"/>
<rect x="7.2" y="14.3" width="7.8" height="1.2" rx=".6" fill="currentColor"/>
<rect x="7.2" y="12" width="7.8" height="1.2" rx=".6" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.4 2a1.8 1.8 0 0 0-1.8 1.8v14.8A1.4 1.4 0 0 0 3 20h1.6v-1.326H3V3.516h1.6V2H3.4Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.992.45C4.244.163 4.585 0 4.94 0h8.038a.63.63 0 0 1 .474.225L18.14 5.61a.83.83 0 0 1 .196.544v12.308c0 .408-.141.799-.392 1.087-.252.289-.593.451-.948.451H4.94c-.356 0-.696-.162-.948-.45a1.661 1.661 0 0 1-.392-1.088V1.538c0-.408.141-.799.392-1.087Zm8.709 1.088H4.94v16.924h12.057V6.472l-4.296-4.934Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.992.45C4.244.163 4.585 0 4.94 0h8.038a.63.63 0 0 1 .474.225L18.14 5.61a.83.83 0 0 1 .196.544v12.308c0 .408-.141.799-.392 1.087a1.26 1.26 0 0 1-.948.451H4.94c-.356 0-.696-.162-.948-.45a1.661 1.661 0 0 1-.392-1.088V1.538c0-.408.141-.799.392-1.087Zm8.709 1.088H4.94v16.924h12.057V6.472l-4.296-4.934Z" fill="currentColor"/>
<path d="m7.9 13.357 2.2 2.357L14.5 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.512.42c.388 0 .702.315.702.703v4.21h4.21a.702.702 0 1 1 0 1.404h-4.912a.702.702 0 0 1-.702-.702V1.123c0-.388.315-.702.702-.702Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.76 18.333a.603.603 0 0 1-.294-.075L10 15.798l-4.467 2.46a.607.607 0 0 1-.663-.052.657.657 0 0 1-.213-.285.69.69 0 0 1-.038-.36l.853-5.21-3.615-3.69a.69.69 0 0 1-.16-.677.663.663 0 0 1 .194-.3c.09-.08.199-.131.315-.149l4.995-.76 2.233-4.74a.65.65 0 0 1 .233-.269.61.61 0 0 1 .666 0c.1.065.18.158.232.269l2.234 4.74 4.994.76c.116.018.226.07.316.149.09.079.157.183.193.3a.69.69 0 0 1-.16.678l-3.615 3.69.854 5.21a.692.692 0 0 1-.14.537.636.636 0 0 1-.216.173.607.607 0 0 1-.266.061h.001Z" fill="currentColor"/>
<path d="M14.76 18.333a.603.603 0 0 1-.294-.075L10 15.798l-4.467 2.46a.607.607 0 0 1-.663-.052.657.657 0 0 1-.213-.285.69.69 0 0 1-.038-.36l.853-5.21-3.615-3.69a.69.69 0 0 1-.16-.677.663.663 0 0 1 .194-.3.615.615 0 0 1 .315-.149l4.995-.76 2.233-4.74a.65.65 0 0 1 .233-.269.61.61 0 0 1 .666 0c.1.065.18.158.232.269l2.234 4.74 4.994.76c.116.018.226.07.316.149.09.079.157.183.193.3a.69.69 0 0 1-.16.678l-3.615 3.69.854 5.21a.692.692 0 0 1-.14.537.636.636 0 0 1-.216.173.607.607 0 0 1-.266.061h.001Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.76 18.333a.603.603 0 0 1-.294-.075l.293.075Zm.003 0c.09 0 .18-.021.262-.061.083-.04.157-.1.216-.173a.674.674 0 0 0 .14-.538l-.854-5.21 3.616-3.69a.69.69 0 0 0 .16-.677.662.662 0 0 0-.194-.3.617.617 0 0 0-.316-.149l-4.884-.743a.208.208 0 0 1-.158-.117l-2.186-4.64a.651.651 0 0 0-.232-.269.61.61 0 0 0-.666 0 .65.65 0 0 0-.233.269l-2.186 4.64a.208.208 0 0 1-.157.117l-4.885.743a.617.617 0 0 0-.315.149.663.663 0 0 0-.194.3.69.69 0 0 0 .16.678L5.4 12.276a.208.208 0 0 1 .056.18L4.62 17.56a.69.69 0 0 0 .038.36.657.657 0 0 0 .213.285.613.613 0 0 0 .663.052l4.366-2.405a.208.208 0 0 1 .201 0l4.366 2.405m-7.795-2.915c-.028.172.154.3.306.216L9.9 13.95a.208.208 0 0 1 .201 0l2.922 1.61a.208.208 0 0 0 .306-.216l-.565-3.452a.208.208 0 0 1 .057-.18l2.485-2.536a.208.208 0 0 0-.117-.351l-3.409-.52a.208.208 0 0 1-.157-.116l-1.434-3.044a.208.208 0 0 0-.377 0L8.377 8.189a.208.208 0 0 1-.157.117l-3.408.518a.208.208 0 0 0-.118.352l2.486 2.537a.208.208 0 0 1 .057.18l-.566 3.45Zm8.092 2.99h-.003.003Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.76 18.333a.603.603 0 0 1-.294-.075l.293.075Zm.003 0a.6.6 0 0 0 .262-.061c.083-.04.157-.1.216-.173a.674.674 0 0 0 .14-.538l-.854-5.21 3.616-3.69a.69.69 0 0 0 .16-.677.662.662 0 0 0-.194-.3.617.617 0 0 0-.316-.149l-4.884-.743a.208.208 0 0 1-.158-.117l-2.186-4.64a.651.651 0 0 0-.232-.269.61.61 0 0 0-.666 0 .65.65 0 0 0-.233.269l-2.186 4.64a.208.208 0 0 1-.157.117l-4.885.743a.617.617 0 0 0-.315.149.663.663 0 0 0-.194.3.69.69 0 0 0 .16.678L5.4 12.276a.208.208 0 0 1 .056.18L4.62 17.56a.69.69 0 0 0 .038.36.657.657 0 0 0 .213.285.613.613 0 0 0 .663.052L9.9 15.852a.208.208 0 0 1 .201 0l4.366 2.405m-7.795-2.915c-.028.172.154.3.306.216L9.9 13.95a.208.208 0 0 1 .201 0l2.922 1.61a.208.208 0 0 0 .306-.216l-.565-3.452a.208.208 0 0 1 .057-.18l2.485-2.536a.208.208 0 0 0-.117-.351l-3.409-.52a.208.208 0 0 1-.157-.116l-1.434-3.044a.208.208 0 0 0-.377 0L8.377 8.189a.208.208 0 0 1-.157.117l-3.408.518a.208.208 0 0 0-.118.352l2.486 2.537a.208.208 0 0 1 .057.18l-.566 3.45Zm8.092 2.99h-.003.003Z" fill="currentColor"/>
</svg>
......@@ -15,13 +15,9 @@ import 'lib/setLocale';
const PAGE_PROPS = {
cookies: '',
referrer: '',
id: '',
height_or_hash: '',
hash: '',
number: '',
q: '',
name: '',
adBannerProvider: '',
query: {},
adBannerProvider: undefined,
apiData: null,
};
const TestApp = ({ children }: {children: React.ReactNode}) => {
......
......@@ -34,7 +34,7 @@ import type {
import type { AddressesResponse } from 'types/api/addresses';
import type { TxBlobs, Blob } from 'types/api/blobs';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block';
import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendVersionConfig } from 'types/api/configs';
import type {
SmartContract,
......@@ -88,6 +88,7 @@ import type {
TransactionsResponseWatchlist,
TransactionsSorting,
TransactionsResponseWithBlobs,
TransactionsStats,
} from 'types/api/transaction';
import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import type { TTxsFilters, TTxsWithBlobsFilters } from 'types/api/txsFilters';
......@@ -276,6 +277,9 @@ export const RESOURCES = {
pathParams: [ 'height_or_hash' as const ],
filterFields: [],
},
txs_stats: {
path: '/api/v2/transactions/stats',
},
txs_validated: {
path: '/api/v2/transactions',
filterFields: [ 'filter' as const, 'type' as const, 'method' as const ],
......@@ -540,6 +544,9 @@ export const RESOURCES = {
stats_charts_market: {
path: '/api/v2/stats/charts/market',
},
stats_charts_secondary_coin_price: {
path: '/api/v2/stats/charts/secondary-coin-market',
},
// HOMEPAGE
homepage_blocks: {
......@@ -823,6 +830,7 @@ Q extends 'token_info_applications' ? TokenInfoApplications :
Q extends 'stats' ? HomeStats :
Q extends 'stats_charts_txs' ? ChartTransactionResponse :
Q extends 'stats_charts_market' ? ChartMarketResponse :
Q extends 'stats_charts_secondary_coin_price' ? ChartSecondaryCoinPriceResponse :
Q extends 'homepage_blocks' ? Array<Block> :
Q extends 'homepage_txs' ? Array<Transaction> :
Q extends 'homepage_txs_watchlist' ? Array<Transaction> :
......@@ -838,6 +846,7 @@ Q extends 'blocks' ? BlocksResponse :
Q extends 'block' ? Block :
Q extends 'block_txs' ? BlockTransactionsResponse :
Q extends 'block_withdrawals' ? BlockWithdrawalsResponse :
Q extends 'txs_stats' ? TransactionsStats :
Q extends 'txs_validated' ? TransactionsResponseValidated :
Q extends 'txs_pending' ? TransactionsResponsePending :
Q extends 'txs_with_blobs' ? TransactionsResponseWithBlobs :
......
......@@ -10,13 +10,9 @@ type Props = {
const AppContext = createContext<PageProps>({
cookies: '',
referrer: '',
id: '',
height_or_hash: '',
hash: '',
number: '',
q: '',
name: '',
adBannerProvider: '',
query: {},
adBannerProvider: undefined,
apiData: null,
});
export function AppContextProvider({ children, pageProps }: Props) {
......
import React from 'react';
export default function useIsMounted() {
const [ isMounted, setIsMounted ] = React.useState(false);
React.useEffect(() => {
setIsMounted(true);
}, [ ]);
return isMounted;
}
......@@ -4,26 +4,29 @@ import type { Route } from 'nextjs-routes';
import generate from './generate';
interface TestCase<R extends Route> {
interface TestCase<Pathname extends Route['pathname']> {
title: string;
route: R;
apiData?: ApiData<R>;
route: {
pathname: Pathname;
query?: Route['query'];
};
apiData?: ApiData<Pathname>;
}
const TEST_CASES: Array<TestCase<Route>> = [
const TEST_CASES = [
{
title: 'static route',
route: {
pathname: '/blocks',
},
},
} as TestCase<'/blocks'>,
{
title: 'dynamic route',
route: {
pathname: '/tx/[hash]',
query: { hash: '0x12345' },
},
},
} as TestCase<'/tx/[hash]'>,
{
title: 'dynamic route with API data',
route: {
......@@ -31,7 +34,7 @@ const TEST_CASES: Array<TestCase<Route>> = [
query: { hash: '0x12345' },
},
apiData: { symbol: 'USDT' },
} as TestCase<{ pathname: '/token/[hash]'; query: { hash: string }}>,
} as TestCase<'/token/[hash]'>,
];
describe('generates correct metadata for:', () => {
......
import type { ApiData, Metadata } from './types';
import type { RouteParams } from 'nextjs/types';
import type { Route } from 'nextjs-routes';
......@@ -9,7 +10,7 @@ import compileValue from './compileValue';
import getPageOgType from './getPageOgType';
import * as templates from './templates';
export default function generate<R extends Route>(route: R, apiData?: ApiData<R>): Metadata {
export default function generate<Pathname extends Route['pathname']>(route: RouteParams<Pathname>, apiData: ApiData<Pathname> = null): Metadata {
const params = {
...route.query,
...apiData,
......@@ -17,7 +18,7 @@ export default function generate<R extends Route>(route: R, apiData?: ApiData<R>
network_title: getNetworkTitle(),
};
const compiledTitle = compileValue(templates.title.make(route.pathname), params);
const compiledTitle = compileValue(templates.title.make(route.pathname, Boolean(apiData)), params);
const title = compiledTitle ? compiledTitle + (config.meta.promoteBlockscoutInTitle ? ' | Blockscout' : '') : '';
const description = compileValue(templates.description.make(route.pathname), params);
......
import type { IncomingMessage, ServerResponse } from 'http';
import { httpLogger } from 'nextjs/utils/logger';
import metrics from 'lib/monitoring/metrics';
export default async function getApiDataForSocialPreview(req: IncomingMessage | undefined, res: ServerResponse<IncomingMessage> | undefined, pathname: string) {
if (!req || !res || !metrics) {
return;
}
const userAgent = req.headers['user-agent'];
if (!userAgent) {
return;
}
if (userAgent.toLowerCase().includes('twitter')) {
httpLogger(req, res);
metrics.requestCounter.inc({ route: pathname, bot: 'twitter' });
}
if (userAgent.toLowerCase().includes('facebook')) {
httpLogger(req, res);
metrics.requestCounter.inc({ route: pathname, bot: 'facebook' });
}
if (userAgent.toLowerCase().includes('telegram')) {
httpLogger(req, res);
metrics.requestCounter.inc({ route: pathname, bot: 'telegram' });
}
}
export { default as generate } from './generate';
export { default as update } from './update';
export * from './types';
......@@ -13,10 +13,10 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/contract-verification': 'verify contract',
'/address/[hash]/contract-verification': 'contract verification for %hash%',
'/tokens': 'tokens',
'/token/[hash]': '%symbol% token details',
'/token/[hash]': 'token details',
'/token/[hash]/instance/[id]': 'NFT instance',
'/apps': 'apps marketplace',
'/apps/[id]': '- %app_name%',
'/apps/[id]': 'marketplace app',
'/stats': 'statistics',
'/api-docs': 'REST API',
'/graphiql': 'GraphQL',
......@@ -56,8 +56,15 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/auth/unverified-email': 'unverified email',
};
export function make(pathname: Route['pathname']) {
const template = TEMPLATE_MAP[pathname];
const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
'/token/[hash]': '%symbol% token details',
'/token/[hash]/instance/[id]': 'token instance for %symbol%',
'/apps/[id]': '- %app_name%',
'/address/[hash]': 'address details for %domain_name%',
};
export function make(pathname: Route['pathname'], isEnriched = false) {
const template = (isEnriched ? TEMPLATE_MAP_ENHANCED[pathname] : undefined) ?? TEMPLATE_MAP[pathname];
return `%network_name% ${ template }`;
}
import type { Route } from 'nextjs-routes';
/* eslint-disable @typescript-eslint/indent */
export type ApiData<R extends Route> =
R['pathname'] extends '/token/[hash]' ? { symbol: string } :
R['pathname'] extends '/token/[hash]/instance/[id]' ? { symbol: string } :
R['pathname'] extends '/apps/[id]' ? { app_name: string } :
never;
export type ApiData<Pathname extends Route['pathname']> =
(
Pathname extends '/address/[hash]' ? { domain_name: string } :
Pathname extends '/token/[hash]' ? { symbol: string } :
Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } :
Pathname extends '/apps/[id]' ? { app_name: string } :
never
) | null;
export interface Metadata {
title: string;
......
import type { ApiData } from './types';
import type { RouteParams } from 'nextjs/types';
import type { Route } from 'nextjs-routes';
import generate from './generate';
export default function update<R extends Route>(route: R, apiData: ApiData<R>) {
export default function update<Pathname extends Route['pathname']>(route: RouteParams<Pathname>, apiData: ApiData<Pathname>) {
const { title, description } = generate(route, apiData);
window.document.title = title;
......
......@@ -8,13 +8,26 @@ const metrics = (() => {
promClient.register.clear();
const requestCounter = new promClient.Counter({
name: 'request_counter',
help: 'Number of incoming requests',
const socialPreviewBotRequests = new promClient.Counter({
name: 'social_preview_bot_requests_total',
help: 'Number of incoming requests from social preview bots',
labelNames: [ 'route', 'bot' ] as const,
});
return { requestCounter };
const searchEngineBotRequests = new promClient.Counter({
name: 'search_engine_bot_requests_total',
help: 'Number of incoming requests from search engine bots',
labelNames: [ 'route', 'bot' ] as const,
});
const apiRequestDuration = new promClient.Histogram({
name: 'api_request_duration_seconds',
help: 'Duration of requests to API in seconds',
labelNames: [ 'route', 'code' ],
buckets: [ 0.2, 0.5, 1, 3, 10 ],
});
return { socialPreviewBotRequests, searchEngineBotRequests, apiRequestDuration };
})();
export default metrics;
......@@ -61,6 +61,11 @@ export const withoutBothPrices = {
gas_prices: _mapValues(base.gas_prices, (price) => price ? ({ ...price, price: null, fiat_price: null }) : null),
};
export const withSecondaryCoin = {
...base,
secondary_coin_price: '3.398',
};
export const noChartData = {
...base,
transactions_today: null,
......
import type { TransactionsStats } from 'types/api/transaction';
export const base: TransactionsStats = {
pending_transactions_count: '4200',
transaction_fees_avg_24h: '22342870314428',
transaction_fees_sum_24h: '22184012506492688277',
transactions_count_24h: '992890',
};
......@@ -2,6 +2,7 @@ import Head from 'next/head';
import React from 'react';
import type { Route } from 'nextjs-routes';
import type { Props as PageProps } from 'nextjs/getServerSideProps';
import config from 'configs/app';
import useAdblockDetect from 'lib/hooks/useAdblockDetect';
......@@ -10,14 +11,17 @@ import * as metadata from 'lib/metadata';
import * as mixpanel from 'lib/mixpanel';
import { init as initSentry } from 'lib/sentry/config';
type Props = Route & {
interface Props<Pathname extends Route['pathname']> {
pathname: Pathname;
children: React.ReactNode;
query?: PageProps<Pathname>['query'];
apiData?: PageProps<Pathname>['apiData'];
}
initSentry();
const PageNextJs = (props: Props) => {
const { title, description, opengraph } = metadata.generate(props);
const PageNextJs = <Pathname extends Route['pathname']>(props: Props<Pathname>) => {
const { title, description, opengraph } = metadata.generate(props, props.apiData);
useGetCsrfToken();
useAdblockDetect();
......
import type { GetServerSideProps } from 'next';
import type { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
import type { AdBannerProviders } from 'types/client/adProviders';
import type { Route } from 'nextjs-routes';
import config from 'configs/app';
import isNeedProxy from 'lib/api/isNeedProxy';
const rollupFeature = config.features.rollup;
const adBannerFeature = config.features.adsBanner;
import type * as metadata from 'lib/metadata';
export type Props = {
export interface Props<Pathname extends Route['pathname'] = never> {
query: Route['query'];
cookies: string;
referrer: string;
id: string;
height_or_hash: string;
hash: string;
number: string;
q: string;
name: string;
adBannerProvider: string;
adBannerProvider: AdBannerProviders | undefined;
// if apiData is undefined, Next.js will complain that it is not serializable
// so we force it to be always present in the props but it can be null
apiData: metadata.ApiData<Pathname> | null;
}
export const base: GetServerSideProps<Props> = async({ req, query }) => {
export const base = async <Pathname extends Route['pathname'] = never>({ req, query }: GetServerSidePropsContext):
Promise<GetServerSidePropsResult<Props<Pathname>>> => {
const adBannerProvider = (() => {
if (adBannerFeature.isEnabled) {
if ('additionalProvider' in adBannerFeature && adBannerFeature.additionalProvider) {
......@@ -28,20 +32,16 @@ export const base: GetServerSideProps<Props> = async({ req, query }) => {
return adBannerFeature.provider;
}
}
return '';
return;
})();
return {
props: {
query,
cookies: req.headers.cookie || '',
referrer: req.headers.referer || '',
id: query.id?.toString() || '',
hash: query.hash?.toString() || '',
height_or_hash: query.height_or_hash?.toString() || '',
number: query.number?.toString() || '',
q: query.q?.toString() || '',
name: query.name?.toString() || '',
adBannerProvider,
apiData: null,
},
};
};
......@@ -119,14 +119,15 @@ export const batch: GetServerSideProps<Props> = async(context) => {
return base(context);
};
export const marketplace: GetServerSideProps<Props> = async(context) => {
export const marketplace = async <Pathname extends Route['pathname'] = never>(context: GetServerSidePropsContext):
Promise<GetServerSidePropsResult<Props<Pathname>>> => {
if (!config.features.marketplace.isEnabled) {
return {
notFound: true,
};
}
return base(context);
return base<Pathname>(context);
};
export const apiDocs: GetServerSideProps<Props> = async(context) => {
......
import type { NextPage } from 'next';
import type { Route } from 'nextjs-routes';
// eslint-disable-next-line @typescript-eslint/ban-types
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: React.ReactElement) => React.ReactNode;
}
export interface RouteParams<Pathname extends Route['pathname']> {
pathname: Pathname;
query?: Route['query'];
}
import type { IncomingMessage } from 'http';
type SocialPreviewBot = 'twitter' | 'facebook' | 'telegram' | 'slack';
type SearchEngineBot = 'google' | 'bing' | 'yahoo' | 'duckduckgo';
type ReturnType = {
type: 'social_preview';
bot: SocialPreviewBot;
} | {
type: 'search_engine';
bot: SearchEngineBot;
} | undefined
export default function detectBotRequest(req: IncomingMessage): ReturnType {
const userAgent = req.headers['user-agent'];
if (!userAgent) {
return;
}
if (userAgent.toLowerCase().includes('twitter')) {
return { type: 'social_preview', bot: 'twitter' };
}
if (userAgent.toLowerCase().includes('facebook')) {
return { type: 'social_preview', bot: 'facebook' };
}
if (userAgent.toLowerCase().includes('telegram')) {
return { type: 'social_preview', bot: 'telegram' };
}
if (userAgent.toLowerCase().includes('slack')) {
return { type: 'social_preview', bot: 'slack' };
}
if (userAgent.toLowerCase().includes('googlebot')) {
return { type: 'search_engine', bot: 'google' };
}
if (userAgent.toLowerCase().includes('bingbot')) {
return { type: 'search_engine', bot: 'bing' };
}
if (userAgent.toLowerCase().includes('yahoo')) {
return { type: 'search_engine', bot: 'yahoo' };
}
if (userAgent.toLowerCase().includes('duckduck')) {
return { type: 'search_engine', bot: 'duckduckgo' };
}
}
import fetch, { AbortError } from 'node-fetch';
import buildUrl from 'nextjs/utils/buildUrl';
import { httpLogger } from 'nextjs/utils/logger';
import { RESOURCES } from 'lib/api/resources';
import type { ResourceName, ResourcePathParams, ResourcePayload } from 'lib/api/resources';
import { SECOND } from 'lib/consts';
import metrics from 'lib/monitoring/metrics';
type Params<R extends ResourceName> = (
{
resource: R;
pathParams?: ResourcePathParams<R>;
} | {
url: string;
route: string;
}
) & {
timeout?: number;
}
export default async function fetchApi<R extends ResourceName = never, S = ResourcePayload<R>>(params: Params<R>): Promise<S | undefined> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, params.timeout || SECOND);
const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams);
const route = 'route' in params ? params.route : RESOURCES[params.resource]['path'];
const end = metrics?.apiRequestDuration.startTimer();
try {
const response = await fetch(url, { signal: controller.signal });
const duration = end?.({ route, code: response.status });
if (response.status === 200) {
httpLogger.logger.info({ message: 'API fetch', url, code: response.status, duration });
} else {
httpLogger.logger.error({ message: 'API fetch', url, code: response.status, duration });
}
return await response.json() as Promise<S>;
} catch (error) {
const code = error instanceof AbortError ? 504 : 500;
const duration = end?.({ route, code });
httpLogger.logger.error({ message: 'API fetch', url, code, duration });
} finally {
clearTimeout(timeout);
}
}
......@@ -30,14 +30,9 @@ export default function fetchFactory(
};
httpLogger.logger.info({
message: 'Trying to call API',
message: 'API fetch via Next.js proxy',
url,
req: _req,
});
httpLogger.logger.info({
message: 'API request headers',
headers,
// headers,
});
const body = (() => {
......
import type { IncomingMessage, ServerResponse } from 'http';
import metrics from 'lib/monitoring/metrics';
import detectBotRequest from './detectBotRequest';
export default async function logRequestFromBot(req: IncomingMessage | undefined, res: ServerResponse<IncomingMessage> | undefined, pathname: string) {
if (!req || !res || !metrics) {
return;
}
const botInfo = detectBotRequest(req);
if (!botInfo) {
return;
}
switch (botInfo.type) {
case 'search_engine': {
metrics.searchEngineBotRequests.inc({ route: pathname, bot: botInfo.bot });
return;
}
case 'social_preview': {
metrics.socialPreviewBotRequests.inc({ route: pathname, bot: botInfo.bot });
return;
}
}
}
......@@ -3,9 +3,9 @@ import type { DocumentContext } from 'next/document';
import Document, { Html, Head, Main, NextScript } from 'next/document';
import React from 'react';
import logRequestFromBot from 'nextjs/utils/logRequestFromBot';
import * as serverTiming from 'nextjs/utils/serverTiming';
import getApiDataForSocialPreview from 'lib/metadata/getApiDataForSocialPreview';
import theme from 'theme';
import * as svgSprite from 'ui/shared/IconSvg';
......@@ -22,7 +22,7 @@ class MyDocument extends Document {
return result;
};
await getApiDataForSocialPreview(ctx.req, ctx.res, ctx.pathname);
await logRequestFromBot(ctx.req, ctx.res, ctx.pathname);
const initialProps = await Document.getInitialProps(ctx);
......
......@@ -8,7 +8,7 @@ import ContractVerificationForAddress from 'ui/pages/ContractVerificationForAddr
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/address/[hash]/contract-verification" query={ props }>
<PageNextJs pathname="/address/[hash]/contract-verification" query={ props.query }>
<ContractVerificationForAddress/>
</PageNextJs>
);
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import type { GetServerSideProps, NextPage } from 'next';
import React from 'react';
import type { Route } from 'nextjs-routes';
import type { Props } from 'nextjs/getServerSideProps';
import * as gSSP from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import detectBotRequest from 'nextjs/utils/detectBotRequest';
import fetchApi from 'nextjs/utils/fetchApi';
const Address = dynamic(() => import('ui/pages/Address'), { ssr: false });
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
import Address from 'ui/pages/Address';
const Page: NextPage<Props> = (props: Props) => {
const pathname: Route['pathname'] = '/address/[hash]';
const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/address/[hash]" query={ props }>
<PageNextJs pathname="/address/[hash]" query={ props.query } apiData={ props.apiData }>
<Address/>
</PageNextJs>
);
......@@ -17,4 +24,24 @@ const Page: NextPage<Props> = (props: Props) => {
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
const addressData = await fetchApi({
resource: 'address',
pathParams: { hash: getQueryParamString(ctx.query.hash) },
timeout: 1_000,
});
(await baseResponse.props).apiData = addressData && addressData.ens_domain_name ? {
domain_name: addressData.ens_domain_name,
} : null;
}
}
return baseResponse;
};
import type { NextApiRequest, NextApiResponse } from 'next';
import buildUrl from 'nextjs/utils/buildUrl';
import fetchFactory from 'nextjs/utils/fetch';
import fetchFactory from 'nextjs/utils/fetchProxy';
import { httpLogger } from 'nextjs/utils/logger';
export default async function csrfHandler(_req: NextApiRequest, res: NextApiResponse) {
......
......@@ -3,16 +3,20 @@ import nodeFetch from 'node-fetch';
import { httpLogger } from 'nextjs/utils/logger';
import metrics from 'lib/monitoring/metrics';
import getQueryParamString from 'lib/router/getQueryParamString';
export default async function mediaTypeHandler(req: NextApiRequest, res: NextApiResponse) {
httpLogger(req, res);
try {
const url = getQueryParamString(req.query.url);
const end = metrics?.apiRequestDuration.startTimer();
const response = await nodeFetch(url, { method: 'HEAD' });
const duration = end?.({ route: '/media-type', code: response.status });
if (response.status !== 200) {
httpLogger.logger.error({ message: 'API fetch', url, code: response.status, duration });
throw new Error();
}
......@@ -30,6 +34,8 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi
return 'html';
}
})();
httpLogger.logger.info({ message: 'API fetch', url, code: response.status, duration });
res.status(200).json({ type: mediaType });
} catch (error) {
res.status(200).json({ type: undefined });
......
......@@ -4,7 +4,7 @@ import * as promClient from 'prom-client';
// eslint-disable-next-line no-restricted-properties
const isEnabled = process.env.PROMETHEUS_METRICS_ENABLED === 'true';
isEnabled && promClient.collectDefaultMetrics();
isEnabled && promClient.collectDefaultMetrics({ prefix: 'frontend_' });
export default async function metricsHandler(req: NextApiRequest, res: NextApiResponse) {
const metrics = await promClient.register.metrics();
......
......@@ -2,7 +2,7 @@ import _pick from 'lodash/pick';
import _pickBy from 'lodash/pickBy';
import type { NextApiRequest, NextApiResponse } from 'next';
import fetchFactory from 'nextjs/utils/fetch';
import fetchFactory from 'nextjs/utils/fetchProxy';
import appConfig from 'configs/app';
......
import type { GetServerSideProps } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { NextPageWithLayout } from 'nextjs/types';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import type { Route } from 'nextjs-routes';
import * as gSSP from 'nextjs/getServerSideProps';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import detectBotRequest from 'nextjs/utils/detectBotRequest';
import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
import LayoutApp from 'ui/shared/layout/LayoutApp';
const MarketplaceApp = dynamic(() => import('ui/pages/MarketplaceApp'), { ssr: false });
const Page: NextPageWithLayout<Props> = (props: Props) => {
const pathname: Route['pathname'] = '/apps/[id]';
const feature = config.features.marketplace;
const Page: NextPageWithLayout<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/apps/[id]" query={ props }>
<PageNextJs pathname="/apps/[id]" query={ props.query } apiData={ props.apiData }>
<MarketplaceApp/>
</PageNextJs>
);
......@@ -28,4 +39,40 @@ Page.getLayout = function getLayout(page: React.ReactElement) {
export default Page;
export { marketplace as getServerSideProps } from 'nextjs/getServerSideProps';
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.marketplace<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse && feature.isEnabled) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
const appData = await(async() => {
if ('configUrl' in feature) {
const appList = await fetchApi<never, Array<MarketplaceAppOverview>>({
url: config.app.baseUrl + feature.configUrl,
route: '/marketplace_config',
timeout: 1_000,
});
if (appList && Array.isArray(appList)) {
return appList.find(app => app.id === getQueryParamString(ctx.query.id));
}
} else {
return await fetchApi({
resource: 'marketplace_dapp',
pathParams: { dappId: getQueryParamString(ctx.query.id), chainId: config.chain.id },
timeout: 1_000,
});
}
})();
(await baseResponse.props).apiData = appData && appData.title ? {
app_name: appData.title,
} : null;
}
}
return baseResponse;
};
......@@ -25,7 +25,7 @@ const Batch = dynamic(() => {
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/batches/[number]" query={ props }>
<PageNextJs pathname="/batches/[number]" query={ props.query }>
<Batch/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const Blob = dynamic(() => import('ui/pages/Blob'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/blobs/[hash]" query={ props }>
<PageNextJs pathname="/blobs/[hash]" query={ props.query }>
<Blob/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const Block = dynamic(() => import('ui/pages/Block'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/block/[height_or_hash]" query={ props }>
<PageNextJs pathname="/block/[height_or_hash]" query={ props.query }>
<Block/>
</PageNextJs>
);
......
......@@ -8,7 +8,7 @@ import ContractVerification from 'ui/pages/ContractVerification';
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/contract-verification" query={ props }>
<PageNextJs pathname="/contract-verification" query={ props.query }>
<ContractVerification/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const NameDomain = dynamic(() => import('ui/pages/NameDomain'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/name-domains/[name]" query={ props }>
<PageNextJs pathname="/name-domains/[name]" query={ props.query }>
<NameDomain/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const UserOp = dynamic(() => import('ui/pages/UserOp'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/op/[hash]" query={ props }>
<PageNextJs pathname="/op/[hash]" query={ props.query }>
<UserOp/>
</PageNextJs>
);
......
......@@ -12,7 +12,7 @@ const SearchResults = dynamic(() => import('ui/pages/SearchResults'), { ssr: fal
const Page: NextPageWithLayout<Props> = (props: Props) => {
return (
<PageNextJs pathname="/search-results" query={ props }>
<PageNextJs pathname="/search-results" query={ props.query }>
<SearchResults/>
</PageNextJs>
);
......
import type { NextPage } from 'next';
import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Route } from 'nextjs-routes';
import type { Props } from 'nextjs/getServerSideProps';
import * as gSSP from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import detectBotRequest from 'nextjs/utils/detectBotRequest';
import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
const Token = dynamic(() => import('ui/pages/Token'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
const pathname: Route['pathname'] = '/token/[hash]';
const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/token/[hash]" query={ props }>
<PageNextJs pathname="/token/[hash]" query={ props.query } apiData={ props.apiData }>
<Token/>
</PageNextJs>
);
......@@ -17,4 +26,24 @@ const Page: NextPage<Props> = (props: Props) => {
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
const tokenData = await fetchApi({
resource: 'token',
pathParams: { hash: getQueryParamString(ctx.query.hash) },
timeout: 1_000,
});
(await baseResponse.props).apiData = tokenData && tokenData.symbol ? {
symbol: tokenData.symbol,
} : null;
}
}
return baseResponse;
};
import type { NextPage } from 'next';
import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Route } from 'nextjs-routes';
import type { Props } from 'nextjs/getServerSideProps';
import * as gSSP from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import detectBotRequest from 'nextjs/utils/detectBotRequest';
import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
const TokenInstance = dynamic(() => import('ui/pages/TokenInstance'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
const pathname: Route['pathname'] = '/token/[hash]/instance/[id]';
const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/token/[hash]/instance/[id]" query={ props }>
<PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }>
<TokenInstance/>
</PageNextJs>
);
......@@ -17,4 +26,24 @@ const Page: NextPage<Props> = (props: Props) => {
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
const tokenData = await fetchApi({
resource: 'token',
pathParams: { hash: getQueryParamString(ctx.query.hash) },
timeout: 1_000,
});
(await baseResponse.props).apiData = tokenData && tokenData.symbol ? {
symbol: tokenData.symbol,
} : null;
}
}
return baseResponse;
};
......@@ -9,7 +9,7 @@ const Transaction = dynamic(() => import('ui/pages/Transaction'), { ssr: false }
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/tx/[hash]" query={ props }>
<PageNextJs pathname="/tx/[hash]" query={ props.query }>
<Transaction/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const KettleTxs = dynamic(() => import('ui/pages/KettleTxs'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/txs/kettle/[hash]" query={ props }>
<PageNextJs pathname="/txs/kettle/[hash]" query={ props.query }>
<KettleTxs/>
</PageNextJs>
);
......
......@@ -24,13 +24,9 @@ const defaultAppContext = {
pageProps: {
cookies: '',
referrer: '',
id: '',
height_or_hash: '',
hash: '',
number: '',
q: '',
name: '',
adBannerProvider: 'slise',
query: {},
adBannerProvider: 'slise' as const,
apiData: null,
},
};
......
import type { RawTracesResponse } from 'types/api/rawTrace';
import type { Transaction } from 'types/api/transaction';
import type { Transaction, TransactionsStats } from 'types/api/transaction';
import { ADDRESS_PARAMS } from './addressParams';
......@@ -59,3 +59,10 @@ export const TX_ZKEVM_L2: Transaction = {
};
export const TX_RAW_TRACE: RawTracesResponse = [];
export const TXS_STATS: TransactionsStats = {
pending_transactions_count: '4200',
transaction_fees_avg_24h: '22342870314428',
transaction_fees_sum_24h: '22184012506492688277',
transactions_count_24h: '992890',
};
......@@ -23,7 +23,7 @@ const sizes = {
minH: 6,
minW: 6,
fontSize: 'sm',
px: 2,
px: 1,
py: '2px',
lineHeight: 5,
},
......
......@@ -18,3 +18,8 @@ export interface ChartMarketResponse {
available_supply: string;
chart_data: Array<ChartMarketItem>;
}
export interface ChartSecondaryCoinPriceResponse {
available_supply: string;
chart_data: Array<ChartMarketItem>;
}
......@@ -16,6 +16,8 @@ export type HomeStats = {
network_utilization_percentage: number;
tvl: string | null;
rootstock_locked_btc?: string | null;
last_output_root_size?: string | null;
secondary_coin_price?: string | null;
}
export type GasPrices = {
......
......@@ -97,6 +97,13 @@ export type Transaction = {
export const ZKEVM_L2_TX_STATUSES = [ 'Confirmed by Sequencer', 'L1 Confirmed' ];
export interface TransactionsStats {
pending_transactions_count: string;
transaction_fees_avg_24h: string;
transaction_fees_sum_24h: string;
transactions_count_24h: string;
}
export type TransactionsResponse = TransactionsResponseValidated | TransactionsResponsePending;
export interface TransactionsResponseValidated {
......
export type ChainIndicatorId = 'daily_txs' | 'coin_price' | 'market_cap' | 'tvl';
export const CHAIN_INDICATOR_IDS = [ 'daily_txs', 'coin_price', 'secondary_coin_price', 'market_cap', 'tvl' ] as const;
export type ChainIndicatorId = typeof CHAIN_INDICATOR_IDS[number];
......@@ -7,6 +7,7 @@ import type { NovesHistoryFilterValue } from 'types/api/noves';
import { NovesHistoryFilterValues } from 'types/api/noves';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { NOVES_TRANSLATE } from 'stubs/noves/NovesTranslate';
import { generateListStub } from 'stubs/utils';
......@@ -25,10 +26,12 @@ const getFilterValue = (getFilterValueFromQuery<NovesHistoryFilterValue>).bind(n
type Props = {
scrollRef?: React.RefObject<HTMLDivElement>;
shouldRender?: boolean;
}
const AddressAccountHistory = ({ scrollRef }: Props) => {
const AddressAccountHistory = ({ scrollRef, shouldRender = true }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const currentAddress = getQueryParamString(router.query.hash).toLowerCase();
......@@ -49,6 +52,10 @@ const AddressAccountHistory = ({ scrollRef }: Props) => {
setFilterValue(newVal);
}, [ ]);
if (!isMounted || !shouldRender) {
return null;
}
const actionBar = (
<ActionBar mt={ -6 } pb={{ base: 6, md: 5 }}>
<AccountHistoryFilter
......
......@@ -8,6 +8,7 @@ import type { AddressBlocksValidatedResponse } from 'types/api/address';
import config from 'configs/app';
import { getResourceKey } from 'lib/api/useApiQuery';
import useIsMounted from 'lib/hooks/useIsMounted';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { currencyUnits } from 'lib/units';
......@@ -25,12 +26,14 @@ import AddressBlocksValidatedTableItem from './blocksValidated/AddressBlocksVali
interface Props {
scrollRef?: React.RefObject<HTMLDivElement>;
shouldRender?: boolean;
}
const AddressBlocksValidated = ({ scrollRef }: Props) => {
const AddressBlocksValidated = ({ scrollRef, shouldRender = true }: Props) => {
const [ socketAlert, setSocketAlert ] = React.useState(false);
const queryClient = useQueryClient();
const router = useRouter();
const isMounted = useIsMounted();
const addressHash = String(router.query.hash);
const query = useQueryWithPages({
......@@ -84,6 +87,10 @@ const AddressBlocksValidated = ({ scrollRef }: Props) => {
handler: handleNewSocketMessage,
});
if (!isMounted || !shouldRender) {
return null;
}
const content = query.data?.items ? (
<>
{ socketAlert && <SocketAlert mb={ 6 }/> }
......
......@@ -6,6 +6,7 @@ import type { SocketMessage } from 'lib/socket/types';
import type { AddressCoinBalanceHistoryResponse } from 'types/api/address';
import { getResourceKey } from 'lib/api/useApiQuery';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
......@@ -17,10 +18,16 @@ import SocketAlert from 'ui/shared/SocketAlert';
import AddressCoinBalanceChart from './coinBalance/AddressCoinBalanceChart';
import AddressCoinBalanceHistory from './coinBalance/AddressCoinBalanceHistory';
const AddressCoinBalance = () => {
type Props = {
shouldRender?: boolean;
}
const AddressCoinBalance = ({ shouldRender = true }: Props) => {
const [ socketAlert, setSocketAlert ] = React.useState(false);
const queryClient = useQueryClient();
const router = useRouter();
const isMounted = useIsMounted();
const scrollRef = React.useRef<HTMLDivElement>(null);
const addressHash = getQueryParamString(router.query.hash);
......@@ -78,6 +85,10 @@ const AddressCoinBalance = () => {
handler: handleNewSocketMessage,
});
if (!isMounted || !shouldRender) {
return null;
}
return (
<>
{ socketAlert && <SocketAlert mb={ 6 }/> }
......
......@@ -3,6 +3,7 @@ import { useRouter } from 'next/router';
import React from 'react';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressCounterItem from 'ui/address/details/AddressCounterItem';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
......@@ -60,6 +61,8 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
has_validated_blocks: false,
}), [ addressHash ]);
const isMounted = useIsMounted();
// error handling (except 404 codes)
if (addressQuery.isError) {
if (isCustomAppError(addressQuery.error)) {
......@@ -74,7 +77,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
const data = addressQuery.isError ? error404Data : addressQuery.data;
if (!data) {
if (!data || !isMounted) {
return null;
}
......
......@@ -6,6 +6,7 @@ import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import useIsMounted from 'lib/hooks/useIsMounted';
import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString';
import { INTERNAL_TX } from 'stubs/internalTx';
......@@ -22,8 +23,14 @@ import AddressIntTxsList from './internals/AddressIntTxsList';
const getFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
const AddressInternalTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
type Props = {
scrollRef?: React.RefObject<HTMLDivElement>;
shouldRender?: boolean;
}
const AddressInternalTxs = ({ scrollRef, shouldRender = true }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const [ filterValue, setFilterValue ] = React.useState<AddressFromToFilter>(getFilterValue(router.query.filter));
const hash = getQueryParamString(router.query.hash);
......@@ -55,6 +62,10 @@ const AddressInternalTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivE
onFilterChange({ filter: newVal });
}, [ onFilterChange ]);
if (!isMounted || !shouldRender) {
return null;
}
const content = data?.items ? (
<>
<Show below="lg" ssr={ false }>
......
import { useRouter } from 'next/router';
import React from 'react';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { LOG } from 'stubs/log';
import { generateListStub } from 'stubs/utils';
......@@ -12,8 +13,14 @@ import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import AddressCsvExportLink from './AddressCsvExportLink';
const AddressLogs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
type Props ={
scrollRef?: React.RefObject<HTMLDivElement>;
shouldRender?: boolean;
}
const AddressLogs = ({ scrollRef, shouldRender = true }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const hash = getQueryParamString(router.query.hash);
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
......@@ -41,6 +48,10 @@ const AddressLogs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>
</ActionBar>
);
if (!isMounted || !shouldRender) {
return null;
}
const content = data?.items ? data.items.map((item, index) => <LogItem key={ index } { ...item } type="address" isLoading={ isPlaceholderData }/>) : null;
return (
......
......@@ -13,6 +13,7 @@ import { getResourceKey } from 'lib/api/useApiQuery';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
......@@ -63,14 +64,16 @@ const matchFilters = (filters: Filters, tokenTransfer: TokenTransfer, address?:
type Props = {
scrollRef?: React.RefObject<HTMLDivElement>;
shouldRender?: boolean;
// for tests only
overloadCount?: number;
}
const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => {
const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT, shouldRender = true }: Props) => {
const router = useRouter();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const currentAddress = getQueryParamString(router.query.hash);
......@@ -179,6 +182,18 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr
handler: handleNewSocketMessage,
});
const tokenData = React.useMemo(() => ({
address: tokenFilter || '',
name: '',
icon_url: '',
symbol: '',
type: 'ERC-20' as const,
}), [ tokenFilter ]);
if (!isMounted || !shouldRender) {
return null;
}
const numActiveFilters = (filters.type?.length || 0) + (filters.filter ? 1 : 0);
const isActionBarHidden = !tokenFilter && !numActiveFilters && !data?.items.length && !currentAddress;
......@@ -218,14 +233,6 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr
</>
) : null;
const tokenData = React.useMemo(() => ({
address: tokenFilter || '',
name: '',
icon_url: '',
symbol: '',
type: 'ERC-20' as const,
}), [ tokenFilter ]);
const tokenFilterComponent = tokenFilter && (
<Flex alignItems="center" flexWrap="wrap" mb={{ base: isActionBarHidden ? 3 : 6, lg: 0 }} mr={ 4 }>
<Text whiteSpace="nowrap" mr={ 2 } py={ 1 }>Filtered by token</Text>
......
......@@ -9,6 +9,7 @@ import { useAppContext } from 'lib/contexts/app';
import * as cookies from 'lib/cookies';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { NFT_TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import { ADDRESS_TOKEN_BALANCE_ERC_20, ADDRESS_NFT_1155, ADDRESS_COLLECTION } from 'stubs/address';
......@@ -41,9 +42,14 @@ const TAB_LIST_PROPS_MOBILE = {
const getTokenFilterValue = (getFilterValuesFromQuery<NFTTokenType>).bind(null, NFT_TOKEN_TYPE_IDS);
const AddressTokens = () => {
type Props = {
shouldRender?: boolean;
}
const AddressTokens = ({ shouldRender = true }: Props) => {
const router = useRouter();
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const scrollRef = React.useRef<HTMLDivElement>(null);
......@@ -99,6 +105,10 @@ const AddressTokens = () => {
setTokenTypes(value);
}, [ nftsQuery, collectionsQuery ]);
if (!isMounted || !shouldRender) {
return null;
}
const nftTypeFilter = (
<PopoverFilter isActive={ tokenTypes && tokenTypes.length > 0 } contentProps={{ w: '200px' }} appliedFiltersNum={ tokenTypes?.length }>
<TokenTypeFilter<NFTTokenType> nftOnly onChange={ handleTokenTypesChange } defaultValue={ tokenTypes }/>
......
......@@ -10,6 +10,7 @@ import type { Transaction, TransactionsSortingField, TransactionsSortingValue, T
import { getResourceKey } from 'lib/api/useApiQuery';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
......@@ -47,13 +48,15 @@ const matchFilter = (filterValue: AddressFromToFilter, transaction: Transaction,
type Props = {
scrollRef?: React.RefObject<HTMLDivElement>;
shouldRender?: boolean;
// for tests only
overloadCount?: number;
}
const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => {
const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT, shouldRender = true }: Props) => {
const router = useRouter();
const queryClient = useQueryClient();
const isMounted = useIsMounted();
const [ socketAlert, setSocketAlert ] = React.useState('');
const [ newItemsCount, setNewItemsCount ] = React.useState(0);
......@@ -156,6 +159,10 @@ const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => {
handler: handleNewSocketMessage,
});
if (!isMounted || !shouldRender) {
return null;
}
const filter = (
<AddressTxsFilter
defaultFilter={ filterValue }
......
import { useRouter } from 'next/router';
import React from 'react';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { USER_OPS_ITEM } from 'stubs/userOps';
import { generateListStub } from 'stubs/utils';
......@@ -9,10 +10,12 @@ import UserOpsContent from 'ui/userOps/UserOpsContent';
type Props = {
scrollRef?: React.RefObject<HTMLDivElement>;
shouldRender?: boolean;
}
const AddressUserOps = ({ scrollRef }: Props) => {
const AddressUserOps = ({ scrollRef, shouldRender = true }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const hash = getQueryParamString(router.query.hash);
......@@ -29,6 +32,10 @@ const AddressUserOps = ({ scrollRef }: Props) => {
filters: { sender: hash },
});
if (!isMounted || !shouldRender) {
return null;
}
return <UserOpsContent query={ userOpsQuery } showSender={ false }/>;
};
......
......@@ -2,6 +2,7 @@ import { Show, Hide } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { generateListStub } from 'stubs/utils';
import { WITHDRAWAL } from 'stubs/withdrawals';
......@@ -12,8 +13,13 @@ import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import BeaconChainWithdrawalsListItem from 'ui/withdrawals/beaconChain/BeaconChainWithdrawalsListItem';
import BeaconChainWithdrawalsTable from 'ui/withdrawals/beaconChain/BeaconChainWithdrawalsTable';
const AddressWithdrawals = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
type Props = {
scrollRef?: React.RefObject<HTMLDivElement>;
shouldRender?: boolean;
}
const AddressWithdrawals = ({ scrollRef, shouldRender = true }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const hash = getQueryParamString(router.query.hash);
......@@ -28,6 +34,11 @@ const AddressWithdrawals = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivE
} }),
},
});
if (!isMounted || !shouldRender) {
return null;
}
const content = data?.items ? (
<>
<Show below="lg" ssr={ false }>
......
......@@ -52,7 +52,7 @@ const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => {
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 3 } w="fit-content">
{ data.price && data.fiat_price && <GasPrice data={ data } prefix={ `${ asymp } ` } unitMode="secondary"/> }
<span> per transaction</span>
{ data.time && <span> / { (data.time / SECOND).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s</span> }
{ typeof data.time === 'number' && data.time > 0 && <span> / { (data.time / SECOND).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s</span> }
</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 2 } w="fit-content" whiteSpace="pre">
{ data.base_fee && <span>Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> }
......
......@@ -59,6 +59,7 @@ const Stats = () => {
if (data) {
!data.gas_prices && itemsCount--;
data.rootstock_locked_btc && itemsCount++;
rollupFeature.isEnabled && data.last_output_root_size && itemsCount++;
const isOdd = Boolean(itemsCount % 2);
const gasInfoTooltip = hasGasTracker && data.gas_prices ? (
<GasInfoTooltip data={ data } dataUpdatedAt={ dataUpdatedAt }>
......@@ -120,6 +121,15 @@ const Stats = () => {
url={ route({ pathname: '/txs' }) }
isLoading={ isLoading }
/>
{ rollupFeature.isEnabled && data.last_output_root_size && (
<StatsItem
icon="txn_batches"
title="Latest L1 state batch"
value={ data.last_output_root_size }
url={ route({ pathname: '/batches' }) }
isLoading={ isLoading }
/>
) }
<StatsItem
icon="wallet"
title="Wallet addresses"
......
......@@ -78,7 +78,8 @@ const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onC
<Flex
alignItems="center"
columnGap={ 3 }
p={ 4 }
px={ 4 }
py={ 2 }
as="li"
borderRadius="md"
cursor="pointer"
......
......@@ -15,7 +15,8 @@ const TX_CHART_API_URL = buildApiUrl('stats_charts_txs');
const test = base.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_HOMEPAGE_CHARTS', value: '["daily_txs","coin_price","market_cap","tvl"]' },
{ name: 'NEXT_PUBLIC_HOMEPAGE_CHARTS', value: '["daily_txs","coin_price","secondary_coin_price","market_cap","tvl"]' },
{ name: 'NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL', value: 'DUCK' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
});
......@@ -26,7 +27,7 @@ test.describe('daily txs chart', () => {
test.beforeEach(async({ page, mount }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
body: JSON.stringify(statsMock.withSecondaryCoin),
}));
await page.route(TX_CHART_API_URL, (route) => route.fulfill({
status: 200,
......
......@@ -4,7 +4,7 @@ import type { TimeChartData } from 'ui/shared/chart/types';
import type { ResourcePayload } from 'lib/api/resources';
export type ChartsResources = 'stats_charts_txs' | 'stats_charts_market';
export type ChartsResources = 'stats_charts_txs' | 'stats_charts_market' | 'stats_charts_secondary_coin_price';
export interface TChainIndicator<R extends ChartsResources> {
id: ChainIndicatorId;
......
......@@ -50,13 +50,13 @@ const nativeTokenData = {
const coinPriceIndicator: TChainIndicator<'stats_charts_market'> = {
id: 'coin_price',
title: `${ config.chain.governanceToken.symbol || config.chain.currency.symbol } price`,
title: `${ config.chain.currency.symbol } price`,
value: (stats) => stats.coin_price === null ?
'$N/A' :
'$' + Number(stats.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
valueDiff: (stats) => stats?.coin_price !== null ? stats?.coin_price_change_percentage : null,
icon: <TokenEntity.Icon token={ nativeTokenData } boxSize={ 6 } marginRight={ 0 }/>,
hint: `${ config.chain.governanceToken.symbol || config.chain.currency.symbol } token daily price in USD.`,
hint: `${ config.chain.currency.symbol } token daily price in USD.`,
api: {
resourceName: 'stats_charts_market',
dataFn: (response) => ([ {
......@@ -65,7 +65,30 @@ const coinPriceIndicator: TChainIndicator<'stats_charts_market'> = {
.sort(sortByDateDesc)
.reduceRight(nonNullTailReducer, [] as Array<TimeChartItemRaw>)
.map(mapNullToZero),
name: `${ config.chain.governanceToken.symbol || config.chain.currency.symbol } price`,
name: `${ config.chain.currency.symbol } price`,
valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
} ]),
},
};
const secondaryCoinPriceIndicator: TChainIndicator<'stats_charts_secondary_coin_price'> = {
id: 'secondary_coin_price',
title: `${ config.chain.secondaryCoin.symbol } price`,
value: (stats) => !stats.secondary_coin_price || stats.secondary_coin_price === null ?
'$N/A' :
'$' + Number(stats.secondary_coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
valueDiff: () => null,
icon: <TokenEntity.Icon token={ nativeTokenData } boxSize={ 6 } marginRight={ 0 }/>,
hint: `${ config.chain.secondaryCoin.symbol } token daily price in USD.`,
api: {
resourceName: 'stats_charts_secondary_coin_price',
dataFn: (response) => ([ {
items: response.chart_data
.map((item) => ({ date: new Date(item.date), value: item.closing_price }))
.sort(sortByDateDesc)
.reduceRight(nonNullTailReducer, [] as Array<TimeChartItemRaw>)
.map(mapNullToZero),
name: `${ config.chain.secondaryCoin.symbol } price`,
valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
} ]),
},
......@@ -138,6 +161,7 @@ const tvlIndicator: TChainIndicator<'stats_charts_market'> = {
const INDICATORS = [
dailyTxsIndicator,
coinPriceIndicator,
secondaryCoinPriceIndicator,
marketPriceIndicator,
tvlIndicator,
];
......
......@@ -12,7 +12,7 @@ import IframeBanner from './Banner/IframeBanner';
const feature = config.features.marketplace;
type BannerProps = {
apps: Array<MarketplaceAppPreview>;
apps: Array<MarketplaceAppPreview> | undefined;
favoriteApps: Array<string>;
isLoading: boolean;
onInfoClick: (id: string) => void;
......@@ -20,7 +20,7 @@ type BannerProps = {
onAppClick: (event: MouseEvent, id: string) => void;
}
const Banner = ({ apps, favoriteApps, isLoading, onInfoClick, onFavoriteClick, onAppClick }: BannerProps) => {
const Banner = ({ apps = [], favoriteApps, isLoading, onInfoClick, onFavoriteClick, onAppClick }: BannerProps) => {
if (!feature.isEnabled) {
return null;
}
......
......@@ -146,6 +146,7 @@ export default function useMarketplace() {
isError,
error,
categories,
apps: data,
displayedApps,
showAppInfo,
selectedAppId,
......@@ -167,6 +168,7 @@ export default function useMarketplace() {
categories,
clearSelectedAppId,
selectedAppId,
data,
displayedApps,
error,
favoriteApps,
......@@ -179,7 +181,6 @@ export default function useMarketplace() {
isAppInfoModalOpen,
isDisclaimerModalOpen,
showDisclaimer,
data?.length,
isCategoriesPlaceholderData,
showContractList,
contractListModalType,
......
......@@ -39,7 +39,6 @@ import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
const TOKEN_TABS = [ 'tokens_erc20', 'tokens_nfts', 'tokens_nfts_collection', 'tokens_nfts_list' ];
......@@ -75,19 +74,22 @@ const AddressPageContent = () => {
const contractTabs = useContractTabs(addressQuery.data);
const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData);
const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData;
const tabs: Array<RoutedTab> = React.useMemo(() => {
return [
{
id: 'txs',
title: 'Transactions',
count: addressTabsCountersQuery.data?.transactions_count,
component: <AddressTxs scrollRef={ tabsScrollRef }/>,
component: <AddressTxs scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading }/>,
},
txInterpretation.isEnabled && txInterpretation.provider === 'noves' ?
{
id: 'account_history',
title: 'Account history',
component: <AddressAccountHistory scrollRef={ tabsScrollRef }/>,
component: <AddressAccountHistory scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading }/>,
} :
undefined,
config.features.userOps.isEnabled && Boolean(userOpsAccountQuery.data?.total_ops) ?
......@@ -95,7 +97,7 @@ const AddressPageContent = () => {
id: 'user_ops',
title: 'User operations',
count: userOpsAccountQuery.data?.total_ops,
component: <AddressUserOps/>,
component: <AddressUserOps shouldRender={ !isTabsLoading }/>,
} :
undefined,
config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.withdrawals_count ?
......@@ -103,39 +105,39 @@ const AddressPageContent = () => {
id: 'withdrawals',
title: 'Withdrawals',
count: addressTabsCountersQuery.data?.withdrawals_count,
component: <AddressWithdrawals scrollRef={ tabsScrollRef }/>,
component: <AddressWithdrawals scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading }/>,
} :
undefined,
{
id: 'token_transfers',
title: 'Token transfers',
count: addressTabsCountersQuery.data?.token_transfers_count,
component: <AddressTokenTransfers scrollRef={ tabsScrollRef }/>,
component: <AddressTokenTransfers scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading }/>,
},
{
id: 'tokens',
title: 'Tokens',
count: addressTabsCountersQuery.data?.token_balances_count,
component: <AddressTokens/>,
component: <AddressTokens shouldRender={ !isTabsLoading }/>,
subTabs: TOKEN_TABS,
},
{
id: 'internal_txns',
title: 'Internal txns',
count: addressTabsCountersQuery.data?.internal_txs_count,
component: <AddressInternalTxs scrollRef={ tabsScrollRef }/>,
component: <AddressInternalTxs scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading }/>,
},
{
id: 'coin_balance_history',
title: 'Coin balance history',
component: <AddressCoinBalance/>,
component: <AddressCoinBalance shouldRender={ !isTabsLoading }/>,
},
config.chain.verificationType === 'validation' && addressTabsCountersQuery.data?.validations_count ?
{
id: 'blocks_validated',
title: 'Blocks validated',
count: addressTabsCountersQuery.data?.validations_count,
component: <AddressBlocksValidated scrollRef={ tabsScrollRef }/>,
component: <AddressBlocksValidated scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading }/>,
} :
undefined,
addressTabsCountersQuery.data?.logs_count ?
......@@ -143,9 +145,10 @@ const AddressPageContent = () => {
id: 'logs',
title: 'Logs',
count: addressTabsCountersQuery.data?.logs_count,
component: <AddressLogs scrollRef={ tabsScrollRef }/>,
component: <AddressLogs scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading }/>,
} :
undefined,
addressQuery.data?.is_contract ? {
id: 'contract',
title: () => {
......@@ -164,9 +167,7 @@ const AddressPageContent = () => {
subTabs: contractTabs.map(tab => tab.id),
} : undefined,
].filter(Boolean);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data ]);
const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading ]);
const tags = (
<EntityTags
......@@ -183,7 +184,9 @@ const AddressPageContent = () => {
/>
);
const content = (addressQuery.isError || addressQuery.isDegradedData) ? null : <RoutedTabs tabs={ tabs } tabListProps={{ mt: 8 }}/>;
const content = (addressQuery.isError || addressQuery.isDegradedData) ?
null :
<RoutedTabs tabs={ tabs } tabListProps={{ mt: 8 }} isLoading={ isTabsLoading }/>;
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/accounts');
......@@ -250,10 +253,7 @@ const AddressPageContent = () => {
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ tabsScrollRef }></Box>
{ (isLoading || addressTabsCountersQuery.isPlaceholderData) ?
<TabsSkeleton tabs={ tabs }/> :
content
}
{ content }
</>
);
};
......
import { test as base, expect } from '@playwright/experimental-ct-react';
import { test as base, devices, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { buildExternalAssetFilePath } from 'configs/app/utils';
......@@ -53,7 +53,7 @@ const testFn: Parameters<typeof test>[1] = async({ mount, page }) => {
await expect(component).toHaveScreenshot();
};
test('base view +@mobile +@dark-mode', testFn);
test('base view +@dark-mode', testFn);
const testWithFeaturedApp = test.extend({
context: contextWithEnvs([
......@@ -63,7 +63,7 @@ const testWithFeaturedApp = test.extend({
]) as any,
});
testWithFeaturedApp('with featured app +@mobile +@dark-mode', testFn);
testWithFeaturedApp('with featured app +@dark-mode', testFn);
const testWithBanner = test.extend({
context: contextWithEnvs([
......@@ -74,7 +74,7 @@ const testWithBanner = test.extend({
]) as any,
});
testWithBanner('with banner +@mobile +@dark-mode', testFn);
testWithBanner('with banner +@dark-mode', testFn);
const testWithScoreFeature = test.extend({
context: contextWithEnvs([
......@@ -85,7 +85,7 @@ const testWithScoreFeature = test.extend({
]) as any,
});
testWithScoreFeature('with scores +@mobile +@dark-mode', async({ mount, page }) => {
testWithScoreFeature('with scores +@dark-mode', async({ mount, page }) => {
await page.route(MARKETPLACE_CONFIG_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(appsMock),
......@@ -115,3 +115,44 @@ testWithScoreFeature('with scores +@mobile +@dark-mode', async({ mount, page })
await expect(component).toHaveScreenshot();
});
// I had a memory error while running tests in GH actions
// separate run for mobile tests fixes it
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', testFn);
testWithFeaturedApp('with featured app', testFn);
testWithBanner('with banner', testFn);
testWithScoreFeature('with scores', async({ mount, page }) => {
await page.route(MARKETPLACE_CONFIG_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(appsMock),
}));
await page.route(MARKETPLACE_SECURITY_REPORTS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(securityReportsMock),
}));
await Promise.all(appsMock.map(app =>
page.route(app.logo, (route) =>
route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
}),
),
));
const component = await mount(
<TestApp>
<Marketplace/>
</TestApp>,
);
await component.getByText('Apps scores').click();
await expect(component).toHaveScreenshot();
});
});
......@@ -21,7 +21,6 @@ import type { IconName } from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle';
import RadioButtonGroup from 'ui/shared/radioButtonGroup/RadioButtonGroup';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import TabsWithScroll from 'ui/shared/Tabs/TabsWithScroll';
import useMarketplace from '../marketplace/useMarketplace';
......@@ -56,6 +55,7 @@ const Marketplace = () => {
filterQuery,
onSearchInputChange,
showAppInfo,
apps,
displayedApps,
selectedAppId,
clearSelectedAppId,
......@@ -93,7 +93,7 @@ const Marketplace = () => {
tabs.unshift({
id: MarketplaceCategory.FAVORITES,
title: () => <IconSvg name="star_outline" w={ 5 } h={ 5 }/>,
title: () => <IconSvg name="star_outline" w={ 5 } h={ 5 } display="flex"/>,
count: null,
component: null,
});
......@@ -170,7 +170,7 @@ const Marketplace = () => {
/>
<Banner
apps={ displayedApps }
apps={ apps }
favoriteApps={ favoriteApps }
isLoading={ isPlaceholderData }
onInfoClick={ showAppInfo }
......@@ -179,16 +179,13 @@ const Marketplace = () => {
/>
<Box marginTop={{ base: 0, lg: 8 }}>
{ (isCategoriesPlaceholderData) ? (
<TabsSkeleton tabs={ categoryTabs }/>
) : (
<TabsWithScroll
tabs={ categoryTabs }
onTabChange={ handleCategoryChange }
defaultTabIndex={ selectedCategoryIndex }
marginBottom={ -2 }
/>
) }
<TabsWithScroll
tabs={ categoryTabs }
onTabChange={ handleCategoryChange }
defaultTabIndex={ selectedCategoryIndex }
marginBottom={ -2 }
isLoading={ isCategoriesPlaceholderData }
/>
</Box>
<Flex direction={{ base: 'column', lg: 'row' }} mb={{ base: 4, lg: 6 }} gap={{ base: 4, lg: 3 }}>
......
......@@ -21,6 +21,7 @@ const Stats = () => {
handleFilterChange,
displayedCharts,
filterQuery,
initialFilterQuery,
} = useStats();
return (
......@@ -33,6 +34,8 @@ const Stats = () => {
<Box mb={{ base: 6, sm: 8 }}>
<StatsFilters
isLoading={ isPlaceholderData }
initialFilterValue={ initialFilterQuery }
sections={ sections }
currentSection={ currentSection }
onSectionChange={ handleSectionChange }
......@@ -44,6 +47,7 @@ const Stats = () => {
<ChartsWidgetsList
filterQuery={ filterQuery }
initialFilterQuery={ initialFilterQuery }
isError={ isError }
isPlaceholderData={ isPlaceholderData }
charts={ displayedCharts }
......
......@@ -174,20 +174,34 @@ const TokenInstanceContent = () => {
pagination = holdersQuery.pagination;
}
const title = (() => {
if (typeof tokenInstanceQuery.data?.metadata?.name === 'string') {
return tokenInstanceQuery.data.metadata.name;
}
if (tokenQuery.data?.symbol) {
return (tokenQuery.data.name || tokenQuery.data.symbol) + ' #' + tokenInstanceQuery.data?.id;
}
return `ID ${ tokenInstanceQuery.data?.id }`;
})();
const titleSecondRow = (
<Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
<TokenEntity
token={ tokenQuery.data }
isLoading={ isLoading }
noSymbol
noCopy
jointSymbol
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
w="auto"
maxW="700px"
/>
{ tokenQuery.data && (
<TokenEntity
token={ tokenQuery.data }
isLoading={ isLoading }
noSymbol
noCopy
jointSymbol
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
w="auto"
maxW="700px"
/>
) }
{ !isLoading && tokenInstanceQuery.data && <AddressAddToWallet token={ tokenQuery.data } variant="button"/> }
<AddressQrCode address={ address } isLoading={ isLoading }/>
<AccountActionsMenu isLoading={ isLoading }/>
......@@ -199,7 +213,7 @@ const TokenInstanceContent = () => {
<>
<TextAd mb={ 6 }/>
<PageTitle
title={ `ID ${ tokenInstanceQuery.data?.id }` }
title={ title }
backLink={ backLink }
contentAfter={ tokenTag }
secondRow={ titleSecondRow }
......
......@@ -14,6 +14,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TxsStats from 'ui/txs/TxsStats';
import TxsWatchlist from 'ui/txs/TxsWatchlist';
import TxsWithFrontendSorting from 'ui/txs/TxsWithFrontendSorting';
......@@ -140,6 +141,7 @@ const Transactions = () => {
return (
<>
<PageTitle title="Transactions" withTextAd/>
<TxsStats/>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
......
import type { StyleProps, ThemingProps } from '@chakra-ui/react';
import { Box, Tab, TabList, useColorModeValue } from '@chakra-ui/react';
import { Box, Skeleton, Tab, TabList, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
......@@ -24,6 +24,7 @@ interface Props extends TabsProps {
activeTabIndex: number;
onItemClick: (index: number) => void;
themeProps: ThemingProps<'Tabs'>;
isLoading?: boolean;
}
const AdaptiveTabsList = (props: Props) => {
......@@ -113,8 +114,10 @@ const AdaptiveTabsList = (props: Props) => {
},
}}
>
{ typeof tab.title === 'function' ? tab.title() : tab.title }
<TabCounter count={ tab.count }/>
<Skeleton isLoaded={ !props.isLoading }>
{ typeof tab.title === 'function' ? tab.title() : tab.title }
<TabCounter count={ tab.count }/>
</Skeleton>
</Tab>
);
}) }
......
......@@ -17,9 +17,10 @@ interface Props extends ThemingProps<'Tabs'> {
stickyEnabled?: boolean;
className?: string;
onTabChange?: (index: number) => void;
isLoading?: boolean;
}
const RoutedTabs = ({ tabs, tabListProps, rightSlot, rightSlotProps, stickyEnabled, className, onTabChange, ...themeProps }: Props) => {
const RoutedTabs = ({ tabs, tabListProps, rightSlot, rightSlotProps, stickyEnabled, className, onTabChange, isLoading, ...themeProps }: Props) => {
const router = useRouter();
const tabIndex = useTabIndexFromQuery(tabs);
const tabsRef = useRef<HTMLDivElement>(null);
......@@ -63,6 +64,7 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, rightSlotProps, stickyEnabl
stickyEnabled={ stickyEnabled }
onTabChange={ handleTabChange }
defaultTabIndex={ tabIndex }
isLoading={ isLoading }
{ ...themeProps }
/>
);
......
......@@ -25,6 +25,7 @@ export interface Props extends ThemingProps<'Tabs'> {
stickyEnabled?: boolean;
onTabChange?: (index: number) => void;
defaultTabIndex?: number;
isLoading?: boolean;
className?: string;
}
......@@ -37,6 +38,7 @@ const TabsWithScroll = ({
stickyEnabled,
onTabChange,
defaultTabIndex,
isLoading,
className,
...themeProps
}: Props) => {
......@@ -101,6 +103,7 @@ const TabsWithScroll = ({
activeTabIndex={ activeTabIndex }
onItemClick={ handleTabChange }
themeProps={ themeProps }
isLoading={ isLoading }
/>
<TabPanels>
{ tabsList.map((tab) => <TabPanel padding={ 0 } key={ tab.id }>{ tab.component }</TabPanel>) }
......
......@@ -16,7 +16,7 @@ const GasInfoTooltipRow = ({ name, info }: Props) => {
<>
<Box>
<chakra.span>{ name }</chakra.span>
{ info && info.time && (
{ info && typeof info.time === 'number' && info.time > 0 && (
<chakra.span color="text_secondary">
{ space }{ (info.time / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s
</chakra.span>
......
import React from 'react';
import { test, expect } from 'playwright/lib';
import StatsWidget from './StatsWidget';
test.use({ viewport: { width: 300, height: 100 } });
test('with positive diff +@dark-mode', async({ render }) => {
const component = await render(
<StatsWidget
label="Verified contracts"
hint="Contracts that have been verified"
value="1 000 000"
diff={ 4200 }
diffFormatted="4 200"
/>,
);
await expect(component).toHaveScreenshot();
});
// according to current logic we don't show diff if it's negative
test('with negative diff', async({ render }) => {
const component = await render(
<StatsWidget
label="Verified contracts"
hint="Contracts that have been verified"
value="1,000,000"
diff={ -4200 }
/>,
);
await expect(component).toHaveScreenshot();
});
test('loading state', async({ render }) => {
const component = await render(
<StatsWidget
label="Verified contracts"
hint="Contracts that have been verified"
value="1,000,000"
isLoading
/>,
);
await expect(component).toHaveScreenshot();
});
test('with period only', async({ render }) => {
const component = await render(
<StatsWidget
label="Verified contracts"
hint="Contracts that have been verified"
value="1,000,000"
period="1h"
/>,
);
await expect(component).toHaveScreenshot();
});
import { Box, Flex, Text, Skeleton, useColorModeValue } from '@chakra-ui/react';
import { Box, Flex, Text, Skeleton, useColorModeValue, chakra } from '@chakra-ui/react';
import NextLink from 'next/link';
import React from 'react';
import type { Route } from 'nextjs-routes';
import Hint from 'ui/shared/Hint';
import TruncatedValue from 'ui/shared/TruncatedValue';
type Props = {
label: string;
value: string;
valuePrefix?: string;
valuePostfix?: string;
hint?: string;
isLoading?: boolean;
diff?: string | number;
diffFormatted?: string;
diffPeriod?: '24h';
period?: '1h' | '24h';
href?: Route;
}
const StatsWidget = ({ label, value, isLoading, hint, diff, diffPeriod = '24h', diffFormatted }: Props) => {
const Container = ({ href, children }: { href?: Route; children: JSX.Element }) => {
if (href) {
return (
<NextLink href={ href } passHref legacyBehavior>
{ children }
</NextLink>
);
}
return children;
};
const StatsWidget = ({ label, value, valuePrefix, valuePostfix, isLoading, hint, diff, diffPeriod = '24h', diffFormatted, period, href }: Props) => {
const bgColor = useColorModeValue('blue.50', 'whiteAlpha.100');
const skeletonBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const hintColor = useColorModeValue('gray.600', 'gray.400');
return (
<Flex
alignItems="flex-start"
bgColor={ isLoading ? skeletonBgColor : bgColor }
px={ 3 }
py={{ base: 2, lg: 3 }}
borderRadius="md"
justifyContent="space-between"
columnGap={ 3 }
>
<Box>
<Skeleton
isLoaded={ !isLoading }
color="text_secondary"
fontSize="xs"
w="fit-content"
>
<span>{ label }</span>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
w="fit-content"
display="flex"
alignItems="baseline"
mt={ 1 }
>
<Text fontWeight={ 500 } fontSize="lg" lineHeight={ 6 }>{ value }</Text>
{ diff && Number(diff) > 0 && (
<>
<Text fontWeight={ 500 } ml={ 2 } mr={ 1 } fontSize="lg" lineHeight={ 6 } color="green.500">
+{ diffFormatted || Number(diff).toLocaleString() }
</Text>
<Text variant="secondary" fontSize="sm">({ diffPeriod })</Text>
</>
) }
</Skeleton>
</Box>
{ hint && (
<Skeleton isLoaded={ !isLoading } alignSelf="center" borderRadius="base">
<Hint label={ hint } boxSize={ 6 } color={ hintColor }/>
</Skeleton>
) }
</Flex>
<Container href={ !isLoading ? href : undefined }>
<Flex
alignItems="flex-start"
bgColor={ isLoading ? skeletonBgColor : bgColor }
px={ 3 }
py={{ base: 2, lg: 3 }}
borderRadius="md"
justifyContent="space-between"
columnGap={ 3 }
{ ...(href && !isLoading ? {
as: 'a',
href,
} : {}) }
>
<Box w="100%">
<Skeleton
isLoaded={ !isLoading }
color="text_secondary"
fontSize="xs"
w="fit-content"
>
<span>{ label }</span>
</Skeleton>
<Skeleton
isLoaded={ !isLoading }
display="flex"
alignItems="baseline"
mt={ 1 }
>
{ valuePrefix && <chakra.span fontWeight={ 500 } fontSize="lg" lineHeight={ 6 } whiteSpace="pre">{ valuePrefix }</chakra.span> }
<TruncatedValue isLoading={ isLoading } fontWeight={ 500 } fontSize="lg" lineHeight={ 6 } value={ value }/>
{ valuePostfix && <chakra.span fontWeight={ 500 } fontSize="lg" lineHeight={ 6 } whiteSpace="pre">{ valuePostfix }</chakra.span> }
{ diff && Number(diff) > 0 && (
<>
<Text fontWeight={ 500 } ml={ 2 } mr={ 1 } fontSize="lg" lineHeight={ 6 } color="green.500">
+{ diffFormatted || Number(diff).toLocaleString() }
</Text>
<Text variant="secondary" fontSize="sm">({ diffPeriod })</Text>
</>
) }
{ period && <Text variant="secondary" fontSize="xs" ml={ 1 }>({ period })</Text> }
</Skeleton>
</Box>
{ hint && (
<Skeleton isLoaded={ !isLoading } alignSelf="center" borderRadius="base">
<Hint label={ hint } boxSize={ 6 } color={ hintColor }/>
</Skeleton>
) }
</Flex>
</Container>
);
};
......
......@@ -38,7 +38,7 @@ const StatusTag = ({ type, text, errorText, isLoading }: Props) => {
return (
<Tooltip label={ errorText }>
<Tag colorScheme={ colorScheme } display="flex" isLoading={ isLoading } >
<IconSvg boxSize={ 2.5 } name={ icon } mr={ 2 } flexShrink={ 0 }/>
<IconSvg boxSize={ 2.5 } name={ icon } mr={ 1 } flexShrink={ 0 }/>
<TagLabel display="block">{ text }</TagLabel>
</Tag>
</Tooltip>
......
......@@ -61,7 +61,7 @@ const NavLink = ({ item, isCollapsed, px, className, onClick }: Props) => {
>
<HStack spacing={ 3 } overflow="hidden">
<NavLinkIcon item={ item }/>
<Text { ...styleProps.textProps }>
<Text { ...styleProps.textProps } as="span">
<span>{ item.text }</span>
{ !isInternalLink && <IconSvg name="arrows/north-east" boxSize={ 4 } color="text_secondary" verticalAlign="middle"/> }
</Text>
......
......@@ -11,7 +11,8 @@ import TopBar from './TopBar';
const test = base.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_SWAP_BUTTON_URL', value: 'uniswap' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ name: 'NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL', value: 'DUCK' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
});
......@@ -28,8 +29,24 @@ test('default view +@dark-mode +@mobile', async({ mount, page }) => {
);
await component.getByText(/\$1\.39/).click();
await expect(page.getByText(/last update/i)).toBeVisible();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 220 } });
await component.getByLabel('User settings').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 400 } });
});
test('with secondary coin price +@mobile', async({ mount, page }) => {
await page.route(buildApiUrl('stats'), (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.withSecondaryCoin),
}));
const component = await mount(
<TestApp>
<TopBar/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
......@@ -4,12 +4,15 @@ import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import dayjs from 'lib/date/dayjs';
import useIsMobile from 'lib/hooks/useIsMobile';
import { HOMEPAGE_STATS } from 'stubs/stats';
import GasInfoTooltip from 'ui/shared/gas/GasInfoTooltip';
import GasPrice from 'ui/shared/gas/GasPrice';
import TextSeparator from 'ui/shared/TextSeparator';
const TopBarStats = () => {
const isMobile = useIsMobile();
const { data, isPlaceholderData, isError, refetch, dataUpdatedAt } = useApiQuery('stats', {
queryOptions: {
placeholderData: HOMEPAGE_STATS,
......@@ -51,7 +54,7 @@ const TopBarStats = () => {
{ data?.coin_price && (
<Flex columnGap={ 1 }>
<Skeleton isLoaded={ !isPlaceholderData }>
<chakra.span color="text_secondary">{ config.chain.governanceToken.symbol || config.chain.currency.symbol } </chakra.span>
<chakra.span color="text_secondary">{ config.chain.currency.symbol } </chakra.span>
<span>${ Number(data.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }) }</span>
</Skeleton>
{ data.coin_price_change_percentage && (
......@@ -63,6 +66,14 @@ const TopBarStats = () => {
) }
</Flex>
) }
{ !isMobile && data?.secondary_coin_price && config.chain.secondaryCoin.symbol && (
<Flex columnGap={ 1 } ml={ data?.coin_price ? 3 : 0 }>
<Skeleton isLoaded={ !isPlaceholderData }>
<chakra.span color="text_secondary">{ config.chain.secondaryCoin.symbol } </chakra.span>
<span>${ Number(data.secondary_coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }) }</span>
</Skeleton>
</Flex>
) }
{ data?.coin_price && config.features.gasTracker.isEnabled && <TextSeparator color="divider"/> }
{ data?.gas_prices && data.gas_prices.average !== null && config.features.gasTracker.isEnabled && (
<Skeleton isLoaded={ !isPlaceholderData }>
......
......@@ -15,16 +15,26 @@ import ChartWidgetContainer from './ChartWidgetContainer';
type Props = {
filterQuery: string;
initialFilterQuery: string;
isError: boolean;
isPlaceholderData: boolean;
charts?: Array<StatsChartsSection>;
interval: StatsIntervalIds;
}
const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, interval }: Props) => {
const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, interval, initialFilterQuery }: Props) => {
const [ isSomeChartLoadingError, setIsSomeChartLoadingError ] = useState(false);
const isAnyChartDisplayed = charts?.some((section) => section.charts.length > 0);
const isEmptyChartList = Boolean(filterQuery) && !isAnyChartDisplayed;
const sectionRef = React.useRef<HTMLUListElement | null>(null);
const shouldScrollToSection = Boolean(initialFilterQuery);
React.useEffect(() => {
if (shouldScrollToSection) {
sectionRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [ shouldScrollToSection ]);
const homeStatsQuery = useApiQuery('stats', {
queryOptions: {
......@@ -50,7 +60,7 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in
<ChartsLoadingErrorAlert/>
) }
<List>
<List ref={ sectionRef }>
{
charts?.map((section) => (
<ListItem
......@@ -61,7 +71,7 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in
}}
>
<Skeleton isLoaded={ !isPlaceholderData } mb={ 4 } display="inline-flex" alignItems="center" columnGap={ 2 } id={ section.id }>
<Heading size="md" >
<Heading size="md" id={ section.id }>
{ section.title }
</Heading>
{ section.id === 'gas' && homeStatsQuery.data && homeStatsQuery.data.gas_prices && (
......
import { Grid, GridItem } from '@chakra-ui/react';
import { Grid, GridItem, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { StatsChartsSection } from 'types/api/stats';
......@@ -21,6 +21,8 @@ type Props = {
interval: StatsIntervalIds;
onIntervalChange: (newInterval: StatsIntervalIds) => void;
onFilterInputChange: (q: string) => void;
isLoading: boolean;
initialFilterValue: string;
}
const StatsFilters = ({
......@@ -30,8 +32,9 @@ const StatsFilters = ({
interval,
onIntervalChange,
onFilterInputChange,
isLoading,
initialFilterValue,
}: Props) => {
const sectionsList = [ {
id: 'all',
title: 'All',
......@@ -51,22 +54,26 @@ const StatsFilters = ({
w={{ base: '100%', lg: 'auto' }}
area="section"
>
<StatsDropdownMenu
items={ sectionsList }
selectedId={ currentSection }
onSelect={ onSectionChange }
/>
{ isLoading ? <Skeleton w={{ base: '100%', lg: '76px' }} h="40px" borderRadius="base"/> : (
<StatsDropdownMenu
items={ sectionsList }
selectedId={ currentSection }
onSelect={ onSectionChange }
/>
) }
</GridItem>
<GridItem
w={{ base: '100%', lg: 'auto' }}
area="interval"
>
<StatsDropdownMenu
items={ intervalList }
selectedId={ interval }
onSelect={ onIntervalChange }
/>
{ isLoading ? <Skeleton w={{ base: '100%', lg: '118px' }} h="40px" borderRadius="base"/> : (
<StatsDropdownMenu
items={ intervalList }
selectedId={ interval }
onSelect={ onIntervalChange }
/>
) }
</GridItem>
<GridItem
......@@ -74,8 +81,12 @@ const StatsFilters = ({
area="input"
>
<FilterInput
key={ initialFilterValue }
isLoading={ isLoading }
onChange={ onFilterInputChange }
placeholder="Find chart, metric..."/>
placeholder="Find chart, metric..."
initialValue={ initialFilterValue }
/>
</GridItem>
</Grid>
);
......
import { useRouter } from 'next/router';
import React, { useCallback, useMemo, useState } from 'react';
import type { StatsChartInfo, StatsChartsSection } from 'types/api/stats';
import type { StatsIntervalIds } from 'types/client/stats';
import useApiQuery from 'lib/api/useApiQuery';
import useDebounce from 'lib/hooks/useDebounce';
import getQueryParamString from 'lib/router/getQueryParamString';
import { STATS_CHARTS } from 'stubs/stats';
function isSectionMatches(section: StatsChartsSection, currentSection: string): boolean {
......@@ -16,6 +17,8 @@ function isChartNameMatches(q: string, chart: StatsChartInfo) {
}
export default function useStats() {
const router = useRouter();
const { data, isPlaceholderData, isError } = useApiQuery('stats_lines', {
queryOptions: {
placeholderData: STATS_CHARTS,
......@@ -24,22 +27,35 @@ export default function useStats() {
const [ currentSection, setCurrentSection ] = useState('all');
const [ filterQuery, setFilterQuery ] = useState('');
const [ initialFilterQuery, setInitialFilterQuery ] = React.useState('');
const [ interval, setInterval ] = useState<StatsIntervalIds>('oneMonth');
const sectionIds = useMemo(() => data?.sections?.map(({ id }) => id), [ data ]);
const debouncedFilterQuery = useDebounce(filterQuery, 500);
React.useEffect(() => {
if (!isPlaceholderData && !isError) {
const chartId = getQueryParamString(router.query.chartId);
const chartName = data?.sections.map((section) => section.charts.find((chart) => chart.id === chartId)).filter(Boolean)[0]?.title;
if (chartName) {
setInitialFilterQuery(chartName);
setFilterQuery(chartName);
router.replace({ pathname: '/stats' }, undefined, { scroll: false });
}
}
// run only when data is loaded
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isPlaceholderData ]);
const displayedCharts = React.useMemo(() => {
return data?.sections
?.map((section) => {
const charts = section.charts.filter((chart) => isSectionMatches(section, currentSection) && isChartNameMatches(debouncedFilterQuery, chart));
const charts = section.charts.filter((chart) => isSectionMatches(section, currentSection) && isChartNameMatches(filterQuery, chart));
return {
...section,
charts,
};
}).filter((section) => section.charts.length > 0);
}, [ currentSection, data?.sections, debouncedFilterQuery ]);
}, [ currentSection, data?.sections, filterQuery ]);
const handleSectionChange = useCallback((newSection: string) => {
setCurrentSection(newSection);
......@@ -58,6 +74,7 @@ export default function useStats() {
sectionIds,
isPlaceholderData,
isError,
initialFilterQuery,
filterQuery,
currentSection,
handleSectionChange,
......@@ -70,6 +87,7 @@ export default function useStats() {
sectionIds,
isPlaceholderData,
isError,
initialFilterQuery,
filterQuery,
currentSection,
handleSectionChange,
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as statsMock from 'mocks/stats';
import * as txsStatsMock from 'mocks/txs/stats';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TxsStats from './TxsStats';
const TXS_STATS_API_URL = buildApiUrl('txs_stats');
const STATS_API_URL = buildApiUrl('stats');
test('base view +@mobile', async({ mount, page }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(TXS_STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txsStatsMock.base),
}));
const component = await mount(
<TestApp>
<TxsStats/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Box } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getCurrencyValue from 'lib/getCurrencyValue';
import { thinsp } from 'lib/html-entities';
import { HOMEPAGE_STATS } from 'stubs/stats';
import { TXS_STATS } from 'stubs/tx';
import StatsWidget from 'ui/shared/stats/StatsWidget';
const TxsStats = () => {
const txsStatsQuery = useApiQuery('txs_stats', {
queryOptions: {
placeholderData: TXS_STATS,
},
});
const statsQuery = useApiQuery('stats', {
queryOptions: {
placeholderData: HOMEPAGE_STATS,
},
});
if (!txsStatsQuery.data) {
return null;
}
const txFeeAvg = getCurrencyValue({
value: txsStatsQuery.data.transaction_fees_avg_24h,
exchangeRate: statsQuery.data?.coin_price,
decimals: String(config.chain.currency.decimals),
accuracyUsd: 2,
});
return (
<Box
display="grid"
gridTemplateColumns={{ base: '1fr', lg: 'repeat(4, calc(25% - 9px))' }}
rowGap={ 3 }
columnGap={ 3 }
mb={ 6 }
>
<StatsWidget
label="Transactions"
value={ Number(txsStatsQuery.data?.transactions_count_24h).toLocaleString() }
period="24h"
isLoading={ txsStatsQuery.isPlaceholderData }
href={{ pathname: '/stats', query: { chartId: 'newTxns' } }}
/>
<StatsWidget
label="Pending transactions"
value={ Number(txsStatsQuery.data?.pending_transactions_count).toLocaleString() }
period="1h"
isLoading={ txsStatsQuery.isPlaceholderData }
/>
<StatsWidget
label="Transactions fees"
value={
(Number(txsStatsQuery.data?.transaction_fees_sum_24h) / (10 ** config.chain.currency.decimals))
.toLocaleString(undefined, { maximumFractionDigits: 2 })
}
valuePostfix={ thinsp + config.chain.currency.symbol }
period="24h"
isLoading={ txsStatsQuery.isPlaceholderData }
href={{ pathname: '/stats', query: { chartId: 'txnsFee' } }}
/>
<StatsWidget
label="Avg. transaction fee"
value={ txFeeAvg.usd ? txFeeAvg.usd : txFeeAvg.valueStr }
valuePrefix={ txFeeAvg.usd ? '$' : undefined }
valuePostfix={ txFeeAvg.usd ? undefined : thinsp + config.chain.currency.symbol }
period="24h"
isLoading={ txsStatsQuery.isPlaceholderData }
href={{ pathname: '/stats', query: { chartId: 'averageTxnFee' } }}
/>
</Box>
);
};
export default React.memo(TxsStats);
......@@ -24,6 +24,7 @@ const VerifiedContractsCounters = () => {
diff={ countersQuery.data.new_smart_contracts_24h }
diffFormatted={ Number(countersQuery.data.new_smart_contracts_24h).toLocaleString() }
isLoading={ countersQuery.isPlaceholderData }
href={{ pathname: '/stats', query: { chartId: 'contractsGrowth' } }}
/>
<StatsWidget
label="Verified contracts"
......@@ -31,6 +32,7 @@ const VerifiedContractsCounters = () => {
diff={ countersQuery.data.new_verified_smart_contracts_24h }
diffFormatted={ Number(countersQuery.data.new_verified_smart_contracts_24h).toLocaleString() }
isLoading={ countersQuery.isPlaceholderData }
href={{ pathname: '/stats', query: { chartId: 'verifiedContractsGrowth' } }}
/>
</Box>
);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment