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:
- eth_sepolia
- eth_goerli
- filecoin
- immutable
- neon_devnet
- optimism
- optimism_celestia
......
......@@ -21,6 +21,7 @@ on:
- eth_sepolia
- eth_goerli
- filecoin
- immutable
- mekong
- neon_devnet
- optimism
......
......@@ -50,5 +50,27 @@ jobs:
test:
name: Run tests
needs: deploy_e2e
uses: blockscout/blockscout-ci-cd/.github/workflows/e2e_new.yaml@master
secrets: inherit
\ No newline at end of file
runs-on: ubuntu-latest
permissions: write-all
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 @@
"celo_alfajores",
"garnet",
"gnosis",
"immutable",
"eth",
"eth_goerli",
"eth_sepolia",
......@@ -374,6 +375,7 @@
"neon_devnet",
"optimism",
"optimism_celestia",
"optimism_interop_0",
"optimism_sepolia",
"polygon",
"rari_testnet",
......
......@@ -35,6 +35,7 @@ const config: Feature<{
type: RollupType;
homepage: { showLatestBlocks: boolean };
outputRootsEnabled: boolean;
interopEnabled: boolean;
L2WithdrawalUrl: string | undefined;
parentChain: ParentChain;
DA: {
......@@ -50,6 +51,7 @@ const config: Feature<{
type,
L2WithdrawalUrl: type === 'optimistic' ? L2WithdrawalUrl : undefined,
outputRootsEnabled: type === 'optimistic' && getEnvValue('NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED') === 'true',
interopEnabled: type === 'optimistic' && getEnvValue('NEXT_PUBLIC_INTEROP_ENABLED') === 'true',
homepage: {
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
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
.string()
.when('NEXT_PUBLIC_ROLLUP_TYPE', {
......
......@@ -5,3 +5,4 @@ NEXT_PUBLIC_FAULT_PROOF_ENABLED=true
NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS=true
NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED=false
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 = {
return null;
}
break;
case '/interop-messages':
if (process.env.NEXT_PUBLIC_INTEROP_ENABLED !== 'true') {
return null;
}
break;
case '/pools':
if (process.env.NEXT_PUBLIC_DEX_POOLS_ENABLED !== 'true') {
return null;
......
......@@ -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).
**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;
......@@ -351,7 +356,7 @@ Settings for meta tags, OG tags and SEO
## 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
......@@ -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_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_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_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+ |
......
<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 {
} from 'types/api/ens';
import type { IndexingStatus } from 'types/api/indexingStatus';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { InteropMessageListResponse } from 'types/api/interop';
import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { MudWorldsResponse } from 'types/api/mudWorlds';
import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves';
......@@ -1243,6 +1244,15 @@ export const RESOURCES = {
block_countdown: {
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;
......@@ -1296,7 +1306,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward
'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'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' |
'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>;
......@@ -1473,6 +1483,8 @@ Q extends 'optimistic_l2_output_roots_count' ? number :
Q extends 'optimistic_l2_withdrawals_count' ? number :
Q extends 'optimistic_l2_deposits_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_deposits' ? ShibariumDepositsResponse :
Q extends 'shibarium_withdrawals_count' ? number :
......
......@@ -45,7 +45,7 @@ type TRewardsContext = {
openLoginModal: () => void;
closeLoginModal: () => 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>;
};
......@@ -70,7 +70,7 @@ const initialState = {
openLoginModal: () => {},
closeLoginModal: () => {},
saveApiToken: () => {},
login: async() => ({}),
login: async() => ({ isNewUser: false, reward: null }),
claim: async() => {},
};
......@@ -211,10 +211,14 @@ export function RewardsContextProvider({ children }: Props) {
apiFetch('rewards_nonce') as Promise<RewardsNonceResponse>,
refCode ?
apiFetch('rewards_check_ref_code', { pathParams: { code: refCode } }) as Promise<RewardsCheckRefCodeResponse> :
Promise.resolve({ valid: true }),
Promise.resolve({ valid: true, reward: null }),
]);
if (!checkCodeResponse.valid) {
return { invalidRefCodeError: true };
return {
invalidRefCodeError: true,
isNewUser: false,
reward: null,
};
}
const message = getMessageToSign(address, nonceResponse.nonce, checkUserQuery.data?.exists, refCode);
const signature = await signMessageAsync({ message });
......@@ -229,7 +233,10 @@ export function RewardsContextProvider({ children }: Props) {
},
}) as RewardsLoginResponse;
saveApiToken(loginResponse.token);
return { isNewUser: loginResponse.created };
return {
isNewUser: loginResponse.created,
reward: checkCodeResponse.reward,
};
} catch (_error) {
errorToast(_error);
throw _error;
......
......@@ -115,6 +115,13 @@ export default function useNavItems(): ReturnType {
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 && (
rollupFeature.type === 'optimistic' ||
rollupFeature.type === 'arbitrum' ||
......@@ -127,7 +134,8 @@ export default function useNavItems(): ReturnType {
internalTxs,
rollupDeposits,
rollupWithdrawals,
],
rollupInteropMessages,
].filter(Boolean),
[
blocks,
rollupTxnBatches,
......
......@@ -60,6 +60,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/advanced-filter': 'Root page',
'/pools': 'Root page',
'/pools/[hash]': 'Regular page',
'/interop-messages': 'Root page',
// service routes, added only to make typescript happy
'/login': 'Regular page',
......
......@@ -63,6 +63,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/advanced-filter': DEFAULT_TEMPLATE,
'/pools': DEFAULT_TEMPLATE,
'/pools/[hash]': DEFAULT_TEMPLATE,
'/interop-messages': DEFAULT_TEMPLATE,
// service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE,
......
......@@ -60,6 +60,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/advanced-filter': '%network_name% advanced filter',
'/pools': '%network_name% DEX pools',
'/pools/[hash]': '%network_name% pool details',
'/interop-messages': '%network_name% interop messages',
// service routes, added only to make typescript happy
'/login': '%network_name% login',
......
......@@ -58,6 +58,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/advanced-filter': 'Advanced filter',
'/pools': 'DEX pools',
'/pools/[hash]': 'Pool details',
'/interop-messages': 'Interop messages',
// service routes, added only to make typescript happy
'/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 { TokenTransfer, TokenTransferResponse } from 'types/api/tokenTransfer';
import * as tokenInstanceMock from './tokenInstance';
export const erc20: TokenTransfer = {
from: {
hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
......@@ -86,6 +88,7 @@ export const erc721: TokenTransfer = {
},
total: {
token_id: '875879856',
token_instance: tokenInstanceMock.base,
},
transaction_hash: '0xf13bc7afe5e02b494dd2f22078381d36a4800ef94a0ccc147431db56c301e6cc',
type: 'token_transfer',
......@@ -135,6 +138,7 @@ export const erc1155A: TokenTransfer = {
token_id: '123',
value: '42',
decimals: null,
token_instance: null,
},
transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746',
type: 'token_minting',
......@@ -151,7 +155,7 @@ export const erc1155B: TokenTransfer = {
name: 'SastanaNFT',
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 = {
......@@ -161,7 +165,7 @@ export const erc1155C: TokenTransfer = {
name: 'SastanaNFT',
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 = {
......@@ -171,7 +175,7 @@ export const erc1155D: TokenTransfer = {
name: 'SastanaNFT',
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 = {
......@@ -213,6 +217,7 @@ export const erc404A: TokenTransfer = {
value: '42000000000000000000000000',
decimals: '18',
token_id: null,
token_instance: null,
},
transaction_hash: '0x05d6589367633c032d757a69c5fb16c0e33e3994b0d9d1483f82aeee1f05d746',
type: 'token_transfer',
......@@ -230,7 +235,7 @@ export const erc404B: TokenTransfer = {
name: 'SastanaNFT',
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
},
total: { token_id: '4625304364899952' },
total: { token_id: '4625304364899952', token_instance: null },
};
export const mixTokens: TokenTransferResponse = {
......
......@@ -19,6 +19,7 @@ export const mintToken: TxStateChange = {
direction: 'from',
total: {
token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065',
token_instance: null,
},
},
],
......@@ -57,6 +58,7 @@ export const receiveMintedToken: TxStateChange = {
direction: 'to',
total: {
token_id: '15077554365819457090226168288698582604878106156134383525616269766016907608065',
token_instance: null,
},
},
],
......
......@@ -3,6 +3,7 @@ import type { Transaction } from 'types/api/transaction';
import * as addressMock from 'mocks/address/address';
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
import * as interopMock from 'mocks/interop/interop';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import * as decodedInputDataMock from 'mocks/txs/decodedInputData';
......@@ -423,3 +424,29 @@ export const withRecipientContract = {
...withRecipientEns,
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) => {
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) => {
if (!config.features.pools.isEnabled) {
return {
......
......@@ -47,6 +47,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/graphiql">
| StaticRoute<"/">
| StaticRoute<"/internal-txs">
| StaticRoute<"/interop-messages">
| StaticRoute<"/login">
| StaticRoute<"/mud-worlds">
| 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]>> = {
externalTxs: [
[ '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 @@
| "integration/full"
| "integration/partial"
| "internal_txns"
| "interop"
| "key"
| "lightning_navbar"
| "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 = {
...TOKEN_TRANSFER_ERC_20,
total: {
token_id: '35870',
token_instance: null,
},
token: TOKEN_INFO_ERC_721,
};
......@@ -120,6 +121,7 @@ export const TOKEN_TRANSFER_ERC_1155: TokenTransfer = {
token_id: '35870',
value: '123',
decimals: '18',
token_instance: null,
},
token: TOKEN_INFO_ERC_1155,
};
......@@ -130,6 +132,7 @@ export const TOKEN_TRANSFER_ERC_404: TokenTransfer = {
token_id: '35870',
value: '123',
decimals: '18',
token_instance: null,
},
token: TOKEN_INFO_ERC_404,
};
......
......@@ -32,6 +32,7 @@ export const STATE_CHANGE_TOKEN: TxStateChange = {
direction: 'to',
total: {
token_id: '1621395',
token_instance: null,
},
},
],
......
......@@ -4,56 +4,58 @@ import { scroller, Element } from 'react-scroll';
import useUpdateEffect from 'lib/hooks/useUpdateEffect';
import type { ButtonProps } from './button';
import { Button } from './button';
import type { LinkProps } from './link';
import { Link } from './link';
interface CollapsibleDetailsProps extends ButtonProps {
interface CollapsibleDetailsProps extends LinkProps {
children: React.ReactNode;
id?: string;
isExpanded?: boolean;
text?: [string, string];
noScroll?: boolean;
}
const SCROLL_CONFIG = {
duration: 500,
smooth: true,
};
const CUT_ID = 'CollapsibleDetails';
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 handleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
const handleClick = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
setIsExpanded((flag) => !flag);
scroller.scrollTo(id, {
duration: 500,
smooth: true,
});
if (!noScroll) {
scroller.scrollTo(id, SCROLL_CONFIG);
}
onClick?.(event);
}, [ id, onClick ]);
}, [ id, noScroll, onClick ]);
useUpdateEffect(() => {
setIsExpanded(isExpandedProp);
isExpandedProp && scroller.scrollTo(id, {
duration: 500,
smooth: true,
});
}, [ isExpandedProp, id ]);
isExpandedProp && !noScroll && scroller.scrollTo(id, SCROLL_CONFIG);
}, [ isExpandedProp, id, noScroll ]);
const text = isExpanded ? (textProp?.[1] ?? 'Hide details') : (textProp?.[0] ?? 'View details');
return (
<>
<Button
variant="link"
<Link
textStyle="sm"
textDecorationLine="underline"
textDecorationStyle="dashed"
w="fit-content"
onClick={ handleClick }
loadingSkeleton={ loading }
loading={ loading }
{ ...rest }
>
<Element name={ id }>{ text }</Element>
</Button>
</Link>
{ isExpanded && children }
</>
);
......@@ -62,7 +64,7 @@ export const CollapsibleDetails = (props: CollapsibleDetailsProps) => {
interface CollapsibleListProps<T> extends FlexProps {
items: Array<T>;
renderItem: (item: T, index: number) => React.ReactNode;
triggerProps?: ButtonProps;
triggerProps?: LinkProps;
cutLength?: number;
}
......@@ -81,8 +83,7 @@ export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
<Flex flexDir="column" w="100%" { ...rest }>
{ items.slice(0, isExpanded ? undefined : cutLength).map(renderItem) }
{ items.length > cutLength && (
<Button
variant="link"
<Link
textStyle="sm"
textDecorationLine="underline"
textDecorationStyle="dashed"
......@@ -92,7 +93,7 @@ export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
{ ...triggerProps }
>
{ isExpanded ? 'Hide' : 'Show all' }
</Button>
</Link>
) }
</Flex>
);
......
......@@ -11,13 +11,15 @@ const PRESETS = {
eth: 'https://eth.blockscout.com',
eth_goerli: 'https://eth-goerli.blockscout.com',
eth_sepolia: 'https://eth-sepolia.blockscout.com',
garnet: 'https://explorer.garnetchain.com',
filecoin: 'https://filecoin.blockscout.com',
garnet: 'https://explorer.garnetchain.com',
gnosis: 'https://gnosis.blockscout.com',
immutable: 'https://explorer.immutable.com',
mekong: 'https://mekong.blockscout.com',
neon_devnet: 'https://neon-devnet.blockscout.com',
optimism: 'https://optimism.blockscout.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',
polygon: 'https://polygon.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 = {
export type RewardsCheckRefCodeResponse = {
valid: boolean;
is_custom: boolean;
reward: string | null;
};
export type RewardsNonceResponse = {
......
......@@ -59,10 +59,12 @@ export interface TokenInstance {
holder_address_hash: string | null;
image_url: string | null;
animation_url: string | null;
media_url?: string | null;
media_type?: string | null;
external_app_url: string | null;
metadata: Record<string, unknown> | null;
owner: AddressParam | null;
thumbnails: Partial<Record<ThumbnailSize, string>> | null;
thumbnails: ({ original: string } & Partial<Record<Exclude<ThumbnailSize, 'original'>, string>>) | null;
}
export interface TokenInstanceMetadataSocketMessage {
......
import type { AddressParam } from './addressParams';
import type { TokenInfo, TokenType } from './token';
import type { TokenInfo, TokenInstance, TokenType } from './token';
export type Erc20TotalPayload = {
decimals: string | null;
......@@ -8,20 +8,24 @@ export type Erc20TotalPayload = {
export type Erc721TotalPayload = {
token_id: string | null;
token_instance: TokenInstance | null;
};
export type Erc1155TotalPayload = {
decimals: string | null;
value: string;
token_id: string | null;
token_instance: TokenInstance | null;
};
export type Erc404TotalPayload = {
decimals: string;
value: string;
token_id: null;
token_instance: TokenInstance | null;
} | {
token_id: string;
token_instance: TokenInstance | null;
};
export type TokenTransfer = (
......
......@@ -3,6 +3,7 @@ import type { ArbitrumBatchStatus, ArbitrumL2TxData } from './arbitrumL2';
import type { BlockTransactionsResponse } from './block';
import type { DecodedInput } from './decodedInput';
import type { Fee } from './fee';
import type { ChainInfo, MessageStatus } from './interop';
import type { NovesTxTranslation } from './noves';
import type { OptimisticL2WithdrawalStatus } from './optimisticL2';
import type { ScrollL2BlockStatus } from './scrollL2';
......@@ -107,6 +108,8 @@ export type Transaction = {
scroll?: ScrollTransactionData;
// EIP-7702
authorization_list?: Array<TxAuthorization>;
// Interop
op_interop?: InteropTransactionInfo;
};
type ArbitrumTransactionData = {
......@@ -215,3 +218,15 @@ export interface TxAuthorization {
chain_id: 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
<NftMedia
mb="18px"
data={ tokenInstance }
size="md"
isLoading={ isLoading }
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';
import { useRewardsContext } from 'lib/contexts/rewards';
import { apos } from 'lib/html-entities';
import { Alert } from 'toolkit/chakra/alert';
import { Image } from 'toolkit/chakra/image';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import DailyRewardClaimButton from 'ui/rewards/dashboard/DailyRewardClaimButton';
import RewardsDashboardCard from 'ui/rewards/dashboard/RewardsDashboardCard';
import RewardsDashboardCardValue from 'ui/rewards/dashboard/RewardsDashboardCardValue';
import RewardsDashboardInfoCard from 'ui/rewards/dashboard/RewardsDashboardInfoCard';
import RewardsReadOnlyInputWithCopy from 'ui/rewards/RewardsReadOnlyInputWithCopy';
import AdBanner from 'ui/shared/ad/AdBanner';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -53,7 +53,7 @@ const RewardsDashboard = () => {
<span>
The Blockscout Merits Program is just getting started! Learn more about the details,
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
</Link>.
</span>
......@@ -69,12 +69,6 @@ const RewardsDashboard = () => {
description="Claim your daily Merits and any Merits received from referrals."
direction="column-reverse"
contentAfter={ <DailyRewardClaimButton/> }
>
<RewardsDashboardCardValue
label="Total balance"
value={ balancesQuery.data?.total || 'N/A' }
isLoading={ balancesQuery.isPending }
withIcon
hint={ (
<>
Total number of Merits earned from all activities.{ ' ' }
......@@ -83,6 +77,11 @@ const RewardsDashboard = () => {
</Link>
</>
) }
>
<RewardsDashboardCardValue
value={ balancesQuery.data?.total || 'N/A' }
isLoading={ balancesQuery.isPending }
withIcon
/>
</RewardsDashboardCard>
<RewardsDashboardCard
......@@ -91,7 +90,6 @@ const RewardsDashboard = () => {
direction="column-reverse"
>
<RewardsDashboardCardValue
label="Referrals"
value={ referralsQuery.data?.referrals ?
`${ referralsQuery.data?.referrals } user${ Number(referralsQuery.data?.referrals) === 1 ? '' : 's' }` :
'N/A'
......@@ -110,29 +108,26 @@ const RewardsDashboard = () => {
</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"
>
<RewardsDashboardCardValue
label="Streak"
value={
dailyRewardQuery.data?.streak ?
`${ dailyRewardQuery.data?.streak } day${ Number(dailyRewardQuery.data?.streak) === 1 ? '' : 's' }` :
'N/A'
}
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>
</Flex>
<Flex gap={ 6 } flexDirection={{ base: 'column', md: 'row' }}>
<Flex w="full" gap={ 6 } flexDirection={{ base: 'column', md: 'row' }}>
<RewardsDashboardCard
title="Referral program"
description={ (
......@@ -169,46 +164,38 @@ const RewardsDashboard = () => {
/>
</Flex>
</RewardsDashboardCard>
<RewardsDashboardCard
<RewardsDashboardInfoCard
title="Badges"
description={ (
<Flex flexDir="column" gap={ 2 }>
<span>
Collect limited and legendary badges by completing different Blockscout related tasks.
Go to the badges website to see what{ apos }s available and start your collection today.
</span>
</Flex>
) }
>
<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"/> }
description={ `Collect limited and legendary badges by completing different Blockscout related tasks.
Go to the badges website to see what${ apos }s available and start your collection today.` }
imageSrc="/static/merits/badges.svg"
imageWidth="260px"
imageHeight="86px"
linkText="View badges"
linkHref={ `https://merits.blockscout.com/?tab=badges&utm_source=${ config.chain.id }&utm_medium=badges` }
/>
<Link
external
href="https://merits.blockscout.com/?tab=badges&utm_source=blockscout&utm_medium=dashboard"
textStyle="md"
fontWeight="500"
>
View badges
</Link>
</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 gap={ 6 } flexDirection={{ base: 'column', md: 'row' }}>
<Flex w="full" gap={ 6 } flexDirection={{ base: 'column', md: 'row' }}>
<RewardsDashboardCard
title="Activity"
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 = () => {
{
id: '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 ?
{
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import { mixTokens } from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib';
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 mockApiResponse('token_transfers_all', mixTokens, { queryParams: { type: [] } });
const component = await render(<Box pt={{ base: '106px', lg: 0 }}> <TokenTransfers/> </Box>);
......
......@@ -2,6 +2,7 @@ import { useRouter } from 'next/router';
import React from 'react';
import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import type { EntityTag as TEntityTag } from 'ui/shared/EntityTags/types';
import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app';
......@@ -31,6 +32,7 @@ import TxUserOps from 'ui/tx/TxUserOps';
import useTxQuery from 'ui/tx/useTxQuery';
const txInterpretation = config.features.txInterpretation;
const rollupFeature = config.features.rollup;
const TransactionPageContent = () => {
const router = useRouter();
......@@ -78,10 +80,21 @@ const TransactionPageContent = () => {
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 = (
<EntityTags
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';
import React from 'react';
import { Badge } from 'toolkit/chakra/badge';
import Hint from 'ui/shared/Hint';
type Props = {
title?: string;
title: string;
description: string | React.ReactNode;
hint?: string | React.ReactNode;
availableSoon?: boolean;
blurFilter?: boolean;
contentAfter?: React.ReactNode;
......@@ -15,7 +17,7 @@ type Props = {
};
const RewardsDashboardCard = ({
title, description, availableSoon, contentAfter,
title, description, hint, availableSoon, contentAfter,
direction = 'column', children, blurFilter,
}: Props) => {
return (
......@@ -36,12 +38,11 @@ const RewardsDashboardCard = ({
p={{ base: 1.5, md: 3 }}
w={{ base: 'full', md: direction === 'row' ? '340px' : 'full' }}
>
{ title && (
<Flex alignItems="center" gap={ 2 }>
<Text fontSize={{ base: 'md', md: 'lg' }} fontWeight="500">{ title }</Text>
{ hint && <Hint label={ hint }/> }
{ availableSoon && <Badge colorPalette="blue">Available soon</Badge> }
</Flex>
) }
<Text as="div" fontSize="sm">
{ description }
</Text>
......
......@@ -7,7 +7,7 @@ import Hint from 'ui/shared/Hint';
import MeritsIcon from '../MeritsIcon';
type Props = {
label: string;
label?: string;
value: number | string | undefined;
withIcon?: boolean;
hint?: string | React.ReactNode;
......@@ -16,6 +16,7 @@ type Props = {
const RewardsDashboardCard = ({ label, value, withIcon, hint, isLoading }: Props) => (
<Flex key={ label } flexDirection="column" alignItems="center" gap={ 2 }>
{ label && (
<Flex alignItems="center" gap={ 1 }>
{ hint && (
<Hint label={ hint }/>
......@@ -24,6 +25,7 @@ const RewardsDashboardCard = ({ label, value, withIcon, hint, isLoading }: Props
{ label }
</Text>
</Flex>
) }
<Skeleton
loading={ isLoading }
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 = () => {
const [ isLoginStep, setIsLoginStep ] = React.useState(true);
const [ isReferral, setIsReferral ] = React.useState(false);
const [ customReferralReward, setCustomReferralReward ] = React.useState<string | null>(null);
const [ authModalInitialScreen, setAuthModalInitialScreen ] = React.useState<Screen>();
const authModal = useDisclosure();
......@@ -33,13 +34,13 @@ const RewardsLoginModal = () => {
if (!isLoginModalOpen) {
setIsLoginStep(true);
setIsReferral(false);
setCustomReferralReward(null);
}
}, [ isLoginModalOpen, setIsLoginStep, setIsReferral ]);
}, [ isLoginModalOpen ]);
const goNext = useCallback((isReferral: boolean) => {
if (isReferral) {
setIsReferral(true);
}
const goNext = useCallback((isReferral: boolean, reward: string | null) => {
setIsReferral(isReferral);
setCustomReferralReward(reward);
setIsLoginStep(false);
}, [ setIsLoginStep, setIsReferral ]);
......@@ -78,7 +79,7 @@ const RewardsLoginModal = () => {
<DialogBody>
{ isLoginStep ?
<LoginStepContent goNext={ goNext } openAuthModal={ handleAuthModalOpen } closeModal={ closeLoginModal }/> :
<CongratsStepContent isReferral={ isReferral }/>
<CongratsStepContent isReferral={ isReferral } customReferralReward={ customReferralReward }/>
}
</DialogBody>
</DialogContent>
......
......@@ -13,14 +13,17 @@ import RewardsReadOnlyInputWithCopy from '../../RewardsReadOnlyInputWithCopy';
type Props = {
isReferral: boolean;
customReferralReward: string | null;
};
const CongratsStepContent = ({ isReferral }: Props) => {
const CongratsStepContent = ({ isReferral, customReferralReward }: Props) => {
const { referralsQuery, rewardsConfigQuery } = useRewardsContext();
const registrationReward = rewardsConfigQuery.data?.rewards.registration;
const registrationWithReferralReward = rewardsConfigQuery.data?.rewards.registration_with_referral;
const referralReward = Number(registrationWithReferralReward) - Number(registrationReward);
const registrationReward = Number(rewardsConfigQuery.data?.rewards.registration);
const registrationWithReferralReward = customReferralReward ?
Number(customReferralReward) + registrationReward :
Number(rewardsConfigQuery.data?.rewards.registration_with_referral);
const referralReward = registrationWithReferralReward - registrationReward;
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
......@@ -41,7 +44,7 @@ const CongratsStepContent = ({ isReferral }: Props) => {
<MeritsIcon boxSize={{ base: isReferral ? 8 : 12, md: 12 }} mr={{ base: isReferral ? 1 : 2, md: 2 }}/>
<Skeleton loading={ rewardsConfigQuery.isLoading }>
<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>
</Skeleton>
{ isReferral && (
......
......@@ -18,7 +18,7 @@ import { Switch } from 'toolkit/chakra/switch';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
type Props = {
goNext: (isReferral: boolean) => void;
goNext: (isReferral: boolean, reward: string | null) => void;
closeModal: () => void;
openAuthModal: (isAuth: boolean, trySharedLogin?: boolean) => void;
};
......@@ -58,12 +58,12 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
try {
setRefCodeError(false);
setIsLoading(true);
const { isNewUser, invalidRefCodeError } = await login(isSignUp && isRefCodeUsed ? refCode : '');
const { isNewUser, reward, invalidRefCodeError } = await login(isSignUp && isRefCodeUsed ? refCode : '');
if (invalidRefCodeError) {
setRefCodeError(true);
} else {
if (isNewUser) {
goNext(isRefCodeUsed);
goNext(isRefCodeUsed, reward);
} else {
closeModal();
router.push({ pathname: '/account/merits' }, undefined, { shallow: true });
......@@ -71,14 +71,11 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
}
} catch (error) {}
setIsLoading(false);
}, [ login, goNext, setIsLoading, router, closeModal, refCode, setRefCodeError, isRefCodeUsed, isSignUp ]);
}, [ login, goNext, router, closeModal, refCode, isRefCodeUsed, isSignUp ]);
React.useEffect(() => {
if (isSignUp && isRefCodeUsed && refCode.length > 0 && refCode.length !== 6) {
setRefCodeError(true);
} else {
setRefCodeError(false);
}
const isInvalid = isSignUp && isRefCodeUsed && refCode.length > 0 && refCode.length !== 6 && refCode.length !== 12;
setRefCodeError(isInvalid);
}, [ refCode, isRefCodeUsed, isSignUp ]);
const handleButtonClick = React.useCallback(() => {
......@@ -118,7 +115,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
return (
<>
<Image
src="/static/merits_program.png"
src="/static/merits/merits_program.png"
alt="Merits program"
mb={ 3 }
fallback={ <Skeleton loading w="full" h="120px" mb={ 3 }/> }
......@@ -150,7 +147,7 @@ const LoginStepContent = ({ goNext, closeModal, openAuthModal }: Props) => {
mt={ 3 }
invalid={ refCodeError }
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
value={ refCode }
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect, devices } from 'playwright/lib';
......@@ -23,7 +24,8 @@ const data = [
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(
<Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferList
......@@ -36,7 +38,8 @@ test('without tx info', async({ render }) => {
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(
<Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferList
......
......@@ -68,7 +68,7 @@ const TokenTransferListItem = ({
) }
</Flex>
{ 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 && (
<Flex justifyContent="space-between" alignItems="center" lineHeight="24px" width="100%">
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { test, expect } from 'playwright/lib';
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(
<Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferTable
......@@ -20,7 +22,8 @@ test('without tx info', async({ render }) => {
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(
<Box pt={{ base: '134px', lg: 6 }}>
<TokenTransferTable
......
......@@ -76,6 +76,7 @@ const TokenTransferTableItem = ({
<NftEntity
hash={ token.address }
id={ total.token_id }
instance={ total.token_instance }
isLoading={ isLoading }
/>
) }
......
......@@ -44,6 +44,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props)
<TokenTransferSnippetNft
token={ data.token }
tokenId={ total.token_id }
instance={ total.token_instance }
value="1"
/>
);
......@@ -56,6 +57,7 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props)
key={ total.token_id }
token={ data.token }
tokenId={ total.token_id }
instance={ total.token_instance }
value={ total.value }
/>
);
......
import { chakra } from '@chakra-ui/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 TokenEntity from 'ui/shared/entities/token/TokenEntity';
......@@ -10,9 +10,10 @@ interface Props {
token: TokenInfo;
value: string;
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 tokenIdContent = (() => {
......@@ -28,6 +29,7 @@ const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => {
<NftEntity
hash={ token.address }
id={ tokenId }
instance={ instance }
fontWeight={ 600 }
variant="content"
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 React from 'react';
import type { TokenInstance } from 'types/api/token';
import { route } from 'nextjs-routes';
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 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) {
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 (
<EntityBase.Icon
{ ...props }
......@@ -53,6 +88,7 @@ const Content = chakra((props: ContentProps) => {
export interface EntityProps extends EntityBase.EntityBaseProps {
hash: string;
id: string;
instance?: TokenInstance | null;
}
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';
import { LinkOverlay } from 'toolkit/chakra/link';
import { mediaStyleProps } from './utils';
import type { MediaElementProps } from './utils';
interface Props {
src: string;
onLoad: () => void;
onError: () => void;
onClick?: () => void;
}
interface Props extends MediaElementProps<'a'> {}
const NftHtml = ({ src, transport, onLoad, onError, onClick, ...rest }: Props) => {
const ref = React.useRef<HTMLIFrameElement>(null);
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 (
<LinkOverlay
onClick={ onClick }
h="100%"
{ ...mediaStyleProps }
{ ...rest }
>
<chakra.iframe
src={ src }
ref={ ref }
h="100%"
w="100%"
sandbox="allow-scripts"
onLoad={ onLoad }
onError={ onError }
opacity={ isLoaded ? 1 : 0 }
/>
</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';
import { Image } from 'toolkit/chakra/image';
import { mediaStyleProps } from './utils';
import useLoadImageViaIpfs from './useLoadImageViaIpfs';
import type { MediaElementProps } from './utils';
interface Props {
src: string;
srcSet?: string;
onLoad: () => void;
onError: () => void;
onClick?: () => void;
}
interface Props extends MediaElementProps<'img'> {}
const NftImage = ({ src, srcSet, onLoad, onError, transport, onClick, ...rest }: Props) => {
const ref = React.useRef<HTMLImageElement>(null);
const [ isLoaded, setIsLoaded ] = React.useState(false);
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 (
<Image
ref={ ref }
w="100%"
h="100%"
src={ src }
srcSet={ srcSet }
opacity={ isLoaded ? 1 : 0 }
alt="Token instance image"
onError={ onError }
onLoad={ onLoad }
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', () => {
} as TokenInstance;
await render(
<Box boxSize="250px">
<NftMedia data={ data }/>
<NftMedia data={ data } size="md"/>
</Box>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
......@@ -84,7 +84,7 @@ test.describe('image', () => {
await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg');
await render(
<Box boxSize="250px">
<NftMedia data={ data }/>
<NftMedia data={ data } size="md"/>
</Box>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
......@@ -95,7 +95,7 @@ test.describe('image', () => {
animation_url: MEDIA_URL,
image_url: null,
} 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 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