Commit e6974331 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge branch 'main' into tom2drum/issue-1484

parents 5102f40e 8f6e3e2a
...@@ -20,15 +20,15 @@ jobs: ...@@ -20,15 +20,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
# Will automatically make nice tags, see the table here https://github.com/docker/metadata-action#basic # Will automatically make nice tags, see the table here https://github.com/docker/metadata-action#basic
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v5
with: with:
images: ghcr.io/blockscout/frontend images: ghcr.io/blockscout/frontend
...@@ -51,13 +51,16 @@ jobs: ...@@ -51,13 +51,16 @@ jobs:
echo "ref_name: $REF_NAME" echo "ref_name: $REF_NAME"
- name: Build and push - name: Build and push
uses: docker/build-push-action@v3 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
cache-from: type=gha cache-from: type=gha
tags: ${{ inputs.tags || steps.meta.outputs.tags }} tags: ${{ inputs.tags || steps.meta.outputs.tags }}
platforms: |
linux/amd64
linux/arm64/v8
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
GIT_COMMIT_SHA=${{ env.SHORT_SHA }} GIT_COMMIT_SHA=${{ env.SHORT_SHA }}
......
...@@ -11,6 +11,7 @@ export { default as graphqlApiDocs } from './graphqlApiDocs'; ...@@ -11,6 +11,7 @@ export { default as graphqlApiDocs } from './graphqlApiDocs';
export { default as growthBook } from './growthBook'; export { default as growthBook } from './growthBook';
export { default as marketplace } from './marketplace'; export { default as marketplace } from './marketplace';
export { default as mixpanel } from './mixpanel'; export { default as mixpanel } from './mixpanel';
export { default as nameService } from './nameService';
export { default as restApiDocs } from './restApiDocs'; export { default as restApiDocs } from './restApiDocs';
export { default as optimisticRollup } from './optimisticRollup'; export { default as optimisticRollup } from './optimisticRollup';
export { default as safe } from './safe'; export { default as safe } from './safe';
......
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const apiHost = getEnvValue('NEXT_PUBLIC_NAME_SERVICE_API_HOST');
const title = 'Name service integration';
const config: Feature<{ api: { endpoint: string; basePath: string } }> = (() => {
if (apiHost) {
return Object.freeze({
title,
isEnabled: true,
api: {
endpoint: apiHost,
basePath: '',
},
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
...@@ -46,6 +46,7 @@ NEXT_PUBLIC_STATS_API_HOST=https://stats-goerli.k8s-dev.blockscout.com ...@@ -46,6 +46,7 @@ NEXT_PUBLIC_STATS_API_HOST=https://stats-goerli.k8s-dev.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
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_NAME_SERVICE_API_HOST=https://bens-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask'] NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true' NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true'
......
...@@ -425,6 +425,7 @@ const schema = yup ...@@ -425,6 +425,7 @@ const schema = yup
NEXT_PUBLIC_STATS_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_STATS_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_VISUALIZE_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_VISUALIZE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_CONTRACT_INFO_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_NAME_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: yup.string().matches(regexp.HEX_REGEXP), NEXT_PUBLIC_GRAPHIQL_TRANSACTION: yup.string().matches(regexp.HEX_REGEXP),
NEXT_PUBLIC_WEB3_WALLETS: yup NEXT_PUBLIC_WEB3_WALLETS: yup
.mixed() .mixed()
......
...@@ -153,6 +153,7 @@ frontend: ...@@ -153,6 +153,7 @@ frontend:
NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/ NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST: https://bens-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_LOGOUT_URL: https://blockscoutcom.us.auth0.com/v2/logout NEXT_PUBLIC_LOGOUT_URL: https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_NETWORK_RPC_URL: https://rpc.ankr.com/eth_goerli NEXT_PUBLIC_NETWORK_RPC_URL: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_HOMEPAGE_CHARTS: "['daily_txs','coin_price','market_cap']" NEXT_PUBLIC_HOMEPAGE_CHARTS: "['daily_txs','coin_price','market_cap']"
......
...@@ -56,6 +56,7 @@ frontend: ...@@ -56,6 +56,7 @@ frontend:
NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/ NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST: https://bens-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_AUTH_URL: https://blockscout-main.k8s-dev.blockscout.com NEXT_PUBLIC_AUTH_URL: https://blockscout-main.k8s-dev.blockscout.com
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: https://airtable.com/shrqUAcjgGJ4jU88C NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_LOGOUT_URL: https://blockscoutcom.us.auth0.com/v2/logout NEXT_PUBLIC_LOGOUT_URL: https://blockscoutcom.us.auth0.com/v2/logout
......
...@@ -47,6 +47,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will ...@@ -47,6 +47,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Web3 wallet integration](ENVS.md#web3-wallet-integration-add-token-or-network-to-the-wallet) (add token or network to the wallet) - [Web3 wallet integration](ENVS.md#web3-wallet-integration-add-token-or-network-to-the-wallet) (add token or network to the wallet)
- [Transaction interpretation](ENVS.md#transaction-interpretation) - [Transaction interpretation](ENVS.md#transaction-interpretation)
- [Verified tokens info](ENVS.md#verified-tokens-info) - [Verified tokens info](ENVS.md#verified-tokens-info)
- [Name service integration](ENVS.md#name-service-integration)
- [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)
- [SUAVE chain](ENVS.md#suave-chain) - [SUAVE chain](ENVS.md#suave-chain)
...@@ -499,6 +500,16 @@ This feature is **enabled by default** with the `['metamask']` value. To switch ...@@ -499,6 +500,16 @@ This feature is **enabled by default** with the `['metamask']` value. To switch
&nbsp; &nbsp;
### Name service integration
This feature allows resolving blockchain addresses using human-readable domain names.
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_NAME_SERVICE_API_HOST | `string` | Name Service API endpoint url | Required | - | `https://bens.services.blockscout.com` |
&nbsp;
### Bridged tokens ### Bridged tokens
This feature allows users to view tokens that have been bridged from other EVM chains. Additional tab "Bridged" will be added to the tokens page and the link to original token will be displayed on the token page. This feature allows users to view tokens that have been bridged from other EVM chains. Additional tab "Bridged" will be added to the tokens page and the link to original token will be displayed on the token page.
......
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.41 3 7.764 7.625c-.396.263-.72.62-.946 1.038a4.74 4.74 0 0 0-.046.1l-.009.019a4.786 4.786 0 0 0 .01 3.93c.233.508.828 1.51.828 1.51L14.41 3ZM4.076 16.404A15.45 15.45 0 0 1 4 14.886l1.168-3.159a6.617 6.617 0 0 0 .755 2.095C9.802 20.393 14.401 27 14.401 27l-7.35-5.108A7.562 7.562 0 0 1 4.25 17.57a7.53 7.53 0 0 1-.174-1.166ZM4 14.886l1.144-3.306a3.575 3.575 0 0 1 0-1.076c-.101.187-.298.57-.298.57a8.68 8.68 0 0 0-.794 2.534A15.45 15.45 0 0 0 4 14.886ZM15.192 27l.063-.103 6.734-11.113.005.009.007-.012s.589 1.002.827 1.51a4.787 4.787 0 0 1-.045 4.05c-.225.417-.55.774-.946 1.037L15.192 27Zm9.263-7.504c.054-.357.054-.72 0-1.076a6.622 6.622 0 0 0-.776-2.242C19.796 9.607 15.2 3 15.2 3l7.35 5.108a7.533 7.533 0 0 1 2.975 5.488c.091.93.098 1.865.02 2.796a8.68 8.68 0 0 1-.794 2.535s-.196.382-.297.57v-.001Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.145 8.091c.198.423.689 1.258.689 1.258L9.508 0 3.97 3.854c-.33.22-.6.517-.788.865a3.989 3.989 0 0 0-.037 3.372ZM.897 11.17a6.277 6.277 0 0 0 2.478 4.573L9.501 20S5.668 14.495 2.436 9.018a5.52 5.52 0 0 1-.65-1.868 2.98 2.98 0 0 1 0-.897 35.09 35.09 0 0 0-.247.475A7.234 7.234 0 0 0 .877 8.84a12.86 12.86 0 0 0 .02 2.33Zm15.626.739c-.198-.423-.689-1.258-.689-1.258L10.16 20l5.538-3.852c.33-.22.6-.516.788-.864a3.99 3.99 0 0 0 .037-3.375Zm2.249-3.079a6.279 6.279 0 0 0-2.48-4.573L10.168 0s3.83 5.505 7.065 10.982a5.52 5.52 0 0 1 .647 1.868c.045.297.045.6 0 .897.084-.156.248-.475.248-.475a7.223 7.223 0 0 0 .662-2.112c.065-.776.059-1.555-.017-2.33Z" fill="currentColor"/>
<path d="M3.182 4.719c.188-.348.458-.645.788-.865L9.508 0 3.834 9.351s-.496-.835-.69-1.257a3.989 3.989 0 0 1 .038-3.375Zm-2.285 6.45a6.278 6.278 0 0 0 2.478 4.574L9.501 20S5.668 14.495 2.436 9.018a5.52 5.52 0 0 1-.65-1.868 2.98 2.98 0 0 1 0-.897 34.31 34.31 0 0 0-.247.475A7.234 7.234 0 0 0 .877 8.84c-.064.776-.057 1.555.02 2.33Zm15.616.742c-.198-.422-.689-1.258-.689-1.258L10.16 20l5.538-3.852c.33-.22.6-.516.788-.864a3.99 3.99 0 0 0 .037-3.375l-.01.002Zm2.249-3.078a6.277 6.277 0 0 0-2.48-4.574L10.169 0s3.83 5.505 7.064 10.982a5.52 5.52 0 0 1 .647 1.868c.045.297.045.6 0 .897.084-.156.248-.475.248-.475a7.233 7.233 0 0 0 .662-2.112c.064-.776.059-1.555-.018-2.33l-.01.003Z" fill="currentColor" style="mix-blend-mode:color"/>
</svg>
...@@ -20,6 +20,7 @@ const PAGE_PROPS = { ...@@ -20,6 +20,7 @@ const PAGE_PROPS = {
hash: '', hash: '',
number: '', number: '',
q: '', q: '',
name: '',
}; };
const TestApp = ({ children }: {children: React.ReactNode}) => { const TestApp = ({ children }: {children: React.ReactNode}) => {
......
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { TextEncoder, TextDecoder } from 'util';
import fetchMock from 'jest-fetch-mock'; import fetchMock from 'jest-fetch-mock';
...@@ -6,6 +7,8 @@ fetchMock.enableMocks(); ...@@ -6,6 +7,8 @@ fetchMock.enableMocks();
const envs = dotenv.config({ path: './configs/envs/.env.jest' }); const envs = dotenv.config({ path: './configs/envs/.env.jest' });
Object.assign(global, { TextDecoder, TextEncoder });
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: jest.fn().mockImplementation(query => ({ value: jest.fn().mockImplementation(query => ({
......
...@@ -9,7 +9,7 @@ import type { ApiResource, ResourceName, ResourcePathParams } from './resources' ...@@ -9,7 +9,7 @@ import type { ApiResource, ResourceName, ResourcePathParams } from './resources'
export default function buildUrl<R extends ResourceName>( export default function buildUrl<R extends ResourceName>(
resourceName: R, resourceName: R,
pathParams?: ResourcePathParams<R>, pathParams?: ResourcePathParams<R>,
queryParams?: Record<string, string | Array<string> | number | null | undefined>, queryParams?: Record<string, string | Array<string> | number | boolean | null | undefined>,
): string { ): string {
const resource: ApiResource = RESOURCES[resourceName]; const resource: ApiResource = RESOURCES[resourceName];
const baseUrl = isNeedProxy() ? config.app.baseUrl : (resource.endpoint || config.api.endpoint); const baseUrl = isNeedProxy() ? config.app.baseUrl : (resource.endpoint || config.api.endpoint);
......
...@@ -36,6 +36,15 @@ import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/ch ...@@ -36,6 +36,15 @@ import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/ch
import type { BackendVersionConfig } from 'types/api/configs'; import type { BackendVersionConfig } from 'types/api/configs';
import type { SmartContract, SmartContractReadMethod, SmartContractWriteMethod, SmartContractVerificationConfig, SolidityscanReport } from 'types/api/contract'; import type { SmartContract, SmartContractReadMethod, SmartContractWriteMethod, SmartContractVerificationConfig, SolidityscanReport } from 'types/api/contract';
import type { VerifiedContractsResponse, VerifiedContractsFilters, VerifiedContractsCounters } from 'types/api/contracts'; import type { VerifiedContractsResponse, VerifiedContractsFilters, VerifiedContractsCounters } from 'types/api/contracts';
import type {
EnsAddressLookupFilters,
EnsAddressLookupResponse,
EnsDomainDetailed,
EnsDomainEventsResponse,
EnsDomainLookupFilters,
EnsDomainLookupResponse,
EnsLookupSorting,
} from 'types/api/ens';
import type { IndexingStatus } from 'types/api/indexingStatus'; import type { IndexingStatus } from 'types/api/indexingStatus';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction'; import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { L2DepositsResponse, L2DepositsItem } from 'types/api/l2Deposits'; import type { L2DepositsResponse, L2DepositsItem } from 'types/api/l2Deposits';
...@@ -176,6 +185,34 @@ export const RESOURCES = { ...@@ -176,6 +185,34 @@ export const RESOURCES = {
basePath: getFeaturePayload(config.features.stats)?.api.basePath, basePath: getFeaturePayload(config.features.stats)?.api.basePath,
}, },
// NAME SERVICE
addresses_lookup: {
path: '/api/v1/:chainId/addresses\\:lookup',
pathParams: [ 'chainId' as const ],
endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint,
basePath: getFeaturePayload(config.features.nameService)?.api.basePath,
filterFields: [ 'address' as const, 'resolved_to' as const, 'owned_by' as const, 'only_active' as const ],
},
domain_info: {
path: '/api/v1/:chainId/domains/:name',
pathParams: [ 'chainId' as const, 'name' as const ],
endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint,
basePath: getFeaturePayload(config.features.nameService)?.api.basePath,
},
domain_events: {
path: '/api/v1/:chainId/domains/:name/events',
pathParams: [ 'chainId' as const, 'name' as const ],
endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint,
basePath: getFeaturePayload(config.features.nameService)?.api.basePath,
},
domains_lookup: {
path: '/api/v1/:chainId/domains\\:lookup',
pathParams: [ 'chainId' as const ],
endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint,
basePath: getFeaturePayload(config.features.nameService)?.api.basePath,
filterFields: [ 'name' as const, 'only_active' as const ],
},
// VISUALIZATION // VISUALIZATION
visualize_sol2uml: { visualize_sol2uml: {
path: '/api/v1/solidity\\:visualize-contracts', path: '/api/v1/solidity\\:visualize-contracts',
...@@ -613,7 +650,8 @@ export type PaginatedResources = 'blocks' | 'block_txs' | ...@@ -613,7 +650,8 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'l2_output_roots' | 'l2_withdrawals' | 'l2_txn_batches' | 'l2_deposits' | 'l2_output_roots' | 'l2_withdrawals' | 'l2_txn_batches' | 'l2_deposits' |
'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' | 'zkevm_l2_txn_batches' | 'zkevm_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';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -712,6 +750,10 @@ Q extends 'zkevm_l2_txn_batches_count' ? number : ...@@ -712,6 +750,10 @@ Q extends 'zkevm_l2_txn_batches_count' ? number :
Q extends 'zkevm_l2_txn_batch' ? ZkEvmL2TxnBatch : Q extends 'zkevm_l2_txn_batch' ? ZkEvmL2TxnBatch :
Q extends 'zkevm_l2_txn_batch_txs' ? ZkEvmL2TxnBatchTxs : Q extends 'zkevm_l2_txn_batch_txs' ? ZkEvmL2TxnBatchTxs :
Q extends 'config_backend_version' ? BackendVersionConfig : Q extends 'config_backend_version' ? BackendVersionConfig :
Q extends 'addresses_lookup' ? EnsAddressLookupResponse :
Q extends 'domain_info' ? EnsDomainDetailed :
Q extends 'domain_events' ? EnsDomainEventsResponse :
Q extends 'domains_lookup' ? EnsDomainLookupResponse :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -731,6 +773,8 @@ Q extends 'token_inventory' ? TokenInventoryFilters : ...@@ -731,6 +773,8 @@ Q extends 'token_inventory' ? TokenInventoryFilters :
Q extends 'tokens' ? TokensFilters : Q extends 'tokens' ? TokensFilters :
Q extends 'tokens_bridged' ? TokensBridgedFilters : Q extends 'tokens_bridged' ? TokensBridgedFilters :
Q extends 'verified_contracts' ? VerifiedContractsFilters : Q extends 'verified_contracts' ? VerifiedContractsFilters :
Q extends 'addresses_lookup' ? EnsAddressLookupFilters :
Q extends 'domains_lookup' ? EnsDomainLookupFilters :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -740,5 +784,7 @@ Q extends 'tokens' ? TokensSorting : ...@@ -740,5 +784,7 @@ Q extends 'tokens' ? TokensSorting :
Q extends 'tokens_bridged' ? TokensSorting : Q extends 'tokens_bridged' ? TokensSorting :
Q extends 'verified_contracts' ? VerifiedContractsSorting : Q extends 'verified_contracts' ? VerifiedContractsSorting :
Q extends 'address_txs' ? TransactionsSorting : Q extends 'address_txs' ? TransactionsSorting :
Q extends 'addresses_lookup' ? EnsLookupSorting :
Q extends 'domains_lookup' ? EnsLookupSorting :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import _omit from 'lodash/omit';
import _pickBy from 'lodash/pickBy'; import _pickBy from 'lodash/pickBy';
import React from 'react'; import React from 'react';
...@@ -18,8 +19,8 @@ import type { ApiResource, ResourceName, ResourcePathParams } from './resources' ...@@ -18,8 +19,8 @@ import type { ApiResource, ResourceName, ResourcePathParams } from './resources'
export interface Params<R extends ResourceName> { export interface Params<R extends ResourceName> {
pathParams?: ResourcePathParams<R>; pathParams?: ResourcePathParams<R>;
queryParams?: Record<string, string | Array<string> | number | undefined>; queryParams?: Record<string, string | Array<string> | number | boolean | undefined>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal'>; fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal' | 'headers'>;
} }
export default function useApiFetch() { export default function useApiFetch() {
...@@ -40,6 +41,7 @@ export default function useApiFetch() { ...@@ -40,6 +41,7 @@ export default function useApiFetch() {
'x-endpoint': resource.endpoint && isNeedProxy() ? resource.endpoint : undefined, 'x-endpoint': resource.endpoint && isNeedProxy() ? resource.endpoint : undefined,
Authorization: resource.endpoint && resource.needAuth ? apiToken : undefined, Authorization: resource.endpoint && resource.needAuth ? apiToken : undefined,
'x-csrf-token': withBody && csrfToken ? csrfToken : undefined, 'x-csrf-token': withBody && csrfToken ? csrfToken : undefined,
...fetchParams?.headers,
}, Boolean) as HeadersInit; }, Boolean) as HeadersInit;
return fetch<SuccessType, ErrorType>( return fetch<SuccessType, ErrorType>(
...@@ -51,7 +53,7 @@ export default function useApiFetch() { ...@@ -51,7 +53,7 @@ export default function useApiFetch() {
// change condition here if something is changed // change condition here if something is changed
credentials: config.features.account.isEnabled ? 'include' : 'same-origin', credentials: config.features.account.isEnabled ? 'include' : 'same-origin',
headers, headers,
...fetchParams, ..._omit(fetchParams, 'headers'),
}, },
{ {
resource: resource.path, resource: resource.path,
......
...@@ -15,6 +15,7 @@ const AppContext = createContext<PageProps>({ ...@@ -15,6 +15,7 @@ const AppContext = createContext<PageProps>({
hash: '', hash: '',
number: '', number: '',
q: '', q: '',
name: '',
}); });
export function AppContextProvider({ children, pageProps }: Props) { export function AppContextProvider({ children, pageProps }: Props) {
......
...@@ -15,22 +15,22 @@ export default function useContractTabs(data: Address | undefined) { ...@@ -15,22 +15,22 @@ export default function useContractTabs(data: Address | undefined) {
// { id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } : // { id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } :
// undefined, // undefined,
data?.has_methods_read ? data?.has_methods_read ?
{ id: 'read_contract', title: 'Read contract', component: <ContractRead addressHash={ data?.hash }/> } : { id: 'read_contract', title: 'Read contract', component: <ContractRead/> } :
undefined, undefined,
data?.has_methods_read_proxy ? data?.has_methods_read_proxy ?
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead addressHash={ data?.hash } isProxy/> } : { id: 'read_proxy', title: 'Read proxy', component: <ContractRead/> } :
undefined, undefined,
data?.has_custom_methods_read ? data?.has_custom_methods_read ?
{ id: 'read_custom_methods', title: 'Read custom', component: <ContractRead addressHash={ data?.hash } isCustomAbi/> } : { id: 'read_custom_methods', title: 'Read custom', component: <ContractRead/> } :
undefined, undefined,
data?.has_methods_write ? data?.has_methods_write ?
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite addressHash={ data?.hash }/> } : { id: 'write_contract', title: 'Write contract', component: <ContractWrite/> } :
undefined, undefined,
data?.has_methods_write_proxy ? data?.has_methods_write_proxy ?
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite addressHash={ data?.hash } isProxy/> } : { id: 'write_proxy', title: 'Write proxy', component: <ContractWrite/> } :
undefined, undefined,
data?.has_custom_methods_write ? data?.has_custom_methods_write ?
{ id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite addressHash={ data?.hash } isCustomAbi/> } : { id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite/> } :
undefined, undefined,
].filter(Boolean); ].filter(Boolean);
}, [ data ]); }, [ data ]);
......
...@@ -53,6 +53,12 @@ export default function useNavItems(): ReturnType { ...@@ -53,6 +53,12 @@ export default function useNavItems(): ReturnType {
icon: 'verified', icon: 'verified',
isActive: pathname === '/verified-contracts', isActive: pathname === '/verified-contracts',
}; };
const ensLookup = config.features.nameService.isEnabled ? {
text: 'ENS lookup',
nextRoute: { pathname: '/name-domains' as const },
icon: 'ENS',
isActive: pathname === '/name-domains' || pathname === '/name-domains/[name]',
} : null;
if (config.features.zkEvmRollup.isEnabled) { if (config.features.zkEvmRollup.isEnabled) {
blockchainNavItems = [ blockchainNavItems = [
...@@ -69,6 +75,7 @@ export default function useNavItems(): ReturnType { ...@@ -69,6 +75,7 @@ export default function useNavItems(): ReturnType {
[ [
topAccounts, topAccounts,
verifiedContracts, verifiedContracts,
ensLookup,
].filter(Boolean), ].filter(Boolean),
]; ];
} else if (config.features.optimisticRollup.isEnabled) { } else if (config.features.optimisticRollup.isEnabled) {
...@@ -90,6 +97,7 @@ export default function useNavItems(): ReturnType { ...@@ -90,6 +97,7 @@ export default function useNavItems(): ReturnType {
[ [
topAccounts, topAccounts,
verifiedContracts, verifiedContracts,
ensLookup,
].filter(Boolean), ].filter(Boolean),
]; ];
} else { } else {
...@@ -98,6 +106,7 @@ export default function useNavItems(): ReturnType { ...@@ -98,6 +106,7 @@ export default function useNavItems(): ReturnType {
blocks, blocks,
topAccounts, topAccounts,
verifiedContracts, verifiedContracts,
ensLookup,
config.features.beaconChain.isEnabled && { config.features.beaconChain.isEnabled && {
text: 'Withdrawals', text: 'Withdrawals',
nextRoute: { pathname: '/withdrawals' as const }, nextRoute: { pathname: '/withdrawals' as const },
......
...@@ -40,6 +40,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -40,6 +40,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/zkevm-l2-txn-batches': 'Root page', '/zkevm-l2-txn-batches': 'Root page',
'/zkevm-l2-txn-batch/[number]': 'Regular page', '/zkevm-l2-txn-batch/[number]': 'Regular page',
'/404': 'Regular page', '/404': 'Regular page',
'/name-domains': 'Root page',
'/name-domains/[name]': 'Regular page',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Regular page', '/login': 'Regular page',
......
...@@ -43,6 +43,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -43,6 +43,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/zkevm-l2-txn-batches': DEFAULT_TEMPLATE, '/zkevm-l2-txn-batches': DEFAULT_TEMPLATE,
'/zkevm-l2-txn-batch/[number]': DEFAULT_TEMPLATE, '/zkevm-l2-txn-batch/[number]': DEFAULT_TEMPLATE,
'/404': DEFAULT_TEMPLATE, '/404': DEFAULT_TEMPLATE,
'/name-domains': DEFAULT_TEMPLATE,
'/name-domains/[name]': DEFAULT_TEMPLATE,
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE, '/login': DEFAULT_TEMPLATE,
......
...@@ -33,11 +33,13 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -33,11 +33,13 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/csv-export': 'export data to CSV', '/csv-export': 'export data to CSV',
'/l2-deposits': 'deposits (L1 > L2)', '/l2-deposits': 'deposits (L1 > L2)',
'/l2-output-roots': 'output roots', '/l2-output-roots': 'output roots',
'/l2-txn-batches': 'Tx batches (L2 blocks)', '/l2-txn-batches': 'tx batches (L2 blocks)',
'/l2-withdrawals': 'withdrawals (L2 > L1)', '/l2-withdrawals': 'withdrawals (L2 > L1)',
'/zkevm-l2-txn-batches': 'zkEvm L2 Tx batches', '/zkevm-l2-txn-batches': 'zkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'zkEvm L2 Tx batch %number%', '/zkevm-l2-txn-batch/[number]': 'zkEvm L2 Tx batch %number%',
'/404': 'error - page not found', '/404': 'error - page not found',
'/name-domains': 'domains search and resolve',
'/name-domains/[name]': '%name% domain details',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'login', '/login': 'login',
......
...@@ -38,6 +38,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -38,6 +38,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/zkevm-l2-txn-batches': 'ZkEvm L2 Tx batches', '/zkevm-l2-txn-batches': 'ZkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'ZkEvm L2 Tx batch details', '/zkevm-l2-txn-batch/[number]': 'ZkEvm L2 Tx batch details',
'/404': '404', '/404': '404',
'/name-domains': 'Domains search and resolve',
'/name-domains/[name]': 'Domain details',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Login', '/login': 'Login',
......
...@@ -3,18 +3,21 @@ import type { WalletType } from 'types/client/wallets'; ...@@ -3,18 +3,21 @@ import type { WalletType } from 'types/client/wallets';
export enum EventTypes { export enum EventTypes {
PAGE_VIEW = 'Page view', PAGE_VIEW = 'Page view',
SEARCH_QUERY = 'Search query', SEARCH_QUERY = 'Search query',
LOCAL_SEARCH = 'Local search',
ADD_TO_WALLET = 'Add to wallet', ADD_TO_WALLET = 'Add to wallet',
ACCOUNT_ACCESS = 'Account access', ACCOUNT_ACCESS = 'Account access',
PRIVATE_TAG = 'Private tag', PRIVATE_TAG = 'Private tag',
VERIFY_ADDRESS = 'Verify address', VERIFY_ADDRESS = 'Verify address',
VERIFY_TOKEN = 'Verify token', VERIFY_TOKEN = 'Verify token',
WALLET_CONNECT = 'Wallet connect', WALLET_CONNECT = 'Wallet connect',
WALLET_ACTION = 'Wallet action',
CONTRACT_INTERACTION = 'Contract interaction', CONTRACT_INTERACTION = 'Contract interaction',
CONTRACT_VERIFICATION = 'Contract verification', CONTRACT_VERIFICATION = 'Contract verification',
QR_CODE = 'QR code', QR_CODE = 'QR code',
PAGE_WIDGET = 'Page widget', PAGE_WIDGET = 'Page widget',
TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction', TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction',
EXPERIMENT_STARTED = 'Experiment started', EXPERIMENT_STARTED = 'Experiment started',
FILTERS = 'Filters'
} }
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
...@@ -31,6 +34,10 @@ Type extends EventTypes.SEARCH_QUERY ? { ...@@ -31,6 +34,10 @@ Type extends EventTypes.SEARCH_QUERY ? {
'Source page type': string; 'Source page type': string;
'Result URL': string; 'Result URL': string;
} : } :
Type extends EventTypes.LOCAL_SEARCH ? {
'Search query': string;
'Source': 'Marketplace';
} :
Type extends EventTypes.ADD_TO_WALLET ? ( Type extends EventTypes.ADD_TO_WALLET ? (
{ {
'Wallet': WalletType; 'Wallet': WalletType;
...@@ -66,6 +73,9 @@ Type extends EventTypes.WALLET_CONNECT ? { ...@@ -66,6 +73,9 @@ Type extends EventTypes.WALLET_CONNECT ? {
'Source': 'Header' | 'Smart contracts'; 'Source': 'Header' | 'Smart contracts';
'Status': 'Started' | 'Connected'; 'Status': 'Started' | 'Connected';
} : } :
Type extends EventTypes.WALLET_ACTION ? {
'Action': 'Open' | 'Address click';
} :
Type extends EventTypes.CONTRACT_INTERACTION ? { Type extends EventTypes.CONTRACT_INTERACTION ? {
'Method type': 'Read' | 'Write'; 'Method type': 'Read' | 'Write';
'Method name': string; 'Method name': string;
...@@ -77,9 +87,14 @@ Type extends EventTypes.CONTRACT_VERIFICATION ? { ...@@ -77,9 +87,14 @@ Type extends EventTypes.CONTRACT_VERIFICATION ? {
Type extends EventTypes.QR_CODE ? { Type extends EventTypes.QR_CODE ? {
'Page type': string; 'Page type': string;
} : } :
Type extends EventTypes.PAGE_WIDGET ? { Type extends EventTypes.PAGE_WIDGET ? (
'Type': 'Tokens dropdown' | 'Tokens show all (icon)' | 'Add to watchlist' | 'Address actions (more button)'; {
} : 'Type': 'Tokens dropdown' | 'Tokens show all (icon)' | 'Add to watchlist' | 'Address actions (more button)';
} | {
'Type': 'Favorite app' | 'More button';
'Info': string;
}
) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
'Type': 'Address click' | 'Token click'; 'Type': 'Address click' | 'Token click';
} : } :
...@@ -88,5 +103,9 @@ Type extends EventTypes.EXPERIMENT_STARTED ? { ...@@ -88,5 +103,9 @@ Type extends EventTypes.EXPERIMENT_STARTED ? {
'Variant name': string; 'Variant name': string;
'Source': 'growthbook'; 'Source': 'growthbook';
} : } :
Type extends EventTypes.FILTERS ? {
'Source': 'Marketplace';
'Filter name': string;
} :
undefined; undefined;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -15,6 +15,19 @@ export const withName: AddressParam = { ...@@ -15,6 +15,19 @@ export const withName: AddressParam = {
private_tags: [], private_tags: [],
watchlist_names: [], watchlist_names: [],
public_tags: [], public_tags: [],
ens_domain_name: null,
};
export const withEns: AddressParam = {
hash: hash,
implementation_name: null,
is_contract: false,
is_verified: null,
name: 'ArianeeStore',
private_tags: [],
watchlist_names: [],
public_tags: [],
ens_domain_name: 'kitty.kitty.kitty.cat.eth',
}; };
export const withoutName: AddressParam = { export const withoutName: AddressParam = {
...@@ -26,6 +39,7 @@ export const withoutName: AddressParam = { ...@@ -26,6 +39,7 @@ export const withoutName: AddressParam = {
private_tags: [], private_tags: [],
watchlist_names: [], watchlist_names: [],
public_tags: [], public_tags: [],
ens_domain_name: null,
}; };
export const token: Address = { export const token: Address = {
...@@ -56,6 +70,7 @@ export const token: Address = { ...@@ -56,6 +70,7 @@ export const token: Address = {
has_token_transfers: true, has_token_transfers: true,
has_tokens: true, has_tokens: true,
has_validated_blocks: false, has_validated_blocks: false,
ens_domain_name: null,
}; };
export const contract: Address = { export const contract: Address = {
...@@ -86,6 +101,7 @@ export const contract: Address = { ...@@ -86,6 +101,7 @@ export const contract: Address = {
token: null, token: null,
watchlist_names: [ watchlistName ], watchlist_names: [ watchlistName ],
watchlist_address_id: 42, watchlist_address_id: 42,
ens_domain_name: null,
}; };
export const validator: Address = { export const validator: Address = {
...@@ -116,4 +132,5 @@ export const validator: Address = { ...@@ -116,4 +132,5 @@ export const validator: Address = {
token: null, token: null,
watchlist_names: [], watchlist_names: [],
watchlist_address_id: null, watchlist_address_id: null,
ens_domain_name: null,
}; };
...@@ -22,6 +22,7 @@ export const base: Block = { ...@@ -22,6 +22,7 @@ export const base: Block = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
nonce: '0x0000000000000000', nonce: '0x0000000000000000',
parent_hash: '0x44125f0eb36a9d942e0c23bb4e8117f7ba86a9537a69b59c0025986ed2b7500f', parent_hash: '0x44125f0eb36a9d942e0c23bb4e8117f7ba86a9537a69b59c0025986ed2b7500f',
...@@ -71,6 +72,7 @@ export const genesis: Block = { ...@@ -71,6 +72,7 @@ export const genesis: Block = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: 'kitty.kitty.cat.eth',
}, },
nonce: '0x0000000000000000', nonce: '0x0000000000000000',
parent_hash: '0x0000000000000000000000000000000000000000000000000000000000000000', parent_hash: '0x0000000000000000000000000000000000000000000000000000000000000000',
...@@ -99,6 +101,7 @@ export const base2: Block = { ...@@ -99,6 +101,7 @@ export const base2: Block = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
timestamp: '2022-11-11T11:46:05Z', timestamp: '2022-11-11T11:46:05Z',
tx_count: 253, tx_count: 253,
......
...@@ -9,7 +9,7 @@ export const read: Array<SmartContractReadMethod> = [ ...@@ -9,7 +9,7 @@ export const read: Array<SmartContractReadMethod> = [
{ {
constant: true, constant: true,
inputs: [ inputs: [
{ internalType: 'address', name: '', type: 'address' }, { internalType: 'address', name: 'wallet', type: 'address' },
], ],
method_id: '70a08231', method_id: '70a08231',
name: 'FLASHLOAN_PREMIUM_TOTAL', name: 'FLASHLOAN_PREMIUM_TOTAL',
......
...@@ -10,6 +10,7 @@ export const contract1: VerifiedContract = { ...@@ -10,6 +10,7 @@ export const contract1: VerifiedContract = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
coin_balance: '2346534676900000008', coin_balance: '2346534676900000008',
compiler_version: 'v0.8.17+commit.8df45f5f', compiler_version: 'v0.8.17+commit.8df45f5f',
...@@ -31,6 +32,7 @@ export const contract2: VerifiedContract = { ...@@ -31,6 +32,7 @@ export const contract2: VerifiedContract = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
coin_balance: '9078234570352343999', coin_balance: '9078234570352343999',
compiler_version: 'v0.3.1+commit.0463ea4c', compiler_version: 'v0.3.1+commit.0463ea4c',
......
import type { EnsDomainDetailed } from 'types/api/ens';
const domainTokenA = {
id: '97352314626701792030827861137068748433918254309635329404916858191911576754327',
contract_hash: '0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85',
type: 'NATIVE_DOMAIN_TOKEN' as const,
};
const domainTokenB = {
id: '423546333',
contract_hash: '0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea86',
type: 'WRAPPED_DOMAIN_TOKEN' as const,
};
export const ensDomainA: EnsDomainDetailed = {
id: '0xb140bf9645e54f02ed3c1bcc225566b515a98d1688f10494a5c3bc5b447936a7',
tokens: [
domainTokenA,
domainTokenB,
],
name: 'cat.eth',
registrant: {
hash: '0x114d4603199df73e7d157787f8778e21fcd13066',
},
resolved_address: {
hash: '0xfe6ab8a0dafe7d41adf247c210451c264155c9b0',
},
owner: {
hash: '0x114d4603199df73e7d157787f8778e21fcd13066',
},
wrapped_owner: null,
registration_date: '2021-06-27T13:34:44.000Z',
expiry_date: '2025-03-01T14:20:24.000Z',
other_addresses: {
ETH: 'fe6ab8a0dafe7d41adf247c210451c264155c9b0',
GNO: 'DDAfbb505ad214D7b80b1f830fcCc89B60fb7A83',
NEAR: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.factory.bridge.near',
},
};
export const ensDomainB: EnsDomainDetailed = {
id: '0x632ac7bec8e883416b371b36beaa822f4784208c99d063ee030020e2bd09b885',
tokens: [ domainTokenA ],
name: 'kitty.kitty.kitty.cat.eth',
resolved_address: null,
registrant: {
hash: '0x114d4603199df73e7d157787f8778e21fcd13066',
},
owner: {
hash: '0x114d4603199df73e7d157787f8778e21fcd13066',
},
wrapped_owner: null,
registration_date: '2023-08-13T13:01:12.000Z',
expiry_date: null,
other_addresses: {},
};
export const ensDomainC: EnsDomainDetailed = {
id: '0xdb7f351de6d93bda077a9211bdc49f249326d87932e4787d109b0262e9d189ad',
tokens: [ domainTokenA ],
name: 'duck.duck.eth',
registrant: {
hash: '0x114d4603199df73e7d157787f8778e21fcd13066',
},
resolved_address: {
hash: '0xfe6ab8a0dafe7d41adf247c210451c264155c9b0',
},
owner: {
hash: '0x114d4603199df73e7d157787f8778e21fcd13066',
},
wrapped_owner: null,
registration_date: '2022-04-24T07:34:44.000Z',
expiry_date: '2022-11-01T13:10:36.000Z',
other_addresses: {},
};
export const ensDomainD: EnsDomainDetailed = {
id: '0xdb7f351de6d93bda077a9211bdc49f249326d87932e4787d109b0262e9d189ae',
tokens: [ domainTokenA ],
name: '🦆.duck.eth',
registrant: {
hash: '0x114d4603199df73e7d157787f8778e21fcd13066',
},
resolved_address: {
hash: '0x114d4603199df73e7d157787f8778e21fcd13066',
},
owner: null,
wrapped_owner: null,
registration_date: '2022-04-24T07:34:44.000Z',
expiry_date: '2027-09-23T13:10:36.000Z',
other_addresses: {},
};
import type { EnsDomainEvent } from 'types/api/ens';
export const ensDomainEventA: EnsDomainEvent = {
transaction_hash: '0x86c66b9fad66e4f20d42a6eed4fe12a0f48a274786fd85e9d4aa6c60e84b5874',
timestamp: '2021-06-27T13:34:44.000000Z',
from_address: {
hash: '0xaa96a50a2f67111262fe24576bd85bb56ec65016',
},
action: '0xf7a16963',
};
export const ensDomainEventB = {
transaction_hash: '0x150bf7d5cd42457dd9c799ddd9d4bf6c30c703e1954a88c6d4b668b23fe0fbf8',
timestamp: '2022-11-02T14:20:24.000000Z',
from_address: {
hash: '0xfe6ab8a0dafe7d41adf247c210451c264155c9b0',
},
action: 'register',
};
...@@ -47,6 +47,15 @@ export const block2: SearchResultBlock = { ...@@ -47,6 +47,15 @@ export const block2: SearchResultBlock = {
url: '/block/0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd2', url: '/block/0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd2',
}; };
export const block3: SearchResultBlock = {
block_hash: '0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd3',
block_number: 8198536,
block_type: 'uncle',
type: 'block' as const,
timestamp: '2022-12-11T18:11:11Z',
url: '/block/0x1af31d7535dded06bab9a88eb40ee2f8d0529a60ab3b8a7be2ba69b008cacbd3',
};
export const address1: SearchResultAddressOrContract = { export const address1: SearchResultAddressOrContract = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a', address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
name: null, name: null,
...@@ -55,6 +64,20 @@ export const address1: SearchResultAddressOrContract = { ...@@ -55,6 +64,20 @@ export const address1: SearchResultAddressOrContract = {
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a', url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
}; };
export const address2: SearchResultAddressOrContract = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131b',
name: null,
type: 'address' as const,
is_smart_contract_verified: false,
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131b',
ens_info: {
address_hash: '0x1234567890123456789012345678901234567890',
expiry_date: '2022-12-11T17:55:20Z',
name: 'utko.eth',
names_count: 1,
},
};
export const contract1: SearchResultAddressOrContract = { export const contract1: SearchResultAddressOrContract = {
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a', address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
name: 'Unknown contract in this network', name: 'Unknown contract in this network',
......
...@@ -3,11 +3,26 @@ import type { HomeStats } from 'types/api/stats'; ...@@ -3,11 +3,26 @@ import type { HomeStats } from 'types/api/stats';
export const base: HomeStats = { export const base: HomeStats = {
average_block_time: 6212.0, average_block_time: 6212.0,
coin_price: '0.00199678', coin_price: '0.00199678',
coin_price_change_percentage: -7.42,
gas_prices: { gas_prices: {
average: 48.0, average: {
fast: 67.5, fiat_price: '1.01',
slow: 48.0, price: 20.41,
time: 12283,
},
fast: {
fiat_price: '1.26',
price: 25.47,
time: 9321,
},
slow: {
fiat_price: '0.97',
price: 19.55,
time: 24543,
},
}, },
gas_price_updated_at: '2022-11-11T11:09:49.051171Z',
gas_prices_update_in: 300000,
gas_used_today: '4108680603', gas_used_today: '4108680603',
market_cap: '330809.96443288102524', market_cap: '330809.96443288102524',
network_utilization_percentage: 1.55372064, network_utilization_percentage: 1.55372064,
......
...@@ -10,6 +10,7 @@ export const erc20: TokenTransfer = { ...@@ -10,6 +10,7 @@ export const erc20: TokenTransfer = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
to: { to: {
hash: '0x7d20a8D54F955b4483A66aB335635ab66e151c51', hash: '0x7d20a8D54F955b4483A66aB335635ab66e151c51',
...@@ -20,6 +21,7 @@ export const erc20: TokenTransfer = { ...@@ -20,6 +21,7 @@ export const erc20: TokenTransfer = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: 'kitty.kitty.cat.eth',
}, },
token: { token: {
address: '0x55d536e4d6c1993d8ef2e2a4ef77f02088419420', address: '0x55d536e4d6c1993d8ef2e2a4ef77f02088419420',
...@@ -55,6 +57,7 @@ export const erc721: TokenTransfer = { ...@@ -55,6 +57,7 @@ export const erc721: TokenTransfer = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: 'kitty.kitty.cat.eth',
}, },
to: { to: {
hash: '0x47eE48AEBc4ab9Ed908b805b8c8dAAa71B31Db1A', hash: '0x47eE48AEBc4ab9Ed908b805b8c8dAAa71B31Db1A',
...@@ -65,6 +68,7 @@ export const erc721: TokenTransfer = { ...@@ -65,6 +68,7 @@ export const erc721: TokenTransfer = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
token: { token: {
address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29', address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
...@@ -99,6 +103,7 @@ export const erc1155A: TokenTransfer = { ...@@ -99,6 +103,7 @@ export const erc1155A: TokenTransfer = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
to: { to: {
hash: '0xBb36c792B9B45Aaf8b848A1392B0d6559202729E', hash: '0xBb36c792B9B45Aaf8b848A1392B0d6559202729E',
...@@ -109,6 +114,7 @@ export const erc1155A: TokenTransfer = { ...@@ -109,6 +114,7 @@ export const erc1155A: TokenTransfer = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: 'kitty.kitty.cat.eth',
}, },
token: { token: {
address: '0xF56b7693E4212C584de4a83117f805B8E89224CB', address: '0xF56b7693E4212C584de4a83117f805B8E89224CB',
......
...@@ -13,6 +13,7 @@ export const base: InternalTransaction = { ...@@ -13,6 +13,7 @@ export const base: InternalTransaction = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
gas_limit: '757586', gas_limit: '757586',
index: 1, index: 1,
...@@ -27,6 +28,7 @@ export const base: InternalTransaction = { ...@@ -27,6 +28,7 @@ export const base: InternalTransaction = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
transaction_hash: '0xe9e27dfeb183066e26cfe556f74b7219b08df6951e25d14003d4fc7af8bbff61', transaction_hash: '0xe9e27dfeb183066e26cfe556f74b7219b08df6951e25d14003d4fc7af8bbff61',
type: 'call', type: 'call',
...@@ -61,6 +63,7 @@ export const withContractCreated: InternalTransaction = { ...@@ -61,6 +63,7 @@ export const withContractCreated: InternalTransaction = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
value: '1420000000000000000', value: '1420000000000000000',
gas_limit: '5433', gas_limit: '5433',
......
...@@ -10,6 +10,7 @@ export const mintToken: TxStateChange = { ...@@ -10,6 +10,7 @@ export const mintToken: TxStateChange = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
balance_after: null, balance_after: null,
balance_before: null, balance_before: null,
...@@ -47,6 +48,7 @@ export const receiveMintedToken: TxStateChange = { ...@@ -47,6 +48,7 @@ export const receiveMintedToken: TxStateChange = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
balance_after: '1', balance_after: '1',
balance_before: '0', balance_before: '0',
...@@ -84,6 +86,7 @@ export const transfer1155Token: TxStateChange = { ...@@ -84,6 +86,7 @@ export const transfer1155Token: TxStateChange = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
balance_after: '1', balance_after: '1',
balance_before: '0', balance_before: '0',
...@@ -115,6 +118,7 @@ export const receiveCoin: TxStateChange = { ...@@ -115,6 +118,7 @@ export const receiveCoin: TxStateChange = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
balance_after: '443787514723917012805', balance_after: '443787514723917012805',
balance_before: '443787484997510408745', balance_before: '443787484997510408745',
...@@ -134,6 +138,7 @@ export const sendCoin: TxStateChange = { ...@@ -134,6 +138,7 @@ export const sendCoin: TxStateChange = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
balance_after: '828282622733717191', balance_after: '828282622733717191',
balance_before: '832127467556437753', balance_before: '832127467556437753',
......
...@@ -29,6 +29,7 @@ export const base: Transaction = { ...@@ -29,6 +29,7 @@ export const base: Transaction = {
private_tags: [ ], private_tags: [ ],
public_tags: [ publicTag ], public_tags: [ publicTag ],
watchlist_names: [], watchlist_names: [],
ens_domain_name: 'kitty.kitty.cat.eth',
}, },
gas_limit: '800000', gas_limit: '800000',
gas_price: '48000000000', gas_price: '48000000000',
...@@ -54,6 +55,7 @@ export const base: Transaction = { ...@@ -54,6 +55,7 @@ export const base: Transaction = {
private_tags: [ privateTag ], private_tags: [ privateTag ],
public_tags: [], public_tags: [],
watchlist_names: [ watchlistName ], watchlist_names: [ watchlistName ],
ens_domain_name: null,
}, },
token_transfers: [], token_transfers: [],
token_transfers_overflow: false, token_transfers_overflow: false,
...@@ -97,6 +99,7 @@ export const withContractCreation: Transaction = { ...@@ -97,6 +99,7 @@ export const withContractCreation: Transaction = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
tx_types: [ tx_types: [
'contract_creation', 'contract_creation',
...@@ -115,6 +118,7 @@ export const withTokenTransfer: Transaction = { ...@@ -115,6 +118,7 @@ export const withTokenTransfer: Transaction = {
private_tags: [ privateTag ], private_tags: [ privateTag ],
public_tags: [], public_tags: [],
watchlist_names: [ watchlistName ], watchlist_names: [ watchlistName ],
ens_domain_name: null,
}, },
token_transfers: [ token_transfers: [
tokenTransferMock.erc20, tokenTransferMock.erc20,
...@@ -168,6 +172,7 @@ export const withRawRevertReason: Transaction = { ...@@ -168,6 +172,7 @@ export const withRawRevertReason: Transaction = {
private_tags: [ ], private_tags: [ ],
public_tags: [], public_tags: [],
watchlist_names: [ ], watchlist_names: [ ],
ens_domain_name: null,
}, },
}; };
...@@ -283,6 +288,7 @@ export const stabilityTx: Transaction = { ...@@ -283,6 +288,7 @@ export const stabilityTx: Transaction = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
dapp_fee: '34381250000000', dapp_fee: '34381250000000',
token: { token: {
...@@ -307,6 +313,7 @@ export const stabilityTx: Transaction = { ...@@ -307,6 +313,7 @@ export const stabilityTx: Transaction = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
validator_fee: '34381250000000', validator_fee: '34381250000000',
}, },
......
...@@ -33,6 +33,7 @@ export const txInterpretation: TxInterpretationResponse = { ...@@ -33,6 +33,7 @@ export const txInterpretation: TxInterpretationResponse = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
}, },
timestamp: { timestamp: {
......
...@@ -10,6 +10,7 @@ export type Props = { ...@@ -10,6 +10,7 @@ export type Props = {
hash: string; hash: string;
number: string; number: string;
q: string; q: string;
name: string;
} }
export const base: GetServerSideProps<Props> = async({ req, query }) => { export const base: GetServerSideProps<Props> = async({ req, query }) => {
...@@ -22,6 +23,7 @@ export const base: GetServerSideProps<Props> = async({ req, query }) => { ...@@ -22,6 +23,7 @@ export const base: GetServerSideProps<Props> = async({ req, query }) => {
height_or_hash: query.height_or_hash?.toString() || '', height_or_hash: query.height_or_hash?.toString() || '',
number: query.number?.toString() || '', number: query.number?.toString() || '',
q: query.q?.toString() || '', q: query.q?.toString() || '',
name: query.name?.toString() || '',
}, },
}; };
}; };
...@@ -126,6 +128,16 @@ export const suave: GetServerSideProps<Props> = async(context) => { ...@@ -126,6 +128,16 @@ export const suave: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const nameService: GetServerSideProps<Props> = async(context) => {
if (!config.features.nameService.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
export const accounts: GetServerSideProps<Props> = async(context) => { export const accounts: GetServerSideProps<Props> = async(context) => {
if (config.UI.views.address.hiddenViews?.top_accounts) { if (config.UI.views.address.hiddenViews?.top_accounts) {
return { return {
......
...@@ -37,6 +37,8 @@ declare module "nextjs-routes" { ...@@ -37,6 +37,8 @@ declare module "nextjs-routes" {
| StaticRoute<"/l2-txn-batches"> | StaticRoute<"/l2-txn-batches">
| StaticRoute<"/l2-withdrawals"> | StaticRoute<"/l2-withdrawals">
| StaticRoute<"/login"> | StaticRoute<"/login">
| DynamicRoute<"/name-domains/[name]", { "name": string }>
| StaticRoute<"/name-domains">
| StaticRoute<"/search-results"> | StaticRoute<"/search-results">
| StaticRoute<"/stats"> | StaticRoute<"/stats">
| DynamicRoute<"/token/[hash]", { "hash": string }> | DynamicRoute<"/token/[hash]", { "hash": string }>
......
import type { IncomingMessage } from 'http'; import type { IncomingMessage } from 'http';
import _pick from 'lodash/pick';
import type { NextApiRequest } from 'next'; import type { NextApiRequest } from 'next';
import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import type { NextApiRequestCookies } from 'next/dist/server/api-utils';
import type { RequestInit, Response } from 'node-fetch'; import type { RequestInit, Response } from 'node-fetch';
...@@ -14,16 +15,18 @@ export default function fetchFactory( ...@@ -14,16 +15,18 @@ export default function fetchFactory(
// first arg can be only a string // first arg can be only a string
// FIXME migrate to RequestInfo later if needed // FIXME migrate to RequestInfo later if needed
return function fetch(url: string, init?: RequestInit): Promise<Response> { return function fetch(url: string, init?: RequestInit): Promise<Response> {
const csrfToken = _req.headers['x-csrf-token'];
const authToken = _req.headers['Authorization'];
const apiToken = _req.cookies[cookies.NAMES.API_TOKEN]; const apiToken = _req.cookies[cookies.NAMES.API_TOKEN];
const headers = { const headers = {
accept: _req.headers['accept'] || 'application/json', accept: _req.headers['accept'] || 'application/json',
'content-type': _req.headers['content-type'] || 'application/json', 'content-type': _req.headers['content-type'] || 'application/json',
cookie: apiToken ? `${ cookies.NAMES.API_TOKEN }=${ apiToken }` : '', cookie: apiToken ? `${ cookies.NAMES.API_TOKEN }=${ apiToken }` : '',
...(csrfToken ? { 'x-csrf-token': String(csrfToken) } : {}), ..._pick(_req.headers, [
...(authToken ? { Authorization: String(authToken) } : {}), 'x-csrf-token',
'Authorization',
// feature flags
'updated-gas-oracle',
]) as Record<string, string | undefined>,
}; };
httpLogger.logger.info({ httpLogger.logger.info({
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
const NameDomain = dynamic(() => import('ui/pages/NameDomain'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/name-domains/[name]" query={ props }>
<NameDomain/>
</PageNextJs>
);
};
export default Page;
export { nameService as getServerSideProps } from 'nextjs/getServerSideProps';
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const NameDomains = dynamic(() => import('ui/pages/NameDomains'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/name-domains">
<NameDomains/>
</PageNextJs>
);
};
export default Page;
export { nameService as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -30,6 +30,7 @@ const defaultAppContext = { ...@@ -30,6 +30,7 @@ const defaultAppContext = {
hash: '', hash: '',
number: '', number: '',
q: '', q: '',
name: '',
}, },
}; };
......
...@@ -31,6 +31,8 @@ ...@@ -31,6 +31,8 @@
| "email-sent" | "email-sent"
| "email" | "email"
| "empty_search_result" | "empty_search_result"
| "ENS_slim"
| "ENS"
| "error-pages/404" | "error-pages/404"
| "error-pages/422" | "error-pages/422"
| "error-pages/429" | "error-pages/429"
......
import type { EnsDomainDetailed, EnsDomainEvent } from 'types/api/ens';
import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams';
import { TX_HASH } from './tx';
export const ENS_DOMAIN: EnsDomainDetailed = {
id: '0x126d74db13895f8d3a1d362410212731d1e1d9be8add83e388385f93d84c8c84',
name: 'kitty.cat.eth',
tokens: [
{
id: '973523146267017920308',
contract_hash: ADDRESS_HASH,
type: 'NATIVE_DOMAIN_TOKEN',
},
],
owner: ADDRESS_PARAMS,
wrapped_owner: null,
resolved_address: ADDRESS_PARAMS,
registrant: ADDRESS_PARAMS,
registration_date: '2023-12-20T01:29:12.000Z',
expiry_date: '2099-01-02T01:29:12.000Z',
other_addresses: {
ETH: ADDRESS_HASH,
},
};
export const ENS_DOMAIN_EVENT: EnsDomainEvent = {
transaction_hash: TX_HASH,
timestamp: '2022-06-06T08:43:15.000000Z',
from_address: ADDRESS_PARAMS,
action: '0xf7a16963',
};
...@@ -41,6 +41,7 @@ export const ADDRESS_INFO: Address = { ...@@ -41,6 +41,7 @@ export const ADDRESS_INFO: Address = {
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
watchlist_address_id: null, watchlist_address_id: null,
ens_domain_name: null,
}; };
export const ADDRESS_COUNTERS: AddressCounters = { export const ADDRESS_COUNTERS: AddressCounters = {
...@@ -71,6 +72,7 @@ export const TOP_ADDRESS: AddressesItem = { ...@@ -71,6 +72,7 @@ export const TOP_ADDRESS: AddressesItem = {
private_tags: [], private_tags: [],
public_tags: [ ], public_tags: [ ],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}; };
export const ADDRESS_COIN_BALANCE: AddressCoinBalanceHistoryItem = { export const ADDRESS_COIN_BALANCE: AddressCoinBalanceHistoryItem = {
......
...@@ -11,4 +11,5 @@ export const ADDRESS_PARAMS: AddressParam = { ...@@ -11,4 +11,5 @@ export const ADDRESS_PARAMS: AddressParam = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}; };
...@@ -3,11 +3,26 @@ import type { Counter, HomeStats, StatsChartsSection } from 'types/api/stats'; ...@@ -3,11 +3,26 @@ import type { Counter, HomeStats, StatsChartsSection } from 'types/api/stats';
export const HOMEPAGE_STATS: HomeStats = { export const HOMEPAGE_STATS: HomeStats = {
average_block_time: 14346, average_block_time: 14346,
coin_price: '1807.68', coin_price: '1807.68',
coin_price_change_percentage: 42,
gas_prices: { gas_prices: {
average: 0.1, average: {
fast: 0.11, fiat_price: '1.01',
slow: 0.1, price: 20.41,
time: 12283,
},
fast: {
fiat_price: '1.26',
price: 25.47,
time: 9321,
},
slow: {
fiat_price: '0.97',
price: 19.55,
time: 24543,
},
}, },
gas_price_updated_at: '2022-11-11T11:09:49.051171Z',
gas_prices_update_in: 300000,
gas_used_today: '0', gas_used_today: '0',
market_cap: '0', market_cap: '0',
network_utilization_percentage: 22.56, network_utilization_percentage: 22.56,
......
...@@ -13,7 +13,7 @@ function getBg(props: StyleFunctionProps) { ...@@ -13,7 +13,7 @@ function getBg(props: StyleFunctionProps) {
const { theme, colorScheme: c } = props; const { theme, colorScheme: c } = props;
const darkBg = transparentize(`${ c }.200`, 0.16)(theme); const darkBg = transparentize(`${ c }.200`, 0.16)(theme);
return { return {
light: `colors.${ c }.100`, light: `colors.${ c }.${ c === 'red' ? '50' : '100' }`,
dark: darkBg, dark: darkBg,
}; };
} }
......
...@@ -12,6 +12,7 @@ export interface Address extends UserTags { ...@@ -12,6 +12,7 @@ export interface Address extends UserTags {
creator_address_hash: string | null; creator_address_hash: string | null;
creation_tx_hash: string | null; creation_tx_hash: string | null;
exchange_rate: string | null; exchange_rate: string | null;
ens_domain_name: string | null;
// TODO: if we are happy with tabs-counters method, should we delete has_something fields? // TODO: if we are happy with tabs-counters method, should we delete has_something fields?
has_beacon_chain_withdrawals?: boolean; has_beacon_chain_withdrawals?: boolean;
has_custom_methods_read: boolean; has_custom_methods_read: boolean;
......
...@@ -21,4 +21,5 @@ export interface AddressParam extends UserTags { ...@@ -21,4 +21,5 @@ export interface AddressParam extends UserTags {
name: string | null; name: string | null;
is_contract: boolean; is_contract: boolean;
is_verified: boolean | null; is_verified: boolean | null;
ens_domain_name: string | null;
} }
import type { Abi } from 'abitype'; import type { Abi, AbiType } from 'abitype';
export type SmartContractMethodArgType = 'address' | 'uint256' | 'bool' | 'string' | 'bytes' | 'bytes32' | 'bytes32[]'; export type SmartContractMethodArgType = AbiType;
export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable'; export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
export interface SmartContract { export interface SmartContract {
...@@ -88,6 +88,8 @@ export interface SmartContractMethodInput { ...@@ -88,6 +88,8 @@ export interface SmartContractMethodInput {
internalType?: SmartContractMethodArgType; internalType?: SmartContractMethodArgType;
name: string; name: string;
type: SmartContractMethodArgType; type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>;
fieldType?: 'native_coin';
} }
export interface SmartContractMethodOutput extends SmartContractMethodInput { export interface SmartContractMethodOutput extends SmartContractMethodInput {
......
export interface EnsDomain {
id: string;
name: string;
resolved_address: {
hash: string;
} | null;
owner: {
hash: string;
} | null;
wrapped_owner: {
hash: string;
} | null;
registration_date?: string;
expiry_date: string | null;
}
export interface EnsDomainDetailed extends EnsDomain {
tokens: Array<{ id: string; contract_hash: string; type: 'NATIVE_DOMAIN_TOKEN' | 'WRAPPED_DOMAIN_TOKEN' }>;
registrant: {
hash: string;
} | null;
other_addresses: Record<string, string>;
}
export interface EnsDomainEvent {
transaction_hash: string;
timestamp: string;
from_address: {
hash: string;
} | null;
action?: string;
}
export interface EnsAddressLookupResponse {
items: Array<EnsDomain>;
next_page_params: {
page_token: string;
page_size: number;
} | null;
}
export interface EnsDomainEventsResponse {
items: Array<EnsDomainEvent>;
}
export interface EnsDomainLookupResponse {
items: Array<EnsDomain>;
next_page_params: {
page_token: string;
page_size: number;
} | null;
}
export interface EnsAddressLookupFilters {
address: string | null;
resolved_to: boolean;
owned_by: boolean;
only_active: boolean;
}
export interface EnsDomainLookupFilters {
name: string | null;
only_active: boolean;
}
export interface EnsLookupSorting {
sort: 'registration_date';
order: 'ASC' | 'DESC';
}
export type EnsDomainLookupFiltersOptions = Array<'resolved_to' | 'owned_by' | 'with_inactive'>;
...@@ -23,6 +23,12 @@ export interface SearchResultAddressOrContract { ...@@ -23,6 +23,12 @@ export interface SearchResultAddressOrContract {
address: string; address: string;
is_smart_contract_verified: boolean; is_smart_contract_verified: boolean;
url?: string; // not used by the frontend, we build the url ourselves url?: string; // not used by the frontend, we build the url ourselves
ens_info?: {
address_hash: string;
expiry_date?: string;
name: string;
names_count: number;
};
} }
export interface SearchResultLabel { export interface SearchResultLabel {
...@@ -35,7 +41,7 @@ export interface SearchResultLabel { ...@@ -35,7 +41,7 @@ export interface SearchResultLabel {
export interface SearchResultBlock { export interface SearchResultBlock {
type: 'block'; type: 'block';
block_type?: 'block' | 'reorg'; block_type?: 'block' | 'reorg' | 'uncle';
block_number: number | string; block_number: number | string;
block_hash: string; block_hash: string;
timestamp: string; timestamp: string;
......
...@@ -4,10 +4,13 @@ export type HomeStats = { ...@@ -4,10 +4,13 @@ export type HomeStats = {
total_transactions: string; total_transactions: string;
average_block_time: number; average_block_time: number;
coin_price: string | null; coin_price: string | null;
coin_price_change_percentage: number | null; // e.g -6.22
total_gas_used: string; total_gas_used: string;
transactions_today: string; transactions_today: string;
gas_used_today: string; gas_used_today: string;
gas_prices: GasPrices | null; gas_prices: GasPrices | null;
gas_price_updated_at: string | null;
gas_prices_update_in: number;
static_gas_price: string | null; static_gas_price: string | null;
market_cap: string; market_cap: string;
network_utilization_percentage: number; network_utilization_percentage: number;
...@@ -16,9 +19,15 @@ export type HomeStats = { ...@@ -16,9 +19,15 @@ export type HomeStats = {
} }
export type GasPrices = { export type GasPrices = {
average: number | null; average: GasPriceInfo | null;
fast: number | null; fast: GasPriceInfo | null;
slow: number | null; slow: GasPriceInfo | null;
}
export interface GasPriceInfo {
fiat_price: string | null;
price: number | null;
time: number | null;
} }
export type Counters = { export type Counters = {
......
...@@ -14,6 +14,12 @@ export type TransactionRevertReason = { ...@@ -14,6 +14,12 @@ export type TransactionRevertReason = {
type WrappedTransactionFields = 'decoded_input' | 'fee' | 'gas_limit' | 'gas_price' | 'hash' | 'max_fee_per_gas' | type WrappedTransactionFields = 'decoded_input' | 'fee' | 'gas_limit' | 'gas_price' | 'hash' | 'max_fee_per_gas' |
'max_priority_fee_per_gas' | 'method' | 'nonce' | 'raw_input' | 'to' | 'type' | 'value'; 'max_priority_fee_per_gas' | 'method' | 'nonce' | 'raw_input' | 'to' | 'type' | 'value';
export interface OpWithdrawal {
l1_transaction_hash: string;
nonce: number;
status: L2WithdrawalStatus;
}
export type Transaction = { export type Transaction = {
to: AddressParam | null; to: AddressParam | null;
created_contract: AddressParam | null; created_contract: AddressParam | null;
...@@ -54,8 +60,7 @@ export type Transaction = { ...@@ -54,8 +60,7 @@ export type Transaction = {
l1_gas_used?: string; l1_gas_used?: string;
has_error_in_internal_txs: boolean | null; has_error_in_internal_txs: boolean | null;
// optimism fields // optimism fields
op_withdrawal_status?: L2WithdrawalStatus; op_withdrawals?: Array<OpWithdrawal>;
op_l1_transaction_hash?: string;
// SUAVE fields // SUAVE fields
execution_node?: AddressParam | null; execution_node?: AddressParam | null;
allowed_peekers?: Array<string>; allowed_peekers?: Array<string>;
......
import { Box, Button, chakra, Flex } from '@chakra-ui/react'; import { Box, Button, chakra, Flex } from '@chakra-ui/react';
import _fromPairs from 'lodash/fromPairs';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import type { MethodFormFields, ContractMethodCallResult } from './types'; import type { MethodFormFields, ContractMethodCallResult } from './types';
import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract'; import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import ContractMethodField from './ContractMethodField'; import ContractMethodCallableRow from './ContractMethodCallableRow';
import { formatFieldValues, transformFieldsToArgs } from './utils';
interface ResultComponentProps<T extends SmartContractMethod> { interface ResultComponentProps<T extends SmartContractMethod> {
item: T; item: T;
...@@ -25,42 +26,9 @@ interface Props<T extends SmartContractMethod> { ...@@ -25,42 +26,9 @@ interface Props<T extends SmartContractMethod> {
isWrite?: boolean; isWrite?: boolean;
} }
const getFieldName = (name: string | undefined, index: number): string => name || String(index); // groupName%groupIndex:inputName%inputIndex
const getFormFieldName = (input: { index: number; name: string }, group?: { index: number; name: string }) =>
const sortFields = (data: Array<SmartContractMethodInput>) => ([ a ]: [string, string], [ b ]: [string, string]): 1 | -1 | 0 => { `${ group ? `${ group.name }%${ group.index }:` : '' }${ input.name || 'input' }%${ input.index }`;
const fieldNames = data.map(({ name }, index) => getFieldName(name, index));
const indexA = fieldNames.indexOf(a);
const indexB = fieldNames.indexOf(b);
if (indexA > indexB) {
return 1;
}
if (indexA < indexB) {
return -1;
}
return 0;
};
const castFieldValue = (data: Array<SmartContractMethodInput>) => ([ key, value ]: [ string, string ], index: number) => {
if (data[index].type.includes('[')) {
return [ key, parseArrayValue(value) ];
}
return [ key, value ];
};
const parseArrayValue = (value: string) => {
try {
const parsedResult = JSON.parse(value);
if (Array.isArray(parsedResult)) {
return parsedResult;
}
throw new Error('Not an array');
} catch (error) {
return '';
}
};
const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props<T>) => { const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props<T>) => {
...@@ -71,15 +39,16 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -71,15 +39,16 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
return [ return [
...('inputs' in data ? data.inputs : []), ...('inputs' in data ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ { ...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: 'value', name: `Send native ${ config.chain.currency.symbol }`,
type: 'uint256' as const, type: 'uint256' as const,
internalType: 'uint256' as const, internalType: 'uint256' as const,
fieldType: 'native_coin' as const,
} ] : []), } ] : []),
]; ];
}, [ data ]); }, [ data ]);
const { control, handleSubmit, setValue, getValues } = useForm<MethodFormFields>({ const formApi = useForm<MethodFormFields>({
defaultValues: _fromPairs(inputs.map(({ name }, index) => [ getFieldName(name, index), '' ])), mode: 'onBlur',
}); });
const handleTxSettle = React.useCallback(() => { const handleTxSettle = React.useCallback(() => {
...@@ -91,10 +60,8 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -91,10 +60,8 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
}, [ result ]); }, [ result ]);
const onFormSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => { const onFormSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => {
const args = Object.entries(formData) const formattedData = formatFieldValues(formData, inputs);
.sort(sortFields(inputs)) const args = transformFieldsToArgs(formattedData);
.map(castFieldValue(inputs))
.map(([ , value ]) => value);
setResult(undefined); setResult(undefined);
setLoading(true); setLoading(true);
...@@ -117,46 +84,87 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -117,46 +84,87 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
return ( return (
<Box> <Box>
<chakra.form <FormProvider { ...formApi }>
noValidate <chakra.form
display="flex" noValidate
columnGap={ 3 } onSubmit={ formApi.handleSubmit(onFormSubmit) }
flexDir={{ base: 'column', lg: 'row' }} onChange={ handleFormChange }
rowGap={ 2 }
alignItems={{ base: 'flex-start', lg: 'center' }}
onSubmit={ handleSubmit(onFormSubmit) }
flexWrap="wrap"
onChange={ handleFormChange }
>
{ inputs.map(({ type, name }, index) => {
const fieldName = getFieldName(name, index);
return (
<ContractMethodField
key={ fieldName }
name={ fieldName }
valueType={ type }
placeholder={ `${ name }(${ type })` }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isLoading }
onChange={ handleFormChange }
/>
);
}) }
<Button
isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Query' }
variant="outline"
size="sm"
flexShrink={ 0 }
type="submit"
> >
{ isWrite ? 'Write' : 'Query' } <Flex
</Button> flexDir="column"
</chakra.form> rowGap={ 3 }
mb={ 3 }
_empty={{ display: 'none' }}
>
{ inputs.map((input, index) => {
const fieldName = getFormFieldName({ name: input.name, index });
if (input.type === 'tuple' && input.components) {
return (
<React.Fragment key={ fieldName }>
{ index !== 0 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
<Box
fontWeight={ 500 }
lineHeight="20px"
py={{ lg: '6px' }}
fontSize="sm"
wordBreak="break-word"
>
{ input.name } ({ input.type })
</Box>
{ input.components.map((component, componentIndex) => {
const fieldName = getFormFieldName(
{ name: component.name, index: componentIndex },
{ name: input.name, index },
);
return (
<ContractMethodCallableRow
key={ fieldName }
fieldName={ fieldName }
argName={ component.name }
argType={ component.type }
isDisabled={ isLoading }
onChange={ handleFormChange }
isGrouped
/>
);
}) }
{ index !== inputs.length - 1 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
</React.Fragment>
);
}
return (
<ContractMethodCallableRow
key={ fieldName }
fieldName={ fieldName }
fieldType={ input.fieldType }
argName={ input.name }
argType={ input.type }
isDisabled={ isLoading }
isOptional={ input.fieldType === 'native_coin' && inputs.length > 1 }
onChange={ handleFormChange }
/>
);
}) }
</Flex>
<Button
isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Read' }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
type="submit"
>
{ isWrite ? 'Write' : 'Read' }
</Button>
</chakra.form>
</FormProvider>
{ 'outputs' in data && !isWrite && data.outputs.length > 0 && ( { 'outputs' in data && !isWrite && data.outputs.length > 0 && (
<Flex mt={ 3 }> <Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/> <IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<p> <p>
{ data.outputs.map(({ type, name }, index) => { { data.outputs.map(({ type, name }, index) => {
......
import { Box, Flex, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { MethodFormFields } from './types';
import type { SmartContractMethodArgType, SmartContractMethodInput } from 'types/api/contract';
import ContractMethodField from './ContractMethodField';
import ContractMethodFieldArray from './ContractMethodFieldArray';
import { ARRAY_REGEXP } from './utils';
interface Props {
fieldName: string;
fieldType?: SmartContractMethodInput['fieldType'];
argName: string;
argType: SmartContractMethodArgType;
onChange: () => void;
isDisabled: boolean;
isGrouped?: boolean;
isOptional?: boolean;
}
const ContractMethodCallableRow = ({ argName, fieldName, fieldType, argType, onChange, isDisabled, isGrouped, isOptional }: Props) => {
const { control, getValues, setValue } = useFormContext<MethodFormFields>();
const arrayTypeMatch = argType.match(ARRAY_REGEXP);
const nativeCoinFieldBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const content = arrayTypeMatch ? (
<ContractMethodFieldArray
name={ fieldName }
argType={ arrayTypeMatch[1] as SmartContractMethodArgType }
size={ Number(arrayTypeMatch[2] || Infinity) }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
onChange={ onChange }
/>
) : (
<ContractMethodField
name={ fieldName }
argType={ argType }
placeholder={ argType }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
isOptional={ isOptional }
onChange={ onChange }
/>
);
const isNativeCoinField = fieldType === 'native_coin';
return (
<Flex
flexDir={{ base: 'column', lg: 'row' }}
columnGap={ 3 }
rowGap={{ base: 2, lg: 0 }}
bgColor={ isNativeCoinField ? nativeCoinFieldBgColor : undefined }
py={ isNativeCoinField ? 1 : undefined }
px={ isNativeCoinField ? '6px' : undefined }
mx={ isNativeCoinField ? '-6px' : undefined }
borderRadius="base"
>
<Box
position="relative"
fontWeight={ 500 }
lineHeight="20px"
py={{ lg: '6px' }}
fontSize="sm"
color={ isGrouped ? 'text_secondary' : 'initial' }
wordBreak="break-word"
w={{ lg: '250px' }}
flexShrink={ 0 }
>
{ argName }{ isOptional ? '' : '*' } ({ argType })
</Box>
{ content }
</Flex>
);
};
export default React.memo(ContractMethodCallableRow);
import { import {
Box,
FormControl, FormControl,
Input, Input,
InputGroup, InputGroup,
InputRightElement, InputRightElement,
useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { Control, ControllerRenderProps, UseFormGetValues, UseFormSetValue } from 'react-hook-form'; import type { Control, ControllerRenderProps, FieldError, UseFormGetValues, UseFormSetValue, UseFormStateReturn } from 'react-hook-form';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import { NumericFormat } from 'react-number-format';
import { isAddress, isHex, getAddress } from 'viem';
import type { MethodFormFields } from './types'; import type { MethodFormFields } from './types';
import type { SmartContractMethodArgType } from 'types/api/contract'; import type { SmartContractMethodArgType } from 'types/api/contract';
...@@ -14,21 +18,25 @@ import type { SmartContractMethodArgType } from 'types/api/contract'; ...@@ -14,21 +18,25 @@ import type { SmartContractMethodArgType } from 'types/api/contract';
import ClearButton from 'ui/shared/ClearButton'; import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldZeroes from './ContractMethodFieldZeroes'; import ContractMethodFieldZeroes from './ContractMethodFieldZeroes';
import { addZeroesAllowed } from './utils'; import { INT_REGEXP, BYTES_REGEXP, getIntBoundaries, formatBooleanValue } from './utils';
interface Props { interface Props {
name: string;
index?: number;
groupName?: string;
placeholder: string;
argType: SmartContractMethodArgType;
control: Control<MethodFormFields>; control: Control<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>; setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>; getValues: UseFormGetValues<MethodFormFields>;
placeholder: string;
name: string;
valueType: SmartContractMethodArgType;
isDisabled: boolean; isDisabled: boolean;
isOptional?: boolean;
onChange: () => void; onChange: () => void;
} }
const ContractMethodField = ({ control, name, valueType, placeholder, setValue, getValues, isDisabled, onChange }: Props) => { const ContractMethodField = ({ control, name, groupName, index, argType, placeholder, setValue, getValues, isDisabled, isOptional, onChange }: Props) => {
const ref = React.useRef<HTMLInputElement>(null); const ref = React.useRef<HTMLInputElement>(null);
const bgColor = useColorModeValue('white', 'black');
const handleClear = React.useCallback(() => { const handleClear = React.useCallback(() => {
setValue(name, ''); setValue(name, '');
...@@ -37,47 +45,147 @@ const ContractMethodField = ({ control, name, valueType, placeholder, setValue, ...@@ -37,47 +45,147 @@ const ContractMethodField = ({ control, name, valueType, placeholder, setValue,
}, [ name, onChange, setValue ]); }, [ name, onChange, setValue ]);
const handleAddZeroesClick = React.useCallback((power: number) => { const handleAddZeroesClick = React.useCallback((power: number) => {
const value = getValues()[name]; const value = groupName && index !== undefined ? getValues()[groupName][index] : getValues()[name];
const zeroes = Array(power).fill('0').join(''); const zeroes = Array(power).fill('0').join('');
const newValue = value ? value + zeroes : '1' + zeroes; const newValue = value ? value + zeroes : '1' + zeroes;
setValue(name, newValue); setValue(name, newValue);
onChange(); onChange();
}, [ getValues, name, onChange, setValue ]); }, [ getValues, groupName, index, name, onChange, setValue ]);
const intMatch = React.useMemo(() => {
const match = argType.match(INT_REGEXP);
if (!match) {
return null;
}
const [ , isUnsigned, power = '256' ] = match;
const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned));
return { isUnsigned, power, min, max };
}, [ argType ]);
const hasZerosControl = addZeroesAllowed(valueType); const bytesMatch = React.useMemo(() => {
return argType.match(BYTES_REGEXP);
}, [ argType ]);
const renderInput = React.useCallback((
{ field, formState }: { field: ControllerRenderProps<MethodFormFields>; formState: UseFormStateReturn<MethodFormFields> },
) => {
const error: FieldError | undefined = index !== undefined && groupName !== undefined ?
(formState.errors[groupName] as unknown as Array<FieldError>)?.[index] :
formState.errors[name];
// show control for all inputs which allows to insert 10^18 or greater numbers
const hasZerosControl = intMatch && Number(intMatch.power) >= 64;
const renderInput = React.useCallback(({ field }: { field: ControllerRenderProps<MethodFormFields> }) => {
return ( return (
<FormControl <Box w="100%">
id={ name } <FormControl
flexBasis={{ base: '100%', lg: 'calc((100% - 24px) / 3 - 65px)' }} id={ name }
w={{ base: '100%', lg: 'auto' }} isDisabled={ isDisabled }
flexGrow={ 1 } >
isDisabled={ isDisabled } <InputGroup size="xs">
> <Input
<InputGroup size="xs"> { ...field }
<Input { ...(intMatch ? {
{ ...field } as: NumericFormat,
ref={ ref } thousandSeparator: ' ',
placeholder={ placeholder } decimalScale: 0,
paddingRight={ hasZerosControl ? '120px' : '40px' } allowNegative: !intMatch.isUnsigned,
/> } : {}) }
<InputRightElement w="auto" right={ 1 }> ref={ ref }
{ field.value && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> } isInvalid={ Boolean(error) }
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick } isDisabled={ isDisabled }/> } required={ !isOptional }
</InputRightElement> placeholder={ placeholder }
</InputGroup> paddingRight={ hasZerosControl ? '120px' : '40px' }
</FormControl> autoComplete="off"
bgColor={ bgColor }
/>
<InputRightElement w="auto" right={ 1 }>
{ typeof field.value === 'string' && field.value.replace('\n', '') && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick } isDisabled={ isDisabled }/> }
</InputRightElement>
</InputGroup>
</FormControl>
{ error && <Box color="error" fontSize="sm" mt={ 1 }>{ error.message }</Box> }
</Box>
); );
}, [ name, isDisabled, placeholder, hasZerosControl, handleClear, handleAddZeroesClick ]); }, [ index, groupName, name, intMatch, isDisabled, isOptional, placeholder, bgColor, handleClear, handleAddZeroesClick ]);
const validate = React.useCallback((_value: string | Array<string> | undefined) => {
if (typeof _value === 'object' || !_value) {
return;
}
const value = _value.replace('\n', '');
if (!value && !isOptional) {
return 'Field is required';
}
if (argType === 'address') {
if (!isAddress(value)) {
return 'Invalid address format';
}
// all lowercase addresses are valid
const isInLowerCase = value === value.toLowerCase();
if (isInLowerCase) {
return true;
}
// check if address checksum is valid
return getAddress(value) === value ? true : 'Invalid address checksum';
}
if (intMatch) {
const formattedValue = Number(value.replace(/\s/g, ''));
if (Object.is(formattedValue, NaN)) {
return 'Invalid integer format';
}
if (formattedValue > intMatch.max || formattedValue < intMatch.min) {
const lowerBoundary = intMatch.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(intMatch.power) / 2 }`;
const upperBoundary = intMatch.isUnsigned ? `2 ^ ${ intMatch.power } - 1` : `2 ^ ${ Number(intMatch.power) / 2 } - 1`;
return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`;
}
return true;
}
if (argType === 'bool') {
const formattedValue = formatBooleanValue(value);
if (formattedValue === undefined) {
return 'Invalid boolean format. Allowed values: 0, 1, true, false';
}
}
if (bytesMatch) {
const [ , length ] = bytesMatch;
if (!isHex(value)) {
return 'Invalid bytes format';
}
if (length) {
const valueLengthInBytes = value.replace('0x', '').length / 2;
return valueLengthInBytes !== Number(length) ? `Value should be ${ length } bytes in length` : true;
}
return true;
}
return true;
}, [ isOptional, argType, intMatch, bytesMatch ]);
return ( return (
<Controller <Controller
name={ name } name={ name }
control={ control } control={ control }
render={ renderInput } render={ renderInput }
rules={{ required: isOptional ? false : 'Field is required', validate }}
/> />
); );
}; };
......
import { Flex, IconButton } from '@chakra-ui/react';
import React from 'react';
import type { Control, UseFormGetValues, UseFormSetValue } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form';
import type { MethodFormFields } from './types';
import type { SmartContractMethodArgType } from 'types/api/contract';
import IconSvg from 'ui/shared/IconSvg';
import ContractMethodField from './ContractMethodField';
interface Props {
name: string;
size: number;
argType: SmartContractMethodArgType;
control: Control<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>;
isDisabled: boolean;
onChange: () => void;
}
const ContractMethodFieldArray = ({ control, name, setValue, getValues, isDisabled, argType, onChange, size }: Props) => {
const { fields, append, remove } = useFieldArray({
name: name as never,
control,
});
React.useEffect(() => {
if (fields.length === 0) {
if (size === Infinity) {
append('');
} else {
for (let i = 0; i < size - 1; i++) {
// a little hack to append multiple empty fields in the array
// had to adjust code in ContractMethodField as well
append('\n');
}
}
}
}, [ fields.length, append, size ]);
const handleAddButtonClick = React.useCallback(() => {
append('');
}, [ append ]);
const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
const itemIndex = event.currentTarget.getAttribute('data-index');
if (itemIndex) {
remove(Number(itemIndex));
}
}, [ remove ]);
return (
<Flex flexDir="column" rowGap={ 3 } w="100%">
{ fields.map((field, index, array) => {
return (
<Flex key={ field.id } columnGap={ 3 }>
<ContractMethodField
name={ `${ name }[${ index }]` }
groupName={ name }
index={ index }
argType={ argType }
placeholder={ argType }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
onChange={ onChange }
/>
{ array.length > 1 && size === Infinity && (
<IconButton
aria-label="remove"
data-index={ index }
variant="outline"
w="30px"
h="30px"
flexShrink={ 0 }
onClick={ handleRemoveButtonClick }
icon={ <IconSvg name="minus" boxSize={ 4 }/> }
isDisabled={ isDisabled }
/>
) }
{ index === array.length - 1 && size === Infinity && (
<IconButton
aria-label="add"
data-index={ index }
variant="outline"
w="30px"
h="30px"
flexShrink={ 0 }
onClick={ handleAddButtonClick }
icon={ <IconSvg name="plus" boxSize={ 4 }/> }
isDisabled={ isDisabled }
/>
) }
</Flex>
);
}) }
</Flex>
);
};
export default React.memo(ContractMethodFieldArray);
...@@ -11,9 +11,10 @@ interface Props<T extends SmartContractMethod> { ...@@ -11,9 +11,10 @@ interface Props<T extends SmartContractMethod> {
data: Array<T>; data: Array<T>;
addressHash?: string; addressHash?: string;
renderItemContent: (item: T, index: number, id: number) => React.ReactNode; renderItemContent: (item: T, index: number, id: number) => React.ReactNode;
tab: string;
} }
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent }: Props<T>) => { const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent, tab }: Props<T>) => {
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []); const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []);
const [ id, setId ] = React.useState(0); const [ id, setId ] = React.useState(0);
...@@ -79,6 +80,7 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address ...@@ -79,6 +80,7 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
index={ index } index={ index }
addressHash={ addressHash } addressHash={ addressHash }
renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode } renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode }
tab={ tab }
/> />
)) } )) }
</Accordion> </Accordion>
......
...@@ -16,9 +16,10 @@ interface Props<T extends SmartContractMethod> { ...@@ -16,9 +16,10 @@ interface Props<T extends SmartContractMethod> {
id: number; id: number;
addressHash?: string; addressHash?: string;
renderContent: (item: T, index: number, id: number) => React.ReactNode; renderContent: (item: T, index: number, id: number) => React.ReactNode;
tab: string;
} }
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent }: Props<T>) => { const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent, tab }: Props<T>) => {
const url = React.useMemo(() => { const url = React.useMemo(() => {
if (!('method_id' in data)) { if (!('method_id' in data)) {
return ''; return '';
...@@ -28,11 +29,11 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -28,11 +29,11 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
pathname: '/address/[hash]', pathname: '/address/[hash]',
query: { query: {
hash: addressHash ?? '', hash: addressHash ?? '',
tab: 'read_contract', tab,
}, },
hash: data.method_id, hash: data.method_id,
}); });
}, [ addressHash, data ]); }, [ addressHash, data, tab ]);
const { hasCopied, onCopy } = useClipboard(url, 1000); const { hasCopied, onCopy } = useClipboard(url, 1000);
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
...@@ -85,7 +86,7 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -85,7 +86,7 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
<AccordionIcon/> <AccordionIcon/>
</AccordionButton> </AccordionButton>
</Element> </Element>
<AccordionPanel pb={ 4 } px={ 0 }> <AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) } { renderContent(data, index, id) }
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
......
...@@ -28,7 +28,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -28,7 +28,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<ContractRead addressHash={ addressHash }/> <ContractRead/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -37,8 +37,8 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -37,8 +37,8 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
await component.getByPlaceholder(/address/i).type('address-hash'); await component.getByPlaceholder(/address/i).type('0xa113Ce24919C08a26C952E81681dAc861d6a2466');
await component.getByText(/query/i).click(); await component.getByText(/read/i).click();
await component.getByText(/wei/i).click(); await component.getByText(/wei/i).click();
......
import { Alert, Flex } from '@chakra-ui/react'; import { Alert, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract'; import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract';
import useApiFetch from 'lib/api/useApiFetch'; import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
...@@ -17,15 +19,15 @@ import ContractMethodConstant from './ContractMethodConstant'; ...@@ -17,15 +19,15 @@ import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult'; import ContractReadResult from './ContractReadResult';
import useWatchAccount from './useWatchAccount'; import useWatchAccount from './useWatchAccount';
interface Props { const ContractRead = () => {
addressHash?: string;
isProxy?: boolean;
isCustomAbi?: boolean;
}
const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => {
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const account = useWatchAccount(); const account = useWatchAccount();
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const addressHash = getQueryParamString(router.query.hash);
const isProxy = tab === 'read_proxy';
const isCustomAbi = tab === 'read_custom_methods';
const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', { const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
...@@ -96,7 +98,7 @@ const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => { ...@@ -96,7 +98,7 @@ const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => {
{ isCustomAbi && <ContractCustomAbiAlert/> } { isCustomAbi && <ContractCustomAbiAlert/> }
{ account && <ContractConnectWallet/> } { account && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> } { isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent }/> <ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
</> </>
); );
}; };
......
...@@ -23,7 +23,7 @@ test('base view +@mobile', async({ mount, page }) => { ...@@ -23,7 +23,7 @@ test('base view +@mobile', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<ContractWrite addressHash={ addressHash }/> <ContractWrite/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
......
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { useAccount, useWalletClient, useNetwork, useSwitchNetwork } from 'wagmi'; import { useAccount, useWalletClient, useNetwork, useSwitchNetwork } from 'wagmi';
...@@ -5,6 +6,7 @@ import type { SmartContractWriteMethod } from 'types/api/contract'; ...@@ -5,6 +6,7 @@ import type { SmartContractWriteMethod } from 'types/api/contract';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
...@@ -17,18 +19,19 @@ import ContractWriteResult from './ContractWriteResult'; ...@@ -17,18 +19,19 @@ import ContractWriteResult from './ContractWriteResult';
import useContractAbi from './useContractAbi'; import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils'; import { getNativeCoinValue, prepareAbi } from './utils';
interface Props { const ContractWrite = () => {
addressHash?: string;
isProxy?: boolean;
isCustomAbi?: boolean;
}
const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {
const { data: walletClient } = useWalletClient(); const { data: walletClient } = useWalletClient();
const { isConnected } = useAccount(); const { isConnected } = useAccount();
const { chain } = useNetwork(); const { chain } = useNetwork();
const { switchNetworkAsync } = useSwitchNetwork(); const { switchNetworkAsync } = useSwitchNetwork();
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const addressHash = getQueryParamString(router.query.hash);
const isProxy = tab === 'write_proxy';
const isCustomAbi = tab === 'write_custom_methods';
const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_write_proxy' : 'contract_methods_write', { const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_write_proxy' : 'contract_methods_write', {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
queryParams: { queryParams: {
...@@ -112,7 +115,7 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => { ...@@ -112,7 +115,7 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {
{ isCustomAbi && <ContractCustomAbiAlert/> } { isCustomAbi && <ContractCustomAbiAlert/> }
<ContractConnectWallet/> <ContractConnectWallet/>
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> } { isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent }/> <ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
</> </>
); );
}; };
......
...@@ -82,7 +82,6 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => { ...@@ -82,7 +82,6 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
return ( return (
<Box <Box
fontSize="sm" fontSize="sm"
pl={ 3 }
mt={ 3 } mt={ 3 }
alignItems="center" alignItems="center"
whiteSpace="pre-wrap" whiteSpace="pre-wrap"
......
...@@ -2,7 +2,10 @@ import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/ap ...@@ -2,7 +2,10 @@ import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/ap
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
export type MethodFormFields = Record<string, string>; export type MethodFormFields = Record<string, string | Array<string>>;
export type MethodFormFieldsFormatted = Record<string, MethodArgType>;
export type MethodArgType = string | boolean | Array<MethodArgType>;
export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError; export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError;
......
import { prepareAbi } from './utils'; import type { SmartContractMethodInput } from 'types/api/contract';
import { prepareAbi, transformFieldsToArgs, formatFieldValues } from './utils';
describe('function prepareAbi()', () => { describe('function prepareAbi()', () => {
const commonAbi = [ const commonAbi = [
...@@ -98,3 +100,100 @@ describe('function prepareAbi()', () => { ...@@ -98,3 +100,100 @@ describe('function prepareAbi()', () => {
expect(item).toEqual(commonAbi[2]); expect(item).toEqual(commonAbi[2]);
}); });
}); });
describe('function formatFieldValues()', () => {
const formFields = {
'_tx%0:nonce%0': '1 000 000 000 000 000 000',
'_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c',
'_tx%0:targets%2': [
'1',
'true',
],
'_l2OutputIndex%1': '0xaeff',
'_paused%2': '0',
'_withdrawalProof%3': [
'0x0f',
'0x02',
],
};
const inputs: Array<SmartContractMethodInput> = [
{
components: [
{ internalType: 'uint256', name: 'nonce', type: 'uint256' },
{ internalType: 'address', name: 'sender', type: 'address' },
{ internalType: 'bool[]', name: 'targets', type: 'bool[]' },
],
internalType: 'tuple',
name: '_tx',
type: 'tuple',
},
{ internalType: 'bytes32', name: '_l2OutputIndex', type: 'bytes32' },
{
internalType: 'bool',
name: '_paused',
type: 'bool',
},
{
internalType: 'bytes32[]',
name: '_withdrawalProof',
type: 'bytes32[]',
},
];
it('converts values to correct format', () => {
const result = formatFieldValues(formFields, inputs);
expect(result).toEqual({
'_tx%0:nonce%0': '1000000000000000000',
'_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c',
'_tx%0:targets%2': [
true,
true,
],
'_l2OutputIndex%1': '0xaeff',
'_paused%2': false,
'_withdrawalProof%3': [
'0x0f',
'0x02',
],
});
});
it('converts nested array string representation to correct format', () => {
const formFields = {
'_withdrawalProof%0': '[ [ 1 ], [ 2, 3 ], [ 4 ]]',
};
const inputs: Array<SmartContractMethodInput> = [
{ internalType: 'uint[][]', name: '_withdrawalProof', type: 'uint[][]' },
];
const result = formatFieldValues(formFields, inputs);
expect(result).toEqual({
'_withdrawalProof%0': [ [ 1 ], [ 2, 3 ], [ 4 ] ],
});
});
});
describe('function transformFieldsToArgs()', () => {
it('groups struct and array fields', () => {
const formFields = {
'_paused%2': 'primitive_1',
'_l2OutputIndex%1': 'primitive_0',
'_tx%0:nonce%0': 'struct_0',
'_tx%0:sender%1': 'struct_1',
'_tx%0:target%2': [ 'struct_2_0', 'struct_2_1' ],
'_withdrawalProof%3': [
'array_0',
'array_1',
],
};
const args = transformFieldsToArgs(formFields);
expect(args).toEqual([
[ 'struct_0', 'struct_1', [ 'struct_2_0', 'struct_2_1' ] ],
'primitive_0',
'primitive_1',
[ 'array_0', 'array_1' ],
]);
});
});
import type { Abi } from 'abitype'; import type { Abi } from 'abitype';
import _mapValues from 'lodash/mapValues';
import type { SmartContractWriteMethod } from 'types/api/contract'; import type { MethodArgType, MethodFormFields, MethodFormFieldsFormatted } from './types';
import type { SmartContractMethodArgType, SmartContractMethodInput, SmartContractWriteMethod } from 'types/api/contract';
export const getNativeCoinValue = (value: string | Array<unknown>) => { export const INT_REGEXP = /^(u)?int(\d+)?$/i;
const _value = Array.isArray(value) ? value[0] : value;
if (typeof _value !== 'string') { export const BYTES_REGEXP = /^bytes(\d+)?$/i;
return BigInt(0);
}
return BigInt(_value); export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/;
export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
const maxUnsigned = 2 ** power;
const max = isUnsigned ? maxUnsigned - 1 : maxUnsigned / 2 - 1;
const min = isUnsigned ? 0 : -maxUnsigned / 2;
return [ min, max ];
}; };
export const addZeroesAllowed = (valueType: string) => { export const formatBooleanValue = (value: string) => {
if (valueType.includes('[')) { const formattedValue = value.toLowerCase();
return false;
} switch (formattedValue) {
case 'true':
case '1': {
return 'true';
}
const REGEXP = /^u?int(\d+)/i; case 'false':
case '0': {
return 'false';
}
default:
return;
}
};
const match = valueType.match(REGEXP); export const getNativeCoinValue = (value: string | Array<unknown>) => {
const power = match?.[1]; const _value = Array.isArray(value) ? value[0] : value;
if (power) { if (typeof _value !== 'string') {
// show control for all inputs which allows to insert 10^18 or greater numbers return BigInt(0);
return Number(power) >= 64;
} }
return false; return BigInt(_value);
}; };
interface ExtendedError extends Error { interface ExtendedError extends Error {
...@@ -75,3 +91,106 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { ...@@ -75,3 +91,106 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
return abi; return abi;
} }
function getFieldType(fieldName: string, inputs: Array<SmartContractMethodInput>) {
const chunks = fieldName.split(':');
if (chunks.length === 1) {
const [ , index ] = chunks[0].split('%');
return inputs[Number(index)].type;
} else {
const group = chunks[0].split('%');
const input = chunks[1].split('%');
return inputs[Number(group[1])].components?.[Number(input[1])].type;
}
}
function parseArrayValue(value: string) {
try {
const parsedResult = JSON.parse(value);
if (Array.isArray(parsedResult)) {
return parsedResult as Array<string>;
}
throw new Error('Not an array');
} catch (error) {
return '';
}
}
function castValue(value: string, type: SmartContractMethodArgType) {
if (type === 'bool') {
return formatBooleanValue(value) === 'true';
}
const intMatch = type.match(INT_REGEXP);
if (intMatch) {
return value.replaceAll(' ', '');
}
const isNestedArray = (type.match(/\[/g) || []).length > 1;
if (isNestedArray) {
return parseArrayValue(value) || value;
}
return value;
}
export function formatFieldValues(formFields: MethodFormFields, inputs: Array<SmartContractMethodInput>) {
const formattedFields = _mapValues(formFields, (value, key) => {
const type = getFieldType(key, inputs);
if (!type) {
return value;
}
if (Array.isArray(value)) {
const arrayMatch = type.match(ARRAY_REGEXP);
if (arrayMatch) {
return value.map((item) => castValue(item, arrayMatch[1] as SmartContractMethodArgType));
}
return value;
}
return castValue(value, type);
});
return formattedFields;
}
export function transformFieldsToArgs(formFields: MethodFormFieldsFormatted) {
const unGroupedFields = Object.entries(formFields)
.reduce((
result: Record<string, MethodArgType>,
[ key, value ]: [ string, MethodArgType ],
) => {
const chunks = key.split(':');
if (chunks.length > 1) {
const groupKey = chunks[0];
const [ , fieldIndex ] = chunks[1].split('%');
if (result[groupKey] === undefined) {
result[groupKey] = [];
}
(result[groupKey] as Array<MethodArgType>)[Number(fieldIndex)] = value;
return result;
}
result[key] = value;
return result;
}, {});
const args = (Object.entries(unGroupedFields)
.map(([ key, value ]) => {
const [ , index ] = key.split('%');
return [ Number(index), value ];
}) as Array<[ number, string | Array<string> ]>)
.sort((a, b) => a[0] - b[0])
.map(([ , value ]) => value);
return args;
}
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import config from 'configs/app';
import * as ensDomainMock from 'mocks/ens/domain';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AddressEnsDomains from './AddressEnsDomains';
const ADDRESS_HASH = ensDomainMock.ensDomainA.owner?.hash ?? '';
const ADDRESSES_LOOKUP_API_URL = buildApiUrl('addresses_lookup', { chainId: config.chain.id }) +
`?address=${ ADDRESS_HASH }&resolved_to=true&owned_by=true&only_active=true&order=ASC`;
test('base view', async({ mount, page }) => {
await page.route(ADDRESSES_LOOKUP_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
ensDomainMock.ensDomainA,
ensDomainMock.ensDomainB,
ensDomainMock.ensDomainC,
ensDomainMock.ensDomainD,
],
}),
}));
const component = await mount(
<TestApp>
<AddressEnsDomains addressHash={ ADDRESS_HASH } mainDomainName={ ensDomainMock.ensDomainA.name }/>
</TestApp>,
);
await component.getByText('4 domains').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 550, height: 350 } });
});
import { Button, chakra, Flex, Grid, Popover, PopoverBody, PopoverContent, PopoverTrigger, Skeleton, useDisclosure } from '@chakra-ui/react';
import _clamp from 'lodash/clamp';
import React from 'react';
import type { EnsDomain } from 'types/api/ens';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import dayjs from 'lib/date/dayjs';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
addressHash: string;
mainDomainName: string | null;
}
const DomainsGrid = ({ data }: { data: Array<EnsDomain> }) => {
return (
<Grid
templateColumns={{ base: `repeat(${ _clamp(data.length, 1, 2) }, 1fr)`, lg: `repeat(${ _clamp(data.length, 1, 3) }, 1fr)` }}
columnGap={ 8 }
rowGap={ 4 }
mt={ 2 }
>
{ data.slice(0, 9).map((domain) => <EnsEntity key={ domain.id } name={ domain.name } noCopy/>) }
</Grid>
);
};
const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const { data, isPending, isError } = useApiQuery('addresses_lookup', {
pathParams: { chainId: config.chain.id },
queryParams: {
address: addressHash,
resolved_to: true,
owned_by: true,
only_active: true,
order: 'ASC',
},
});
if (isError) {
return null;
}
if (isPending) {
return <Skeleton h={ 8 } w={{ base: '60px', lg: '120px' }} borderRadius="base"/>;
}
if (data.items.length === 0) {
return null;
}
const mainDomain = data.items.find((domain) => domain.name === mainDomainName);
const ownedDomains = data.items.filter((domain) => {
if (domain.name === mainDomainName) {
return false;
}
// exclude resolved address
if (domain.resolved_address && domain.resolved_address.hash.toLowerCase() === addressHash.toLowerCase()) {
return false;
}
if (domain.owner && domain.owner.hash.toLowerCase() === addressHash.toLowerCase()) {
return true;
}
// include wrapped owner
if (domain.wrapped_owner?.hash.toLowerCase() === addressHash.toLowerCase()) {
return !domain.resolved_address || domain.resolved_address.hash.toLowerCase() !== addressHash.toLowerCase();
}
return false;
});
const resolvedDomains = data.items.filter((domain) =>
domain.resolved_address &&
domain.resolved_address.hash.toLowerCase() === addressHash.toLowerCase() &&
domain.name !== mainDomainName,
);
const totalRecords = data.items.length > 40 ? '40+' : data.items.length;
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Button
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
aria-label="Address ENS domains"
fontWeight={ 500 }
px={ 2 }
h="32px"
flexShrink={ 0 }
>
<IconSvg name="ENS_slim" boxSize={ 5 }/>
<chakra.span ml={ 1 } display={{ base: 'none', lg: 'block' }}>{ totalRecords } Domain{ data.items.length > 1 ? 's' : '' }</chakra.span>
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 }/>
</Button>
</PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '500px' }}>
<PopoverBody px={ 6 } py={ 5 } fontSize="sm" display="flex" flexDir="column" rowGap={ 5 } alignItems="flex-start">
{ mainDomain && (
<div>
<chakra.span color="text_secondary" fontSize="xs">Primary*</chakra.span>
<Flex alignItems="center" fontSize="md" mt={ 2 }>
<EnsEntity name={ mainDomain.name } fontWeight={ 600 } noCopy/>
{ mainDomain.expiry_date &&
<chakra.span color="text_secondary" whiteSpace="pre"> (expires { dayjs(mainDomain.expiry_date).fromNow() })</chakra.span> }
</Flex>
</div>
) }
{ ownedDomains.length > 0 && (
<div>
<chakra.span color="text_secondary" fontSize="xs">Owned by this address</chakra.span>
<DomainsGrid data={ ownedDomains }/>
</div>
) }
{ resolvedDomains.length > 0 && (
<div>
<chakra.span color="text_secondary" fontSize="xs">Resolved to this address</chakra.span>
<DomainsGrid data={ resolvedDomains }/>
</div>
) }
{ (ownedDomains.length > 9 || resolvedDomains.length > 9) && (
<LinkInternal
href={ route({ pathname: '/name-domains', query: { owned_by: 'true', resolved_to: 'true', address: addressHash } }) }
>
<span> More results</span>
<chakra.span color="text_secondary"> ({ totalRecords })</chakra.span>
</LinkInternal>
) }
{ mainDomain && (
<chakra.span fontSize="xs" mt={ -1 }>
*A domain name is not necessarily held by a person popularly associated with the name
</chakra.span>
) }
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(AddressEnsDomains);
...@@ -128,6 +128,17 @@ const BlockDetails = ({ query }: Props) => { ...@@ -128,6 +128,17 @@ const BlockDetails = ({ query }: Props) => {
return config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by'; return config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by';
})(); })();
const blockTypeLabel = (() => {
switch (data.type) {
case 'reorg':
return 'Reorg';
case 'uncle':
return 'Uncle';
default:
return 'Block';
}
})();
return ( return (
<Grid <Grid
columnGap={ 8 } columnGap={ 8 }
...@@ -136,7 +147,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -136,7 +147,7 @@ const BlockDetails = ({ query }: Props) => {
overflow="hidden" overflow="hidden"
> >
<DetailsInfoItem <DetailsInfoItem
title={ `${ data.type === 'reorg' ? 'Reorg' : 'Block' } height` } title={ `${ blockTypeLabel } height` }
hint="The block height of a particular block is defined as the number of blocks preceding it in the blockchain" hint="The block height of a particular block is defined as the number of blocks preceding it in the blockchain"
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
......
...@@ -43,7 +43,7 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -43,7 +43,7 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<BlockEntity <BlockEntity
isLoading={ isLoading } isLoading={ isLoading }
number={ data.height } number={ data.height }
hash={ data.type === 'reorg' ? data.hash : undefined } hash={ data.type !== 'block' ? data.hash : undefined }
noIcon noIcon
fontWeight={ 600 } fontWeight={ 600 }
/> />
......
...@@ -14,6 +14,11 @@ interface Props { ...@@ -14,6 +14,11 @@ interface Props {
const BlocksTabSlot = ({ pagination }: Props) => { const BlocksTabSlot = ({ pagination }: Props) => {
const statsQuery = useApiQuery('homepage_stats', { const statsQuery = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: { queryOptions: {
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
}, },
......
...@@ -50,7 +50,7 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -50,7 +50,7 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<BlockEntity <BlockEntity
isLoading={ isLoading } isLoading={ isLoading }
number={ data.height } number={ data.height }
hash={ data.type === 'reorg' ? data.hash : undefined } hash={ data.type !== 'block' ? data.hash : undefined }
noIcon noIcon
fontSize="sm" fontSize="sm"
lineHeight={ 5 } lineHeight={ 5 }
......
...@@ -37,7 +37,13 @@ const LatestBlocks = () => { ...@@ -37,7 +37,13 @@ const LatestBlocks = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const statsQueryResult = useApiQuery('homepage_stats', { const statsQueryResult = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: { queryOptions: {
refetchOnMount: false,
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
}, },
}); });
......
...@@ -16,8 +16,14 @@ const hasGasTracker = config.UI.homepage.showGasTracker; ...@@ -16,8 +16,14 @@ const hasGasTracker = config.UI.homepage.showGasTracker;
const hasAvgBlockTime = config.UI.homepage.showAvgBlockTime; const hasAvgBlockTime = config.UI.homepage.showAvgBlockTime;
const Stats = () => { const Stats = () => {
const { data, isPlaceholderData, isError } = useApiQuery('homepage_stats', { const { data, isPlaceholderData, isError, dataUpdatedAt } = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: { queryOptions: {
refetchOnMount: false,
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
}, },
}); });
...@@ -45,7 +51,19 @@ const Stats = () => { ...@@ -45,7 +51,19 @@ const Stats = () => {
!data.gas_prices && itemsCount--; !data.gas_prices && itemsCount--;
data.rootstock_locked_btc && itemsCount++; data.rootstock_locked_btc && itemsCount++;
const isOdd = Boolean(itemsCount % 2); const isOdd = Boolean(itemsCount % 2);
const gasLabel = hasGasTracker && data.gas_prices ? <GasInfoTooltipContent gasPrices={ data.gas_prices }/> : null; const gasLabel = hasGasTracker && data.gas_prices ? <GasInfoTooltipContent data={ data } dataUpdatedAt={ dataUpdatedAt }/> : null;
const gasPriceText = (() => {
if (data.gas_prices?.average?.fiat_price) {
return `$${ data.gas_prices.average.fiat_price }`;
}
if (data.gas_prices?.average?.price) {
return `${ data.gas_prices.average.price.toLocaleString() } Gwei`;
}
return 'N/A';
})();
content = ( content = (
<> <>
...@@ -92,7 +110,7 @@ const Stats = () => { ...@@ -92,7 +110,7 @@ const Stats = () => {
<StatsItem <StatsItem
icon="gas" icon="gas"
title="Gas tracker" title="Gas tracker"
value={ data.gas_prices.average !== null ? `${ Number(data.gas_prices.average).toLocaleString() } Gwei` : 'N/A' } value={ gasPriceText }
_last={ isOdd ? lastItemTouchStyle : undefined } _last={ isOdd ? lastItemTouchStyle : undefined }
tooltipLabel={ gasLabel } tooltipLabel={ gasLabel }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
......
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { HOMEPAGE_STATS } from 'stubs/stats';
import Hint from 'ui/shared/Hint'; import Hint from 'ui/shared/Hint';
import ChainIndicatorChartContainer from './ChainIndicatorChartContainer'; import ChainIndicatorChartContainer from './ChainIndicatorChartContainer';
...@@ -29,7 +30,17 @@ const ChainIndicators = () => { ...@@ -29,7 +30,17 @@ const ChainIndicators = () => {
const indicator = indicators.find(({ id }) => id === selectedIndicator); const indicator = indicators.find(({ id }) => id === selectedIndicator);
const queryResult = useFetchChartData(indicator); const queryResult = useFetchChartData(indicator);
const statsQueryResult = useApiQuery('homepage_stats'); const statsQueryResult = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: {
refetchOnMount: false,
placeholderData: HOMEPAGE_STATS,
},
});
const bgColorDesktop = useColorModeValue('white', 'gray.900'); const bgColorDesktop = useColorModeValue('white', 'gray.900');
const bgColorMobile = useColorModeValue('white', 'black'); const bgColorMobile = useColorModeValue('white', 'black');
......
...@@ -65,7 +65,7 @@ const DepositsListItem = ({ item, isLoading }: Props) => { ...@@ -65,7 +65,7 @@ const DepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn origin</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn origin</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<AddressEntityL1 <AddressEntityL1
address={{ hash: item.l1_tx_origin, name: '', is_contract: false, is_verified: false, implementation_name: '' }} address={{ hash: item.l1_tx_origin, name: '', is_contract: false, is_verified: false, implementation_name: '', ens_domain_name: null }}
isLoading={ isLoading } isLoading={ isLoading }
noCopy noCopy
/> />
......
...@@ -56,7 +56,7 @@ const WithdrawalsTableItem = ({ item, isLoading }: Props) => { ...@@ -56,7 +56,7 @@ const WithdrawalsTableItem = ({ item, isLoading }: Props) => {
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<AddressEntityL1 <AddressEntityL1
address={{ hash: item.l1_tx_origin, name: '', is_contract: false, is_verified: false, implementation_name: '' }} address={{ hash: item.l1_tx_origin, name: '', is_contract: false, is_verified: false, implementation_name: '', ens_domain_name: null }}
isLoading={ isLoading } isLoading={ isLoading }
truncation="constant" truncation="constant"
noCopy noCopy
......
...@@ -4,6 +4,7 @@ import React, { useCallback } from 'react'; ...@@ -4,6 +4,7 @@ import React, { useCallback } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace'; import type { MarketplaceAppPreview } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import MarketplaceAppCardLink from './MarketplaceAppCardLink'; import MarketplaceAppCardLink from './MarketplaceAppCardLink';
...@@ -43,6 +44,7 @@ const MarketplaceAppCard = ({ ...@@ -43,6 +44,7 @@ const MarketplaceAppCard = ({
const handleInfoClick = useCallback((event: MouseEvent) => { const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id });
onInfoClick(id); onInfoClick(id);
}, [ onInfoClick, id ]); }, [ onInfoClick, id ]);
......
...@@ -6,6 +6,7 @@ import React from 'react'; ...@@ -6,6 +6,7 @@ import React from 'react';
import { MarketplaceCategory } from 'types/client/marketplace'; import { MarketplaceCategory } from 'types/client/marketplace';
import useDebounce from 'lib/hooks/useDebounce'; import useDebounce from 'lib/hooks/useDebounce';
import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useMarketplaceApps from './useMarketplaceApps'; import useMarketplaceApps from './useMarketplaceApps';
...@@ -33,6 +34,8 @@ export default function useMarketplace() { ...@@ -33,6 +34,8 @@ export default function useMarketplace() {
const [ isDisclaimerModalOpen, setIsDisclaimerModalOpen ] = React.useState<boolean>(false); const [ isDisclaimerModalOpen, setIsDisclaimerModalOpen ] = React.useState<boolean>(false);
const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean) => { const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Favorite app', Info: id });
const favoriteApps = getFavoriteApps(); const favoriteApps = getFavoriteApps();
if (isFavorite) { if (isFavorite) {
...@@ -64,6 +67,7 @@ export default function useMarketplace() { ...@@ -64,6 +67,7 @@ export default function useMarketplace() {
}, []); }, []);
const handleCategoryChange = React.useCallback((newCategory: string) => { const handleCategoryChange = React.useCallback((newCategory: string) => {
mixpanel.logEvent(mixpanel.EventTypes.FILTERS, { Source: 'Marketplace', 'Filter name': newCategory });
setSelectedCategoryId(newCategory); setSelectedCategoryId(newCategory);
}, []); }, []);
...@@ -91,6 +95,11 @@ export default function useMarketplace() { ...@@ -91,6 +95,11 @@ export default function useMarketplace() {
category: selectedCategoryId === MarketplaceCategory.ALL ? undefined : selectedCategoryId, category: selectedCategoryId === MarketplaceCategory.ALL ? undefined : selectedCategoryId,
filter: debouncedFilterQuery, filter: debouncedFilterQuery,
}, Boolean); }, Boolean);
if (debouncedFilterQuery.length > 0) {
mixpanel.logEvent(mixpanel.EventTypes.LOCAL_SEARCH, { Source: 'Marketplace', 'Search query': debouncedFilterQuery });
}
router.replace( router.replace(
{ pathname: '/apps', query }, { pathname: '/apps', query },
undefined, undefined,
......
import { Grid, Skeleton, Tooltip, Flex } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { EnsDomainDetailed } from 'types/api/ens';
import { route } from 'nextjs-routes';
import type { ResourceError } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import NftEntity from 'ui/shared/entities/nft/NftEntity';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/LinkInternal';
import TextSeparator from 'ui/shared/TextSeparator';
import NameDomainExpiryStatus from './NameDomainExpiryStatus';
interface Props {
query: UseQueryResult<EnsDomainDetailed, ResourceError<unknown>>;
}
const NameDomainDetails = ({ query }: Props) => {
const isLoading = query.isPlaceholderData;
const otherAddresses = Object.entries(query.data?.other_addresses ?? {});
const hasExpired = query.data?.expiry_date && dayjs(query.data.expiry_date).isBefore(dayjs());
return (
<Grid columnGap={ 8 } rowGap={ 3 } templateColumns={{ base: 'minmax(0, 1fr)', lg: 'max-content minmax(728px, auto)' }}>
{ query.data?.registration_date && (
<DetailsInfoItem
title="Registration date"
hint="The date the name was registered"
isLoading={ isLoading }
>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" verticalAlign="middle" isLoading={ isLoading } mr={ 2 }/>
<Skeleton isLoaded={ !isLoading } display="inline" whiteSpace="pre-wrap" lineHeight="20px">
{ dayjs(query.data.registration_date).format('llll') }
</Skeleton>
</DetailsInfoItem>
) }
{ query.data?.expiry_date && (
<DetailsInfoItem
title="Expiration date"
// eslint-disable-next-line max-len
hint="The date the name expires, upon which there is a 90 day grace period for the owner to renew. After the 90 days, the name is released to the market"
isLoading={ isLoading }
display="inline-block"
>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" verticalAlign="middle" isLoading={ isLoading } mr={ 2 } mt="-2px"/>
{ hasExpired && (
<>
<Skeleton isLoaded={ !isLoading } display="inline" whiteSpace="pre-wrap" lineHeight="24px">
{ dayjs(query.data.expiry_date).fromNow() }
</Skeleton>
<TextSeparator color="gray.500"/>
</>
) }
<Skeleton isLoaded={ !isLoading } display="inline" whiteSpace="pre-wrap" lineHeight="24px">
{ dayjs(query.data.expiry_date).format('llll') }
</Skeleton>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline">
<NameDomainExpiryStatus date={ query.data?.expiry_date }/>
</Skeleton>
</DetailsInfoItem>
) }
{ query.data?.registrant && (
<DetailsInfoItem
title="Registrant"
hint="The account that owns the domain name and has the rights to edit its ownership and records"
isLoading={ isLoading }
columnGap={ 2 }
flexWrap="nowrap"
>
<AddressEntity
address={ query.data.registrant }
isLoading={ isLoading }
/>
<Tooltip label="Lookup for related domain names">
<LinkInternal
flexShrink={ 0 }
display="inline-flex"
href={ route({ pathname: '/name-domains', query: { owned_by: 'true', resolved_to: 'true', address: query.data.registrant.hash } }) }
>
<IconSvg name="search" boxSize={ 5 } isLoading={ isLoading }/>
</LinkInternal>
</Tooltip>
</DetailsInfoItem>
) }
{ query.data?.owner && (
<DetailsInfoItem
title="Owner"
hint="The account that owns the rights to edit the records of this domain name"
isLoading={ isLoading }
columnGap={ 2 }
flexWrap="nowrap"
>
<AddressEntity
address={ query.data.owner }
isLoading={ isLoading }
/>
<Tooltip label="Lookup for related domain names">
<LinkInternal
flexShrink={ 0 }
display="inline-flex"
href={ route({ pathname: '/name-domains', query: { owned_by: 'true', resolved_to: 'true', address: query.data.owner.hash } }) }
>
<IconSvg name="search" boxSize={ 5 } isLoading={ isLoading }/>
</LinkInternal>
</Tooltip>
</DetailsInfoItem>
) }
{ query.data?.wrapped_owner && (
<DetailsInfoItem
title="Manager"
hint="Owner of this NFT domain in NameWrapper contract"
isLoading={ isLoading }
columnGap={ 2 }
flexWrap="nowrap"
>
<AddressEntity
address={ query.data.wrapped_owner }
isLoading={ isLoading }
/>
<Tooltip label="Lookup for related domain names">
<LinkInternal
flexShrink={ 0 }
display="inline-flex"
href={ route({ pathname: '/name-domains', query: { owned_by: 'true', resolved_to: 'true', address: query.data.wrapped_owner.hash } }) }
>
<IconSvg name="search" boxSize={ 5 } isLoading={ isLoading }/>
</LinkInternal>
</Tooltip>
</DetailsInfoItem>
) }
{ query.data?.tokens.map((token) => (
<DetailsInfoItem
key={ token.type }
title={ token.type === 'WRAPPED_DOMAIN_TOKEN' ? 'Wrapped token ID' : 'Token ID' }
hint={ `The ${ token.type === 'WRAPPED_DOMAIN_TOKEN' ? 'wrapped ' : '' }token ID of this domain name NFT` }
isLoading={ isLoading }
wordBreak="break-all"
whiteSpace="pre-wrap"
>
<NftEntity hash={ token.contract_hash } id={ token.id } isLoading={ isLoading } noIcon/>
</DetailsInfoItem>
)) }
{ otherAddresses.length > 0 && (
<DetailsInfoItem
title="Other addresses"
hint="Other cryptocurrency addresses added to this domain name"
isLoading={ isLoading }
flexDir="column"
alignItems="flex-start"
>
{ otherAddresses.map(([ type, address ]) => (
<Flex key={ type } columnGap={ 2 } minW="0" w="100%" overflow="hidden">
<Skeleton isLoaded={ !isLoading }>{ type }</Skeleton>
<AddressEntity
address={{ hash: address }}
isLoading={ isLoading }
noLink
noIcon
/>
</Flex>
)) }
</DetailsInfoItem>
) }
</Grid>
);
};
export default React.memo(NameDomainDetails);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import dayjs from 'lib/date/dayjs';
interface Props {
date: string | undefined;
}
const NameDomainExpiryStatus = ({ date }: Props) => {
if (!date) {
return null;
}
const hasExpired = dayjs(date).isBefore(dayjs());
if (hasExpired) {
return <chakra.span color="red.600">Expired</chakra.span>;
}
const diff = dayjs(date).diff(dayjs(), 'day');
if (diff < 30) {
return <chakra.span color="red.600">{ diff } days left</chakra.span>;
}
return <chakra.span color="text_secondary">Expires { dayjs(date).fromNow() }</chakra.span>;
};
export default React.memo(NameDomainExpiryStatus);
import { Box, Hide, Show } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ENS_DOMAIN_EVENT } from 'stubs/ENS';
import DataListDisplay from 'ui/shared/DataListDisplay';
import NameDomainHistoryListItem from './history/NameDomainHistoryListItem';
import NameDomainHistoryTable from './history/NameDomainHistoryTable';
import { getNextSortValue, type Sort, type SortField } from './history/utils';
const NameDomainHistory = () => {
const router = useRouter();
const domainName = getQueryParamString(router.query.name);
const [ sort, setSort ] = React.useState<Sort>();
const { isPlaceholderData, isError, data } = useApiQuery('domain_events', {
pathParams: { name: domainName, chainId: config.chain.id },
queryOptions: {
placeholderData: { items: Array(4).fill(ENS_DOMAIN_EVENT) },
},
});
const handleSortToggle = React.useCallback((event: React.MouseEvent) => {
if (isPlaceholderData) {
return;
}
const field = (event.currentTarget as HTMLDivElement).getAttribute('data-field') as SortField | undefined;
if (field) {
setSort(getNextSortValue(field));
}
}, [ isPlaceholderData ]);
const content = (
<>
<Show below="lg" ssr={ false }>
<Box>
{ data?.items.map((item, index) => <NameDomainHistoryListItem key={ index } { ...item } isLoading={ isPlaceholderData }/>) }
</Box>
</Show>
<Hide below="lg" ssr={ false }>
<NameDomainHistoryTable
data={ data }
isLoading={ isPlaceholderData }
sort={ sort }
onSortToggle={ handleSortToggle }
/>
</Hide>
</>
);
return (
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no events for this domain."
content={ content }
/>
);
};
export default React.memo(NameDomainHistory);
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { EnsDomainEvent } from 'types/api/ens';
import dayjs from 'lib/date/dayjs';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
type Props = EnsDomainEvent & {
isLoading?: boolean;
}
const NameDomainHistoryListItem = ({ isLoading, transaction_hash: transactionHash, timestamp, from_address: fromAddress, action }: Props) => {
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>Txn hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TxEntity hash={ transactionHash } isLoading={ isLoading } fontWeight={ 500 }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block">
<span>{ dayjs(timestamp).fromNow() }</span>
</Skeleton>
</ListItemMobileGrid.Value>
{ fromAddress && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>From</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<AddressEntity address={ fromAddress } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
</>
) }
{ action && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Method</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Tag colorScheme="gray" isLoading={ isLoading }>{ action }</Tag>
</ListItemMobileGrid.Value>
</>
) }
</ListItemMobileGrid.Container>
);
};
export default React.memo(NameDomainHistoryListItem);
import { Table, Tbody, Tr, Th, Link } from '@chakra-ui/react';
import React from 'react';
import type { EnsDomainEventsResponse } from 'types/api/ens';
import IconSvg from 'ui/shared/IconSvg';
import { default as Thead } from 'ui/shared/TheadSticky';
import NameDomainHistoryTableItem from './NameDomainHistoryTableItem';
import type { Sort } from './utils';
import { sortFn } from './utils';
interface Props {
data: EnsDomainEventsResponse | undefined;
isLoading?: boolean;
sort: Sort | undefined;
onSortToggle: (event: React.MouseEvent) => void;
}
const NameDomainHistoryTable = ({ data, isLoading, sort, onSortToggle }: Props) => {
const sortIconTransform = sort?.includes('asc') ? 'rotate(-90deg)' : 'rotate(90deg)';
return (
<Table variant="simple" size="sm">
<Thead top={ 0 }>
<Tr>
<Th width="25%">Txn hash</Th>
<Th width="25%" pl={ 9 }>
<Link display="flex" alignItems="center" justifyContent="flex-start" position="relative" data-field="timestamp" onClick={ onSortToggle }>
{ sort?.includes('timestamp') && (
<IconSvg
name="arrows/east"
boxSize={ 4 }
transform={ sortIconTransform }
color="link"
position="absolute"
left={ -5 }
top={ 0 }
/>
) }
<span>Age</span>
</Link>
</Th>
<Th width="25%">From</Th>
<Th width="25%">Method</Th>
</Tr>
</Thead>
<Tbody>
{
data?.items
.slice()
.sort(sortFn(sort))
.map((item, index) => <NameDomainHistoryTableItem key={ index } { ...item } isLoading={ isLoading }/>)
}
</Tbody>
</Table>
);
};
export default React.memo(NameDomainHistoryTable);
import { Tr, Td, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { EnsDomainEvent } from 'types/api/ens';
import dayjs from 'lib/date/dayjs';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
type Props = EnsDomainEvent & {
isLoading?: boolean;
}
const NameDomainHistoryTableItem = ({ isLoading, transaction_hash: transactionHash, from_address: fromAddress, action, timestamp }: Props) => {
return (
<Tr>
<Td verticalAlign="middle">
<TxEntity hash={ transactionHash } isLoading={ isLoading } fontWeight={ 700 }/>
</Td>
<Td pl={ 9 } verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block">
<span>{ dayjs(timestamp).fromNow() }</span>
</Skeleton>
</Td>
<Td verticalAlign="middle">
{ fromAddress && <AddressEntity address={ fromAddress } isLoading={ isLoading }/> }
</Td>
<Td verticalAlign="middle">
{ action && <Tag colorScheme="gray" isLoading={ isLoading }>{ action }</Tag> }
</Td>
</Tr>
);
};
export default React.memo(NameDomainHistoryTableItem);
import type { EnsDomainEvent } from 'types/api/ens';
import getNextSortValueShared from 'ui/shared/sort/getNextSortValue';
export type SortField = 'timestamp';
export type Sort = `${ SortField }-asc` | `${ SortField }-desc`;
const SORT_SEQUENCE: Record<SortField, Array<Sort | undefined>> = {
timestamp: [ 'timestamp-desc', 'timestamp-asc', undefined ],
};
export const getNextSortValue = (getNextSortValueShared<SortField, Sort>).bind(undefined, SORT_SEQUENCE);
export const sortFn = (sort: Sort | undefined) => (a: EnsDomainEvent, b: EnsDomainEvent) => {
switch (sort) {
case 'timestamp-asc': {
return b.timestamp.localeCompare(a.timestamp);
}
case 'timestamp-desc': {
return a.timestamp.localeCompare(b.timestamp);
}
default:
return 0;
}
};
import { Checkbox, CheckboxGroup, HStack, Text } from '@chakra-ui/react';
import React from 'react';
import type { EnsDomainLookupFiltersOptions } from 'types/api/ens';
import type { PaginationParams } from 'ui/shared/pagination/types';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import ActionBar from 'ui/shared/ActionBar';
import FilterInput from 'ui/shared/filters/FilterInput';
import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import Pagination from 'ui/shared/pagination/Pagination';
import Sort from 'ui/shared/sort/Sort';
import type { Sort as TSort } from './utils';
import { SORT_OPTIONS } from './utils';
interface Props {
pagination: PaginationParams;
searchTerm: string | undefined;
onSearchChange: (value: string) => void;
filterValue: EnsDomainLookupFiltersOptions;
onFilterValueChange: (nextValue: EnsDomainLookupFiltersOptions) => void;
sort: TSort | undefined;
onSortChange: (nextValue: TSort | undefined) => void;
isLoading: boolean;
isAddressSearch: boolean;
}
const NameDomainsActionBar = ({
searchTerm,
onSearchChange,
filterValue,
onFilterValueChange,
sort,
onSortChange,
isLoading,
isAddressSearch,
pagination,
}: Props) => {
const isInitialLoading = useIsInitialLoading(isLoading);
const searchInput = (
<FilterInput
w={{ base: '100%', lg: '360px' }}
minW={{ base: 'auto', lg: '250px' }}
size="xs"
onChange={ onSearchChange }
placeholder="Search by name"
initialValue={ searchTerm }
isLoading={ isInitialLoading }
/>
);
const filter = (
<PopoverFilter appliedFiltersNum={ filterValue.length } contentProps={{ w: '220px' }} isLoading={ isInitialLoading }>
<div>
<CheckboxGroup size="lg" onChange={ onFilterValueChange } value={ filterValue } defaultValue={ filterValue }>
<Text variant="secondary" fontWeight={ 600 } mb={ 3 } fontSize="sm">Address</Text>
<Checkbox value="owned_by" display="block" isDisabled={ !isAddressSearch }>
Owned by
</Checkbox>
<Checkbox
value="resolved_to"
display="block"
mt={ 5 }
mb={ 4 }
pb={ 4 }
borderBottom="1px solid"
borderColor="divider"
isDisabled={ !isAddressSearch }
>
Resolved to address
</Checkbox>
<Text variant="secondary" fontWeight={ 600 } mb={ 3 } fontSize="sm">Status</Text>
<Checkbox value="with_inactive" display="block">
Include expired
</Checkbox>
</CheckboxGroup>
</div>
</PopoverFilter>
);
const sortButton = (
<Sort
options={ SORT_OPTIONS }
sort={ sort }
setSort={ onSortChange }
isLoading={ isInitialLoading }
/>
);
return (
<>
<HStack spacing={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ filter }
{ sortButton }
{ searchInput }
</HStack>
<ActionBar
mt={ -6 }
display={{ base: pagination.isVisible ? 'flex' : 'none', lg: 'flex' }}
>
<HStack spacing={ 3 } display={{ base: 'none', lg: 'flex' }}>
{ filter }
{ searchInput }
</HStack>
<Pagination { ...pagination } ml="auto"/>
</ActionBar>
</>
);
};
export default React.memo(NameDomainsActionBar);
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { EnsDomain } from 'types/api/ens';
import dayjs from 'lib/date/dayjs';
import NameDomainExpiryStatus from 'ui/nameDomain/NameDomainExpiryStatus';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
interface Props extends EnsDomain {
isLoading: boolean;
}
const NameDomainsListItem = ({ name, isLoading, resolved_address: resolvedAddress, registration_date: registrationDate, expiry_date: expiryDate }: Props) => {
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>Domain</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<EnsEntity name={ name } isLoading={ isLoading } fontWeight={ 500 }/>
</ListItemMobileGrid.Value>
{ resolvedAddress && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Address</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<AddressEntity address={ resolvedAddress } isLoading={ isLoading } fontWeight={ 500 }/>
</ListItemMobileGrid.Value>
</>
) }
{ registrationDate && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Registered on</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading }>
<div>{ dayjs(registrationDate).format('MMM DD YYYY HH:mm:ss A') }</div>
<div> { dayjs(registrationDate).fromNow() }</div>
</Skeleton>
</ListItemMobileGrid.Value>
</>
) }
{ expiryDate && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Expiration date</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">
<div>{ dayjs(expiryDate).format('MMM DD YYYY HH:mm:ss A') } </div>
<NameDomainExpiryStatus date={ expiryDate }/>
</Skeleton>
</ListItemMobileGrid.Value>
</>
) }
</ListItemMobileGrid.Container>
);
};
export default React.memo(NameDomainsListItem);
import { Table, Tbody, Tr, Th, Link } from '@chakra-ui/react';
import React from 'react';
import type { EnsDomainLookupResponse } from 'types/api/ens';
import IconSvg from 'ui/shared/IconSvg';
import { default as Thead } from 'ui/shared/TheadSticky';
import NameDomainsTableItem from './NameDomainsTableItem';
import { type Sort } from './utils';
interface Props {
data: EnsDomainLookupResponse | undefined;
isLoading?: boolean;
sort: Sort | undefined;
onSortToggle: (event: React.MouseEvent) => void;
}
const NameDomainsTable = ({ data, isLoading, sort, onSortToggle }: Props) => {
const sortIconTransform = sort?.toLowerCase().includes('asc') ? 'rotate(-90deg)' : 'rotate(90deg)';
return (
<Table variant="simple" size="sm">
<Thead top={ 80 }>
<Tr>
<Th width="25%">Domain</Th>
<Th width="25%">Address</Th>
<Th width="25%" pl={ 9 }>
<Link display="flex" alignItems="center" justifyContent="flex-start" position="relative" data-field="registration_date" onClick={ onSortToggle }>
{ sort?.includes('registration_date') && (
<IconSvg
name="arrows/east"
boxSize={ 4 }
transform={ sortIconTransform }
color="link"
position="absolute"
left={ -5 }
top={ 0 }
/>
) }
<span>Registered on</span>
</Link>
</Th>
<Th width="25%">Expiration date</Th>
</Tr>
</Thead>
<Tbody>
{ data?.items.map((item, index) => <NameDomainsTableItem key={ index } { ...item } isLoading={ isLoading }/>) }
</Tbody>
</Table>
);
};
export default React.memo(NameDomainsTable);
import { chakra, Tr, Td, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { EnsDomain } from 'types/api/ens';
import dayjs from 'lib/date/dayjs';
import NameDomainExpiryStatus from 'ui/nameDomain/NameDomainExpiryStatus';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
type Props = EnsDomain & {
isLoading?: boolean;
}
const NameDomainsTableItem = ({ isLoading, name, resolved_address: resolvedAddress, registration_date: registrationDate, expiry_date: expiryDate }: Props) => {
return (
<Tr>
<Td verticalAlign="middle">
<EnsEntity name={ name } isLoading={ isLoading } fontWeight={ 600 }/>
</Td>
<Td verticalAlign="middle">
{ resolvedAddress && <AddressEntity address={ resolvedAddress } isLoading={ isLoading } fontWeight={ 500 }/> }
</Td>
<Td verticalAlign="middle" pl={ 9 }>
{ registrationDate && (
<Skeleton isLoaded={ !isLoading }>
{ dayjs(registrationDate).format('MMM DD YYYY HH:mm:ss A') }
<chakra.span color="text_secondary"> { dayjs(registrationDate).fromNow() }</chakra.span>
</Skeleton>
) }
</Td>
<Td verticalAlign="middle">
{ expiryDate && (
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">
<span>{ dayjs(expiryDate).format('MMM DD YYYY HH:mm:ss A') } </span>
<NameDomainExpiryStatus date={ expiryDate }/>
</Skeleton>
) }
</Td>
</Tr>
);
};
export default React.memo(NameDomainsTableItem);
import type { EnsLookupSorting } from 'types/api/ens';
import getNextSortValueShared from 'ui/shared/sort/getNextSortValue';
import type { Option } from 'ui/shared/sort/Sort';
export type SortField = EnsLookupSorting['sort'];
export type Sort = `${ EnsLookupSorting['sort'] }-${ EnsLookupSorting['order'] }`;
export const SORT_OPTIONS: Array<Option<Sort>> = [
{ title: 'Default', id: undefined },
{ title: 'Registered on descending', id: 'registration_date-DESC' },
{ title: 'Registered on ascending', id: 'registration_date-ASC' },
];
const SORT_SEQUENCE: Record<SortField, Array<Sort | undefined>> = {
registration_date: [ 'registration_date-DESC', 'registration_date-ASC', undefined ],
};
export const getNextSortValue = (getNextSortValueShared<SortField, Sort>).bind(undefined, SORT_SEQUENCE);
...@@ -21,6 +21,7 @@ const addresses: AddressesResponse = { ...@@ -21,6 +21,7 @@ const addresses: AddressesResponse = {
...addressMocks.token, ...addressMocks.token,
tx_count: '109123890123', tx_count: '109123890123',
coin_balance: '22222345678901234567890000', coin_balance: '22222345678901234567890000',
ens_domain_name: null,
}, { }, {
...addressMocks.withoutName, ...addressMocks.withoutName,
tx_count: '11', tx_count: '11',
......
...@@ -23,11 +23,13 @@ import AddressTxs from 'ui/address/AddressTxs'; ...@@ -23,11 +23,13 @@ import AddressTxs from 'ui/address/AddressTxs';
import AddressWithdrawals from 'ui/address/AddressWithdrawals'; import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton'; import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode'; import AddressQrCode from 'ui/address/details/AddressQrCode';
import AddressEnsDomains from 'ui/address/ensDomains/AddressEnsDomains';
import SolidityscanReport from 'ui/address/SolidityscanReport'; import SolidityscanReport from 'ui/address/SolidityscanReport';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
...@@ -172,14 +174,25 @@ const AddressPageContent = () => { ...@@ -172,14 +174,25 @@ const AddressPageContent = () => {
const titleSecondRow = ( const titleSecondRow = (
<Flex alignItems="center" w="100%" columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}> <Flex alignItems="center" w="100%" columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
{ addressQuery.data?.ens_domain_name && (
<EnsEntity
name={ addressQuery.data?.ens_domain_name }
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
mr={ 1 }
maxW="300px"
/>
) }
<AddressEntity <AddressEntity
address={{ ...addressQuery.data, hash, name: '' }} address={{ ...addressQuery.data, hash, name: '', ens_domain_name: '' }}
isLoading={ isLoading } isLoading={ isLoading }
fontFamily="heading" fontFamily="heading"
fontSize="lg" fontSize="lg"
fontWeight={ 500 } fontWeight={ 500 }
noLink noLink
isSafeAddress={ isSafeAddress } isSafeAddress={ isSafeAddress }
mr={ 4 }
/> />
{ !isLoading && addressQuery.data?.is_contract && addressQuery.data.token && { !isLoading && addressQuery.data?.is_contract && addressQuery.data.token &&
<AddressAddToWallet token={ addressQuery.data.token } variant="button"/> } <AddressAddToWallet token={ addressQuery.data.token } variant="button"/> }
...@@ -190,6 +203,8 @@ const AddressPageContent = () => { ...@@ -190,6 +203,8 @@ const AddressPageContent = () => {
<AccountActionsMenu isLoading={ isLoading }/> <AccountActionsMenu isLoading={ isLoading }/>
<HStack ml="auto" gap={ 2 }/> <HStack ml="auto" gap={ 2 }/>
{ addressQuery.data?.is_contract && addressQuery.data?.is_verified && config.UI.views.address.solidityscanEnabled && <SolidityscanReport hash={ hash }/> } { addressQuery.data?.is_contract && addressQuery.data?.is_verified && config.UI.views.address.solidityscanEnabled && <SolidityscanReport hash={ hash }/> }
{ !isLoading && addressQuery.data && config.features.nameService.isEnabled &&
<AddressEnsDomains addressHash={ hash } mainDomainName={ addressQuery.data.ens_domain_name }/> }
<NetworkExplorers type="address" pathParam={ hash }/> <NetworkExplorers type="address" pathParam={ hash }/>
</Flex> </Flex>
); );
......
...@@ -113,7 +113,18 @@ const BlockPageContent = () => { ...@@ -113,7 +113,18 @@ const BlockPageContent = () => {
}; };
}, [ appProps.referrer ]); }, [ appProps.referrer ]);
const title = blockQuery.data?.type === 'reorg' ? `Reorged block #${ blockQuery.data?.height }` : `Block #${ blockQuery.data?.height }`; const title = (() => {
switch (blockQuery.data?.type) {
case 'reorg':
return `Reorged block #${ blockQuery.data?.height }`;
case 'uncle':
return `Uncle block #${ blockQuery.data?.height }`;
default:
return `Block #${ blockQuery.data?.height }`;
}
})();
const titleSecondRow = ( const titleSecondRow = (
<> <>
{ !config.UI.views.block.hiddenFields?.miner && ( { !config.UI.views.block.hiddenFields?.miner && (
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import config from 'configs/app';
import * as textAdMock from 'mocks/ad/textAd';
import * as ensDomainMock from 'mocks/ens/domain';
import * as ensDomainEventsMock from 'mocks/ens/events';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import NameDomain from './NameDomain';
const DOMAIN_API_URL = buildApiUrl('domain_info', { chainId: config.chain.id, name: ensDomainMock.ensDomainA.name });
const DOMAIN_EVENTS_API_URL = buildApiUrl('domain_events', { chainId: config.chain.id, name: ensDomainMock.ensDomainA.name });
test.beforeEach(async({ page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
});
});
});
test('details tab', async({ mount, page }) => {
await page.route(DOMAIN_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(ensDomainMock.ensDomainA),
}));
const component = await mount(
<TestApp>
<NameDomain/>
</TestApp>,
{ hooksConfig: {
router: {
query: { name: ensDomainMock.ensDomainA.name },
isReady: true,
},
} },
);
await expect(component).toHaveScreenshot();
});
test('history tab +@mobile', async({ mount, page }) => {
await page.route(DOMAIN_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(ensDomainMock.ensDomainA),
}));
await page.route(DOMAIN_EVENTS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
ensDomainEventsMock.ensDomainEventA,
ensDomainEventsMock.ensDomainEventB,
],
totalRecords: 2,
}),
}));
const component = await mount(
<TestApp>
<NameDomain/>
</TestApp>,
{ hooksConfig: {
router: {
query: { name: ensDomainMock.ensDomainA.name, tab: 'history' },
isReady: true,
},
} },
);
await expect(component).toHaveScreenshot();
});
import { Flex, Tooltip } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ENS_DOMAIN } from 'stubs/ENS';
import NameDomainDetails from 'ui/nameDomain/NameDomainDetails';
import NameDomainHistory from 'ui/nameDomain/NameDomainHistory';
import TextAd from 'ui/shared/ad/TextAd';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/LinkInternal';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
const NameDomain = () => {
const isMobile = useIsMobile();
const router = useRouter();
const domainName = getQueryParamString(router.query.name);
const infoQuery = useApiQuery('domain_info', {
pathParams: { name: domainName, chainId: config.chain.id },
queryOptions: {
placeholderData: ENS_DOMAIN,
},
});
const tabs: Array<RoutedTab> = [
{ id: 'details', title: 'Details', component: <NameDomainDetails query={ infoQuery }/> },
{ id: 'history', title: 'History', component: <NameDomainHistory/> },
];
const tabIndex = useTabIndexFromQuery(tabs);
if (infoQuery.isError) {
throw new Error(undefined, { cause: infoQuery.error });
}
const isLoading = infoQuery.isPlaceholderData;
const titleSecondRow = (
<Flex columnGap={ 3 } rowGap={ 3 } fontFamily="heading" fontSize="lg" fontWeight={ 500 } alignItems="center" w="100%">
<EnsEntity
name={ domainName }
isLoading={ isLoading }
noLink
maxW={ infoQuery.data?.resolved_address ? '300px' : 'min-content' }
/>
{ infoQuery.data?.resolved_address && (
<AddressEntity
address={ infoQuery.data?.resolved_address }
isLoading={ isLoading }
truncation={ isMobile ? 'constant' : 'dynamic' }
flexShrink={ 0 }
/>
) }
{ infoQuery.data?.resolved_address && (
<Tooltip label="Lookup for related domain names">
<LinkInternal
flexShrink={ 0 }
display="inline-flex"
href={ route({ pathname: '/name-domains', query: { owned_by: 'true', resolved_to: 'true', address: infoQuery.data?.resolved_address?.hash } }) }
>
<IconSvg name="search" boxSize={ 5 } isLoading={ isLoading }/>
</LinkInternal>
</Tooltip>
) }
</Flex>
);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle title="ENS Domain details" secondRow={ titleSecondRow }/>
{ infoQuery.isPlaceholderData ? (
<>
<TabsSkeleton tabs={ tabs } mt={ 6 }/>
{ tabs[tabIndex]?.component }
</>
) : <RoutedTabs tabs={ tabs }/> }
</>
);
};
export default NameDomain;
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import config from 'configs/app';
import * as textAdMock from 'mocks/ad/textAd';
import * as ensDomainMock from 'mocks/ens/domain';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import NameDomains from './NameDomains';
const DOMAINS_LOOKUP_API_URL = buildApiUrl('domains_lookup', { chainId: config.chain.id }) + '?only_active=true';
test.beforeEach(async({ page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
});
});
});
test('default view +@mobile', async({ mount, page }) => {
await page.route(DOMAINS_LOOKUP_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
ensDomainMock.ensDomainA,
ensDomainMock.ensDomainB,
ensDomainMock.ensDomainC,
ensDomainMock.ensDomainD,
],
next_page_params: {
token_id: '<token-id>',
},
}),
}));
const component = await mount(
<TestApp>
<NameDomains/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Box, Hide, Show } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { EnsDomainLookupFiltersOptions, EnsLookupSorting } from 'types/api/ens';
import config from 'configs/app';
import useDebounce from 'lib/hooks/useDebounce';
import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import { ENS_DOMAIN } from 'stubs/ENS';
import { generateListStub } from 'stubs/utils';
import NameDomainsActionBar from 'ui/nameDomains/NameDomainsActionBar';
import NameDomainsListItem from 'ui/nameDomains/NameDomainsListItem';
import NameDomainsTable from 'ui/nameDomains/NameDomainsTable';
import type { Sort, SortField } from 'ui/nameDomains/utils';
import { SORT_OPTIONS, getNextSortValue } from 'ui/nameDomains/utils';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
const NameDomains = () => {
const router = useRouter();
const q = getQueryParamString(router.query.name) || getQueryParamString(router.query.address);
const ownedBy = getQueryParamString(router.query.owned_by);
const resolvedTo = getQueryParamString(router.query.resolved_to);
const onlyActive = getQueryParamString(router.query.only_active);
const initialFilters: EnsDomainLookupFiltersOptions = [
ownedBy === 'true' ? 'owned_by' as const : undefined,
resolvedTo === 'true' ? 'resolved_to' as const : undefined,
onlyActive === 'false' ? 'with_inactive' as const : undefined,
].filter(Boolean);
const initialSort = getSortValueFromQuery<Sort>(router.query, SORT_OPTIONS);
const [ searchTerm, setSearchTerm ] = React.useState<string>(q || '');
const [ filterValue, setFilterValue ] = React.useState<EnsDomainLookupFiltersOptions>(initialFilters);
const [ sort, setSort ] = React.useState<Sort | undefined>(initialSort);
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const isAddressSearch = React.useMemo(() => ADDRESS_REGEXP.test(debouncedSearchTerm), [ debouncedSearchTerm ]);
const sortParams = getSortParamsFromValue<Sort, EnsLookupSorting['sort'], EnsLookupSorting['order']>(sort);
const addressesLookupQuery = useQueryWithPages({
resourceName: 'addresses_lookup',
pathParams: { chainId: config.chain.id },
filters: {
address: debouncedSearchTerm,
resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'),
},
sorting: sortParams,
options: {
enabled: isAddressSearch,
placeholderData: generateListStub<'addresses_lookup'>(ENS_DOMAIN, 50, { next_page_params: null }),
},
});
const domainsLookupQuery = useQueryWithPages({
resourceName: 'domains_lookup',
pathParams: { chainId: config.chain.id },
filters: {
name: debouncedSearchTerm,
only_active: !filterValue.includes('with_inactive'),
},
sorting: sortParams,
options: {
enabled: !isAddressSearch,
placeholderData: generateListStub<'domains_lookup'>(ENS_DOMAIN, 50, { next_page_params: null }),
},
});
const query = isAddressSearch ? addressesLookupQuery : domainsLookupQuery;
const { data, isError, isPlaceholderData: isLoading, onFilterChange, onSortingChange } = query;
React.useEffect(() => {
const hasInactiveFilter = filterValue.some((value) => value === 'with_inactive');
if (isAddressSearch) {
setFilterValue([ 'owned_by' as const, 'resolved_to' as const, hasInactiveFilter ? 'with_inactive' as const : undefined ].filter(Boolean));
onFilterChange<'addresses_lookup'>({
address: debouncedSearchTerm,
resolved_to: true,
owned_by: true,
only_active: !hasInactiveFilter,
});
} else {
setFilterValue([ hasInactiveFilter ? 'with_inactive' as const : undefined ].filter(Boolean));
onFilterChange<'domains_lookup'>({
name: debouncedSearchTerm,
only_active: !hasInactiveFilter,
});
}
// should run only the type of search changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isAddressSearch ]);
const handleSortToggle = React.useCallback((event: React.MouseEvent) => {
if (isLoading) {
return;
}
const field = (event.currentTarget as HTMLDivElement).getAttribute('data-field') as SortField | undefined;
if (field) {
setSort((prevValue) => {
const nextSortValue = getNextSortValue(field)(prevValue);
onSortingChange(getSortParamsFromValue(nextSortValue));
return nextSortValue;
});
}
}, [ isLoading, onSortingChange ]);
const handleSearchTermChange = React.useCallback((value: string) => {
setSearchTerm(value);
const isAddressSearch = ADDRESS_REGEXP.test(value);
if (isAddressSearch) {
onFilterChange<'addresses_lookup'>({
address: value,
resolved_to: filterValue.includes('resolved_to'),
owned_by: filterValue.includes('owned_by'),
only_active: !filterValue.includes('with_inactive'),
});
} else {
onFilterChange<'domains_lookup'>({
name: value,
only_active: !filterValue.includes('with_inactive'),
});
}
}, [ onFilterChange, filterValue ]);
const handleFilterValueChange = React.useCallback((value: EnsDomainLookupFiltersOptions) => {
setFilterValue(value);
const isAddressSearch = ADDRESS_REGEXP.test(debouncedSearchTerm);
if (isAddressSearch) {
onFilterChange<'addresses_lookup'>({
address: debouncedSearchTerm,
resolved_to: value.includes('resolved_to'),
owned_by: value.includes('owned_by'),
only_active: !value.includes('with_inactive'),
});
} else {
onFilterChange<'domains_lookup'>({
name: debouncedSearchTerm,
only_active: !value.includes('with_inactive'),
});
}
}, [ debouncedSearchTerm, onFilterChange ]);
const hasActiveFilters = Boolean(debouncedSearchTerm) || filterValue.length > 0;
const content = (
<>
<Show below="lg" ssr={ false }>
<Box>
{ data?.items.map((item, index) => (
<NameDomainsListItem
key={ item.id + (isLoading ? index : '') }
{ ...item }
isLoading={ isLoading }
/>
)) }
</Box>
</Show>
<Hide below="lg" ssr={ false }>
<NameDomainsTable
data={ data }
isLoading={ isLoading }
sort={ sort }
onSortToggle={ handleSortToggle }
/>
</Hide>
</>
);
const actionBar = (
<NameDomainsActionBar
isLoading={ isLoading }
searchTerm={ searchTerm }
onSearchChange={ handleSearchTermChange }
filterValue={ filterValue }
onFilterValueChange={ handleFilterValueChange }
sort={ sort }
onSortChange={ setSort }
isAddressSearch={ isAddressSearch }
pagination={ query.pagination }
/>
);
return (
<>
<PageTitle title="ENS domains lookup" withTextAd/>
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no name domains."
filterProps={{
emptyFilteredText: `Couldn${ apos }t find name domains that match your filter query.`,
hasActiveFilters,
}}
content={ content }
actionBar={ actionBar }
/>
</>
);
};
export default NameDomains;
...@@ -32,6 +32,7 @@ test.describe('search by name ', () => { ...@@ -32,6 +32,7 @@ test.describe('search by name ', () => {
searchMock.token1, searchMock.token1,
searchMock.token2, searchMock.token2,
searchMock.contract1, searchMock.contract1,
searchMock.address2,
searchMock.label1, searchMock.label1,
], ],
}), }),
...@@ -91,6 +92,7 @@ test('search by block number +@mobile', async({ mount, page }) => { ...@@ -91,6 +92,7 @@ test('search by block number +@mobile', async({ mount, page }) => {
items: [ items: [
searchMock.block1, searchMock.block1,
searchMock.block2, searchMock.block2,
searchMock.block3,
], ],
}), }),
})); }));
......
import { Flex, Grid, Image, Box, Text, Skeleton, useColorMode, Tag } from '@chakra-ui/react'; import { chakra, Flex, Grid, Image, Box, Text, Skeleton, useColorMode, Tag } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import xss from 'xss'; import xss from 'xss';
...@@ -10,6 +10,7 @@ import dayjs from 'lib/date/dayjs'; ...@@ -10,6 +10,7 @@ import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText'; import highlightText from 'lib/highlightText';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
...@@ -73,13 +74,14 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -73,13 +74,14 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
case 'contract': case 'contract':
case 'address': { case 'address': {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase(); const shouldHighlightHash = ADDRESS_REGEXP.test(searchTerm);
const address = { const address = {
hash: data.address, hash: data.address,
is_contract: data.type === 'contract', is_contract: data.type === 'contract',
is_verified: data.is_smart_contract_verified, is_verified: data.is_smart_contract_verified,
name: null, name: null,
implementation_name: null, implementation_name: null,
ens_domain_name: null,
}; };
return ( return (
...@@ -173,6 +175,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -173,6 +175,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
/> />
</BlockEntity.Link> </BlockEntity.Link>
{ data.block_type === 'reorg' && <Tag ml={ 2 }>Reorg</Tag> } { data.block_type === 'reorg' && <Tag ml={ 2 }>Reorg</Tag> }
{ data.block_type === 'uncle' && <Tag ml={ 2 }>Uncle</Tag> }
</BlockEntity.Container> </BlockEntity.Container>
); );
} }
...@@ -264,8 +267,23 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -264,8 +267,23 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
} }
case 'contract': case 'contract':
case 'address': { case 'address': {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase(); const shouldHighlightHash = ADDRESS_REGEXP.test(searchTerm);
return data.name ? <span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? xss(data.name) : highlightText(data.name, searchTerm) }}/> : null; const addressName = data.name || data.ens_info?.name;
const expiresText = data.ens_info?.expiry_date ? ` (expires ${ dayjs(data.ens_info.expiry_date).fromNow() })` : '';
return addressName ? (
<>
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? xss(addressName) : highlightText(addressName, searchTerm) }}/>
{ data.ens_info &&
(
data.ens_info.names_count > 1 ?
<chakra.span color="text_secondary"> ({ data.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }` })</chakra.span> :
<chakra.span color="text_secondary">{ expiresText }</chakra.span>
)
}
</>
) :
null;
} }
default: default:
......
import { Tr, Td, Text, Flex, Image, Box, Skeleton, useColorMode, Tag } from '@chakra-ui/react'; import { chakra, Tr, Td, Text, Flex, Image, Box, Skeleton, useColorMode, Tag } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import xss from 'xss'; import xss from 'xss';
...@@ -10,6 +10,7 @@ import dayjs from 'lib/date/dayjs'; ...@@ -10,6 +10,7 @@ import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText'; import highlightText from 'lib/highlightText';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
...@@ -20,7 +21,6 @@ import LinkExternal from 'ui/shared/LinkExternal'; ...@@ -20,7 +21,6 @@ import LinkExternal from 'ui/shared/LinkExternal';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import type { SearchResultAppItem } from 'ui/shared/search/utils'; import type { SearchResultAppItem } from 'ui/shared/search/utils';
import { getItemCategory, searchItemTitles } from 'ui/shared/search/utils'; import { getItemCategory, searchItemTitles } from 'ui/shared/search/utils';
interface Props { interface Props {
data: SearchResultItem | SearchResultAppItem; data: SearchResultItem | SearchResultAppItem;
searchTerm: string; searchTerm: string;
...@@ -91,70 +91,51 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -91,70 +91,51 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
case 'contract': case 'contract':
case 'address': { case 'address': {
if (data.name) { const shouldHighlightHash = ADDRESS_REGEXP.test(searchTerm);
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase(); const addressName = data.name || data.ens_info?.name;
const address = {
hash: data.address,
is_contract: data.type === 'contract',
is_verified: data.is_smart_contract_verified,
name: null,
implementation_name: null,
};
return (
<>
<Td fontSize="sm">
<AddressEntity.Container>
<AddressEntity.Icon address={ address }/>
<AddressEntity.Link
address={ address }
onClick={ handleLinkClick }
>
<AddressEntity.Content
asProp={ shouldHighlightHash ? 'mark' : 'span' }
address={ address }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</AddressEntity.Link>
<AddressEntity.Copy address={ address }/>
</AddressEntity.Container>
</Td>
<Td colSpan={ 2 } fontSize="sm" verticalAlign="middle">
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? xss(data.name) : highlightText(data.name, searchTerm) }}/>
</Td>
</>
);
}
const address = { const address = {
hash: data.address, hash: data.address,
is_contract: data.type === 'contract', is_contract: data.type === 'contract',
is_verified: data.is_smart_contract_verified, is_verified: data.is_smart_contract_verified,
name: null, name: null,
implementation_name: null, implementation_name: null,
ens_domain_name: null,
}; };
const expiresText = data.ens_info?.expiry_date ? ` (expires ${ dayjs(data.ens_info.expiry_date).fromNow() })` : '';
return ( return (
<Td colSpan={ 3 } fontSize="sm"> <>
<AddressEntity.Container> <Td fontSize="sm" colSpan={ addressName ? 1 : 3 }>
<AddressEntity.Icon address={ address }/> <AddressEntity.Container>
<AddressEntity.Link <AddressEntity.Icon address={ address }/>
address={ address } <AddressEntity.Link
onClick={ handleLinkClick }
>
<AddressEntity.Content
asProp="mark"
address={ address } address={ address }
fontSize="sm" onClick={ handleLinkClick }
lineHeight={ 5 } >
fontWeight={ 700 } <AddressEntity.Content
/> asProp={ shouldHighlightHash ? 'mark' : 'span' }
</AddressEntity.Link> address={ address }
<AddressEntity.Copy address={ address }/> fontSize="sm"
</AddressEntity.Container> lineHeight={ 5 }
</Td> fontWeight={ 700 }
/>
</AddressEntity.Link>
<AddressEntity.Copy address={ address }/>
</AddressEntity.Container>
</Td>
{ addressName && (
<Td colSpan={ 2 } fontSize="sm" verticalAlign="middle">
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? xss(addressName) : highlightText(addressName, searchTerm) }}/>
{ data.ens_info &&
(
data.ens_info.names_count > 1 ?
<chakra.span color="text_secondary"> ({ data.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }` })</chakra.span> :
<chakra.span color="text_secondary">{ expiresText }</chakra.span>
)
}
</Td>
) }
</>
); );
} }
...@@ -263,6 +244,7 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -263,6 +244,7 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
<Td fontSize="sm" verticalAlign="middle"> <Td fontSize="sm" verticalAlign="middle">
<Flex columnGap={ 2 } alignItems="center"> <Flex columnGap={ 2 } alignItems="center">
{ data.block_type === 'reorg' && <Tag flexShrink={ 0 }>Reorg</Tag> } { data.block_type === 'reorg' && <Tag flexShrink={ 0 }>Reorg</Tag> }
{ data.block_type === 'uncle' && <Tag flexShrink={ 0 }>Uncle</Tag> }
<Box overflow="hidden" whiteSpace="nowrap" as={ shouldHighlightHash ? 'mark' : 'span' } display="block"> <Box overflow="hidden" whiteSpace="nowrap" as={ shouldHighlightHash ? 'mark' : 'span' } display="block">
<HashStringShortenDynamic hash={ data.block_hash }/> <HashStringShortenDynamic hash={ data.block_hash }/>
</Box> </Box>
......
import { GridItem, chakra } from '@chakra-ui/react';
import React from 'react';
import type { GasPriceInfo } from 'types/api/stats';
import { asymp, space } from 'lib/html-entities';
interface Props {
name: string;
info: GasPriceInfo | null;
}
const GasInfoRow = ({ name, info }: Props) => {
const content = (() => {
if (!info || info.price === null) {
return 'N/A';
}
return (
<>
<span>{ info.fiat_price ? `$${ info.fiat_price }` : `${ info.price } Gwei` }</span>
{ info.time && (
<chakra.span color="text_secondary">
{ space }per tx { asymp } { (info.time / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s
</chakra.span>
) }
</>
);
})();
return (
<>
<GridItem color="blue.100">{ name }</GridItem>
<GridItem color="text" textAlign="right">{ content }</GridItem>
</>
);
};
export default React.memo(GasInfoRow);
import { Grid, GridItem, useColorModeValue } from '@chakra-ui/react'; import { DarkMode, Grid, GridItem } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { GasPrices } from 'types/api/stats'; import type { HomeStats } from 'types/api/stats';
const GasInfoTooltipContent = ({ gasPrices }: {gasPrices: GasPrices}) => { import dayjs from 'lib/date/dayjs';
const nameStyleProps = {
color: useColorModeValue('blue.100', 'blue.600'), import GasInfoRow from './GasInfoRow';
}; import GasInfoUpdateTimer from './GasInfoUpdateTimer';
interface Props {
data: HomeStats;
dataUpdatedAt: number;
}
const GasInfoTooltipContent = ({ data, dataUpdatedAt }: Props) => {
if (!data.gas_prices) {
return null;
}
return ( return (
<Grid templateColumns="repeat(2, max-content)" rowGap={ 2 } columnGap={ 4 } padding={ 4 } fontSize="xs"> <DarkMode>
<GridItem { ...nameStyleProps }>Slow</GridItem> <Grid templateColumns="repeat(2, max-content)" rowGap={ 2 } columnGap={ 4 } padding={ 4 } fontSize="xs" lineHeight={ 4 }>
<GridItem>{ gasPrices.slow !== null ? `${ gasPrices.slow } Gwei` : 'N/A' }</GridItem> { data.gas_price_updated_at && (
<GridItem { ...nameStyleProps }>Average</GridItem> <>
<GridItem>{ gasPrices.average !== null ? `${ gasPrices.average } Gwei` : 'N/A' }</GridItem> <GridItem color="text_secondary">Last update</GridItem>
<GridItem { ...nameStyleProps }>Fast</GridItem> <GridItem color="text_secondary" display="flex" justifyContent="flex-end" columnGap={ 2 }>
<GridItem>{ gasPrices.fast !== null ? `${ gasPrices.fast } Gwei` : 'N/A' }</GridItem> { dayjs(data.gas_price_updated_at).format('MMM DD, HH:mm:ss') }
</Grid> { data.gas_prices_update_in !== 0 &&
<GasInfoUpdateTimer key={ dataUpdatedAt } startTime={ dataUpdatedAt } duration={ data.gas_prices_update_in }/> }
</GridItem>
</>
) }
<GasInfoRow name="Slow" info={ data.gas_prices.slow }/>
<GasInfoRow name="Normal" info={ data.gas_prices.average }/>
<GasInfoRow name="Fast" info={ data.gas_prices.fast }/>
</Grid>
</DarkMode>
); );
}; };
......
import { CircularProgress } from '@chakra-ui/react';
import React from 'react';
import dayjs from 'lib/date/dayjs';
interface Props {
startTime: number;
duration: number;
}
const getValue = (startDate: dayjs.Dayjs, duration: number) => {
const now = dayjs();
const diff = now.diff(startDate, 'ms');
const value = diff / duration * 100;
if (value >= 99) {
return 99;
}
return value;
};
const GasInfoUpdateTimer = ({ startTime, duration }: Props) => {
const [ value, setValue ] = React.useState(getValue(dayjs(startTime), duration));
React.useEffect(() => {
const startDate = dayjs(startTime);
const intervalId = window.setInterval(() => {
const nextValue = getValue(startDate, duration);
setValue(nextValue);
if (nextValue === 99) {
window.clearInterval(intervalId);
}
}, 100);
return () => {
window.clearInterval(intervalId);
};
}, [ startTime, duration ]);
return <CircularProgress value={ value } trackColor="whiteAlpha.100" size={ 4 }/>;
};
export default React.memo(GasInfoUpdateTimer);
...@@ -11,6 +11,8 @@ import React, { useEffect, useRef, useState } from 'react'; ...@@ -11,6 +11,8 @@ import React, { useEffect, useRef, useState } from 'react';
import type { TabItem } from './types'; import type { TabItem } from './types';
import isBrowser from 'lib/isBrowser';
import AdaptiveTabsList from './AdaptiveTabsList'; import AdaptiveTabsList from './AdaptiveTabsList';
import { menuButton } from './utils'; import { menuButton } from './utils';
...@@ -39,7 +41,7 @@ const TabsWithScroll = ({ ...@@ -39,7 +41,7 @@ const TabsWithScroll = ({
...themeProps ...themeProps
}: Props) => { }: Props) => {
const [ activeTabIndex, setActiveTabIndex ] = useState<number>(defaultTabIndex || 0); const [ activeTabIndex, setActiveTabIndex ] = useState<number>(defaultTabIndex || 0);
const [ screenWidth, setScreenWidth ] = React.useState(0); const [ screenWidth, setScreenWidth ] = React.useState(isBrowser() ? window.innerWidth : 0);
const tabsRef = useRef<HTMLDivElement>(null); const tabsRef = useRef<HTMLDivElement>(null);
......
...@@ -55,7 +55,6 @@ const TokenTransferFilter = ({ ...@@ -55,7 +55,6 @@ const TokenTransferFilter = ({
</RadioGroup> </RadioGroup>
</> </>
) } ) }
<Text variant="secondary" fontWeight={ 600 }>Type</Text>
<TokenTypeFilter<TokenType> onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters } nftOnly={ false }/> <TokenTypeFilter<TokenType> onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters } nftOnly={ false }/>
</PopoverFilter> </PopoverFilter>
); );
......
...@@ -80,6 +80,19 @@ test.describe('loading', () => { ...@@ -80,6 +80,19 @@ test.describe('loading', () => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
});
test('with ENS', async({ mount }) => {
const component = await mount(
<TestApp>
<AddressEntity
address={ addressMock.withEns }
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
}); });
test('external link', async({ mount }) => { test('external link', async({ mount }) => {
......
...@@ -98,10 +98,11 @@ const Icon = (props: IconProps) => { ...@@ -98,10 +98,11 @@ const Icon = (props: IconProps) => {
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'address'>; type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'address'>;
const Content = chakra((props: ContentProps) => { const Content = chakra((props: ContentProps) => {
if (props.address.name) { if (props.address.name || props.address.ens_domain_name) {
const text = props.address.ens_domain_name || props.address.name;
const label = ( const label = (
<VStack gap={ 0 } py={ 1 } color="inherit"> <VStack gap={ 0 } py={ 1 } color="inherit">
<Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ props.address.name }</Box> <Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ text }</Box>
<Box whiteSpace="pre-wrap" wordBreak="break-word">{ props.address.hash }</Box> <Box whiteSpace="pre-wrap" wordBreak="break-word">{ props.address.hash }</Box>
</VStack> </VStack>
); );
...@@ -109,7 +110,7 @@ const Content = chakra((props: ContentProps) => { ...@@ -109,7 +110,7 @@ const Content = chakra((props: ContentProps) => {
return ( return (
<Tooltip label={ label } maxW="100vw"> <Tooltip label={ label } maxW="100vw">
<Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span"> <Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span">
{ props.address.name } { text }
</Skeleton> </Skeleton>
</Tooltip> </Tooltip>
); );
...@@ -137,7 +138,7 @@ const Copy = (props: CopyProps) => { ...@@ -137,7 +138,7 @@ const Copy = (props: CopyProps) => {
const Container = EntityBase.Container; const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps { export interface EntityProps extends EntityBase.EntityBaseProps {
address: Pick<AddressParam, 'hash' | 'name' | 'is_contract' | 'is_verified' | 'implementation_name'>; address: Pick<AddressParam, 'hash' | 'name' | 'is_contract' | 'is_verified' | 'implementation_name' | 'ens_domain_name'>;
isSafeAddress?: boolean; isSafeAddress?: boolean;
} }
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import EnsEntity from './EnsEntity';
const name = 'cat.eth';
const iconSizes = [ 'md', 'lg' ];
test.use({ viewport: { width: 180, height: 30 } });
test.describe('icon size', () => {
iconSizes.forEach((size) => {
test(size, async({ mount }) => {
const component = await mount(
<TestApp>
<EnsEntity
name={ name }
iconSize={ size }
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
});
});
test('loading', async({ mount }) => {
const component = await mount(
<TestApp>
<EnsEntity
name={ name }
isLoading
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('with long name', async({ mount }) => {
const component = await mount(
<TestApp>
<EnsEntity
name="kitty.kitty.kitty.cat.eth"
/>
</TestApp>,
);
await component.getByText(name.slice(0, 4)).hover();
await expect(component).toHaveScreenshot();
});
test('customization', async({ mount }) => {
const component = await mount(
<TestApp>
<EnsEntity
name={ name }
p={ 3 }
borderWidth="1px"
borderColor="blue.700"
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { chakra } from '@chakra-ui/react';
import _omit from 'lodash/omit';
import React from 'react';
import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components';
import TruncatedValue from 'ui/shared/TruncatedValue';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'name'>;
const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/name-domains/[name]', query: { name: props.name } });
return (
<EntityBase.Link
{ ...props }
href={ props.href ?? defaultHref }
>
{ props.children }
</EntityBase.Link>
);
});
type IconProps = Omit<EntityBase.IconBaseProps, 'name'> & {
iconName?: EntityBase.IconBaseProps['name'];
};
const Icon = (props: IconProps) => {
return (
<EntityBase.Icon
{ ...props }
name={ props.iconName ?? 'ENS_slim' }
/>
);
};
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'name'>;
const Content = chakra((props: ContentProps) => {
return (
<TruncatedValue
isLoading={ props.isLoading }
value={ props.name }
/>
);
});
type CopyProps = Omit<EntityBase.CopyBaseProps, 'text'> & Pick<EntityProps, 'name'>;
const Copy = (props: CopyProps) => {
return (
<EntityBase.Copy
{ ...props }
text={ props.name }
/>
);
};
const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
name: string;
}
const EnsEntity = (props: EntityProps) => {
const linkProps = _omit(props, [ 'className' ]);
const partsProps = _omit(props, [ 'className', 'onClick' ]);
return (
<Container className={ props.className }>
<Icon { ...partsProps }/>
<Link { ...linkProps }>
<Content { ...partsProps }/>
</Link>
<Copy { ...partsProps }/>
</Container>
);
};
export default React.memo(chakra(EnsEntity));
export {
Container,
Link,
Icon,
Content,
Copy,
};
...@@ -19,7 +19,7 @@ const FilterButton = ({ isActive, isLoading, appliedFiltersNum, onClick, as }: P ...@@ -19,7 +19,7 @@ const FilterButton = ({ isActive, isLoading, appliedFiltersNum, onClick, as }: P
const badgeBgColor = useColorModeValue('blue.700', 'gray.50'); const badgeBgColor = useColorModeValue('blue.700', 'gray.50');
if (isLoading) { if (isLoading) {
return <Skeleton w={{ base: 9, lg: '78px' }} h={ 8 } borderRadius="base"/>; return <Skeleton w={{ base: 9, lg: '78px' }} h={ 8 } borderRadius="base" flexShrink={ 0 }/>;
} }
return ( return (
......
...@@ -37,6 +37,7 @@ const FilterInput = ({ onChange, className, size = 'sm', placeholder, initialVal ...@@ -37,6 +37,7 @@ const FilterInput = ({ onChange, className, size = 'sm', placeholder, initialVal
isLoaded={ !isLoading } isLoaded={ !isLoading }
className={ className } className={ className }
minW="250px" minW="250px"
borderRadius="base"
> >
<InputGroup <InputGroup
size={ size } size={ size }
......
...@@ -3,7 +3,6 @@ import React from 'react'; ...@@ -3,7 +3,6 @@ import React from 'react';
import type { Props } from './types'; import type { Props } from './types';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary'; import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import HeaderAlert from 'ui/snippets/header/HeaderAlert';
import HeaderDesktop from 'ui/snippets/header/HeaderDesktop'; import HeaderDesktop from 'ui/snippets/header/HeaderDesktop';
import HeaderMobile from 'ui/snippets/header/HeaderMobile'; import HeaderMobile from 'ui/snippets/header/HeaderMobile';
...@@ -19,7 +18,6 @@ const LayoutDefault = ({ children }: Props) => { ...@@ -19,7 +18,6 @@ const LayoutDefault = ({ children }: Props) => {
paddingTop={{ base: 16, lg: 6 }} paddingTop={{ base: 16, lg: 6 }}
paddingX={{ base: 4, lg: 6 }} paddingX={{ base: 4, lg: 6 }}
> >
<HeaderAlert/>
<HeaderDesktop isMarketplaceAppPage/> <HeaderDesktop isMarketplaceAppPage/>
<AppErrorBoundary> <AppErrorBoundary>
<Layout.Content pt={{ base: 0, lg: 6 }}> <Layout.Content pt={{ base: 0, lg: 6 }}>
......
...@@ -7,7 +7,7 @@ interface Props { ...@@ -7,7 +7,7 @@ interface Props {
const MainArea = ({ children }: Props) => { const MainArea = ({ children }: Props) => {
return ( return (
<Flex w="100%" minH="100vh" alignItems="stretch"> <Flex w="100%" minH="calc(100vh - 36px)" alignItems="stretch">
{ children } { children }
</Flex> </Flex>
); );
......
...@@ -20,13 +20,19 @@ interface Props { ...@@ -20,13 +20,19 @@ interface Props {
} }
const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => { const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
const [ isMediaLoading, setIsMediaLoading ] = React.useState(Boolean(url)); const [ isMediaLoading, setIsMediaLoading ] = React.useState(true);
const [ isLoadingError, setIsLoadingError ] = React.useState(false); const [ isLoadingError, setIsLoadingError ] = React.useState(false);
const { ref, inView } = useInView({ triggerOnce: true }); const { ref, inView } = useInView({ triggerOnce: true });
const type = useNftMediaType(url, !isLoading && inView); const type = useNftMediaType(url, !isLoading && inView);
React.useEffect(() => {
if (!isLoading) {
setIsMediaLoading(Boolean(url));
}
}, [ isLoading, url ]);
const handleMediaLoaded = React.useCallback(() => { const handleMediaLoaded = React.useCallback(() => {
setIsMediaLoading(false); setIsMediaLoading(false);
}, []); }, []);
......
...@@ -37,7 +37,7 @@ function getPaginationParamsFromQuery(queryString: string | Array<string> | unde ...@@ -37,7 +37,7 @@ function getPaginationParamsFromQuery(queryString: string | Array<string> | unde
export type QueryWithPagesResult<Resource extends PaginatedResources> = export type QueryWithPagesResult<Resource extends PaginatedResources> =
UseQueryResult<ResourcePayload<Resource>, ResourceError<unknown>> & UseQueryResult<ResourcePayload<Resource>, ResourceError<unknown>> &
{ {
onFilterChange: (filters: PaginationFilters<Resource>) => void; onFilterChange: <R extends PaginatedResources = Resource>(filters: PaginationFilters<R>) => void;
onSortingChange: (sorting?: PaginationSorting<Resource>) => void; onSortingChange: (sorting?: PaginationSorting<Resource>) => void;
pagination: PaginationParams; pagination: PaginationParams;
} }
...@@ -136,12 +136,13 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -136,12 +136,13 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
}); });
}, [ queryClient, resourceName, router, scrollToTop ]); }, [ queryClient, resourceName, router, scrollToTop ]);
const onFilterChange = useCallback((newFilters: PaginationFilters<Resource> | undefined) => { const onFilterChange = useCallback(<R extends PaginatedResources = Resource>(newFilters: PaginationFilters<R> | undefined) => {
const newQuery = omit<typeof router.query>(router.query, 'next_page_params', 'page', resource.filterFields); const newQuery = omit<typeof router.query>(router.query, 'next_page_params', 'page', resource.filterFields);
if (newFilters) { if (newFilters) {
Object.entries(newFilters).forEach(([ key, value ]) => { Object.entries(newFilters).forEach(([ key, value ]) => {
if (value && value.length) { const isValidValue = typeof value === 'boolean' || (value && value.length);
newQuery[key] = Array.isArray(value) ? value.join(',') : (value || ''); if (isValidValue) {
newQuery[key] = Array.isArray(value) ? value.join(',') : (String(value) || '');
} }
}); });
} }
......
import { Text, HStack } from '@chakra-ui/react'; import { HStack, Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { Step } from './types'; import type { Step } from './types';
...@@ -17,7 +17,7 @@ const VerificationStep = ({ step, isLast, isPassed }: Props) => { ...@@ -17,7 +17,7 @@ const VerificationStep = ({ step, isLast, isPassed }: Props) => {
return ( return (
<HStack gap={ 2 } color={ stepColor }> <HStack gap={ 2 } color={ stepColor }>
<IconSvg name={ isPassed ? 'finalized' : 'unfinalized' } boxSize={ 5 }/> <IconSvg name={ isPassed ? 'finalized' : 'unfinalized' } boxSize={ 5 }/>
<Text color={ stepColor }>{ typeof step === 'string' ? step : step.content }</Text> <Box color={ stepColor }>{ typeof step === 'string' ? step : step.content }</Box>
{ !isLast && <IconSvg name="arrows/east" boxSize={ 5 }/> } { !isLast && <IconSvg name="arrows/east" boxSize={ 5 }/> }
</HStack> </HStack>
); );
......
...@@ -30,7 +30,7 @@ const VerificationSteps = ({ currentStep, steps, isLoading, rightSlot, className ...@@ -30,7 +30,7 @@ const VerificationSteps = ({ currentStep, steps, isLoading, rightSlot, className
> >
{ steps.map((step, index) => ( { steps.map((step, index) => (
<VerificationStep <VerificationStep
key={ currentStep } key={ index }
step={ step } step={ step }
isLast={ index === steps.length - 1 && !rightSlot } isLast={ index === steps.length - 1 && !rightSlot }
isPassed={ index <= currentStepIndex } isPassed={ index <= currentStepIndex }
......
...@@ -65,6 +65,7 @@ test('search by contract name +@mobile +@dark-mode', async({ mount, page }) => ...@@ -65,6 +65,7 @@ test('search by contract name +@mobile +@dark-mode', async({ mount, page }) =>
status: 200, status: 200,
body: JSON.stringify([ body: JSON.stringify([
searchMock.contract1, searchMock.contract1,
searchMock.address2,
]), ]),
})); }));
...@@ -148,6 +149,7 @@ test('search by block number +@mobile', async({ mount, page }) => { ...@@ -148,6 +149,7 @@ test('search by block number +@mobile', async({ mount, page }) => {
body: JSON.stringify([ body: JSON.stringify([
searchMock.block1, searchMock.block1,
searchMock.block2, searchMock.block2,
searchMock.block3,
]), ]),
})); }));
......
import { Box, Text, Flex } from '@chakra-ui/react'; import { chakra, Box, Text, Flex } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SearchResultAddressOrContract } from 'types/api/search'; import type { SearchResultAddressOrContract } from 'types/api/search';
import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText'; import highlightText from 'lib/highlightText';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
...@@ -14,23 +16,41 @@ interface Props { ...@@ -14,23 +16,41 @@ interface Props {
} }
const SearchBarSuggestAddress = ({ data, isMobile, searchTerm }: Props) => { const SearchBarSuggestAddress = ({ data, isMobile, searchTerm }: Props) => {
const shouldHighlightHash = data.address.toLowerCase() === searchTerm.toLowerCase(); const shouldHighlightHash = ADDRESS_REGEXP.test(searchTerm);
const icon = ( const icon = (
<AddressEntity.Icon <AddressEntity.Icon
address={{ hash: data.address, is_contract: data.type === 'contract', name: '', is_verified: data.is_smart_contract_verified, implementation_name: null }} address={{
hash: data.address,
is_contract: data.type === 'contract',
name: '',
is_verified: data.is_smart_contract_verified,
implementation_name: null,
ens_domain_name: null,
}}
/> />
); );
const name = data.name && ( const addressName = data.name || data.ens_info?.name;
const expiresText = data.ens_info?.expiry_date ? ` (expires ${ dayjs(data.ens_info.expiry_date).fromNow() })` : '';
const nameEl = addressName && (
<Text <Text
variant="secondary" variant="secondary"
overflow="hidden" overflow="hidden"
whiteSpace="nowrap" whiteSpace="nowrap"
textOverflow="ellipsis" textOverflow="ellipsis"
> >
<span dangerouslySetInnerHTML={{ __html: highlightText(data.name, searchTerm) }}/> <chakra.span fontWeight={ 500 } dangerouslySetInnerHTML={{ __html: highlightText(addressName, searchTerm) }}/>
{ data.ens_info &&
(
data.ens_info.names_count > 1 ?
<span> ({ data.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }` })</span> :
<span>{ expiresText }</span>
)
}
</Text> </Text>
); );
const address = <HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>; const addressEl = <HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>;
if (isMobile) { if (isMobile) {
return ( return (
...@@ -44,10 +64,10 @@ const SearchBarSuggestAddress = ({ data, isMobile, searchTerm }: Props) => { ...@@ -44,10 +64,10 @@ const SearchBarSuggestAddress = ({ data, isMobile, searchTerm }: Props) => {
whiteSpace="nowrap" whiteSpace="nowrap"
fontWeight={ 700 } fontWeight={ 700 }
> >
{ address } { addressEl }
</Box> </Box>
</Flex> </Flex>
{ name } { nameEl }
</> </>
); );
} }
...@@ -63,10 +83,10 @@ const SearchBarSuggestAddress = ({ data, isMobile, searchTerm }: Props) => { ...@@ -63,10 +83,10 @@ const SearchBarSuggestAddress = ({ data, isMobile, searchTerm }: Props) => {
whiteSpace="nowrap" whiteSpace="nowrap"
fontWeight={ 700 } fontWeight={ 700 }
> >
{ address } { addressEl }
</Box> </Box>
</Flex> </Flex>
{ name } { nameEl }
</Flex> </Flex>
); );
}; };
......
...@@ -47,6 +47,7 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => { ...@@ -47,6 +47,7 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => {
{ icon } { icon }
{ blockNumber } { blockNumber }
{ data.block_type === 'reorg' && <Tag ml="auto">Reorg</Tag> } { data.block_type === 'reorg' && <Tag ml="auto">Reorg</Tag> }
{ data.block_type === 'uncle' && <Tag ml="auto">Uncle</Tag> }
</Flex> </Flex>
{ hash } { hash }
<Text variant="secondary">{ date }</Text> <Text variant="secondary">{ date }</Text>
...@@ -62,6 +63,7 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => { ...@@ -62,6 +63,7 @@ const SearchBarSuggestBlock = ({ data, isMobile, searchTerm }: Props) => {
</Flex> </Flex>
<Flex columnGap={ 3 } minW={ 0 } alignItems="center"> <Flex columnGap={ 3 } minW={ 0 } alignItems="center">
{ data.block_type === 'reorg' && <Tag flexShrink={ 0 }>Reorg</Tag> } { data.block_type === 'reorg' && <Tag flexShrink={ 0 }>Reorg</Tag> }
{ data.block_type === 'uncle' && <Tag flexShrink={ 0 }>Uncle</Tag> }
{ hash } { hash }
</Flex> </Flex>
<Text variant="secondary" textAlign="end">{ date }</Text> <Text variant="secondary" textAlign="end">{ date }</Text>
......
...@@ -19,7 +19,7 @@ test('default view +@dark-mode +@mobile', async({ mount, page }) => { ...@@ -19,7 +19,7 @@ test('default view +@dark-mode +@mobile', async({ mount, page }) => {
</TestApp>, </TestApp>,
); );
await component.getByText(/gwei/i).hover(); await component.getByText(/\$1\.01/).hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 220 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 220 } });
await component.getByLabel('color mode switch').click(); await component.getByLabel('color mode switch').click();
......
import { Flex, LightMode, Link, Skeleton, Tooltip, chakra, useDisclosure } from '@chakra-ui/react'; import { Flex, Link, Skeleton, Tooltip, chakra, useDisclosure } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import dayjs from 'lib/date/dayjs';
import { HOMEPAGE_STATS } from 'stubs/stats'; import { HOMEPAGE_STATS } from 'stubs/stats';
import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent'; import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent';
import TextSeparator from 'ui/shared/TextSeparator'; import TextSeparator from 'ui/shared/TextSeparator';
...@@ -16,13 +17,39 @@ const TopBarStats = () => { ...@@ -16,13 +17,39 @@ const TopBarStats = () => {
onToggle(); onToggle();
}, [ onToggle ]); }, [ onToggle ]);
const { data, isPlaceholderData, isError } = useApiQuery('homepage_stats', { const { data, isPlaceholderData, isError, refetch, dataUpdatedAt } = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: { queryOptions: {
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
refetchOnMount: false, refetchOnMount: false,
}, },
}); });
React.useEffect(() => {
if (isPlaceholderData || !data?.gas_price_updated_at) {
return;
}
const endDate = dayjs(dataUpdatedAt).add(data.gas_prices_update_in, 'ms');
const timeout = endDate.diff(dayjs(), 'ms');
if (timeout <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
refetch();
}, timeout);
return () => {
window.clearTimeout(timeoutId);
};
}, [ isPlaceholderData, data?.gas_price_updated_at, dataUpdatedAt, data?.gas_prices_update_in, refetch ]);
if (isError) { if (isError) {
return <div/>; return <div/>;
} }
...@@ -34,35 +61,42 @@ const TopBarStats = () => { ...@@ -34,35 +61,42 @@ const TopBarStats = () => {
fontWeight={ 500 } fontWeight={ 500 }
> >
{ data?.coin_price && ( { data?.coin_price && (
<Skeleton isLoaded={ !isPlaceholderData }> <Flex columnGap={ 1 }>
<chakra.span color="text_secondary">{ config.chain.governanceToken.symbol || config.chain.currency.symbol } </chakra.span> <Skeleton isLoaded={ !isPlaceholderData }>
<span>${ Number(data.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }) }</span> <chakra.span color="text_secondary">{ config.chain.governanceToken.symbol || config.chain.currency.symbol } </chakra.span>
</Skeleton> <span>${ Number(data.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }) }</span>
</Skeleton>
{ data.coin_price_change_percentage && (
<Skeleton isLoaded={ !isPlaceholderData }>
<chakra.span color={ Number(data.coin_price_change_percentage) >= 0 ? 'green.500' : 'red.500' }>
{ Number(data.coin_price_change_percentage).toFixed(2) }%
</chakra.span>
</Skeleton>
) }
</Flex>
) } ) }
{ data?.coin_price && config.UI.homepage.showGasTracker && <TextSeparator color="divider"/> } { data?.coin_price && config.UI.homepage.showGasTracker && <TextSeparator color="divider"/> }
{ data?.gas_prices && data.gas_prices.average !== null && config.UI.homepage.showGasTracker && ( { data?.gas_prices && data.gas_prices.average !== null && config.UI.homepage.showGasTracker && (
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
<chakra.span color="text_secondary">Gas </chakra.span> <chakra.span color="text_secondary">Gas </chakra.span>
<LightMode> <Tooltip
<Tooltip label={ <GasInfoTooltipContent data={ data } dataUpdatedAt={ dataUpdatedAt }/> }
label={ <GasInfoTooltipContent gasPrices={ data.gas_prices }/> } hasArrow={ false }
hasArrow={ false } borderRadius="md"
borderRadius="md" offset={ [ 0, 16 ] }
offset={ [ 0, 16 ] } bgColor="blackAlpha.900"
bgColor="blackAlpha.900" p={ 0 }
p={ 0 } isOpen={ isOpen }
isOpen={ isOpen } >
<Link
_hover={{ textDecoration: 'none', color: 'link_hovered' }}
onClick={ handleClick }
onMouseEnter={ onOpen }
onMouseLeave={ onClose }
> >
<Link { data.gas_prices.average.fiat_price ? `$${ data.gas_prices.average.fiat_price }` : `${ data.gas_prices.average.price } Gwei` }
_hover={{ textDecoration: 'none', color: 'link_hovered' }} </Link>
onClick={ handleClick } </Tooltip>
onMouseEnter={ onOpen }
onMouseLeave={ onClose }
>
{ data.gas_prices.average } Gwei
</Link>
</Tooltip>
</LightMode>
</Skeleton> </Skeleton>
) } ) }
</Flex> </Flex>
......
import { Box, Button, Text } from '@chakra-ui/react'; import { Box, Button, Text } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import * as mixpanel from 'lib/mixpanel/index';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps'; import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
...@@ -9,38 +10,45 @@ type Props = { ...@@ -9,38 +10,45 @@ type Props = {
disconnect?: () => void; disconnect?: () => void;
}; };
const WalletMenuContent = ({ address, disconnect }: Props) => ( const WalletMenuContent = ({ address, disconnect }: Props) => {
<Box> const onAddressClick = React.useCallback(() => {
<Text mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Address click' });
fontSize="sm" }, []);
fontWeight={ 600 }
mb={ 1 } return (
{ ...getDefaultTransitionProps() } <Box>
> <Text
My wallet fontSize="sm"
</Text> fontWeight={ 600 }
<Text mb={ 1 }
fontSize="sm" { ...getDefaultTransitionProps() }
mb={ 5 } >
fontWeight={ 400 } My wallet
color="text_secondary" </Text>
{ ...getDefaultTransitionProps() } <Text
> fontSize="sm"
Your wallet is used to interact with apps and contracts in the explorer. mb={ 5 }
</Text> fontWeight={ 400 }
<AddressEntity color="text_secondary"
address={{ hash: address }} { ...getDefaultTransitionProps() }
noTooltip >
truncation="dynamic" Your wallet is used to interact with apps and contracts in the explorer.
fontSize="sm" </Text>
fontWeight={ 700 } <AddressEntity
color="text" address={{ hash: address }}
mb={ 6 } noTooltip
/> truncation="dynamic"
<Button size="sm" width="full" variant="outline" onClick={ disconnect }> fontSize="sm"
Disconnect fontWeight={ 700 }
</Button> color="text"
</Box> mb={ 6 }
); onClick={ onAddressClick }
/>
<Button size="sm" width="full" variant="outline" onClick={ disconnect }>
Disconnect
</Button>
</Box>
);
};
export default WalletMenuContent; export default WalletMenuContent;
...@@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverBody, PopoverTrigger, Button, Box, useB ...@@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverBody, PopoverTrigger, Button, Box, useB
import React from 'react'; import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon'; import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import useWallet from 'ui/snippets/walletMenu/useWallet'; import useWallet from 'ui/snippets/walletMenu/useWallet';
...@@ -48,6 +49,11 @@ const WalletMenuDesktop = ({ isHomePage }: Props) => { ...@@ -48,6 +49,11 @@ const WalletMenuDesktop = ({ isHomePage }: Props) => {
}; };
} }
const openPopover = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Open' });
setIsPopoverOpen.on();
}, [ setIsPopoverOpen ]);
return ( return (
<Popover <Popover
openDelay={ 300 } openDelay={ 300 }
...@@ -66,7 +72,7 @@ const WalletMenuDesktop = ({ isHomePage }: Props) => { ...@@ -66,7 +72,7 @@ const WalletMenuDesktop = ({ isHomePage }: Props) => {
flexShrink={ 0 } flexShrink={ 0 }
isLoading={ isModalOpening || isModalOpen } isLoading={ isModalOpening || isModalOpen }
loadingText="Connect wallet" loadingText="Connect wallet"
onClick={ isWalletConnected ? setIsPopoverOpen.on : connect } onClick={ isWalletConnected ? openPopover : connect }
fontSize="sm" fontSize="sm"
{ ...buttonStyles } { ...buttonStyles }
> >
......
...@@ -2,6 +2,7 @@ import { Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, IconBu ...@@ -2,6 +2,7 @@ import { Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, IconBu
import React from 'react'; import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon'; import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import useWallet from 'ui/snippets/walletMenu/useWallet'; import useWallet from 'ui/snippets/walletMenu/useWallet';
...@@ -16,6 +17,11 @@ const WalletMenuMobile = () => { ...@@ -16,6 +17,11 @@ const WalletMenuMobile = () => {
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors(); const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const openPopover = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Open' });
onOpen();
}, [ onOpen ]);
return ( return (
<> <>
<WalletTooltip isDisabled={ isWalletConnected || isMobile === undefined || !isMobile } isMobile> <WalletTooltip isDisabled={ isWalletConnected || isMobile === undefined || !isMobile } isMobile>
...@@ -32,7 +38,7 @@ const WalletMenuMobile = () => { ...@@ -32,7 +38,7 @@ const WalletMenuMobile = () => {
bg={ isWalletConnected ? themedBackground : undefined } bg={ isWalletConnected ? themedBackground : undefined }
color={ themedColor } color={ themedColor }
borderColor={ !isWalletConnected ? themedBorderColor : undefined } borderColor={ !isWalletConnected ? themedBorderColor : undefined }
onClick={ isWalletConnected ? onOpen : connect } onClick={ isWalletConnected ? openPopover : connect }
isLoading={ isModalOpening || isModalOpen } isLoading={ isModalOpening || isModalOpen }
/> />
</WalletTooltip> </WalletTooltip>
......
...@@ -2,6 +2,8 @@ import { Tooltip, useBoolean, useOutsideClick } from '@chakra-ui/react'; ...@@ -2,6 +2,8 @@ import { Tooltip, useBoolean, useOutsideClick } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { SECOND } from 'lib/consts';
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
isDisabled?: boolean; isDisabled?: boolean;
...@@ -26,12 +28,15 @@ const WalletTooltip = ({ children, isDisabled, isMobile }: Props) => { ...@@ -26,12 +28,15 @@ const WalletTooltip = ({ children, isDisabled, isMobile }: Props) => {
React.useEffect(() => { React.useEffect(() => {
const wasShown = window.localStorage.getItem(localStorageKey); const wasShown = window.localStorage.getItem(localStorageKey);
if (!isDisabled && !wasShown) { const isMarketplacePage = [ '/apps', '/apps/[id]' ].includes(router.pathname);
setIsTooltipShown.on(); if (!isDisabled && !wasShown && isMarketplacePage) {
window.localStorage.setItem(localStorageKey, 'true'); setTimeout(() => {
setTimeout(() => setIsTooltipShown.off(), 3000); setIsTooltipShown.on();
window.localStorage.setItem(localStorageKey, 'true');
setTimeout(() => setIsTooltipShown.off(), 5 * SECOND);
}, SECOND);
} }
}, [ setIsTooltipShown, localStorageKey, isDisabled ]); }, [ setIsTooltipShown, localStorageKey, isDisabled, router.pathname ]);
return ( return (
<Tooltip <Tooltip
......
import type { TooltipProps } from '@chakra-ui/react';
import { Box, Grid, Heading, List, ListItem, Skeleton } from '@chakra-ui/react'; import { Box, Grid, Heading, List, ListItem, Skeleton } from '@chakra-ui/react';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { StatsChartsSection } from 'types/api/stats'; import type { StatsChartsSection } from 'types/api/stats';
import type { StatsIntervalIds } from 'types/client/stats'; import type { StatsIntervalIds } from 'types/client/stats';
import useApiQuery from 'lib/api/useApiQuery';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/shared/EmptySearchResult'; import EmptySearchResult from 'ui/shared/EmptySearchResult';
import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent';
import Hint from 'ui/shared/Hint';
import ChartsLoadingErrorAlert from './ChartsLoadingErrorAlert'; import ChartsLoadingErrorAlert from './ChartsLoadingErrorAlert';
import ChartWidgetContainer from './ChartWidgetContainer'; import ChartWidgetContainer from './ChartWidgetContainer';
const GAS_TOOLTIP_PROPS: Partial<TooltipProps> = {
borderRadius: 'md',
hasArrow: false,
padding: 0,
};
type Props = { type Props = {
filterQuery: string; filterQuery: string;
isError: boolean; isError: boolean;
...@@ -23,6 +33,17 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in ...@@ -23,6 +33,17 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in
const isAnyChartDisplayed = charts?.some((section) => section.charts.length > 0); const isAnyChartDisplayed = charts?.some((section) => section.charts.length > 0);
const isEmptyChartList = Boolean(filterQuery) && !isAnyChartDisplayed; const isEmptyChartList = Boolean(filterQuery) && !isAnyChartDisplayed;
const homeStatsQuery = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: {
refetchOnMount: false,
},
});
const handleChartLoadingError = useCallback( const handleChartLoadingError = useCallback(
() => setIsSomeChartLoadingError(true), () => setIsSomeChartLoadingError(true),
[ setIsSomeChartLoadingError ]); [ setIsSomeChartLoadingError ]);
...@@ -51,10 +72,16 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in ...@@ -51,10 +72,16 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in
marginBottom: 0, marginBottom: 0,
}} }}
> >
<Skeleton isLoaded={ !isPlaceholderData } mb={ 4 } display="inline-block"> <Skeleton isLoaded={ !isPlaceholderData } mb={ 4 } display="inline-flex" alignItems="center" columnGap={ 2 }>
<Heading size="md" > <Heading size="md" >
{ section.title } { section.title }
</Heading> </Heading>
{ section.id === 'gas' && homeStatsQuery.data && homeStatsQuery.data.gas_prices && (
<Hint
label={ <GasInfoTooltipContent data={ homeStatsQuery.data } dataUpdatedAt={ homeStatsQuery.dataUpdatedAt }/> }
tooltipProps={ GAS_TOOLTIP_PROPS }
/>
) }
</Skeleton> </Skeleton>
<Grid <Grid
......
...@@ -48,6 +48,7 @@ const TokensTableItem = ({ ...@@ -48,6 +48,7 @@ const TokensTableItem = ({
implementation_name: null, implementation_name: null,
is_contract: true, is_contract: true,
is_verified: false, is_verified: false,
ens_domain_name: null,
}; };
return ( return (
......
...@@ -150,10 +150,27 @@ const TxDetails = () => { ...@@ -150,10 +150,27 @@ const TxDetails = () => {
</Tag> </Tag>
) } ) }
</DetailsInfoItem> </DetailsInfoItem>
<TxDetailsWithdrawalStatus { config.features.optimisticRollup.isEnabled && data.op_withdrawals && data.op_withdrawals.length > 0 && (
status={ data.op_withdrawal_status } <DetailsInfoItem
l1TxHash={ data.op_l1_transaction_hash } title="Withdrawal status"
/> hint="Detailed status progress of the transaction"
>
<Flex flexDir="column" rowGap={ 2 }>
{ data.op_withdrawals.map((withdrawal) => (
<Box key={ withdrawal.nonce }>
<Box mb={ 2 }>
<span>Nonce: </span>
<chakra.span fontWeight={ 600 }>{ withdrawal.nonce }</chakra.span>
</Box>
<TxDetailsWithdrawalStatus
status={ withdrawal.status }
l1TxHash={ withdrawal.l1_transaction_hash }
/>
</Box>
)) }
</Flex>
</DetailsInfoItem>
) }
{ data.zkevm_status && ( { data.zkevm_status && (
<DetailsInfoItem <DetailsInfoItem
title="Confirmation status" title="Confirmation status"
......
import { Box } from '@chakra-ui/react';
import { test as base, expect } from '@playwright/experimental-ct-react'; import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
...@@ -25,7 +26,9 @@ statuses.forEach((status) => { ...@@ -25,7 +26,9 @@ statuses.forEach((status) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetailsWithdrawalStatus status={ status } l1TxHash="0x7d93a59a228e97d084a635181c3053e324237d07566ec12287eae6da2bcf9456"/> <Box p={ 2 }>
<TxDetailsWithdrawalStatus status={ status } l1TxHash="0x7d93a59a228e97d084a635181c3053e324237d07566ec12287eae6da2bcf9456"/>
</Box>
</TestApp>, </TestApp>,
); );
......
...@@ -4,8 +4,6 @@ import React from 'react'; ...@@ -4,8 +4,6 @@ import React from 'react';
import type { L2WithdrawalStatus } from 'types/api/l2Withdrawals'; import type { L2WithdrawalStatus } from 'types/api/l2Withdrawals';
import { WITHDRAWAL_STATUSES } from 'types/api/l2Withdrawals'; import { WITHDRAWAL_STATUSES } from 'types/api/l2Withdrawals';
import config from 'configs/app';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
...@@ -15,10 +13,6 @@ interface Props { ...@@ -15,10 +13,6 @@ interface Props {
} }
const TxDetailsWithdrawalStatus = ({ status, l1TxHash }: Props) => { const TxDetailsWithdrawalStatus = ({ status, l1TxHash }: Props) => {
if (!config.features.optimisticRollup.isEnabled) {
return null;
}
if (!status || !WITHDRAWAL_STATUSES.includes(status)) { if (!status || !WITHDRAWAL_STATUSES.includes(status)) {
return null; return null;
} }
...@@ -55,23 +49,18 @@ const TxDetailsWithdrawalStatus = ({ status, l1TxHash }: Props) => { ...@@ -55,23 +49,18 @@ const TxDetailsWithdrawalStatus = ({ status, l1TxHash }: Props) => {
href="https://app.optimism.io/bridge/withdraw" href="https://app.optimism.io/bridge/withdraw"
target="_blank" target="_blank"
> >
Claim funds Claim funds
</Button> </Button>
) : null; ) : null;
return ( return (
<DetailsInfoItem <VerificationSteps
title="Withdrawal status" steps={ steps as unknown as Array<L2WithdrawalStatus> }
hint="Detailed status progress of the transaction" currentStep={ status }
> rightSlot={ rightSlot }
<VerificationSteps my={ hasClaimButton ? '-6px' : 0 }
steps={ steps as unknown as Array<L2WithdrawalStatus> } lineHeight={ hasClaimButton ? 8 : undefined }
currentStep={ status } />
rightSlot={ rightSlot }
my={ hasClaimButton ? '-6px' : 0 }
lineHeight={ hasClaimButton ? 8 : undefined }
/>
</DetailsInfoItem>
); );
}; };
......
...@@ -13969,6 +13969,13 @@ react-jazzicon@^1.0.4: ...@@ -13969,6 +13969,13 @@ react-jazzicon@^1.0.4:
dependencies: dependencies:
mersenne-twister "^1.1.0" mersenne-twister "^1.1.0"
react-number-format@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.3.1.tgz#840c257da9cb4b248990d8db46e4d23e8bac67ff"
integrity sha512-qpYcQLauIeEhCZUZY9jXZnnroOtdy3jYaS1zQ3M1Sr6r/KMOBEIGNIb7eKT19g2N1wbYgFgvDzs19hw5TrB8XQ==
dependencies:
prop-types "^15.7.2"
react-redux@^8.1.2: react-redux@^8.1.2:
version "8.1.3" version "8.1.3"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.1.3.tgz#4fdc0462d0acb59af29a13c27ffef6f49ab4df46" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.1.3.tgz#4fdc0462d0acb59af29a13c27ffef6f49ab4df46"
......
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