Commit 011d3669 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into chakra-v3

parents 2750d34d 07388c62
...@@ -21,6 +21,7 @@ on: ...@@ -21,6 +21,7 @@ on:
- eth_sepolia - eth_sepolia
- eth_goerli - eth_goerli
- filecoin - filecoin
- immutable
- neon_devnet - neon_devnet
- optimism - optimism
- optimism_celestia - optimism_celestia
......
...@@ -21,6 +21,7 @@ on: ...@@ -21,6 +21,7 @@ on:
- eth_sepolia - eth_sepolia
- eth_goerli - eth_goerli
- filecoin - filecoin
- immutable
- mekong - mekong
- neon_devnet - neon_devnet
- optimism - optimism
......
...@@ -50,5 +50,27 @@ jobs: ...@@ -50,5 +50,27 @@ jobs:
test: test:
name: Run tests name: Run tests
needs: deploy_e2e needs: deploy_e2e
uses: blockscout/blockscout-ci-cd/.github/workflows/e2e_new.yaml@master runs-on: ubuntu-latest
secrets: inherit permissions: write-all
\ No newline at end of file steps:
- name: Get Vault credentials
id: retrieve-vault-secrets
uses: hashicorp/vault-action@v2.4.1
with:
url: https://vault.k8s.blockscout.com
role: ci-dev
path: github-jwt
method: jwt
tlsSkipVerify: false
exportToken: true
secrets: |
ci/data/dev/github token | WORKFLOW_TRIGGER_TOKEN ;
- name: Trigger tests
uses: convictional/trigger-workflow-and-wait@v1.6.1
with:
owner: blockscout
repo: blockscout-ci-cd
github_token: ${{ env.WORKFLOW_TRIGGER_TOKEN }}
workflow_file_name: e2e_new.yaml
ref: master
wait_interval: 30
...@@ -366,6 +366,7 @@ ...@@ -366,6 +366,7 @@
"celo_alfajores", "celo_alfajores",
"garnet", "garnet",
"gnosis", "gnosis",
"immutable",
"eth", "eth",
"eth_goerli", "eth_goerli",
"eth_sepolia", "eth_sepolia",
...@@ -374,6 +375,7 @@ ...@@ -374,6 +375,7 @@
"neon_devnet", "neon_devnet",
"optimism", "optimism",
"optimism_celestia", "optimism_celestia",
"optimism_interop_0",
"optimism_sepolia", "optimism_sepolia",
"polygon", "polygon",
"rari_testnet", "rari_testnet",
......
...@@ -35,6 +35,7 @@ const config: Feature<{ ...@@ -35,6 +35,7 @@ const config: Feature<{
type: RollupType; type: RollupType;
homepage: { showLatestBlocks: boolean }; homepage: { showLatestBlocks: boolean };
outputRootsEnabled: boolean; outputRootsEnabled: boolean;
interopEnabled: boolean;
L2WithdrawalUrl: string | undefined; L2WithdrawalUrl: string | undefined;
parentChain: ParentChain; parentChain: ParentChain;
DA: { DA: {
...@@ -50,6 +51,7 @@ const config: Feature<{ ...@@ -50,6 +51,7 @@ const config: Feature<{
type, type,
L2WithdrawalUrl: type === 'optimistic' ? L2WithdrawalUrl : undefined, L2WithdrawalUrl: type === 'optimistic' ? L2WithdrawalUrl : undefined,
outputRootsEnabled: type === 'optimistic' && getEnvValue('NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED') === 'true', outputRootsEnabled: type === 'optimistic' && getEnvValue('NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED') === 'true',
interopEnabled: type === 'optimistic' && getEnvValue('NEXT_PUBLIC_INTEROP_ENABLED') === 'true',
homepage: { homepage: {
showLatestBlocks: getEnvValue('NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS') === 'true', showLatestBlocks: getEnvValue('NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS') === 'true',
}, },
......
# Set of ENVs for Immutable network explorer
# https://explorer.immutable.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=immutable"
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# Instance ENVs
NEXT_PUBLIC_AD_BANNER_PROVIDER=none
NEXT_PUBLIC_AD_TEXT_PROVIDER=none
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=immutable-mainnet.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_DEX_POOLS_ENABLED=true
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/immutable-mainnet.json
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/immutable.json
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x6166cece570f4731ccc94c2d17d854ce88496cd3b48e03b537959992ab6685c8
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS=true
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED=false
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['no-repeat center/100% 100% url(https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-skins/immutable.jpg)'],'text_color':['rgba(19, 19, 19, 1)']}
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://blockscout-immutable.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_ENABLED=false
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/pools']
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=IMX
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=IMX
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'GeckoTerminal','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/geckoterminal.png','baseUrl':'https://www.geckoterminal.com/','paths':{'token':'/immutable-zkevm/pools'}}]
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/immutable-short.svg
NEXT_PUBLIC_NETWORK_ID=13371
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/immutable.svg
NEXT_PUBLIC_NETWORK_NAME=Immutable
NEXT_PUBLIC_NETWORK_RPC_URL=https://rpc.immutable.com/
NEXT_PUBLIC_NETWORK_SHORT_NAME=Immutable
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/immutable.png
NEXT_PUBLIC_STATS_API_HOST=https://stats-immutable-mainnet.k8s.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS=["miner"]
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'Rarible','collection_url':'https://rarible.com/collection/immutablex/{hash}/items','instance_url':'https://rarible.com/token/immutablex/{hash}:{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/rarible.png'}]
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
# Set of ENVs for OP Interop Alpha 0 network explorer
# https://optimism-interop-alpha-0.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=optimism_interop_0"
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# Instance ENVs
NEXT_PUBLIC_AD_BANNER_PROVIDER=none
NEXT_PUBLIC_AD_TEXT_PROVIDER=none
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=optimism-interop-alpha-0.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_FAULT_PROOF_ENABLED=true
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/optimism.json
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['linear-gradient(90deg, rgb(232, 52, 53) 0%, rgb(139, 28, 232) 100%)'],'text_color':['rgb(255, 255, 255)']}
NEXT_PUBLIC_INTEROP_ENABLED=true
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism.svg
NEXT_PUBLIC_NETWORK_ID=420120000
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism.svg
NEXT_PUBLIC_NETWORK_NAME=OP Interop Alpha 0
NEXT_PUBLIC_NETWORK_RPC_URL=https://interop-alpha-0.optimism.io
NEXT_PUBLIC_NETWORK_SHORT_NAME=OP Interop Alpha 0
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-sepolia.blockscout.com/
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://bridge.interop-alpha-0.optimism.io
NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED=false
NEXT_PUBLIC_ROLLUP_TYPE=optimistic
NEXT_PUBLIC_STATS_API_HOST=https://stats-optimism-interop-devnet-0.k8s-prod-1.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket', 'metamask']
\ No newline at end of file
...@@ -346,6 +346,17 @@ const rollupSchema = yup ...@@ -346,6 +346,17 @@ const rollupSchema = yup
value => value === undefined, value => value === undefined,
), ),
}), }),
NEXT_PUBLIC_INTEROP_ENABLED: yup
.boolean()
.when('NEXT_PUBLIC_ROLLUP_TYPE', {
is: 'optimistic',
then: (schema) => schema,
otherwise: (schema) => schema.test(
'not-exist',
'NEXT_PUBLIC_INTEROP_ENABLED can only be used if NEXT_PUBLIC_ROLLUP_TYPE is set to \'optimistic\' ',
value => value === undefined,
),
}),
NEXT_PUBLIC_ROLLUP_PARENT_CHAIN_NAME: yup NEXT_PUBLIC_ROLLUP_PARENT_CHAIN_NAME: yup
.string() .string()
.when('NEXT_PUBLIC_ROLLUP_TYPE', { .when('NEXT_PUBLIC_ROLLUP_TYPE', {
......
...@@ -5,3 +5,4 @@ NEXT_PUBLIC_FAULT_PROOF_ENABLED=true ...@@ -5,3 +5,4 @@ NEXT_PUBLIC_FAULT_PROOF_ENABLED=true
NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS=true NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS=true
NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED=false NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED=false
NEXT_PUBLIC_ROLLUP_PARENT_CHAIN={'baseUrl':'https://explorer.duckchain.io'} NEXT_PUBLIC_ROLLUP_PARENT_CHAIN={'baseUrl':'https://explorer.duckchain.io'}
NEXT_PUBLIC_INTEROP_ENABLED=true
\ No newline at end of file
...@@ -114,6 +114,11 @@ module.exports = { ...@@ -114,6 +114,11 @@ module.exports = {
return null; return null;
} }
break; break;
case '/interop-messages':
if (process.env.NEXT_PUBLIC_INTEROP_ENABLED !== 'true') {
return null;
}
break;
case '/pools': case '/pools':
if (process.env.NEXT_PUBLIC_DEX_POOLS_ENABLED !== 'true') { if (process.env.NEXT_PUBLIC_DEX_POOLS_ENABLED !== 'true') {
return null; return null;
......
...@@ -2,11 +2,16 @@ ...@@ -2,11 +2,16 @@
The app instance can be customized by passing the following variables to the Node.js environment at runtime. Some of these variables have been deprecated, and their full list can be found in the [file](./DEPRECATED_ENVS.md). The app instance can be customized by passing the following variables to the Node.js environment at runtime. Some of these variables have been deprecated, and their full list can be found in the [file](./DEPRECATED_ENVS.md).
**IMPORTANT NOTE!** For _production_ build purposes all json-like values should be single-quoted. If it contains a hash (`#`) or a dollar-sign (`$`) the whole value should be wrapped in single quotes as well (see `dotenv` [readme](https://github.com/bkeepers/dotenv#variable-substitution) for the reference) ## Read before you run the app
## Disclaimer about using variables ### Variables compulsoriness
Please note that in the tables below, the "Compulsoriness" column indicates whether the variable is required for starting up the application, except for the "App Features" section. All features are optional by definition; therefore, the "Compulsoriness" column indicates whether a certain variable is required or optional only within the context of that feature, not for the entire application.
Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will be exposed to the browser. So any user can obtain its values. Make sure that for all 3rd-party services keys (e.g., Sentri, Auth0, WalletConnect, etc.) in the services administration panel you have created a whitelist of allowed origins and have added your app domain into it. That will help you prevent using your key by unauthorized app, if someone gets its value. ### Disclaimer about using variables
Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will be exposed to the browser. So any user can obtain its values. Make sure that for all 3rd-party services keys (e.g., Auth0, WalletConnect, etc.) in the services administration panel you have created a whitelist of allowed origins and have added your app domain into it. That will help you prevent using your key by unauthorized app, if someone gets its value.
### Note about escaping variables values
All json-like values should be single-quoted. If it contains a hash (`#`) or a dollar-sign (`$`) the whole value should be wrapped in single quotes as well (see `dotenv` [readme](https://github.com/bkeepers/dotenv#variable-substitution) for the reference)
&nbsp; &nbsp;
...@@ -351,7 +356,7 @@ Settings for meta tags, OG tags and SEO ...@@ -351,7 +356,7 @@ Settings for meta tags, OG tags and SEO
## App features ## App features
*Note* The variables which are marked as required should be passed as described in order to enable the particular feature, but they are not required in the whole app context. *Note* The variables which are marked as required should be passed as described in order to enable the particular feature, but they are not required in the entire app context.
### My account ### My account
...@@ -461,6 +466,7 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi ...@@ -461,6 +466,7 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi
| NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL | `string` | URL for L2 -> L1 withdrawals (Optimistic stack only) | Required for `optimistic` rollups | - | `https://app.optimism.io/bridge/withdraw` | v1.24.0+ | | NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL | `string` | URL for L2 -> L1 withdrawals (Optimistic stack only) | Required for `optimistic` rollups | - | `https://app.optimism.io/bridge/withdraw` | v1.24.0+ |
| NEXT_PUBLIC_FAULT_PROOF_ENABLED | `boolean` | Set to `true` for chains with fault proof system enabled (Optimistic stack only) | - | - | `true` | v1.31.0+ | | NEXT_PUBLIC_FAULT_PROOF_ENABLED | `boolean` | Set to `true` for chains with fault proof system enabled (Optimistic stack only) | - | - | `true` | v1.31.0+ |
| NEXT_PUBLIC_HAS_MUD_FRAMEWORK | `boolean` | Set to `true` for instances that use MUD framework (Optimistic stack only) | - | - | `true` | v1.33.0+ | | NEXT_PUBLIC_HAS_MUD_FRAMEWORK | `boolean` | Set to `true` for instances that use MUD framework (Optimistic stack only) | - | - | `true` | v1.33.0+ |
| NEXT_PUBLIC_INTEROP_ENABLED | `boolean` | Enables "Interop messages" page (Optimistic stack only) | - | `false` | `true` | v1.39.0+ |
| NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS | `boolean` | Set to `true` to display "Latest blocks" widget instead of "Latest batches" on the home page | - | - | `true` | v1.36.0+ | | NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS | `boolean` | Set to `true` to display "Latest blocks" widget instead of "Latest batches" on the home page | - | - | `true` | v1.36.0+ |
| NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED | `boolean` | Enables "Output roots" page (Optimistic stack only) | - | `false` | `true` | v1.37.0+ | | NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED | `boolean` | Enables "Output roots" page (Optimistic stack only) | - | `false` | `true` | v1.37.0+ |
| NEXT_PUBLIC_ROLLUP_PARENT_CHAIN_NAME | `string` | Set to customize L1 transaction status labels in the UI (e.g., "Sent to <chain-name>"). This setting is applicable only for Arbitrum-based chains. **DEPRECATED** _Use `NEXT_PUBLIC_ROLLUP_PARENT_CHAIN` instead_ | - | - | `DuckChain` | v1.37.0+ | | NEXT_PUBLIC_ROLLUP_PARENT_CHAIN_NAME | `string` | Set to customize L1 transaction status labels in the UI (e.g., "Sent to <chain-name>"). This setting is applicable only for Arbitrum-based chains. **DEPRECATED** _Use `NEXT_PUBLIC_ROLLUP_PARENT_CHAIN` instead_ | - | - | `DuckChain` | v1.37.0+ |
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30">
<path fill="currentColor" fill-rule="evenodd" d="M8.455 6c.602 0 1.09.488 1.09 1.09v.864a6 6 0 0 0 10.91 0v-.863a1.091 1.091 0 0 1 2.181 0v.65a7.1 7.1 0 0 0 2.551 1.596 1.09 1.09 0 0 1-.738 2.053 9.3 9.3 0 0 1-1.813-.885v6.404h3.273a1.09 1.09 0 1 1 0 2.182h-3.273v3.273a1.09 1.09 0 0 1-2.181 0V19.09H9.545v3.273a1.09 1.09 0 1 1-2.181 0V19.09H4.09a1.09 1.09 0 1 1 0-2.182h3.273v-6.404a9.3 9.3 0 0 1-1.813.885 1.09 1.09 0 0 1-.738-2.053 7.1 7.1 0 0 0 2.55-1.596v-.65A1.094 1.094 0 0 1 8.456 6m7.636 10.91h4.363v-5.357a8.17 8.17 0 0 1-4.363 2.01zm-2.182-3.347v3.346H9.545v-5.356a8.18 8.18 0 0 0 4.364 2.01" clip-rule="evenodd"/>
</svg>
...@@ -82,6 +82,7 @@ import type { ...@@ -82,6 +82,7 @@ import type {
} from 'types/api/ens'; } from 'types/api/ens';
import type { IndexingStatus } from 'types/api/indexingStatus'; import type { IndexingStatus } from 'types/api/indexingStatus';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction'; import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { InteropMessageListResponse } from 'types/api/interop';
import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log'; import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { MudWorldsResponse } from 'types/api/mudWorlds'; import type { MudWorldsResponse } from 'types/api/mudWorlds';
import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves'; import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves';
...@@ -1243,6 +1244,15 @@ export const RESOURCES = { ...@@ -1243,6 +1244,15 @@ export const RESOURCES = {
block_countdown: { block_countdown: {
path: '/api', path: '/api',
}, },
// INTEROP
optimistic_l2_interop_messages: {
path: '/api/v2/optimism/interop/messages',
filterFields: [],
},
optimistic_l2_interop_messages_count: {
path: '/api/v2/optimism/interop/messages/count',
},
}; };
export type ResourceName = keyof typeof RESOURCES; export type ResourceName = keyof typeof RESOURCES;
...@@ -1296,7 +1306,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward ...@@ -1296,7 +1306,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward
'watchlist' | 'private_tags_address' | 'private_tags_tx' | 'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators_stability' | 'validators_blackfort' | 'validators_zilliqa' | 'noves_address_history' | 'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators_stability' | 'validators_blackfort' | 'validators_zilliqa' | 'noves_address_history' |
'token_transfers_all' | 'scroll_l2_txn_batches' | 'scroll_l2_txn_batch_txs' | 'scroll_l2_txn_batch_blocks' | 'token_transfers_all' | 'scroll_l2_txn_batches' | 'scroll_l2_txn_batch_txs' | 'scroll_l2_txn_batch_blocks' |
'scroll_l2_deposits' | 'scroll_l2_withdrawals' | 'advanced_filter' | 'pools'; 'scroll_l2_deposits' | 'scroll_l2_withdrawals' | 'advanced_filter' | 'pools' | 'optimistic_l2_interop_messages';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -1473,6 +1483,8 @@ Q extends 'optimistic_l2_output_roots_count' ? number : ...@@ -1473,6 +1483,8 @@ Q extends 'optimistic_l2_output_roots_count' ? number :
Q extends 'optimistic_l2_withdrawals_count' ? number : Q extends 'optimistic_l2_withdrawals_count' ? number :
Q extends 'optimistic_l2_deposits_count' ? number : Q extends 'optimistic_l2_deposits_count' ? number :
Q extends 'optimistic_l2_dispute_games_count' ? number : Q extends 'optimistic_l2_dispute_games_count' ? number :
Q extends 'optimistic_l2_interop_messages' ? InteropMessageListResponse :
Q extends 'optimistic_l2_interop_messages_count' ? number :
Q extends 'shibarium_withdrawals' ? ShibariumWithdrawalsResponse : Q extends 'shibarium_withdrawals' ? ShibariumWithdrawalsResponse :
Q extends 'shibarium_deposits' ? ShibariumDepositsResponse : Q extends 'shibarium_deposits' ? ShibariumDepositsResponse :
Q extends 'shibarium_withdrawals_count' ? number : Q extends 'shibarium_withdrawals_count' ? number :
......
...@@ -45,7 +45,7 @@ type TRewardsContext = { ...@@ -45,7 +45,7 @@ type TRewardsContext = {
openLoginModal: () => void; openLoginModal: () => void;
closeLoginModal: () => void; closeLoginModal: () => void;
saveApiToken: (token: string | undefined) => void; saveApiToken: (token: string | undefined) => void;
login: (refCode: string) => Promise<{ isNewUser?: boolean; invalidRefCodeError?: boolean }>; login: (refCode: string) => Promise<{ isNewUser: boolean; reward: string | null; invalidRefCodeError?: boolean }>;
claim: () => Promise<void>; claim: () => Promise<void>;
}; };
...@@ -70,7 +70,7 @@ const initialState = { ...@@ -70,7 +70,7 @@ const initialState = {
openLoginModal: () => {}, openLoginModal: () => {},
closeLoginModal: () => {}, closeLoginModal: () => {},
saveApiToken: () => {}, saveApiToken: () => {},
login: async() => ({}), login: async() => ({ isNewUser: false, reward: null }),
claim: async() => {}, claim: async() => {},
}; };
...@@ -211,10 +211,14 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -211,10 +211,14 @@ export function RewardsContextProvider({ children }: Props) {
apiFetch('rewards_nonce') as Promise<RewardsNonceResponse>, apiFetch('rewards_nonce') as Promise<RewardsNonceResponse>,
refCode ? refCode ?
apiFetch('rewards_check_ref_code', { pathParams: { code: refCode } }) as Promise<RewardsCheckRefCodeResponse> : apiFetch('rewards_check_ref_code', { pathParams: { code: refCode } }) as Promise<RewardsCheckRefCodeResponse> :
Promise.resolve({ valid: true }), Promise.resolve({ valid: true, reward: null }),
]); ]);
if (!checkCodeResponse.valid) { if (!checkCodeResponse.valid) {
return { invalidRefCodeError: true }; return {
invalidRefCodeError: true,
isNewUser: false,
reward: null,
};
} }
const message = getMessageToSign(address, nonceResponse.nonce, checkUserQuery.data?.exists, refCode); const message = getMessageToSign(address, nonceResponse.nonce, checkUserQuery.data?.exists, refCode);
const signature = await signMessageAsync({ message }); const signature = await signMessageAsync({ message });
...@@ -229,7 +233,10 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -229,7 +233,10 @@ export function RewardsContextProvider({ children }: Props) {
}, },
}) as RewardsLoginResponse; }) as RewardsLoginResponse;
saveApiToken(loginResponse.token); saveApiToken(loginResponse.token);
return { isNewUser: loginResponse.created }; return {
isNewUser: loginResponse.created,
reward: checkCodeResponse.reward,
};
} catch (_error) { } catch (_error) {
errorToast(_error); errorToast(_error);
throw _error; throw _error;
......
...@@ -115,6 +115,13 @@ export default function useNavItems(): ReturnType { ...@@ -115,6 +115,13 @@ export default function useNavItems(): ReturnType {
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
const rollupInteropMessages = rollupFeature.isEnabled && rollupFeature.interopEnabled ? {
text: 'Interop messages',
nextRoute: { pathname: '/interop-messages' as const },
icon: 'interop',
isActive: pathname === '/interop-messages',
} : null;
if (rollupFeature.isEnabled && ( if (rollupFeature.isEnabled && (
rollupFeature.type === 'optimistic' || rollupFeature.type === 'optimistic' ||
rollupFeature.type === 'arbitrum' || rollupFeature.type === 'arbitrum' ||
...@@ -127,7 +134,8 @@ export default function useNavItems(): ReturnType { ...@@ -127,7 +134,8 @@ export default function useNavItems(): ReturnType {
internalTxs, internalTxs,
rollupDeposits, rollupDeposits,
rollupWithdrawals, rollupWithdrawals,
], rollupInteropMessages,
].filter(Boolean),
[ [
blocks, blocks,
rollupTxnBatches, rollupTxnBatches,
......
...@@ -60,6 +60,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -60,6 +60,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/advanced-filter': 'Root page', '/advanced-filter': 'Root page',
'/pools': 'Root page', '/pools': 'Root page',
'/pools/[hash]': 'Regular page', '/pools/[hash]': 'Regular page',
'/interop-messages': 'Root page',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Regular page', '/login': 'Regular page',
......
...@@ -63,6 +63,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -63,6 +63,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/advanced-filter': DEFAULT_TEMPLATE, '/advanced-filter': DEFAULT_TEMPLATE,
'/pools': DEFAULT_TEMPLATE, '/pools': DEFAULT_TEMPLATE,
'/pools/[hash]': DEFAULT_TEMPLATE, '/pools/[hash]': DEFAULT_TEMPLATE,
'/interop-messages': DEFAULT_TEMPLATE,
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE, '/login': DEFAULT_TEMPLATE,
......
...@@ -60,6 +60,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -60,6 +60,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/advanced-filter': '%network_name% advanced filter', '/advanced-filter': '%network_name% advanced filter',
'/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',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': '%network_name% login', '/login': '%network_name% login',
......
...@@ -58,6 +58,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -58,6 +58,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/advanced-filter': 'Advanced filter', '/advanced-filter': 'Advanced filter',
'/pools': 'DEX pools', '/pools': 'DEX pools',
'/pools/[hash]': 'Pool details', '/pools/[hash]': 'Pool details',
'/interop-messages': 'Interop messages',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Login', '/login': 'Login',
......
import type { ChainInfo, InteropMessage } from 'types/api/interop';
export const chain: ChainInfo = {
chain_id: 1,
chain_name: 'Ethereum',
chain_logo: 'https://example.com/logo.png',
instance_url: 'https://example.com',
};
export const interopMessageIn: InteropMessage = {
init_transaction_hash: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
nonce: 1,
payload: 'payload',
relay_transaction_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
sender: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
status: 'Relayed',
target: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
timestamp: '2022-10-10T14:34:30.000000Z',
init_chain: chain,
};
export const interopMessageIn1: InteropMessage = {
init_transaction_hash: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
nonce: 1,
payload: 'payload',
relay_transaction_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
sender: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
status: 'Sent',
target: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
timestamp: '2022-10-10T14:34:30.000000Z',
init_chain: null,
};
export const interopMessageOut: InteropMessage = {
init_transaction_hash: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
nonce: 1,
payload: 'payload',
relay_transaction_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
sender: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
status: 'Relayed',
target: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
timestamp: '2022-10-10T14:34:30.000000Z',
relay_chain: chain,
};
export const interopMessageOut1: InteropMessage = {
init_transaction_hash: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
nonce: 1,
payload: 'payload',
relay_transaction_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
sender: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
status: 'Failed',
target: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
timestamp: '2022-10-10T14:34:30.000000Z',
relay_chain: null,
};
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import type { TokenTransfer, TokenTransferResponse } from 'types/api/tokenTransfer'; import type { TokenTransfer, TokenTransferResponse } from 'types/api/tokenTransfer';
import * as tokenInstanceMock from './tokenInstance';
export const erc20: TokenTransfer = { export const erc20: TokenTransfer = {
from: { from: {
hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859', hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
...@@ -86,6 +88,7 @@ export const erc721: TokenTransfer = { ...@@ -86,6 +88,7 @@ export const erc721: TokenTransfer = {
}, },
total: { total: {
token_id: '875879856', token_id: '875879856',
token_instance: tokenInstanceMock.base,
}, },
transaction_hash: '0xf13bc7afe5e02b494dd2f22078381d36a4800ef94a0ccc147431db56c301e6cc', transaction_hash: '0xf13bc7afe5e02b494dd2f22078381d36a4800ef94a0ccc147431db56c301e6cc',
type: 'token_transfer', type: 'token_transfer',
...@@ -135,6 +138,7 @@ export const erc1155A: TokenTransfer = { ...@@ -135,6 +138,7 @@ export const erc1155A: TokenTransfer = {
token_id: '123', token_id: '123',
value: '42', value: '42',
decimals: null, decimals: null,
token_instance: null,
}, },
transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746', transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746',
type: 'token_minting', type: 'token_minting',
...@@ -151,7 +155,7 @@ export const erc1155B: TokenTransfer = { ...@@ -151,7 +155,7 @@ export const erc1155B: TokenTransfer = {
name: 'SastanaNFT', name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
}, },
total: { token_id: '12345678', value: '100000000000000000000', decimals: null }, total: { token_id: '12345678', value: '100000000000000000000', decimals: null, token_instance: null },
}; };
export const erc1155C: TokenTransfer = { export const erc1155C: TokenTransfer = {
...@@ -161,7 +165,7 @@ export const erc1155C: TokenTransfer = { ...@@ -161,7 +165,7 @@ export const erc1155C: TokenTransfer = {
name: 'SastanaNFT', name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
}, },
total: { token_id: '483200961027732618117991942553110860267520', value: '200000000000000000000', decimals: null }, total: { token_id: '483200961027732618117991942553110860267520', value: '200000000000000000000', decimals: null, token_instance: null },
}; };
export const erc1155D: TokenTransfer = { export const erc1155D: TokenTransfer = {
...@@ -171,7 +175,7 @@ export const erc1155D: TokenTransfer = { ...@@ -171,7 +175,7 @@ export const erc1155D: TokenTransfer = {
name: 'SastanaNFT', name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
}, },
total: { token_id: '456', value: '42', decimals: null }, total: { token_id: '456', value: '42', decimals: null, token_instance: null },
}; };
export const erc404A: TokenTransfer = { export const erc404A: TokenTransfer = {
...@@ -213,6 +217,7 @@ export const erc404A: TokenTransfer = { ...@@ -213,6 +217,7 @@ export const erc404A: TokenTransfer = {
value: '42000000000000000000000000', value: '42000000000000000000000000',
decimals: '18', decimals: '18',
token_id: null, token_id: null,
token_instance: null,
}, },
transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746', transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746',
type: 'token_transfer', type: 'token_transfer',
...@@ -230,7 +235,7 @@ export const erc404B: TokenTransfer = { ...@@ -230,7 +235,7 @@ export const erc404B: TokenTransfer = {
name: 'SastanaNFT', name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
}, },
total: { token_id: '4625304364899952' }, total: { token_id: '4625304364899952', token_instance: null },
}; };
export const mixTokens: TokenTransferResponse = { export const mixTokens: TokenTransferResponse = {
......
...@@ -19,6 +19,7 @@ export const mintToken: TxStateChange = { ...@@ -19,6 +19,7 @@ export const mintToken: TxStateChange = {
direction: 'from', direction: 'from',
total: { total: {
token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065', token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065',
token_instance: null,
}, },
}, },
], ],
...@@ -57,6 +58,7 @@ export const receiveMintedToken: TxStateChange = { ...@@ -57,6 +58,7 @@ export const receiveMintedToken: TxStateChange = {
direction: 'to', direction: 'to',
total: { total: {
token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065', token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065',
token_instance: null,
}, },
}, },
], ],
......
...@@ -3,6 +3,7 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -3,6 +3,7 @@ import type { Transaction } from 'types/api/transaction';
import * as addressMock from 'mocks/address/address'; import * as addressMock from 'mocks/address/address';
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag'; import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
import * as interopMock from 'mocks/interop/interop';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer'; import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import * as decodedInputDataMock from 'mocks/txs/decodedInputData'; import * as decodedInputDataMock from 'mocks/txs/decodedInputData';
...@@ -423,3 +424,29 @@ export const withRecipientContract = { ...@@ -423,3 +424,29 @@ export const withRecipientContract = {
...withRecipientEns, ...withRecipientEns,
to: addressMock.contract, to: addressMock.contract,
}; };
export const withInteropInMessage: Transaction = {
...base,
op_interop: {
init_chain: interopMock.chain,
nonce: 1,
payload: '0x',
init_transaction_hash: '0x01a8c328b0370068aaaef49c107f70901cd79adcda81e3599a88855532122e09',
sender: addressMock.hash,
status: 'Sent',
target: addressMock.hash,
},
};
export const withInteropOutMessage: Transaction = {
...base,
op_interop: {
relay_chain: interopMock.chain,
nonce: 1,
payload: '0xfa4b78b90000000000000000000000000000000000000000000000000000000005001bcfe835d1028984e9e6e7d016b77164eacbcc6cc061e9333c0b37982b504f7ea791000000000000000000000000a79b29ad7e0196c95b87f4663ded82fbf2e3add8',
relay_transaction_hash: '0x01a8c328b0370068aaaef49c107f70901cd79adcda81e3599a88855532122e09',
sender: addressMock.hash,
status: 'Sent',
target: addressMock.hash,
},
};
...@@ -380,6 +380,17 @@ export const mud: GetServerSideProps<Props> = async(context) => { ...@@ -380,6 +380,17 @@ export const mud: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const interopMessages: GetServerSideProps<Props> = async(context) => {
const rollupFeature = config.features.rollup;
if (!rollupFeature.isEnabled || !rollupFeature.interopEnabled) {
return {
notFound: true,
};
}
return base(context);
};
export const pools: GetServerSideProps<Props> = async(context) => { export const pools: GetServerSideProps<Props> = async(context) => {
if (!config.features.pools.isEnabled) { if (!config.features.pools.isEnabled) {
return { return {
......
...@@ -47,6 +47,7 @@ declare module "nextjs-routes" { ...@@ -47,6 +47,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/graphiql"> | StaticRoute<"/graphiql">
| StaticRoute<"/"> | StaticRoute<"/">
| StaticRoute<"/internal-txs"> | StaticRoute<"/internal-txs">
| StaticRoute<"/interop-messages">
| StaticRoute<"/login"> | StaticRoute<"/login">
| StaticRoute<"/mud-worlds"> | StaticRoute<"/mud-worlds">
| DynamicRoute<"/name-domains/[name]", { "name": string }> | DynamicRoute<"/name-domains/[name]", { "name": string }>
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const InteropMessages = dynamic(() => import('ui/pages/InteropMessages'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/interop-messages">
<InteropMessages/>
</PageNextJs>
);
};
export default Page;
export { interopMessages as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -97,4 +97,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ...@@ -97,4 +97,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
externalTxs: [ externalTxs: [
[ 'NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG', '{"chain_name": "Solana", "chain_logo_url": "http://example.url", "explorer_url_template": "https://scan.io/tx/{hash}"}' ], [ 'NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG', '{"chain_name": "Solana", "chain_logo_url": "http://example.url", "explorer_url_template": "https://scan.io/tx/{hash}"}' ],
], ],
interop: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'optimistic' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
[ 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', 'https://localhost:3102' ],
[ 'NEXT_PUBLIC_INTEROP_ENABLED', 'true' ],
],
}; };
...@@ -86,6 +86,7 @@ ...@@ -86,6 +86,7 @@
| "integration/full" | "integration/full"
| "integration/partial" | "integration/partial"
| "internal_txns" | "internal_txns"
| "interop"
| "key" | "key"
| "lightning_navbar" | "lightning_navbar"
| "lightning" | "lightning"
......
<svg viewBox="0 0 184 79" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.83 78.287h31.624c11.672 0 21.144-9.357 21.144-20.914V26.1c0-11.557-9.472-20.914-21.144-20.914H24.831c-11.672 0-21.144 9.357-21.144 20.914v31.272c0 11.557 9.472 20.914 21.144 20.914Z" fill="#CFE3FF" stroke="#7E94CE" stroke-width="1.125" stroke-linejoin="round"/>
<path d="M22.581 74.538h31.624c11.671 0 21.143-9.357 21.143-20.914V22.352c0-11.557-9.472-20.914-21.143-20.914H22.58c-11.672 0-21.143 9.357-21.143 20.914v31.272c0 11.557 9.471 20.914 21.143 20.914Z" fill="#fff" stroke="#7E94CE" stroke-width="1.125" stroke-linejoin="round"/>
<path d="M31.138 32.901c.143 6.487 7.717 15.4 7.717 15.4s7.662-9.333 8.028-15.4c.397-6.561-7.454-13.299-8.083-13.827-.026-.022-.054-.021-.079.001-.588.538-7.73 7.21-7.583 13.826Z" fill="#FD95AF" stroke="#F27DA2" stroke-width="2.143"/>
<path d="M52.963 43.002c3.149-6.104.29-17.6.29-17.6s-9.824 2.533-13.206 7.95c-3.653 5.85-1.66 18.191-1.49 19.197.008.04.038.058.076.047.904-.266 11.13-3.39 14.33-9.594Z" fill="#FEE231" stroke="#CCAD18" stroke-width="2.143"/>
<path d="M24.53 42.601c3.065 6.146 14.082 10.733 14.082 10.733s3.07-12.101.703-18.023c-2.562-6.403-13.087-9.628-13.926-9.876-.033-.01-.06.004-.072.037-.3.806-3.914 10.857-.786 17.129Z" fill="#7ED5EE" stroke="#3DA6C5" stroke-width="2.143"/>
<path d="M25.596 51.18c4.098 4.855 13.141 4.615 14.703 4.535a.25.25 0 0 0 .24-.248c.043-1.535.114-10.26-3.59-14.397-4.381-4.895-15.692-5.938-16.688-6.02-.041-.004-.068.023-.064.064.09.942 1.142 11.023 5.4 16.067Z" fill="#68AE19" stroke="#005225" stroke-width="2.143"/>
<path d="M42.49 40.333c-4.238 4.387-4.51 14.055-4.526 15.274a.127.127 0 0 0 .124.13c1.175.032 10.265.126 14.606-4.155 4.68-4.614 5.97-15.572 6.075-16.538.004-.04-.021-.067-.062-.065-.955.04-11.618.593-16.217 5.354Z" fill="#68AE19" stroke="#005225" stroke-width="2.143"/>
<path d="M129.795 78.287h31.624c11.671 0 21.143-9.357 21.143-20.914V26.1c0-11.557-9.472-20.914-21.143-20.914h-31.624c-11.672 0-21.143 9.357-21.143 20.914v31.272c0 11.557 9.471 20.914 21.143 20.914Z" fill="#CFE3FF" stroke="#7E94CE" stroke-width="1.125" stroke-linejoin="round"/>
<path d="M127.546 74.538h31.624c11.671 0 21.143-9.357 21.143-20.914V22.352c0-11.557-9.472-20.914-21.143-20.914h-31.624c-11.672 0-21.143 9.357-21.143 20.914v31.272c0 11.557 9.471 20.914 21.143 20.914Z" fill="#fff" stroke="#7E94CE" stroke-width="1.125" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M137.981 51.029a4.626 4.626 0 0 1-4.411-3.228l-3.136-9.883h-1.927a4.629 4.629 0 0 1-4.412-3.228l-1.863-5.872h6.988l-3.686-5.553h34.921l-3.686 5.553h6.988l-1.863 5.872a4.628 4.628 0 0 1-4.411 3.228h-1.928l-3.135 9.883a4.629 4.629 0 0 1-4.412 3.228h-10.027Zm-2.368-15.81c0 1.49 1.113 2.7 2.486 2.7 1.373 0 2.486-1.21 2.486-2.7 0-1.49-1.113-2.7-2.486-2.7-1.373 0-2.486 1.21-2.486 2.7Zm14.764 0c0 1.49-1.113 2.7-2.486 2.7-1.374 0-2.487-1.21-2.487-2.7 0-1.49 1.113-2.7 2.487-2.7 1.373 0 2.486 1.21 2.486 2.7Z" fill="#004293"/>
<path d="M77.313 78.287h31.623c11.672 0 21.144-9.357 21.144-20.914V26.1c0-11.557-9.472-20.914-21.144-20.914H77.313c-11.672 0-21.144 9.357-21.144 20.914v31.272c0 11.557 9.472 20.914 21.144 20.914Z" fill="#CFE3FF" stroke="#7E94CE" stroke-width="1.125" stroke-linejoin="round"/>
<path d="M75.064 74.538h31.623c11.672 0 21.144-9.357 21.144-20.914V22.352c0-11.557-9.472-20.914-21.144-20.914H75.064c-11.672 0-21.144 9.357-21.144 20.914v31.272c0 11.557 9.472 20.914 21.144 20.914Z" fill="#fff" stroke="#7E94CE" stroke-width="1.125" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M109.285 48.52h.013v2.028a5.795 5.795 0 0 1-5.795 5.796h-3.333v-.01H81.7a1.77 1.77 0 0 1-1.768-1.774v-1.486c.007-.066.011-.134.011-.202 0-1.043-.908-1.889-2.028-1.889-1.08 0-1.964.787-2.025 1.78l-.005.002v1.672a1.833 1.833 0 1 1-3.665 0v-7.123a2.324 2.324 0 0 1-.016-.238v-4.203c0-.98.792-1.774 1.769-1.774H85.56a1.687 1.687 0 1 1 0 3.374h-1.792c-1.032.014-1.865.917-1.865 2.029 0 1.12.846 2.028 1.889 2.028h.003c.123.033 9.006.045 15.868.05v-.009h.028a1.927 1.927 0 0 0 1.924-1.808l.004-.003v-4.316c0-.8.649-1.449 1.449-1.449h4.767c.8 0 1.449.65 1.449 1.45v6.075ZM97.713 26.796a2.65 2.65 0 0 0-.126.004c-1.258-.038-12.012-.044-15.724-.044l-.05-.001c-1.025 0-1.862.8-1.924 1.809l-.003.002v4.316c0 .8-.649 1.449-1.449 1.449H73.67a1.45 1.45 0 0 1-1.449-1.45v-6.085h-.016v-2.029a5.796 5.796 0 0 1 5.796-5.796h3.333v.033h18.469c.976 0 1.768.794 1.768 1.774v1.498a1.734 1.734 0 0 0 0 .36.777.777 0 0 0 .036.213c.195.854 1.009 1.493 1.984 1.493 1.077 0 1.957-.78 2.024-1.768l.003-.001v-.05l.001-.07v-.025l-.001-.043v-1.496a1.832 1.832 0 1 1 3.665 0v7.139c.011.094.016.173.016.234v4.203a1.77 1.77 0 0 1-1.769 1.773h-4.191c-.068 0-.135-.004-.202-.011h-7.195a1.687 1.687 0 1 1 0-3.374h1.771c1.043 0 1.889-.908 1.889-2.028s-.846-2.029-1.89-2.029Z" fill="#5353D3"/>
</svg>
This diff is collapsed.
import type { InteropMessage } from 'types/api/interop';
import { ADDRESS_HASH } from './addressParams';
import { TX_HASH } from './tx';
export const INTEROP_MESSAGE: InteropMessage = {
init_transaction_hash: TX_HASH,
nonce: 52,
payload: '0x4f0edcc90000000000000000000000007da521cbbe62e89cd75e0993c78b8c68c25f696b',
relay_chain: {
chain_id: 420120000,
chain_name: 'Optimism Testnet',
chain_logo: null,
instance_url: 'https://optimism-interop-alpha-0.blockscout.com/',
},
relay_transaction_hash: TX_HASH,
sender: ADDRESS_HASH,
status: 'Relayed',
target: ADDRESS_HASH,
timestamp: '2025-02-20T01:05:14.000000Z',
};
...@@ -110,6 +110,7 @@ export const TOKEN_TRANSFER_ERC_721: TokenTransfer = { ...@@ -110,6 +110,7 @@ export const TOKEN_TRANSFER_ERC_721: TokenTransfer = {
...TOKEN_TRANSFER_ERC_20, ...TOKEN_TRANSFER_ERC_20,
total: { total: {
token_id: '35870', token_id: '35870',
token_instance: null,
}, },
token: TOKEN_INFO_ERC_721, token: TOKEN_INFO_ERC_721,
}; };
...@@ -120,6 +121,7 @@ export const TOKEN_TRANSFER_ERC_1155: TokenTransfer = { ...@@ -120,6 +121,7 @@ export const TOKEN_TRANSFER_ERC_1155: TokenTransfer = {
token_id: '35870', token_id: '35870',
value: '123', value: '123',
decimals: '18', decimals: '18',
token_instance: null,
}, },
token: TOKEN_INFO_ERC_1155, token: TOKEN_INFO_ERC_1155,
}; };
...@@ -130,6 +132,7 @@ export const TOKEN_TRANSFER_ERC_404: TokenTransfer = { ...@@ -130,6 +132,7 @@ export const TOKEN_TRANSFER_ERC_404: TokenTransfer = {
token_id: '35870', token_id: '35870',
value: '123', value: '123',
decimals: '18', decimals: '18',
token_instance: null,
}, },
token: TOKEN_INFO_ERC_404, token: TOKEN_INFO_ERC_404,
}; };
......
...@@ -32,6 +32,7 @@ export const STATE_CHANGE_TOKEN: TxStateChange = { ...@@ -32,6 +32,7 @@ export const STATE_CHANGE_TOKEN: TxStateChange = {
direction: 'to', direction: 'to',
total: { total: {
token_id: '1621395', token_id: '1621395',
token_instance: null,
}, },
}, },
], ],
......
...@@ -4,56 +4,58 @@ import { scroller, Element } from 'react-scroll'; ...@@ -4,56 +4,58 @@ import { scroller, Element } from 'react-scroll';
import useUpdateEffect from 'lib/hooks/useUpdateEffect'; import useUpdateEffect from 'lib/hooks/useUpdateEffect';
import type { ButtonProps } from './button'; import type { LinkProps } from './link';
import { Button } from './button'; import { Link } from './link';
interface CollapsibleDetailsProps extends ButtonProps { interface CollapsibleDetailsProps extends LinkProps {
children: React.ReactNode; children: React.ReactNode;
id?: string; id?: string;
isExpanded?: boolean; isExpanded?: boolean;
text?: [string, string]; text?: [string, string];
noScroll?: boolean;
} }
const SCROLL_CONFIG = {
duration: 500,
smooth: true,
};
const CUT_ID = 'CollapsibleDetails';
export const CollapsibleDetails = (props: CollapsibleDetailsProps) => { export const CollapsibleDetails = (props: CollapsibleDetailsProps) => {
const CUT_ID = 'CollapsibleDetails';
const { children, id = CUT_ID, onClick, isExpanded: isExpandedProp = false, text: textProp, loading, ...rest } = props; const { children, id = CUT_ID, onClick, isExpanded: isExpandedProp = false, text: textProp, loading, noScroll, ...rest } = props;
const [ isExpanded, setIsExpanded ] = React.useState(isExpandedProp); const [ isExpanded, setIsExpanded ] = React.useState(isExpandedProp);
const handleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => { const handleClick = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
setIsExpanded((flag) => !flag); setIsExpanded((flag) => !flag);
scroller.scrollTo(id, { if (!noScroll) {
duration: 500, scroller.scrollTo(id, SCROLL_CONFIG);
smooth: true, }
});
onClick?.(event); onClick?.(event);
}, [ id, onClick ]); }, [ id, noScroll, onClick ]);
useUpdateEffect(() => { useUpdateEffect(() => {
setIsExpanded(isExpandedProp); setIsExpanded(isExpandedProp);
isExpandedProp && scroller.scrollTo(id, { isExpandedProp && !noScroll && scroller.scrollTo(id, SCROLL_CONFIG);
duration: 500, }, [ isExpandedProp, id, noScroll ]);
smooth: true,
});
}, [ isExpandedProp, id ]);
const text = isExpanded ? (textProp?.[1] ?? 'Hide details') : (textProp?.[0] ?? 'View details'); const text = isExpanded ? (textProp?.[1] ?? 'Hide details') : (textProp?.[0] ?? 'View details');
return ( return (
<> <>
<Button <Link
variant="link"
textStyle="sm" textStyle="sm"
textDecorationLine="underline" textDecorationLine="underline"
textDecorationStyle="dashed" textDecorationStyle="dashed"
w="fit-content" w="fit-content"
onClick={ handleClick } onClick={ handleClick }
loadingSkeleton={ loading } loading={ loading }
{ ...rest } { ...rest }
> >
<Element name={ id }>{ text }</Element> <Element name={ id }>{ text }</Element>
</Button> </Link>
{ isExpanded && children } { isExpanded && children }
</> </>
); );
...@@ -62,7 +64,7 @@ export const CollapsibleDetails = (props: CollapsibleDetailsProps) => { ...@@ -62,7 +64,7 @@ export const CollapsibleDetails = (props: CollapsibleDetailsProps) => {
interface CollapsibleListProps<T> extends FlexProps { interface CollapsibleListProps<T> extends FlexProps {
items: Array<T>; items: Array<T>;
renderItem: (item: T, index: number) => React.ReactNode; renderItem: (item: T, index: number) => React.ReactNode;
triggerProps?: ButtonProps; triggerProps?: LinkProps;
cutLength?: number; cutLength?: number;
} }
...@@ -81,8 +83,7 @@ export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => { ...@@ -81,8 +83,7 @@ export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
<Flex flexDir="column" w="100%" { ...rest }> <Flex flexDir="column" w="100%" { ...rest }>
{ items.slice(0, isExpanded ? undefined : cutLength).map(renderItem) } { items.slice(0, isExpanded ? undefined : cutLength).map(renderItem) }
{ items.length > cutLength && ( { items.length > cutLength && (
<Button <Link
variant="link"
textStyle="sm" textStyle="sm"
textDecorationLine="underline" textDecorationLine="underline"
textDecorationStyle="dashed" textDecorationStyle="dashed"
...@@ -92,7 +93,7 @@ export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => { ...@@ -92,7 +93,7 @@ export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
{ ...triggerProps } { ...triggerProps }
> >
{ isExpanded ? 'Hide' : 'Show all' } { isExpanded ? 'Hide' : 'Show all' }
</Button> </Link>
) } ) }
</Flex> </Flex>
); );
......
...@@ -11,13 +11,15 @@ const PRESETS = { ...@@ -11,13 +11,15 @@ const PRESETS = {
eth: 'https://eth.blockscout.com', eth: 'https://eth.blockscout.com',
eth_goerli: 'https://eth-goerli.blockscout.com', eth_goerli: 'https://eth-goerli.blockscout.com',
eth_sepolia: 'https://eth-sepolia.blockscout.com', eth_sepolia: 'https://eth-sepolia.blockscout.com',
garnet: 'https://explorer.garnetchain.com',
filecoin: 'https://filecoin.blockscout.com', filecoin: 'https://filecoin.blockscout.com',
garnet: 'https://explorer.garnetchain.com',
gnosis: 'https://gnosis.blockscout.com', gnosis: 'https://gnosis.blockscout.com',
immutable: 'https://explorer.immutable.com',
mekong: 'https://mekong.blockscout.com', mekong: 'https://mekong.blockscout.com',
neon_devnet: 'https://neon-devnet.blockscout.com', neon_devnet: 'https://neon-devnet.blockscout.com',
optimism: 'https://optimism.blockscout.com', optimism: 'https://optimism.blockscout.com',
optimism_celestia: 'https://opcelestia-raspberry.gelatoscout.com', optimism_celestia: 'https://opcelestia-raspberry.gelatoscout.com',
optimism_interop_0: 'https://optimism-interop-alpha-0.blockscout.com',
optimism_sepolia: 'https://optimism-sepolia.blockscout.com', optimism_sepolia: 'https://optimism-sepolia.blockscout.com',
polygon: 'https://polygon.blockscout.com', polygon: 'https://polygon.blockscout.com',
rari_testnet: 'https://rari-testnet.cloud.blockscout.com', rari_testnet: 'https://rari-testnet.cloud.blockscout.com',
......
export interface ChainInfo {
chain_id: number;
chain_name: string | null;
chain_logo: string | null;
instance_url: string;
}
export type MessageStatus = 'Sent' | 'Relayed' | 'Failed';
export interface InteropMessage {
init_transaction_hash: string;
init_chain?: ChainInfo | null;
nonce: number;
payload: string;
relay_chain?: ChainInfo | null;
relay_transaction_hash: string | null;
sender: string;
status: MessageStatus;
target: string;
timestamp: string;
}
export interface InteropMessageListResponse {
items: Array<InteropMessage>;
next_page_params?: {
init_transaction_hash: string;
items_count: number;
timestamp: number;
};
}
...@@ -12,6 +12,8 @@ export type RewardsConfigResponse = { ...@@ -12,6 +12,8 @@ export type RewardsConfigResponse = {
export type RewardsCheckRefCodeResponse = { export type RewardsCheckRefCodeResponse = {
valid: boolean; valid: boolean;
is_custom: boolean;
reward: string | null;
}; };
export type RewardsNonceResponse = { export type RewardsNonceResponse = {
......
...@@ -59,10 +59,12 @@ export interface TokenInstance { ...@@ -59,10 +59,12 @@ export interface TokenInstance {
holder_address_hash: string | null; holder_address_hash: string | null;
image_url: string | null; image_url: string | null;
animation_url: string | null; animation_url: string | null;
media_url?: string | null;
media_type?: string | null;
external_app_url: string | null; external_app_url: string | null;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
owner: AddressParam | null; owner: AddressParam | null;
thumbnails: Partial<Record<ThumbnailSize, string>> | null; thumbnails: ({ original: string } & Partial<Record<Exclude<ThumbnailSize, 'original'>, string>>) | null;
} }
export interface TokenInstanceMetadataSocketMessage { export interface TokenInstanceMetadataSocketMessage {
......
import type { AddressParam } from './addressParams'; import type { AddressParam } from './addressParams';
import type { TokenInfo, TokenType } from './token'; import type { TokenInfo, TokenInstance, TokenType } from './token';
export type Erc20TotalPayload = { export type Erc20TotalPayload = {
decimals: string | null; decimals: string | null;
...@@ -8,20 +8,24 @@ export type Erc20TotalPayload = { ...@@ -8,20 +8,24 @@ export type Erc20TotalPayload = {
export type Erc721TotalPayload = { export type Erc721TotalPayload = {
token_id: string | null; token_id: string | null;
token_instance: TokenInstance | null;
}; };
export type Erc1155TotalPayload = { export type Erc1155TotalPayload = {
decimals: string | null; decimals: string | null;
value: string; value: string;
token_id: string | null; token_id: string | null;
token_instance: TokenInstance | null;
}; };
export type Erc404TotalPayload = { export type Erc404TotalPayload = {
decimals: string; decimals: string;
value: string; value: string;
token_id: null; token_id: null;
token_instance: TokenInstance | null;
} | { } | {
token_id: string; token_id: string;
token_instance: TokenInstance | null;
}; };
export type TokenTransfer = ( export type TokenTransfer = (
......
...@@ -3,6 +3,7 @@ import type { ArbitrumBatchStatus, ArbitrumL2TxData } from './arbitrumL2'; ...@@ -3,6 +3,7 @@ import type { ArbitrumBatchStatus, ArbitrumL2TxData } from './arbitrumL2';
import type { BlockTransactionsResponse } from './block'; import type { BlockTransactionsResponse } from './block';
import type { DecodedInput } from './decodedInput'; import type { DecodedInput } from './decodedInput';
import type { Fee } from './fee'; import type { Fee } from './fee';
import type { ChainInfo, MessageStatus } from './interop';
import type { NovesTxTranslation } from './noves'; import type { NovesTxTranslation } from './noves';
import type { OptimisticL2WithdrawalStatus } from './optimisticL2'; import type { OptimisticL2WithdrawalStatus } from './optimisticL2';
import type { ScrollL2BlockStatus } from './scrollL2'; import type { ScrollL2BlockStatus } from './scrollL2';
...@@ -107,6 +108,8 @@ export type Transaction = { ...@@ -107,6 +108,8 @@ export type Transaction = {
scroll?: ScrollTransactionData; scroll?: ScrollTransactionData;
// EIP-7702 // EIP-7702
authorization_list?: Array<TxAuthorization>; authorization_list?: Array<TxAuthorization>;
// Interop
op_interop?: InteropTransactionInfo;
}; };
type ArbitrumTransactionData = { type ArbitrumTransactionData = {
...@@ -215,3 +218,15 @@ export interface TxAuthorization { ...@@ -215,3 +218,15 @@ export interface TxAuthorization {
chain_id: number; chain_id: number;
nonce: number; nonce: number;
} }
export interface InteropTransactionInfo {
nonce: number;
payload: string;
init_chain?: ChainInfo | null;
relay_chain?: ChainInfo | null;
init_transaction_hash?: string;
relay_transaction_hash?: string;
sender: string;
status: MessageStatus;
target: string;
}
...@@ -33,6 +33,7 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P ...@@ -33,6 +33,7 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P
<NftMedia <NftMedia
mb="18px" mb="18px"
data={ tokenInstance } data={ tokenInstance }
size="md"
isLoading={ isLoading } isLoading={ isLoading }
autoplayVideo={ false } autoplayVideo={ false }
/> />
......
import { chakra, Text, Flex } from '@chakra-ui/react';
import React from 'react';
import type { InteropMessage } from 'types/api/interop';
import { PopoverBody, PopoverCloseTriggerWrapper, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
import AdditionalInfoButton from 'ui/shared/AdditionalInfoButton';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
type Props = {
payload: InteropMessage['payload'];
isLoading?: boolean;
className?: string;
};
const InteropMessageAdditionalInfo = ({ payload, isLoading, className }: Props) => {
return (
<PopoverRoot positioning={{ placement: 'right-start' }}>
<PopoverTrigger>
<AdditionalInfoButton loading={ isLoading } className={ className }/>
</PopoverTrigger>
<PopoverContent w="330px">
<PopoverBody>
<Flex alignItems="center" justifyContent="space-between" mb={ 3 }>
<Text color="text.secondary" fontWeight="600">Message payload</Text>
<PopoverCloseTriggerWrapper>
<CopyToClipboard text={ payload }/>
</PopoverCloseTriggerWrapper>
</Flex>
<Text>
{ payload }
</Text>
</PopoverBody>
</PopoverContent>
</PopoverRoot>
);
};
export default React.memo(chakra(InteropMessageAdditionalInfo));
import React from 'react';
import type { ChainInfo } from 'types/api/interop';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import type { EntityProps } from 'ui/shared/entities/tx/TxEntity';
import TxEntityInterop from 'ui/shared/entities/tx/TxEntityInterop';
type Props = {
relay_transaction_hash?: string | null;
relay_chain?: ChainInfo | null;
truncation?: EntityProps['truncation'];
isLoading?: boolean;
};
const InteropMessageDestinationTx = (props: Props) => {
if (props.relay_chain !== undefined) {
return (
<TxEntityInterop
chain={ props.relay_chain }
hash={ props.relay_transaction_hash }
isLoading={ props.isLoading }
truncation={ props.truncation || 'constant' }
/>
);
}
if (!props.relay_transaction_hash) {
return 'N/A';
}
return (
<TxEntity
hash={ props.relay_transaction_hash }
isLoading={ props.isLoading }
noIcon
truncation={ props.truncation || 'constant' }
/>
);
};
export default InteropMessageDestinationTx;
import React from 'react';
import type { ChainInfo } from 'types/api/interop';
import type { EntityProps } from 'ui/shared/entities/tx/TxEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityInterop from 'ui/shared/entities/tx/TxEntityInterop';
type Props = {
init_transaction_hash?: string | null;
init_chain?: ChainInfo | null;
isLoading?: boolean;
truncation?: EntityProps['truncation'];
};
const InteropMessageSourceTx = (props: Props) => {
if (props.init_chain !== undefined) {
return (
<TxEntityInterop
chain={ props.init_chain }
hash={ props.init_transaction_hash }
isLoading={ props.isLoading }
truncation={ props.truncation || 'constant' }
/>
);
}
if (!props.init_transaction_hash) {
return 'N/A';
}
return (
<TxEntity
hash={ props.init_transaction_hash }
isLoading={ props.isLoading }
noIcon
truncation={ props.truncation || 'constant' }
/>
);
};
export default InteropMessageSourceTx;
import { Flex, HStack, Text, Grid } from '@chakra-ui/react';
import React from 'react';
import type { InteropMessage } from 'types/api/interop';
import AddressFromToIcon from 'ui/shared/address/AddressFromToIcon';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import AddressEntityInterop from 'ui/shared/entities/address/AddressEntityInterop';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import InteropMessageStatus from 'ui/shared/statusTag/InteropMessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import InteropMessageAdditionalInfo from './InteropMessageAdditionalInfo';
import InteropMessageDestinationTx from './InteropMessageDestinationTx';
import InteropMessageSourceTx from './InteropMessageSourceTx';
interface Props {
item: InteropMessage;
isLoading?: boolean;
}
const InteropMessagesListItem = ({ item, isLoading }: Props) => {
return (
<ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" justifyContent="space-between" w="100%">
<InteropMessageStatus status={ item.status } isLoading={ isLoading }/>
<InteropMessageAdditionalInfo payload={ item.payload } isLoading={ isLoading }/>
</Flex>
<Flex alignItems="flex-start" flexDirection="column" gap={ 2 } w="100%">
<HStack w="100%">
<Text fontWeight={ 500 } flexGrow={ 1 }>#{ item.nonce }</Text>
<TimeAgoWithTooltip timestamp={ item.timestamp } isLoading={ isLoading } color="text.secondary"/>
</HStack>
<Grid templateColumns="120px 1fr" rowGap={ 2 }>
<Text as="span" color="text.secondary">Source tx</Text>
<InteropMessageSourceTx { ...item } isLoading={ isLoading }/>
<Text as="span" color="text.secondary">Destination tx</Text>
<InteropMessageDestinationTx { ...item } isLoading={ isLoading }/>
</Grid>
<Flex gap={ 2 } justifyContent="space-between" mt={ 2 }>
{ item.init_chain !== undefined ? (
<AddressEntityInterop
chain={ item.init_chain }
address={{ hash: item.sender }}
isLoading={ isLoading }
truncation="constant"
/>
) : (
<AddressEntity address={{ hash: item.sender }} isLoading={ isLoading } truncation="constant"/>
) }
<AddressFromToIcon
isLoading={ isLoading }
type={ item.init_chain !== undefined ? 'in' : 'out' }
/>
{ item.relay_chain !== undefined ? (
<AddressEntityInterop
chain={ item.relay_chain }
address={{ hash: item.target }}
isLoading={ isLoading }
truncation="constant"
/>
) : (
<AddressEntity address={{ hash: item.target }} isLoading={ isLoading } truncation="constant"/>
) }
</Flex>
</Flex>
</ListItemMobile>
);
};
export default React.memo(InteropMessagesListItem);
import React from 'react';
import type { InteropMessage } from 'types/api/interop';
import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table';
import InteropMessagesTableItem from './InteropMessagesTableItem';
interface Props {
items?: Array<InteropMessage>;
top: number;
isLoading?: boolean;
}
const InteropMessagesTable = ({ items, top, isLoading }: Props) => {
return (
<TableRoot tableLayout="auto">
<TableHeaderSticky top={ top }>
<TableRow>
<TableColumnHeader/>
<TableColumnHeader>Message</TableColumnHeader>
<TableColumnHeader>Age</TableColumnHeader>
<TableColumnHeader>Status</TableColumnHeader>
<TableColumnHeader>Source tx</TableColumnHeader>
<TableColumnHeader>Destination tx</TableColumnHeader>
<TableColumnHeader>Sender</TableColumnHeader>
<TableColumnHeader>In/Out</TableColumnHeader>
<TableColumnHeader>Target</TableColumnHeader>
</TableRow>
</TableHeaderSticky>
<TableBody>
{ items?.map((item, index) => (
<InteropMessagesTableItem
key={ item.init_transaction_hash + '_' + index }
item={ item }
isLoading={ isLoading }
/>
)) }
</TableBody>
</TableRoot>
);
};
export default React.memo(InteropMessagesTable);
import React from 'react';
import type { InteropMessage } from 'types/api/interop';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import AddressFromToIcon from 'ui/shared/address/AddressFromToIcon';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import AddressEntityInterop from 'ui/shared/entities/address/AddressEntityInterop';
import InteropMessageStatus from 'ui/shared/statusTag/InteropMessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import InteropMessageAdditionalInfo from './InteropMessageAdditionalInfo';
import InteropMessageDestinationTx from './InteropMessageDestinationTx';
import InteropMessageSourceTx from './InteropMessageSourceTx';
interface Props {
item: InteropMessage;
isLoading?: boolean;
}
const InteropMessagesTableItem = ({ item, isLoading }: Props) => {
return (
<TableRow>
<TableCell>
<InteropMessageAdditionalInfo payload={ item.payload } isLoading={ isLoading }/>
</TableCell>
<TableCell>
<Skeleton loading={ isLoading } fontWeight="700">
{ item.nonce }
</Skeleton>
</TableCell>
<TableCell>
<TimeAgoWithTooltip timestamp={ item.timestamp } isLoading={ isLoading } color="text.secondary"/>
</TableCell>
<TableCell>
<InteropMessageStatus status={ item.status } isLoading={ isLoading }/>
</TableCell>
<TableCell>
<InteropMessageSourceTx { ...item } isLoading={ isLoading }/>
</TableCell>
<TableCell>
<InteropMessageDestinationTx { ...item } isLoading={ isLoading }/>
</TableCell>
<TableCell>
{ item.init_chain !== undefined ?
<AddressEntityInterop address={{ hash: item.target }} isLoading={ isLoading } truncation="constant" chain={ item.init_chain }/> :
<AddressEntity address={{ hash: item.target }} isLoading={ isLoading } truncation="constant"/>
}
</TableCell>
<TableCell>
<AddressFromToIcon
isLoading={ isLoading }
type={ item.init_chain !== undefined ? 'in' : 'out' }
/>
</TableCell>
<TableCell>
{ item.relay_chain !== undefined ?
<AddressEntityInterop address={{ hash: item.target }} isLoading={ isLoading } truncation="constant" chain={ item.relay_chain }/> :
<AddressEntity address={{ hash: item.target }} isLoading={ isLoading } truncation="constant"/>
}
</TableCell>
</TableRow>
);
};
export default React.memo(InteropMessagesTableItem);
import React from 'react';
import * as interopMessageMock from 'mocks/interop/interop';
import { test, expect } from 'playwright/lib';
import InteropMessages from './InteropMessages';
test('default view +@mobile', async({ render, mockTextAd, mockAssetResponse, mockApiResponse }) => {
await mockTextAd();
await mockAssetResponse(interopMessageMock.chain.chain_logo as string, './playwright/mocks/image_s.jpg');
await mockApiResponse('optimistic_l2_interop_messages', {
items: [
interopMessageMock.interopMessageIn,
interopMessageMock.interopMessageIn1,
interopMessageMock.interopMessageOut,
interopMessageMock.interopMessageOut1,
],
next_page_params: {
init_transaction_hash: '1',
items_count: 4,
timestamp: 1719456000,
},
});
await mockApiResponse('optimistic_l2_interop_messages_count', 4000000);
const component = await render(<InteropMessages/>);
await expect(component).toHaveScreenshot();
});
import { Box } from '@chakra-ui/react';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { INTEROP_MESSAGE } from 'stubs/interop';
import { generateListStub } from 'stubs/utils';
import { Skeleton } from 'toolkit/chakra/skeleton';
import InteropMessagesListItem from 'ui/interopMessages/InteropMessagesListItem';
import InteropMessagesTable from 'ui/interopMessages/InteropMessagesTable';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
const InteropMessages = () => {
const interopMessagesQuery = useQueryWithPages({
resourceName: 'optimistic_l2_interop_messages',
options: {
placeholderData: generateListStub<'optimistic_l2_interop_messages'>(INTEROP_MESSAGE, 50, { next_page_params: {
init_transaction_hash: '',
items_count: 50,
timestamp: 0,
} }),
},
});
const countQuery = useApiQuery('optimistic_l2_interop_messages_count', {
queryOptions: {
placeholderData: 1927029,
},
});
const text = (() => {
if (countQuery.isError) {
return null;
}
return (
<Skeleton loading={ countQuery.isPlaceholderData }>
A total of { countQuery.data?.toLocaleString() } messages found
</Skeleton>
);
})();
const actionBar = <StickyPaginationWithText text={ text } pagination={ interopMessagesQuery.pagination }/>;
const content = (
<>
<Box hideFrom="lg">
{ interopMessagesQuery.data?.items.map((item, index) => (
<InteropMessagesListItem
key={ item.init_transaction_hash + (interopMessagesQuery.isPlaceholderData ? index : '') }
item={ item }
isLoading={ interopMessagesQuery.isPlaceholderData }
/>
)) }
</Box>
<Box hideBelow="lg">
<InteropMessagesTable
items={ interopMessagesQuery.data?.items }
top={ interopMessagesQuery.pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 }
isLoading={ interopMessagesQuery.isPlaceholderData }
/>
</Box>
</>
);
return (
<>
<PageTitle
title="Interop messages"
withTextAd
/>
<DataListDisplay
isError={ interopMessagesQuery.isError }
itemsNum={ interopMessagesQuery.data?.items.length }
emptyText="There are no interop messages."
actionBar={ actionBar }
>
{ content }
</DataListDisplay>
</>
);
};
export default InteropMessages;
...@@ -5,12 +5,12 @@ import config from 'configs/app'; ...@@ -5,12 +5,12 @@ import config from 'configs/app';
import { useRewardsContext } from 'lib/contexts/rewards'; import { useRewardsContext } from 'lib/contexts/rewards';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import { Alert } from 'toolkit/chakra/alert'; import { Alert } from 'toolkit/chakra/alert';
import { Image } from 'toolkit/chakra/image';
import { Link } from 'toolkit/chakra/link'; import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton'; import { Skeleton } from 'toolkit/chakra/skeleton';
import DailyRewardClaimButton from 'ui/rewards/dashboard/DailyRewardClaimButton'; import DailyRewardClaimButton from 'ui/rewards/dashboard/DailyRewardClaimButton';
import RewardsDashboardCard from 'ui/rewards/dashboard/RewardsDashboardCard'; import RewardsDashboardCard from 'ui/rewards/dashboard/RewardsDashboardCard';
import RewardsDashboardCardValue from 'ui/rewards/dashboard/RewardsDashboardCardValue'; import RewardsDashboardCardValue from 'ui/rewards/dashboard/RewardsDashboardCardValue';
import RewardsDashboardInfoCard from 'ui/rewards/dashboard/RewardsDashboardInfoCard';
import RewardsReadOnlyInputWithCopy from 'ui/rewards/RewardsReadOnlyInputWithCopy'; import RewardsReadOnlyInputWithCopy from 'ui/rewards/RewardsReadOnlyInputWithCopy';
import AdBanner from 'ui/shared/ad/AdBanner'; import AdBanner from 'ui/shared/ad/AdBanner';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
...@@ -53,7 +53,7 @@ const RewardsDashboard = () => { ...@@ -53,7 +53,7 @@ const RewardsDashboard = () => {
<span> <span>
The Blockscout Merits Program is just getting started! Learn more about the details, The Blockscout Merits Program is just getting started! Learn more about the details,
features, and future plans in our{ ' ' } features, and future plans in our{ ' ' }
<Link external href="https://www.blog.blockscout.com/blockscout-merits-rewarding-block-explorer-skills"> <Link external href={ `https://merits.blockscout.com/?tab=users&utm_source=${ config.chain.id }&utm_medium=text-banner` }>
blog post blog post
</Link>. </Link>.
</span> </span>
...@@ -69,12 +69,6 @@ const RewardsDashboard = () => { ...@@ -69,12 +69,6 @@ const RewardsDashboard = () => {
description="Claim your daily Merits and any Merits received from referrals." description="Claim your daily Merits and any Merits received from referrals."
direction="column-reverse" direction="column-reverse"
contentAfter={ <DailyRewardClaimButton/> } contentAfter={ <DailyRewardClaimButton/> }
>
<RewardsDashboardCardValue
label="Total balance"
value={ balancesQuery.data?.total || 'N/A' }
isLoading={ balancesQuery.isPending }
withIcon
hint={ ( hint={ (
<> <>
Total number of Merits earned from all activities.{ ' ' } Total number of Merits earned from all activities.{ ' ' }
...@@ -83,6 +77,11 @@ const RewardsDashboard = () => { ...@@ -83,6 +77,11 @@ const RewardsDashboard = () => {
</Link> </Link>
</> </>
) } ) }
>
<RewardsDashboardCardValue
value={ balancesQuery.data?.total || 'N/A' }
isLoading={ balancesQuery.isPending }
withIcon
/> />
</RewardsDashboardCard> </RewardsDashboardCard>
<RewardsDashboardCard <RewardsDashboardCard
...@@ -91,7 +90,6 @@ const RewardsDashboard = () => { ...@@ -91,7 +90,6 @@ const RewardsDashboard = () => {
direction="column-reverse" direction="column-reverse"
> >
<RewardsDashboardCardValue <RewardsDashboardCardValue
label="Referrals"
value={ referralsQuery.data?.referrals ? value={ referralsQuery.data?.referrals ?
`${ referralsQuery.data?.referrals } user${ Number(referralsQuery.data?.referrals) === 1 ? '' : 's' }` : `${ referralsQuery.data?.referrals } user${ Number(referralsQuery.data?.referrals) === 1 ? '' : 's' }` :
'N/A' 'N/A'
...@@ -110,29 +108,26 @@ const RewardsDashboard = () => { ...@@ -110,29 +108,26 @@ const RewardsDashboard = () => {
</Link> </Link>
</> </>
) } ) }
hint={ (
<>
See the{ ' ' }
<Link external href="https://docs.blockscout.com/using-blockscout/merits/streak-rewards">docs</Link>{ ' ' }
to learn how your streak number affects daily rewards
</>
) }
direction="column-reverse" direction="column-reverse"
> >
<RewardsDashboardCardValue <RewardsDashboardCardValue
label="Streak"
value={ value={
dailyRewardQuery.data?.streak ? dailyRewardQuery.data?.streak ?
`${ dailyRewardQuery.data?.streak } day${ Number(dailyRewardQuery.data?.streak) === 1 ? '' : 's' }` : `${ dailyRewardQuery.data?.streak } day${ Number(dailyRewardQuery.data?.streak) === 1 ? '' : 's' }` :
'N/A' 'N/A'
} }
isLoading={ dailyRewardQuery.isPending } isLoading={ dailyRewardQuery.isPending }
hint={ (
<>
See the{ ' ' }
<Link external href="https://docs.blockscout.com/using-blockscout/merits/streak-rewards">
docs
</Link>{ ' ' }
to learn how your streak number affects daily rewards
</>
) }
/> />
</RewardsDashboardCard> </RewardsDashboardCard>
</Flex> </Flex>
<Flex gap={ 6 } flexDirection={{ base: 'column', md: 'row' }}> <Flex w="full" gap={ 6 } flexDirection={{ base: 'column', md: 'row' }}>
<RewardsDashboardCard <RewardsDashboardCard
title="Referral program" title="Referral program"
description={ ( description={ (
...@@ -169,46 +164,38 @@ const RewardsDashboard = () => { ...@@ -169,46 +164,38 @@ const RewardsDashboard = () => {
/> />
</Flex> </Flex>
</RewardsDashboardCard> </RewardsDashboardCard>
<RewardsDashboardCard <RewardsDashboardInfoCard
title="Badges" title="Badges"
description={ ( description={ `Collect limited and legendary badges by completing different Blockscout related tasks.
<Flex flexDir="column" gap={ 2 }> Go to the badges website to see what${ apos }s available and start your collection today.` }
<span> imageSrc="/static/merits/badges.svg"
Collect limited and legendary badges by completing different Blockscout related tasks. imageWidth="260px"
Go to the badges website to see what{ apos }s available and start your collection today. imageHeight="86px"
</span> linkText="View badges"
</Flex> linkHref={ `https://merits.blockscout.com/?tab=badges&utm_source=${ config.chain.id }&utm_medium=badges` }
) }
>
<Flex
flex={ 1 }
gap={ 4 }
pl={ 10 }
pr={ 7 }
py={{ base: 4, lg: 0 }}
flexDirection={{ base: 'column', lg: 'row' }}
justifyContent="space-between"
alignItems="center"
>
<Image
src="/static/badges.svg"
alt="Badges"
w="260px"
h="86px"
fallback={ <Skeleton loading w="260px" h="86px"/> }
/> />
<Link
external
href="https://merits.blockscout.com/?tab=badges&utm_source=blockscout&utm_medium=dashboard"
textStyle="md"
fontWeight="500"
>
View badges
</Link>
</Flex> </Flex>
</RewardsDashboardCard> <Flex w="full" gap={ 6 } flexDirection={{ base: 'column', md: 'row' }}>
<RewardsDashboardInfoCard
title="Blockscout campaigns"
description="Join Blockscout activities to earn bonus Merits and exclusive rewards from our partners!"
imageSrc="/static/merits/campaigns.svg"
imageWidth="180px"
imageHeight="76px"
linkText="Check campaigns"
linkHref={ `https://merits.blockscout.com/?tab=campaigns&utm_source=${ config.chain.id }&utm_medium=campaigns` }
/>
<RewardsDashboardInfoCard
title="Use your Merits"
description="Spend your Merits to get exclusive discounts and offers across several web3 products!"
imageSrc="/static/merits/offers.svg"
imageWidth="180px"
imageHeight="86px"
linkText="Check offers"
linkHref={ `https://merits.blockscout.com/?tab=redeem&utm_source=${ config.chain.id }&utm_medium=redeem` }
/>
</Flex> </Flex>
<Flex gap={ 6 } flexDirection={{ base: 'column', md: 'row' }}> <Flex w="full" gap={ 6 } flexDirection={{ base: 'column', md: 'row' }}>
<RewardsDashboardCard <RewardsDashboardCard
title="Activity" title="Activity"
description="Earn Merits for your everyday Blockscout activities. You deserve to be rewarded for choosing open-source public goods!" description="Earn Merits for your everyday Blockscout activities. You deserve to be rewarded for choosing open-source public goods!"
......
...@@ -97,7 +97,16 @@ const TokenInstanceContent = () => { ...@@ -97,7 +97,16 @@ const TokenInstanceContent = () => {
{ {
id: 'token_transfers', id: 'token_transfers',
title: 'Token transfers', title: 'Token transfers',
component: <TokenTransfer transfersQuery={ transfersQuery } tokenId={ id } tokenQuery={ tokenQuery } shouldRender={ !isLoading } tabsHeight={ 80 }/>, component: (
<TokenTransfer
transfersQuery={ transfersQuery }
tokenId={ id }
tokenQuery={ tokenQuery }
tokenInstance={ tokenInstanceQuery.data }
shouldRender={ !isLoading }
tabsHeight={ 80 }
/>
),
}, },
shouldFetchHolders ? shouldFetchHolders ?
{ {
......
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import { mixTokens } from 'mocks/tokens/tokenTransfer'; import { mixTokens } from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import TokenTransfers from './TokenTransfers'; import TokenTransfers from './TokenTransfers';
test('base view +@mobile', async({ render, mockTextAd, mockApiResponse }) => { test('base view +@mobile', async({ render, mockTextAd, mockApiResponse, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
await mockTextAd(); await mockTextAd();
await mockApiResponse('token_transfers_all', mixTokens, { queryParams: { type: [] } }); await mockApiResponse('token_transfers_all', mixTokens, { queryParams: { type: [] } });
const component = await render(<Box pt={{ base: '106px', lg: 0 }}> <TokenTransfers/> </Box>); const component = await render(<Box pt={{ base: '106px', lg: 0 }}> <TokenTransfers/> </Box>);
......
...@@ -2,6 +2,7 @@ import { useRouter } from 'next/router'; ...@@ -2,6 +2,7 @@ import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types'; import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import type { EntityTag as TEntityTag } from 'ui/shared/EntityTags/types';
import config from 'configs/app'; import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
...@@ -31,6 +32,7 @@ import TxUserOps from 'ui/tx/TxUserOps'; ...@@ -31,6 +32,7 @@ import TxUserOps from 'ui/tx/TxUserOps';
import useTxQuery from 'ui/tx/useTxQuery'; import useTxQuery from 'ui/tx/useTxQuery';
const txInterpretation = config.features.txInterpretation; const txInterpretation = config.features.txInterpretation;
const rollupFeature = config.features.rollup;
const TransactionPageContent = () => { const TransactionPageContent = () => {
const router = useRouter(); const router = useRouter();
...@@ -78,10 +80,21 @@ const TransactionPageContent = () => { ...@@ -78,10 +80,21 @@ const TransactionPageContent = () => {
const activeTab = useActiveTabFromQuery(tabs); const activeTab = useActiveTabFromQuery(tabs);
const txTags: Array<TEntityTag> = data?.transaction_tag ?
[ { slug: data.transaction_tag, name: data.transaction_tag, tagType: 'private_tag' as const, ordinal: 1 } ] : [];
if (rollupFeature.isEnabled && rollupFeature.interopEnabled && data?.op_interop) {
if (data.op_interop.init_chain !== undefined) {
txTags.push({ slug: 'relay_tx', name: 'Relay tx', tagType: 'custom' as const, ordinal: 0 });
}
if (data.op_interop.relay_chain !== undefined) {
txTags.push({ slug: 'init_tx', name: 'Source tx', tagType: 'custom' as const, ordinal: 0 });
}
}
const tags = ( const tags = (
<EntityTags <EntityTags
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
tags={ data?.transaction_tag ? [ { slug: data.transaction_tag, name: data.transaction_tag, tagType: 'private_tag' as const, ordinal: 10 } ] : [] } tags={ txTags }
/> />
); );
......
...@@ -2,10 +2,12 @@ import { Flex, Text } from '@chakra-ui/react'; ...@@ -2,10 +2,12 @@ import { Flex, Text } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { Badge } from 'toolkit/chakra/badge'; import { Badge } from 'toolkit/chakra/badge';
import Hint from 'ui/shared/Hint';
type Props = { type Props = {
title?: string; title: string;
description: string | React.ReactNode; description: string | React.ReactNode;
hint?: string | React.ReactNode;
availableSoon?: boolean; availableSoon?: boolean;
blurFilter?: boolean; blurFilter?: boolean;
contentAfter?: React.ReactNode; contentAfter?: React.ReactNode;
...@@ -15,7 +17,7 @@ type Props = { ...@@ -15,7 +17,7 @@ type Props = {
}; };
const RewardsDashboardCard = ({ const RewardsDashboardCard = ({
title, description, availableSoon, contentAfter, title, description, hint, availableSoon, contentAfter,
direction = 'column', children, blurFilter, direction = 'column', children, blurFilter,
}: Props) => { }: Props) => {
return ( return (
...@@ -36,12 +38,11 @@ const RewardsDashboardCard = ({ ...@@ -36,12 +38,11 @@ const RewardsDashboardCard = ({
p={{ base: 1.5, md: 3 }} p={{ base: 1.5, md: 3 }}
w={{ base: 'full', md: direction === 'row' ? '340px' : 'full' }} w={{ base: 'full', md: direction === 'row' ? '340px' : 'full' }}
> >
{ title && (
<Flex alignItems="center" gap={ 2 }> <Flex alignItems="center" gap={ 2 }>
<Text fontSize={{ base: 'md', md: 'lg' }} fontWeight="500">{ title }</Text> <Text fontSize={{ base: 'md', md: 'lg' }} fontWeight="500">{ title }</Text>
{ hint && <Hint label={ hint }/> }
{ availableSoon && <Badge colorPalette="blue">Available soon</Badge> } { availableSoon && <Badge colorPalette="blue">Available soon</Badge> }
</Flex> </Flex>
) }
<Text as="div" fontSize="sm"> <Text as="div" fontSize="sm">
{ description } { description }
</Text> </Text>
......
...@@ -7,7 +7,7 @@ import Hint from 'ui/shared/Hint'; ...@@ -7,7 +7,7 @@ import Hint from 'ui/shared/Hint';
import MeritsIcon from '../MeritsIcon'; import MeritsIcon from '../MeritsIcon';
type Props = { type Props = {
label: string; label?: string;
value: number | string | undefined; value: number | string | undefined;
withIcon?: boolean; withIcon?: boolean;
hint?: string | React.ReactNode; hint?: string | React.ReactNode;
...@@ -16,6 +16,7 @@ type Props = { ...@@ -16,6 +16,7 @@ type Props = {
const RewardsDashboardCard = ({ label, value, withIcon, hint, isLoading }: Props) => ( const RewardsDashboardCard = ({ label, value, withIcon, hint, isLoading }: Props) => (
<Flex key={ label } flexDirection="column" alignItems="center" gap={ 2 }> <Flex key={ label } flexDirection="column" alignItems="center" gap={ 2 }>
{ label && (
<Flex alignItems="center" gap={ 1 }> <Flex alignItems="center" gap={ 1 }>
{ hint && ( { hint && (
<Hint label={ hint }/> <Hint label={ hint }/>
...@@ -24,6 +25,7 @@ const RewardsDashboardCard = ({ label, value, withIcon, hint, isLoading }: Props ...@@ -24,6 +25,7 @@ const RewardsDashboardCard = ({ label, value, withIcon, hint, isLoading }: Props
{ label } { label }
</Text> </Text>
</Flex> </Flex>
) }
<Skeleton <Skeleton
loading={ isLoading } loading={ isLoading }
display="flex" display="flex"
......
import { Flex } from '@chakra-ui/react';
import React from 'react';
import { Image } from 'toolkit/chakra/image';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import RewardsDashboardCard from './RewardsDashboardCard';
type Props = {
title: string;
description: string | React.ReactNode;
imageSrc: string;
imageWidth: string;
imageHeight: string;
linkText: string;
linkHref: string;
};
const RewardsDashboardInfoCard = ({ title, description, imageSrc, imageWidth, imageHeight, linkText, linkHref }: Props) => (
<RewardsDashboardCard
title={ title }
description={ description }
>
<Flex
flex={ 1 }
gap={ 4 }
pl={ 10 }
pr={ 7 }
py={{ base: 4, lg: 0 }}
flexDirection={{ base: 'column', lg: 'row' }}
justifyContent="space-between"
alignItems="center"
>
<Image
src={ imageSrc }
alt={ title }
w={ imageWidth }
h={ imageHeight }
fallback={ <Skeleton loading w={ imageWidth } h={ imageHeight }/> }
/>
<Link
external
href={ linkHref }
fontSize="md"
fontWeight="500"
>
{ linkText }
</Link>
</Flex>
</RewardsDashboardCard>
);
export default RewardsDashboardInfoCard;
...@@ -26,6 +26,7 @@ const RewardsLoginModal = () => { ...@@ -26,6 +26,7 @@ const RewardsLoginModal = () => {
const [ isLoginStep, setIsLoginStep ] = React.useState(true); const [ isLoginStep, setIsLoginStep ] = React.useState(true);
const [ isReferral, setIsReferral ] = React.useState(false); const [ isReferral, setIsReferral ] = React.useState(false);
const [ customReferralReward, setCustomReferralReward ] = React.useState<string | null>(null);
const [ authModalInitialScreen, setAuthModalInitialScreen ] = React.useState<Screen>(); const [ authModalInitialScreen, setAuthModalInitialScreen ] = React.useState<Screen>();
const authModal = useDisclosure(); const authModal = useDisclosure();
...@@ -33,13 +34,13 @@ const RewardsLoginModal = () => { ...@@ -33,13 +34,13 @@ const RewardsLoginModal = () => {
if (!isLoginModalOpen) { if (!isLoginModalOpen) {
setIsLoginStep(true); setIsLoginStep(true);
setIsReferral(false); setIsReferral(false);
setCustomReferralReward(null);
} }
}, [ isLoginModalOpen, setIsLoginStep, setIsReferral ]); }, [ isLoginModalOpen ]);
const goNext = useCallback((isReferral: boolean) => { const goNext = useCallback((isReferral: boolean, reward: string | null) => {
if (isReferral) { setIsReferral(isReferral);
setIsReferral(true); setCustomReferralReward(reward);
}
setIsLoginStep(false); setIsLoginStep(false);
}, [ setIsLoginStep, setIsReferral ]); }, [ setIsLoginStep, setIsReferral ]);
...@@ -78,7 +79,7 @@ const RewardsLoginModal = () => { ...@@ -78,7 +79,7 @@ const RewardsLoginModal = () => {
<DialogBody> <DialogBody>
{ isLoginStep ? { isLoginStep ?
<LoginStepContent goNext={ goNext } openAuthModal={ handleAuthModalOpen } closeModal={ closeLoginModal }/> : <LoginStepContent goNext={ goNext } openAuthModal={ handleAuthModalOpen } closeModal={ closeLoginModal }/> :
<CongratsStepContent isReferral={ isReferral }/> <CongratsStepContent isReferral={ isReferral } customReferralReward={ customReferralReward }/>
} }
</DialogBody> </DialogBody>
</DialogContent> </DialogContent>
......
...@@ -13,14 +13,17 @@ import RewardsReadOnlyInputWithCopy from '../../RewardsReadOnlyInputWithCopy'; ...@@ -13,14 +13,17 @@ import RewardsReadOnlyInputWithCopy from '../../RewardsReadOnlyInputWithCopy';
type Props = { type Props = {
isReferral: boolean; isReferral: boolean;
customReferralReward: string | null;
}; };
const CongratsStepContent = ({ isReferral }: Props) => { const CongratsStepContent = ({ isReferral, customReferralReward }: Props) => {
const { referralsQuery, rewardsConfigQuery } = useRewardsContext(); const { referralsQuery, rewardsConfigQuery } = useRewardsContext();
const registrationReward = rewardsConfigQuery.data?.rewards.registration; const registrationReward = Number(rewardsConfigQuery.data?.rewards.registration);
const registrationWithReferralReward = rewardsConfigQuery.data?.rewards.registration_with_referral; const registrationWithReferralReward = customReferralReward ?
const referralReward = Number(registrationWithReferralReward) - Number(registrationReward); Number(customReferralReward) + registrationReward :
Number(rewardsConfigQuery.data?.rewards.registration_with_referral);
const referralReward = registrationWithReferralReward - registrationReward;
const refLink = referralsQuery.data?.link || 'N/A'; const refLink = referralsQuery.data?.link || 'N/A';
const shareText = `I joined the @blockscout Merits Program and got my first ${ registrationReward || 'N/A' } #Merits! Use this link for a sign-up bonus and start earning rewards with @blockscout block explorer.\n\n${ refLink }`; // eslint-disable-line max-len const shareText = `I joined the @blockscout Merits Program and got my first ${ registrationReward || 'N/A' } #Merits! Use this link for a sign-up bonus and start earning rewards with @blockscout block explorer.\n\n${ refLink }`; // eslint-disable-line max-len
...@@ -41,7 +44,7 @@ const CongratsStepContent = ({ isReferral }: Props) => { ...@@ -41,7 +44,7 @@ const CongratsStepContent = ({ isReferral }: Props) => {
<MeritsIcon boxSize={{ base: isReferral ? 8 : 12, md: 12 }} mr={{ base: isReferral ? 1 : 2, md: 2 }}/> <MeritsIcon boxSize={{ base: isReferral ? 8 : 12, md: 12 }} mr={{ base: isReferral ? 1 : 2, md: 2 }}/>
<Skeleton loading={ rewardsConfigQuery.isLoading }> <Skeleton loading={ rewardsConfigQuery.isLoading }>
<Text fontSize={{ base: isReferral ? '24px' : '30px', md: '30px' }} fontWeight="700" color={ textColor }> <Text fontSize={{ base: isReferral ? '24px' : '30px', md: '30px' }} fontWeight="700" color={ textColor }>
+{ rewardsConfigQuery.data?.rewards[ isReferral ? 'registration_with_referral' : 'registration' ] || 'N/A' } +{ (isReferral ? registrationWithReferralReward : registrationReward) || 'N/A' }
</Text> </Text>
</Skeleton> </Skeleton>
{ isReferral && ( { isReferral && (
......
...@@ -18,7 +18,7 @@ import { Switch } from 'toolkit/chakra/switch'; ...@@ -18,7 +18,7 @@ import { Switch } from 'toolkit/chakra/switch';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery'; import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
type Props = { type Props = {
goNext: (isReferral: boolean) => void; goNext: (isReferral: boolean, reward: string | null) => void;
closeModal: () => void; closeModal: () => void;
openAuthModal: (isAuth: boolean, trySharedLogin?: boolean) => void; openAuthModal: (isAuth: boolean, trySharedLogin?: boolean) => void;
}; };
...@@ -58,12 +58,12 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => { ...@@ -58,12 +58,12 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
try { try {
setRefCodeError(false); setRefCodeError(false);
setIsLoading(true); setIsLoading(true);
const { isNewUser, invalidRefCodeError } = await login(isSignUp && isRefCodeUsed ? refCode : ''); const { isNewUser, reward, invalidRefCodeError } = await login(isSignUp && isRefCodeUsed ? refCode : '');
if (invalidRefCodeError) { if (invalidRefCodeError) {
setRefCodeError(true); setRefCodeError(true);
} else { } else {
if (isNewUser) { if (isNewUser) {
goNext(isRefCodeUsed); goNext(isRefCodeUsed, reward);
} else { } else {
closeModal(); closeModal();
router.push({ pathname: '/account/merits' }, undefined, { shallow: true }); router.push({ pathname: '/account/merits' }, undefined, { shallow: true });
...@@ -71,14 +71,11 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => { ...@@ -71,14 +71,11 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
} }
} catch (error) {} } catch (error) {}
setIsLoading(false); setIsLoading(false);
}, [ login, goNext, setIsLoading, router, closeModal, refCode, setRefCodeError, isRefCodeUsed, isSignUp ]); }, [ login, goNext, router, closeModal, refCode, isRefCodeUsed, isSignUp ]);
React.useEffect(() => { React.useEffect(() => {
if (isSignUp && isRefCodeUsed && refCode.length > 0 && refCode.length !== 6) { const isInvalid = isSignUp && isRefCodeUsed && refCode.length > 0 && refCode.length !== 6 && refCode.length !== 12;
setRefCodeError(true); setRefCodeError(isInvalid);
} else {
setRefCodeError(false);
}
}, [ refCode, isRefCodeUsed, isSignUp ]); }, [ refCode, isRefCodeUsed, isSignUp ]);
const handleButtonClick = React.useCallback(() => { const handleButtonClick = React.useCallback(() => {
...@@ -118,7 +115,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => { ...@@ -118,7 +115,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
return ( return (
<> <>
<Image <Image
src="/static/merits_program.png" src="/static/merits/merits_program.png"
alt="Merits program" alt="Merits program"
mb={ 3 } mb={ 3 }
fallback={ <Skeleton loading w="full" h="120px" mb={ 3 }/> } fallback={ <Skeleton loading w="full" h="120px" mb={ 3 }/> }
...@@ -150,7 +147,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => { ...@@ -150,7 +147,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
mt={ 3 } mt={ 3 }
invalid={ refCodeError } invalid={ refCodeError }
helperText="The code should be in format XXXXXX" helperText="The code should be in format XXXXXX"
errorText={ refCodeError ? 'Incorrect code or format' : undefined } errorText={ refCodeError ? 'Incorrect code or format (6 or 12 characters)' : undefined }
> >
<Input <Input
value={ refCode } value={ refCode }
......
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer'; import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
...@@ -23,7 +24,8 @@ const data = [ ...@@ -23,7 +24,8 @@ const data = [
tokenTransferMock.erc1155D, tokenTransferMock.erc1155D,
]; ];
test('without tx info', async({ render }) => { test('without tx info', async({ render, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render( const component = await render(
<Box pt={{ base: '134px', lg: 6 }}> <Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferList <TokenTransferList
...@@ -36,7 +38,8 @@ test('without tx info', async({ render }) => { ...@@ -36,7 +38,8 @@ test('without tx info', async({ render }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with tx info', async({ render }) => { test('with tx info', async({ render, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render( const component = await render(
<Box pt={{ base: '134px', lg: 6 }}> <Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferList <TokenTransferList
......
...@@ -68,7 +68,7 @@ const TokenTransferListItem = ({ ...@@ -68,7 +68,7 @@ const TokenTransferListItem = ({
) } ) }
</Flex> </Flex>
{ total && 'token_id' in total && total.token_id !== null && token && ( { total && 'token_id' in total && total.token_id !== null && token && (
<NftEntity hash={ token.address } id={ total.token_id } isLoading={ isLoading }/> <NftEntity hash={ token.address } id={ total.token_id } instance={ total.token_instance } isLoading={ isLoading }/>
) } ) }
{ showTxInfo && txHash && ( { showTxInfo && txHash && (
<Flex justifyContent="space-between" alignItems="center" lineHeight="24px" width="100%"> <Flex justifyContent="space-between" alignItems="center" lineHeight="24px" width="100%">
......
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer'; import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import TokenTransferTable from './TokenTransferTable'; import TokenTransferTable from './TokenTransferTable';
test('without tx info', async({ render }) => { test('without tx info', async({ render, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render( const component = await render(
<Box pt={{ base: '134px', lg: 6 }}> <Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferTable <TokenTransferTable
...@@ -20,7 +22,8 @@ test('without tx info', async({ render }) => { ...@@ -20,7 +22,8 @@ test('without tx info', async({ render }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with tx info', async({ render }) => { test('with tx info', async({ render, mockAssetResponse }) => {
await mockAssetResponse(tokenInstanceMock.base.image_url as string, './playwright/mocks/image_s.jpg');
const component = await render( const component = await render(
<Box pt={{ base: '134px', lg: 6 }}> <Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferTable <TokenTransferTable
......
...@@ -76,6 +76,7 @@ const TokenTransferTableItem = ({ ...@@ -76,6 +76,7 @@ const TokenTransferTableItem = ({
<NftEntity <NftEntity
hash={ token.address } hash={ token.address }
id={ total.token_id } id={ total.token_id }
instance={ total.token_instance }
isLoading={ isLoading } isLoading={ isLoading }
/> />
) } ) }
......
...@@ -44,6 +44,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props) ...@@ -44,6 +44,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props)
<TokenTransferSnippetNft <TokenTransferSnippetNft
token={ data.token } token={ data.token }
tokenId={ total.token_id } tokenId={ total.token_id }
instance={ total.token_instance }
value="1" value="1"
/> />
); );
...@@ -56,6 +57,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props) ...@@ -56,6 +57,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props)
key={ total.token_id } key={ total.token_id }
token={ data.token } token={ data.token }
tokenId={ total.token_id } tokenId={ total.token_id }
instance={ total.token_instance }
value={ total.value } value={ total.value }
/> />
); );
......
import { chakra } from '@chakra-ui/react'; import { chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo, TokenInstance } from 'types/api/token';
import NftEntity from 'ui/shared/entities/nft/NftEntity'; import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity';
...@@ -10,9 +10,10 @@ interface Props { ...@@ -10,9 +10,10 @@ interface Props {
token: TokenInfo; token: TokenInfo;
value: string; value: string;
tokenId: string | null; tokenId: string | null;
instance?: TokenInstance | null;
} }
const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => { const NftTokenTransferSnippet = ({ value, token, tokenId, instance }: Props) => {
const num = value === '1' ? '' : value; const num = value === '1' ? '' : value;
const tokenIdContent = (() => { const tokenIdContent = (() => {
...@@ -28,6 +29,7 @@ const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => { ...@@ -28,6 +29,7 @@ const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => {
<NftEntity <NftEntity
hash={ token.address } hash={ token.address }
id={ tokenId } id={ tokenId }
instance={ instance }
fontWeight={ 600 } fontWeight={ 600 }
variant="content" variant="content"
maxW={{ base: '100%', lg: '150px' }} maxW={{ base: '100%', lg: '150px' }}
......
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as interopMock from 'mocks/interop/interop';
import { test, expect } from 'playwright/lib';
import AddressEntityInterop from './AddressEntityInterop';
test.use({ viewport: { width: 180, height: 140 } });
test('with chain icon', async({ render, mockAssetResponse }) => {
await mockAssetResponse('https://example.com/logo.png', './playwright/mocks/image_svg.svg');
const component = await render(
<AddressEntityInterop
address={ addressMock.withoutName }
chain={ interopMock.chain }
/>,
);
await expect(component).toHaveScreenshot();
});
test('with chain icon stub +@dark-mode', async({ render }) => {
const component = await render(
<AddressEntityInterop
address={ addressMock.withoutName }
chain={{ ...interopMock.chain, chain_logo: null }}
/>,
);
await expect(component).toHaveScreenshot();
});
import { Box, chakra, Flex } from '@chakra-ui/react';
import React from 'react';
import type { ChainInfo } from 'types/api/interop';
import { route } from 'nextjs-routes';
import { Image } from 'toolkit/chakra/image';
import { Tooltip } from 'toolkit/chakra/tooltip';
import IconSvg from 'ui/shared/IconSvg';
import { distributeEntityProps } from '../base/utils';
import * as AddressEntity from './AddressEntity';
interface Props extends AddressEntity.EntityProps {
chain: ChainInfo | null;
}
const IconStub = () => {
return (
<Flex
position="absolute"
bottom="-2px"
right="4px"
alignItems="center"
justifyContent="center"
borderRadius="base"
background={{ _light: 'gray.100', _dark: 'gray.700' }}
width="14px"
height="14px"
border="1px solid"
borderColor="global.body.bg"
>
<IconSvg
name="networks/icon-placeholder"
width="10px"
height="10px"
color="text.secondary"
/>
</Flex>
);
};
const AddressEntryInterop = (props: Props) => {
const partsProps = distributeEntityProps(props);
const href = props.chain?.instance_url ? props.chain.instance_url.replace(/\/$/, '') + route({
pathname: '/address/[hash]',
query: {
...props.query,
hash: props.address.hash,
},
}) : null;
const addressIcon = (
<Box position="relative">
<AddressEntity.Icon { ...partsProps.icon }/>
{ !props.isLoading && (
props.chain?.chain_logo ? (
<Image
position="absolute"
bottom="-3px"
right="4px"
src={ props.chain.chain_logo }
alt={ props.chain.chain_name || 'external chain logo' }
width="14px"
height="14px"
borderRadius="base"
/>
) : (
<IconStub/>
)
) }
</Box>
);
return (
<AddressEntity.Container>
{ props.chain && (
<Tooltip content={ `Address on ${ props.chain.chain_name ? props.chain.chain_name : 'external chain' } (chain id ${ props.chain.chain_id })` }>
{ addressIcon }
</Tooltip>
) }
{ !props.chain && addressIcon }
{ href ? (
<AddressEntity.Link { ...partsProps.link } href={ href } isExternal>
<AddressEntity.Content { ...partsProps.content }/>
</AddressEntity.Link>
) : (
<AddressEntity.Content { ...partsProps.content }/>
) }
<AddressEntity.Copy { ...partsProps.copy }/>
</AddressEntity.Container>
);
};
export default chakra(AddressEntryInterop);
import { chakra } from '@chakra-ui/react'; import { chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components'; import * as EntityBase from 'ui/shared/entities/base/components';
import NftMedia from 'ui/shared/nft/NftMedia';
import { distributeEntityProps } from '../base/utils'; import { distributeEntityProps, getIconProps } from '../base/utils';
const Container = EntityBase.Container; const Container = EntityBase.Container;
const Icon = (props: EntityBase.IconBaseProps) => { type IconProps = EntityBase.IconBaseProps & {
instance?: TokenInstance | null;
};
const ICON_MEDIA_TYPES = [ 'image' as const ];
const Icon = (props: IconProps) => {
if (props.noIcon) { if (props.noIcon) {
return null; return null;
} }
if (props.instance) {
const styles = getIconProps(props.variant ?? 'heading');
const fallback = (
<EntityBase.Icon
{ ...props }
variant={ props.variant ?? 'heading' }
name={ props.name ?? 'nft_shield' }
marginRight={ 0 }
/>
);
return (
<NftMedia
data={ props.instance }
isLoading={ props.isLoading }
boxSize={ styles.boxSize }
size="sm"
allowedTypes={ ICON_MEDIA_TYPES }
borderRadius="sm"
flexShrink={ 0 }
mr={ 2 }
fallback={ fallback }
/>
);
}
return ( return (
<EntityBase.Icon <EntityBase.Icon
{ ...props } { ...props }
...@@ -53,6 +88,7 @@ const Content = chakra((props: ContentProps) => { ...@@ -53,6 +88,7 @@ const Content = chakra((props: ContentProps) => {
export interface EntityProps extends EntityBase.EntityBaseProps { export interface EntityProps extends EntityBase.EntityBaseProps {
hash: string; hash: string;
id: string; id: string;
instance?: TokenInstance | null;
} }
const NftEntity = (props: EntityProps) => { const NftEntity = (props: EntityProps) => {
......
import React from 'react';
import * as interopMock from 'mocks/interop/interop';
import { test, expect } from 'playwright/lib';
import TxEntityInterop from './TxEntityInterop';
const hash = '0x376db52955d5bce114d0ccea2dcf22289b4eae1b86bcae5a59bb5fdbfef48899';
test.use({ viewport: { width: 180, height: 30 } });
test('with chain icon', async({ render, mockAssetResponse }) => {
await mockAssetResponse('https://example.com/logo.png', './playwright/mocks/image_svg.svg');
const component = await render(
<TxEntityInterop
hash={ hash }
chain={ interopMock.chain }
/>,
);
await expect(component).toHaveScreenshot();
});
test('with chain icon stub +@dark-mode', async({ render }) => {
const component = await render(
<TxEntityInterop
hash={ hash }
chain={{ ...interopMock.chain, chain_logo: null }}
/>,
);
await expect(component).toHaveScreenshot();
});
import { Box, chakra } from '@chakra-ui/react';
import React from 'react';
import type { ChainInfo } from 'types/api/interop';
import { route } from 'nextjs-routes';
import stripTrailingSlash from 'lib/stripTrailingSlash';
import { Image } from 'toolkit/chakra/image';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { Tooltip } from 'toolkit/chakra/tooltip';
import IconSvg from 'ui/shared/IconSvg';
import { distributeEntityProps } from '../base/utils';
import * as TxEntity from './TxEntity';
type Props = {
chain: ChainInfo | null;
hash?: string | null;
} & Omit<TxEntity.EntityProps, 'hash'>;
const IconStub = ({ isLoading }: { isLoading?: boolean }) => {
return (
<Skeleton
loading={ !isLoading }
display="flex"
minWidth="20px"
h="20px"
borderRadius="full"
background={{ _light: 'blackAlpha.100', _dark: 'whiteAlpha.100' }}
alignItems="center"
justifyContent="center"
mr={ 2 }
>
<IconSvg
name="networks/icon-placeholder"
width="16px"
height="16px"
color="text.secondary"
display="block"
/>
</Skeleton>
);
};
const TxEntityInterop = ({ chain, hash, ...props }: Props) => {
const partsProps = distributeEntityProps(props);
const href = (chain?.instance_url && hash) ? stripTrailingSlash(chain.instance_url) + route({
pathname: '/tx/[hash]',
query: {
...props.query,
hash: hash,
},
}) : null;
return (
<TxEntity.Container { ...partsProps.container }>
{ chain && (
<Tooltip content={ `${ chain.chain_name ? chain.chain_name : 'External chain' } (chain id ${ chain.chain_id })` }>
<Box>
{ chain.chain_logo ? (
<Image
src={ chain.chain_logo }
alt={ chain.chain_name || 'external chain logo' }
width="20px"
height="20px"
mr={ 2 }
borderRadius="base"
/>
) : (
<IconStub isLoading={ props.isLoading }/>
) }
</Box>
</Tooltip>
) }
{ !chain && (
<IconStub/>
) }
{ hash && (
<>
{ href ? (
<TxEntity.Link { ...partsProps.link } hash={ hash } href={ href } isExternal>
<TxEntity.Content { ...partsProps.content } hash={ hash }/>
</TxEntity.Link>
) : (
<TxEntity.Content { ...partsProps.content } hash={ hash }/>
) }
<TxEntity.Copy { ...partsProps.copy } hash={ hash }/>
</>
) }
{ !hash && (
'N/A'
) }
</TxEntity.Container>
);
};
export default chakra(TxEntityInterop);
...@@ -3,29 +3,55 @@ import React from 'react'; ...@@ -3,29 +3,55 @@ import React from 'react';
import { LinkOverlay } from 'toolkit/chakra/link'; import { LinkOverlay } from 'toolkit/chakra/link';
import { mediaStyleProps } from './utils'; import type { MediaElementProps } from './utils';
interface Props { interface Props extends MediaElementProps<'a'> {}
src: string;
onLoad: () => void; const NftHtml = ({ src, transport, onLoad, onError, onClick, ...rest }: Props) => {
onError: () => void; const ref = React.useRef<HTMLIFrameElement>(null);
onClick?: () => void;
} const [ isLoaded, setIsLoaded ] = React.useState(false);
const handleLoad = React.useCallback(() => {
setIsLoaded(true);
onLoad?.();
}, [ onLoad ]);
const loadViaHttp = React.useCallback(async() => {
if (!ref.current) {
return;
}
ref.current.src = src;
ref.current.onload = handleLoad;
onError && (ref.current.onerror = onError);
}, [ src, handleLoad, onError ]);
React.useEffect(() => {
switch (transport) {
case 'ipfs': {
// Currently we don't support IPFS video loading
onError?.();
break;
}
case 'http':
loadViaHttp();
break;
}
}, [ loadViaHttp, onError, transport ]);
const NftHtml = ({ src, onLoad, onError, onClick }: Props) => {
return ( return (
<LinkOverlay <LinkOverlay
onClick={ onClick } onClick={ onClick }
h="100%" h="100%"
{ ...mediaStyleProps } { ...rest }
> >
<chakra.iframe <chakra.iframe
src={ src } ref={ ref }
h="100%" h="100%"
w="100%" w="100%"
sandbox="allow-scripts" sandbox="allow-scripts"
onLoad={ onLoad } opacity={ isLoaded ? 1 : 0 }
onError={ onError }
/> />
</LinkOverlay> </LinkOverlay>
); );
......
import { chakra } from '@chakra-ui/react';
import React from 'react';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
interface Props {
src: string;
open: boolean;
onOpenChange: ({ open }: { open: boolean }) => void;
}
const NftHtmlFullscreen = ({ src, open, onOpenChange }: Props) => {
return (
<NftMediaFullscreenModal open={ open } onOpenChange={ onOpenChange }>
<chakra.iframe
w="90vw"
h="90vh"
src={ src }
sandbox="allow-scripts"
/>
</NftMediaFullscreenModal>
);
};
export default NftHtmlFullscreen;
...@@ -2,30 +2,66 @@ import React from 'react'; ...@@ -2,30 +2,66 @@ import React from 'react';
import { Image } from 'toolkit/chakra/image'; import { Image } from 'toolkit/chakra/image';
import { mediaStyleProps } from './utils'; import useLoadImageViaIpfs from './useLoadImageViaIpfs';
import type { MediaElementProps } from './utils';
interface Props { interface Props extends MediaElementProps<'img'> {}
src: string;
srcSet?: string; const NftImage = ({ src, srcSet, onLoad, onError, transport, onClick, ...rest }: Props) => {
onLoad: () => void; const ref = React.useRef<HTMLImageElement>(null);
onError: () => void; const [ isLoaded, setIsLoaded ] = React.useState(false);
onClick?: () => void;
} const handleLoad = React.useCallback(() => {
setIsLoaded(true);
onLoad?.();
}, [ onLoad ]);
const loadImageViaIpfs = useLoadImageViaIpfs();
const loadViaHttp = React.useCallback(async() => {
if (!ref.current) {
return;
}
ref.current.src = src;
srcSet && (ref.current.srcset = srcSet);
ref.current.onload = handleLoad;
onError && (ref.current.onerror = onError);
}, [ src, srcSet, handleLoad, onError ]);
const loadViaIpfs = React.useCallback(() => {
loadImageViaIpfs(src)
.then((src) => {
if (src && ref.current) {
ref.current.src = src;
handleLoad();
}
})
.catch(onError);
}, [ handleLoad, loadImageViaIpfs, onError, src ]);
React.useEffect(() => {
switch (transport) {
case 'ipfs':
loadViaIpfs();
break;
case 'http':
loadViaHttp();
break;
}
}, [ loadViaHttp, loadViaIpfs, transport ]);
const NftImage = ({ src, srcSet, onLoad, onError, onClick }: Props) => {
return ( return (
<Image <Image
ref={ ref }
w="100%" w="100%"
h="100%" h="100%"
src={ src } opacity={ isLoaded ? 1 : 0 }
srcSet={ srcSet }
alt="Token instance image" alt="Token instance image"
onError={ onError }
onLoad={ onLoad }
onClick={ onClick } onClick={ onClick }
{ ...mediaStyleProps } { ...rest }
/> />
); );
}; };
export default NftImage; export default React.memo(NftImage);
import React from 'react';
import { Image } from 'toolkit/chakra/image';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
interface Props {
src: string;
open: boolean;
onOpenChange: ({ open }: { open: boolean }) => void;
}
const NftImageFullscreen = ({ src, open, onOpenChange }: Props) => {
const imgRef = React.useRef<HTMLImageElement>(null);
const [ hasDimensions, setHasDimensions ] = React.useState<boolean>(true);
const checkWidth = React.useCallback(() => {
if (imgRef.current?.getBoundingClientRect().width === 0) {
setHasDimensions(false);
}
}, [ ]);
return (
<NftMediaFullscreenModal open={ open } onOpenChange={ onOpenChange }>
<Image
src={ src }
alt="Token instance image"
maxH="90vh"
maxW="90vw"
ref={ imgRef }
onLoad={ checkWidth }
{ ...(hasDimensions ? {} : { width: '90vw', height: '90vh' }) }
/>
</NftMediaFullscreenModal>
);
};
export default NftImageFullscreen;
...@@ -66,7 +66,7 @@ test.describe('image', () => { ...@@ -66,7 +66,7 @@ test.describe('image', () => {
} as TokenInstance; } as TokenInstance;
await render( await render(
<Box boxSize="250px"> <Box boxSize="250px">
<NftMedia data={ data }/> <NftMedia data={ data } size="md"/>
</Box>, </Box>,
); );
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
...@@ -84,7 +84,7 @@ test.describe('image', () => { ...@@ -84,7 +84,7 @@ test.describe('image', () => {
await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg'); await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg');
await render( await render(
<Box boxSize="250px"> <Box boxSize="250px">
<NftMedia data={ data }/> <NftMedia data={ data } size="md"/>
</Box>, </Box>,
); );
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
...@@ -95,7 +95,7 @@ test.describe('image', () => { ...@@ -95,7 +95,7 @@ test.describe('image', () => {
animation_url: MEDIA_URL, animation_url: MEDIA_URL,
image_url: null, image_url: null,
} as TokenInstance; } as TokenInstance;
const component = await render(<NftMedia data={ data } w="250px"/>); const component = await render(<NftMedia data={ data } w="250px" size="md"/>);
await component.getByRole('img', { name: 'Token instance image' }).hover(); await component.getByRole('img', { name: 'Token instance image' }).hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
}); });
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment