Commit 8563cf20 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Add support for Ton Application Chains (#2722)

* operation lifecycle accordion

* operation entity

* adapt layout to mobile device

* basic operations view

* pass response data to operation details

* update types package

* tac operation status and links to tx on ton

* feature config, docs and operation badge

* add info to tx page

* add operations to search

* search input on operations page

* tests

* update tooltips and temporary fix of ts

* add InterFallback typeface with → glyph

* design and api updates

* support new API version

* add time format toggle

* add shield to entity icon

* minor fixes

* update screenshots

* clean up

* fix screenshot
parent 720fc065
...@@ -33,6 +33,7 @@ on: ...@@ -33,6 +33,7 @@ on:
- shibarium - shibarium
- scroll_sepolia - scroll_sepolia
- stability - stability
- tac_turin
- zkevm - zkevm
- zilliqa_prototestnet - zilliqa_prototestnet
- zksync - zksync
......
...@@ -383,6 +383,7 @@ ...@@ -383,6 +383,7 @@
"scroll_sepolia", "scroll_sepolia",
"shibarium", "shibarium",
"stability_testnet", "stability_testnet",
"tac_turin",
"zkevm", "zkevm",
"zilliqa_prototestnet", "zilliqa_prototestnet",
"zksync", "zksync",
......
...@@ -112,6 +112,17 @@ const statsApi = (() => { ...@@ -112,6 +112,17 @@ const statsApi = (() => {
}); });
})(); })();
const tacApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST');
if (!apiHost) {
return;
}
return Object.freeze({
endpoint: apiHost,
});
})();
const visualizeApi = (() => { const visualizeApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_VISUALIZE_API_HOST'); const apiHost = getEnvValue('NEXT_PUBLIC_VISUALIZE_API_HOST');
if (!apiHost) { if (!apiHost) {
...@@ -136,6 +147,7 @@ const apis: Apis = Object.freeze({ ...@@ -136,6 +147,7 @@ const apis: Apis = Object.freeze({
metadata: metadataApi, metadata: metadataApi,
rewards: rewardsApi, rewards: rewardsApi,
stats: statsApi, stats: statsApi,
tac: tacApi,
visualize: visualizeApi, visualize: visualizeApi,
}); });
......
...@@ -36,6 +36,7 @@ export { default as saveOnGas } from './saveOnGas'; ...@@ -36,6 +36,7 @@ export { default as saveOnGas } from './saveOnGas';
export { default as sol2uml } from './sol2uml'; export { default as sol2uml } from './sol2uml';
export { default as stats } from './stats'; export { default as stats } from './stats';
export { default as suave } from './suave'; export { default as suave } from './suave';
export { default as tac } from './tac';
export { default as txInterpretation } from './txInterpretation'; export { default as txInterpretation } from './txInterpretation';
export { default as userOps } from './userOps'; export { default as userOps } from './userOps';
export { default as addressProfileAPI } from './addressProfileAPI'; export { default as addressProfileAPI } from './addressProfileAPI';
......
import type { Feature } from './types';
import apis from '../apis';
import { getEnvValue } from '../utils';
const title = 'Ton Application Chain (TAC)';
const tonExplorerUrl = getEnvValue('NEXT_PUBLIC_TAC_TON_EXPLORER_URL');
const config: Feature<{ explorerUrl: string }> = (() => {
if (apis.tac && tonExplorerUrl) {
return Object.freeze({
title,
isEnabled: true,
explorerUrl: tonExplorerUrl,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
...@@ -53,6 +53,7 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=http://localhost:3006 ...@@ -53,6 +53,7 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=http://localhost:3006
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=http://localhost:3007 NEXT_PUBLIC_METADATA_SERVICE_API_HOST=http://localhost:3007
NEXT_PUBLIC_NAME_SERVICE_API_HOST=http://localhost:3008 NEXT_PUBLIC_NAME_SERVICE_API_HOST=http://localhost:3008
NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=http://localhost:3009 NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=http://localhost:3009
NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST=http://localhost:3100
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32'] NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
......
# Set of ENVs for TAC Turin network explorer
# https://tac-turin.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=tac_turin"
NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST=https://tac-operation-lifecycle.k8s-dev.blockscout.com
NEXT_PUBLIC_TAC_TON_EXPLORER_URL=https://testnet.tonscan.org
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# Instance ENVs
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=tac-turin.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/tac.json
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_GAS_TRACKER_UNITS=[ 'gwei' ]
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x9df2e91d1eed5637f0ffb9423b1fe34ff477942c2a3e64cfa46a95be81892214
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['no-repeat center/100% auto url(https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-skins/tac.jpg)'],'text_color':['rgba(242,235,255,1)'],'button':{'_default':{'background':['rgba(30,23,44,1)']},'_hover':{'background':['rgba(66,14,70,1)']}}}
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=TAC
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=TAC
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/tac-light.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/tac-dark.svg
NEXT_PUBLIC_NETWORK_ID=2390
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/tac-light.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/tac-dark.svg
NEXT_PUBLIC_NETWORK_NAME=TAC Turin
NEXT_PUBLIC_NETWORK_RPC_URL=https://turin.rpc.tac.build
NEXT_PUBLIC_NETWORK_SHORT_NAME=TAC Turin
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/tac-turin.png
NEXT_PUBLIC_STATS_API_BASE_PATH=/stats-service
NEXT_PUBLIC_STATS_API_HOST=https://tac-turin.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
...@@ -279,6 +279,23 @@ const beaconChainSchema = yup ...@@ -279,6 +279,23 @@ const beaconChainSchema = yup
}), }),
}); });
const tacSchema = yup
.object()
.shape({
NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_TAC_TON_EXPLORER_URL: yup
.string()
.when('NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST', {
is: (value: string) => Boolean(value),
then: (schema) => schema.test(urlTest),
otherwise: (schema) => schema.test(
'not-exist',
'NEXT_PUBLIC_TAC_TON_EXPLORER_URL can only be used with NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST',
value => value === undefined,
),
}),
});
const parentChainCurrencySchema = yup const parentChainCurrencySchema = yup
.object() .object()
.shape({ .shape({
...@@ -1088,6 +1105,7 @@ const schema = yup ...@@ -1088,6 +1105,7 @@ const schema = yup
.concat(celoSchema) .concat(celoSchema)
.concat(beaconChainSchema) .concat(beaconChainSchema)
.concat(bridgedTokensSchema) .concat(bridgedTokensSchema)
.concat(sentrySchema); .concat(sentrySchema)
.concat(tacSchema);
export default schema; export default schema;
NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST=https://tac-operation-lifecycle.blockscout.com
NEXT_PUBLIC_TAC_TON_EXPLORER_URL=https://tonscan.org
\ No newline at end of file
...@@ -63,6 +63,8 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d ...@@ -63,6 +63,8 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d
- [Address profile API](ENVS.md#address-profile-api) - [Address profile API](ENVS.md#address-profile-api)
- [Address XStar XHS score](ENVS.md#address-xstar-xhs-score) - [Address XStar XHS score](ENVS.md#address-xstar-xhs-score)
- [SUAVE chain](ENVS.md#suave-chain) - [SUAVE chain](ENVS.md#suave-chain)
- [Celo chain](ENVS.md#celo-chain)
- [Ton Application Chain (TAC)](ENVS.md#ton-application-chain-tac)
- [MetaSuites extension](ENVS.md#metasuites-extension) - [MetaSuites extension](ENVS.md#metasuites-extension)
- [Validators list](ENVS.md#validators-list) - [Validators list](ENVS.md#validators-list)
- [Sentry error monitoring](ENVS.md#sentry-error-monitoring) - [Sentry error monitoring](ENVS.md#sentry-error-monitoring)
...@@ -773,6 +775,17 @@ For blockchains that use the Celo platform. _Note_, that once the Celo mainnet b ...@@ -773,6 +775,17 @@ For blockchains that use the Celo platform. _Note_, that once the Celo mainnet b
&nbsp; &nbsp;
### Ton Application Chain (TAC)
For Ton Application Chains, this feature enables additional views, such as a list of cross-chain operations and a detailed page for a specific cross-chain operation, as well as extra fields on the transaction page.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST | `string` | URL for the TAC Operation Lifecycle service. | Required | - | `https://tac-operation-lifecycle.blockscout.com` | v2.1.0+ |
| NEXT_PUBLIC_TAC_TON_EXPLORER_URL | `string` | URL of the Ton chain explorer. This is used to build links to transactions and addresses on the Ton chain. | Required | - | `https://tonscan.org` | v2.1.0+ |
&nbsp;
### MetaSuites extension ### MetaSuites extension
Enables [MetaSuites browser extension](https://github.com/blocksecteam/metasuites) to integrate with the app views. Enables [MetaSuites browser extension](https://github.com/blocksecteam/metasuites) to integrate with the app views.
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="#91019B" fill-rule="evenodd" d="M20 10c0 5.523-4.477 10-10 10S0 15.523 0 10 4.477 0 10 0s10 4.477 10 10Zm-9.598 6.938V7.425c0-.231.187-.418.418-.418h5.214c.533 0 .862.58.589 1.037l-5.445 9.109c-.218.364-.776.21-.776-.215Zm-.804-4.373V3.05c0-.424-.559-.578-.776-.214l-5.445 9.108a.686.686 0 0 0 .589 1.037H9.18a.418.418 0 0 0 .418-.417Z" clip-rule="evenodd"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="#0098EA" d="M10 20c5.523 0 10-4.477 10-10S15.523 0 10 0 0 4.477 0 10s4.477 10 10 10Z"/>
<path fill="#fff" d="M13.414 5.581H6.585c-1.255 0-2.051 1.355-1.42 2.45l4.215 7.305a.716.716 0 0 0 1.24 0l4.215-7.305c.63-1.093-.165-2.45-1.42-2.45Zm-4.037 7.564-.918-1.776-2.215-3.961a.387.387 0 0 1 .34-.579h2.792v6.317Zm4.377-5.738L11.54 11.37l-.918 1.775V6.828h2.792c.306 0 .486.325.34.579Z"/>
</svg>
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.7 4a3.3 3.3 0 0 1 .99 6.447v9.105A3.301 3.301 0 0 1 21.7 26a3.3 3.3 0 0 1-3.26-3.809L8.502 17.46a3.297 3.297 0 0 1-2.203.84A3.29 3.29 0 0 1 3 15.019l.004-.17A3.293 3.293 0 0 1 6.3 11.735l.17.005a3.299 3.299 0 0 1 2.355 1.166l9.666-4.834A3.3 3.3 0 0 1 21.7 4Zm0 17.38a1.32 1.32 0 1 0 0 2.641 1.32 1.32 0 0 0 0-2.641ZM9.585 14.74c.003.036.009.072.01.109l.005.17c0 .251-.03.496-.084.731l9.802 4.667c.38-.396.856-.697 1.392-.865v-9.105a3.293 3.293 0 0 1-1.193-.675L9.585 14.74ZM6.3 13.715c-.74 0-1.32.594-1.32 1.303 0 .708.58 1.301 1.32 1.301.739 0 1.32-.593 1.32-1.301 0-.71-.581-1.303-1.32-1.303Zm15.4-7.737a1.32 1.32 0 1 0 0 2.642 1.32 1.32 0 0 0 0-2.642Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 0a3 3 0 0 1 .9 5.862v8.275A3.002 3.002 0 0 1 17 20a3 3 0 0 1-2.965-3.463L5 12.234A2.993 2.993 0 0 1 3 13c-1.657 0-3-1.336-3-2.983l.004-.155A2.993 2.993 0 0 1 3 7.032l.154.004a3 3 0 0 1 2.142 1.06L14.085 3.7A3 3 0 0 1 17 0Zm0 15.8a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4ZM5.985 9.763c.003.033.01.066.011.1l.004.154c0 .228-.028.45-.077.664l8.913 4.244a3 3 0 0 1 1.264-.788V5.862a2.992 2.992 0 0 1-1.083-.615L5.985 9.763ZM3 8.832c-.672 0-1.2.54-1.2 1.185 0 .644.528 1.183 1.2 1.183.672 0 1.2-.539 1.2-1.183 0-.645-.528-1.185-1.2-1.185ZM17 1.8a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x=".8" y=".8" width="18.4" height="18.4" rx="5.2" stroke="currentColor" stroke-width="1.6"/>
<path d="M9.293 8.182a1 1 0 0 0 1.414 0l2.627-2.627a.786.786 0 1 1 1.11 1.111l-2.626 2.627a1 1 0 0 0 0 1.414l2.627 2.627a.785.785 0 1 1-1.111 1.11l-2.627-2.626a1 1 0 0 0-1.414 0l-2.627 2.627a.786.786 0 1 1-1.11-1.111l2.626-2.627a1 1 0 0 0 0-1.414L5.555 6.666a.786.786 0 1 1 1.111-1.11l2.627 2.626Z" fill="currentColor"/>
</svg>
...@@ -14,6 +14,12 @@ import type { RewardsApiResourceName, RewardsApiResourcePayload } from './servic ...@@ -14,6 +14,12 @@ import type { RewardsApiResourceName, RewardsApiResourcePayload } from './servic
import { REWARDS_API_RESOURCES } from './services/rewards'; import { REWARDS_API_RESOURCES } from './services/rewards';
import type { StatsApiResourceName, StatsApiResourcePayload } from './services/stats'; import type { StatsApiResourceName, StatsApiResourcePayload } from './services/stats';
import { STATS_API_RESOURCES } from './services/stats'; import { STATS_API_RESOURCES } from './services/stats';
import { TAC_OPERATION_LIFECYCLE_API_RESOURCES } from './services/tac-operation-lifecycle';
import type {
TacOperationLifecycleApiPaginationFilters,
TacOperationLifecycleApiResourceName,
TacOperationLifecycleApiResourcePayload,
} from './services/tac-operation-lifecycle';
import type { IsPaginated } from './services/utils'; import type { IsPaginated } from './services/utils';
import { VISUALIZE_API_RESOURCES } from './services/visualize'; import { VISUALIZE_API_RESOURCES } from './services/visualize';
import type { VisualizeApiResourceName, VisualizeApiResourcePayload } from './services/visualize'; import type { VisualizeApiResourceName, VisualizeApiResourcePayload } from './services/visualize';
...@@ -26,6 +32,7 @@ export const RESOURCES = { ...@@ -26,6 +32,7 @@ export const RESOURCES = {
metadata: METADATA_API_RESOURCES, metadata: METADATA_API_RESOURCES,
rewards: REWARDS_API_RESOURCES, rewards: REWARDS_API_RESOURCES,
stats: STATS_API_RESOURCES, stats: STATS_API_RESOURCES,
tac: TAC_OPERATION_LIFECYCLE_API_RESOURCES,
visualize: VISUALIZE_API_RESOURCES, visualize: VISUALIZE_API_RESOURCES,
} satisfies Record<ApiName, Record<string, ApiResource>>; } satisfies Record<ApiName, Record<string, ApiResource>>;
...@@ -46,6 +53,7 @@ R extends GeneralApiResourceName ? GeneralApiResourcePayload<R> : ...@@ -46,6 +53,7 @@ R extends GeneralApiResourceName ? GeneralApiResourcePayload<R> :
R extends MetadataApiResourceName ? MetadataApiResourcePayload<R> : R extends MetadataApiResourceName ? MetadataApiResourcePayload<R> :
R extends RewardsApiResourceName ? RewardsApiResourcePayload<R> : R extends RewardsApiResourceName ? RewardsApiResourcePayload<R> :
R extends StatsApiResourceName ? StatsApiResourcePayload<R> : R extends StatsApiResourceName ? StatsApiResourcePayload<R> :
R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiResourcePayload<R> :
R extends VisualizeApiResourceName ? VisualizeApiResourcePayload<R> : R extends VisualizeApiResourceName ? VisualizeApiResourcePayload<R> :
never; never;
/* eslint-enable @stylistic/indent */ /* eslint-enable @stylistic/indent */
...@@ -77,6 +85,7 @@ export type PaginationFilters<R extends ResourceName> = ...@@ -77,6 +85,7 @@ export type PaginationFilters<R extends ResourceName> =
R extends BensApiResourceName ? BensApiPaginationFilters<R> : R extends BensApiResourceName ? BensApiPaginationFilters<R> :
R extends GeneralApiResourceName ? GeneralApiPaginationFilters<R> : R extends GeneralApiResourceName ? GeneralApiPaginationFilters<R> :
R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters<R> : R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters<R> :
R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiPaginationFilters<R> :
never; never;
/* eslint-enable @stylistic/indent */ /* eslint-enable @stylistic/indent */
......
import type { ApiResource } from '../types';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
export const TAC_OPERATION_LIFECYCLE_API_RESOURCES = {
operations: {
path: '/api/v1/tac/operations',
paginated: true,
filterFields: [ 'q' ],
},
operation: {
path: '/api/v1/tac/operations/:id',
pathParams: [ 'id' ],
},
operation_by_tx_hash: {
path: '/api/v1/tac/operations\\:byTx/:tx_hash',
pathParams: [ 'tx_hash' ],
},
stat_operations: {
path: '/api/v1/stat/operations',
},
} satisfies Record<string, ApiResource>;
export type TacOperationLifecycleApiResourceName = `tac:${ keyof typeof TAC_OPERATION_LIFECYCLE_API_RESOURCES }`;
/* eslint-disable @stylistic/indent */
export type TacOperationLifecycleApiResourcePayload<R extends TacOperationLifecycleApiResourceName> =
R extends 'tac:operations' ? tac.OperationsResponse :
R extends 'tac:operation' ? tac.OperationDetails :
R extends 'tac:operation_by_tx_hash' ? tac.OperationsFullResponse :
R extends 'tac:stat_operations' ? tac.GetOperationStatisticsResponse :
never;
/* eslint-enable @stylistic/indent */
/* eslint-disable @stylistic/indent */
export type TacOperationLifecycleApiPaginationFilters<R extends TacOperationLifecycleApiResourceName> =
R extends 'tac:operations' ? { q: string } :
never;
/* eslint-enable @stylistic/indent */
export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'rewards' | 'stats' | 'visualize'; export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'rewards' | 'stats' | 'visualize' | 'tac';
export interface ApiResource { export interface ApiResource {
path: string; path: string;
......
...@@ -44,6 +44,12 @@ export default function useNavItems(): ReturnType { ...@@ -44,6 +44,12 @@ export default function useNavItems(): ReturnType {
icon: 'transactions', icon: 'transactions',
isActive: pathname === '/txs' || pathname === '/tx/[hash]', isActive: pathname === '/txs' || pathname === '/tx/[hash]',
}; };
const operations: NavItem | null = config.features.tac.isEnabled ? {
text: 'Operations',
nextRoute: { pathname: '/operations' as const },
icon: 'operation',
isActive: pathname === '/operations' || pathname === '/operation/[id]',
} : null;
const internalTxs: NavItem | null = { const internalTxs: NavItem | null = {
text: 'Internal transactions', text: 'Internal transactions',
nextRoute: { pathname: '/internal-txs' as const }, nextRoute: { pathname: '/internal-txs' as const },
...@@ -186,6 +192,7 @@ export default function useNavItems(): ReturnType { ...@@ -186,6 +192,7 @@ export default function useNavItems(): ReturnType {
} else { } else {
blockchainNavItems = [ blockchainNavItems = [
txs, txs,
operations,
internalTxs, internalTxs,
userOps, userOps,
blocks, blocks,
......
...@@ -61,6 +61,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -61,6 +61,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/pools': 'Root page', '/pools': 'Root page',
'/pools/[hash]': 'Regular page', '/pools/[hash]': 'Regular page',
'/interop-messages': 'Root page', '/interop-messages': 'Root page',
'/operations': 'Root page',
'/operation/[id]': 'Regular page',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Regular page', '/login': 'Regular page',
......
...@@ -64,6 +64,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -64,6 +64,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/pools': DEFAULT_TEMPLATE, '/pools': DEFAULT_TEMPLATE,
'/pools/[hash]': DEFAULT_TEMPLATE, '/pools/[hash]': DEFAULT_TEMPLATE,
'/interop-messages': DEFAULT_TEMPLATE, '/interop-messages': DEFAULT_TEMPLATE,
'/operations': DEFAULT_TEMPLATE,
'/operation/[id]': DEFAULT_TEMPLATE,
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE, '/login': DEFAULT_TEMPLATE,
......
...@@ -61,6 +61,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -61,6 +61,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/pools': '%network_name% DEX pools', '/pools': '%network_name% DEX pools',
'/pools/[hash]': '%network_name% pool details', '/pools/[hash]': '%network_name% pool details',
'/interop-messages': '%network_name% interop messages', '/interop-messages': '%network_name% interop messages',
'/operations': '%network_name% operations',
'/operation/[id]': '%network_name% operation %id%',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': '%network_name% login', '/login': '%network_name% login',
......
...@@ -59,6 +59,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -59,6 +59,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/pools': 'DEX pools', '/pools': 'DEX pools',
'/pools/[hash]': 'Pool details', '/pools/[hash]': 'Pool details',
'/interop-messages': 'Interop messages', '/interop-messages': 'Interop messages',
'/operations': 'Operations',
'/operation/[id]': 'Operation details',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Login', '/login': 'Login',
......
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { rightLineArrow } from 'toolkit/utils/htmlEntities';
export function getTacOperationStatus(type: tac.OperationType) {
switch (type) {
case tac.OperationType.TON_TAC_TON:
return `TON ${ rightLineArrow } TAC ${ rightLineArrow } TON`;
case tac.OperationType.TAC_TON:
return `TAC ${ rightLineArrow } TON`;
case tac.OperationType.TON_TAC:
return `TON ${ rightLineArrow } TAC`;
case tac.OperationType.ERROR:
return 'Error';
case tac.OperationType.ROLLBACK:
return 'Rollback';
case tac.OperationType.PENDING:
return 'Pending';
default:
return null;
}
}
export function getTacOperationStage(data: tac.OperationDetails, txHash: string) {
const currentStep = data.status_history.find((step) => step.transactions.some((tx) => tx.hash.toLowerCase() === txHash.toLowerCase()));
if (!currentStep) {
return null;
}
return STATUS_LABELS[currentStep.type];
}
export const STATUS_SEQUENCE: Array<tac.OperationStage_StageType> = [
tac.OperationStage_StageType.COLLECTED_IN_TAC,
tac.OperationStage_StageType.INCLUDED_IN_TAC_CONSENSUS,
tac.OperationStage_StageType.EXECUTED_IN_TAC,
tac.OperationStage_StageType.COLLECTED_IN_TON,
tac.OperationStage_StageType.INCLUDED_IN_TON_CONSENSUS,
tac.OperationStage_StageType.EXECUTED_IN_TON,
];
export const STATUS_LABELS: Record<tac.OperationStage_StageType, string> = {
[tac.OperationStage_StageType.COLLECTED_IN_TAC]: 'Collected in TAC',
[tac.OperationStage_StageType.INCLUDED_IN_TAC_CONSENSUS]: 'Included in TAC consensus',
[tac.OperationStage_StageType.EXECUTED_IN_TAC]: 'Executed in TAC',
[tac.OperationStage_StageType.COLLECTED_IN_TON]: 'Collected in TON',
[tac.OperationStage_StageType.INCLUDED_IN_TON_CONSENSUS]: 'Included in TON consensus',
[tac.OperationStage_StageType.EXECUTED_IN_TON]: 'Executed in TON',
[tac.OperationStage_StageType.UNRECOGNIZED]: 'Unknown',
};
export const sortStatusHistory = (a: tac.OperationStage, b: tac.OperationStage) => {
const aIndex = STATUS_SEQUENCE.indexOf(a.type);
const bIndex = STATUS_SEQUENCE.indexOf(b.type);
return aIndex - bIndex;
};
import * as tac from '@blockscout/tac-operation-lifecycle-types';
export const tacOperation: tac.OperationDetails = {
operation_id: '0x35f5d9c2bf07477ede48935c7130945faf17a3e5f69a7d20ce3725676513095c',
type: tac.OperationType.TON_TAC_TON,
timestamp: '2025-05-08T07:20:05.000Z',
sender: {
address: 'EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt',
blockchain: tac.BlockchainType.TON,
},
status_history: [
{
type: tac.OperationStage_StageType.COLLECTED_IN_TON,
is_exist: true,
is_success: true,
timestamp: '2025-05-08T07:20:05.000Z',
transactions: [
{
hash: '0x77e3c6bef84681157dda17dec60f680a1ff6caaedec2e94c23f4ec44aa62aba8',
type: tac.BlockchainType.TON,
},
],
note: undefined,
},
{
type: tac.OperationStage_StageType.INCLUDED_IN_TON_CONSENSUS,
is_exist: true,
is_success: true,
timestamp: '2025-05-08T07:25:35.000Z',
transactions: [
{
hash: '0xafc8a8e04739b4996e9b5ef6c91673fb421d00ed42be4404d6fca6a915899235',
type: tac.BlockchainType.TAC,
},
{
hash: '0xafc8a8e04739b4996e9b5ef6c91673fb421d00ed42be4404d6fca6a915899236',
type: tac.BlockchainType.TON,
},
],
note: undefined,
},
{
type: tac.OperationStage_StageType.EXECUTED_IN_TON,
is_exist: true,
is_success: false,
timestamp: '2025-05-08T07:26:14.000Z',
transactions: [
{
hash: '0xa9c6087ee95ede3cb0bcba7119a7f7b0ee3fc91d04faa1bb1ecd94ed83ef8161',
type: tac.BlockchainType.TAC,
},
],
note: 'ProxyCallError: UniswapV2Router: Insufficient output amount',
},
],
};
...@@ -9,8 +9,11 @@ import type { ...@@ -9,8 +9,11 @@ import type {
SearchResultBlob, SearchResultBlob,
SearchResultDomain, SearchResultDomain,
SearchResultMetadataTag, SearchResultMetadataTag,
SearchResultTacOperation,
} from 'types/api/search'; } from 'types/api/search';
import * as tacOperationMock from 'mocks/operations/tac';
export const token1: SearchResultToken = { export const token1: SearchResultToken = {
address_hash: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B', address_hash: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
address_url: '/address/0x377c5F2B300B25a534d4639177873b7fEAA56d4B', address_url: '/address/0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
...@@ -184,6 +187,11 @@ export const metatag3: SearchResultMetadataTag = { ...@@ -184,6 +187,11 @@ export const metatag3: SearchResultMetadataTag = {
}, },
}; };
export const tacOperation1: SearchResultTacOperation = {
type: 'tac_operation',
tac_operation: tacOperationMock.tacOperation,
};
export const baseResponse: SearchResult = { export const baseResponse: SearchResult = {
items: [ items: [
token1, token1,
...@@ -195,7 +203,7 @@ export const baseResponse: SearchResult = { ...@@ -195,7 +203,7 @@ export const baseResponse: SearchResult = {
blob1, blob1,
domain1, domain1,
metatag1, metatag1,
tacOperation1,
], ],
next_page_params: null, next_page_params: null,
}; };
...@@ -109,6 +109,7 @@ export function app(): CspDev.DirectiveDescriptor { ...@@ -109,6 +109,7 @@ export function app(): CspDev.DirectiveDescriptor {
'font-src': [ 'font-src': [
KEY_WORDS.DATA, KEY_WORDS.DATA,
KEY_WORDS.SELF,
...MAIN_DOMAINS, ...MAIN_DOMAINS,
...(externalFontsDomains || []), ...(externalFontsDomains || []),
], ],
......
...@@ -380,6 +380,16 @@ export const mud: GetServerSideProps<Props> = async(context) => { ...@@ -380,6 +380,16 @@ export const mud: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const tac: GetServerSideProps<Props> = async(context) => {
if (!config.features.tac.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
export const interopMessages: GetServerSideProps<Props> = async(context) => { export const interopMessages: GetServerSideProps<Props> = async(context) => {
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
if (!rollupFeature.isEnabled || !rollupFeature.interopEnabled) { if (!rollupFeature.isEnabled || !rollupFeature.interopEnabled) {
......
@font-face {
font-family: 'InterFallback';
src: url('/static/fonts/Inter-fallback.woff2') format('woff2');
unicode-range: U+2192; /* Only the right arrow */
font-display: swap;
}
\ No newline at end of file
...@@ -53,6 +53,8 @@ declare module "nextjs-routes" { ...@@ -53,6 +53,8 @@ declare module "nextjs-routes" {
| DynamicRoute<"/name-domains/[name]", { "name": string }> | DynamicRoute<"/name-domains/[name]", { "name": string }>
| StaticRoute<"/name-domains"> | StaticRoute<"/name-domains">
| DynamicRoute<"/op/[hash]", { "hash": string }> | DynamicRoute<"/op/[hash]", { "hash": string }>
| DynamicRoute<"/operation/[id]", { "id": string }>
| StaticRoute<"/operations">
| StaticRoute<"/ops"> | StaticRoute<"/ops">
| StaticRoute<"/output-roots"> | StaticRoute<"/output-roots">
| DynamicRoute<"/pools/[hash]", { "hash": string }> | DynamicRoute<"/pools/[hash]", { "hash": string }>
......
...@@ -30,6 +30,7 @@ import Web3ModalProvider from 'ui/shared/Web3ModalProvider'; ...@@ -30,6 +30,7 @@ import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
import 'lib/setLocale'; import 'lib/setLocale';
// import 'focus-visible/dist/focus-visible'; // import 'focus-visible/dist/focus-visible';
import 'nextjs/global.css';
type AppPropsWithLayout = AppProps & { type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout; Component: NextPageWithLayout;
......
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 TacOperation = dynamic(() => import('ui/pages/TacOperation'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/operation/[id]" query={ props.query }>
<TacOperation/>
</PageNextJs>
);
};
export default Page;
export { tac 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 TacOperations = dynamic(() => import('ui/pages/TacOperations'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/operations">
<TacOperations/>
</PageNextJs>
);
};
export default Page;
export { tac as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -105,4 +105,8 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ...@@ -105,4 +105,8 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', 'https://localhost:3102' ], [ 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', 'https://localhost:3102' ],
[ 'NEXT_PUBLIC_INTEROP_ENABLED', 'true' ], [ 'NEXT_PUBLIC_INTEROP_ENABLED', 'true' ],
], ],
tac: [
[ 'NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST', 'http://localhost:3100' ],
[ 'NEXT_PUBLIC_TAC_TON_EXPLORER_URL', 'https://testnet.tonscan.org' ],
],
}; };
import './fonts.css'; import './fonts.css';
import './index.css'; import './index.css';
import '../nextjs/global.css';
import { beforeMount } from '@playwright/experimental-ct-react/hooks'; import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import MockDate from 'mockdate'; import MockDate from 'mockdate';
import * as router from 'next/router'; import * as router from 'next/router';
......
...@@ -29,6 +29,8 @@ ...@@ -29,6 +29,8 @@
| "brands/graph" | "brands/graph"
| "brands/safe" | "brands/safe"
| "brands/solidity_scan" | "brands/solidity_scan"
| "brands/tac"
| "brands/ton"
| "burger" | "burger"
| "certified" | "certified"
| "check" | "check"
...@@ -117,6 +119,8 @@ ...@@ -117,6 +119,8 @@
| "networks/logo-placeholder" | "networks/logo-placeholder"
| "nft_shield" | "nft_shield"
| "open-link" | "open-link"
| "operation_slim"
| "operation"
| "output_roots" | "output_roots"
| "payment_link" | "payment_link"
| "plus" | "plus"
...@@ -182,6 +186,7 @@ ...@@ -182,6 +186,7 @@
| "user_op_slim" | "user_op_slim"
| "user_op" | "user_op"
| "validator" | "validator"
| "verification-steps/error"
| "verification-steps/finalized" | "verification-steps/finalized"
| "verification-steps/unfinalized" | "verification-steps/unfinalized"
| "verified_slim" | "verified_slim"
......
This diff is collapsed.
To add new glyphs to the Inter fallback font:
1. Download and install [FontForge](https://fontforge.org/en-US/downloads/mac-dl/).
2. Download the [Inter](https://rsms.me/inter/) typefaces.
3. In FontForge, open the Inter Regular typeface from the web directory.
4. Find the desired glyph (e.g., a missing symbol or character).
5. Copy the glyph (Edit > Copy or Cmd+C).
6. Open the `Inter-fallback.sfd` file in FontForge.
7. Go to the correct Unicode slot for the glyph (use Encoding > Goto or right-click > Goto, then enter the Unicode value).
8. Paste the glyph into the slot (Edit > Paste or Cmd+V).
9. Generate a new .ttf file
10. To use [pyftsubset](https://fonttools.readthedocs.io/en/latest/subset/index.html) tool run `brew install fonttools`
11. Adjust the range of included glyphs and run the script
```bash
pyftsubset <path-to-ttf-file> \
--output-file=./public/static/fonts/Inter-fallback.woff2 \
--flavor=woff2 \
--unicodes=U+2192
```
\ No newline at end of file
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { ADDRESS_HASH } from './addressParams';
export const TAC_OPERATION: tac.OperationBriefDetails = {
operation_id: '0x4d3d36b7fcab0a2f93f24bf313ebfe9cc0b2c7157d2aef7e7f7d5835528428c6',
type: tac.OperationType.PENDING,
timestamp: '2025-05-05T12:32:22.000Z',
sender: {
address: '0x4d3d36b7fcab0a2f93f24bf313ebfe9cc0b2c7157d2aef7e7f7d5835528428c6',
blockchain: tac.BlockchainType.TAC,
},
};
export const TAC_OPERATION_DETAILS: tac.OperationDetails = {
operation_id: '0x6e7cdeea3f39e7664597a44ddb33ce47ba061cbee2992e2c7b0e3f9294ff8b30',
type: tac.OperationType.PENDING,
timestamp: '2025-05-05T12:32:22.000Z',
sender: {
address: ADDRESS_HASH,
blockchain: tac.BlockchainType.TAC,
},
status_history: [
{
type: tac.OperationStage_StageType.COLLECTED_IN_TAC,
is_exist: true,
is_success: true,
timestamp: '2025-05-05T12:32:22.000Z',
transactions: [
{
hash: '0x064e57a9f43d032ac0c1cb0d7883b0d783a9fa5d207a39563a6ed06c5dc17622',
type: tac.BlockchainType.TON,
},
],
note: undefined,
},
],
};
...@@ -4,7 +4,7 @@ import type { ExcludeUndefined } from 'types/utils'; ...@@ -4,7 +4,7 @@ import type { ExcludeUndefined } from 'types/utils';
import config from 'configs/app'; import config from 'configs/app';
export const BODY_TYPEFACE = config.UI.fonts.body?.name ?? 'Inter'; export const BODY_TYPEFACE = config.UI.fonts.body?.name ?? 'Inter, InterFallback';
export const HEADING_TYPEFACE = config.UI.fonts.heading?.name ?? 'Poppins'; export const HEADING_TYPEFACE = config.UI.fonts.heading?.name ?? 'Poppins';
export const fonts: ExcludeUndefined<ThemingConfig['tokens']>['fonts'] = { export const fonts: ExcludeUndefined<ThemingConfig['tokens']>['fonts'] = {
......
import type { SystemConfig } from '@chakra-ui/react'; import type { SystemConfig } from '@chakra-ui/react';
import addressEntity from './globals/address-entity'; import addressEntity from './globals/address-entity';
import entity from './globals/entity';
import recaptcha from './globals/recaptcha'; import recaptcha from './globals/recaptcha';
import scrollbar from './globals/scrollbar'; import scrollbar from './globals/scrollbar';
...@@ -51,6 +52,7 @@ const globalCss: SystemConfig['globalCss'] = { ...@@ -51,6 +52,7 @@ const globalCss: SystemConfig['globalCss'] = {
}, },
...recaptcha, ...recaptcha,
...scrollbar, ...scrollbar,
...entity,
...addressEntity, ...addressEntity,
}; };
......
...@@ -18,6 +18,10 @@ const styles = { ...@@ -18,6 +18,10 @@ const styles = {
bgColor: 'address.highlighted.bg', bgColor: 'address.highlighted.bg',
zIndex: -1, zIndex: -1,
}, },
'& .entity__shield': {
borderColor: 'address.highlighted.bg',
bgColor: 'address.highlighted.bg',
},
}, },
}, },
'.address-entity_no-copy': { '.address-entity_no-copy': {
......
const styles = {
'.entity__shield': {
bgColor: 'global.body.bg',
borderColor: 'global.body.bg',
},
};
export default styles;
...@@ -26,6 +26,7 @@ const PRESETS = { ...@@ -26,6 +26,7 @@ const PRESETS = {
scroll_sepolia: 'https://scroll-sepolia.blockscout.com', scroll_sepolia: 'https://scroll-sepolia.blockscout.com',
shibarium: 'https://www.shibariumscan.io', shibarium: 'https://www.shibariumscan.io',
stability_testnet: 'https://stability-testnet.blockscout.com', stability_testnet: 'https://stability-testnet.blockscout.com',
tac_turin: 'https://tac-turin.blockscout.com',
zkevm: 'https://zkevm.blockscout.com', zkevm: 'https://zkevm.blockscout.com',
zksync: 'https://zksync.blockscout.com', zksync: 'https://zksync.blockscout.com',
zilliqa_prototestnet: 'https://zilliqa-prototestnet.blockscout.com', zilliqa_prototestnet: 'https://zilliqa-prototestnet.blockscout.com',
......
import type * as bens from '@blockscout/bens-types'; import type * as bens from '@blockscout/bens-types';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import type { TokenType } from 'types/api/token'; import type { TokenType } from 'types/api/token';
import type { AddressMetadataTagApi } from './addressMetadata'; import type { AddressMetadataTagApi } from './addressMetadata';
...@@ -14,6 +15,7 @@ export const SEARCH_RESULT_TYPES = { ...@@ -14,6 +15,7 @@ export const SEARCH_RESULT_TYPES = {
user_operation: 'user_operation', user_operation: 'user_operation',
blob: 'blob', blob: 'blob',
metadata_tag: 'metadata_tag', metadata_tag: 'metadata_tag',
tac_operation: 'tac_operation',
} as const; } as const;
export type SearchResultType = typeof SEARCH_RESULT_TYPES[keyof typeof SEARCH_RESULT_TYPES]; export type SearchResultType = typeof SEARCH_RESULT_TYPES[keyof typeof SEARCH_RESULT_TYPES];
...@@ -56,6 +58,11 @@ export interface SearchResultAddressOrContract extends SearchResultAddressData { ...@@ -56,6 +58,11 @@ export interface SearchResultAddressOrContract extends SearchResultAddressData {
ens_info?: SearchResultEnsInfo; ens_info?: SearchResultEnsInfo;
} }
export interface SearchResultTacOperation {
type: 'tac_operation';
tac_operation: tac.OperationDetails;
}
export interface SearchResultMetadataTag extends SearchResultAddressData { export interface SearchResultMetadataTag extends SearchResultAddressData {
type: 'metadata_tag'; type: 'metadata_tag';
ens_info?: SearchResultEnsInfo; ens_info?: SearchResultEnsInfo;
...@@ -120,7 +127,8 @@ export type SearchResultItem = ...@@ -120,7 +127,8 @@ export type SearchResultItem =
SearchResultUserOp | SearchResultUserOp |
SearchResultBlob | SearchResultBlob |
SearchResultDomain | SearchResultDomain |
SearchResultMetadataTag; SearchResultMetadataTag |
SearchResultTacOperation;
export interface SearchResult { export interface SearchResult {
items: Array<SearchResultItem>; items: Array<SearchResultItem>;
......
import { Grid } from '@chakra-ui/react';
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { sortStatusHistory } from 'lib/operations/tac';
import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo';
import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp';
import AddressEntityTacTon from 'ui/shared/entities/address/AddressEntityTacTon';
import TacOperationLifecycleAccordion from './TacOperationLifecycleAccordion';
interface Props {
isLoading?: boolean;
data: tac.OperationDetails;
}
const TacOperationDetails = ({ isLoading, data }: Props) => {
const statusHistory = data.status_history.filter((item) => item.is_exist).sort(sortStatusHistory);
return (
<Grid
columnGap={ 8 }
rowGap={ 3 }
templateColumns={{ base: 'minmax(0, 1fr)', lg: '210px minmax(728px, auto)' }}
>
{ data?.sender && (
<>
<DetailedInfo.ItemLabel
hint="The address on the source chain that starts a cross‑chain operation"
isLoading={ isLoading }
>
Sender
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<AddressEntityTacTon
address={{ hash: data.sender.address }}
chainType={ data.sender.blockchain }
isLoading={ isLoading }
/>
</DetailedInfo.ItemValue>
</>
) }
{ data.timestamp && (
<>
<DetailedInfo.ItemLabel
hint="Block time on the source chain when a cross‑chain operation is formed and sent"
isLoading={ isLoading }
>
Timestamp
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<DetailedInfoTimestamp timestamp={ data.timestamp } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
</>
) }
{ statusHistory.length > 0 && (
<>
<DetailedInfo.ItemLabel
hint="Stages of a cross‑chain operation"
isLoading={ isLoading }
>
Lifecycle
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<TacOperationLifecycleAccordion data={ statusHistory } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
</>
) }
</Grid>
);
};
export default React.memo(TacOperationDetails);
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { AccordionItem, AccordionRoot } from 'toolkit/chakra/accordion';
import TacOperationLifecycleAccordionItemContent from './TacOperationLifecycleAccordionItemContent';
import TacOperationLifecycleAccordionItemTrigger from './TacOperationLifecycleAccordionItemTrigger';
interface Props {
data: tac.OperationDetails['status_history'];
isLoading?: boolean;
}
const TacOperationLifecycleAccordion = ({ data, isLoading }: Props) => {
return (
<AccordionRoot maxW="800px" display="flex" flexDirection="column" rowGap={ 6 } lazyMount>
{ data.map((item, index) => {
const isLast = index === data.length - 1;
return (
<AccordionItem key={ index } value={ item.type } borderBottomWidth="0px">
<TacOperationLifecycleAccordionItemTrigger
status={ item.type }
isFirst={ index === 0 }
isLast={ isLast }
isLoading={ isLoading }
isSuccess={ item.is_success ?? false }
/>
<TacOperationLifecycleAccordionItemContent
isLast={ isLast }
data={ item }
/>
</AccordionItem>
);
}) }
</AccordionRoot>
);
};
export default React.memo(TacOperationLifecycleAccordion);
import { AccordionItemContent, Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityTon from 'ui/shared/entities/tx/TxEntityTon';
import StatusTag from 'ui/shared/statusTag/StatusTag';
interface Props {
isLast: boolean;
data: tac.OperationStage;
}
const TacOperationLifecycleAccordionItemContent = ({ isLast, data }: Props) => {
return (
<AccordionItemContent
ml={{ base: 0, lg: '9px' }}
pl={{ base: 0, lg: '17px' }}
pt={ 2 }
borderLeftWidth={{ base: 0, lg: isLast ? undefined : '2px' }}
borderColor="border.divider"
>
<Grid
gridTemplateColumns="112px 1fr"
alignItems="flex-start"
columnGap={ 3 }
rowGap={ 1 }
bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }}
p="6px"
pl="18px"
textStyle="sm"
borderBottomLeftRadius="base"
borderBottomRightRadius="base"
>
<GridItem color="text.secondary" py="6px">
Status
</GridItem>
<GridItem py={ 1 }>
<StatusTag type={ data.is_success ? 'ok' : 'error' } text={ data.is_success ? 'Success' : 'Failed' }/>
</GridItem>
{ data.timestamp && (
<>
<GridItem color="text.secondary" py="6px">
Timestamp
</GridItem>
<GridItem
display="inline-flex"
flexWrap="wrap"
alignItems="center"
py="6px"
>
<DetailedInfoTimestamp timestamp={ data.timestamp } noIcon isLoading={ false }/>
</GridItem>
</>
) }
<GridItem color="text.secondary" py="6px">
Transactions
</GridItem>
<GridItem
display="flex"
flexDirection="column"
rowGap={ 3 }
py="6px"
width="100%"
overflow="hidden"
>
{
data.transactions.map((tx) => {
if (tx.type === tac.BlockchainType.TON) {
return <TxEntityTon key={ tx.hash } hash={ tx.hash } noCopy={ false }/>;
}
return <TxEntity key={ tx.hash } hash={ tx.hash } icon={{ name: 'brands/tac' }} noCopy={ false }/>;
})
}
</GridItem>
{ data.note && (
<>
<GridItem color="text.secondary" py="6px">
Note
</GridItem>
<GridItem
display="inline-flex"
alignItems="center"
py="6px"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{ data.note }
</GridItem>
</>
) }
</Grid>
</AccordionItemContent>
);
};
export default React.memo(TacOperationLifecycleAccordionItemContent);
import { HStack } from '@chakra-ui/react';
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { STATUS_LABELS } from 'lib/operations/tac';
import { AccordionItemTrigger } from 'toolkit/chakra/accordion';
import { Skeleton } from 'toolkit/chakra/skeleton';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
status: tac.OperationStage_StageType;
isFirst: boolean;
isLast: boolean;
isLoading?: boolean;
isSuccess: boolean;
}
const TacOperationLifecycleAccordionItemTrigger = ({ status, isFirst, isLast, isSuccess, isLoading }: Props) => {
return (
<AccordionItemTrigger
position="relative"
pt={ isFirst ? 0 : 1 }
pb={ 1 }
_before={ !isFirst ? {
position: 'absolute',
left: '9px',
bottom: 'calc(100% - 6px)',
width: '0',
height: '30px',
borderColor: 'border.divider',
borderLeftWidth: '2px',
content: '""',
} : undefined }
_after={ !isLast ? {
position: 'absolute',
left: '9px',
top: 'calc(100% - 6px)',
width: '0',
height: '6px',
borderColor: 'border.divider',
borderLeftWidth: '2px',
content: '""',
} : undefined }
_open={{
_after: {
height: { base: '14px', lg: '6px' },
},
}}
disabled={ isLoading }
noIndicator={ isLoading }
>
<HStack gap={ 2 } color={ isSuccess ? 'green.500' : 'red.600' }>
<IconSvg name={ isSuccess ? 'verification-steps/finalized' : 'verification-steps/error' } boxSize={ 5 } isLoading={ isLoading }/>
<Skeleton loading={ isLoading }>
{ STATUS_LABELS[status] }
</Skeleton>
</HStack>
</AccordionItemTrigger>
);
};
export default React.memo(TacOperationLifecycleAccordionItemTrigger);
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import AddressEntityTacTon from 'ui/shared/entities/address/AddressEntityTacTon';
import OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TacOperationStatus from 'ui/shared/statusTag/TacOperationStatus';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
type Props = { item: tac.OperationBriefDetails; isLoading?: boolean };
const TacOperationsListItem = ({ item, isLoading }: Props) => {
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>Operation</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<OperationEntity
id={ item.operation_id }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TimeWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TacOperationStatus status={ item.type } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
{ item.sender && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Sender</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<AddressEntityTacTon
address={{ hash: item.sender.address }}
chainType={ item.sender.blockchain }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
</>
) }
</ListItemMobileGrid.Container>
);
};
export default TacOperationsListItem;
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table';
import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle';
import TacOperationsTableItem from './TacOperationsTableItem';
type Props = {
items: Array<tac.OperationBriefDetails>;
isLoading?: boolean;
};
const TacOperationsTable = ({ items, isLoading }: Props) => {
return (
<AddressHighlightProvider>
<TableRoot minW="950px">
<TableHeaderSticky top={ 68 }>
<TableRow>
<TableColumnHeader w="100%">Operation</TableColumnHeader>
<TableColumnHeader w="200px">
Timestamp
<TimeFormatToggle/>
</TableColumnHeader>
<TableColumnHeader w="200px">Status</TableColumnHeader>
<TableColumnHeader w="250px">Sender</TableColumnHeader>
</TableRow>
</TableHeaderSticky>
<TableBody>
{ items.map((item, index) => (
<TacOperationsTableItem key={ String(item.operation_id) + (isLoading ? index : '') } item={ item } isLoading={ isLoading }/>
)) }
</TableBody>
</TableRoot>
</AddressHighlightProvider>
);
};
export default TacOperationsTable;
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import AddressEntityTacTon from 'ui/shared/entities/address/AddressEntityTacTon';
import OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import TacOperationStatus from 'ui/shared/statusTag/TacOperationStatus';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
interface Props {
item: tac.OperationBriefDetails;
isLoading?: boolean;
}
const TacOperationsTableItem = ({ item, isLoading }: Props) => {
return (
<TableRow>
<TableCell verticalAlign="middle">
<OperationEntity
id={ item.operation_id }
isLoading={ isLoading }
/>
</TableCell>
<TableCell verticalAlign="middle">
<TimeWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
color="text.secondary"
/>
</TableCell>
<TableCell verticalAlign="middle">
<TacOperationStatus status={ item.type } isLoading={ isLoading }/>
</TableCell>
<TableCell verticalAlign="middle" pr={ 12 }>
{ item.sender ? (
<AddressEntityTacTon
address={{ hash: item.sender.address }}
chainType={ item.sender.blockchain }
truncation="constant"
isLoading={ isLoading }
w="fit-content"
/>
) : '-' }
</TableCell>
</TableRow>
);
};
export default TacOperationsTableItem;
...@@ -118,6 +118,23 @@ test('search by tx hash +@mobile', async({ render, mockApiResponse }) => { ...@@ -118,6 +118,23 @@ test('search by tx hash +@mobile', async({ render, mockApiResponse }) => {
await expect(component.locator('main')).toHaveScreenshot(); await expect(component.locator('main')).toHaveScreenshot();
}); });
test('search by tac operation hash +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.tac);
const hooksConfig = {
router: {
query: { q: searchMock.tacOperation1.tac_operation.operation_id },
},
};
const data = {
items: [ searchMock.tacOperation1 ],
next_page_params: null,
};
await mockApiResponse('general:search', data, { queryParams: { q: searchMock.tacOperation1.tac_operation.operation_id } });
const component = await render(<SearchResults/>, { hooksConfig });
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by blob hash +@mobile', async({ render, mockApiResponse, mockEnvs }) => { test('search by blob hash +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
const hooksConfig = { const hooksConfig = {
router: { router: {
......
...@@ -108,6 +108,9 @@ const SearchResultsPageContent = () => { ...@@ -108,6 +108,9 @@ const SearchResultsPageContent = () => {
if (!config.features.nameService.isEnabled && item.type === 'ens_domain') { if (!config.features.nameService.isEnabled && item.type === 'ens_domain') {
return false; return false;
} }
if (!config.features.tac.isEnabled && item.type === 'tac_operation') {
return false;
}
return true; return true;
}); });
......
import React from 'react';
import * as tacOperationMock from 'mocks/operations/tac';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import TacOperation from './TacOperation';
test('base view +@dark-mode +@mobile', async({ render, mockTextAd, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.tac);
await mockTextAd();
await mockApiResponse('tac:operation', tacOperationMock.tacOperation, {
pathParams: { id: tacOperationMock.tacOperation.operation_id },
});
const component = await render(
<TacOperation/>,
{ hooksConfig: {
router: {
query: { id: tacOperationMock.tacOperation.operation_id },
isReady: true,
},
} },
);
await component.getByRole('button', { name: 'Collected in TON' }).click();
await component.getByRole('button', { name: 'Included in TON consensus' }).click();
await component.getByRole('button', { name: 'Executed in TON' }).click();
await expect(component).toHaveScreenshot();
});
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TAC_OPERATION_DETAILS } from 'stubs/operations';
import TacOperationDetails from 'ui/operation/tac/TacOperationDetails';
import TextAd from 'ui/shared/ad/TextAd';
import OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
import TacOperationTag from 'ui/shared/TacOperationTag';
const TacOperation = () => {
const appProps = useAppContext();
const router = useRouter();
const id = getQueryParamString(router.query.id);
const query = useApiQuery('tac:operation', {
pathParams: { id },
queryOptions: {
placeholderData: TAC_OPERATION_DETAILS,
},
});
throwOnResourceLoadError(query);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.endsWith('/operations');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to operations list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const titleContentAfter = query.data ? (
<TacOperationTag type={ query.data.type } loading={ query.isPlaceholderData }/>
) : null;
const titleSecondRow = (
<OperationEntity id={ id } noLink variant="subheading"/>
);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title="Operation details"
backLink={ backLink }
contentAfter={ titleContentAfter }
isLoading={ query.isPlaceholderData }
secondRow={ titleSecondRow }
/>
{ query.data && (
<TacOperationDetails isLoading={ query.isPlaceholderData } data={ query.data }/>
) }
</>
);
};
export default React.memo(TacOperation);
import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import useDebounce from 'lib/hooks/useDebounce';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TAC_OPERATION } from 'stubs/operations';
import { generateListStub } from 'stubs/utils';
import { FilterInput } from 'toolkit/components/filters/FilterInput';
import { apos } from 'toolkit/utils/htmlEntities';
import TacOperationsListItem from 'ui/operations/tac/TacOperationsListItem';
import TacOperationsTable from 'ui/operations/tac/TacOperationsTable';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
const TacOperations = () => {
const router = useRouter();
const [ searchTerm, setSearchTerm ] = React.useState(getQueryParamString(router.query.q) || undefined);
const debouncedSearchTerm = useDebounce(searchTerm || '', 300);
const isMobile = useIsMobile();
const { isError, isPlaceholderData, data, pagination, onFilterChange } = useQueryWithPages({
resourceName: 'tac:operations',
filters: { q: debouncedSearchTerm },
options: {
placeholderData: generateListStub<'tac:operations'>(
TAC_OPERATION,
50,
{ next_page_params: undefined },
),
},
});
const handleSearchTermChange = React.useCallback((value: string) => {
onFilterChange({ q: value });
setSearchTerm(value);
}, [ onFilterChange ]);
const filterInput = (
<FilterInput
w={{ base: '100%', lg: '460px' }}
size="sm"
onChange={ handleSearchTermChange }
placeholder="Search by operation, tx hash, sender"
initialValue={ searchTerm }
/>
);
const actionBar = (
<>
<Box gap={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ filterInput }
</Box>
{ (!isMobile || pagination.isVisible) && (
<ActionBar mt={ -6 }>
<Box gap={ 3 } display={{ base: 'none', lg: 'flex' }}>
{ filterInput }
</Box>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) }
</>
);
const content = data?.items ? (
<>
<Box hideFrom="lg">
{ data.items.map(((item, index) => (
<TacOperationsListItem
key={ String(item.operation_id) + (isPlaceholderData ? index : '') }
isLoading={ isPlaceholderData }
item={ item }
/>
))) }
</Box>
<Box hideBelow="lg">
<TacOperationsTable
items={ data.items }
isLoading={ isPlaceholderData }
/>
</Box>
</>
) : null;
return (
<>
<PageTitle title="Operations" withTextAd/>
<DataListDisplay
isError={ isError }
itemsNum={ data?.items?.length }
emptyText="There are no operations."
filterProps={{
emptyFilteredText: `Couldn${ apos }t find any operation that matches your query.`,
hasActiveFilters: Boolean(searchTerm),
}}
actionBar={ actionBar }
>
{ content }
</DataListDisplay>
</>
);
};
export default React.memo(TacOperations);
...@@ -5,6 +5,7 @@ import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types'; ...@@ -5,6 +5,7 @@ import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import type { EntityTag as TEntityTag } from 'ui/shared/EntityTags/types'; import type { EntityTag as TEntityTag } from 'ui/shared/EntityTags/types';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
...@@ -31,6 +32,7 @@ import useTxQuery from 'ui/tx/useTxQuery'; ...@@ -31,6 +32,7 @@ import useTxQuery from 'ui/tx/useTxQuery';
const txInterpretation = config.features.txInterpretation; const txInterpretation = config.features.txInterpretation;
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
const tacFeature = config.features.tac;
const TransactionPageContent = () => { const TransactionPageContent = () => {
const router = useRouter(); const router = useRouter();
...@@ -38,6 +40,14 @@ const TransactionPageContent = () => { ...@@ -38,6 +40,14 @@ const TransactionPageContent = () => {
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
const txQuery = useTxQuery(); const txQuery = useTxQuery();
const tacOperationQuery = useApiQuery('tac:operation_by_tx_hash', {
pathParams: { tx_hash: hash },
queryOptions: {
enabled: tacFeature.isEnabled,
},
});
const { data, isPlaceholderData, isError, error, errorUpdateCount } = txQuery; const { data, isPlaceholderData, isError, error, errorUpdateCount } = txQuery;
const showDegradedView = publicClient && ((isError && error.status !== 422) || isPlaceholderData) && errorUpdateCount > 0; const showDegradedView = publicClient && ((isError && error.status !== 422) || isPlaceholderData) && errorUpdateCount > 0;
...@@ -45,7 +55,7 @@ const TransactionPageContent = () => { ...@@ -45,7 +55,7 @@ const TransactionPageContent = () => {
const tabs: Array<TabItemRegular> = (() => { const tabs: Array<TabItemRegular> = (() => {
const detailsComponent = showDegradedView ? const detailsComponent = showDegradedView ?
<TxDetailsDegraded hash={ hash } txQuery={ txQuery }/> : <TxDetailsDegraded hash={ hash } txQuery={ txQuery }/> :
<TxDetails txQuery={ txQuery }/>; <TxDetails txQuery={ txQuery } tacOperationQuery={ tacFeature.isEnabled ? tacOperationQuery : undefined }/>;
return [ return [
{ {
...@@ -89,7 +99,7 @@ const TransactionPageContent = () => { ...@@ -89,7 +99,7 @@ const TransactionPageContent = () => {
const tags = ( const tags = (
<EntityTags <EntityTags
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData || (tacFeature.isEnabled && tacOperationQuery.isPlaceholderData) }
tags={ txTags } tags={ txTags }
/> />
); );
......
...@@ -23,6 +23,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -23,6 +23,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity'; import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity'; import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
...@@ -31,6 +32,7 @@ import IconSvg from 'ui/shared/IconSvg'; ...@@ -31,6 +32,7 @@ import IconSvg from 'ui/shared/IconSvg';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
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';
import TacOperationTag from 'ui/shared/TacOperationTag';
import SearchResultEntityTag from './SearchResultEntityTag'; import SearchResultEntityTag from './SearchResultEntityTag';
...@@ -214,6 +216,28 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr ...@@ -214,6 +216,28 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr
); );
} }
case 'tac_operation': {
return (
<OperationEntity.Container>
<OperationEntity.Icon/>
<OperationEntity.Link
isLoading={ isLoading }
id={ data.tac_operation.operation_id }
onClick={ handleLinkClick }
>
<OperationEntity.Content
asProp="mark"
id={ data.tac_operation.operation_id }
textStyle="sm"
fontWeight={ 700 }
mr={ 2 }
/>
</OperationEntity.Link>
<TacOperationTag type={ data.tac_operation.type }/>
</OperationEntity.Container>
);
}
case 'blob': { case 'blob': {
return ( return (
<BlobEntity.Container> <BlobEntity.Container>
...@@ -325,6 +349,11 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr ...@@ -325,6 +349,11 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr
<Text color="text.secondary">{ dayjs(data.timestamp).format('llll') }</Text> <Text color="text.secondary">{ dayjs(data.timestamp).format('llll') }</Text>
); );
} }
case 'tac_operation': {
return (
<Text color="text.secondary">{ dayjs(data.tac_operation.timestamp).format('llll') }</Text>
);
}
case 'user_operation': { case 'user_operation': {
return ( return (
......
...@@ -24,6 +24,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -24,6 +24,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity'; import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity'; import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
...@@ -31,6 +32,7 @@ import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; ...@@ -31,6 +32,7 @@ import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
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';
import TacOperationTag from 'ui/shared/TacOperationTag';
import SearchResultEntityTag from './SearchResultEntityTag'; import SearchResultEntityTag from './SearchResultEntityTag';
...@@ -324,6 +326,35 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P ...@@ -324,6 +326,35 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P
); );
} }
case 'tac_operation': {
return (
<>
<TableCell colSpan={ 2 } fontSize="sm">
<OperationEntity.Container>
<OperationEntity.Icon/>
<OperationEntity.Link
isLoading={ isLoading }
id={ data.tac_operation.operation_id }
onClick={ handleLinkClick }
>
<OperationEntity.Content
asProp="mark"
id={ data.tac_operation.operation_id }
textStyle="sm"
fontWeight={ 700 }
mr={ 2 }
/>
</OperationEntity.Link>
<TacOperationTag type={ data.tac_operation.type }/>
</OperationEntity.Container>
</TableCell>
<TableCell fontSize="sm" verticalAlign="middle" isNumeric>
<Text color="text.secondary">{ dayjs(data.tac_operation.timestamp).format('llll') }</Text>
</TableCell>
</>
);
}
case 'blob': { case 'blob': {
return ( return (
<TableCell colSpan={ 3 } fontSize="sm"> <TableCell colSpan={ 3 } fontSize="sm">
......
...@@ -9,13 +9,14 @@ type Props = { ...@@ -9,13 +9,14 @@ type Props = {
// should be string, will be fixed on the back-end // should be string, will be fixed on the back-end
timestamp: string | number; timestamp: string | number;
isLoading?: boolean; isLoading?: boolean;
noIcon?: boolean;
}; };
const DetailedInfoTimestamp = ({ timestamp, isLoading }: Props) => { const DetailedInfoTimestamp = ({ timestamp, isLoading, noIcon }: Props) => {
return ( return (
<> <>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isLoading }/> { !noIcon && <IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isLoading } mr={ 2 }/> }
<Skeleton loading={ isLoading } ml={ 2 }> <Skeleton loading={ isLoading }>
{ dayjs(timestamp).fromNow() } { dayjs(timestamp).fromNow() }
</Skeleton> </Skeleton>
<TextSeparator color="gray.500"/> <TextSeparator color="gray.500"/>
......
...@@ -10,7 +10,7 @@ export const href = config.app.spriteHash ? `/icons/sprite.${ config.app.spriteH ...@@ -10,7 +10,7 @@ export const href = config.app.spriteHash ? `/icons/sprite.${ config.app.spriteH
export { IconName }; export { IconName };
interface Props extends HTMLChakraProps<'div'> { export interface Props extends HTMLChakraProps<'div'> {
name: IconName; name: IconName;
isLoading?: boolean; isLoading?: boolean;
} }
......
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { getTacOperationStatus } from 'lib/operations/tac';
import type { BadgeProps } from 'toolkit/chakra/badge';
import { Badge } from 'toolkit/chakra/badge';
interface Props extends BadgeProps {
type: tac.OperationType;
}
const TacOperationTag = ({ type, ...rest }: Props) => {
const text = getTacOperationStatus(type);
if (!text) {
return null;
}
return <Badge { ...rest }>{ text }</Badge>;
};
export default React.memo(TacOperationTag);
...@@ -56,6 +56,46 @@ test.describe('contract', () => { ...@@ -56,6 +56,46 @@ test.describe('contract', () => {
}); });
}); });
test.describe('shield', () => {
const ICON_URL = 'https://images.com/icons/shield.png';
test.use({ viewport: { width: 500, height: 200 } });
test('regular address with image', async({ render, page, mockAssetResponse }) => {
await mockAssetResponse(ICON_URL, './playwright/mocks/image_svg.svg');
await render(
<AddressEntity
address={{ ...addressMock.withoutName }}
icon={{
shield: { src: ICON_URL },
hint: 'Address on TON',
}}
/>,
);
await page.locator('img').first().hover();
await page.locator('div').filter({ hasText: 'Address on TON' }).first().waitFor({ state: 'visible' });
await expect(page).toHaveScreenshot();
});
test('contract with icon', async({ render, page }) => {
await render(
<AddressEntity
address={{ ...addressMock.contract, is_verified: true, implementations: null }}
icon={{
shield: { name: 'brands/ton' },
hint: 'Address on TON',
hintPostfix: ' on TON',
}}
/>,
);
await page.getByRole('img').first().hover();
await page.locator('div').filter({ hasText: 'Verified contract on TON' }).first().waitFor({ state: 'visible' });
await expect(page).toHaveScreenshot();
});
});
test.describe('proxy contract', () => { test.describe('proxy contract', () => {
test.use({ viewport: { width: 500, height: 300 } }); test.use({ viewport: { width: 500, height: 300 } });
......
...@@ -36,18 +36,17 @@ const Link = chakra((props: LinkProps) => { ...@@ -36,18 +36,17 @@ const Link = chakra((props: LinkProps) => {
); );
}); });
type IconProps = Pick<EntityProps, 'address' | 'isSafeAddress'> & EntityBase.IconBaseProps & { type IconProps = Pick<EntityProps, 'address' | 'isSafeAddress'> & EntityBase.IconBaseProps;
tooltipInteractive?: boolean;
};
const Icon = (props: IconProps) => { const Icon = (props: IconProps) => {
if (props.noIcon) { if (props.noIcon) {
return null; return null;
} }
const marginRight = props.marginRight ?? (props.shield ? '18px' : '8px');
const styles = { const styles = {
...getIconProps(props.variant), ...getIconProps(props.variant),
marginRight: props.marginRight ?? 2, marginRight,
}; };
if (props.isLoading) { if (props.isLoading) {
...@@ -69,35 +68,40 @@ const Icon = (props: IconProps) => { ...@@ -69,35 +68,40 @@ const Icon = (props: IconProps) => {
const isProxy = Boolean(props.address.implementations?.length); const isProxy = Boolean(props.address.implementations?.length);
const isVerified = isProxy ? props.address.is_verified && props.address.implementations?.every(({ name }) => Boolean(name)) : props.address.is_verified; const isVerified = isProxy ? props.address.is_verified && props.address.implementations?.every(({ name }) => Boolean(name)) : props.address.is_verified;
const contractIconName: EntityBase.IconBaseProps['name'] = props.address.is_verified ? 'contracts/verified' : 'contracts/regular'; const contractIconName: EntityBase.IconBaseProps['name'] = props.address.is_verified ? 'contracts/verified' : 'contracts/regular';
const label = (isVerified ? 'verified ' : '') + (isProxy ? 'proxy contract' : 'contract'); const label = (isVerified ? 'verified ' : '') + (isProxy ? 'proxy contract' : 'contract') + props.hintPostfix;
return ( return (
<Tooltip content={ label.slice(0, 1).toUpperCase() + label.slice(1) } interactive={ props.tooltipInteractive }> <EntityBase.Icon
<span> { ...props }
<EntityBase.Icon name={ isProxy ? 'contracts/proxy' : contractIconName }
{ ...props } color={ isVerified ? 'green.500' : undefined }
name={ isProxy ? 'contracts/proxy' : contractIconName } borderRadius={ 0 }
color={ isVerified ? 'green.500' : undefined } hint={ label.slice(0, 1).toUpperCase() + label.slice(1) }
borderRadius={ 0 } />
/>
</span>
</Tooltip>
); );
} }
const label = (() => { const label = (() => {
if (isDelegatedAddress) { if (isDelegatedAddress) {
return props.address.is_verified ? 'EOA + verified code' : 'EOA + code'; return (props.address.is_verified ? 'EOA + verified code' : 'EOA + code') + props.hintPostfix;
} }
return props.hint;
})(); })();
return ( return (
<Tooltip content={ label } disabled={ !label } interactive={ props.tooltipInteractive }> <Tooltip
content={ label }
disabled={ !label }
interactive={ props.tooltipInteractive }
positioning={ props.shield ? { offset: { mainAxis: 8 } } : undefined }
>
<Flex marginRight={ styles.marginRight } position="relative"> <Flex marginRight={ styles.marginRight } position="relative">
<AddressIdenticon <AddressIdenticon
size={ props.variant === 'heading' ? 30 : 20 } size={ props.variant === 'heading' ? 30 : 20 }
hash={ getDisplayedAddress(props.address) } hash={ getDisplayedAddress(props.address) }
/> />
{ props.shield && <EntityBase.IconShield { ...props.shield }/> }
{ isDelegatedAddress && <AddressIconDelegated isVerified={ Boolean(props.address.is_verified) }/> } { isDelegatedAddress && <AddressIconDelegated isVerified={ Boolean(props.address.is_verified) }/> }
</Flex> </Flex>
</Tooltip> </Tooltip>
......
import { chakra } from '@chakra-ui/react';
import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import * as AddressEntity from './AddressEntity';
const tacFeature = config.features.tac;
interface Props extends AddressEntity.EntityProps {
chainType: tac.BlockchainType | null;
}
const AddressEntityTacTon = (props: Props) => {
if (!tacFeature.isEnabled) {
return null;
}
const href = (() => {
switch (props.chainType) {
case tac.BlockchainType.TON:
return tacFeature.explorerUrl + route({
pathname: '/address/[hash]',
query: {
...props.query,
hash: props.address.hash,
},
});
case tac.BlockchainType.TAC:
return route({
pathname: '/address/[hash]',
query: {
...props.query,
hash: props.address.hash,
},
});
default:
return null;
}
})();
if (!href) {
return null;
}
return (
<AddressEntity.default
{ ...props }
href={ href }
isExternal={ props.chainType === tac.BlockchainType.TON }
icon={ props.chainType === tac.BlockchainType.TON ? {
shield: { name: 'brands/ton' },
hint: 'Address on TON',
hintPostfix: ' on TON',
} : undefined }
/>
);
};
export default chakra(AddressEntityTacTon);
import { chakra, Flex } from '@chakra-ui/react'; import { Box, chakra, Flex } from '@chakra-ui/react';
import type { IconProps } from '@chakra-ui/react'; import type { IconProps } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ImageProps } from 'toolkit/chakra/image';
import { Image } from 'toolkit/chakra/image';
import type { LinkProps } from 'toolkit/chakra/link'; import type { LinkProps } from 'toolkit/chakra/link';
import { Link as LinkToolkit } from 'toolkit/chakra/link'; import { Link as LinkToolkit } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton'; import { Skeleton } from 'toolkit/chakra/skeleton';
import { Tooltip } from 'toolkit/chakra/tooltip';
import type { Props as CopyToClipboardProps } from 'ui/shared/CopyToClipboard'; import type { Props as CopyToClipboardProps } from 'ui/shared/CopyToClipboard';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import type { IconName } from 'ui/shared/IconSvg'; import type { IconName, Props as IconSvgProps } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import TruncatedValue from 'ui/shared/TruncatedValue'; import TruncatedValue from 'ui/shared/TruncatedValue';
...@@ -87,29 +90,81 @@ const Link = chakra(({ isLoading, children, isExternal, onClick, href, noLink, v ...@@ -87,29 +90,81 @@ const Link = chakra(({ isLoading, children, isExternal, onClick, href, noLink, v
interface EntityIconProps extends Pick<IconProps, 'color' | 'borderRadius' | 'marginRight' | 'boxSize'> { interface EntityIconProps extends Pick<IconProps, 'color' | 'borderRadius' | 'marginRight' | 'boxSize'> {
name?: IconName; name?: IconName;
shield?: IconShieldProps;
hint?: string;
hintPostfix?: string;
tooltipInteractive?: boolean;
} }
export interface IconBaseProps extends Pick<EntityBaseProps, 'isLoading' | 'noIcon' | 'variant'>, EntityIconProps {} export interface IconBaseProps extends Pick<EntityBaseProps, 'isLoading' | 'noIcon' | 'variant'>, EntityIconProps {}
const Icon = ({ isLoading, noIcon, variant, name, color, borderRadius, marginRight, boxSize }: IconBaseProps) => { const Icon = ({ isLoading, noIcon, variant, name, color, borderRadius, marginRight, boxSize, shield, hint, tooltipInteractive }: IconBaseProps) => {
if (noIcon || !name) { if (noIcon || !name) {
return null; return null;
} }
const styles = getIconProps(variant); const styles = getIconProps(variant);
return (
const iconElement = (
<IconSvg <IconSvg
name={ name } name={ name }
boxSize={ boxSize ?? styles.boxSize } boxSize={ boxSize ?? styles.boxSize }
isLoading={ isLoading } isLoading={ isLoading }
borderRadius={ borderRadius ?? 'base' } borderRadius={ borderRadius ?? 'base' }
display="block" display="block"
mr={ marginRight ?? 2 } mr={ marginRight ?? (shield ? '18px' : '8px') }
color={ color ?? { _light: 'gray.500', _dark: 'gray.400' } } color={ color ?? { _light: 'gray.500', _dark: 'gray.400' } }
minW={ 0 } minW={ 0 }
flexShrink={ 0 } flexShrink={ 0 }
/> />
); );
const iconElementWithHint = hint ? (
<Tooltip
content={ hint }
interactive={ tooltipInteractive }
positioning={ shield ? { offset: { mainAxis: 8 } } : undefined }
>
{ iconElement }
</Tooltip>
) : iconElement;
if (!shield) {
return iconElementWithHint;
}
return (
<Box position="relative">
{ iconElementWithHint }
<IconShield { ...shield }/>
</Box>
);
};
type IconShieldProps = (ImageProps | IconSvgProps);
const IconShield = (props: IconShieldProps) => {
const styles = {
position: 'absolute',
top: '6px',
left: '12px',
boxSize: '18px',
borderRadius: 'full',
borderWidth: '1px',
borderStyle: 'solid',
// The colors can be changed on hover, if address is highlighted
// Because the highlighted styles are described as CSS classes, we must do the same for the shield border color.
// borderColor: 'global.body.bg',
// backgroundColor: 'global.body.bg',
};
if ('src' in props) {
return <Image className="entity__shield" { ...styles } { ...props }/>;
}
const svgProps = props as IconSvgProps;
return <IconSvg className="entity__shield" { ...styles } { ...svgProps }/>;
}; };
export interface ContentBaseProps extends Pick<EntityBaseProps, 'className' | 'isLoading' | 'truncation' | 'tailLength' | 'noTooltip' | 'variant'> { export interface ContentBaseProps extends Pick<EntityBaseProps, 'className' | 'isLoading' | 'truncation' | 'tailLength' | 'noTooltip' | 'variant'> {
...@@ -210,6 +265,7 @@ export { ...@@ -210,6 +265,7 @@ export {
Container, Container,
Link, Link,
Icon, Icon,
IconShield,
Copy, Copy,
Content, Content,
}; };
import { chakra } from '@chakra-ui/react';
import React from 'react';
import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components';
import { distributeEntityProps } from '../base/utils';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'id'>;
const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/operation/[id]', query: { id: props.id } });
return (
<EntityBase.Link
{ ...props }
href={ props.href ?? defaultHref }
>
{ props.children }
</EntityBase.Link>
);
});
const Icon = (props: EntityBase.IconBaseProps) => {
return (
<EntityBase.Icon
{ ...props }
name={ props.name ?? 'operation_slim' }
borderRadius="none"
/>
);
};
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'id'>;
const Content = chakra((props: ContentProps) => {
return (
<EntityBase.Content
{ ...props }
text={ props.id }
/>
);
});
type CopyProps = Omit<EntityBase.CopyBaseProps, 'text'> & Pick<EntityProps, 'id'>;
const Copy = ({ id, ...props }: CopyProps) => {
return (
<EntityBase.Copy
{ ...props }
text={ id }
/>
);
};
const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
id: string;
}
const OperationEntity = (props: EntityProps) => {
const partsProps = distributeEntityProps(props);
const content = <Content { ...partsProps.content }/>;
return (
<Container { ...partsProps.container }>
<Icon { ...partsProps.icon }/>
{ props.noLink ? content : <Link { ...partsProps.link }>{ content }</Link> }
<Copy { ...partsProps.copy }/>
</Container>
);
};
export default React.memo(OperationEntity);
export {
Container,
Link,
Icon,
Content,
Copy,
};
import { chakra } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import { stripTrailingSlash } from 'toolkit/utils/url';
import * as TxEntity from './TxEntity';
const tacFeature = config.features.tac;
const TxEntityTon = (props: TxEntity.EntityProps) => {
if (!tacFeature.isEnabled) {
return null;
}
const formattedHash = props.hash.replace(/^0x/, '');
const defaultHref = `${ stripTrailingSlash(tacFeature.explorerUrl) }/tx/${ formattedHash }`;
return <TxEntity.default { ...props } hash={ formattedHash } href={ props.href ?? defaultHref } icon={{ name: 'brands/ton' }} isExternal/>;
};
export default chakra(TxEntityTon);
...@@ -3,7 +3,7 @@ import type { SearchResultItem } from 'types/client/search'; ...@@ -3,7 +3,7 @@ import type { SearchResultItem } from 'types/client/search';
import config from 'configs/app'; import config from 'configs/app';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob' | 'domain'; export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob' | 'domain' | 'tac_operation';
export type Category = ApiCategory | 'app'; export type Category = ApiCategory | 'app';
export type ItemsCategoriesMap = export type ItemsCategoriesMap =
...@@ -23,6 +23,7 @@ export const searchCategories: Array<{ id: Category; title: string }> = [ ...@@ -23,6 +23,7 @@ export const searchCategories: Array<{ id: Category; title: string }> = [
{ id: 'public_tag', title: 'Public tags' }, { id: 'public_tag', title: 'Public tags' },
{ id: 'transaction', title: 'Transactions' }, { id: 'transaction', title: 'Transactions' },
{ id: 'block', title: 'Blocks' }, { id: 'block', title: 'Blocks' },
{ id: 'tac_operation', title: 'Operations' },
]; ];
if (config.features.userOps.isEnabled) { if (config.features.userOps.isEnabled) {
...@@ -48,6 +49,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh ...@@ -48,6 +49,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
block: { itemTitle: 'Block', itemTitleShort: 'Block' }, block: { itemTitle: 'Block', itemTitleShort: 'Block' },
user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' }, user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' },
blob: { itemTitle: 'Blob', itemTitleShort: 'Blob' }, blob: { itemTitle: 'Blob', itemTitleShort: 'Blob' },
tac_operation: { itemTitle: 'Operations', itemTitleShort: 'Operations' },
}; };
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined { export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
...@@ -84,5 +86,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C ...@@ -84,5 +86,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'ens_domain': { case 'ens_domain': {
return 'domain'; return 'domain';
} }
case 'tac_operation': {
return 'tac_operation';
}
} }
} }
...@@ -41,7 +41,7 @@ const ArbitrumL2MessageStatus = ({ status, isLoading }: Props) => { ...@@ -41,7 +41,7 @@ const ArbitrumL2MessageStatus = ({ status, isLoading }: Props) => {
break; break;
} }
return <StatusTag type={ type } text={ text } isLoading={ isLoading }/>; return <StatusTag type={ type } text={ text } loading={ isLoading }/>;
}; };
export default ArbitrumL2MessageStatus; export default ArbitrumL2MessageStatus;
...@@ -22,7 +22,7 @@ const ArbitrumL2TxnBatchStatus = ({ status, isLoading }: Props) => { ...@@ -22,7 +22,7 @@ const ArbitrumL2TxnBatchStatus = ({ status, isLoading }: Props) => {
break; break;
} }
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>; return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
}; };
export default ArbitrumL2TxnBatchStatus; export default ArbitrumL2TxnBatchStatus;
...@@ -31,7 +31,7 @@ const InteropMessageStatus = ({ status, isLoading }: Props) => { ...@@ -31,7 +31,7 @@ const InteropMessageStatus = ({ status, isLoading }: Props) => {
break; break;
} }
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>; return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
}; };
export default InteropMessageStatus; export default InteropMessageStatus;
...@@ -20,7 +20,7 @@ const ZkEvmL2TxnBatchStatus = ({ status, isLoading }: Props) => { ...@@ -20,7 +20,7 @@ const ZkEvmL2TxnBatchStatus = ({ status, isLoading }: Props) => {
break; break;
} }
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>; return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
}; };
export default ZkEvmL2TxnBatchStatus; export default ZkEvmL2TxnBatchStatus;
import { chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import capitalizeFirstLetter from 'lib/capitalizeFirstLetter'; import capitalizeFirstLetter from 'lib/capitalizeFirstLetter';
...@@ -10,15 +9,13 @@ import IconSvg from 'ui/shared/IconSvg'; ...@@ -10,15 +9,13 @@ import IconSvg from 'ui/shared/IconSvg';
export type StatusTagType = 'ok' | 'error' | 'pending'; export type StatusTagType = 'ok' | 'error' | 'pending';
export interface Props { export interface Props extends BadgeProps {
type: 'ok' | 'error' | 'pending'; type: 'ok' | 'error' | 'pending';
text: string; text: string;
errorText?: string | null; errorText?: string | null;
isLoading?: boolean;
className?: string;
} }
const StatusTag = ({ type, text, errorText, isLoading, className }: Props) => { const StatusTag = ({ type, text, errorText, ...rest }: Props) => {
let icon: IconName; let icon: IconName;
let colorPalette: BadgeProps['colorPalette']; let colorPalette: BadgeProps['colorPalette'];
...@@ -43,11 +40,11 @@ const StatusTag = ({ type, text, errorText, isLoading, className }: Props) => { ...@@ -43,11 +40,11 @@ const StatusTag = ({ type, text, errorText, isLoading, className }: Props) => {
return ( return (
<Tooltip content={ errorText } disabled={ !errorText }> <Tooltip content={ errorText } disabled={ !errorText }>
<Badge colorPalette={ colorPalette } loading={ isLoading } className={ className } startElement={ startElement }> <Badge colorPalette={ colorPalette } startElement={ startElement } { ...rest }>
{ capitalizedText } { capitalizedText }
</Badge> </Badge>
</Tooltip> </Tooltip>
); );
}; };
export default chakra(StatusTag); export default StatusTag;
import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { test, expect } from 'playwright/lib';
import TacOperationStatus from './TacOperationStatus';
const STATUSES: Array<tac.OperationType> = [
tac.OperationType.TON_TAC_TON,
tac.OperationType.TAC_TON,
tac.OperationType.TON_TAC,
tac.OperationType.ERROR,
tac.OperationType.PENDING,
];
test.use({ viewport: { width: 200, height: 50 } });
STATUSES.forEach((status) => {
test(`${ status }`, async({ render }) => {
const component = await render(<TacOperationStatus status={ status }/>);
await expect(component).toHaveScreenshot();
});
});
import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { getTacOperationStatus } from 'lib/operations/tac';
import { Tooltip } from 'toolkit/chakra/tooltip';
import StatusTag from './StatusTag';
interface Props {
status: tac.OperationType;
isLoading?: boolean;
noTooltip?: boolean;
}
const TacOperationStatus = ({ status, isLoading, noTooltip }: Props) => {
const text = getTacOperationStatus(status);
if (!text) {
return null;
}
switch (status) {
case tac.OperationType.ERROR:
return <StatusTag type="error" text={ text } loading={ isLoading }/>;
case tac.OperationType.ROLLBACK:
return (
<Tooltip
// eslint-disable-next-line max-len
content="The cross‑chain operation was reverted and the original assets and state were returned to the sender after a failure on the destination chain"
disabled={ noTooltip }
>
<StatusTag type="error" text={ text } loading={ isLoading }/>
</Tooltip>
);
case tac.OperationType.PENDING: {
return <StatusTag type="pending" text={ text } loading={ isLoading }/>;
}
default: {
return <StatusTag type="ok" text={ text } loading={ isLoading }/>;
}
}
};
export default React.memo(TacOperationStatus);
...@@ -34,7 +34,7 @@ const TxStatus = ({ status, errorText, isLoading }: Props) => { ...@@ -34,7 +34,7 @@ const TxStatus = ({ status, errorText, isLoading }: Props) => {
break; break;
} }
return <StatusTag type={ type } text={ text } errorText={ errorText } isLoading={ isLoading }/>; return <StatusTag type={ type } text={ text } errorText={ errorText } loading={ isLoading }/>;
}; };
export default TxStatus; export default TxStatus;
...@@ -12,11 +12,11 @@ interface Props { ...@@ -12,11 +12,11 @@ interface Props {
const ValidatorStabilityStatus = ({ state, isLoading }: Props) => { const ValidatorStabilityStatus = ({ state, isLoading }: Props) => {
switch (state) { switch (state) {
case 'active': case 'active':
return <StatusTag type="ok" text="Active" isLoading={ isLoading }/>; return <StatusTag type="ok" text="Active" loading={ isLoading }/>;
case 'probation': case 'probation':
return <StatusTag type="pending" text="Probation" isLoading={ isLoading }/>; return <StatusTag type="pending" text="Probation" loading={ isLoading }/>;
case 'inactive': case 'inactive':
return <StatusTag type="error" text="Inactive" isLoading={ isLoading }/>; return <StatusTag type="error" text="Inactive" loading={ isLoading }/>;
} }
}; };
......
...@@ -23,7 +23,7 @@ const ZkEvmL2TxnBatchStatus = ({ status, isLoading }: Props) => { ...@@ -23,7 +23,7 @@ const ZkEvmL2TxnBatchStatus = ({ status, isLoading }: Props) => {
break; break;
} }
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>; return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
}; };
export default ZkEvmL2TxnBatchStatus; export default ZkEvmL2TxnBatchStatus;
...@@ -22,7 +22,7 @@ const ZkSyncL2TxnBatchStatus = ({ status, isLoading }: Props) => { ...@@ -22,7 +22,7 @@ const ZkSyncL2TxnBatchStatus = ({ status, isLoading }: Props) => {
break; break;
} }
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>; return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
}; };
export default ZkSyncL2TxnBatchStatus; export default ZkSyncL2TxnBatchStatus;
...@@ -13,7 +13,7 @@ const UserOpStatus = ({ status, isLoading }: Props) => { ...@@ -13,7 +13,7 @@ const UserOpStatus = ({ status, isLoading }: Props) => {
} }
return ( return (
<StatusTag isLoading={ isLoading } type={ status === true ? 'ok' : 'error' } text={ status === true ? 'Success' : 'Failed' }/> <StatusTag loading={ isLoading } type={ status === true ? 'ok' : 'error' } text={ status === true ? 'Success' : 'Failed' }/>
); );
}; };
......
...@@ -124,6 +124,17 @@ test('search by tx hash +@mobile', async({ render, page, mockApiResponse }) => { ...@@ -124,6 +124,17 @@ test('search by tx hash +@mobile', async({ render, page, mockApiResponse }) => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
}); });
test('search by tac operation hash +@mobile', async({ render, page, mockApiResponse }) => {
const apiUrl = await mockApiResponse('general:quick_search', [
searchMock.tacOperation1,
], { queryParams: { q: searchMock.tacOperation1.tac_operation.operation_id } });
await render(<SearchBar/>);
await page.getByPlaceholder(/search/i).fill(searchMock.tacOperation1.tac_operation.operation_id);
await page.waitForResponse(apiUrl);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search by blob hash +@mobile', async({ render, page, mockApiResponse, mockEnvs }) => { test('search by blob hash +@mobile', async({ render, page, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.dataAvailability); await mockEnvs(ENVS_MAP.dataAvailability);
const apiUrl = await mockApiResponse('general:quick_search', [ const apiUrl = await mockApiResponse('general:quick_search', [
......
...@@ -11,6 +11,7 @@ import SearchBarSuggestBlock from './SearchBarSuggestBlock'; ...@@ -11,6 +11,7 @@ import SearchBarSuggestBlock from './SearchBarSuggestBlock';
import SearchBarSuggestDomain from './SearchBarSuggestDomain'; import SearchBarSuggestDomain from './SearchBarSuggestDomain';
import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel'; import SearchBarSuggestLabel from './SearchBarSuggestLabel';
import SearchBarSuggestTacOperation from './SearchBarSuggestTacOperation';
import SearchBarSuggestToken from './SearchBarSuggestToken'; import SearchBarSuggestToken from './SearchBarSuggestToken';
import SearchBarSuggestTx from './SearchBarSuggestTx'; import SearchBarSuggestTx from './SearchBarSuggestTx';
import SearchBarSuggestUserOp from './SearchBarSuggestUserOp'; import SearchBarSuggestUserOp from './SearchBarSuggestUserOp';
...@@ -56,6 +57,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm ...@@ -56,6 +57,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm
case 'ens_domain': { case 'ens_domain': {
return route({ pathname: '/address/[hash]', query: { hash: data.address_hash } }); return route({ pathname: '/address/[hash]', query: { hash: data.address_hash } });
} }
case 'tac_operation': {
return route({ pathname: '/operation/[id]', query: { id: data.tac_operation.operation_id } });
}
} }
})(); })();
...@@ -108,6 +112,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm ...@@ -108,6 +112,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm
case 'ens_domain': { case 'ens_domain': {
return <SearchBarSuggestDomain data={ data } searchTerm={ searchTerm } isMobile={ isMobile } addressFormat={ addressFormat }/>; return <SearchBarSuggestDomain data={ data } searchTerm={ searchTerm } isMobile={ isMobile } addressFormat={ addressFormat }/>;
} }
case 'tac_operation': {
return <SearchBarSuggestTacOperation data={ data } searchTerm={ searchTerm } isMobile={ isMobile } addressFormat={ addressFormat }/>;
}
} }
})(); })();
......
import { Text, Flex, chakra } from '@chakra-ui/react';
import React from 'react';
import type { ItemsProps } from './types';
import type { SearchResultTacOperation } from 'types/api/search';
import dayjs from 'lib/date/dayjs';
import * as OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import TacOperationTag from 'ui/shared/TacOperationTag';
const SearchBarSuggestTacOperation = ({ data, isMobile }: ItemsProps<SearchResultTacOperation>) => {
const icon = <OperationEntity.Icon/>;
const hash = (
<chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 } mr={ 2 }>
<HashStringShortenDynamic hash={ data.tac_operation.operation_id } noTooltip/>
</chakra.mark>
);
const status = <TacOperationTag type={ data.tac_operation.type }/>;
const date = dayjs(data.tac_operation.timestamp).format('llll');
if (isMobile) {
return (
<>
<Flex alignItems="center">
{ icon }
{ hash }
{ status }
</Flex>
<Text color="text.secondary">{ date }</Text>
</>
);
}
return (
<Flex columnGap={ 2 }>
<Flex alignItems="center" minW={ 0 }>
{ icon }
{ hash }
{ status }
</Flex>
<Text color="text.secondary" textAlign="end" flexShrink={ 0 } ml="auto">{ date }</Text>
</Flex>
);
};
export default React.memo(SearchBarSuggestTacOperation);
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment