Commit 000bf0d6 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into tom2drum/issue-2029

parents 261724c4 10e5e934
...@@ -30,7 +30,7 @@ jobs: ...@@ -30,7 +30,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.11.0 node-version: 20.17.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
...@@ -43,7 +43,7 @@ jobs: ...@@ -43,7 +43,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile --ignore-optional run: yarn --frozen-lockfile
- name: Run ESLint - name: Run ESLint
run: yarn lint:eslint run: yarn lint:eslint
...@@ -62,7 +62,7 @@ jobs: ...@@ -62,7 +62,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.11.0 node-version: 20.17.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
...@@ -75,10 +75,10 @@ jobs: ...@@ -75,10 +75,10 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile --ignore-optional run: yarn --frozen-lockfile
- name: Install script dependencies - name: Install script dependencies
run: cd ./deploy/tools/envs-validator && yarn --frozen-lockfile --ignore-optional run: cd ./deploy/tools/envs-validator && yarn --frozen-lockfile
- name: Run validation tests - name: Run validation tests
run: | run: |
...@@ -101,7 +101,7 @@ jobs: ...@@ -101,7 +101,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.11.0 node-version: 20.17.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
...@@ -114,7 +114,7 @@ jobs: ...@@ -114,7 +114,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile --ignore-optional run: yarn --frozen-lockfile
- name: Run Jest - name: Run Jest
run: yarn test:jest ${{ github.event_name == 'pull_request' && '--changedSince=origin/main' || '' }} --passWithNoTests run: yarn test:jest ${{ github.event_name == 'pull_request' && '--changedSince=origin/main' || '' }} --passWithNoTests
...@@ -133,7 +133,7 @@ jobs: ...@@ -133,7 +133,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.11.0 node-version: 20.17.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
...@@ -146,7 +146,7 @@ jobs: ...@@ -146,7 +146,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile --ignore-optional run: yarn --frozen-lockfile
- name: Install script dependencies - name: Install script dependencies
run: cd ./deploy/tools/affected-tests && yarn --frozen-lockfile run: cd ./deploy/tools/affected-tests && yarn --frozen-lockfile
...@@ -171,7 +171,7 @@ jobs: ...@@ -171,7 +171,7 @@ jobs:
(needs.pw_affected_tests.result == 'success' || needs.pw_affected_tests.result == 'skipped') (needs.pw_affected_tests.result == 'success' || needs.pw_affected_tests.result == 'skipped')
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.41.1-focal image: mcr.microsoft.com/playwright:v1.47.2-focal
strategy: strategy:
fail-fast: false fail-fast: false
...@@ -190,7 +190,7 @@ jobs: ...@@ -190,7 +190,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.11.0 node-version: 20.17.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
...@@ -203,7 +203,7 @@ jobs: ...@@ -203,7 +203,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile --ignore-optional run: yarn --frozen-lockfile
- name: Download affected tests list - name: Download affected tests list
if: ${{ needs.pw_affected_tests.result == 'success' }} if: ${{ needs.pw_affected_tests.result == 'success' }}
......
...@@ -21,7 +21,7 @@ jobs: ...@@ -21,7 +21,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.11.0 node-version: 20.17.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
...@@ -34,7 +34,7 @@ jobs: ...@@ -34,7 +34,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile --ignore-optional run: yarn --frozen-lockfile
- name: Make production build with source maps - name: Make production build with source maps
run: yarn build run: yarn build
......
20.11.0 20.17.0
\ No newline at end of file
# ***************************** # *****************************
# *** STAGE 1: Dependencies *** # *** STAGE 1: Dependencies ***
# ***************************** # *****************************
FROM node:20.11.0-alpine AS deps FROM node:20.17.0-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat python3 make g++ RUN apk add --no-cache libc6-compat python3 make g++
RUN ln -sf /usr/bin/python3 /usr/bin/python RUN ln -sf /usr/bin/python3 /usr/bin/python
...@@ -31,7 +31,7 @@ RUN yarn --frozen-lockfile ...@@ -31,7 +31,7 @@ RUN yarn --frozen-lockfile
# ***************************** # *****************************
# ****** STAGE 2: Build ******* # ****** STAGE 2: Build *******
# ***************************** # *****************************
FROM node:20.11.0-alpine AS builder FROM node:20.17.0-alpine AS builder
RUN apk add --no-cache --upgrade libc6-compat bash RUN apk add --no-cache --upgrade libc6-compat bash
# pass build args to env variables # pass build args to env variables
...@@ -81,7 +81,7 @@ RUN cd ./deploy/tools/envs-validator && yarn build ...@@ -81,7 +81,7 @@ RUN cd ./deploy/tools/envs-validator && yarn build
# ******* STAGE 3: Run ******** # ******* STAGE 3: Run ********
# ***************************** # *****************************
# Production image, copy all the files and run next # Production image, copy all the files and run next
FROM node:20.11.0-alpine AS runner FROM node:20.17.0-alpine AS runner
RUN apk add --no-cache --upgrade bash curl jq unzip RUN apk add --no-cache --upgrade bash curl jq unzip
### APP ### APP
......
...@@ -14,6 +14,10 @@ ...@@ -14,6 +14,10 @@
- Updated dependency: PackageName 1 to version x.x.x. - Updated dependency: PackageName 1 to version x.x.x.
- Updated dependency: PackageName 2 to version x.x.x. - Updated dependency: PackageName 2 to version x.x.x.
## 🎨 Design updates
- New style 1.
- New style 2.
## ✨ Other Changes ## ✨ Other Changes
- Another minor change 1. - Another minor change 1.
- Another minor change 2. - Another minor change 2.
......
import type { Feature } from './types';
import type { AddressProfileAPIConfig } from 'types/client/addressProfileAPIConfig';
import { getEnvValue, parseEnvJson } from '../utils';
const value = parseEnvJson<AddressProfileAPIConfig>(getEnvValue('NEXT_PUBLIC_ADDRESS_USERNAME_TAG'));
function checkApiUrlTemplate(apiUrlTemplate: string): boolean {
try {
const testUrl = apiUrlTemplate.replace('{address}', '0x0000000000000000000000000000000000000000');
new URL(testUrl).toString();
return true;
} catch (error) {
return false;
}
}
const title = 'User profile API';
const config: Feature<{
apiUrlTemplate: string;
tagLinkTemplate?: string;
tagIcon?: string;
tagBgColor?: string;
tagTextColor?: string;
}> = (() => {
if (value && checkApiUrlTemplate(value.api_url_template)) {
return Object.freeze({
title,
isEnabled: true,
apiUrlTemplate: value.api_url_template,
tagLinkTemplate: value.tag_link_template,
tagIcon: value.tag_icon,
tagBgColor: value.tag_bg_color,
tagTextColor: value.tag_text_color,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
...@@ -32,6 +32,7 @@ export { default as stats } from './stats'; ...@@ -32,6 +32,7 @@ export { default as stats } from './stats';
export { default as suave } from './suave'; export { default as suave } from './suave';
export { default as txInterpretation } from './txInterpretation'; export { default as txInterpretation } from './txInterpretation';
export { default as userOps } from './userOps'; export { default as userOps } from './userOps';
export { default as addressProfileAPI } from './addressProfileAPI';
export { default as validators } from './validators'; export { default as validators } from './validators';
export { default as verifiedTokens } from './verifiedTokens'; export { default as verifiedTokens } from './verifiedTokens';
export { default as web3Wallet } from './web3Wallet'; export { default as web3Wallet } from './web3Wallet';
# Set of ENVs for BXN Testnet network explorer
# https://blackfort-testnet.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=blackfort_testnet"
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# Instance ENVs
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=blackfort-testnet.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/blackfort-testnet.json
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/blackfort.json
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xcb4140e22cde3412eb5aecdedf2403032c7a251f5c96b11122aca5b1b88ed953
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=linear-gradient(92deg, rgb(3, 150, 254) 0.24%, rgb(36, 209, 245) 98.31%)
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_MARKETPLACE_ENABLED=false
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=TBXN
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=TBXN
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/blackfort.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/blackfort-dark.svg
NEXT_PUBLIC_NETWORK_ID=4777
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/blackfort.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/blackfort-dark.svg
NEXT_PUBLIC_NETWORK_NAME=BXN Testnet
NEXT_PUBLIC_NETWORK_RPC_URL=https://testnet.blackfort.network/rpc
NEXT_PUBLIC_NETWORK_SHORT_NAME=BXN Testnet
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/blackfort.png
NEXT_PUBLIC_STATS_API_HOST=https://stats-blackfort-testnet.k8s.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE=blackfort
\ No newline at end of file
...@@ -14,7 +14,7 @@ NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height ...@@ -14,7 +14,7 @@ NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" } NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" }
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/ NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=eth-sepolia.blockscout.com NEXT_PUBLIC_API_HOST=eth-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
...@@ -59,7 +59,7 @@ NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-c ...@@ -59,7 +59,7 @@ NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-c
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://sepolia.drpc.org?ref=559183','text':'Public RPC'}] NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://sepolia.drpc.org?ref=559183','text':'Public RPC'}]
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-sepolia.safe.global NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-sepolia.safe.global
NEXT_PUBLIC_SENTRY_ENABLE_TRACING=true NEXT_PUBLIC_SENTRY_ENABLE_TRACING=true
NEXT_PUBLIC_STATS_API_HOST=https://stats-sepolia.k8s.blockscout.com NEXT_PUBLIC_STATS_API_HOST=https://stats-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
# Set of ENVs for Zora Mainnet network explorer
# https://explorer.zora.energy
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=zora"
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# Instance ENVs
NEXT_PUBLIC_AD_BANNER_PROVIDER=none
NEXT_PUBLIC_AD_TEXT_PROVIDER=none
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=explorer.zora.energy
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS=[{'text':'Swap','icon':'swap','dappId':'uniswap'}]
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/zora.json
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x6d54c0226a57f5bc854f8aa589bb15113388f984f318c9e1b2722115e4e35873
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS=true
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=linear-gradient(89deg, rgb(63, 36, 22) 0.56%, rgb(44, 56, 105) 98.31%)
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://zora-blockscout.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY=patbqG4V2CI998jAq.9810c58c9de973ba2650621c94559088cbdfa1a914498e385621ed035d33c0d0
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=appGkvtmKI7fXE4Vs
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com
NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/apps']
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'GeckoTerminal','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/geckoterminal.png','baseUrl':'https://www.geckoterminal.com/','paths':{'token':'/zora-network/pools'}}]
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zora.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zora-dark.svg
NEXT_PUBLIC_NETWORK_ID=7777777
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/zora.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/zora-dark.svg
NEXT_PUBLIC_NETWORK_NAME=Zora Mainnet
NEXT_PUBLIC_NETWORK_RPC_URL=https://rpc.zora.energy
NEXT_PUBLIC_NETWORK_SHORT_NAME=Zora Mainnet
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/zora-mainnet.png
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth.blockscout.com/
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://bridge.zora.energy
NEXT_PUBLIC_ROLLUP_TYPE=optimistic
NEXT_PUBLIC_STATS_API_HOST=https://stats-l2-zora-mainnet.k8s-prod-1.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG={'name': 'zerion', 'dapp_id': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'}
NEXT_PUBLIC_ADDRESS_USERNAME_TAG={'api_url_template': 'https://api.zora.co/discover/user/{address}', 'tag_link_template': 'httpszora.co/{username}', 'tag_icon': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/zora.svg', 'tag_bg_color': 'rgba(0,0,0)', 'tag_text_color': 'rgba(255,255,255)'}
\ No newline at end of file
...@@ -10,6 +10,7 @@ declare module 'yup' { ...@@ -10,6 +10,7 @@ declare module 'yup' {
import * as yup from 'yup'; import * as yup from 'yup';
import type { AdButlerConfig } from '../../../types/client/adButlerConfig'; import type { AdButlerConfig } from '../../../types/client/adButlerConfig';
import type { AddressProfileAPIConfig } from '../../../types/client/addressProfileAPIConfig';
import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS, SUPPORTED_AD_BANNER_ADDITIONAL_PROVIDERS } from '../../../types/client/adProviders'; import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS, SUPPORTED_AD_BANNER_ADDITIONAL_PROVIDERS } from '../../../types/client/adProviders';
import type { AdTextProviders, AdBannerProviders, AdBannerAdditionalProviders } from '../../../types/client/adProviders'; import type { AdTextProviders, AdBannerProviders, AdBannerAdditionalProviders } from '../../../types/client/adProviders';
import { SMART_CONTRACT_EXTRA_VERIFICATION_METHODS, type ContractCodeIde, type SmartContractVerificationMethodExtra } from '../../../types/client/contract'; import { SMART_CONTRACT_EXTRA_VERIFICATION_METHODS, type ContractCodeIde, type SmartContractVerificationMethodExtra } from '../../../types/client/contract';
...@@ -803,6 +804,20 @@ const schema = yup ...@@ -803,6 +804,20 @@ const schema = yup
), ),
}), }),
NEXT_PUBLIC_SAVE_ON_GAS_ENABLED: yup.boolean(), NEXT_PUBLIC_SAVE_ON_GAS_ENABLED: yup.boolean(),
NEXT_PUBLIC_ADDRESS_USERNAME_TAG: yup
.mixed()
.test('shape', 'Invalid schema were provided for NEXT_PUBLIC_ADDRESS_USERNAME_TAG, it should have api_url_template', (data) => {
const isUndefined = data === undefined;
const valueSchema = yup.object<AddressProfileAPIConfig>().transform(replaceQuotes).json().shape({
api_url_template: yup.string().required(),
tag_link_template: yup.string(),
tag_icon: yup.string(),
tag_bg_color: yup.string(),
tag_text_color: yup.string(),
});
return isUndefined || valueSchema.isValidSync(data);
}),
// 6. External services envs // 6. External services envs
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(),
......
...@@ -54,6 +54,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will ...@@ -54,6 +54,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Data availability](ENVS.md#data-availability) - [Data availability](ENVS.md#data-availability)
- [Bridged tokens](ENVS.md#bridged-tokens) - [Bridged tokens](ENVS.md#bridged-tokens)
- [Safe{Core} address tags](ENVS.md#safecore-address-tags) - [Safe{Core} address tags](ENVS.md#safecore-address-tags)
- [Address profile API](ENVS.md#address-profile-api)
- [SUAVE chain](ENVS.md#suave-chain) - [SUAVE chain](ENVS.md#suave-chain)
- [MetaSuites extension](ENVS.md#metasuites-extension) - [MetaSuites extension](ENVS.md#metasuites-extension)
- [Validators list](ENVS.md#validators-list) - [Validators list](ENVS.md#validators-list)
...@@ -653,6 +654,28 @@ For the smart contract addresses which are [Safe{Core} accounts](https://safe.gl ...@@ -653,6 +654,28 @@ For the smart contract addresses which are [Safe{Core} accounts](https://safe.gl
&nbsp; &nbsp;
### Address profile API
This feature allows the integration of an external API to fetch user info for addresses or contracts. When configured, if the API returns a username, a public tag with a custom link will be displayed in the address page header.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_ADDRESS_USERNAME_TAG | `{api_url: string; tag_link_template: string; tag_icon: string; tag_bg_color: string; tag_text_color: string}` | Address profile API tag configuration properties. See [below](#user-profile-api-configuration-properties). | - | - | `uniswap` | v1.35.0+ |
&nbsp;
#### Address profile API configuration properties
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| api_url_template | `string` | User profile API URL. Should be a template with `{address}` variable | Required | - | `https://example-api.com/{address}` |
| tag_link_template | `string` | External link to the profile. Should be a template with `{username}` variable | - | - | `https://example.com/{address}` |
| tag_icon | `string` | Public tag icon (.svg) url | - | - | `https://example.com/icon.svg` |
| tag_bg_color | `string` | Public tag background color (escape "#" symbol if you use HEX color codes or use rgba-value instead) | - | - | `\#000000` |
| tag_text_color | `string` | Public tag text color (escape "#" symbol if you use HEX color codes or use rgba-value instead) | - | - | `\#FFFFFF` |
&nbsp;
### SUAVE chain ### SUAVE chain
For blockchains that implement SUAVE architecture additional fields will be shown on the transaction page ("Allowed peekers", "Kettle"). Users also will be able to see the list of all transactions for a particular Kettle in the separate view. For blockchains that implement SUAVE architecture additional fields will be shown on the transaction page ("Allowed peekers", "Kettle"). Users also will be able to see the list of all transactions for a particular Kettle in the separate view.
...@@ -679,7 +702,7 @@ The feature enables the Validators page which provides detailed information abou ...@@ -679,7 +702,7 @@ The feature enables the Validators page which provides detailed information abou
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE | `'stability'` | Chain type | Required | - | `'stability'` | v1.25.0+ | | NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE | `'stability' \| 'blackfort'` | Chain type | Required | - | `'stability'` | v1.25.0+ |
&nbsp; &nbsp;
......
...@@ -42,12 +42,12 @@ import type { AddressesResponse, AddressesMetadataSearchResult, AddressesMetadat ...@@ -42,12 +42,12 @@ import type { AddressesResponse, AddressesMetadataSearchResult, AddressesMetadat
import type { AddressMetadataInfo, PublicTagTypesResponse } from 'types/api/addressMetadata'; import type { AddressMetadataInfo, PublicTagTypesResponse } from 'types/api/addressMetadata';
import type { import type {
ArbitrumL2MessagesResponse, ArbitrumL2MessagesResponse,
ArbitrumL2MessagesItem,
ArbitrumL2TxnBatch, ArbitrumL2TxnBatch,
ArbitrumL2TxnBatchesResponse, ArbitrumL2TxnBatchesResponse,
ArbitrumL2BatchTxs, ArbitrumL2BatchTxs,
ArbitrumL2BatchBlocks, ArbitrumL2BatchBlocks,
ArbitrumL2TxnBatchesItem, ArbitrumL2TxnBatchesItem,
ArbitrumLatestDepositsResponse,
} from 'types/api/arbitrumL2'; } from 'types/api/arbitrumL2';
import type { TxBlobs, Blob } from 'types/api/blobs'; import type { TxBlobs, Blob } from 'types/api/blobs';
import type { import type {
...@@ -118,7 +118,15 @@ import type { TxInterpretationResponse } from 'types/api/txInterpretation'; ...@@ -118,7 +118,15 @@ import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import type { TTxsFilters, TTxsWithBlobsFilters } from 'types/api/txsFilters'; import type { TTxsFilters, TTxsWithBlobsFilters } from 'types/api/txsFilters';
import type { TxStateChanges } from 'types/api/txStateChanges'; import type { TxStateChanges } from 'types/api/txStateChanges';
import type { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps'; import type { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps';
import type { ValidatorsCountersResponse, ValidatorsFilters, ValidatorsResponse, ValidatorsSorting } from 'types/api/validators'; import type {
ValidatorsStabilityCountersResponse,
ValidatorsStabilityFilters,
ValidatorsStabilityResponse,
ValidatorsStabilitySorting,
ValidatorsBlackfortCountersResponse,
ValidatorsBlackfortResponse,
ValidatorsBlackfortSorting,
} from 'types/api/validators';
import type { VerifiedContractsSorting } from 'types/api/verifiedContracts'; import type { VerifiedContractsSorting } from 'types/api/verifiedContracts';
import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals'; import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals';
import type { import type {
...@@ -911,14 +919,19 @@ export const RESOURCES = { ...@@ -911,14 +919,19 @@ export const RESOURCES = {
}, },
// VALIDATORS // VALIDATORS
validators: { validators_stability: {
path: '/api/v2/validators/:chainType', path: '/api/v2/validators/stability',
pathParams: [ 'chainType' as const ],
filterFields: [ 'address_hash' as const, 'state_filter' as const ], filterFields: [ 'address_hash' as const, 'state_filter' as const ],
}, },
validators_counters: { validators_stability_counters: {
path: '/api/v2/validators/:chainType/counters', path: '/api/v2/validators/stability/counters',
pathParams: [ 'chainType' as const ], },
validators_blackfort: {
path: '/api/v2/validators/blackfort',
filterFields: [],
},
validators_blackfort_counters: {
path: '/api/v2/validators/blackfort/counters',
}, },
// BLOBS // BLOBS
...@@ -1016,7 +1029,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward ...@@ -1016,7 +1029,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward
'zksync_l2_txn_batches' | 'zksync_l2_txn_batch_txs' | 'zksync_l2_txn_batches' | 'zksync_l2_txn_batch_txs' |
'withdrawals' | 'address_withdrawals' | 'block_withdrawals' | 'withdrawals' | 'address_withdrawals' | 'block_withdrawals' |
'watchlist' | 'private_tags_address' | 'private_tags_tx' | 'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators' | 'noves_address_history'; 'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators_stability' | 'validators_blackfort' | 'noves_address_history';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -1042,7 +1055,7 @@ Q extends 'homepage_blocks' ? Array<Block> : ...@@ -1042,7 +1055,7 @@ Q extends 'homepage_blocks' ? Array<Block> :
Q extends 'homepage_txs' ? Array<Transaction> : Q extends 'homepage_txs' ? Array<Transaction> :
Q extends 'homepage_txs_watchlist' ? Array<Transaction> : Q extends 'homepage_txs_watchlist' ? Array<Transaction> :
Q extends 'homepage_optimistic_deposits' ? Array<OptimisticL2DepositsItem> : Q extends 'homepage_optimistic_deposits' ? Array<OptimisticL2DepositsItem> :
Q extends 'homepage_arbitrum_deposits' ? { items: Array<ArbitrumL2MessagesItem> } : Q extends 'homepage_arbitrum_deposits' ? ArbitrumLatestDepositsResponse :
Q extends 'homepage_zkevm_l2_batches' ? { items: Array<ZkEvmL2TxnBatchesItem> } : Q extends 'homepage_zkevm_l2_batches' ? { items: Array<ZkEvmL2TxnBatchesItem> } :
Q extends 'homepage_arbitrum_l2_batches' ? { items: Array<ArbitrumL2TxnBatchesItem>} : Q extends 'homepage_arbitrum_l2_batches' ? { items: Array<ArbitrumL2TxnBatchesItem>} :
Q extends 'homepage_indexing_status' ? IndexingStatus : Q extends 'homepage_indexing_status' ? IndexingStatus :
...@@ -1137,8 +1150,10 @@ Q extends 'address_metadata_tag_types' ? PublicTagTypesResponse : ...@@ -1137,8 +1150,10 @@ Q extends 'address_metadata_tag_types' ? PublicTagTypesResponse :
Q extends 'blob' ? Blob : Q extends 'blob' ? Blob :
Q extends 'marketplace_dapps' ? Array<MarketplaceAppOverview> : Q extends 'marketplace_dapps' ? Array<MarketplaceAppOverview> :
Q extends 'marketplace_dapp' ? MarketplaceAppOverview : Q extends 'marketplace_dapp' ? MarketplaceAppOverview :
Q extends 'validators' ? ValidatorsResponse : Q extends 'validators_stability' ? ValidatorsStabilityResponse :
Q extends 'validators_counters' ? ValidatorsCountersResponse : Q extends 'validators_stability_counters' ? ValidatorsStabilityCountersResponse :
Q extends 'validators_blackfort' ? ValidatorsBlackfortResponse :
Q extends 'validators_blackfort_counters' ? ValidatorsBlackfortCountersResponse :
Q extends 'shibarium_withdrawals' ? ShibariumWithdrawalsResponse : Q extends 'shibarium_withdrawals' ? ShibariumWithdrawalsResponse :
Q extends 'shibarium_deposits' ? ShibariumDepositsResponse : Q extends 'shibarium_deposits' ? ShibariumDepositsResponse :
Q extends 'shibarium_withdrawals_count' ? number : Q extends 'shibarium_withdrawals_count' ? number :
...@@ -1214,7 +1229,7 @@ Q extends 'verified_contracts' ? VerifiedContractsFilters : ...@@ -1214,7 +1229,7 @@ Q extends 'verified_contracts' ? VerifiedContractsFilters :
Q extends 'addresses_lookup' ? EnsAddressLookupFilters : Q extends 'addresses_lookup' ? EnsAddressLookupFilters :
Q extends 'domains_lookup' ? EnsDomainLookupFilters : Q extends 'domains_lookup' ? EnsDomainLookupFilters :
Q extends 'user_ops' ? UserOpsFilters : Q extends 'user_ops' ? UserOpsFilters :
Q extends 'validators' ? ValidatorsFilters : Q extends 'validators_stability' ? ValidatorsStabilityFilters :
Q extends 'address_mud_tables' ? AddressMudTablesFilter : Q extends 'address_mud_tables' ? AddressMudTablesFilter :
Q extends 'address_mud_records' ? AddressMudRecordsFilter : Q extends 'address_mud_records' ? AddressMudRecordsFilter :
never; never;
...@@ -1228,7 +1243,8 @@ Q extends 'verified_contracts' ? VerifiedContractsSorting : ...@@ -1228,7 +1243,8 @@ Q extends 'verified_contracts' ? VerifiedContractsSorting :
Q extends 'address_txs' ? TransactionsSorting : Q extends 'address_txs' ? TransactionsSorting :
Q extends 'addresses_lookup' ? EnsLookupSorting : Q extends 'addresses_lookup' ? EnsLookupSorting :
Q extends 'domains_lookup' ? EnsLookupSorting : Q extends 'domains_lookup' ? EnsLookupSorting :
Q extends 'validators' ? ValidatorsSorting : Q extends 'validators_stability' ? ValidatorsStabilitySorting :
Q extends 'validators_blackfort' ? ValidatorsBlackfortSorting :
Q extends 'address_mud_records' ? AddressMudRecordsSorting : Q extends 'address_mud_records' ? AddressMudRecordsSorting :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
import { useQuery } from '@tanstack/react-query';
import * as v from 'valibot';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useFetch from 'lib/hooks/useFetch';
const feature = config.features.addressProfileAPI;
type AddressInfoApiQueryResponse = v.InferOutput<typeof AddressInfoSchema>;
const AddressInfoSchema = v.object({
user_profile: v.object({
username: v.union([ v.string(), v.null() ]),
}),
});
const ERROR_NAME = 'Invalid response schema';
export default function useAddressProfileApiQuery(hash: string | undefined, isEnabled = true) {
const fetch = useFetch();
return useQuery<unknown, ResourceError<unknown>, AddressInfoApiQueryResponse>({
queryKey: [ 'username_api', hash ],
queryFn: async() => {
if (!feature.isEnabled || !hash) {
return Promise.reject();
}
return fetch(feature.apiUrlTemplate.replace('{address}', hash), undefined, { omitSentryErrorLog: true });
},
enabled: isEnabled && Boolean(hash),
refetchOnMount: false,
select: (response) => {
const parsedResponse = v.safeParse(AddressInfoSchema, response);
if (!parsedResponse.success) {
throw Error(ERROR_NAME);
}
return parsedResponse.output;
},
});
}
...@@ -246,7 +246,7 @@ export default function useNavItems(): ReturnType { ...@@ -246,7 +246,7 @@ export default function useNavItems(): ReturnType {
text: 'Charts & stats', text: 'Charts & stats',
nextRoute: { pathname: '/stats' as const }, nextRoute: { pathname: '/stats' as const },
icon: 'stats', icon: 'stats',
isActive: pathname === '/stats', isActive: pathname.startsWith('/stats'),
} : null, } : null,
apiNavItems.length > 0 && { apiNavItems.length > 0 && {
text: 'API', text: 'API',
......
...@@ -20,7 +20,7 @@ export default function generate<Pathname extends Route['pathname']>(route: Rout ...@@ -20,7 +20,7 @@ export default function generate<Pathname extends Route['pathname']>(route: Rout
}; };
const title = compileValue(templates.title.make(route.pathname, Boolean(apiData)), params); const title = compileValue(templates.title.make(route.pathname, Boolean(apiData)), params);
const description = compileValue(templates.description.make(route.pathname), params); const description = compileValue(templates.description.make(route.pathname, Boolean(apiData)), params);
const pageOgType = getPageOgType(route.pathname); const pageOgType = getPageOgType(route.pathname);
......
...@@ -23,6 +23,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -23,6 +23,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/apps': 'Root page', '/apps': 'Root page',
'/apps/[id]': 'Regular page', '/apps/[id]': 'Regular page',
'/stats': 'Root page', '/stats': 'Root page',
'/stats/[id]': 'Regular page',
'/api-docs': 'Regular page', '/api-docs': 'Regular page',
'/graphiql': 'Regular page', '/graphiql': 'Regular page',
'/search-results': 'Regular page', '/search-results': 'Regular page',
......
...@@ -27,6 +27,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -27,6 +27,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/apps': DEFAULT_TEMPLATE, '/apps': DEFAULT_TEMPLATE,
'/apps/[id]': DEFAULT_TEMPLATE, '/apps/[id]': DEFAULT_TEMPLATE,
'/stats': DEFAULT_TEMPLATE, '/stats': DEFAULT_TEMPLATE,
'/stats/[id]': DEFAULT_TEMPLATE,
'/api-docs': DEFAULT_TEMPLATE, '/api-docs': DEFAULT_TEMPLATE,
'/graphiql': DEFAULT_TEMPLATE, '/graphiql': DEFAULT_TEMPLATE,
'/search-results': DEFAULT_TEMPLATE, '/search-results': DEFAULT_TEMPLATE,
...@@ -68,8 +69,10 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -68,8 +69,10 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/api/sprite': DEFAULT_TEMPLATE, '/api/sprite': DEFAULT_TEMPLATE,
}; };
export function make(pathname: Route['pathname']) { const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
const template = TEMPLATE_MAP[pathname]; '/stats/[id]': '%description%',
};
return template ?? ''; export function make(pathname: Route['pathname'], isEnriched = false) {
return (isEnriched ? TEMPLATE_MAP_ENHANCED[pathname] : undefined) ?? TEMPLATE_MAP[pathname] ?? '';
} }
...@@ -23,6 +23,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -23,6 +23,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/apps': '%network_name% DApps - Explore top apps', '/apps': '%network_name% DApps - Explore top apps',
'/apps/[id]': '%network_name% marketplace app', '/apps/[id]': '%network_name% marketplace app',
'/stats': '%network_name% stats - %network_name% network insights', '/stats': '%network_name% stats - %network_name% network insights',
'/stats/[id]': '%network_name% stats - %id% chart',
'/api-docs': '%network_name% API docs - %network_name% developer tools', '/api-docs': '%network_name% API docs - %network_name% developer tools',
'/graphiql': 'GraphQL for %network_name% - %network_name% data query', '/graphiql': 'GraphQL for %network_name% - %network_name% data query',
'/search-results': '%network_name% search result for %q%', '/search-results': '%network_name% search result for %q%',
...@@ -69,6 +70,7 @@ const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = { ...@@ -69,6 +70,7 @@ const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
'/token/[hash]/instance/[id]': '%network_name% token instance for %symbol%', '/token/[hash]/instance/[id]': '%network_name% token instance for %symbol%',
'/apps/[id]': '%network_name% - %app_name%', '/apps/[id]': '%network_name% - %app_name%',
'/address/[hash]': '%network_name% address details for %domain_name%', '/address/[hash]': '%network_name% address details for %domain_name%',
'/stats/[id]': '%title% chart on %network_name%',
}; };
export function make(pathname: Route['pathname'], isEnriched = false) { export function make(pathname: Route['pathname'], isEnriched = false) {
......
import type { LineChart } from '@blockscout/stats-types';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import type { Route } from 'nextjs-routes'; import type { Route } from 'nextjs-routes';
...@@ -9,6 +10,7 @@ export type ApiData<Pathname extends Route['pathname']> = ...@@ -9,6 +10,7 @@ export type ApiData<Pathname extends Route['pathname']> =
Pathname extends '/token/[hash]' ? TokenInfo : Pathname extends '/token/[hash]' ? TokenInfo :
Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } : Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } :
Pathname extends '/apps/[id]' ? { app_name: string } : Pathname extends '/apps/[id]' ? { app_name: string } :
Pathname extends '/stats/[id]' ? LineChart['info'] :
never never
) | null; ) | null;
......
...@@ -21,6 +21,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -21,6 +21,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/apps': 'DApps', '/apps': 'DApps',
'/apps/[id]': 'DApp', '/apps/[id]': 'DApp',
'/stats': 'Stats', '/stats': 'Stats',
'/stats/[id]': 'Stats chart',
'/api-docs': 'REST API', '/api-docs': 'REST API',
'/graphiql': 'GraphQL', '/graphiql': 'GraphQL',
'/search-results': 'Search results', '/search-results': 'Search results',
......
...@@ -138,6 +138,9 @@ Type extends EventTypes.PAGE_WIDGET ? ( ...@@ -138,6 +138,9 @@ Type extends EventTypes.PAGE_WIDGET ? (
'Type': 'Address tag'; 'Type': 'Address tag';
'Info': string; 'Info': string;
'URL': string; 'URL': string;
} | {
'Type': 'Share chart';
'Info': string;
} }
) : ) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
......
import type { ArbitrumL2MessagesResponse } from 'types/api/arbitrumL2'; import type { ArbitrumL2MessagesResponse, ArbitrumLatestDepositsResponse } from 'types/api/arbitrumL2';
export const baseResponse: ArbitrumL2MessagesResponse = { export const baseResponse: ArbitrumL2MessagesResponse = {
items: [ items: [
...@@ -27,3 +27,20 @@ export const baseResponse: ArbitrumL2MessagesResponse = { ...@@ -27,3 +27,20 @@ export const baseResponse: ArbitrumL2MessagesResponse = {
direction: 'to-rollup', direction: 'to-rollup',
}, },
}; };
export const latestDepositsResponse: ArbitrumLatestDepositsResponse = {
items: [
{
completion_transaction_hash: '0x3ccdf87449d3de6a9dcd3eddb7bc9ecdf1770d4631f03cdf12a098911618d138',
origination_transaction_block_number: 123400,
origination_transaction_hash: '0x210d9f70f411de1079e32a98473b04345a5ea6ff2340a8511ebc2df641274436',
origination_timestamp: '2023-06-01T14:46:48.000000Z',
},
{
completion_transaction_hash: '0xd16d918b2f95a5cdf66824f6291b6d5eb80b6f4acab3f9fb82ee0ec4109646a0',
origination_timestamp: null,
origination_transaction_block_number: null,
origination_transaction_hash: null,
},
],
};
...@@ -4,158 +4,195 @@ export const averageGasPrice: stats.LineChart = { ...@@ -4,158 +4,195 @@ export const averageGasPrice: stats.LineChart = {
chart: [ chart: [
{ {
date: '2023-12-22', date: '2023-12-22',
date_to: '2023-12-22',
value: '37.7804422597599', value: '37.7804422597599',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2023-12-23', date: '2023-12-23',
date_to: '2023-12-23',
value: '25.84889883009387', value: '25.84889883009387',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2023-12-24', date: '2023-12-24',
date_to: '2023-12-24',
value: '25.818463227198574', value: '25.818463227198574',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2023-12-25', date: '2023-12-25',
date_to: '2023-12-25',
value: '26.045513050051298', value: '26.045513050051298',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2023-12-26', date: '2023-12-26',
date_to: '2023-12-26',
value: '21.42600692652399', value: '21.42600692652399',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2023-12-27', date: '2023-12-27',
date_to: '2023-12-27',
value: '31.066730409846656', value: '31.066730409846656',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2023-12-28', date: '2023-12-28',
date_to: '2023-12-28',
value: '33.63955781902089', value: '33.63955781902089',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2023-12-29', date: '2023-12-29',
date_to: '2023-12-29',
value: '28.064736756058384', value: '28.064736756058384',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2023-12-30', date: '2023-12-30',
date_to: '2023-12-30',
value: '23.074500869678175', value: '23.074500869678175',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2023-12-31', date: '2023-12-31',
date_to: '2023-12-31',
value: '17.651005734615133', value: '17.651005734615133',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-01', date: '2024-01-01',
date_to: '2023-01-01',
value: '14.906085174476441', value: '14.906085174476441',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-02', date: '2024-01-02',
date_to: '2023-01-02',
value: '22.28459059038656', value: '22.28459059038656',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-03', date: '2024-01-03',
date_to: '2023-01-03',
value: '39.8311646806592', value: '39.8311646806592',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-04', date: '2024-01-04',
date_to: '2023-01-04',
value: '26.09989322256083', value: '26.09989322256083',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-05', date: '2024-01-05',
date_to: '2023-01-05',
value: '22.821996688111998', value: '22.821996688111998',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-06', date: '2024-01-06',
date_to: '2023-01-06',
value: '20.32680041262083', value: '20.32680041262083',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-07', date: '2024-01-07',
date_to: '2023-01-07',
value: '32.535045831809704', value: '32.535045831809704',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-08', date: '2024-01-08',
date_to: '2023-01-08',
value: '27.443477102139482', value: '27.443477102139482',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-09', date: '2024-01-09',
date_to: '2023-01-09',
value: '20.7911332558055', value: '20.7911332558055',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-10', date: '2024-01-10',
date_to: '2023-01-10',
value: '42.10740192523919', value: '42.10740192523919',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-11', date: '2024-01-11',
date_to: '2023-01-11',
value: '35.75215680343582', value: '35.75215680343582',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-12', date: '2024-01-12',
date_to: '2023-01-12',
value: '27.430414798093253', value: '27.430414798093253',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-13', date: '2024-01-13',
date_to: '2023-01-13',
value: '20.170934096589875', value: '20.170934096589875',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-14', date: '2024-01-14',
date_to: '2023-01-14',
value: '38.79660984371034', value: '38.79660984371034',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-15', date: '2024-01-15',
date_to: '2023-01-15',
value: '26.140740484554204', value: '26.140740484554204',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-16', date: '2024-01-16',
date_to: '2023-01-16',
value: '36.708543184194156', value: '36.708543184194156',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-17', date: '2024-01-17',
date_to: '2023-01-17',
value: '40.325438794298876', value: '40.325438794298876',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-18', date: '2024-01-18',
date_to: '2023-01-18',
value: '37.55145309930694', value: '37.55145309930694',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-19', date: '2024-01-19',
date_to: '2023-01-19',
value: '33.271450114434664', value: '33.271450114434664',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-20', date: '2024-01-20',
date_to: '2023-01-20',
value: '19.303304377685638', value: '19.303304377685638',
is_approximate: false, is_approximate: false,
}, },
{ {
date: '2024-01-21', date: '2024-01-21',
date_to: '2023-01-21',
value: '14.375908594704976', value: '14.375908594704976',
is_approximate: false, is_approximate: false,
}, },
], ],
info: {
title: 'Chart title',
description: 'Chart description',
id: 'chart',
resolutions: [ 'DAY', 'MONTH' ],
},
}; };
...@@ -11,18 +11,21 @@ export const base: stats.LineCharts = { ...@@ -11,18 +11,21 @@ export const base: stats.LineCharts = {
title: 'Accounts growth', title: 'Accounts growth',
description: 'Cumulative accounts number per period', description: 'Cumulative accounts number per period',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'activeAccounts', id: 'activeAccounts',
title: 'Active accounts', title: 'Active accounts',
description: 'Active accounts number per period', description: 'Active accounts number per period',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'newAccounts', id: 'newAccounts',
title: 'New accounts', title: 'New accounts',
description: 'New accounts number per day', description: 'New accounts number per day',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
], ],
}, },
...@@ -35,30 +38,35 @@ export const base: stats.LineCharts = { ...@@ -35,30 +38,35 @@ export const base: stats.LineCharts = {
title: 'Average transaction fee', title: 'Average transaction fee',
description: 'The average amount in ETH spent per transaction', description: 'The average amount in ETH spent per transaction',
units: 'ETH', units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'newTxns', id: 'newTxns',
title: 'New transactions', title: 'New transactions',
description: 'New transactions number', description: 'New transactions number',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'txnsFee', id: 'txnsFee',
title: 'Transactions fees', title: 'Transactions fees',
description: 'Amount of tokens paid as fees', description: 'Amount of tokens paid as fees',
units: 'ETH', units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'txnsGrowth', id: 'txnsGrowth',
title: 'Transactions growth', title: 'Transactions growth',
description: 'Cumulative transactions number', description: 'Cumulative transactions number',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'txnsSuccessRate', id: 'txnsSuccessRate',
title: 'Transactions success rate', title: 'Transactions success rate',
description: 'Successful transactions rate per day', description: 'Successful transactions rate per day',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
], ],
}, },
...@@ -71,18 +79,21 @@ export const base: stats.LineCharts = { ...@@ -71,18 +79,21 @@ export const base: stats.LineCharts = {
title: 'Average block rewards', title: 'Average block rewards',
description: 'Average amount of distributed reward in tokens per day', description: 'Average amount of distributed reward in tokens per day',
units: 'ETH', units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'averageBlockSize', id: 'averageBlockSize',
title: 'Average block size', title: 'Average block size',
description: 'Average size of blocks in bytes', description: 'Average size of blocks in bytes',
units: 'Bytes', units: 'Bytes',
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'newBlocks', id: 'newBlocks',
title: 'New blocks', title: 'New blocks',
description: 'New blocks number', description: 'New blocks number',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
], ],
}, },
...@@ -95,6 +106,7 @@ export const base: stats.LineCharts = { ...@@ -95,6 +106,7 @@ export const base: stats.LineCharts = {
title: 'New ETH transfers', title: 'New ETH transfers',
description: 'New token transfers number for the period', description: 'New token transfers number for the period',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
], ],
}, },
...@@ -107,18 +119,21 @@ export const base: stats.LineCharts = { ...@@ -107,18 +119,21 @@ export const base: stats.LineCharts = {
title: 'Average gas limit', title: 'Average gas limit',
description: 'Average gas limit per block for the period', description: 'Average gas limit per block for the period',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'averageGasPrice', id: 'averageGasPrice',
title: 'Average gas price', title: 'Average gas price',
description: 'Average gas price for the period (Gwei)', description: 'Average gas price for the period (Gwei)',
units: 'Gwei', units: 'Gwei',
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'gasUsedGrowth', id: 'gasUsedGrowth',
title: 'Gas used growth', title: 'Gas used growth',
description: 'Cumulative gas used for the period', description: 'Cumulative gas used for the period',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
], ],
}, },
...@@ -131,12 +146,14 @@ export const base: stats.LineCharts = { ...@@ -131,12 +146,14 @@ export const base: stats.LineCharts = {
title: 'New verified contracts', title: 'New verified contracts',
description: 'New verified contracts number for the period', description: 'New verified contracts number for the period',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'verifiedContractsGrowth', id: 'verifiedContractsGrowth',
title: 'Verified contracts growth', title: 'Verified contracts growth',
description: 'Cumulative number verified contracts for the period', description: 'Cumulative number verified contracts for the period',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
], ],
}, },
......
import type {
ValidatorBlackfort,
ValidatorsBlackfortCountersResponse,
ValidatorsBlackfortResponse,
} from 'types/api/validators';
import * as addressMock from '../address/address';
export const validator1: ValidatorBlackfort = {
address: addressMock.withName,
name: 'testnet-3',
commission: 10,
delegated_amount: '0',
self_bonded_amount: '10000',
};
export const validator2: ValidatorBlackfort = {
address: addressMock.withEns,
name: 'GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG',
commission: 5000,
delegated_amount: '10000',
self_bonded_amount: '100',
};
export const validator3: ValidatorBlackfort = {
address: addressMock.withoutName,
name: 'testnet-1',
commission: 0,
delegated_amount: '0',
self_bonded_amount: '10000',
};
export const validatorsResponse: ValidatorsBlackfortResponse = {
items: [ validator1, validator2, validator3 ],
next_page_params: null,
};
export const validatorsCountersResponse: ValidatorsBlackfortCountersResponse = {
new_validators_counter_24h: '11',
validators_counter: '140',
};
import type { Validator, ValidatorsCountersResponse, ValidatorsResponse } from 'types/api/validators'; import type {
ValidatorStability,
ValidatorsStabilityCountersResponse,
ValidatorsStabilityResponse,
} from 'types/api/validators';
import * as addressMock from '../address/address'; import * as addressMock from '../address/address';
export const validator1: Validator = { export const validator1: ValidatorStability = {
address: addressMock.withName, address: addressMock.withName,
blocks_validated_count: 7334224, blocks_validated_count: 7334224,
state: 'active', state: 'active',
}; };
export const validator2: Validator = { export const validator2: ValidatorStability = {
address: addressMock.withEns, address: addressMock.withEns,
blocks_validated_count: 8937453, blocks_validated_count: 8937453,
state: 'probation', state: 'probation',
}; };
export const validator3: Validator = { export const validator3: ValidatorStability = {
address: addressMock.withoutName, address: addressMock.withoutName,
blocks_validated_count: 1234, blocks_validated_count: 1234,
state: 'inactive', state: 'inactive',
}; };
export const validatorsResponse: ValidatorsResponse = { export const validatorsResponse: ValidatorsStabilityResponse = {
items: [ validator1, validator2, validator3 ], items: [ validator1, validator2, validator3 ],
next_page_params: null, next_page_params: null,
}; };
export const validatorsCountersResponse: ValidatorsCountersResponse = { export const validatorsCountersResponse: ValidatorsStabilityCountersResponse = {
active_validators_counter: '42', active_validators_counter: '42',
active_validators_percentage: 7.14, active_validators_percentage: 7.14,
new_validators_counter_24h: '11', new_validators_counter_24h: '11',
......
...@@ -16,6 +16,7 @@ function generateCspPolicy() { ...@@ -16,6 +16,7 @@ function generateCspPolicy() {
descriptors.monaco(), descriptors.monaco(),
descriptors.safe(), descriptors.safe(),
descriptors.sentry(), descriptors.sentry(),
descriptors.usernameApi(),
descriptors.walletConnect(), descriptors.walletConnect(),
); );
......
...@@ -11,4 +11,5 @@ export { mixpanel } from './mixpanel'; ...@@ -11,4 +11,5 @@ export { mixpanel } from './mixpanel';
export { monaco } from './monaco'; export { monaco } from './monaco';
export { safe } from './safe'; export { safe } from './safe';
export { sentry } from './sentry'; export { sentry } from './sentry';
export { usernameApi } from './usernameApi';
export { walletConnect } from './walletConnect'; export { walletConnect } from './walletConnect';
import type CspDev from 'csp-dev';
import config from 'configs/app';
const feature = config.features.addressProfileAPI;
export function usernameApi(): CspDev.DirectiveDescriptor {
if (!feature.isEnabled) {
return {};
}
const apiOrigin = (() => {
try {
const url = new URL(feature.apiUrlTemplate);
return url.origin;
} catch (error) {
return '';
}
})();
return {
'connect-src': [
apiOrigin,
],
};
}
...@@ -52,6 +52,7 @@ declare module "nextjs-routes" { ...@@ -52,6 +52,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/public-tags/submit"> | StaticRoute<"/public-tags/submit">
| StaticRoute<"/search-results"> | StaticRoute<"/search-results">
| StaticRoute<"/sprite"> | StaticRoute<"/sprite">
| DynamicRoute<"/stats/[id]", { "id": string }>
| StaticRoute<"/stats"> | StaticRoute<"/stats">
| DynamicRoute<"/token/[hash]", { "hash": string }> | DynamicRoute<"/token/[hash]", { "hash": string }>
| DynamicRoute<"/token/[hash]/instance/[id]", { "hash": string; "id": string }> | DynamicRoute<"/token/[hash]/instance/[id]", { "hash": string; "id": string }>
......
...@@ -12,6 +12,7 @@ type Params<R extends ResourceName> = ( ...@@ -12,6 +12,7 @@ type Params<R extends ResourceName> = (
{ {
resource: R; resource: R;
pathParams?: ResourcePathParams<R>; pathParams?: ResourcePathParams<R>;
queryParams?: Record<string, string | number | undefined>;
} | { } | {
url: string; url: string;
route: string; route: string;
...@@ -22,12 +23,11 @@ type Params<R extends ResourceName> = ( ...@@ -22,12 +23,11 @@ type Params<R extends ResourceName> = (
export default async function fetchApi<R extends ResourceName = never, S = ResourcePayload<R>>(params: Params<R>): Promise<S | undefined> { export default async function fetchApi<R extends ResourceName = never, S = ResourcePayload<R>>(params: Params<R>): Promise<S | undefined> {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
controller.abort(); controller.abort();
}, params.timeout || SECOND); }, params.timeout || SECOND);
const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams); const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams, params.queryParams);
const route = 'route' in params ? params.route : RESOURCES[params.resource]['path']; const route = 'route' in params ? params.route : RESOURCES[params.resource]['path'];
const end = metrics?.apiRequestDuration.startTimer(); const end = metrics?.apiRequestDuration.startTimer();
......
...@@ -4,8 +4,8 @@ ...@@ -4,8 +4,8 @@
"private": false, "private": false,
"homepage": "https://github.com/blockscout/frontend#readme", "homepage": "https://github.com/blockscout/frontend#readme",
"engines": { "engines": {
"node": "20.11.0", "node": "20.17.0",
"npm": "10.2.4" "npm": "10.8.2"
}, },
"scripts": { "scripts": {
"dev": "./tools/scripts/dev.sh", "dev": "./tools/scripts/dev.sh",
...@@ -26,8 +26,8 @@ ...@@ -26,8 +26,8 @@
"svg:build-sprite": "icons build -i ./icons -o ./public/icons --optimize", "svg:build-sprite": "icons build -i ./icons -o ./public/icons --optimize",
"test:pw": "./tools/scripts/pw.sh", "test:pw": "./tools/scripts/pw.sh",
"test:pw:local": "export NODE_PATH=$(pwd)/node_modules && yarn test:pw", "test:pw:local": "export NODE_PATH=$(pwd)/node_modules && yarn test:pw",
"test:pw:docker": "docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-focal ./tools/scripts/pw.docker.sh", "test:pw:docker": "docker run --rm --ipc=host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.47.2-focal ./tools/scripts/pw.docker.sh",
"test:pw:docker:deps": "docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-focal ./tools/scripts/pw.docker.deps.sh", "test:pw:docker:deps": "docker run --rm --ipc=host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.47.2-focal ./tools/scripts/pw.docker.deps.sh",
"test:pw:ci": "yarn test:pw --project=$PW_PROJECT", "test:pw:ci": "yarn test:pw --project=$PW_PROJECT",
"test:pw:detect-affected": "node ./deploy/tools/affected-tests/index.js", "test:pw:detect-affected": "node ./deploy/tools/affected-tests/index.js",
"test:jest": "jest", "test:jest": "jest",
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
}, },
"dependencies": { "dependencies": {
"@blockscout/bens-types": "1.4.1", "@blockscout/bens-types": "1.4.1",
"@blockscout/stats-types": "1.6.0", "@blockscout/stats-types": "2.0.0",
"@blockscout/visualizer-types": "0.2.0", "@blockscout/visualizer-types": "0.2.0",
"@chakra-ui/react": "2.7.1", "@chakra-ui/react": "2.7.1",
"@chakra-ui/theme-tools": "^2.0.18", "@chakra-ui/theme-tools": "^2.0.18",
...@@ -87,7 +87,7 @@ ...@@ -87,7 +87,7 @@
"magic-bytes.js": "1.8.0", "magic-bytes.js": "1.8.0",
"mixpanel-browser": "^2.47.0", "mixpanel-browser": "^2.47.0",
"monaco-editor": "^0.34.1", "monaco-editor": "^0.34.1",
"next": "14.2.9", "next": "14.2.13",
"nextjs-routes": "^1.0.8", "nextjs-routes": "^1.0.8",
"node-fetch": "^3.2.9", "node-fetch": "^3.2.9",
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
...@@ -115,8 +115,8 @@ ...@@ -115,8 +115,8 @@
"xss": "^1.0.14" "xss": "^1.0.14"
}, },
"devDependencies": { "devDependencies": {
"@playwright/experimental-ct-react": "1.41.1", "@playwright/experimental-ct-react": "1.47.2",
"@playwright/test": "1.41.1", "@playwright/test": "1.47.2",
"@svgr/webpack": "^6.5.1", "@svgr/webpack": "^6.5.1",
"@tanstack/eslint-plugin-query": "^5.0.5", "@tanstack/eslint-plugin-query": "^5.0.5",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
...@@ -128,7 +128,7 @@ ...@@ -128,7 +128,7 @@
"@types/jest": "^29.2.0", "@types/jest": "^29.2.0",
"@types/js-cookie": "^3.0.2", "@types/js-cookie": "^3.0.2",
"@types/mixpanel-browser": "^2.38.1", "@types/mixpanel-browser": "^2.38.1",
"@types/node": "20.11.0", "@types/node": "20.16.7",
"@types/phoenix": "^1.5.4", "@types/phoenix": "^1.5.4",
"@types/qrcode": "^1.5.0", "@types/qrcode": "^1.5.0",
"@types/react": "18.0.9", "@types/react": "18.0.9",
...@@ -161,7 +161,7 @@ ...@@ -161,7 +161,7 @@
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "5.4.2", "typescript": "5.4.2",
"vite-plugin-svgr": "^2.2.2", "vite-plugin-svgr": "^2.2.2",
"vite-tsconfig-paths": "^3.5.2", "vite-tsconfig-paths": "4.3.2",
"ws": "^8.17.1" "ws": "^8.17.1"
}, },
"lint-staged": { "lint-staged": {
......
import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
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 dayjs from 'lib/date/dayjs';
import getQueryParamString from 'lib/router/getQueryParamString';
const Chart = dynamic(() => import('ui/pages/Chart'), { ssr: false });
const pathname: Route['pathname'] = '/stats/[id]';
const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/stats/[id]" query={ props.query } apiData={ props.apiData }>
<Chart/>
</PageNextJs>
);
};
export default Page;
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if ('props' in baseResponse) {
if (
config.meta.seo.enhancedDataEnabled ||
(config.meta.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview')
) {
const chartData = await fetchApi({
resource: 'stats_line',
pathParams: { id: getQueryParamString(ctx.query.id) },
queryParams: { from: dayjs().format('YYYY-MM-DD'), to: dayjs().format('YYYY-MM-DD') },
timeout: 1000,
});
(await baseResponse.props).apiData = chartData?.info ?? null;
}
}
return baseResponse;
};
...@@ -4,7 +4,21 @@ import React from 'react'; ...@@ -4,7 +4,21 @@ import React from 'react';
import PageNextJs from 'nextjs/PageNextJs'; import PageNextJs from 'nextjs/PageNextJs';
const Validators = dynamic(() => import('ui/pages/Validators'), { ssr: false }); import config from 'configs/app';
const validatorsFeature = config.features.validators;
const Validators = dynamic(() => {
if (validatorsFeature.isEnabled && validatorsFeature.chainType === 'stability') {
return import('ui/pages/ValidatorsStability');
}
if (validatorsFeature.isEnabled && validatorsFeature.chainType === 'blackfort') {
return import('ui/pages/ValidatorsBlackfort');
}
throw new Error('Validators feature is not enabled.');
}, { ssr: false });
const Page: NextPage = () => { const Page: NextPage = () => {
return ( return (
......
...@@ -50,7 +50,7 @@ const config: PlaywrightTestConfig = defineConfig({ ...@@ -50,7 +50,7 @@ const config: PlaywrightTestConfig = defineConfig({
ctViteConfig: { ctViteConfig: {
plugins: [ plugins: [
tsconfigPaths(), tsconfigPaths({ loose: true, ignoreConfigErrors: true }),
react(), react(),
svgr({ svgr({
exportAsDefault: true, exportAsDefault: true,
...@@ -82,6 +82,9 @@ const config: PlaywrightTestConfig = defineConfig({ ...@@ -82,6 +82,9 @@ const config: PlaywrightTestConfig = defineConfig({
// We don't call this function in TestApp and since we use useWeb3Modal() and useWeb3ModalState() hooks in the code, we have to mock the module // We don't call this function in TestApp and since we use useWeb3Modal() and useWeb3ModalState() hooks in the code, we have to mock the module
// Otherwise it will complain that createWeb3Modal() is no called before the hooks are used // Otherwise it will complain that createWeb3Modal() is no called before the hooks are used
{ find: /^@web3modal\/wagmi\/react$/, replacement: './playwright/mocks/modules/@web3modal/wagmi/react.js' }, { find: /^@web3modal\/wagmi\/react$/, replacement: './playwright/mocks/modules/@web3modal/wagmi/react.js' },
{ find: '/playwright/index.ts', replacement: './playwright/index.ts' },
{ find: '/playwright/envs.js', replacement: './playwright/envs.js' },
], ],
}, },
define: { define: {
......
...@@ -4,8 +4,6 @@ import type { Locator, TestFixture } from '@playwright/test'; ...@@ -4,8 +4,6 @@ import type { Locator, TestFixture } from '@playwright/test';
import type router from 'next/router'; import type router from 'next/router';
import React from 'react'; import React from 'react';
import type { JsonObject } from '@playwright/experimental-ct-core/types/component';
import type { Props as TestAppProps } from 'playwright/TestApp'; import type { Props as TestAppProps } from 'playwright/TestApp';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
...@@ -14,15 +12,13 @@ interface MountResult extends Locator { ...@@ -14,15 +12,13 @@ interface MountResult extends Locator {
update(component: JSX.Element): Promise<void>; update(component: JSX.Element): Promise<void>;
} }
type Mount = <HooksConfig extends JsonObject>(component: JSX.Element, options?: MountOptions<HooksConfig>) => Promise<MountResult>; interface AppHooksConfig {
router: Partial<Pick<typeof router, 'query' | 'isReady' | 'asPath' | 'pathname'>>;
interface Options extends JsonObject {
hooksConfig?: {
router: Partial<Pick<typeof router, 'query' | 'isReady' | 'asPath' | 'pathname'>>;
};
} }
export type RenderFixture = (component: JSX.Element, options?: Options, props?: Omit<TestAppProps, 'children'>) => Promise<MountResult> type Mount = <HooksConfig extends AppHooksConfig>(component: JSX.Element, options?: MountOptions<HooksConfig>) => Promise<MountResult>
export type RenderFixture = (component: JSX.Element, options?: MountOptions<AppHooksConfig>, props?: Omit<TestAppProps, 'children'>) => Promise<MountResult>
const fixture: TestFixture<RenderFixture, { mount: Mount }> = async({ mount }, use) => { const fixture: TestFixture<RenderFixture, { mount: Mount }> = async({ mount }, use) => {
await use((component, options, props) => { await use((component, options, props) => {
......
/* eslint-disable no-console */ /* eslint-disable no-console */
import { test as base } from '@playwright/experimental-ct-react'; import { test as base } from '@playwright/experimental-ct-react';
import type { Page } from '@playwright/test';
import * as injectMetaMaskProvider from './fixtures/injectMetaMaskProvider'; import * as injectMetaMaskProvider from './fixtures/injectMetaMaskProvider';
import * as mockApiResponse from './fixtures/mockApiResponse'; import * as mockApiResponse from './fixtures/mockApiResponse';
...@@ -13,7 +14,7 @@ import * as mockTextAd from './fixtures/mockTextAd'; ...@@ -13,7 +14,7 @@ import * as mockTextAd from './fixtures/mockTextAd';
import * as render from './fixtures/render'; import * as render from './fixtures/render';
import * as socketServer from './fixtures/socketServer'; import * as socketServer from './fixtures/socketServer';
interface Fixtures { export interface Fixtures {
render: render.RenderFixture; render: render.RenderFixture;
mockApiResponse: mockApiResponse.MockApiResponseFixture; mockApiResponse: mockApiResponse.MockApiResponseFixture;
mockAssetResponse: mockAssetResponse.MockAssetResponseFixture; mockAssetResponse: mockAssetResponse.MockAssetResponseFixture;
...@@ -27,6 +28,8 @@ interface Fixtures { ...@@ -27,6 +28,8 @@ interface Fixtures {
mockTextAd: mockTextAd.MockTextAdFixture; mockTextAd: mockTextAd.MockTextAdFixture;
} }
export type TestFnArgs = Fixtures & { page: Page };
const test = base.extend<Fixtures>({ const test = base.extend<Fixtures>({
render: render.default, render: render.default,
mockApiResponse: mockApiResponse.default, mockApiResponse: mockApiResponse.default,
......
...@@ -51,24 +51,28 @@ export const STATS_CHARTS_SECTION: stats.LineChartSection = { ...@@ -51,24 +51,28 @@ export const STATS_CHARTS_SECTION: stats.LineChartSection = {
title: 'Average transaction fee', title: 'Average transaction fee',
description: 'The average amount in ETH spent per transaction', description: 'The average amount in ETH spent per transaction',
units: 'ETH', units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'chart_1', id: 'chart_1',
title: 'Transactions fees', title: 'Transactions fees',
description: 'Amount of tokens paid as fees', description: 'Amount of tokens paid as fees',
units: 'ETH', units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'chart_2', id: 'chart_2',
title: 'New transactions', title: 'New transactions',
description: 'New transactions number', description: 'New transactions number',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
{ {
id: 'chart_3', id: 'chart_3',
title: 'Transactions growth', title: 'Transactions growth',
description: 'Cumulative transactions number', description: 'Cumulative transactions number',
units: undefined, units: undefined,
resolutions: [ 'DAY', 'MONTH' ],
}, },
], ],
}; };
......
import type { Validator, ValidatorsCountersResponse } from 'types/api/validators'; import type {
ValidatorStability,
ValidatorsStabilityCountersResponse,
ValidatorBlackfort,
ValidatorsBlackfortCountersResponse,
} from 'types/api/validators';
import { ADDRESS_PARAMS } from './addressParams'; import { ADDRESS_PARAMS } from './addressParams';
export const VALIDATOR: Validator = { export const VALIDATOR_STABILITY: ValidatorStability = {
address: ADDRESS_PARAMS, address: ADDRESS_PARAMS,
blocks_validated_count: 25987, blocks_validated_count: 25987,
state: 'active', state: 'active',
}; };
export const VALIDATORS_COUNTERS: ValidatorsCountersResponse = { export const VALIDATORS_STABILITY_COUNTERS: ValidatorsStabilityCountersResponse = {
active_validators_counter: '42', active_validators_counter: '42',
active_validators_percentage: 7.14, active_validators_percentage: 7.14,
new_validators_counter_24h: '11', new_validators_counter_24h: '11',
validators_counter: '140', validators_counter: '140',
}; };
export const VALIDATOR_BLACKFORT: ValidatorBlackfort = {
address: ADDRESS_PARAMS,
name: 'testnet-1',
commission: 10,
delegated_amount: '0',
self_bonded_amount: '10000',
};
export const VALIDATORS_BLACKFORT_COUNTERS: ValidatorsBlackfortCountersResponse = {
new_validators_counter_24h: '11',
validators_counter: '140',
};
...@@ -47,6 +47,16 @@ const sizes = { ...@@ -47,6 +47,16 @@ const sizes = {
lineHeight: 5, lineHeight: 5,
}, },
}), }),
md: definePartsStyle({
container: {
minH: 8,
minW: 8,
fontSize: 'sm',
px: '6px',
py: '6px',
lineHeight: 5,
},
}),
}; };
const baseStyleContainer = defineStyle({ const baseStyleContainer = defineStyle({
......
...@@ -5,6 +5,7 @@ import path from 'path'; ...@@ -5,6 +5,7 @@ import path from 'path';
const PRESETS = { const PRESETS = {
arbitrum: 'https://arbitrum.blockscout.com', arbitrum: 'https://arbitrum.blockscout.com',
base: 'https://base.blockscout.com', base: 'https://base.blockscout.com',
blackfort_testnet: 'https://blackfort-testnet.blockscout.com',
celo_alfajores: 'https://celo-alfajores.blockscout.com', celo_alfajores: 'https://celo-alfajores.blockscout.com',
eth: 'https://eth.blockscout.com', eth: 'https://eth.blockscout.com',
eth_goerli: 'https://eth-goerli.blockscout.com', eth_goerli: 'https://eth-goerli.blockscout.com',
...@@ -18,6 +19,7 @@ const PRESETS = { ...@@ -18,6 +19,7 @@ const PRESETS = {
stability_testnet: 'https://stability-testnet.blockscout.com', stability_testnet: 'https://stability-testnet.blockscout.com',
zkevm: 'https://zkevm.blockscout.com', zkevm: 'https://zkevm.blockscout.com',
zksync: 'https://zksync.blockscout.com', zksync: 'https://zksync.blockscout.com',
zora: 'https://explorer.zora.energy',
// main === staging // main === staging
main: 'https://eth-sepolia.k8s-dev.blockscout.com', main: 'https://eth-sepolia.k8s-dev.blockscout.com',
}; };
......
import type { Block } from './block'; import type { Block } from './block';
import type { Transaction } from './transaction'; import type { Transaction } from './transaction';
export interface ArbitrumLatestDepositsItem {
completion_transaction_hash: string;
origination_timestamp: string | null;
origination_transaction_block_number: number | null;
origination_transaction_hash: string | null;
}
export interface ArbitrumLatestDepositsResponse {
items: Array<ArbitrumLatestDepositsItem>;
}
export type ArbitrumL2MessagesItem = { export type ArbitrumL2MessagesItem = {
completion_transaction_hash: string | null; completion_transaction_hash: string | null;
id: number; id: number;
origination_address: string; origination_address: string;
origination_timestamp: string | null; origination_timestamp: string | null;
origination_transaction_block_number: number; origination_transaction_block_number: number | null;
origination_transaction_hash: string; origination_transaction_hash: string;
status: 'initiated' | 'sent' | 'confirmed' | 'relayed'; status: 'initiated' | 'sent' | 'confirmed' | 'relayed';
} }
......
import type { AddressParam } from './addressParams'; import type { AddressParam } from './addressParams';
export interface Validator { export interface ValidatorStability {
address: AddressParam; address: AddressParam;
blocks_validated_count: number; blocks_validated_count: number;
state: 'active' | 'probation' | 'inactive'; state: 'active' | 'probation' | 'inactive';
} }
export interface ValidatorsResponse { export interface ValidatorsStabilityResponse {
items: Array<Validator>; items: Array<ValidatorStability>;
next_page_params: { next_page_params: {
'address_hash': string; 'address_hash': string;
'blocks_validated': string; 'blocks_validated': string;
'items_count': string; 'items_count': string;
'state': Validator['state']; 'state': ValidatorStability['state'];
} | null; } | null;
} }
export interface ValidatorsCountersResponse { export interface ValidatorsStabilityCountersResponse {
active_validators_counter: string; active_validators_counter: string;
active_validators_percentage: number; active_validators_percentage: number;
new_validators_counter_24h: string; new_validators_counter_24h: string;
validators_counter: string; validators_counter: string;
} }
export interface ValidatorsFilters { export interface ValidatorsStabilityFilters {
// address_hash: string | undefined; // right now API doesn't support filtering by address_hash // address_hash: string | undefined; // right now API doesn't support filtering by address_hash
state_filter: Validator['state'] | undefined; state_filter: ValidatorStability['state'] | undefined;
} }
export interface ValidatorsSorting { export interface ValidatorsStabilitySorting {
sort: 'state' | 'blocks_validated'; sort: 'state' | 'blocks_validated';
order: 'asc' | 'desc'; order: 'asc' | 'desc';
} }
export type ValidatorsSortingField = ValidatorsSorting['sort']; export type ValidatorsStabilitySortingField = ValidatorsStabilitySorting['sort'];
export type ValidatorsSortingValue = `${ ValidatorsSortingField }-${ ValidatorsSorting['order'] }`; export type ValidatorsStabilitySortingValue = `${ ValidatorsStabilitySortingField }-${ ValidatorsStabilitySorting['order'] }`;
export interface ValidatorBlackfort {
address: AddressParam;
name: string;
commission: number;
delegated_amount: string;
self_bonded_amount: string;
}
export interface ValidatorsBlackfortResponse {
items: Array<ValidatorBlackfort>;
next_page_params: {
'address_hash': string;
} | null;
}
export interface ValidatorsBlackfortCountersResponse {
new_validators_counter_24h: string;
validators_counter: string;
}
export interface ValidatorsBlackfortSorting {
sort: 'address_hash';
order: 'asc' | 'desc';
}
export type ValidatorsBlackfortSortingField = ValidatorsBlackfortSorting['sort'];
export type ValidatorsBlackfortSortingValue = `${ ValidatorsBlackfortSortingField }-${ ValidatorsBlackfortSorting['order'] }`;
export type AddressProfileAPIConfig = {
api_url_template: string;
tag_link_template?: string;
tag_icon?: string;
tag_bg_color?: string;
tag_text_color?: string;
};
...@@ -2,6 +2,7 @@ import type { ArrayElement } from 'types/utils'; ...@@ -2,6 +2,7 @@ import type { ArrayElement } from 'types/utils';
export const VALIDATORS_CHAIN_TYPE = [ export const VALIDATORS_CHAIN_TYPE = [
'stability', 'stability',
'blackfort',
] as const; ] as const;
export type ValidatorsChainType = ArrayElement<typeof VALIDATORS_CHAIN_TYPE>; export type ValidatorsChainType = ArrayElement<typeof VALIDATORS_CHAIN_TYPE>;
import React from 'react'; import React from 'react';
import * as balanceHistoryMock from 'mocks/address/coinBalanceHistory'; import * as balanceHistoryMock from 'mocks/address/coinBalanceHistory';
import { test, expect } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
import AddressCoinBalance from './AddressCoinBalance'; import AddressCoinBalance from './AddressCoinBalance';
...@@ -12,7 +12,7 @@ const hooksConfig = { ...@@ -12,7 +12,7 @@ const hooksConfig = {
}, },
}; };
test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse }) => { test('base view +@dark-mode', async({ render, page, mockApiResponse }) => {
await mockApiResponse('address_coin_balance', balanceHistoryMock.baseResponse, { pathParams: { hash: addressHash } }); await mockApiResponse('address_coin_balance', balanceHistoryMock.baseResponse, { pathParams: { hash: addressHash } });
await mockApiResponse('address_coin_balance_chart', balanceHistoryMock.chartResponse, { pathParams: { hash: addressHash } }); await mockApiResponse('address_coin_balance_chart', balanceHistoryMock.chartResponse, { pathParams: { hash: addressHash } });
const component = await render(<AddressCoinBalance/>, { hooksConfig }); const component = await render(<AddressCoinBalance/>, { hooksConfig });
...@@ -23,3 +23,19 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse }) ...@@ -23,3 +23,19 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse })
await page.mouse.move(240, 100); await page.mouse.move(240, 100);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ render, page, mockApiResponse }) => {
await mockApiResponse('address_coin_balance', balanceHistoryMock.baseResponse, { pathParams: { hash: addressHash } });
await mockApiResponse('address_coin_balance_chart', balanceHistoryMock.chartResponse, { pathParams: { hash: addressHash } });
const component = await render(<AddressCoinBalance/>, { hooksConfig });
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1';
});
await page.mouse.move(100, 100);
await page.mouse.move(240, 100);
await expect(component).toHaveScreenshot();
});
});
...@@ -42,6 +42,7 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, isOp ...@@ -42,6 +42,7 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, isOp
basePath={ `${ basePath }:${ index }` } basePath={ `${ basePath }:${ index }` }
level={ level + 1 } level={ level + 1 }
isDisabled={ isDisabled } isDisabled={ isDisabled }
isOptional={ isOptional }
/> />
); );
} }
......
...@@ -74,4 +74,27 @@ describe('transformFormDataToMethodArgs', () => { ...@@ -74,4 +74,27 @@ describe('transformFormDataToMethodArgs', () => {
], ],
]); ]);
}); });
it('should transform all nested empty arrays to empty arrays', () => {
const formData = {
'0': '0x1D415D28380ff51A507F7B176ca5F27833F7FffD',
'1': '0x1D415D28380ff51A507F7B176ca5F27833F7FffD',
'2': '3160',
'3': true,
// tuple array without elements
'4:0:0:0': undefined,
'4:0:1:0': undefined,
'4:0:1:1': undefined,
'4:0:1:2': undefined,
'4:0:1:3': undefined,
};
const result = transformFormDataToMethodArgs(formData);
expect(result).toEqual([
'0x1D415D28380ff51A507F7B176ca5F27833F7FffD',
'0x1D415D28380ff51A507F7B176ca5F27833F7FffD',
'3160',
true,
[],
]);
});
}); });
...@@ -81,7 +81,9 @@ export function transformFormDataToMethodArgs(formData: ContractMethodFormFields ...@@ -81,7 +81,9 @@ export function transformFormDataToMethodArgs(formData: ContractMethodFormFields
_set(result, field.replaceAll(':', '.'), value); _set(result, field.replaceAll(':', '.'), value);
} }
return filterOutEmptyItems(result); const filteredResult = filterOutEmptyItems(result);
const mappedResult = mapEmptyNestedArrays(filteredResult);
return mappedResult;
} }
function filterOutEmptyItems(array: Array<unknown>): Array<unknown> { function filterOutEmptyItems(array: Array<unknown>): Array<unknown> {
...@@ -90,11 +92,26 @@ function filterOutEmptyItems(array: Array<unknown>): Array<unknown> { ...@@ -90,11 +92,26 @@ function filterOutEmptyItems(array: Array<unknown>): Array<unknown> {
// The only optional field is the native coin value, which is safely handled in the form submit handler. // The only optional field is the native coin value, which is safely handled in the form submit handler.
// 2. When the user adds and removes items from a field array. // 2. When the user adds and removes items from a field array.
// In this scenario, empty items need to be filtered out to maintain the correct sequence of arguments. // In this scenario, empty items need to be filtered out to maintain the correct sequence of arguments.
// We don't use isEmptyField() function here because of the second case otherwise it will not keep the correct order of arguments.
return array return array
.map((item) => Array.isArray(item) ? filterOutEmptyItems(item) : item) .map((item) => Array.isArray(item) ? filterOutEmptyItems(item) : item)
.filter((item) => item !== undefined); .filter((item) => item !== undefined);
} }
function isEmptyField(field: unknown): boolean {
// the empty string is meant that the field was touched but left empty
// the undefined is meant that the field was not touched
return field === undefined || field === '';
}
function isEmptyNestedArray(array: Array<unknown>): boolean {
return array.flat(Infinity).filter((item) => !isEmptyField(item)).length === 0;
}
function mapEmptyNestedArrays(array: Array<unknown>): Array<unknown> {
return array.map((item) => Array.isArray(item) && isEmptyNestedArray(item) ? [] : item);
}
export function getFieldLabel(input: ContractAbiItemInput, isRequired?: boolean) { export function getFieldLabel(input: ContractAbiItemInput, isRequired?: boolean) {
const name = input.name || input.internalType || '<unnamed argument>'; const name = input.name || input.internalType || '<unnamed argument>';
return `${ name } (${ input.type })${ isRequired ? '*' : '' }`; return `${ name } (${ input.type })${ isRequired ? '*' : '' }`;
......
...@@ -28,11 +28,14 @@ export default function useCallMethodPublicClient(): (params: Params) => Promise ...@@ -28,11 +28,14 @@ export default function useCallMethodPublicClient(): (params: Params) => Promise
} }
const address = getAddress(addressHash); const address = getAddress(addressHash);
// for write payable methods we add additional input for native coin value
// so in simulate mode we need to strip it off
const _args = args.slice(0, item.inputs.length);
const params = { const params = {
abi: [ item ], abi: [ item ],
functionName: item.name, functionName: item.name,
args: args, args: _args,
address, address,
account, account,
}; };
......
...@@ -61,7 +61,6 @@ test.describe('mobile', () => { ...@@ -61,7 +61,6 @@ test.describe('mobile', () => {
); );
await page.getByRole('button', { name: /select/i }).click(); await page.getByRole('button', { name: /select/i }).click();
await page.getByText('USD Coin').hover();
await expect(page).toHaveScreenshot(); await expect(page).toHaveScreenshot();
}); });
......
import React from 'react';
import * as depositMock from 'mocks/arbitrum/deposits';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import LatestArbitrumDeposits from './LatestArbitrumDeposits';
test('default view +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.arbitrumRollup);
mockApiResponse('homepage_arbitrum_deposits', depositMock.latestDepositsResponse);
const component = await render(<LatestArbitrumDeposits/>);
await expect(component).toHaveScreenshot();
});
...@@ -2,6 +2,7 @@ import { ...@@ -2,6 +2,7 @@ import {
Box, Box,
Flex, Flex,
Grid, Grid,
GridItem,
Skeleton, Skeleton,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
...@@ -17,9 +18,9 @@ import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice'; ...@@ -17,9 +18,9 @@ import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type DepositsItem = { type DepositsItem = {
l1BlockNumber: number; l1BlockNumber: number | null;
l1TxHash: string; l1TxHash: string | null;
l2TxHash: string | null; l2TxHash: string;
timestamp: string | null; timestamp: string | null;
} }
...@@ -38,7 +39,7 @@ type ItemProps = { ...@@ -38,7 +39,7 @@ type ItemProps = {
const LatestDepositsItem = ({ item, isLoading }: ItemProps) => { const LatestDepositsItem = ({ item, isLoading }: ItemProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const l1BlockLink = ( const l1BlockLink = item.l1BlockNumber ? (
<BlockEntityL1 <BlockEntityL1
number={ item.l1BlockNumber } number={ item.l1BlockNumber }
isLoading={ isLoading } isLoading={ isLoading }
...@@ -46,9 +47,18 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => { ...@@ -46,9 +47,18 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => {
lineHeight={ 5 } lineHeight={ 5 }
fontWeight={ 700 } fontWeight={ 700 }
/> />
) : (
<BlockEntityL1
number="TBD"
isLoading={ isLoading }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
noLink
/>
); );
const l1TxLink = ( const l1TxLink = item.l1TxHash ? (
<TxEntityL1 <TxEntityL1
isLoading={ isLoading } isLoading={ isLoading }
hash={ item.l1TxHash } hash={ item.l1TxHash }
...@@ -56,9 +66,18 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => { ...@@ -56,9 +66,18 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => {
lineHeight={ 5 } lineHeight={ 5 }
truncation={ isMobile ? 'constant_long' : 'dynamic' } truncation={ isMobile ? 'constant_long' : 'dynamic' }
/> />
) : (
<TxEntityL1
isLoading={ isLoading }
hash="To be determined"
fontSize="sm"
lineHeight={ 5 }
truncation="none"
noLink
/>
); );
const l2TxLink = item.l2TxHash ? ( const l2TxLink = (
<TxEntity <TxEntity
isLoading={ isLoading } isLoading={ isLoading }
hash={ item.l2TxHash } hash={ item.l2TxHash }
...@@ -66,7 +85,7 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => { ...@@ -66,7 +85,7 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => {
lineHeight={ 5 } lineHeight={ 5 }
truncation={ isMobile ? 'constant_long' : 'dynamic' } truncation={ isMobile ? 'constant_long' : 'dynamic' }
/> />
) : null; );
const content = (() => { const content = (() => {
if (isMobile) { if (isMobile) {
...@@ -74,11 +93,13 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => { ...@@ -74,11 +93,13 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => {
<> <>
<Flex justifyContent="space-between" alignItems="center" mb={ 1 }> <Flex justifyContent="space-between" alignItems="center" mb={ 1 }>
{ l1BlockLink } { l1BlockLink }
<TimeAgoWithTooltip { item.timestamp ? (
timestamp={ item.timestamp } <TimeAgoWithTooltip
isLoading={ isLoading } timestamp={ item.timestamp }
color="text_secondary" isLoading={ isLoading }
/> color="text_secondary"
/>
) : <GridItem/> }
</Flex> </Flex>
<Grid gridTemplateColumns="56px auto"> <Grid gridTemplateColumns="56px auto">
<Skeleton isLoaded={ !isLoading } my="5px" w="fit-content"> <Skeleton isLoaded={ !isLoading } my="5px" w="fit-content">
...@@ -101,14 +122,16 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => { ...@@ -101,14 +122,16 @@ const LatestDepositsItem = ({ item, isLoading }: ItemProps) => {
L1 txn L1 txn
</Skeleton> </Skeleton>
{ l1TxLink } { l1TxLink }
<TimeAgoWithTooltip { item.timestamp ? (
timestamp={ item.timestamp } <TimeAgoWithTooltip
isLoading={ isLoading } timestamp={ item.timestamp }
color="text_secondary" isLoading={ isLoading }
w="fit-content" color="text_secondary"
h="fit-content" w="fit-content"
my="2px" h="fit-content"
/> my="2px"
/>
) : <GridItem/> }
<Skeleton isLoaded={ !isLoading } w="fit-content" h="fit-content" my="2px"> <Skeleton isLoaded={ !isLoading } w="fit-content" h="fit-content" my="2px">
L2 txn L2 txn
</Skeleton> </Skeleton>
......
...@@ -4,6 +4,7 @@ import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace' ...@@ -4,6 +4,7 @@ import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace'
import { apps as appsMock } from 'mocks/apps/apps'; import { apps as appsMock } from 'mocks/apps/apps';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports'; import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import type { TestFnArgs } from 'playwright/lib';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
import MarketplaceAppModal from './MarketplaceAppModal'; import MarketplaceAppModal from './MarketplaceAppModal';
...@@ -28,7 +29,7 @@ const props = { ...@@ -28,7 +29,7 @@ const props = {
canRate: undefined, canRate: undefined,
}; };
const testFn: Parameters<typeof test>[1] = async({ render, page, mockAssetResponse, mockEnvs }) => { const testFn = async({ render, page, mockAssetResponse, mockEnvs }: TestFnArgs) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ], [ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ], [ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ],
......
...@@ -33,13 +33,15 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => { ...@@ -33,13 +33,15 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => {
<> <>
<ListItemMobileGrid.Label isLoading={ isLoading }>L1 block</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>L1 block</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<BlockEntityL1 { item.origination_transaction_block_number ? (
number={ item.origination_transaction_block_number } <BlockEntityL1
isLoading={ isLoading } number={ item.origination_transaction_block_number }
fontSize="sm" isLoading={ isLoading }
lineHeight={ 5 } fontSize="sm"
fontWeight={ 600 } lineHeight={ 5 }
/> fontWeight={ 600 }
/>
) : <chakra.span>N/A</chakra.span> }
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</> </>
) } ) }
...@@ -84,14 +86,18 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => { ...@@ -84,14 +86,18 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => {
) } ) }
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> { item.origination_timestamp && (
<ListItemMobileGrid.Value> <>
<TimeAgoWithTooltip <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
timestamp={ item.origination_timestamp } <ListItemMobileGrid.Value>
isLoading={ isLoading } <TimeAgoWithTooltip
display="inline-block" timestamp={ item.origination_timestamp }
/> isLoading={ isLoading }
</ListItemMobileGrid.Value> display="inline-block"
/>
</ListItemMobileGrid.Value>
</>
) }
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
......
...@@ -29,14 +29,16 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => { ...@@ -29,14 +29,16 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => {
<Tr> <Tr>
{ direction === 'to-rollup' && ( { direction === 'to-rollup' && (
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<BlockEntityL1 { item.origination_transaction_block_number ? (
number={ item.origination_transaction_block_number } <BlockEntityL1
isLoading={ isLoading } number={ item.origination_transaction_block_number }
fontSize="sm" isLoading={ isLoading }
lineHeight={ 5 } fontSize="sm"
fontWeight={ 600 } lineHeight={ 5 }
noIcon fontWeight={ 600 }
/> noIcon
/>
) : <chakra.span color="text_secondary">N/A</chakra.span> }
</Td> </Td>
) } ) }
{ direction === 'from-rollup' && ( { direction === 'from-rollup' && (
......
...@@ -10,6 +10,7 @@ import getCheckedSummedAddress from 'lib/address/getCheckedSummedAddress'; ...@@ -10,6 +10,7 @@ import getCheckedSummedAddress from 'lib/address/getCheckedSummedAddress';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery'; import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery';
import useContractTabs from 'lib/hooks/useContractTabs'; import useContractTabs from 'lib/hooks/useContractTabs';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText';
...@@ -54,6 +55,7 @@ import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; ...@@ -54,6 +55,7 @@ import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
const TOKEN_TABS = [ 'tokens_erc20', 'tokens_nfts', 'tokens_nfts_collection', 'tokens_nfts_list' ]; const TOKEN_TABS = [ 'tokens_erc20', 'tokens_nfts', 'tokens_nfts_collection', 'tokens_nfts_list' ];
const txInterpretation = config.features.txInterpretation; const txInterpretation = config.features.txInterpretation;
const addressProfileAPIFeature = config.features.addressProfileAPI;
const AddressPageContent = () => { const AddressPageContent = () => {
const router = useRouter(); const router = useRouter();
...@@ -92,6 +94,7 @@ const AddressPageContent = () => { ...@@ -92,6 +94,7 @@ const AddressPageContent = () => {
const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]); const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery, areQueriesEnabled); const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery, areQueriesEnabled);
const userPropfileApiQuery = useAddressProfileApiQuery(hash, addressProfileAPIFeature.isEnabled && areQueriesEnabled);
const addressEnsDomainsQuery = useApiQuery('addresses_lookup', { const addressEnsDomainsQuery = useApiQuery('addresses_lookup', {
pathParams: { chainId: config.chain.id }, pathParams: { chainId: config.chain.id },
...@@ -248,6 +251,8 @@ const AddressPageContent = () => { ...@@ -248,6 +251,8 @@ const AddressPageContent = () => {
mudTablesCountQuery.data, mudTablesCountQuery.data,
]); ]);
const usernameApiTag = userPropfileApiQuery.data?.user_profile?.username;
const tags: Array<EntityTag> = React.useMemo(() => { const tags: Array<EntityTag> = React.useMemo(() => {
return [ return [
...(addressQuery.data?.public_tags?.map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'custom' as const, ordinal: -1 })) || []), ...(addressQuery.data?.public_tags?.map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'custom' as const, ordinal: -1 })) || []),
...@@ -258,6 +263,18 @@ const AddressPageContent = () => { ...@@ -258,6 +263,18 @@ const AddressPageContent = () => {
addressQuery.data?.implementations?.length ? { slug: 'proxy', name: 'Proxy', tagType: 'custom' as const, ordinal: -1 } : undefined, addressQuery.data?.implementations?.length ? { slug: 'proxy', name: 'Proxy', tagType: 'custom' as const, ordinal: -1 } : undefined,
addressQuery.data?.token ? { slug: 'token', name: 'Token', tagType: 'custom' as const, ordinal: -1 } : undefined, addressQuery.data?.token ? { slug: 'token', name: 'Token', tagType: 'custom' as const, ordinal: -1 } : undefined,
isSafeAddress ? { slug: 'safe', name: 'Multisig: Safe', tagType: 'custom' as const, ordinal: -10 } : undefined, isSafeAddress ? { slug: 'safe', name: 'Multisig: Safe', tagType: 'custom' as const, ordinal: -10 } : undefined,
addressProfileAPIFeature.isEnabled && usernameApiTag ? {
slug: 'username_api',
name: usernameApiTag,
tagType: 'custom' as const,
ordinal: 11,
meta: {
tagIcon: addressProfileAPIFeature.tagIcon,
bgColor: addressProfileAPIFeature.tagBgColor,
textColor: addressProfileAPIFeature.tagTextColor,
tagUrl: addressProfileAPIFeature.tagLinkTemplate ? addressProfileAPIFeature.tagLinkTemplate.replace('{username}', usernameApiTag) : undefined,
},
} : undefined,
config.features.userOps.isEnabled && userOpsAccountQuery.data ? config.features.userOps.isEnabled && userOpsAccountQuery.data ?
{ slug: 'user_ops_acc', name: 'Smart contract wallet', tagType: 'custom' as const, ordinal: -10 } : { slug: 'user_ops_acc', name: 'Smart contract wallet', tagType: 'custom' as const, ordinal: -10 } :
undefined, undefined,
...@@ -267,7 +284,7 @@ const AddressPageContent = () => { ...@@ -267,7 +284,7 @@ const AddressPageContent = () => {
...formatUserTags(addressQuery.data), ...formatUserTags(addressQuery.data),
...(addressMetadataQuery.data?.addresses?.[hash.toLowerCase()]?.tags || []), ...(addressMetadataQuery.data?.addresses?.[hash.toLowerCase()]?.tags || []),
].filter(Boolean).sort(sortEntityTags); ].filter(Boolean).sort(sortEntityTags);
}, [ addressMetadataQuery.data, addressQuery.data, hash, isSafeAddress, userOpsAccountQuery.data, mudTablesCountQuery.data ]); }, [ addressMetadataQuery.data, addressQuery.data, hash, isSafeAddress, userOpsAccountQuery.data, mudTablesCountQuery.data, usernameApiTag ]);
const titleContentAfter = ( const titleContentAfter = (
<EntityTags <EntityTags
...@@ -275,7 +292,8 @@ const AddressPageContent = () => { ...@@ -275,7 +292,8 @@ const AddressPageContent = () => {
isLoading={ isLoading={
isLoading || isLoading ||
(config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData) || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData) ||
(config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) ||
(addressProfileAPIFeature.isEnabled && userPropfileApiQuery.isPending)
} }
/> />
); );
......
import React from 'react';
import * as statsLineMock from 'mocks/stats/line';
import { test, expect } from 'playwright/lib';
import formatDate from 'ui/shared/chart/utils/formatIntervalDate';
import Chart from './Chart';
const CHART_ID = 'averageGasPrice';
test.beforeEach(async({ mockTextAd }) => {
await mockTextAd();
});
const hooksConfig = {
router: {
query: { id: CHART_ID },
},
};
test('base view +@dark-mode +@mobile', async({ render, mockApiResponse, page }) => {
const date = new Date();
date.setMonth(date.getMonth() - 1);
const chartApiUrl = await mockApiResponse(
'stats_line',
statsLineMock.averageGasPrice,
{
pathParams: { id: CHART_ID },
queryParams: {
from: formatDate(date),
to: '2022-11-11',
resolution: 'DAY',
},
},
);
const component = await render(<Chart/>, { hooksConfig });
await page.waitForResponse(chartApiUrl);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Charttitle-fullscreen"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
import { Button, Flex, Link, Text } from '@chakra-ui/react';
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { StatsIntervalIds } from 'types/client/stats';
import { StatsIntervalId } from 'types/client/stats';
import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile';
import isBrowser from 'lib/isBrowser';
import * as metadata from 'lib/metadata';
import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import ChartIntervalSelect from 'ui/shared/chart/ChartIntervalSelect';
import ChartMenu from 'ui/shared/chart/ChartMenu';
import ChartResolutionSelect from 'ui/shared/chart/ChartResolutionSelect';
import ChartWidgetContent from 'ui/shared/chart/ChartWidgetContent';
import useChartQuery from 'ui/shared/chart/useChartQuery';
import useZoom from 'ui/shared/chart/useZoom';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import IconSvg from 'ui/shared/IconSvg';
import PageTitle from 'ui/shared/Page/PageTitle';
const DEFAULT_RESOLUTION = Resolution.DAY;
const getIntervalByResolution = (resolution: Resolution): StatsIntervalIds => {
switch (resolution) {
case 'DAY':
return 'oneMonth';
case 'WEEK':
return 'oneMonth';
case 'MONTH':
return 'oneYear';
case 'YEAR':
return 'all';
default:
return 'oneMonth';
}
};
const getIntervalFromQuery = (router: NextRouter): StatsIntervalIds | undefined => {
const intervalFromQuery = getQueryParamString(router.query.interval);
if (!intervalFromQuery || !Object.values(StatsIntervalId).includes(intervalFromQuery as StatsIntervalIds)) {
return undefined;
}
return intervalFromQuery as StatsIntervalIds;
};
const getResolutionFromQuery = (router: NextRouter) => {
const resolutionFromQuery = getQueryParamString(router.query.resolution);
if (!resolutionFromQuery || !Resolution[resolutionFromQuery as keyof typeof Resolution]) {
return DEFAULT_RESOLUTION;
}
return resolutionFromQuery as Resolution;
};
const Chart = () => {
const router = useRouter();
const id = getQueryParamString(router.query.id);
const intervalFromQuery = getIntervalFromQuery(router);
const resolutionFromQuery = getResolutionFromQuery(router);
const [ intervalState, setIntervalState ] = React.useState<StatsIntervalIds | undefined>(intervalFromQuery);
const [ resolution, setResolution ] = React.useState<Resolution>(resolutionFromQuery || DEFAULT_RESOLUTION);
const { zoomRange, handleZoom, handleZoomReset } = useZoom();
const interval = intervalState || getIntervalByResolution(resolution);
const ref = React.useRef(null);
const isMobile = useIsMobile();
const isInBrowser = isBrowser();
const appProps = useAppContext();
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/stats');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to charts list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const onIntervalChange = React.useCallback((interval: StatsIntervalIds) => {
setIntervalState(interval);
router.push(
{
pathname: router.pathname,
query: { ...router.query, interval },
},
undefined,
{ shallow: true },
);
}, [ setIntervalState, router ]);
const onResolutionChange = React.useCallback((resolution: Resolution) => {
setResolution(resolution);
router.push({
pathname: router.pathname,
query: { ...router.query, resolution },
});
}, [ setResolution, router ]);
const handleReset = React.useCallback(() => {
handleZoomReset();
onResolutionChange(DEFAULT_RESOLUTION);
}, [ handleZoomReset, onResolutionChange ]);
const { items, info, lineQuery } = useChartQuery(id, resolution, interval);
React.useEffect(() => {
if (info && !config.meta.seo.enhancedDataEnabled) {
metadata.update({ pathname: '/stats/[id]', query: { id } }, info);
}
}, [ info, id ]);
const onShare = React.useCallback(async() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Share chart', Info: id });
try {
await window.navigator.share({
title: info?.title,
text: info?.description,
url: window.location.href,
});
} catch (error) {}
}, [ info, id ]);
if (lineQuery.isError) {
if (isCustomAppError(lineQuery.error)) {
throwOnResourceLoadError({ resource: 'stats_line', error: lineQuery.error, isError: true });
}
}
const hasItems = (items && items.length > 2) || lineQuery.isPending;
const isInfoLoading = !info && lineQuery.isPlaceholderData;
const shareButton = (
<Button
leftIcon={ <IconSvg name="share" w={ 4 } h={ 4 }/> }
colorScheme="blue"
size="sm"
variant="outline"
onClick={ onShare }
ml={ 6 }
>
Share
</Button>
);
return (
<>
<PageTitle
title={ info?.title || lineQuery.data?.info?.title || '' }
mb={ 3 }
isLoading={ isInfoLoading }
backLink={ backLink }
secondRow={ info?.description || lineQuery.data?.info?.description }
withTextAd
/>
<Flex alignItems="center" justifyContent="space-between">
<Flex alignItems="center" gap={{ base: 3, lg: 6 }} maxW="100%" overflow="hidden">
<Flex alignItems="center" gap={ 3 }>
{ !isMobile && <Text>Period</Text> }
<ChartIntervalSelect interval={ interval } onIntervalChange={ onIntervalChange }/>
</Flex>
{ lineQuery.data?.info?.resolutions && lineQuery.data?.info?.resolutions.length > 1 && (
<Flex alignItems="center" gap={ 3 }>
<Text>{ isMobile ? 'Res.' : 'Resolution' }</Text>
<ChartResolutionSelect
resolution={ resolution }
onResolutionChange={ onResolutionChange }
resolutions={ lineQuery.data?.info?.resolutions || [] }
/>
</Flex>
) }
{ (Boolean(zoomRange)) && (
<Link
onClick={ handleReset }
display="flex"
alignItems="center"
gap={ 2 }
>
<IconSvg name="repeat" w={ 5 } h={ 5 }/>
{ !isMobile && 'Reset' }
</Link>
) }
</Flex>
<Flex alignItems="center" gap={ 3 }>
{ /* TS thinks window.navigator.share can't be undefined, but it can */ }
{ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ }
{ !isMobile && (isInBrowser && ((window.navigator.share as any) ?
shareButton :
(
<CopyToClipboard
text={ config.app.baseUrl + router.asPath }
size={ 5 }
type="link"
variant="outline"
colorScheme="blue"
display="flex"
borderRadius="8px"
width={ 8 }
height={ 8 }
/>
)
)) }
{ (hasItems || lineQuery.isPlaceholderData) && (
<ChartMenu
items={ items }
title={ info?.title || '' }
isLoading={ lineQuery.isPlaceholderData }
chartRef={ ref }
resolution={ resolution }
zoomRange={ zoomRange }
handleZoom={ handleZoom }
handleZoomReset={ handleZoomReset }
chartUrl={ isMobile ? window.location.href : undefined }
/>
) }
</Flex>
</Flex>
<Flex
ref={ ref }
flexGrow={ 1 }
h="50vh"
mt={ 3 }
position="relative"
>
<ChartWidgetContent
isError={ lineQuery.isError }
items={ items }
title={ info?.title || '' }
units={ info?.units || undefined }
isEnlarged
isLoading={ lineQuery.isPlaceholderData }
zoomRange={ zoomRange }
handleZoom={ handleZoom }
emptyText="No data for the selected resolution & interval."
resolution={ resolution }
/>
</Flex>
</>
);
};
export default Chart;
...@@ -6,6 +6,7 @@ import config from 'configs/app'; ...@@ -6,6 +6,7 @@ import config from 'configs/app';
import { apps as appsMock } from 'mocks/apps/apps'; import { apps as appsMock } from 'mocks/apps/apps';
import { ratings as ratingsMock } from 'mocks/apps/ratings'; import { ratings as ratingsMock } from 'mocks/apps/ratings';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports'; import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import type { TestFnArgs } from 'playwright/lib';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
import MarketplaceApp from './MarketplaceApp'; import MarketplaceApp from './MarketplaceApp';
...@@ -20,7 +21,7 @@ const hooksConfig = { ...@@ -20,7 +21,7 @@ const hooksConfig = {
const MARKETPLACE_CONFIG_URL = 'http://localhost:4000/marketplace-config.json'; const MARKETPLACE_CONFIG_URL = 'http://localhost:4000/marketplace-config.json';
const MARKETPLACE_SECURITY_REPORTS_URL = 'http://localhost:4000/marketplace-security-reports.json'; const MARKETPLACE_SECURITY_REPORTS_URL = 'http://localhost:4000/marketplace-security-reports.json';
const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse, page }) => { const testFn = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse, page }: TestFnArgs) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ], [ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ], [ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ],
......
import React from 'react';
import * as validatorsMock from 'mocks/validators/blackfort';
import { test, expect } from 'playwright/lib';
import Validators from './ValidatorsBlackfort';
const chainType = 'blackfort';
test('base view +@mobile', async({ render, mockApiResponse, mockEnvs, mockTextAd }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE', chainType ],
]);
await mockApiResponse('validators_blackfort', validatorsMock.validatorsResponse);
await mockApiResponse('validators_blackfort_counters', validatorsMock.validatorsCountersResponse);
await mockTextAd();
const component = await render(<Validators/>);
await expect(component).toHaveScreenshot();
});
import { Box, Hide, HStack, Show } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type {
ValidatorsBlackfortSorting,
ValidatorsBlackfortSortingField,
ValidatorsBlackfortSortingValue,
} from 'types/api/validators';
import config from 'configs/app';
import { generateListStub } from 'stubs/utils';
import { VALIDATOR_BLACKFORT } from 'stubs/validators';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
import Sort from 'ui/shared/sort/Sort';
import { VALIDATORS_BLACKFORT_SORT_OPTIONS } from 'ui/validatorsBlackfort/utils';
import ValidatorsCounters from 'ui/validatorsBlackfort/ValidatorsCounters';
import ValidatorsList from 'ui/validatorsBlackfort/ValidatorsList';
import ValidatorsTable from 'ui/validatorsBlackfort/ValidatorsTable';
const ValidatorsBlackfort = () => {
const router = useRouter();
const [ sort, setSort ] =
React.useState<ValidatorsBlackfortSortingValue | undefined>(
getSortValueFromQuery<ValidatorsBlackfortSortingValue>(router.query, VALIDATORS_BLACKFORT_SORT_OPTIONS),
);
const { isError, isPlaceholderData, data, pagination, onSortingChange } = useQueryWithPages({
resourceName: 'validators_blackfort',
sorting: getSortParamsFromValue<ValidatorsBlackfortSortingValue, ValidatorsBlackfortSortingField, ValidatorsBlackfortSorting['order']>(sort),
options: {
enabled: config.features.validators.isEnabled,
placeholderData: generateListStub<'validators_blackfort'>(
VALIDATOR_BLACKFORT,
50,
{ next_page_params: null },
),
},
});
const handleSortChange = React.useCallback((value?: ValidatorsBlackfortSortingValue) => {
setSort(value);
onSortingChange(getSortParamsFromValue(value));
}, [ onSortingChange ]);
const sortButton = (
<Sort
name="validators_sorting"
defaultValue={ sort }
options={ VALIDATORS_BLACKFORT_SORT_OPTIONS }
onChange={ handleSortChange }
/>
);
const actionBar = (
<>
<HStack spacing={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ sortButton }
</HStack>
{ pagination.isVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) }
</>
);
const content = data?.items ? (
<>
<Show below="lg" ssr={ false }>
<ValidatorsList data={ data.items } isLoading={ isPlaceholderData }/>
</Show>
<Hide below="lg" ssr={ false }>
<ValidatorsTable
data={ data.items }
sort={ sort }
setSorting={ handleSortChange }
isLoading={ isPlaceholderData }
top={ pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 }
/>
</Hide>
</>
) : null;
return (
<Box>
<PageTitle title="Validators" withTextAd/>
<ValidatorsCounters/>
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no validators."
content={ content }
actionBar={ actionBar }
/>
</Box>
);
};
export default ValidatorsBlackfort;
import React from 'react'; import React from 'react';
import * as validatorsMock from 'mocks/validators/index'; import * as validatorsMock from 'mocks/validators/stability';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import Validators from './Validators'; import Validators from './ValidatorsStability';
const chainType = 'stability'; const chainType = 'stability';
...@@ -11,8 +11,8 @@ test('base view +@mobile', async({ render, mockApiResponse, mockEnvs, mockTextAd ...@@ -11,8 +11,8 @@ test('base view +@mobile', async({ render, mockApiResponse, mockEnvs, mockTextAd
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE', chainType ], [ 'NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE', chainType ],
]); ]);
await mockApiResponse('validators', validatorsMock.validatorsResponse, { pathParams: { chainType } }); await mockApiResponse('validators_stability', validatorsMock.validatorsResponse);
await mockApiResponse('validators_counters', validatorsMock.validatorsCountersResponse, { pathParams: { chainType } }); await mockApiResponse('validators_stability_counters', validatorsMock.validatorsCountersResponse);
await mockTextAd(); await mockTextAd();
const component = await render(<Validators/>); const component = await render(<Validators/>);
......
...@@ -2,8 +2,12 @@ import { Box, Hide, HStack, Show } from '@chakra-ui/react'; ...@@ -2,8 +2,12 @@ import { Box, Hide, HStack, Show } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { getFeaturePayload } from 'configs/app/features/types'; import type {
import type { ValidatorsFilters, ValidatorsSorting, ValidatorsSortingField, ValidatorsSortingValue } from 'types/api/validators'; ValidatorsStabilityFilters,
ValidatorsStabilitySorting,
ValidatorsStabilitySortingField,
ValidatorsStabilitySortingValue,
} from 'types/api/validators';
import config from 'configs/app'; import config from 'configs/app';
// import useDebounce from 'lib/hooks/useDebounce'; // import useDebounce from 'lib/hooks/useDebounce';
...@@ -11,7 +15,7 @@ import useIsMobile from 'lib/hooks/useIsMobile'; ...@@ -11,7 +15,7 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import { VALIDATOR } from 'stubs/validators'; import { VALIDATOR_STABILITY } from 'stubs/validators';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
// import FilterInput from 'ui/shared/filters/FilterInput'; // import FilterInput from 'ui/shared/filters/FilterInput';
...@@ -21,35 +25,36 @@ import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; ...@@ -21,35 +25,36 @@ import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue'; import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery'; import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
import Sort from 'ui/shared/sort/Sort'; import Sort from 'ui/shared/sort/Sort';
import { SORT_OPTIONS } from 'ui/validators/utils'; import { VALIDATORS_STABILITY_SORT_OPTIONS } from 'ui/validatorsStability/utils';
import ValidatorsCounters from 'ui/validators/ValidatorsCounters'; import ValidatorsCounters from 'ui/validatorsStability/ValidatorsCounters';
import ValidatorsFilter from 'ui/validators/ValidatorsFilter'; import ValidatorsFilter from 'ui/validatorsStability/ValidatorsFilter';
import ValidatorsList from 'ui/validators/ValidatorsList'; import ValidatorsList from 'ui/validatorsStability/ValidatorsList';
import ValidatorsTable from 'ui/validators/ValidatorsTable'; import ValidatorsTable from 'ui/validatorsStability/ValidatorsTable';
const Validators = () => { const ValidatorsStability = () => {
const router = useRouter(); const router = useRouter();
// const [ searchTerm, setSearchTerm ] = React.useState(getQueryParamString(router.query.address_hash) || undefined); // const [ searchTerm, setSearchTerm ] = React.useState(getQueryParamString(router.query.address_hash) || undefined);
const [ statusFilter, setStatusFilter ] = React.useState(getQueryParamString(router.query.state_filter) as ValidatorsFilters['state_filter'] || undefined); const [ statusFilter, setStatusFilter ] =
const [ sort, setSort ] = React.useState(getQueryParamString(router.query.state_filter) as ValidatorsStabilityFilters['state_filter'] || undefined);
React.useState<ValidatorsSortingValue | undefined>(getSortValueFromQuery<ValidatorsSortingValue>(router.query, SORT_OPTIONS)); const [ sort, setSort ] = React.useState<ValidatorsStabilitySortingValue | undefined>(
getSortValueFromQuery<ValidatorsStabilitySortingValue>(router.query, VALIDATORS_STABILITY_SORT_OPTIONS),
);
// const debouncedSearchTerm = useDebounce(searchTerm || '', 300); // const debouncedSearchTerm = useDebounce(searchTerm || '', 300);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { isError, isPlaceholderData, data, pagination, onFilterChange, onSortingChange } = useQueryWithPages({ const { isError, isPlaceholderData, data, pagination, onFilterChange, onSortingChange } = useQueryWithPages({
resourceName: 'validators', resourceName: 'validators_stability',
pathParams: { chainType: getFeaturePayload(config.features.validators)?.chainType },
filters: { filters: {
// address_hash: debouncedSearchTerm, // address_hash: debouncedSearchTerm,
state_filter: statusFilter, state_filter: statusFilter,
}, },
sorting: getSortParamsFromValue<ValidatorsSortingValue, ValidatorsSortingField, ValidatorsSorting['order']>(sort), sorting: getSortParamsFromValue<ValidatorsStabilitySortingValue, ValidatorsStabilitySortingField, ValidatorsStabilitySorting['order']>(sort),
options: { options: {
enabled: config.features.validators.isEnabled, enabled: config.features.validators.isEnabled,
placeholderData: generateListStub<'validators'>( placeholderData: generateListStub<'validators_stability'>(
VALIDATOR, VALIDATOR_STABILITY,
50, 50,
{ next_page_params: null }, { next_page_params: null },
), ),
...@@ -69,7 +74,7 @@ const Validators = () => { ...@@ -69,7 +74,7 @@ const Validators = () => {
return; return;
} }
const state = value === 'all' ? undefined : value as ValidatorsFilters['state_filter']; const state = value === 'all' ? undefined : value as ValidatorsStabilityFilters['state_filter'];
onFilterChange({ onFilterChange({
// address_hash: debouncedSearchTerm, // address_hash: debouncedSearchTerm,
...@@ -78,12 +83,13 @@ const Validators = () => { ...@@ -78,12 +83,13 @@ const Validators = () => {
setStatusFilter(state); setStatusFilter(state);
}, [ onFilterChange ]); }, [ onFilterChange ]);
const handleSortChange = React.useCallback((value?: ValidatorsSortingValue) => { const handleSortChange = React.useCallback((value?: ValidatorsStabilitySortingValue) => {
setSort(value); setSort(value);
onSortingChange(getSortParamsFromValue(value)); onSortingChange(getSortParamsFromValue(value));
}, [ onSortingChange ]); }, [ onSortingChange ]);
const filterMenu = <ValidatorsFilter onChange={ handleStateFilterChange } defaultValue={ statusFilter } hasActiveFilter={ Boolean(statusFilter) }/>; const filterMenu =
<ValidatorsFilter onChange={ handleStateFilterChange } defaultValue={ statusFilter } hasActiveFilter={ Boolean(statusFilter) }/>;
// const filterInput = ( // const filterInput = (
// <FilterInput // <FilterInput
...@@ -99,7 +105,7 @@ const Validators = () => { ...@@ -99,7 +105,7 @@ const Validators = () => {
<Sort <Sort
name="validators_sorting" name="validators_sorting"
defaultValue={ sort } defaultValue={ sort }
options={ SORT_OPTIONS } options={ VALIDATORS_STABILITY_SORT_OPTIONS }
onChange={ handleSortChange } onChange={ handleSortChange }
/> />
); );
...@@ -141,7 +147,7 @@ const Validators = () => { ...@@ -141,7 +147,7 @@ const Validators = () => {
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
items={ data?.items } items={ data?.items }
emptyText="There are no verified contracts." emptyText="There are no validators."
filterProps={{ filterProps={{
emptyFilteredText: `Couldn${ apos }t find any validator that matches your query.`, emptyFilteredText: `Couldn${ apos }t find any validator that matches your query.`,
hasActiveFilters: Boolean( hasActiveFilters: Boolean(
...@@ -156,4 +162,4 @@ const Validators = () => { ...@@ -156,4 +162,4 @@ const Validators = () => {
); );
}; };
export default Validators; export default ValidatorsStability;
...@@ -22,6 +22,7 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type, ...@@ -22,6 +22,7 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type,
// have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107 // have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const iconColor = useColorModeValue('gray.400', 'gray.500'); const iconColor = useColorModeValue('gray.400', 'gray.500');
const colorProps = colorScheme ? {} : { color: iconColor };
const iconName = icon || (type === 'link' ? 'link' : 'copy'); const iconName = icon || (type === 'link' ? 'link' : 'copy');
useEffect(() => { useEffect(() => {
...@@ -44,10 +45,10 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type, ...@@ -44,10 +45,10 @@ const CopyToClipboard = ({ text, className, isLoading, onClick, size = 5, type,
return ( return (
<Tooltip label={ copied ? 'Copied' : `Copy${ type === 'link' ? ' link ' : ' ' }to clipboard` } isOpen={ isOpen || copied }> <Tooltip label={ copied ? 'Copied' : `Copy${ type === 'link' ? ' link ' : ' ' }to clipboard` } isOpen={ isOpen || copied }>
<IconButton <IconButton
{ ...colorProps }
aria-label="copy" aria-label="copy"
icon={ <IconSvg name={ iconName } boxSize={ size }/> } icon={ <IconSvg name={ iconName } boxSize={ size }/> }
boxSize={ size } boxSize={ size }
color={ iconColor }
variant={ variant } variant={ variant }
colorScheme={ colorScheme } colorScheme={ colorScheme }
display="inline-block" display="inline-block"
......
...@@ -154,9 +154,9 @@ const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoa ...@@ -154,9 +154,9 @@ const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoa
{ withTextAd && <TextAd order={{ base: -1, lg: 100 }} mb={{ base: 6, lg: 0 }} ml="auto" w={{ base: '100%', lg: 'auto' }}/> } { withTextAd && <TextAd order={{ base: -1, lg: 100 }} mb={{ base: 6, lg: 0 }} ml="auto" w={{ base: '100%', lg: 'auto' }}/> }
</Flex> </Flex>
{ secondRow && ( { secondRow && (
<Flex alignItems="center" minH={ 10 } overflow="hidden" _empty={{ display: 'none' }}> <Skeleton isLoaded={ !isLoading } alignItems="center" minH={ 10 } overflow="hidden" display="flex" _empty={{ display: 'none' }}>
{ secondRow } { secondRow }
</Flex> </Skeleton>
) } ) }
</Flex> </Flex>
); );
......
import type { TagProps } from '@chakra-ui/react';
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { StatsInterval, StatsIntervalIds } from 'types/client/stats';
import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect';
import { STATS_INTERVALS } from 'ui/stats/constants';
import StatsDropdownMenu from 'ui/stats/StatsDropdownMenu';
const intervalList = Object.keys(STATS_INTERVALS).map((id: string) => ({
id: id,
title: STATS_INTERVALS[id as StatsIntervalIds].title,
})) as Array<StatsInterval>;
const intervalListShort = Object.keys(STATS_INTERVALS).map((id: string) => ({
id: id,
title: STATS_INTERVALS[id as StatsIntervalIds].shortTitle,
})) as Array<StatsInterval>;
type Props = {
interval: StatsIntervalIds;
onIntervalChange: (newInterval: StatsIntervalIds) => void;
isLoading?: boolean;
selectTagSize?: TagProps['size'];
}
const ChartIntervalSelect = ({ interval, onIntervalChange, isLoading, selectTagSize }: Props) => {
return (
<>
<Skeleton display={{ base: 'none', lg: 'flex' }} borderRadius="base" isLoaded={ !isLoading }>
<TagGroupSelect<StatsIntervalIds> items={ intervalListShort } onChange={ onIntervalChange } value={ interval } tagSize={ selectTagSize }/>
</Skeleton>
<Skeleton display={{ base: 'block', lg: 'none' }} borderRadius="base" isLoaded={ !isLoading }>
<StatsDropdownMenu
items={ intervalList }
selectedId={ interval }
onSelect={ onIntervalChange }
/>
</Skeleton>
</>
);
};
export default React.memo(ChartIntervalSelect);
import {
IconButton,
MenuButton,
MenuItem,
MenuList,
Skeleton,
useClipboard,
useColorModeValue,
VisuallyHidden,
} from '@chakra-ui/react';
import domToImage from 'dom-to-image';
import React from 'react';
import type { TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import dayjs from 'lib/date/dayjs';
import isBrowser from 'lib/isBrowser';
import saveAsCSV from 'lib/saveAsCSV';
import Menu from 'ui/shared/chakra/Menu';
import IconSvg from 'ui/shared/IconSvg';
import FullscreenChartModal from './FullscreenChartModal';
export type Props = {
items?: Array<TimeChartItem>;
title: string;
description?: string;
units?: string;
isLoading: boolean;
chartRef: React.RefObject<HTMLDivElement>;
chartUrl?: string;
resolution?: Resolution;
zoomRange?: [ Date, Date ];
handleZoom: (range: [ Date, Date ]) => void;
handleZoomReset: () => void;
}
const DOWNLOAD_IMAGE_SCALE = 5;
const ChartMenu = ({
items,
title,
description,
units,
isLoading,
chartRef,
chartUrl,
resolution,
zoomRange,
handleZoom,
handleZoomReset,
}: Props) => {
const pngBackgroundColor = useColorModeValue('white', 'black');
const [ isFullscreen, setIsFullscreen ] = React.useState(false);
const { onCopy } = useClipboard(chartUrl ?? '');
const isInBrowser = isBrowser();
const showChartFullscreen = React.useCallback(() => {
setIsFullscreen(true);
}, []);
const clearFullscreenChart = React.useCallback(() => {
setIsFullscreen(false);
}, []);
const handleFileSaveClick = React.useCallback(() => {
// wait for context menu to close
setTimeout(() => {
if (chartRef.current) {
domToImage.toPng(chartRef.current,
{
quality: 100,
bgcolor: pngBackgroundColor,
width: chartRef.current.offsetWidth * DOWNLOAD_IMAGE_SCALE,
height: chartRef.current.offsetHeight * DOWNLOAD_IMAGE_SCALE,
filter: (node) => node.nodeName !== 'BUTTON',
style: {
borderColor: 'transparent',
transform: `scale(${ DOWNLOAD_IMAGE_SCALE })`,
'transform-origin': 'top left',
},
})
.then((dataUrl) => {
const link = document.createElement('a');
link.download = `${ title } (Blockscout chart).png`;
link.href = dataUrl;
link.click();
link.remove();
});
}
}, 100);
}, [ pngBackgroundColor, title, chartRef ]);
const handleSVGSavingClick = React.useCallback(() => {
if (items) {
const headerRows = [
'Date', 'Value',
];
const dataRows = items.map((item) => [
dayjs(item.date).format('YYYY-MM-DD'), String(item.value),
]);
saveAsCSV(headerRows, dataRows, `${ title } (Blockscout stats)`);
}
}, [ items, title ]);
// TS thinks window.navigator.share can't be undefined, but it can
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasShare = isInBrowser && (window.navigator.share as any);
const handleShare = React.useCallback(async() => {
try {
await window.navigator.share({
title: title,
text: description,
url: chartUrl,
});
} catch (error) {}
}, [ title, description, chartUrl ]);
return (
<>
<Menu>
<Skeleton isLoaded={ !isLoading } borderRadius="base">
<MenuButton
w="36px"
h="32px"
icon={ <IconSvg name="dots" boxSize={ 4 } transform="rotate(-90deg)"/> }
colorScheme="gray"
variant="simple"
as={ IconButton }
>
<VisuallyHidden>
Open chart options menu
</VisuallyHidden>
</MenuButton>
</Skeleton>
<MenuList>
{ chartUrl && (
<MenuItem
display="flex"
alignItems="center"
onClick={ hasShare ? handleShare : onCopy }
closeOnSelect={ hasShare ? false : true }
>
<IconSvg name={ hasShare ? 'share' : 'copy' } boxSize={ 5 } mr={ 3 }/>
{ hasShare ? 'Share' : 'Copy link' }
</MenuItem>
) }
<MenuItem
display="flex"
alignItems="center"
onClick={ showChartFullscreen }
>
<IconSvg name="scope" boxSize={ 5 } mr={ 3 }/>
View fullscreen
</MenuItem>
<MenuItem
display="flex"
alignItems="center"
onClick={ handleFileSaveClick }
>
<IconSvg name="files/image" boxSize={ 5 } mr={ 3 }/>
Save as PNG
</MenuItem>
<MenuItem
display="flex"
alignItems="center"
onClick={ handleSVGSavingClick }
>
<IconSvg name="files/csv" boxSize={ 5 } mr={ 3 }/>
Save as CSV
</MenuItem>
</MenuList>
</Menu>
{ items && isFullscreen && (
<FullscreenChartModal
isOpen
items={ items }
title={ title }
description={ description }
onClose={ clearFullscreenChart }
units={ units }
resolution={ resolution }
zoomRange={ zoomRange }
handleZoom={ handleZoom }
handleZoomReset={ handleZoomReset }
/>
) }
</>
);
};
export default ChartMenu;
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { Resolution } from '@blockscout/stats-types';
import { STATS_RESOLUTIONS } from 'ui/stats/constants';
import StatsDropdownMenu from 'ui/stats/StatsDropdownMenu';
type Props = {
resolution: Resolution;
resolutions: Array<string>;
onResolutionChange: (resolution: Resolution) => void;
isLoading?: boolean;
}
const ChartResolutionSelect = ({ resolution, resolutions, onResolutionChange, isLoading }: Props) => {
return (
<Skeleton borderRadius="base" isLoaded={ !isLoading } w={{ base: 'auto', lg: '160px' }}>
<StatsDropdownMenu
items={ STATS_RESOLUTIONS.filter(r => resolutions.includes(r.id)) }
selectedId={ resolution }
onSelect={ onResolutionChange }
/>
</Skeleton>
);
};
export default React.memo(ChartResolutionSelect);
import * as d3 from 'd3'; import * as d3 from 'd3';
import React from 'react'; import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { TimeChartData } from 'ui/shared/chart/types'; import type { TimeChartData } from 'ui/shared/chart/types';
import ChartTooltipBackdrop, { useRenderBackdrop } from './tooltip/ChartTooltipBackdrop'; import ChartTooltipBackdrop, { useRenderBackdrop } from './tooltip/ChartTooltipBackdrop';
...@@ -21,9 +22,21 @@ interface Props { ...@@ -21,9 +22,21 @@ interface Props {
yScale: d3.ScaleLinear<number, number>; yScale: d3.ScaleLinear<number, number>;
anchorEl: SVGRectElement | null; anchorEl: SVGRectElement | null;
noAnimation?: boolean; noAnimation?: boolean;
resolution?: Resolution;
} }
const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, anchorEl, noAnimation, ...props }: Props) => { const ChartTooltip = ({
xScale,
yScale,
width,
tooltipWidth = 200,
height,
data,
anchorEl,
noAnimation,
resolution,
...props
}: Props) => {
const ref = React.useRef<SVGGElement>(null); const ref = React.useRef<SVGGElement>(null);
const trackerId = React.useRef<number>(); const trackerId = React.useRef<number>();
const isVisible = React.useRef(false); const isVisible = React.useRef(false);
...@@ -150,8 +163,8 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, ...@@ -150,8 +163,8 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data,
{ data.map(({ name }) => <ChartTooltipPoint key={ name }/>) } { data.map(({ name }) => <ChartTooltipPoint key={ name }/>) }
<ChartTooltipContent> <ChartTooltipContent>
<ChartTooltipBackdrop/> <ChartTooltipBackdrop/>
<ChartTooltipTitle/> <ChartTooltipTitle resolution={ resolution }/>
<ChartTooltipRow label="Date" lineNum={ 1 }/> <ChartTooltipRow label={ getDateLabel(resolution) } lineNum={ 1 }/>
{ data.map(({ name }, index) => <ChartTooltipRow key={ name } label={ name } lineNum={ index + 1 }/>) } { data.map(({ name }, index) => <ChartTooltipRow key={ name } label={ name } lineNum={ index + 1 }/>) }
</ChartTooltipContent> </ChartTooltipContent>
</g> </g>
...@@ -159,3 +172,16 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, ...@@ -159,3 +172,16 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data,
}; };
export default React.memo(ChartTooltip); export default React.memo(ChartTooltip);
function getDateLabel(resolution?: Resolution): string {
switch (resolution) {
case Resolution.WEEK:
return 'Dates';
case Resolution.MONTH:
return 'Month';
case Resolution.YEAR:
return 'Year';
default:
return 'Date';
}
}
import type { IconProps } from '@chakra-ui/react';
import { Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
// eslint-disable-next-line no-restricted-imports
import logoIcon from 'icons/networks/logo-placeholder.svg';
const ChartWatermarkIcon = (props: IconProps) => {
const watermarkColor = useColorModeValue('link', 'white');
return (
<Icon
{ ...props }
as={ logoIcon }
position="absolute"
opacity={ 0.1 }
top="50%"
left="50%"
transform="translate(-50%, -50%)"
pointerEvents="none"
viewBox="0 0 114 20"
color={ watermarkColor }
/>
);
};
export default ChartWatermarkIcon;
This diff is collapsed.
import { Box, Center, Flex, Link, Skeleton, Text } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import { apos } from 'lib/html-entities';
import ChartWatermarkIcon from './ChartWatermarkIcon';
import ChartWidgetGraph from './ChartWidgetGraph';
export type Props = {
items?: Array<TimeChartItem>;
title: string;
units?: string;
isLoading?: boolean;
isError?: boolean;
emptyText?: string;
zoomRange?: [ Date, Date ];
handleZoom: (range: [ Date, Date ]) => void;
isEnlarged?: boolean;
noAnimation?: boolean;
resolution?: Resolution;
}
const ChartWidgetContent = ({
items,
title,
isLoading,
isError,
units,
emptyText,
zoomRange,
handleZoom,
isEnlarged,
noAnimation,
resolution,
}: Props) => {
const hasItems = items && items.length > 2;
if (isError) {
return (
<Flex
alignItems="center"
justifyContent="center"
flexGrow={ 1 }
py={ 4 }
>
<Text
variant="secondary"
fontSize="sm"
textAlign="center"
>
{ `The data didn${ apos }t load. Please, ` }
<Link href={ window.document.location.href }>try to reload the page.</Link>
</Text>
</Flex>
);
}
if (isLoading) {
return <Skeleton flexGrow={ 1 } w="100%"/>;
}
if (!hasItems) {
return (
<Center flexGrow={ 1 }>
<Text variant="secondary" fontSize="sm">{ emptyText || 'No data' }</Text>
</Center>
);
}
return (
<Box flexGrow={ 1 } maxW="100%" position="relative" h="100%">
<ChartWidgetGraph
items={ items }
zoomRange={ zoomRange }
onZoom={ handleZoom }
title={ title }
units={ units }
isEnlarged={ isEnlarged }
noAnimation={ noAnimation }
resolution={ resolution }
/>
<ChartWatermarkIcon w="162px" h="15%"/>
</Box>
);
};
export default React.memo(ChartWidgetContent);
...@@ -2,9 +2,9 @@ import { useToken } from '@chakra-ui/react'; ...@@ -2,9 +2,9 @@ import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3'; import * as d3 from 'd3';
import React from 'react'; import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { ChartMargin, TimeChartData, TimeChartItem } from 'ui/shared/chart/types'; import type { ChartMargin, TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
import dayjs from 'lib/date/dayjs';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import ChartArea from 'ui/shared/chart/ChartArea'; import ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis'; import ChartAxis from 'ui/shared/chart/ChartAxis';
...@@ -20,37 +20,42 @@ interface Props { ...@@ -20,37 +20,42 @@ interface Props {
title: string; title: string;
units?: string; units?: string;
items: Array<TimeChartItem>; items: Array<TimeChartItem>;
onZoom: () => void; zoomRange?: [ Date, Date ];
isZoomResetInitial: boolean; onZoom: (range: [ Date, Date ]) => void;
margin?: ChartMargin; margin?: ChartMargin;
noAnimation?: boolean; noAnimation?: boolean;
resolution?: Resolution;
} }
// temporarily turn off the data aggregation, we need a better algorithm for that
const MAX_SHOW_ITEMS = 100_000_000_000;
const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 }; const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 };
const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin: marginProps, units, noAnimation }: Props) => { const ChartWidgetGraph = ({
isEnlarged,
items,
onZoom,
title,
margin: marginProps,
units,
noAnimation,
resolution,
zoomRange,
}: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const color = useToken('colors', 'blue.200'); const color = useToken('colors', 'blue.200');
const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`; const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
const overlayRef = React.useRef<SVGRectElement>(null); const overlayRef = React.useRef<SVGRectElement>(null);
const [ range, setRange ] = React.useState<[ Date, Date ]>([ items[0].date, items[items.length - 1].date ]); const range = React.useMemo(() => zoomRange || [ items[0].date, items[items.length - 1].date ], [ zoomRange, items ]);
const rangedItems = React.useMemo(() => const displayedData = React.useMemo(() =>
items.filter((item) => item.date >= range[0] && item.date <= range[1]), items
[ items, range ]); .filter((item) => item.date >= range[0] && item.date <= range[1])
const isGroupedValues = rangedItems.length > MAX_SHOW_ITEMS; .map((item) => ({
...item,
const displayedData = React.useMemo(() => { dateLabel: getDateLabel(item.date, item.date_to, resolution),
if (isGroupedValues) { })),
return groupChartItemsByWeekNumber(rangedItems); [ items, range, resolution ]);
} else {
return rangedItems;
}
}, [ isGroupedValues, rangedItems ]);
const chartData: TimeChartData = React.useMemo(() => ([ { items: displayedData, name: 'Value', color, units } ]), [ color, displayedData, units ]); const chartData: TimeChartData = React.useMemo(() => ([ { items: displayedData, name: 'Value', color, units } ]), [ color, displayedData, units ]);
...@@ -80,17 +85,6 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -80,17 +85,6 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
axesConfig, axesConfig,
}); });
const handleRangeSelect = React.useCallback((nextRange: [ Date, Date ]) => {
setRange([ nextRange[0], nextRange[1] ]);
onZoom();
}, [ onZoom ]);
React.useEffect(() => {
if (isZoomResetInitial) {
setRange([ items[0].date, items[items.length - 1].date ]);
}
}, [ isZoomResetInitial, items ]);
return ( return (
<svg width="100%" height="100%" ref={ ref } cursor="pointer" id={ chartId } opacity={ rect ? 1 : 0 }> <svg width="100%" height="100%" ref={ ref } cursor="pointer" id={ chartId } opacity={ rect ? 1 : 0 }>
...@@ -143,12 +137,13 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -143,12 +137,13 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
<ChartTooltip <ChartTooltip
anchorEl={ overlayRef.current } anchorEl={ overlayRef.current }
width={ innerWidth } width={ innerWidth }
tooltipWidth={ isGroupedValues ? 280 : 200 } tooltipWidth={ (resolution === Resolution.WEEK) ? 280 : 200 }
height={ innerHeight } height={ innerHeight }
xScale={ axes.x.scale } xScale={ axes.x.scale }
yScale={ axes.y.scale } yScale={ axes.y.scale }
data={ chartData } data={ chartData }
noAnimation={ noAnimation } noAnimation={ noAnimation }
resolution={ resolution }
/> />
<ChartSelectionX <ChartSelectionX
...@@ -156,7 +151,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -156,7 +151,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
height={ innerHeight } height={ innerHeight }
scale={ axes.x.scale } scale={ axes.x.scale }
data={ chartData } data={ chartData }
onSelect={ handleRangeSelect } onSelect={ onZoom }
/> />
</ChartOverlay> </ChartOverlay>
</g> </g>
...@@ -166,13 +161,15 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -166,13 +161,15 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
export default React.memo(ChartWidgetGraph); export default React.memo(ChartWidgetGraph);
function groupChartItemsByWeekNumber(items: Array<TimeChartItem>): Array<TimeChartItem> { function getDateLabel(date: Date, dateTo?: Date, resolution?: Resolution): string {
return d3.rollups(items, switch (resolution) {
(group) => ({ case Resolution.WEEK:
date: group[0].date, return d3.timeFormat('%e %b %Y')(date) + (dateTo ? ` – ${ d3.timeFormat('%e %b %Y')(dateTo) }` : '');
value: d3.sum(group, (d) => d.value), case Resolution.MONTH:
dateLabel: `${ d3.timeFormat('%e %b %Y')(group[0].date) }${ d3.timeFormat('%e %b %Y')(group[group.length - 1].date) }`, return d3.timeFormat('%b %Y')(date);
}), case Resolution.YEAR:
(t) => `${ dayjs(t.date).week() } / ${ dayjs(t.date).year() }`, return d3.timeFormat('%Y')(date);
).map(([ , v ]) => v); default:
return d3.timeFormat('%e %b %Y')(date);
}
} }
import { Box, Button, Grid, Heading, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay, Text } from '@chakra-ui/react'; import { Box, Button, Grid, Heading, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay, Text } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React from 'react';
import type { TimeChartItem } from './types'; import type { TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import ChartWidgetGraph from './ChartWidgetGraph'; import ChartWidgetContent from './ChartWidgetContent';
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
...@@ -14,6 +15,10 @@ type Props = { ...@@ -14,6 +15,10 @@ type Props = {
items: Array<TimeChartItem>; items: Array<TimeChartItem>;
onClose: () => void; onClose: () => void;
units?: string; units?: string;
resolution?: Resolution;
zoomRange?: [ Date, Date ];
handleZoom: (range: [ Date, Date ]) => void;
handleZoomReset: () => void;
} }
const FullscreenChartModal = ({ const FullscreenChartModal = ({
...@@ -23,17 +28,11 @@ const FullscreenChartModal = ({ ...@@ -23,17 +28,11 @@ const FullscreenChartModal = ({
items, items,
units, units,
onClose, onClose,
resolution,
zoomRange,
handleZoom,
handleZoomReset,
}: Props) => { }: Props) => {
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true);
const handleZoom = useCallback(() => {
setIsZoomResetInitial(false);
}, []);
const handleZoomResetClick = useCallback(() => {
setIsZoomResetInitial(true);
}, []);
return ( return (
<Modal <Modal
isOpen={ isOpen } isOpen={ isOpen }
...@@ -69,7 +68,7 @@ const FullscreenChartModal = ({ ...@@ -69,7 +68,7 @@ const FullscreenChartModal = ({
</Text> </Text>
) } ) }
{ !isZoomResetInitial && ( { Boolean(zoomRange) && (
<Button <Button
leftIcon={ <IconSvg name="repeat" w={ 4 } h={ 4 }/> } leftIcon={ <IconSvg name="repeat" w={ 4 } h={ 4 }/> }
colorScheme="blue" colorScheme="blue"
...@@ -79,7 +78,7 @@ const FullscreenChartModal = ({ ...@@ -79,7 +78,7 @@ const FullscreenChartModal = ({
gridRow="1/3" gridRow="1/3"
size="sm" size="sm"
variant="outline" variant="outline"
onClick={ handleZoomResetClick } onClick={ handleZoomReset }
> >
Reset zoom Reset zoom
</Button> </Button>
...@@ -91,15 +90,16 @@ const FullscreenChartModal = ({ ...@@ -91,15 +90,16 @@ const FullscreenChartModal = ({
<ModalBody <ModalBody
h="100%" h="100%"
margin={{ bottom: 60 }}
> >
<ChartWidgetGraph <ChartWidgetContent
margin={{ bottom: 60 }}
isEnlarged isEnlarged
items={ items } items={ items }
units={ units } units={ units }
onZoom={ handleZoom } handleZoom={ handleZoom }
isZoomResetInitial={ isZoomResetInitial } zoomRange={ zoomRange }
title={ title } title={ title }
resolution={ resolution }
/> />
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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