Commit 52f5f6c9 authored by Max Alekseenko's avatar Max Alekseenko

Merge branch 'main' into marketplace-improvements

parents c0429dbe 47425b88
...@@ -2,6 +2,8 @@ const RESTRICTED_MODULES = { ...@@ -2,6 +2,8 @@ const RESTRICTED_MODULES = {
paths: [ paths: [
{ name: 'dayjs', message: 'Please use lib/date/dayjs.ts instead of directly importing dayjs' }, { name: 'dayjs', message: 'Please use lib/date/dayjs.ts instead of directly importing dayjs' },
{ name: '@chakra-ui/icons', message: 'Using @chakra-ui/icons is prohibited. Please use regular svg-icon instead (see examples in "icons/" folder)' }, { name: '@chakra-ui/icons', message: 'Using @chakra-ui/icons is prohibited. Please use regular svg-icon instead (see examples in "icons/" folder)' },
{ name: '@metamask/providers', message: 'Please lazy-load @metamask/providers or use useProvider hook instead' },
{ name: '@metamask/post-message-stream', message: 'Please lazy-load @metamask/post-message-stream or use useProvider hook instead' },
], ],
}; };
...@@ -307,7 +309,15 @@ module.exports = { ...@@ -307,7 +309,15 @@ module.exports = {
}, },
}, },
{ {
files: [ '*.config.ts', '*.config.js', 'playwright/**', 'deploy/tools/**', 'middleware.ts', 'nextjs/**' ], files: [
'*.config.ts',
'*.config.js',
'playwright/**',
'deploy/tools/**',
'middleware.ts',
'nextjs/**',
'instrumentation*.ts',
],
rules: { rules: {
// for configs allow to consume env variables from process.env directly // for configs allow to consume env variables from process.env directly
'no-restricted-properties': [ 0 ], 'no-restricted-properties': [ 0 ],
......
...@@ -40,10 +40,9 @@ jobs: ...@@ -40,10 +40,9 @@ jobs:
run: yarn build run: yarn build
env: env:
NODE_ENV: production NODE_ENV: production
GENERATE_SOURCEMAPS: true
- name: Inject Sentry debug ID - name: Inject Sentry debug ID
run: yarn sentry-cli sourcemaps inject ./.next run: yarn sentry-cli sourcemaps inject ./.next
- name: Upload source maps to Sentry - name: Upload source maps to Sentry
run: yarn sentry-cli sourcemaps upload --release=${{ github.ref_name }} --validate ./.next run: yarn sentry-cli sourcemaps upload --release=${{ github.ref_name }} --url-prefix=~/_next/ --validate ./.next
\ No newline at end of file \ No newline at end of file
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
/out/ /out/
/public/assets/ /public/assets/
/public/envs.js /public/envs.js
/analyze
# production # production
/build /build
......
...@@ -27,6 +27,7 @@ const hiddenViews = (() => { ...@@ -27,6 +27,7 @@ const hiddenViews = (() => {
const config = Object.freeze({ const config = Object.freeze({
identiconType, identiconType,
hiddenViews, hiddenViews,
solidityscanEnabled: getEnvValue('NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED') === 'true',
}); });
export default config; export default config;
...@@ -47,6 +47,7 @@ NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com ...@@ -47,6 +47,7 @@ NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask'] NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true'
#meta #meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth-goerli.png?raw=true NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth-goerli.png?raw=true
...@@ -392,6 +392,7 @@ const schema = yup ...@@ -392,6 +392,7 @@ const schema = yup
.transform(replaceQuotes) .transform(replaceQuotes)
.json() .json()
.of(yup.string<AddressViewId>().oneOf(ADDRESS_VIEWS_IDS)), .of(yup.string<AddressViewId>().oneOf(ADDRESS_VIEWS_IDS)),
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED: yup.boolean(),
NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS: yup NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS: yup
.array() .array()
.transform(replaceQuotes) .transform(replaceQuotes)
......
...@@ -51,8 +51,8 @@ frontend: ...@@ -51,8 +51,8 @@ frontend:
NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg
NEXT_PUBLIC_API_HOST: blockscout-main.k8s-dev.blockscout.com NEXT_PUBLIC_API_HOST: eth-goerli.blockscout.com
NEXT_PUBLIC_STATS_API_HOST: https://stats-test.k8s-dev.blockscout.com/ NEXT_PUBLIC_STATS_API_HOST: https://stats-goerli.k8s-dev.blockscout.com/
NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/ NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com
...@@ -67,10 +67,14 @@ frontend: ...@@ -67,10 +67,14 @@ frontend:
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']" NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE: gradient_avatar NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE: gradient_avatar
NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS: "['top_accounts']" NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS: "['top_accounts']"
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED: true
NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS: "['value','fee_currency','gas_price','gas_fees','burnt_fees']" NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS: "['value','fee_currency','gas_price','gas_fees','burnt_fees']"
NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']" NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']"
NEXT_PUBLIC_USE_NEXT_JS_PROXY: true NEXT_PUBLIC_USE_NEXT_JS_PROXY: true
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]" NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
OTEL_SDK_ENABLED: true
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger-collector.jaeger.svc.cluster.local:4318
NEXT_OTEL_VERBOSE: 1
envFromSecret: envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
......
...@@ -44,15 +44,21 @@ And of course our premier language is [Typescript](https://www.typescriptlang.or ...@@ -44,15 +44,21 @@ And of course our premier language is [Typescript](https://www.typescriptlang.or
## Local development ## Local development
1. Prepare your environment variables: To develop locally, follow one of the two paths outlined below:
- clone `.env.example` into `configs/envs/.env.secrets` and fill it with necessary secrets for the [external services](./ENVS.md#external-services-configuration) integration; you can pick up only those that your needed
- choose one of the following options: A. Custom configuration:
A. create `.env.local` file in the root folder with environment variables from the [list](./ENVS.md); all required variables should be present in the file;
B. pick up one of the predefined configurations located at `/configs/envs` folder; no actual action is needed at this stage; 1. Create `.env.local` file in the root folder and include all required environment variables from the [list](./ENVS.md)
2. Run your local dev server: 2. Optionally, clone `.env.example` and name it `.env.secrets`. Fill it with necessary secrets for integrating with [external services](./ENVS.md#external-services-configuration). Include only secrets your need.
- if you picked up option "A" above, use `yarn dev` command 3. Use `yarn dev` command to start the dev server.
- if your options is "B", use `yarn dev:<config_name>` command 4. Open your browser and navigate to the URL provided in the command line output (by default, it is `http://localhost:3000`).
3. In browser navigate to the URL from the command output (by default, it is `http://localhost:3000`)
B. Pre-defined configuration:
1. Optionally, clone `.env.example` file into `configs/envs/.env.secrets`. Fill it with necessary secrets for integrating with [external services](./ENVS.md#external-services-configuration). Include only secrets your need.
2. Choose one of the predefined configurations located in the `/configs/envs` folder.
3. Start your local dev server using the `yarn dev:<config_name>` command.
4. Open your browser and navigate to the URL provided in the command line output (by default, it is `http://localhost:3000`).
&nbsp; &nbsp;
......
...@@ -49,6 +49,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will ...@@ -49,6 +49,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Safe{Core} address tags](ENVS.md#safecore-address-tags) - [Safe{Core} address tags](ENVS.md#safecore-address-tags)
- [SUAVE chain](ENVS.md#suave-chain) - [SUAVE chain](ENVS.md#suave-chain)
- [Sentry error monitoring](ENVS.md#sentry-error-monitoring) - [Sentry error monitoring](ENVS.md#sentry-error-monitoring)
- [OpenTelemetry](ENVS.md#opentelemetry)
- [3rd party services configuration](ENVS.md#external-services-configuration) - [3rd party services configuration](ENVS.md#external-services-configuration)
&nbsp; &nbsp;
...@@ -196,6 +197,7 @@ Settings for meta tags and OG tags ...@@ -196,6 +197,7 @@ Settings for meta tags and OG tags
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE | `"github" \| "jazzicon" \| "gradient_avatar" \| "blockie"` | Style of address identicon appearance. Choose between [GitHub](https://github.blog/2013-08-14-identicons/), [Metamask Jazzicon](https://metamask.github.io/jazzicon/), [Gradient Avatar](https://github.com/varld/gradient-avatar) and [Ethereum Blocky](https://mycryptohq.github.io/ethereum-blockies-base64/) | - | `jazzicon` | `gradient_avatar` | | NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE | `"github" \| "jazzicon" \| "gradient_avatar" \| "blockie"` | Style of address identicon appearance. Choose between [GitHub](https://github.blog/2013-08-14-identicons/), [Metamask Jazzicon](https://metamask.github.io/jazzicon/), [Gradient Avatar](https://github.com/varld/gradient-avatar) and [Ethereum Blocky](https://mycryptohq.github.io/ethereum-blockies-base64/) | - | `jazzicon` | `gradient_avatar` |
| NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS | `Array<AddressViewId>` | Address views that should not be displayed. See below the list of the possible id values. | - | - | `'["top_accounts"]'` | | NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS | `Array<AddressViewId>` | Address views that should not be displayed. See below the list of the possible id values. | - | - | `'["top_accounts"]'` |
| NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED | `boolean` | Set to `true` if SolidityScan reports are supperted | - | - | `true` |
##### Address views list ##### Address views list
| Id | Description | | Id | Description |
...@@ -536,6 +538,16 @@ For blockchains that implementing SUAVE architecture additional fields will be s ...@@ -536,6 +538,16 @@ For blockchains that implementing SUAVE architecture additional fields will be s
&nbsp; &nbsp;
### OpenTelemetry
OpenTelemetry SDK for Node.js app could be enabled by passing `OTEL_SDK_ENABLED=true` variable. Configure the OpenTelemetry Protocol Exporter by using the generic environment variables described in the [OT docs](https://opentelemetry.io/docs/specs/otel/protocol/exporter/#configuration-options).
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| OTEL_SDK_ENABLED | `boolean` | Flag to enable the feature | Required | `false` | `true` |
&nbsp;
## External services configuration ## External services configuration
### Google ReCaptcha ### Google ReCaptcha
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="currentColor" fill-opacity=".8" fill-rule="evenodd" d="M9.767 2.074a.7.7 0 0 1 .626 0l7.38 3.69a.7.7 0 0 1 0 1.252l-7.38 3.69a.7.7 0 0 1-.626 0l-7.38-3.69a.7.7 0 0 1 0-1.252l7.38-3.69ZM4.266 6.39l5.814 2.907 5.815-2.907-5.815-2.907L4.266 6.39Zm-2.192 7.067a.7.7 0 0 1 .94-.313l7.066 3.534 7.067-3.534a.7.7 0 0 1 .627 1.252l-7.38 3.69a.7.7 0 0 1-.627 0l-7.38-3.69a.7.7 0 0 1-.313-.939Zm.94-4.003a.7.7 0 0 0-.627 1.252l7.38 3.69a.7.7 0 0 0 .626 0l7.38-3.69a.7.7 0 1 0-.626-1.252l-7.067 3.534-7.067-3.534Z" clip-rule="evenodd"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3.6v4.812c0 6.447 5.414 8.703 6.79 9.17 1.377-.467 6.791-2.723 6.791-9.17V3.6H3ZM1.506 2.091A1.714 1.714 0 0 1 2.708 1.6h14.165c.448 0 .88.175 1.202.491.322.317.506.75.506 1.206v5.115c0 8.015-6.912 10.66-8.246 11.097a1.647 1.647 0 0 1-1.089 0C7.912 19.072 1 16.427 1 8.412V3.297c0-.455.184-.889.506-1.206Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.9 5.4a1.1 1.1 0 0 0-1.1 1.1v3.8a1.1 1.1 0 0 0 2.2 0V6.5a1.1 1.1 0 0 0-1.1-1.1Zm1.1 8.343a1.1 1.1 0 1 1-2.2 0 1.1 1.1 0 0 1 2.2 0Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 8.412V3.6h13.581v4.812c0 6.447-5.414 8.703-6.79 9.17C8.414 17.115 3 14.86 3 8.412ZM2.708 1.6c-.448 0-.88.175-1.202.491-.322.317-.506.75-.506 1.206v5.115c0 8.015 6.912 10.66 8.246 11.097.352.123.737.123 1.089 0 1.334-.437 8.246-3.082 8.246-11.097V3.297c0-.455-.184-.889-.506-1.206a1.714 1.714 0 0 0-1.202-.491H2.708ZM14.37 8.208a1 1 0 1 0-1.369-1.458L8.49 10.986 6.58 9.191a1 1 0 1 0-1.37 1.457l2.594 2.44a1 1 0 0 0 1.37 0l5.196-4.88Z" fill="currentColor"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 15"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<mask id="discord_svg__a" width="20" height="15" x="0" y="0" fill="#000" maskUnits="userSpaceOnUse"> <path stroke="currentColor" stroke-width=".8" d="M20.133 10.988a22.045 22.045 0 0 0-1.62-4.36.295.295 0 0 0-.1-.108 12.246 12.246 0 0 0-2.142-1.008C14.891 5.01 14.238 5 14.211 5a.281.281 0 0 0-.281.242l-.084.586a18.721 18.721 0 0 0-3.696 0l-.084-.586A.282.282 0 0 0 9.785 5c-.027 0-.68 0-2.06.508-.743.267-1.459.607-2.136 1.012a.295.295 0 0 0-.1.108 22.039 22.039 0 0 0-1.62 4.36C3.142 13.924 3 15.393 3 15.454a.281.281 0 0 0 .069.21 7.763 7.763 0 0 0 2.56 1.792c.888.378 1.824.634 2.782.762a.281.281 0 0 0 .27-.14l.76-1.301a14.77 14.77 0 0 0 2.559.21 14.83 14.83 0 0 0 2.559-.213l.76 1.303a.28.28 0 0 0 .241.141h.028a10.73 10.73 0 0 0 2.782-.756 7.76 7.76 0 0 0 2.559-1.792.28.28 0 0 0 .069-.21c.001-.067-.132-1.536-.866-4.473Zm-1.977 5.95c-.785.325-1.606.557-2.446.69l-.574-.983c.659-.175 2.028-.628 3.07-1.575a.283.283 0 0 0-.378-.422c-1.245 1.138-3.097 1.512-3.176 1.528h-.01c-.87.169-1.755.252-2.641.248a13.58 13.58 0 0 1-2.641-.24h-.01c-.02 0-1.905-.37-3.178-1.528a.283.283 0 1 0-.378.422c1.042.948 2.412 1.406 3.07 1.575l-.574.984a10.7 10.7 0 0 1-2.445-.692 7.345 7.345 0 0 1-2.271-1.567c.046-.41.236-1.838.844-4.253a22.03 22.03 0 0 1 1.535-4.168 12.397 12.397 0 0 1 1.969-.916 8.627 8.627 0 0 1 1.628-.449l.05.336c-.563.14-1.948.543-3.045 1.34a.281.281 0 0 0 .332.454c1.23-.894 2.925-1.268 3.07-1.3h.004A16.65 16.65 0 0 1 12 6.304c.682-.003 1.363.036 2.04.118.128.026 1.828.398 3.074 1.3a.281.281 0 1 0 .332-.454c-1.097-.797-2.476-1.198-3.044-1.34l.049-.336a8.68 8.68 0 0 1 1.627.449c.681.247 1.34.554 1.968.916a21.975 21.975 0 0 1 1.547 4.168c.603 2.415.793 3.843.844 4.253a7.345 7.345 0 0 1-2.282 1.56ZM9.36 10.652c-.866 0-1.57.787-1.57 1.755 0 .967.703 1.753 1.57 1.753.868 0 1.57-.786 1.57-1.753 0-.968-.705-1.755-1.57-1.755Zm0 2.953c-.562 0-1.008-.534-1.008-1.191s.451-1.193 1.008-1.193c.557 0 1.007.535 1.007 1.193s-.451 1.184-1.007 1.184v.007Zm3.71-1.198c0-.968.704-1.755 1.57-1.755.865 0 1.57.787 1.57 1.755 0 .967-.705 1.753-1.57 1.753-.867 0-1.57-.786-1.57-1.753Zm.562.007c0 .657.445 1.19 1.007 1.19V13.6c.557 0 1.008-.526 1.008-1.184 0-.658-.452-1.193-1.008-1.193-.555 0-1.007.536-1.007 1.193Z" clip-rule="evenodd"/>
<path fill="#fff" d="M0 0h20v15H0z"/>
<path fill-rule="evenodd" d="M18.133 6.988a22.044 22.044 0 0 0-1.62-4.36.295.295 0 0 0-.1-.108 12.246 12.246 0 0 0-2.142-1.008C12.891 1.01 12.238 1 12.211 1a.281.281 0 0 0-.281.242l-.084.586a18.723 18.723 0 0 0-3.696 0l-.084-.586A.281.281 0 0 0 7.785 1c-.027 0-.68 0-2.06.508-.743.267-1.459.607-2.136 1.012a.295.295 0 0 0-.1.108 22.038 22.038 0 0 0-1.62 4.36C1.142 9.924 1 11.393 1 11.454a.281.281 0 0 0 .069.21 7.763 7.763 0 0 0 2.56 1.792c.888.378 1.824.635 2.782.762a.281.281 0 0 0 .27-.14l.76-1.301a14.77 14.77 0 0 0 2.559.21 14.83 14.83 0 0 0 2.559-.213l.76 1.304a.28.28 0 0 0 .241.14h.028a10.73 10.73 0 0 0 2.782-.756 7.76 7.76 0 0 0 2.559-1.792.28.28 0 0 0 .069-.21c.001-.067-.132-1.536-.866-4.472Zm-1.977 5.95c-.785.325-1.606.557-2.446.69l-.574-.983c.659-.175 2.028-.627 3.07-1.575a.283.283 0 0 0-.378-.422c-1.245 1.138-3.097 1.512-3.176 1.528h-.01c-.87.169-1.755.252-2.641.248a13.58 13.58 0 0 1-2.641-.24h-.01c-.02 0-1.905-.37-3.178-1.528a.283.283 0 1 0-.378.422c1.042.948 2.412 1.406 3.07 1.575l-.574.984a10.7 10.7 0 0 1-2.445-.692 7.345 7.345 0 0 1-2.271-1.567c.046-.41.236-1.838.844-4.253a22.03 22.03 0 0 1 1.535-4.168 12.397 12.397 0 0 1 1.969-.916 8.627 8.627 0 0 1 1.628-.449l.05.336c-.563.14-1.948.543-3.045 1.34a.281.281 0 0 0 .332.454c1.23-.894 2.925-1.268 3.07-1.3h.004A16.65 16.65 0 0 1 10 2.304c.682-.003 1.363.036 2.04.118.128.026 1.828.398 3.074 1.3a.281.281 0 1 0 .332-.454c-1.097-.797-2.476-1.198-3.044-1.34l.049-.336a8.68 8.68 0 0 1 1.627.449 12.4 12.4 0 0 1 1.968.916 21.974 21.974 0 0 1 1.547 4.168c.603 2.415.793 3.843.844 4.253a7.345 7.345 0 0 1-2.282 1.56ZM7.36 6.652c-.866 0-1.57.787-1.57 1.755 0 .967.703 1.753 1.57 1.753.868 0 1.57-.786 1.57-1.753 0-.968-.705-1.755-1.57-1.755Zm0 2.953c-.562 0-1.008-.534-1.008-1.191s.451-1.193 1.008-1.193c.557 0 1.007.535 1.007 1.193S7.916 9.598 7.36 9.598v.007Zm3.71-1.198c0-.968.704-1.755 1.57-1.755.865 0 1.57.787 1.57 1.755 0 .967-.705 1.753-1.57 1.753-.867 0-1.57-.786-1.57-1.753Zm.562.007c0 .657.445 1.19 1.007 1.19V9.6c.557 0 1.008-.526 1.008-1.184 0-.658-.452-1.193-1.008-1.193-.555 0-1.007.536-1.007 1.193Z" clip-rule="evenodd"/>
</mask>
<path fill="currentColor" fill-rule="evenodd" d="M18.133 6.988a22.044 22.044 0 0 0-1.62-4.36.295.295 0 0 0-.1-.108 12.246 12.246 0 0 0-2.142-1.008C12.891 1.01 12.238 1 12.211 1a.281.281 0 0 0-.281.242l-.084.586a18.723 18.723 0 0 0-3.696 0l-.084-.586A.281.281 0 0 0 7.785 1c-.027 0-.68 0-2.06.508-.743.267-1.459.607-2.136 1.012a.295.295 0 0 0-.1.108 22.038 22.038 0 0 0-1.62 4.36C1.142 9.924 1 11.393 1 11.454a.281.281 0 0 0 .069.21 7.763 7.763 0 0 0 2.56 1.792c.888.378 1.824.635 2.782.762a.281.281 0 0 0 .27-.14l.76-1.301a14.77 14.77 0 0 0 2.559.21 14.83 14.83 0 0 0 2.559-.213l.76 1.304a.28.28 0 0 0 .241.14h.028a10.73 10.73 0 0 0 2.782-.756 7.76 7.76 0 0 0 2.559-1.792.28.28 0 0 0 .069-.21c.001-.067-.132-1.536-.866-4.472Zm-1.977 5.95c-.785.325-1.606.557-2.446.69l-.574-.983c.659-.175 2.028-.627 3.07-1.575a.283.283 0 0 0-.378-.422c-1.245 1.138-3.097 1.512-3.176 1.528h-.01c-.87.169-1.755.252-2.641.248a13.58 13.58 0 0 1-2.641-.24h-.01c-.02 0-1.905-.37-3.178-1.528a.283.283 0 1 0-.378.422c1.042.948 2.412 1.406 3.07 1.575l-.574.984a10.7 10.7 0 0 1-2.445-.692 7.345 7.345 0 0 1-2.271-1.567c.046-.41.236-1.838.844-4.253a22.03 22.03 0 0 1 1.535-4.168 12.397 12.397 0 0 1 1.969-.916 8.627 8.627 0 0 1 1.628-.449l.05.336c-.563.14-1.948.543-3.045 1.34a.281.281 0 0 0 .332.454c1.23-.894 2.925-1.268 3.07-1.3h.004A16.65 16.65 0 0 1 10 2.304c.682-.003 1.363.036 2.04.118.128.026 1.828.398 3.074 1.3a.281.281 0 1 0 .332-.454c-1.097-.797-2.476-1.198-3.044-1.34l.049-.336a8.68 8.68 0 0 1 1.627.449 12.4 12.4 0 0 1 1.968.916 21.974 21.974 0 0 1 1.547 4.168c.603 2.415.793 3.843.844 4.253a7.345 7.345 0 0 1-2.282 1.56ZM7.36 6.652c-.866 0-1.57.787-1.57 1.755 0 .967.703 1.753 1.57 1.753.868 0 1.57-.786 1.57-1.753 0-.968-.705-1.755-1.57-1.755Zm0 2.953c-.562 0-1.008-.534-1.008-1.191s.451-1.193 1.008-1.193c.557 0 1.007.535 1.007 1.193S7.916 9.598 7.36 9.598v.007Zm3.71-1.198c0-.968.704-1.755 1.57-1.755.865 0 1.57.787 1.57 1.755 0 .967-.705 1.753-1.57 1.753-.867 0-1.57-.786-1.57-1.753Zm.562.007c0 .657.445 1.19 1.007 1.19V9.6c.557 0 1.008-.526 1.008-1.184 0-.658-.452-1.193-1.008-1.193-.555 0-1.007.536-1.007 1.193Z" clip-rule="evenodd"/>
<path fill="currentColor" d="m16.513 2.628.358-.178-.006-.01-.352.188Zm1.62 4.36.388-.097v-.002l-.388.099Zm-1.72-4.468.217-.336-.006-.004-.006-.004-.205.344ZM14.27 1.512l-.136.376h.002l.134-.376ZM12.211 1l-.004.4h.004V1Zm-.186.068-.26-.303.26.303Zm-.095.174.396.057v-.001l-.396-.056Zm-.084.586-.04.398.381.038.055-.379-.396-.057Zm-3.696 0-.396.057.055.38.38-.039-.039-.398Zm-.084-.586-.396.056.396-.056Zm-.095-.174.26-.303-.26.303ZM7.785 1v.4h.003L7.785 1Zm-2.06.508.135.376h.003l-.139-.376ZM3.588 2.52l-.206-.343-.012.007.218.336Zm-.1.108-.353-.189-.005.011.358.178Zm-1.62 4.36-.388-.1v.003l.388.097ZM1 11.454l.399.035.001-.018v-.017H1Zm.013.112.38-.124-.38.124Zm.056.098-.3.265.004.005.005.005.291-.274Zm2.56 1.792-.16.367.003.001.156-.368Zm2.782.762-.053.397h.008l.008.002.037-.399Zm.27-.14-.345-.202-.002.003.347.199Zm.76-1.301.067-.394-.273-.047-.14.24.345.2Zm2.559.21.001-.4H10l.002.4Zm2.559-.213.346-.201-.14-.24-.274.047.068.394Zm.76 1.304.346-.2-.001-.002-.346.202Zm.101.102.202-.346-.201.346Zm.14.038-.002.4h.002v-.4Zm.028 0v.4h.027l.026-.003-.053-.397Zm2.782-.756.156.368.002-.001-.158-.367Zm2.559-1.792.29.275.006-.005.004-.006-.3-.264Zm.056-.098-.38-.124.38.124Zm.013-.113-.4-.008v.022l.002.022.398-.036Zm-5.289 2.17-.345.201.137.237.271-.043-.063-.395Zm2.446-.692.153.37.006-.003-.16-.367Zm-3.02-.292-.102-.387-.508.134.265.454.345-.201Zm3.07-1.575-.267-.298-.002.002.27.296Zm.067-.089-.361-.173.36.173Zm.027-.107-.4-.022.4.022Zm-.016-.11-.377.134.377-.133Zm-.056-.094.298-.267-.298.267Zm-.088-.067.172-.36-.172.36Zm-.108-.027-.021.4.021-.4Zm-.109.015.133.378-.133-.377Zm-.095.057-.267-.298-.003.003.27.295Zm-3.176 1.528-.079-.392.08.392Zm-.002 0v.4h.04l.04-.008-.08-.392Zm-.008 0v-.4h-.039l-.037.008.075.392ZM10 12.424l.001-.4h-.004l.003.4Zm-2.641-.24.075-.392-.037-.007H7.36v.4Zm-3.187-1.528.27-.296-.002-.002-.268.298Zm-.204-.072-.022-.4.022.4Zm-.196.094-.297-.267.297.267Zm.022.4.27-.296-.003-.002-.267.298Zm3.07 1.575.345.201.267-.457-.513-.131-.1.387Zm-.574.984-.062.396.27.042.138-.236-.346-.202Zm-2.445-.692-.162.367.008.003.154-.37Zm-2.271-1.567-.398-.045-.021.19.134.135.285-.28Zm.844-4.253-.388-.1v.003l.388.097Zm1.535-4.168-.2-.346-.106.061-.053.11.36.175Zm1.969-.916.136.376.007-.003-.143-.373Zm1.628-.449.396-.058-.06-.406-.404.07.068.394Zm.05.336.096.388.352-.088-.053-.358-.396.058Zm-3.045 1.34-.235-.323.236.323Zm-.074.081-.342-.208.342.208Zm-.038.103-.395-.061.395.061Zm.235.321.061-.395-.061.395Zm.11-.004-.095-.39.094.39Zm.099-.047L4.653 3.4h-.001l.236.323Zm3.07-1.3.087.391-.087-.39Zm.004 0-.048-.397-.02.002-.018.004.086.39ZM10 2.304l-.002.4h.003l-.001-.4Zm2.04.118.082-.392-.017-.003-.017-.002-.048.397Zm3.074 1.3.236-.323h-.001l-.235.323Zm.21.051-.062-.395.061.395Zm.234-.321.395-.061-.395.061Zm-.112-.184.236-.323h-.001l-.235.323Zm-3.044-1.34-.396-.058-.053.358.351.088.098-.388Zm.049-.336.068-.394-.405-.07-.06.406.397.058Zm1.627.449-.144.373.007.003.137-.376Zm1.968.916.36-.176-.054-.11-.106-.06-.2.346Zm1.547 4.168.388-.097v-.003l-.388.1Zm.844 4.253.284.281.137-.137-.024-.194-.397.05ZM7.36 9.606v.4h.4v-.4h-.4Zm0-.007v-.4h-.4v.4h.4Zm5.28.007v.4h.4v-.4h-.4Zm0-.007v-.4h-.4v.4h.4Zm3.514-6.793a21.645 21.645 0 0 1 1.59 4.28l.775-.197a22.449 22.449 0 0 0-1.65-4.439l-.715.356Zm.04.05a.105.105 0 0 1-.035-.038l.705-.379a.695.695 0 0 0-.235-.255l-.434.672Zm-2.058-.967c.72.257 1.414.584 2.071.975l.41-.688a12.647 12.647 0 0 0-2.212-1.04l-.27.753ZM12.21 1.4c-.028 0 .578-.001 1.924.488l.273-.752C12.992.62 12.293.6 12.21.6v.8Zm.075-.029a.119.119 0 0 1-.079.029l.008-.8a.681.681 0 0 0-.45.165l.52.606Zm.04-.073a.119.119 0 0 1-.04.073l-.522-.606a.681.681 0 0 0-.23.42l.792.113Zm-.085.587.085-.586-.792-.114-.084.586.792.114Zm-4.051.341a18.358 18.358 0 0 1 3.616 0l.08-.796a19.12 19.12 0 0 0-3.775 0l.079.796ZM7.67 1.3l.084.586.792-.114-.084-.586-.792.114Zm.04.072a.119.119 0 0 1-.04-.073l.792-.112a.681.681 0 0 0-.23-.421l-.522.606Zm.079.029a.119.119 0 0 1-.08-.029l.523-.606A.681.681 0 0 0 7.78.6l.008.8Zm-1.926.483a9.294 9.294 0 0 1 1.473-.426A3.785 3.785 0 0 1 7.78 1.4h.005V.6c-.079 0-.778.01-2.199.532l.277.751Zm-2.07.98c.656-.392 1.348-.72 2.067-.979l-.271-.753c-.767.277-1.506.627-2.206 1.046l.41.686Zm.048-.045a.105.105 0 0 1-.035.038l-.435-.672a.695.695 0 0 0-.235.255l.705.379ZM2.256 7.087a21.65 21.65 0 0 1 1.59-4.28l-.715-.357A22.438 22.438 0 0 0 1.48 6.89l.775.198Zm-.855 4.367c0 .016.005-.059.03-.26.024-.187.064-.462.126-.828.124-.732.339-1.823.7-3.282L1.48 6.89a46.404 46.404 0 0 0-.712 3.34c-.064.377-.105.664-.13.861a4.254 4.254 0 0 0-.037.362h.8Zm-.007-.012a.119.119 0 0 1 .006.047l-.797-.071a.681.681 0 0 0 .03.272l.761-.248ZM1.37 11.4c.011.012.02.027.024.042l-.76.248a.68.68 0 0 0 .136.239l.6-.529Zm2.418 1.689a7.362 7.362 0 0 1-2.427-1.7l-.582.55a8.16 8.16 0 0 0 2.692 1.884l.317-.734Zm2.677.733a10.328 10.328 0 0 1-2.68-.734l-.312.736c.922.392 1.893.658 2.886.79l.106-.792Zm-.082.01a.119.119 0 0 1 .066-.012l-.074.796a.682.682 0 0 0 .378-.074l-.37-.71Zm-.048.047a.12.12 0 0 1 .048-.046l.37.71a.681.681 0 0 0 .276-.267l-.694-.397Zm.761-1.304-.76 1.3.692.404.759-1.3-.691-.404Zm2.903.011a14.377 14.377 0 0 1-2.49-.204l-.135.79c.869.147 1.748.219 2.629.214l-.004-.8Zm2.493-.206a14.38 14.38 0 0 1-2.49.206l-.003.8c.88.004 1.76-.069 2.628-.218l-.135-.788Zm1.173 1.496-.76-1.303-.69.402.759 1.304.69-.403Zm-.042-.042c.018.01.032.026.043.043l-.693.4a.68.68 0 0 0 .247.249l.402-.692Zm-.06-.016a.12.12 0 0 1 .06.016l-.403.692a.68.68 0 0 0 .339.092l.005-.8Zm.026 0h-.028v.8h.028v-.8Zm2.626-.725c-.856.362-1.757.607-2.678.729l.104.793c.993-.13 1.963-.395 2.886-.785l-.312-.737Zm2.424-1.698a7.364 7.364 0 0 1-2.427 1.7l.317.734a8.163 8.163 0 0 0 2.692-1.884l-.582-.55Zm-.033.053a.118.118 0 0 1 .024-.042l.6.528a.681.681 0 0 0 .136-.238l-.76-.248Zm-.006.047a.118.118 0 0 1 .006-.047l.76.248a.682.682 0 0 0 .031-.273l-.796.072Zm-.855-4.41c.364 1.458.579 2.55.702 3.281a19.276 19.276 0 0 1 .147 1.04c.006.061.005.065.005.045l.8.017a1.4 1.4 0 0 0-.009-.137 20.03 20.03 0 0 0-.154-1.098 44.227 44.227 0 0 0-.715-3.342l-.776.194Zm-3.972 6.939c.87-.138 1.722-.38 2.536-.717l-.307-.74c-.756.315-1.546.538-2.355.667l.126.79Zm-.982-1.178.574.984.69-.402-.573-.985-.691.403Zm3.146-2.072c-.97.882-2.264 1.315-2.903 1.484l.205.773c.678-.18 2.122-.652 3.236-1.665l-.538-.592Zm-.025.034a.118.118 0 0 1 .027-.036l.534.595a.684.684 0 0 0 .16-.213l-.721-.346Zm-.012.044a.116.116 0 0 1 .012-.044l.721.346a.682.682 0 0 0 .066-.258l-.799-.044Zm.007.046a.117.117 0 0 1-.007-.046l.8.044a.682.682 0 0 0-.038-.264l-.755.266Zm.023.039a.116.116 0 0 1-.023-.04l.755-.265a.682.682 0 0 0-.136-.23l-.596.535Zm.037.027a.116.116 0 0 1-.037-.027l.596-.534a.684.684 0 0 0-.213-.16l-.346.721Zm.044.011a.116.116 0 0 1-.044-.011l.345-.721a.682.682 0 0 0-.258-.066l-.043.798Zm.045-.006a.117.117 0 0 1-.045.006l.043-.798a.682.682 0 0 0-.264.037l.266.755Zm.039-.023a.116.116 0 0 1-.04.023l-.265-.755a.684.684 0 0 0-.23.136l.535.596Zm-3.363 1.622c.08-.016 2.032-.405 3.366-1.625l-.54-.59c-1.156 1.057-2.907 1.415-2.985 1.43l.159.785Zm-.002 0h.002l-.16-.784h-.002l.16.784Zm-.088.008h.008v-.8h-.008v.8Zm-2.643.248a13.98 13.98 0 0 0 2.718-.255l-.152-.785a13.19 13.19 0 0 1-2.563.24l-.003.8Zm-2.714-.246c.896.17 1.807.253 2.719.246l-.006-.8a13.19 13.19 0 0 1-2.563-.232l-.15.786Zm.066.007h.009v-.8h-.01v.8Zm-3.447-1.633c.69.627 1.531 1.03 2.188 1.277a8.823 8.823 0 0 0 1.083.328 3.754 3.754 0 0 0 .114.023l.014.002a.32.32 0 0 0 .048.003v-.8a.381.381 0 0 1 .047.003l.011.001h.005a.634.634 0 0 1-.066-.013 8.056 8.056 0 0 1-.975-.296c-.61-.229-1.347-.589-1.93-1.12l-.539.592Zm.087.032a.117.117 0 0 1-.085-.03l.535-.596a.683.683 0 0 0-.494-.173l.044.799Zm.08-.039a.117.117 0 0 1-.08.039l-.044-.8a.684.684 0 0 0-.471.227l.595.534Zm.03-.084a.117.117 0 0 1-.03.084l-.595-.534a.683.683 0 0 0-.174.493l.799-.043Zm-.039-.08c.023.02.037.049.039.08l-.799.043c.01.181.091.351.226.472l.534-.596Zm2.902 1.485c-.634-.163-1.929-.6-2.9-1.484l-.538.592c1.113 1.013 2.557 1.492 3.24 1.666l.198-.774Zm-.327 1.573.574-.985-.692-.402-.573.984.69.403Zm-2.945-.524c.814.339 1.666.58 2.537.718l.125-.79A10.3 10.3 0 0 1 4 12.575l-.308.739ZM1.29 11.658a7.745 7.745 0 0 0 2.394 1.654l.323-.732a6.944 6.944 0 0 1-2.147-1.483l-.57.561Zm.74-4.63c-.611 2.431-.804 3.878-.853 4.305l.795.09c.045-.392.231-1.803.834-4.2l-.775-.195Zm1.565-4.246A22.372 22.372 0 0 0 2.03 7.026l.775.199a21.572 21.572 0 0 1 1.508-4.092l-.72-.351Zm2.191-1.117a12.84 12.84 0 0 0-2.032.946l.4.693a12 12 0 0 1 1.905-.887l-.273-.752Zm1.697-.467a8.93 8.93 0 0 0-1.704.47l.287.746a8.227 8.227 0 0 1 1.553-.428l-.136-.788Zm.513.672-.05-.336-.79.116.049.336.791-.116ZM4.791 3.592c1.033-.75 2.356-1.138 2.906-1.276l-.194-.776c-.574.144-2.021.56-3.182 1.405l.47.647Zm.032-.035a.119.119 0 0 1-.031.034l-.472-.646a.681.681 0 0 0-.18.196l.683.416Zm.016-.043a.119.119 0 0 1-.016.043l-.683-.416a.681.681 0 0 0-.091.25l.79.123Zm-.021-.089a.119.119 0 0 1 .021.089l-.79-.123a.681.681 0 0 0 .123.506l.646-.472Zm-.078-.047a.119.119 0 0 1 .078.047l-.646.472a.681.681 0 0 0 .445.271l.123-.79Zm-.046.002a.119.119 0 0 1 .046-.002l-.123.79a.7.7 0 0 0 .266-.01l-.19-.778Zm-.042.02a.119.119 0 0 1 .042-.02l.189.777a.681.681 0 0 0 .24-.112l-.47-.645Zm3.22-1.368c-.145.032-1.915.419-3.22 1.367l.471.647c1.156-.84 2.775-1.2 2.922-1.233l-.173-.781Zm.004 0h-.004l.173.781h.003l-.172-.782Zm2.126-.128a17.049 17.049 0 0 0-2.088.12l.095.795A16.25 16.25 0 0 1 10 2.704l.003-.8Zm2.087.12a16.943 16.943 0 0 0-2.09-.12l.003.8a16.14 16.14 0 0 1 1.991.115l.096-.794Zm3.26 1.374c-1.32-.956-3.093-1.34-3.226-1.368l-.164.783c.123.026 1.75.384 2.921 1.233l.47-.648Zm-.086-.02a.119.119 0 0 1 .088.021l-.472.646a.679.679 0 0 0 .507.123l-.123-.79Zm-.078.047a.119.119 0 0 1 .078-.047l.123.79a.681.681 0 0 0 .445-.27l-.646-.473Zm-.021.089a.119.119 0 0 1 .021-.089l.646.472a.681.681 0 0 0 .123-.506l-.79.123Zm.047.077a.118.118 0 0 1-.047-.077l.79-.123a.681.681 0 0 0-.271-.446l-.472.646Zm-2.906-1.275c.557.14 1.874.525 2.907 1.276l.47-.647c-1.161-.844-2.602-1.26-3.183-1.405l-.194.776Zm-.25-.782-.048.336.791.116.05-.336-.792-.116Zm2.167.133a9.083 9.083 0 0 0-1.702-.47l-.137.79c.53.091 1.05.234 1.552.427l.287-.747Zm2.025.944a12.796 12.796 0 0 0-2.032-.946l-.273.752c.66.24 1.297.536 1.905.887l.4-.693Zm1.734 4.414a22.376 22.376 0 0 0-1.575-4.244l-.718.353a21.572 21.572 0 0 1 1.519 4.092l.774-.201Zm.854 4.303c-.053-.423-.245-1.867-.853-4.3l-.776.194c.599 2.397.786 3.81.835 4.205l.794-.098Zm-2.52 1.976a7.745 7.745 0 0 0 2.407-1.645l-.568-.563a6.946 6.946 0 0 1-2.158 1.474l.32.734ZM6.191 8.408c0-.79.566-1.355 1.17-1.355v-.8c-1.127 0-1.97 1.01-1.97 2.155h.8ZM7.36 9.76c-.606 0-1.17-.564-1.17-1.353h-.8c0 1.146.841 2.153 1.97 2.153v-.8Zm1.17-1.353c0 .79-.564 1.353-1.17 1.353v.8c1.13 0 1.97-1.008 1.97-2.153h-.8ZM7.36 7.053c.603 0 1.17.565 1.17 1.355h.8c0-1.145-.843-2.155-1.97-2.155v.8ZM5.953 8.415c0 .81.563 1.59 1.408 1.59v-.8c-.28 0-.608-.287-.608-.79h-.8Zm1.408-1.593c-.838 0-1.408.781-1.408 1.593h.8c0-.502.333-.793.608-.793v-.8Zm1.407 1.593c0-.813-.568-1.593-1.407-1.593v.8c.275 0 .607.29.607.793h.8ZM7.361 9.999c.834 0 1.407-.767 1.407-1.584h-.8c0 .5-.33.784-.607.784v.8Zm.4-.393v-.007h-.8v.007h.8Zm4.88-3.353c-1.127 0-1.97 1.01-1.97 2.155h.8c0-.79.566-1.355 1.17-1.355v-.8Zm1.97 2.155c0-1.146-.843-2.155-1.97-2.155v.8c.604 0 1.17.565 1.17 1.355h.8Zm-1.97 2.153c1.126 0 1.97-1.007 1.97-2.153h-.8c0 .789-.566 1.353-1.17 1.353v.8Zm-1.97-2.153c0 1.145.841 2.153 1.97 2.153v-.8c-.605 0-1.17-.564-1.17-1.353h-.8Zm1.97.798c-.28 0-.608-.288-.608-.791h-.8c0 .81.562 1.59 1.407 1.59v-.8Zm-.4.393v.007h.8v-.007h-.8Zm1.007-1.184c0 .499-.329.784-.608.784v.8c.835 0 1.408-.767 1.408-1.584h-.8Zm-.608-.793c.275 0 .608.29.608.793h.8c0-.814-.572-1.593-1.408-1.593v.8Zm-.607.793c0-.502.334-.793.607-.793v-.8c-.837 0-1.407.782-1.407 1.593h.8Z" mask="url(#discord_svg__a)"/>
</svg> </svg>
/* eslint-disable no-console */
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import {
PeriodicExportingMetricReader,
ConsoleMetricExporter,
} from '@opentelemetry/sdk-metrics';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);
const traceExporter = new OTLPTraceExporter();
const sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'blockscout_frontend',
[SemanticResourceAttributes.SERVICE_VERSION]: process.env.NEXT_PUBLIC_GIT_TAG || process.env.NEXT_PUBLIC_GIT_COMMIT_SHA || 'unknown_version',
[SemanticResourceAttributes.SERVICE_INSTANCE_ID]:
process.env.NEXT_PUBLIC_APP_INSTANCE ||
process.env.NEXT_PUBLIC_APP_HOST?.replace('.blockscout.com', '').replaceAll('-', '_') ||
'unknown_app',
}),
spanProcessor: new SimpleSpanProcessor(traceExporter),
traceExporter,
metricReader: new PeriodicExportingMetricReader({
exporter:
process.env.NODE_ENV === 'production' ?
new OTLPMetricExporter() :
new ConsoleMetricExporter(),
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (request) => {
try {
if (!request.url) {
return false;
}
const url = new URL(request.url, `http://${ request.headers.host }`);
if (
url.pathname.startsWith('/_next/static/') ||
url.pathname.startsWith('/_next/data/') ||
url.pathname.startsWith('/assets/') ||
url.pathname.startsWith('/static/') ||
url.pathname.startsWith('/favicon/') ||
url.pathname.startsWith('/envs.js')
) {
return true;
}
} catch (error) {}
return false;
},
},
}),
],
});
if (process.env.OTEL_SDK_ENABLED) {
sdk.start();
process.on('SIGTERM', () => {
sdk
.shutdown()
.then(() => console.log('Tracing terminated'))
.catch((error) => console.log('Error terminating tracing', error))
.finally(() => process.exit(0));
});
}
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation.node');
}
}
...@@ -26,12 +26,15 @@ import type { ...@@ -26,12 +26,15 @@ import type {
AddressTokensFilter, AddressTokensFilter,
AddressTokensResponse, AddressTokensResponse,
AddressWithdrawalsResponse, AddressWithdrawalsResponse,
AddressNFTsResponse,
AddressCollectionsResponse,
AddressNFTTokensFilter,
} from 'types/api/address'; } from 'types/api/address';
import type { AddressesResponse } from 'types/api/addresses'; import type { AddressesResponse } from 'types/api/addresses';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block'; import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block';
import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts'; import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendVersionConfig } from 'types/api/configs'; import type { BackendVersionConfig } from 'types/api/configs';
import type { SmartContract, SmartContractReadMethod, SmartContractWriteMethod, SmartContractVerificationConfig } from 'types/api/contract'; import type { SmartContract, SmartContractReadMethod, SmartContractWriteMethod, SmartContractVerificationConfig, SolidityscanReport } from 'types/api/contract';
import type { VerifiedContractsResponse, VerifiedContractsFilters, VerifiedContractsCounters } from 'types/api/contracts'; import type { VerifiedContractsResponse, VerifiedContractsFilters, VerifiedContractsCounters } from 'types/api/contracts';
import type { 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';
...@@ -51,6 +54,7 @@ import type { ...@@ -51,6 +54,7 @@ import type {
TokenInstance, TokenInstance,
TokenInstanceTransfersCount, TokenInstanceTransfersCount,
TokenVerifiedInfo, TokenVerifiedInfo,
TokenInventoryFilters,
} from 'types/api/token'; } from 'types/api/token';
import type { TokensResponse, TokensFilters, TokensSorting, TokenInstanceTransferResponse, TokensBridgedFilters } from 'types/api/tokens'; import type { TokensResponse, TokensFilters, TokensSorting, TokenInstanceTransferResponse, TokensBridgedFilters } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer'; import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
...@@ -77,16 +81,16 @@ export const SORTING_FIELDS = [ 'sort', 'order' ]; ...@@ -77,16 +81,16 @@ export const SORTING_FIELDS = [ 'sort', 'order' ];
export const RESOURCES = { export const RESOURCES = {
// ACCOUNT // ACCOUNT
csrf: { csrf: {
path: '/api/account/v1/get_csrf', path: '/api/account/v2/get_csrf',
}, },
user_info: { user_info: {
path: '/api/account/v1/user/info', path: '/api/account/v2/user/info',
}, },
email_resend: { email_resend: {
path: '/api/account/v1/email/resend', path: '/api/account/v2/email/resend',
}, },
custom_abi: { custom_abi: {
path: '/api/account/v1/user/custom_abis/:id?', path: '/api/account/v2/user/custom_abis/:id?',
pathParams: [ 'id' as const ], pathParams: [ 'id' as const ],
}, },
watchlist: { watchlist: {
...@@ -95,7 +99,7 @@ export const RESOURCES = { ...@@ -95,7 +99,7 @@ export const RESOURCES = {
filterFields: [ ], filterFields: [ ],
}, },
public_tags: { public_tags: {
path: '/api/account/v1/user/public_tags/:id?', path: '/api/account/v2/user/public_tags/:id?',
pathParams: [ 'id' as const ], pathParams: [ 'id' as const ],
}, },
private_tags_address: { private_tags_address: {
...@@ -109,7 +113,7 @@ export const RESOURCES = { ...@@ -109,7 +113,7 @@ export const RESOURCES = {
filterFields: [ ], filterFields: [ ],
}, },
api_keys: { api_keys: {
path: '/api/account/v1/user/api_keys/:id?', path: '/api/account/v2/user/api_keys/:id?',
pathParams: [ 'id' as const ], pathParams: [ 'id' as const ],
}, },
...@@ -305,6 +309,16 @@ export const RESOURCES = { ...@@ -305,6 +309,16 @@ export const RESOURCES = {
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
filterFields: [ 'type' as const ], filterFields: [ 'type' as const ],
}, },
address_nfts: {
path: '/api/v2/addresses/:hash/nft',
pathParams: [ 'hash' as const ],
filterFields: [ 'type' as const ],
},
address_collections: {
path: '/api/v2/addresses/:hash/nft/collections',
pathParams: [ 'hash' as const ],
filterFields: [ 'type' as const ],
},
address_withdrawals: { address_withdrawals: {
path: '/api/v2/addresses/:hash/withdrawals', path: '/api/v2/addresses/:hash/withdrawals',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
...@@ -343,6 +357,10 @@ export const RESOURCES = { ...@@ -343,6 +357,10 @@ export const RESOURCES = {
path: '/api/v2/smart-contracts/:hash/verification/via/:method', path: '/api/v2/smart-contracts/:hash/verification/via/:method',
pathParams: [ 'hash' as const, 'method' as const ], pathParams: [ 'hash' as const, 'method' as const ],
}, },
contract_solidityscan_report: {
path: '/api/v2/smart-contracts/:hash/solidityscan-report',
pathParams: [ 'hash' as const ],
},
verified_contracts: { verified_contracts: {
path: '/api/v2/smart-contracts', path: '/api/v2/smart-contracts',
...@@ -380,7 +398,7 @@ export const RESOURCES = { ...@@ -380,7 +398,7 @@ export const RESOURCES = {
token_inventory: { token_inventory: {
path: '/api/v2/tokens/:hash/instances', path: '/api/v2/tokens/:hash/instances',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
filterFields: [], filterFields: [ 'holder_address_hash' as const ],
}, },
tokens: { tokens: {
path: '/api/v2/tokens', path: '/api/v2/tokens',
...@@ -576,7 +594,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | ...@@ -576,7 +594,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'addresses' | 'addresses' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' | 'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' | 'search' |
'address_logs' | 'address_tokens' | 'address_logs' | 'address_tokens' | 'address_nfts' | 'address_collections' |
'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens' | 'tokens_bridged' | 'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens' | 'tokens_bridged' |
'token_instance_transfers' | 'token_instance_holders' | 'token_instance_transfers' | 'token_instance_holders' |
'verified_contracts' | 'verified_contracts' |
...@@ -638,6 +656,8 @@ Q extends 'address_coin_balance' ? AddressCoinBalanceHistoryResponse : ...@@ -638,6 +656,8 @@ Q extends 'address_coin_balance' ? AddressCoinBalanceHistoryResponse :
Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart : Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart :
Q extends 'address_logs' ? LogsResponseAddress : Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'address_tokens' ? AddressTokensResponse : Q extends 'address_tokens' ? AddressTokensResponse :
Q extends 'address_nfts' ? AddressNFTsResponse :
Q extends 'address_collections' ? AddressCollectionsResponse :
Q extends 'address_withdrawals' ? AddressWithdrawalsResponse : Q extends 'address_withdrawals' ? AddressWithdrawalsResponse :
Q extends 'token' ? TokenInfo : Q extends 'token' ? TokenInfo :
Q extends 'token_verified_info' ? TokenVerifiedInfo : Q extends 'token_verified_info' ? TokenVerifiedInfo :
...@@ -659,6 +679,7 @@ Q extends 'contract_methods_read' ? Array<SmartContractReadMethod> : ...@@ -659,6 +679,7 @@ Q extends 'contract_methods_read' ? Array<SmartContractReadMethod> :
Q extends 'contract_methods_read_proxy' ? Array<SmartContractReadMethod> : Q extends 'contract_methods_read_proxy' ? Array<SmartContractReadMethod> :
Q extends 'contract_methods_write' ? Array<SmartContractWriteMethod> : Q extends 'contract_methods_write' ? Array<SmartContractWriteMethod> :
Q extends 'contract_methods_write_proxy' ? Array<SmartContractWriteMethod> : Q extends 'contract_methods_write_proxy' ? Array<SmartContractWriteMethod> :
Q extends 'contract_solidityscan_report' ? SolidityscanReport :
Q extends 'verified_contracts' ? VerifiedContractsResponse : Q extends 'verified_contracts' ? VerifiedContractsResponse :
Q extends 'verified_contracts_counters' ? VerifiedContractsCounters : Q extends 'verified_contracts_counters' ? VerifiedContractsCounters :
Q extends 'visualize_sol2uml' ? VisualizedContract : Q extends 'visualize_sol2uml' ? VisualizedContract :
...@@ -690,7 +711,10 @@ Q extends 'token_transfers' ? TokenTransferFilters : ...@@ -690,7 +711,10 @@ Q extends 'token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters : Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
Q extends 'address_token_transfers' ? AddressTokenTransferFilters : Q extends 'address_token_transfers' ? AddressTokenTransferFilters :
Q extends 'address_tokens' ? AddressTokensFilter : Q extends 'address_tokens' ? AddressTokensFilter :
Q extends 'address_nfts' ? AddressNFTTokensFilter :
Q extends 'address_collections' ? AddressNFTTokensFilter :
Q extends 'search' ? SearchResultFilters : Q extends 'search' ? SearchResultFilters :
Q extends 'token_inventory' ? TokenInventoryFilters :
Q extends 'tokens' ? TokensFilters : Q extends 'tokens' ? TokensFilters :
Q extends 'tokens_bridged' ? TokensBridgedFilters : Q extends 'tokens_bridged' ? TokensBridgedFilters :
Q extends 'verified_contracts' ? VerifiedContractsFilters : Q extends 'verified_contracts' ? VerifiedContractsFilters :
......
...@@ -12,6 +12,7 @@ export enum NAMES { ...@@ -12,6 +12,7 @@ export enum NAMES {
INDEXING_ALERT='indexing_alert', INDEXING_ALERT='indexing_alert',
ADBLOCK_DETECTED='adblock_detected', ADBLOCK_DETECTED='adblock_detected',
MIXPANEL_DEBUG='_mixpanel_debug', MIXPANEL_DEBUG='_mixpanel_debug',
ADDRESS_NFT_DISPLAY_TYPE='address_nft_display_type'
} }
export function get(name?: NAMES | undefined | null, serverCookie?: string) { export function get(name?: NAMES | undefined | null, serverCookie?: string) {
......
import type * as Sentry from '@sentry/react'; import type * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
import appConfig from 'configs/app'; import appConfig from 'configs/app';
...@@ -10,12 +11,13 @@ export const config: Sentry.BrowserOptions | undefined = (() => { ...@@ -10,12 +11,13 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
} }
const tracesSampleRate: number | undefined = (() => { const tracesSampleRate: number | undefined = (() => {
if (feature.environment === 'staging') { switch (feature.environment) {
return 1; case 'development':
} return 1;
case 'staging':
if (feature.environment === 'production' && feature.instance === 'eth') { return 0.75;
return 0.2; case 'production':
return 0.2;
} }
})(); })();
...@@ -25,6 +27,7 @@ export const config: Sentry.BrowserOptions | undefined = (() => { ...@@ -25,6 +27,7 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
release: feature.release, release: feature.release,
enableTracing: feature.enableTracing, enableTracing: feature.enableTracing,
tracesSampleRate, tracesSampleRate,
integrations: feature.enableTracing ? [ new BrowserTracing() ] : undefined,
// error filtering settings // error filtering settings
// were taken from here - https://docs.sentry.io/platforms/node/guides/azure-functions/configuration/filtering/#decluttering-sentry // were taken from here - https://docs.sentry.io/platforms/node/guides/azure-functions/configuration/filtering/#decluttering-sentry
......
import type { TokenType } from 'types/api/token'; import type { NFTTokenType, TokenType } from 'types/api/token';
export const TOKEN_TYPES: Array<{ title: string; id: TokenType }> = [ export const NFT_TOKEN_TYPES: Array<{ title: string; id: NFTTokenType }> = [
{ title: 'ERC-20', id: 'ERC-20' },
{ title: 'ERC-721', id: 'ERC-721' }, { title: 'ERC-721', id: 'ERC-721' },
{ title: 'ERC-1155', id: 'ERC-1155' }, { title: 'ERC-1155', id: 'ERC-1155' },
]; ];
export const TOKEN_TYPES: Array<{ title: string; id: TokenType }> = [
{ title: 'ERC-20', id: 'ERC-20' },
...NFT_TOKEN_TYPES,
];
export const NFT_TOKEN_TYPE_IDS = NFT_TOKEN_TYPES.map(i => i.id);
export const TOKEN_TYPE_IDS = TOKEN_TYPES.map(i => i.id); export const TOKEN_TYPE_IDS = TOKEN_TYPES.map(i => i.id);
import { WindowPostMessageStream } from '@metamask/post-message-stream';
import { initializeProvider } from '@metamask/providers';
import React from 'react'; import React from 'react';
import type { WindowProvider } from 'wagmi'; import type { WindowProvider } from 'wagmi';
...@@ -15,13 +13,16 @@ export default function useProvider() { ...@@ -15,13 +13,16 @@ export default function useProvider() {
const [ provider, setProvider ] = React.useState<WindowProvider>(); const [ provider, setProvider ] = React.useState<WindowProvider>();
const [ wallet, setWallet ] = React.useState<WalletType>(); const [ wallet, setWallet ] = React.useState<WalletType>();
React.useEffect(() => { const initializeProvider = React.useMemo(() => async() => {
if (!feature.isEnabled) { if (!feature.isEnabled) {
return; return;
} }
if (!('ethereum' in window && window.ethereum)) { if (!('ethereum' in window && window.ethereum)) {
if (feature.wallets.includes('metamask') && window.navigator.userAgent.includes('Firefox')) { if (feature.wallets.includes('metamask') && window.navigator.userAgent.includes('Firefox')) {
const { WindowPostMessageStream } = (await import('@metamask/post-message-stream'));
const { initializeProvider } = (await import('@metamask/providers'));
// workaround for MetaMask in Firefox // workaround for MetaMask in Firefox
// Firefox blocks MetaMask injection script because of our CSP for 'script-src' // Firefox blocks MetaMask injection script because of our CSP for 'script-src'
// so we have to inject it manually while the issue is not fixed // so we have to inject it manually while the issue is not fixed
...@@ -73,5 +74,9 @@ export default function useProvider() { ...@@ -73,5 +74,9 @@ export default function useProvider() {
} }
}, []); }, []);
React.useEffect(() => {
initializeProvider();
}, [ initializeProvider ]);
return { provider, wallet }; return { provider, wallet };
} }
import type { AddressTokenBalance } from 'types/api/address'; import type { AddressCollectionsResponse, AddressNFTsResponse, AddressTokenBalance } from 'types/api/address';
import * as tokens from 'mocks/tokens/tokenInfo'; import * as tokens from 'mocks/tokens/tokenInfo';
import * as tokenInstance from 'mocks/tokens/tokenInstance'; import * as tokenInstance from 'mocks/tokens/tokenInstance';
...@@ -117,3 +117,51 @@ export const erc1155List = { ...@@ -117,3 +117,51 @@ export const erc1155List = {
erc1155b, erc1155b,
], ],
}; };
export const nfts: AddressNFTsResponse = {
items: [
{
...tokenInstance.base,
token: tokens.tokenInfoERC1155a,
token_type: 'ERC-1155',
value: '11',
},
{
...tokenInstance.unique,
token: tokens.tokenInfoERC721a,
token_type: 'ERC-721',
value: '1',
},
],
next_page_params: null,
};
const nftInstance = {
...tokenInstance.base,
token_type: 'ERC-1155',
value: '11',
};
export const collections: AddressCollectionsResponse = {
items: [
{
token: tokens.tokenInfoERC1155a,
amount: '100',
token_instances: Array(5).fill(nftInstance),
},
{
token: tokens.tokenInfoERC20LongSymbol,
amount: '100',
token_instances: Array(5).fill(nftInstance),
},
{
token: tokens.tokenInfoERC1155WithoutName,
amount: '1',
token_instances: [ nftInstance ],
},
],
next_page_params: {
token_contract_address_hash: '123',
token_type: 'ERC-1155',
},
};
export const solidityscanReportAverage = {
scan_report: {
scan_status: 'scan_done',
scan_summary: {
issue_severity_distribution: {
critical: 0,
gas: 1,
high: 0,
informational: 0,
low: 2,
medium: 0,
},
lines_analyzed_count: 18,
scan_time_taken: 1,
score: '3.61',
score_v2: '72.22',
threat_score: '94.74',
},
scanner_reference_url: 'https://solidityscan.com/quickscan/0xc1EF7811FF2ebFB74F80ed7423f2AdAA37454be2/blockscout/eth-goerli?ref=blockscout',
},
};
export const solidityscanReportGreat = {
scan_report: {
scan_status: 'scan_done',
scan_summary: {
issue_severity_distribution: {
critical: 0,
gas: 0,
high: 0,
informational: 0,
low: 0,
medium: 0,
},
lines_analyzed_count: 18,
scan_time_taken: 1,
score: '3.61',
score_v2: '100',
threat_score: '94.74',
},
scanner_reference_url: 'https://solidityscan.com/quickscan/0xc1EF7811FF2ebFB74F80ed7423f2AdAA37454be2/blockscout/eth-goerli?ref=blockscout',
},
};
export const solidityscanReportLow = {
scan_report: {
scan_status: 'scan_done',
scan_summary: {
issue_severity_distribution: {
critical: 2,
gas: 1,
high: 3,
informational: 0,
low: 2,
medium: 10,
},
lines_analyzed_count: 18,
scan_time_taken: 1,
score: '3.61',
score_v2: '22.22',
threat_score: '94.74',
},
scanner_reference_url: 'https://solidityscan.com/quickscan/0xc1EF7811FF2ebFB74F80ed7423f2AdAA37454be2/blockscout/eth-goerli?ref=blockscout',
},
};
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import type { TokenInstance } from 'types/api/token'; import type { TokenInstance } from 'types/api/token';
import * as addressMock from '../address/address'; import * as addressMock from '../address/address';
import { tokenInfoERC721a } from './tokenInfo';
export const base: TokenInstance = { export const base: TokenInstance = {
animation_url: null, animation_url: null,
...@@ -74,7 +73,6 @@ export const base: TokenInstance = { ...@@ -74,7 +73,6 @@ export const base: TokenInstance = {
name: 'GENESIS #188848, 22a5f8bbb1602995. Blockchain pixel PFP NFT + "on music video" trait inspired by God', name: 'GENESIS #188848, 22a5f8bbb1602995. Blockchain pixel PFP NFT + "on music video" trait inspired by God',
}, },
owner: addressMock.withName, owner: addressMock.withName,
token: tokenInfoERC721a,
}; };
export const withRichMetadata: TokenInstance = { export const withRichMetadata: TokenInstance = {
......
...@@ -25,7 +25,7 @@ export const erc20: TokenTransfer = { ...@@ -25,7 +25,7 @@ export const erc20: TokenTransfer = {
address: '0x55d536e4d6c1993d8ef2e2a4ef77f02088419420', address: '0x55d536e4d6c1993d8ef2e2a4ef77f02088419420',
circulating_market_cap: '117629601.61913824', circulating_market_cap: '117629601.61913824',
decimals: '18', decimals: '18',
exchange_rate: null, exchange_rate: '42',
holders: '46554', holders: '46554',
name: 'ARIANEE', name: 'ARIANEE',
symbol: 'ARIA', symbol: 'ARIA',
......
...@@ -124,6 +124,7 @@ export const withTokenTransfer: Transaction = { ...@@ -124,6 +124,7 @@ export const withTokenTransfer: Transaction = {
tokenTransferMock.erc1155C, tokenTransferMock.erc1155C,
tokenTransferMock.erc1155D, tokenTransferMock.erc1155D,
], ],
token_transfers_overflow: true,
tx_types: [ tx_types: [
'token_transfer', 'token_transfer',
], ],
......
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.BUNDLE_ANALYZER === 'true',
});
const withRoutes = require('nextjs-routes/config')({ const withRoutes = require('nextjs-routes/config')({
outDir: 'nextjs', outDir: 'nextjs',
}); });
...@@ -38,7 +42,10 @@ const moduleExports = { ...@@ -38,7 +42,10 @@ const moduleExports = {
redirects, redirects,
headers, headers,
output: 'standalone', output: 'standalone',
productionBrowserSourceMaps: process.env.GENERATE_SOURCEMAPS === 'true', productionBrowserSourceMaps: true,
experimental: {
instrumentationHook: true,
},
}; };
module.exports = withRoutes(moduleExports); module.exports = withBundleAnalyzer(withRoutes(moduleExports));
...@@ -16,7 +16,7 @@ export function googleAnalytics(): CspDev.DirectiveDescriptor { ...@@ -16,7 +16,7 @@ export function googleAnalytics(): CspDev.DirectiveDescriptor {
], ],
'script-src': [ 'script-src': [
// inline script hash, see ui/shared/GoogleAnalytics.tsx // inline script hash, see ui/shared/GoogleAnalytics.tsx
'\'sha256-NTmEg2dBnojQfTYrYJEmp3nG7V66756qPbQMCIBrctk=\'', '\'sha256-WXRwCtfSfMoCPzPUIOUAosSaADdGgct0/Lhmnbm7MCA=\'',
'https://www.googletagmanager.com', 'https://www.googletagmanager.com',
'*.google-analytics.com', '*.google-analytics.com',
'*.analytics.google.com', '*.analytics.google.com',
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
"npm": "8" "npm": "8"
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "./tools/scripts/dev.sh",
"dev:preset": "./tools/scripts/dev.preset.sh", "dev:preset": "./tools/scripts/dev.preset.sh",
"build": "next build", "build": "next build",
"build:docker": "docker build --build-arg GIT_COMMIT_SHA=$(git rev-parse --short HEAD) --build-arg GIT_TAG=$(git describe --tags --abbrev=0) -t blockscout-frontend:local ./", "build:docker": "docker build --build-arg GIT_COMMIT_SHA=$(git rev-parse --short HEAD) --build-arg GIT_TAG=$(git describe --tags --abbrev=0) -t blockscout-frontend:local ./",
...@@ -37,8 +37,17 @@ ...@@ -37,8 +37,17 @@
"@metamask/post-message-stream": "^7.0.0", "@metamask/post-message-stream": "^7.0.0",
"@metamask/providers": "^10.2.1", "@metamask/providers": "^10.2.1",
"@monaco-editor/react": "^4.4.6", "@monaco-editor/react": "^4.4.6",
"@next/bundle-analyzer": "^14.0.1",
"@opentelemetry/auto-instrumentations-node": "^0.39.4",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.45.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.45.0",
"@opentelemetry/resources": "^1.18.0",
"@opentelemetry/sdk-node": "^0.45.0",
"@opentelemetry/sdk-trace-node": "^1.18.0",
"@opentelemetry/semantic-conventions": "^1.18.0",
"@sentry/cli": "^2.21.2", "@sentry/cli": "^2.21.2",
"@sentry/react": "^7.72.0", "@sentry/react": "7.24.0",
"@sentry/tracing": "7.24.0",
"@slise/embed-react": "^2.2.0", "@slise/embed-react": "^2.2.0",
"@tanstack/react-query": "^5.4.3", "@tanstack/react-query": "^5.4.3",
"@tanstack/react-query-devtools": "^5.4.3", "@tanstack/react-query-devtools": "^5.4.3",
...@@ -47,13 +56,13 @@ ...@@ -47,13 +56,13 @@
"@web3modal/ethereum": "^2.6.2", "@web3modal/ethereum": "^2.6.2",
"@web3modal/react": "^2.6.2", "@web3modal/react": "^2.6.2",
"bignumber.js": "^9.1.0", "bignumber.js": "^9.1.0",
"blo": "^1.1.1",
"chakra-react-select": "^4.4.3", "chakra-react-select": "^4.4.3",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"d3": "^7.6.1", "d3": "^7.6.1",
"dappscout-iframe": "^0.1.0", "dappscout-iframe": "^0.1.0",
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"dom-to-image": "^2.6.0", "dom-to-image": "^2.6.0",
"ethereum-blockies-base64": "^1.0.2",
"framer-motion": "^6.5.1", "framer-motion": "^6.5.1",
"gradient-avatar": "^1.0.2", "gradient-avatar": "^1.0.2",
"graphiql": "^2.2.0", "graphiql": "^2.2.0",
......
...@@ -22,7 +22,7 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi ...@@ -22,7 +22,7 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi
return 'video'; return 'video';
} }
if (contentType === 'text/html') { if (contentType?.startsWith('text/html')) {
return 'html'; return 'html';
} }
......
import type { Address, AddressCoinBalanceHistoryItem, AddressCounters, AddressTabsCounters, AddressTokenBalance } from 'types/api/address'; import type {
Address,
AddressCoinBalanceHistoryItem,
AddressCollection,
AddressCounters,
AddressNFT,
AddressTabsCounters,
AddressTokenBalance,
} from 'types/api/address';
import type { AddressesItem } from 'types/api/addresses'; import type { AddressesItem } from 'types/api/addresses';
import { ADDRESS_HASH } from './addressParams'; import { ADDRESS_HASH } from './addressParams';
...@@ -80,16 +88,22 @@ export const ADDRESS_TOKEN_BALANCE_ERC_20: AddressTokenBalance = { ...@@ -80,16 +88,22 @@ export const ADDRESS_TOKEN_BALANCE_ERC_20: AddressTokenBalance = {
value: '1000000000000000000000000', value: '1000000000000000000000000',
}; };
export const ADDRESS_TOKEN_BALANCE_ERC_721: AddressTokenBalance = { export const ADDRESS_NFT_721: AddressNFT = {
token_type: 'ERC-721',
token: TOKEN_INFO_ERC_721, token: TOKEN_INFO_ERC_721,
token_id: null, value: '1',
token_instance: null, ...TOKEN_INSTANCE,
value: '176', };
export const ADDRESS_NFT_1155: AddressNFT = {
token_type: 'ERC-1155',
token: TOKEN_INFO_ERC_1155,
value: '10',
...TOKEN_INSTANCE,
}; };
export const ADDRESS_TOKEN_BALANCE_ERC_1155: AddressTokenBalance = { export const ADDRESS_COLLECTION: AddressCollection = {
token: TOKEN_INFO_ERC_1155, token: TOKEN_INFO_ERC_1155,
token_id: '188882', amount: '4',
token_instance: TOKEN_INSTANCE, token_instances: Array(4).fill(TOKEN_INSTANCE),
value: '176',
}; };
import type { SmartContract } from 'types/api/contract'; import type { SmartContract, SolidityscanReport } from 'types/api/contract';
import type { VerifiedContract } from 'types/api/contracts'; import type { VerifiedContract } from 'types/api/contracts';
import { ADDRESS_PARAMS } from './addressParams'; import { ADDRESS_PARAMS } from './addressParams';
...@@ -53,3 +53,25 @@ export const VERIFIED_CONTRACT_INFO: VerifiedContract = { ...@@ -53,3 +53,25 @@ export const VERIFIED_CONTRACT_INFO: VerifiedContract = {
tx_count: 565058, tx_count: 565058,
verified_at: '2023-04-10T13:16:33.884921Z', verified_at: '2023-04-10T13:16:33.884921Z',
}; };
export const SOLIDITYSCAN_REPORT: SolidityscanReport = {
scan_report: {
scan_status: 'scan_done',
scan_summary: {
issue_severity_distribution: {
critical: 0,
gas: 1,
high: 0,
informational: 0,
low: 2,
medium: 0,
},
lines_analyzed_count: 18,
scan_time_taken: 1,
score: '3.61',
score_v2: '72.22',
threat_score: '94.74',
},
scanner_reference_url: 'https://solidityscan.com/quickscan/0xc1EF7811FF2ebFB74F80ed7423f2AdAA37454be2/blockscout/eth-goerli?ref=blockscout',
},
};
...@@ -108,6 +108,5 @@ export const TOKEN_INSTANCE: TokenInstance = { ...@@ -108,6 +108,5 @@ export const TOKEN_INSTANCE: TokenInstance = {
name: 'GENESIS #188882, 8a77ca1bcaa4036f. Blockchain pixel PFP NFT + "on music video" trait inspired by God', name: 'GENESIS #188882, 8a77ca1bcaa4036f. Blockchain pixel PFP NFT + "on music video" trait inspired by God',
}, },
owner: ADDRESS_PARAMS, owner: ADDRESS_PARAMS,
token: TOKEN_INFO_ERC_1155,
holder_address_hash: ADDRESS_HASH, holder_address_hash: ADDRESS_HASH,
}; };
...@@ -14,11 +14,6 @@ if [ ! -f "$config_file" ]; then ...@@ -14,11 +14,6 @@ if [ ! -f "$config_file" ]; then
exit 1 exit 1
fi fi
if [ ! -f "$secrets_file" ]; then
echo "Error: File '$secrets_file' not found."
exit 1
fi
# download assets for the running instance # download assets for the running instance
dotenv \ dotenv \
-e $config_file \ -e $config_file \
......
#!/bin/bash
# download assets for the running instance
dotenv \
-e .env.development.local \
-e .env.local \
-e .env.development \
-e .env \
-- bash -c './deploy/scripts/download_assets.sh ./public/assets'
# generate envs.js file and run the app
dotenv \
-v NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) \
-v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \
-e .env.secrets \
-e .env.development.local \
-e .env.local \
-e .env.development \
-e .env \
-- bash -c './deploy/scripts/make_envs_script.sh && next dev -- -p $NEXT_PUBLIC_APP_PORT' |
pino-pretty
\ No newline at end of file
...@@ -16,6 +16,6 @@ ...@@ -16,6 +16,6 @@
"incremental": true, "incremental": true,
"baseUrl": ".", "baseUrl": ".",
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "decs.d.ts", "global.d.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.node.ts", "**/*.tsx", "decs.d.ts", "global.d.ts"],
"exclude": ["node_modules", "node_modules_linux", "./deploy/tools/envs-validator"], "exclude": ["node_modules", "node_modules_linux", "./deploy/tools/envs-validator"],
} }
...@@ -3,7 +3,7 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -3,7 +3,7 @@ import type { Transaction } from 'types/api/transaction';
import type { UserTags } from './addressParams'; import type { UserTags } from './addressParams';
import type { Block } from './block'; import type { Block } from './block';
import type { InternalTransaction } from './internalTransaction'; import type { InternalTransaction } from './internalTransaction';
import type { TokenInfo, TokenInstance, TokenType } from './token'; import type { NFTTokenType, TokenInfo, TokenInstance, TokenType } from './token';
import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer'; import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer';
export interface Address extends UserTags { export interface Address extends UserTags {
...@@ -49,17 +49,47 @@ export interface AddressTokenBalance { ...@@ -49,17 +49,47 @@ export interface AddressTokenBalance {
token_instance: TokenInstance | null; token_instance: TokenInstance | null;
} }
export type AddressNFT = TokenInstance & {
token: TokenInfo;
token_type: Omit<TokenType, 'ERC-20'>;
value: string;
}
export type AddressCollection = {
token: TokenInfo;
amount: string;
token_instances: Array<Omit<AddressNFT, 'token'>>;
}
export interface AddressTokensResponse { export interface AddressTokensResponse {
items: Array<AddressTokenBalance>; items: Array<AddressTokenBalance>;
next_page_params: { next_page_params: {
items_count: number; items_count: number;
token_name: 'string' | null; token_name: string | null;
token_type: TokenType; token_type: TokenType;
value: number; value: number;
fiat_value: string | null; fiat_value: string | null;
} | null; } | null;
} }
export interface AddressNFTsResponse {
items: Array<AddressNFT>;
next_page_params: {
items_count: number;
token_id: string;
token_type: TokenType;
token_contract_address_hash: string;
} | null;
}
export interface AddressCollectionsResponse {
items: Array<AddressCollection>;
next_page_params: {
token_contract_address_hash: string;
token_type: TokenType;
} | null;
}
export interface AddressTokensBalancesSocketMessage { export interface AddressTokensBalancesSocketMessage {
overflow: boolean; overflow: boolean;
token_balances: Array<AddressTokenBalance>; token_balances: Array<AddressTokenBalance>;
...@@ -97,6 +127,10 @@ export type AddressTokensFilter = { ...@@ -97,6 +127,10 @@ export type AddressTokensFilter = {
type: TokenType; type: TokenType;
} }
export type AddressNFTTokensFilter = {
type: Array<NFTTokenType> | undefined;
}
export interface AddressCoinBalanceHistoryItem { export interface AddressCoinBalanceHistoryItem {
block_number: number; block_number: number;
block_timestamp: string; block_timestamp: string;
......
...@@ -156,3 +156,25 @@ export interface SmartContractVerificationError { ...@@ -156,3 +156,25 @@ export interface SmartContractVerificationError {
constructor_arguments?: Array<string>; constructor_arguments?: Array<string>;
name?: Array<string>; name?: Array<string>;
} }
export type SolidityscanReport = {
scan_report: {
scan_status: string;
scan_summary: {
issue_severity_distribution: {
critical: number;
gas: number;
high: number;
informational: number;
low: number;
medium: number;
};
lines_analyzed_count: number;
scan_time_taken: number;
score: string;
score_v2: string;
threat_score: string;
};
scanner_reference_url: string;
};
}
import type { TokenInfoApplication } from './account'; import type { TokenInfoApplication } from './account';
import type { AddressParam } from './addressParams'; import type { AddressParam } from './addressParams';
export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155'; export type NFTTokenType = 'ERC-721' | 'ERC-1155';
export type TokenType = 'ERC-20' | NFTTokenType;
export interface TokenInfo<T extends TokenType = TokenType> { export interface TokenInfo<T extends TokenType = TokenType> {
address: string; address: string;
...@@ -61,7 +62,6 @@ export interface TokenInstance { ...@@ -61,7 +62,6 @@ export interface TokenInstance {
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;
token: TokenInfo;
} }
export interface TokenInstanceTransfersCount { export interface TokenInstanceTransfersCount {
...@@ -78,3 +78,7 @@ export type TokenInventoryPagination = { ...@@ -78,3 +78,7 @@ export type TokenInventoryPagination = {
} }
export type TokenVerifiedInfo = Omit<TokenInfoApplication, 'id' | 'status'>; export type TokenVerifiedInfo = Omit<TokenInfoApplication, 'id' | 'status'>;
export type TokenInventoryFilters = {
holder_address_hash?: string;
}
import { Flex, Hide, Icon, Show, Text, Tooltip, useColorModeValue } from '@chakra-ui/react'; import { Flex, Hide, Show, Text } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
...@@ -9,7 +9,6 @@ import type { AddressFromToFilter, AddressTokenTransferResponse } from 'types/ap ...@@ -9,7 +9,6 @@ import type { AddressFromToFilter, AddressTokenTransferResponse } from 'types/ap
import type { TokenType } from 'types/api/token'; import type { TokenType } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import crossIcon from 'icons/cross.svg';
import { getResourceKey } from 'lib/api/useApiQuery'; import { getResourceKey } from 'lib/api/useApiQuery';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery'; import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery'; import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
...@@ -26,6 +25,7 @@ import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; ...@@ -26,6 +25,7 @@ import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import ResetIconButton from 'ui/shared/ResetIconButton';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice'; import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter'; import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList'; import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
...@@ -115,9 +115,6 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr ...@@ -115,9 +115,6 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr
onFilterChange({}); onFilterChange({});
}, [ onFilterChange ]); }, [ onFilterChange ]);
const resetTokenIconColor = useColorModeValue('blue.600', 'blue.300');
const resetTokenIconHoverColor = useColorModeValue('blue.400', 'blue.200');
const handleNewSocketMessage: SocketMessage.AddressTokenTransfer['handler'] = (payload) => { const handleNewSocketMessage: SocketMessage.AddressTokenTransfer['handler'] = (payload) => {
setSocketAlert(''); setSocketAlert('');
...@@ -235,19 +232,7 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr ...@@ -235,19 +232,7 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr
<Flex alignItems="center" py={ 1 }> <Flex alignItems="center" py={ 1 }>
<TokenEntity.Icon token={ tokenData } isLoading={ isPlaceholderData }/> <TokenEntity.Icon token={ tokenData } isLoading={ isPlaceholderData }/>
{ isMobile ? <HashStringShorten hash={ tokenFilter }/> : tokenFilter } { isMobile ? <HashStringShorten hash={ tokenFilter }/> : tokenFilter }
<Tooltip label="Reset filter"> <ResetIconButton onClick={ resetTokenFilter }/>
<Flex>
<Icon
as={ crossIcon }
boxSize={ 5 }
ml={ 1 }
color={ resetTokenIconColor }
cursor="pointer"
_hover={{ color: resetTokenIconHoverColor }}
onClick={ resetTokenFilter }
/>
</Flex>
</Tooltip>
</Flex> </Flex>
</Flex> </Flex>
); );
......
...@@ -13,6 +13,8 @@ import AddressTokens from './AddressTokens'; ...@@ -13,6 +13,8 @@ import AddressTokens from './AddressTokens';
const ADDRESS_HASH = addressMock.withName.hash; const ADDRESS_HASH = addressMock.withName.hash;
const API_URL_ADDRESS = buildApiUrl('address', { hash: ADDRESS_HASH }); const API_URL_ADDRESS = buildApiUrl('address', { hash: ADDRESS_HASH });
const API_URL_TOKENS = buildApiUrl('address_tokens', { hash: ADDRESS_HASH }); const API_URL_TOKENS = buildApiUrl('address_tokens', { hash: ADDRESS_HASH });
const API_URL_NFT = buildApiUrl('address_nfts', { hash: ADDRESS_HASH }) + '?type=';
const API_URL_COLLECTIONS = buildApiUrl('address_collections', { hash: ADDRESS_HASH }) + '?type=';
const nextPageParams = { const nextPageParams = {
items_count: 50, items_count: 50,
...@@ -52,6 +54,14 @@ const test = base.extend({ ...@@ -52,6 +54,14 @@ const test = base.extend({
status: 200, status: 200,
body: JSON.stringify(response1155), body: JSON.stringify(response1155),
})); }));
await page.route(API_URL_NFT, (route) => route.fulfill({
status: 200,
body: JSON.stringify(tokensMock.nfts),
}));
await page.route(API_URL_COLLECTIONS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(tokensMock.collections),
}));
use(page); use(page);
}, },
...@@ -76,10 +86,10 @@ test('erc20 +@dark-mode', async({ mount }) => { ...@@ -76,10 +86,10 @@ test('erc20 +@dark-mode', async({ mount }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('erc721 +@dark-mode', async({ mount }) => { test('collections +@dark-mode', async({ mount }) => {
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { hash: ADDRESS_HASH, tab: 'tokens_erc721' }, query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' },
isReady: true, isReady: true,
}, },
}; };
...@@ -95,10 +105,10 @@ test('erc721 +@dark-mode', async({ mount }) => { ...@@ -95,10 +105,10 @@ test('erc721 +@dark-mode', async({ mount }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('erc1155 +@dark-mode', async({ mount }) => { test('nfts +@dark-mode', async({ mount }) => {
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { hash: ADDRESS_HASH, tab: 'tokens_erc1155' }, query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' },
isReady: true, isReady: true,
}, },
}; };
...@@ -111,6 +121,8 @@ test('erc1155 +@dark-mode', async({ mount }) => { ...@@ -111,6 +121,8 @@ test('erc1155 +@dark-mode', async({ mount }) => {
{ hooksConfig }, { hooksConfig },
); );
await component.getByText('List').click();
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
...@@ -136,10 +148,10 @@ test.describe('mobile', () => { ...@@ -136,10 +148,10 @@ test.describe('mobile', () => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('erc721', async({ mount }) => { test('nfts', async({ mount }) => {
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { hash: ADDRESS_HASH, tab: 'tokens_erc721' }, query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' },
isReady: true, isReady: true,
}, },
}; };
...@@ -152,13 +164,15 @@ test.describe('mobile', () => { ...@@ -152,13 +164,15 @@ test.describe('mobile', () => {
{ hooksConfig }, { hooksConfig },
); );
await component.getByLabel('list').click();
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('erc1155', async({ mount }) => { test('collections', async({ mount }) => {
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { hash: ADDRESS_HASH, tab: 'tokens_erc1155' }, query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' },
isReady: true, isReady: true,
}, },
}; };
......
import { Box } from '@chakra-ui/react'; import { Box, HStack } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { SocketMessage } from 'lib/socket/types'; import type { NFTTokenType } from 'types/api/token';
import type { AddressTokenBalance, AddressTokensBalancesSocketMessage, AddressTokensResponse } from 'types/api/address';
import type { TokenType } from 'types/api/token';
import type { PaginationParams } from 'ui/shared/pagination/types'; import type { PaginationParams } from 'ui/shared/pagination/types';
import { getResourceKey } from 'lib/api/useApiQuery'; import listIcon from 'icons/apps.svg';
import collectionIcon from 'icons/collection.svg';
import { useAppContext } from 'lib/contexts/app';
import * as cookies from 'lib/cookies';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel'; import { NFT_TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import useSocketMessage from 'lib/socket/useSocketMessage'; import { ADDRESS_TOKEN_BALANCE_ERC_20, ADDRESS_NFT_1155, ADDRESS_COLLECTION } from 'stubs/address';
import { ADDRESS_TOKEN_BALANCE_ERC_1155, ADDRESS_TOKEN_BALANCE_ERC_20, ADDRESS_TOKEN_BALANCE_ERC_721 } from 'stubs/address';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import { tokenTabsByType } from 'ui/pages/Address'; import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import TokenTypeFilter from 'ui/shared/filters/TokenTypeFilter';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RadioButtonGroup from 'ui/shared/radioButtonGroup/RadioButtonGroup';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import ERC1155Tokens from './tokens/ERC1155Tokens'; import AddressCollections from './tokens/AddressCollections';
import AddressNFTs from './tokens/AddressNFTs';
import ERC20Tokens from './tokens/ERC20Tokens'; import ERC20Tokens from './tokens/ERC20Tokens';
import ERC721Tokens from './tokens/ERC721Tokens';
import TokenBalances from './tokens/TokenBalances'; import TokenBalances from './tokens/TokenBalances';
type TNftDisplayType = 'collection' | 'list';
const TAB_LIST_PROPS = { const TAB_LIST_PROPS = {
marginBottom: 0, my: 3,
py: 5, py: 5,
marginTop: 3,
columnGap: 3, columnGap: 3,
}; };
const TAB_LIST_PROPS_MOBILE = { const TAB_LIST_PROPS_MOBILE = {
mt: 8, my: 8,
columnGap: 3, columnGap: 3,
}; };
const tokenBalanceItemIdentityFactory = (match: AddressTokenBalance) => (item: AddressTokenBalance) => (( const getTokenFilterValue = (getFilterValuesFromQuery<NFTTokenType>).bind(null, NFT_TOKEN_TYPE_IDS);
match.token.address === item.token.address &&
match.token_id === item.token_id &&
match.token_instance?.id === item.token_instance?.id
));
const AddressTokens = () => { const AddressTokens = () => {
const router = useRouter(); const router = useRouter();
...@@ -49,6 +48,10 @@ const AddressTokens = () => { ...@@ -49,6 +48,10 @@ const AddressTokens = () => {
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
const displayTypeCookie = cookies.get(cookies.NAMES.ADDRESS_NFT_DISPLAY_TYPE, useAppContext().cookies);
const [ nftDisplayType, setNftDisplayType ] = React.useState<TNftDisplayType>(displayTypeCookie === 'list' ? 'list' : 'collection');
const [ tokenTypes, setTokenTypes ] = React.useState<Array<NFTTokenType> | undefined>(getTokenFilterValue(router.query.type) || []);
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
...@@ -58,111 +61,100 @@ const AddressTokens = () => { ...@@ -58,111 +61,100 @@ const AddressTokens = () => {
filters: { type: 'ERC-20' }, filters: { type: 'ERC-20' },
scrollRef, scrollRef,
options: { options: {
enabled: !tab || tab === 'tokens_erc20',
refetchOnMount: false, refetchOnMount: false,
placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_20, 10, { next_page_params: null }), placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_20, 10, { next_page_params: null }),
}, },
}); });
const erc721Query = useQueryWithPages({ const collectionsQuery = useQueryWithPages({
resourceName: 'address_tokens', resourceName: 'address_collections',
pathParams: { hash }, pathParams: { hash },
filters: { type: 'ERC-721' },
scrollRef, scrollRef,
options: { options: {
refetchOnMount: false, enabled: tab === 'tokens_nfts' && nftDisplayType === 'collection',
placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_721, 10, { next_page_params: null }), placeholderData: generateListStub<'address_collections'>(ADDRESS_COLLECTION, 10, { next_page_params: null }),
}, },
filters: { type: tokenTypes },
}); });
const erc1155Query = useQueryWithPages({ const nftsQuery = useQueryWithPages({
resourceName: 'address_tokens', resourceName: 'address_nfts',
pathParams: { hash }, pathParams: { hash },
filters: { type: 'ERC-1155' },
scrollRef, scrollRef,
options: { options: {
refetchOnMount: false, enabled: tab === 'tokens_nfts' && nftDisplayType === 'list',
placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_1155, 10, { next_page_params: null }), placeholderData: generateListStub<'address_nfts'>(ADDRESS_NFT_1155, 10, { next_page_params: null }),
}, },
filters: { type: tokenTypes },
}); });
const queryClient = useQueryClient(); const handleNFTsDisplayTypeChange = React.useCallback((val: TNftDisplayType) => {
cookies.set(cookies.NAMES.ADDRESS_NFT_DISPLAY_TYPE, val);
const updateTokensData = React.useCallback((type: TokenType, payload: AddressTokensBalancesSocketMessage) => { setNftDisplayType(val);
const queryKey = getResourceKey('address_tokens', { pathParams: { hash }, queryParams: { type } }); }, []);
queryClient.setQueryData(queryKey, (prevData: AddressTokensResponse | undefined) => { const handleTokenTypesChange = React.useCallback((value: Array<NFTTokenType>) => {
const items = prevData?.items.map((currentItem) => { nftsQuery.onFilterChange({ type: value });
const updatedData = payload.token_balances.find(tokenBalanceItemIdentityFactory(currentItem)); collectionsQuery.onFilterChange({ type: value });
return updatedData ?? currentItem; setTokenTypes(value);
}) || []; }, [ nftsQuery, collectionsQuery ]);
const extraItems = prevData?.next_page_params ? const nftTypeFilter = (
[] : <PopoverFilter isActive={ tokenTypes && tokenTypes.length > 0 } contentProps={{ w: '200px' }} appliedFiltersNum={ tokenTypes?.length }>
payload.token_balances.filter((socketItem) => !items.some(tokenBalanceItemIdentityFactory(socketItem))); <TokenTypeFilter<NFTTokenType> nftOnly onChange={ handleTokenTypesChange } defaultValue={ tokenTypes }/>
</PopoverFilter>
if (!prevData) { );
return {
items: extraItems,
next_page_params: null,
};
}
return {
items: items.concat(extraItems),
next_page_params: prevData.next_page_params,
};
});
}, [ hash, queryClient ]);
const handleTokenBalancesErc20Message: SocketMessage.AddressTokenBalancesErc20['handler'] = React.useCallback((payload) => {
updateTokensData('ERC-20', payload);
}, [ updateTokensData ]);
const handleTokenBalancesErc721Message: SocketMessage.AddressTokenBalancesErc721['handler'] = React.useCallback((payload) => {
updateTokensData('ERC-721', payload);
}, [ updateTokensData ]);
const handleTokenBalancesErc1155Message: SocketMessage.AddressTokenBalancesErc1155['handler'] = React.useCallback((payload) => {
updateTokensData('ERC-1155', payload);
}, [ updateTokensData ]);
const channel = useSocketChannel({
topic: `addresses:${ hash.toLowerCase() }`,
isDisabled: erc20Query.isPlaceholderData || erc721Query.isPlaceholderData || erc1155Query.isPlaceholderData,
});
useSocketMessage({ const hasActiveFilters = Boolean(tokenTypes?.length);
channel,
event: 'updated_token_balances_erc_20',
handler: handleTokenBalancesErc20Message,
});
useSocketMessage({
channel,
event: 'updated_token_balances_erc_721',
handler: handleTokenBalancesErc721Message,
});
useSocketMessage({
channel,
event: 'updated_token_balances_erc_1155',
handler: handleTokenBalancesErc1155Message,
});
const tabs = [ const tabs = [
{ id: tokenTabsByType['ERC-20'], title: 'ERC-20', component: <ERC20Tokens tokensQuery={ erc20Query }/> }, { id: 'tokens_erc20', title: 'ERC-20', component: <ERC20Tokens tokensQuery={ erc20Query }/> },
{ id: tokenTabsByType['ERC-721'], title: 'ERC-721', component: <ERC721Tokens tokensQuery={ erc721Query }/> }, {
{ id: tokenTabsByType['ERC-1155'], title: 'ERC-1155', component: <ERC1155Tokens tokensQuery={ erc1155Query }/> }, id: 'tokens_nfts',
title: 'NFTs',
component: nftDisplayType === 'list' ?
<AddressNFTs tokensQuery={ nftsQuery } hasActiveFilters={ hasActiveFilters }/> :
<AddressCollections collectionsQuery={ collectionsQuery } address={ hash } hasActiveFilters={ hasActiveFilters }/>,
},
]; ];
const nftDisplayTypeRadio = (
<RadioButtonGroup<TNftDisplayType>
onChange={ handleNFTsDisplayTypeChange }
defaultValue={ nftDisplayType }
name="type"
options={ [
{ title: 'By collection', value: 'collection', icon: collectionIcon, onlyIcon: isMobile },
{ title: 'List', value: 'list', icon: listIcon, onlyIcon: isMobile },
] }
/>
);
let pagination: PaginationParams | undefined; let pagination: PaginationParams | undefined;
if (tab === tokenTabsByType['ERC-1155']) { if (tab === 'tokens_nfts') {
pagination = erc1155Query.pagination; pagination = nftDisplayType === 'list' ? nftsQuery.pagination : collectionsQuery.pagination;
} else if (tab === tokenTabsByType['ERC-721']) {
pagination = erc721Query.pagination;
} else { } else {
pagination = erc20Query.pagination; pagination = erc20Query.pagination;
} }
const hasNftData =
(!nftsQuery.isPlaceholderData && nftsQuery.data?.items.length) ||
(!collectionsQuery.isPlaceholderData && collectionsQuery.data?.items.length);
const isNftTab = tab !== 'tokens' && tab !== 'tokens_erc20';
const rightSlot = (
<>
<HStack spacing={ 3 }>
{ isNftTab && (hasNftData || hasActiveFilters) && nftDisplayTypeRadio }
{ isNftTab && (hasNftData || hasActiveFilters) && nftTypeFilter }
</HStack>
{ pagination.isVisible && !isMobile && <Pagination { ...pagination }/> }
</>
);
return ( return (
<> <>
<TokenBalances/> <TokenBalances/>
...@@ -174,7 +166,8 @@ const AddressTokens = () => { ...@@ -174,7 +166,8 @@ const AddressTokens = () => {
colorScheme="gray" colorScheme="gray"
size="sm" size="sm"
tabListProps={ isMobile ? TAB_LIST_PROPS_MOBILE : TAB_LIST_PROPS } tabListProps={ isMobile ? TAB_LIST_PROPS_MOBILE : TAB_LIST_PROPS }
rightSlot={ pagination.isVisible && !isMobile ? <Pagination { ...pagination }/> : null } rightSlot={ rightSlot }
rightSlotProps={ tab !== 'tokens_erc20' && !isMobile ? { flexGrow: 1, display: 'flex', justifyContent: 'space-between', ml: 8 } : {} }
stickyEnabled={ !isMobile } stickyEnabled={ !isMobile }
/> />
</> </>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as solidityscanReportMock from 'mocks/contract/solidityscanReport';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import SolidityscanReport from './SolidityscanReport';
const addressHash = 'hash';
const REPORT_API_URL = buildApiUrl('contract_solidityscan_report', { hash: addressHash });
test('average report +@dark-mode +@mobile', async({ mount, page }) => {
await page.route(REPORT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(solidityscanReportMock.solidityscanReportAverage),
}));
const component = await mount(
<TestApp>
<SolidityscanReport hash={ addressHash }/>
</TestApp>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 100, height: 50 } });
await component.getByLabel('SolidityScan score').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 500 } });
});
test('great report', async({ mount, page }) => {
await page.route(REPORT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(solidityscanReportMock.solidityscanReportGreat),
}));
const component = await mount(
<TestApp>
<SolidityscanReport hash={ addressHash }/>
</TestApp>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 100, height: 50 } });
await component.getByLabel('SolidityScan score').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 500 } });
});
test('low report', async({ mount, page }) => {
await page.route(REPORT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(solidityscanReportMock.solidityscanReportLow),
}));
const component = await mount(
<TestApp>
<SolidityscanReport hash={ addressHash }/>
</TestApp>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 100, height: 50 } });
await component.getByLabel('SolidityScan score').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 500 } });
});
import {
Box,
Flex,
Text,
Grid,
Button,
Icon,
chakra,
Popover,
PopoverTrigger,
PopoverBody,
PopoverContent,
useDisclosure,
Skeleton,
Center,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
import { SolidityscanReport } from 'types/api/contract';
import scoreNotOkIcon from 'icons/score/score-not-ok.svg';
import scoreOkIcon from 'icons/score/score-ok.svg';
import useApiQuery from 'lib/api/useApiQuery';
import { SOLIDITYSCAN_REPORT } from 'stubs/contract';
import LinkExternal from 'ui/shared/LinkExternal';
type DistributionItem = {
id: keyof SolidityscanReport['scan_report']['scan_summary']['issue_severity_distribution'];
name: string;
color: string;
}
const DISTRIBUTION_ITEMS: Array<DistributionItem> = [
{ id: 'critical', name: 'Critical', color: '#891F11' },
{ id: 'high', name: 'High', color: '#EC672C' },
{ id: 'medium', name: 'Medium', color: '#FBE74D' },
{ id: 'low', name: 'Low', color: '#68C88E' },
{ id: 'informational', name: 'Informational', color: '#A3AEBE' },
{ id: 'gas', name: 'Gas', color: '#A47585' },
];
interface Props {
className?: string;
hash: string;
}
const SolidityscanReport = ({ className, hash }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const { data, isPlaceholderData, isError } = useApiQuery('contract_solidityscan_report', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: SOLIDITYSCAN_REPORT,
},
});
const score = Number(data?.scan_report.scan_summary.score_v2);
const bgBar = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const chartGrayColor = useColorModeValue('gray.100', 'gray.700');
const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500');
const popoverBgColor = useColorModeValue('white', 'gray.900');
if (isError || !score) {
return null;
}
let scoreColor;
let scoreLevel;
if (score >= 80) {
scoreColor = 'green.600';
scoreLevel = 'GREAT';
} else if (score >= 30) {
scoreColor = 'orange.600';
scoreLevel = 'AVERAGE';
} else {
scoreColor = 'red.600';
scoreLevel = 'LOW';
}
const vulnerabilities = data?.scan_report.scan_summary.issue_severity_distribution;
const vulnerabilitiesCounts = vulnerabilities ? Object.values(vulnerabilities) : [];
const vulnerabilitiesCount = vulnerabilitiesCounts.reduce((acc, val) => acc + val, 0);
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Skeleton isLoaded={ !isPlaceholderData }>
<Button
className={ className }
color={ scoreColor }
borderColor={ scoreColor }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
aria-label="SolidityScan score"
fontWeight={ 500 }
px={ 2 }
h="32px"
flexShrink={ 0 }
>
<Icon as={ score < 80 ? scoreNotOkIcon : scoreOkIcon } boxSize={ 5 } mr={ 1 }/>
{ score }
</Button>
</Skeleton>
</PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '328px' }}>
<PopoverBody px="26px" py="20px" fontSize="sm">
<Box mb={ 5 }>Contract analyzed for 140+ vulnerability patterns by SolidityScan</Box>
<Flex alignItems="center" mb={ 5 }>
<Box
w={ 12 }
h={ 12 }
bgGradient={ `conic-gradient(${ scoreColor } 0, ${ scoreColor } ${ score }%, ${ chartGrayColor } 0, ${ chartGrayColor } 100%)` }
borderRadius="24px"
position="relative"
mr={ 3 }
>
<Center position="absolute" w="38px" h="38px" top="5px" right="5px" bg={ popoverBgColor } borderRadius="20px">
<Icon as={ score < 80 ? scoreNotOkIcon : scoreOkIcon } boxSize={ 5 } color={ scoreColor }/>
</Center>
</Box>
<Box>
<Flex>
<Text color={ scoreColor } fontSize="lg" fontWeight={ 500 }>{ score }</Text>
<Text color={ yetAnotherGrayColor } fontSize="lg" fontWeight={ 500 } whiteSpace="pre"> / 100</Text>
</Flex>
<Text color={ scoreColor } fontWeight={ 500 }>Security score is { scoreLevel }</Text>
</Box>
</Flex>
{ vulnerabilities && vulnerabilitiesCount > 0 && (
<Box mb={ 5 }>
<Text py="7px" variant="secondary" fontSize="xs" fontWeight={ 500 }>Vulnerabilities distribution</Text>
<Grid templateColumns="20px 1fr 100px" alignItems="center" rowGap={ 2 }>
{ DISTRIBUTION_ITEMS.map(item => (
<>
<Box w={ 3 } h={ 3 } bg={ item.color } borderRadius="6px" mr={ 2 }></Box>
<Flex justifyContent="space-between" mr={ 3 }>
<Text>{ item.name }</Text>
<Text color={ vulnerabilities[item.id] > 0 ? 'text' : yetAnotherGrayColor }>{ vulnerabilities[item.id] }</Text>
</Flex>
<Box bg={ bgBar } h="10px" borderRadius="8px">
<Box bg={ item.color } w={ vulnerabilities[item.id] / vulnerabilitiesCount } h="10px" borderRadius="8px"/>
</Box>
</>
)) }
</Grid>
</Box>
) }
<LinkExternal href={ data?.scan_report.scanner_reference_url }>View full report</LinkExternal>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default chakra(SolidityscanReport);
...@@ -9,7 +9,7 @@ export const getNativeCoinValue = (value: string | Array<unknown>) => { ...@@ -9,7 +9,7 @@ export const getNativeCoinValue = (value: string | Array<unknown>) => {
return BigInt(0); return BigInt(0);
} }
return BigInt(Number(_value)); return BigInt(_value);
}; };
export const addZeroesAllowed = (valueType: string) => { export const addZeroesAllowed = (valueType: string) => {
......
...@@ -2,10 +2,8 @@ import { Flex } from '@chakra-ui/react'; ...@@ -2,10 +2,8 @@ import { Flex } from '@chakra-ui/react';
import { test as base, expect, devices } from '@playwright/experimental-ct-react'; import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as coinBalanceMock from 'mocks/address/coinBalanceHistory';
import * as tokensMock from 'mocks/address/tokens'; import * as tokensMock from 'mocks/address/tokens';
import { tokenInfoERC20a } from 'mocks/tokens/tokenInfo'; import { tokenInfoERC20a } from 'mocks/tokens/tokenInfo';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
import MockAddressPage from 'ui/address/testUtils/MockAddressPage'; import MockAddressPage from 'ui/address/testUtils/MockAddressPage';
...@@ -175,76 +173,3 @@ base('long values', async({ mount, page }) => { ...@@ -175,76 +173,3 @@ base('long values', async({ mount, page }) => {
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA }); await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
}); });
test.describe('socket', () => {
const testWithSocket = test.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
testWithSocket.describe.configure({ mode: 'serial' });
testWithSocket('new item after token balance update', async({ page, mount, createSocket }) => {
await mount(
<TestApp withSocket>
<MockAddressPage>
<Flex>
<TokenSelect/>
</Flex>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
await page.route(TOKENS_ERC20_API_URL, async(route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
...tokensMock.erc20List.items,
tokensMock.erc20d,
],
}),
}), { times: 1 });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:1');
socketServer.sendMessage(socket, channel, 'token_balance', {
block_number: 1,
});
const button = page.getByRole('button', { name: /select/i });
const text = await button.innerText();
expect(text).toContain('10');
});
testWithSocket('new item after coin balance update', async({ page, mount, createSocket }) => {
await mount(
<TestApp withSocket>
<MockAddressPage>
<Flex>
<TokenSelect/>
</Flex>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
await page.route(TOKENS_ERC20_API_URL, async(route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
...tokensMock.erc20List.items,
tokensMock.erc20d,
],
}),
}), { times: 1 });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:1');
socketServer.sendMessage(socket, channel, 'coin_balance', {
coin_balance: coinBalanceMock.base,
});
const button = page.getByRole('button', { name: /select/i });
const text = await button.innerText();
expect(text).toContain('10');
});
});
...@@ -5,7 +5,6 @@ import NextLink from 'next/link'; ...@@ -5,7 +5,6 @@ import NextLink from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { Address } from 'types/api/address'; import type { Address } from 'types/api/address';
import walletIcon from 'icons/wallet.svg'; import walletIcon from 'icons/wallet.svg';
...@@ -13,8 +12,6 @@ import { getResourceKey } from 'lib/api/useApiQuery'; ...@@ -13,8 +12,6 @@ import { getResourceKey } from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import useFetchTokens from '../utils/useFetchTokens'; import useFetchTokens from '../utils/useFetchTokens';
import TokenSelectDesktop from './TokenSelectDesktop'; import TokenSelectDesktop from './TokenSelectDesktop';
...@@ -28,14 +25,13 @@ const TokenSelect = ({ onClick }: Props) => { ...@@ -28,14 +25,13 @@ const TokenSelect = ({ onClick }: Props) => {
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [ blockNumber, setBlockNumber ] = React.useState<number>();
const addressHash = getQueryParamString(router.query.hash); const addressHash = getQueryParamString(router.query.hash);
const addressResourceKey = getResourceKey('address', { pathParams: { hash: addressHash } }); const addressResourceKey = getResourceKey('address', { pathParams: { hash: addressHash } });
const addressQueryData = queryClient.getQueryData<Address>(addressResourceKey); const addressQueryData = queryClient.getQueryData<Address>(addressResourceKey);
const { data, isError, isPending, refetch } = useFetchTokens({ hash: addressQueryData?.hash }); const { data, isError, isPending } = useFetchTokens({ hash: addressQueryData?.hash });
const tokensResourceKey = getResourceKey('address_tokens', { pathParams: { hash: addressQueryData?.hash }, queryParams: { type: 'ERC-20' } }); const tokensResourceKey = getResourceKey('address_tokens', { pathParams: { hash: addressQueryData?.hash }, queryParams: { type: 'ERC-20' } });
const tokensIsFetching = useIsFetching({ queryKey: tokensResourceKey }); const tokensIsFetching = useIsFetching({ queryKey: tokensResourceKey });
...@@ -44,34 +40,6 @@ const TokenSelect = ({ onClick }: Props) => { ...@@ -44,34 +40,6 @@ const TokenSelect = ({ onClick }: Props) => {
onClick?.(); onClick?.();
}, [ onClick ]); }, [ onClick ]);
const handleTokenBalanceMessage: SocketMessage.AddressTokenBalance['handler'] = React.useCallback((payload) => {
if (payload.block_number !== blockNumber) {
refetch();
setBlockNumber(payload.block_number);
}
}, [ blockNumber, refetch ]);
const handleCoinBalanceMessage: SocketMessage.AddressCoinBalance['handler'] = React.useCallback((payload) => {
if (payload.coin_balance.block_number !== blockNumber) {
refetch();
setBlockNumber(payload.coin_balance.block_number);
}
}, [ blockNumber, refetch ]);
const channel = useSocketChannel({
topic: `addresses:${ addressQueryData?.hash.toLowerCase() }`,
isDisabled: !addressQueryData,
});
useSocketMessage({
channel,
event: 'coin_balance',
handler: handleCoinBalanceMessage,
});
useSocketMessage({
channel,
event: 'token_balance',
handler: handleTokenBalanceMessage,
});
if (isPending) { if (isPending) {
return ( return (
<Flex columnGap={ 3 }> <Flex columnGap={ 3 }>
......
import { Box, Flex, Text, Grid, HStack, Skeleton } from '@chakra-ui/react';
import React from 'react';
import { route } from 'nextjs-routes';
import useIsMobile from 'lib/hooks/useIsMobile';
import { apos } from 'lib/html-entities';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import LinkInternal from 'ui/shared/LinkInternal';
import NftFallback from 'ui/shared/nft/NftFallback';
import Pagination from 'ui/shared/pagination/Pagination';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import NFTItem from './NFTItem';
import NFTItemContainer from './NFTItemContainer';
type Props = {
collectionsQuery: QueryWithPagesResult<'address_collections'>;
address: string;
hasActiveFilters: boolean;
}
const AddressCollections = ({ collectionsQuery, address, hasActiveFilters }: Props) => {
const isMobile = useIsMobile();
const { isError, isPlaceholderData, data, pagination } = collectionsQuery;
const actionBar = isMobile && pagination.isVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
const content = data?.items ? data?.items.map((item, index) => {
const collectionUrl = route({
pathname: '/token/[hash]',
query: {
hash: item.token.address,
tab: 'inventory',
holder_address_hash: address,
scroll_to_tabs: 'true',
},
});
const hasOverload = Number(item.amount) > item.token_instances.length;
return (
<Box key={ item.token.address + index } mb={ 6 }>
<Flex mb={ 3 } flexWrap="wrap" lineHeight="30px">
<TokenEntity
width="auto"
noSymbol
token={ item.token }
isLoading={ isPlaceholderData }
noCopy
fontWeight="600"
/>
<Skeleton isLoaded={ !isPlaceholderData } mr={ 3 }>
<Text variant="secondary" whiteSpace="pre">{ ` - ${ Number(item.amount).toLocaleString() } item${ Number(item.amount) > 1 ? 's' : '' }` }</Text>
</Skeleton>
<LinkInternal href={ collectionUrl } isLoading={ isPlaceholderData }>
<Skeleton isLoaded={ !isPlaceholderData }>View in collection</Skeleton>
</LinkInternal>
</Flex>
<Grid
w="100%"
mb={ 7 }
columnGap={{ base: 3, lg: 6 }}
rowGap={{ base: 3, lg: 6 }}
gridTemplateColumns={{ base: 'repeat(2, calc((100% - 12px)/2))', lg: 'repeat(auto-fill, minmax(210px, 1fr))' }}
>
{ item.token_instances.map((instance, index) => {
const key = item.token.address + '_' + (instance.id && !isPlaceholderData ? `id_${ instance.id }` : `index_${ index }`);
return (
<NFTItem
key={ key }
{ ...instance }
token={ item.token }
isLoading={ isPlaceholderData }
/>
);
}) }
{ hasOverload && (
<LinkInternal display="flex" href={ collectionUrl }>
<NFTItemContainer display="flex" alignItems="center" justifyContent="center" flexDirection="column" minH="248px">
<HStack gap={ 2 } mb={ 3 }>
<NftFallback bgColor="unset" w="30px" h="30px" boxSize="30px" p={ 0 }/>
<NftFallback bgColor="unset" w="30px" h="30px" boxSize="30px" p={ 0 }/>
<NftFallback bgColor="unset" w="30px" h="30px" boxSize="30px" p={ 0 }/>
</HStack>
View all NFTs
</NFTItemContainer>
</LinkInternal>
) }
</Grid>
</Box>
);
}) : null;
return (
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no tokens of selected type."
content={ content }
actionBar={ actionBar }
filterProps={{
emptyFilteredText: `Couldn${ apos }t find any token that matches your query.`,
hasActiveFilters,
}}
/>
);
};
export default AddressCollections;
...@@ -2,6 +2,7 @@ import { Grid } from '@chakra-ui/react'; ...@@ -2,6 +2,7 @@ import { Grid } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import { apos } from 'lib/html-entities';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
...@@ -10,10 +11,11 @@ import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPage ...@@ -10,10 +11,11 @@ import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPage
import NFTItem from './NFTItem'; import NFTItem from './NFTItem';
type Props = { type Props = {
tokensQuery: QueryWithPagesResult<'address_tokens'>; tokensQuery: QueryWithPagesResult<'address_nfts'>;
hasActiveFilters: boolean;
} }
const ERC1155Tokens = ({ tokensQuery }: Props) => { const AddressNFTs = ({ tokensQuery, hasActiveFilters }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { isError, isPlaceholderData, data, pagination } = tokensQuery; const { isError, isPlaceholderData, data, pagination } = tokensQuery;
...@@ -32,13 +34,14 @@ const ERC1155Tokens = ({ tokensQuery }: Props) => { ...@@ -32,13 +34,14 @@ const ERC1155Tokens = ({ tokensQuery }: Props) => {
gridTemplateColumns={{ base: 'repeat(2, calc((100% - 12px)/2))', lg: 'repeat(auto-fill, minmax(210px, 1fr))' }} gridTemplateColumns={{ base: 'repeat(2, calc((100% - 12px)/2))', lg: 'repeat(auto-fill, minmax(210px, 1fr))' }}
> >
{ data.items.map((item, index) => { { data.items.map((item, index) => {
const key = item.token.address + '_' + (item.token_instance?.id && !isPlaceholderData ? `id_${ item.token_instance?.id }` : `index_${ index }`); const key = item.token.address + '_' + (item.id && !isPlaceholderData ? `id_${ item.id }` : `index_${ index }`);
return ( return (
<NFTItem <NFTItem
key={ key } key={ key }
{ ...item } { ...item }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
withTokenLink
/> />
); );
}) } }) }
...@@ -52,8 +55,12 @@ const ERC1155Tokens = ({ tokensQuery }: Props) => { ...@@ -52,8 +55,12 @@ const ERC1155Tokens = ({ tokensQuery }: Props) => {
emptyText="There are no tokens of selected type." emptyText="There are no tokens of selected type."
content={ content } content={ content }
actionBar={ actionBar } actionBar={ actionBar }
filterProps={{
emptyFilteredText: `Couldn${ apos }t find any token that matches your query.`,
hasActiveFilters,
}}
/> />
); );
}; };
export default ERC1155Tokens; export default AddressNFTs;
import { Show, Hide } from '@chakra-ui/react';
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import ERC721TokensListItem from './ERC721TokensListItem';
import ERC721TokensTable from './ERC721TokensTable';
type Props = {
tokensQuery: QueryWithPagesResult<'address_tokens'>;
}
const ERC721Tokens = ({ tokensQuery }: Props) => {
const isMobile = useIsMobile();
const { isError, isPlaceholderData, data, pagination } = tokensQuery;
const actionBar = isMobile && pagination.isVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
const content = data?.items ? (
<>
<Hide below="lg" ssr={ false }><ERC721TokensTable data={ data.items } isLoading={ isPlaceholderData } top={ pagination.isVisible ? 72 : 0 }/></Hide>
<Show below="lg" ssr={ false }>{ data.items.map((item, index) => (
<ERC721TokensListItem
key={ item.token.address + (isPlaceholderData ? index : '') }
{ ...item }
isLoading={ isPlaceholderData }
/>
)) }</Show></>
) : null;
return (
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no tokens of selected type."
content={ content }
actionBar={ actionBar }
/>
);
};
export default ERC721Tokens;
import { Flex, HStack, Skeleton } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntityWithAddressFilter from 'ui/shared/entities/token/TokenEntityWithAddressFilter';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
type Props = AddressTokenBalance & { isLoading: boolean};
const ERC721TokensListItem = ({ token, value, isLoading }: Props) => {
const router = useRouter();
const hash = router.query.hash?.toString() || '';
return (
<ListItemMobile rowGap={ 2 }>
<TokenEntityWithAddressFilter
token={ token }
isLoading={ isLoading }
addressHash={ hash }
noCopy
jointSymbol
fontWeight={ 700 }
/>
<Flex alignItems="center" pl={ 8 }>
<AddressEntity
address={{ hash: token.address }}
isLoading={ isLoading }
truncation="constant"
noIcon
/>
<AddressAddToWallet token={ token } ml={ 2 } isLoading={ isLoading }/>
</Flex>
<HStack spacing={ 3 }>
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Quantity</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary">
<span>{ value }</span>
</Skeleton>
</HStack>
</ListItemMobile>
);
};
export default ERC721TokensListItem;
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import { default as Thead } from 'ui/shared/TheadSticky';
import ERC721TokensTableItem from './ERC721TokensTableItem';
interface Props {
data: Array<AddressTokenBalance>;
top: number;
isLoading: boolean;
}
const ERC721TokensTable = ({ data, top, isLoading }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ top }>
<Tr>
<Th width="40%">Asset</Th>
<Th width="40%">Contract address</Th>
<Th width="20%" isNumeric>Quantity</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item, index) => (
<ERC721TokensTableItem key={ item.token.address + (isLoading ? index : '') } { ...item } isLoading={ isLoading }/>
)) }
</Tbody>
</Table>
);
};
export default ERC721TokensTable;
import { Tr, Td, Flex, Skeleton } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntityWithAddressFilter from 'ui/shared/entities/token/TokenEntityWithAddressFilter';
type Props = AddressTokenBalance & { isLoading: boolean};
const ERC721TokensTableItem = ({
token,
value,
isLoading,
}: Props) => {
const router = useRouter();
const hash = router.query.hash?.toString() || '';
return (
<Tr>
<Td verticalAlign="middle">
<TokenEntityWithAddressFilter
token={ token }
addressHash={ hash }
isLoading={ isLoading }
noCopy
jointSymbol
fontWeight="700"
/>
</Td>
<Td verticalAlign="middle">
<Flex alignItems="center" width="150px" justifyContent="space-between">
<AddressEntity
address={{ hash: token.address }}
isLoading={ isLoading }
noIcon
/>
<AddressAddToWallet token={ token } ml={ 4 } isLoading={ isLoading }/>
</Flex>
</Td>
<Td isNumeric verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ value }
</Skeleton>
</Td>
</Tr>
);
};
export default React.memo(ERC721TokensTableItem);
import { Box, Flex, Text, Link, useColorModeValue } from '@chakra-ui/react'; import { Tag, Flex, Text, Link, Skeleton, LightMode } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { AddressTokenBalance } from 'types/api/address'; import type { AddressNFT } from 'types/api/address';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -9,22 +9,20 @@ import NftEntity from 'ui/shared/entities/nft/NftEntity'; ...@@ -9,22 +9,20 @@ import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import NftMedia from 'ui/shared/nft/NftMedia'; import NftMedia from 'ui/shared/nft/NftMedia';
type Props = AddressTokenBalance & { isLoading: boolean }; import NFTItemContainer from './NFTItemContainer';
const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance, isLoading }: Props) => { type Props = AddressNFT & { isLoading: boolean; withTokenLink?: boolean };
const tokenInstanceLink = tokenId ? route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: tokenId } }) : undefined;
const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: Props) => {
const tokenInstanceLink = tokenInstance.id ?
route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: tokenInstance.id } }) :
undefined;
return ( return (
<Box <NFTItemContainer position="relative">
w={{ base: '100%', lg: '210px' }} <Skeleton isLoaded={ !isLoading }>
border="1px solid" <LightMode><Tag background="gray.50" zIndex={ 1 } position="absolute" top="18px" right="18px">{ token.type }</Tag></LightMode>
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') } </Skeleton>
borderRadius="12px"
p="10px"
fontSize="sm"
fontWeight={ 500 }
lineHeight="20px"
>
<Link href={ isLoading ? undefined : tokenInstanceLink }> <Link href={ isLoading ? undefined : tokenInstanceLink }>
<NftMedia <NftMedia
mb="18px" mb="18px"
...@@ -32,19 +30,25 @@ const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance, isLo ...@@ -32,19 +30,25 @@ const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance, isLo
isLoading={ isLoading } isLoading={ isLoading }
/> />
</Link> </Link>
{ tokenId && ( <Flex justifyContent="space-between" w="100%">
<Flex mb={ 2 } ml={ 1 }> <Flex ml={ 1 } overflow="hidden">
<Text whiteSpace="pre" variant="secondary">ID# </Text> <Text whiteSpace="pre" variant="secondary">ID# </Text>
<NftEntity hash={ token.address } id={ tokenId } isLoading={ isLoading } noIcon/> <NftEntity hash={ token.address } id={ tokenInstance.id } isLoading={ isLoading } noIcon/>
</Flex> </Flex>
<Skeleton isLoaded={ !isLoading }>
{ Number(value) > 1 && <Flex><Text variant="secondary" whiteSpace="pre">Qty </Text>{ value }</Flex> }
</Skeleton>
</Flex>
{ withTokenLink && (
<TokenEntity
mt={ 2 }
token={ token }
isLoading={ isLoading }
noCopy
noSymbol
/>
) } ) }
<TokenEntity </NFTItemContainer>
token={ token }
isLoading={ isLoading }
noCopy
noSymbol
/>
</Box>
); );
}; };
......
import { Box, useColorModeValue, chakra } from '@chakra-ui/react';
import React from 'react';
type Props = {
children: React.ReactNode;
className?: string;
};
const NFTItemContainer = ({ children, className }: Props) => {
return (
<Box
w={{ base: '100%', lg: '210px' }}
border="1px solid"
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
borderRadius="12px"
p="10px"
fontSize="sm"
fontWeight={ 500 }
lineHeight="20px"
className={ className }
>
{ children }
</Box>
);
};
export default chakra(NFTItemContainer);
import { useQueryClient } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import useApiQuery from 'lib/api/useApiQuery'; import type { SocketMessage } from 'lib/socket/types';
import type { AddressTokenBalance, AddressTokensBalancesSocketMessage, AddressTokensResponse } from 'types/api/address';
import type { TokenType } from 'types/api/token';
import { calculateUsdValue } from './tokenUtils'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { calculateUsdValue } from './tokenUtils';
interface Props { interface Props {
hash?: string; hash?: string;
} }
const tokenBalanceItemIdentityFactory = (match: AddressTokenBalance) => (item: AddressTokenBalance) => ((
match.token.address === item.token.address &&
match.token_id === item.token_id &&
match.token_instance?.id === item.token_instance?.id
));
export default function useFetchTokens({ hash }: Props) { export default function useFetchTokens({ hash }: Props) {
const erc20query = useApiQuery('address_tokens', { const erc20query = useApiQuery('address_tokens', {
pathParams: { hash }, pathParams: { hash },
...@@ -25,11 +37,67 @@ export default function useFetchTokens({ hash }: Props) { ...@@ -25,11 +37,67 @@ export default function useFetchTokens({ hash }: Props) {
queryOptions: { enabled: Boolean(hash), refetchOnMount: false }, queryOptions: { enabled: Boolean(hash), refetchOnMount: false },
}); });
const refetch = React.useCallback(() => { const queryClient = useQueryClient();
erc20query.refetch();
erc721query.refetch(); const updateTokensData = React.useCallback((type: TokenType, payload: AddressTokensBalancesSocketMessage) => {
erc1155query.refetch(); const queryKey = getResourceKey('address_tokens', { pathParams: { hash }, queryParams: { type } });
}, [ erc1155query, erc20query, erc721query ]);
queryClient.setQueryData(queryKey, (prevData: AddressTokensResponse | undefined) => {
const items = prevData?.items.map((currentItem) => {
const updatedData = payload.token_balances.find(tokenBalanceItemIdentityFactory(currentItem));
return updatedData ?? currentItem;
}) || [];
const extraItems = prevData?.next_page_params ?
[] :
payload.token_balances.filter((socketItem) => !items.some(tokenBalanceItemIdentityFactory(socketItem)));
if (!prevData) {
return {
items: extraItems,
next_page_params: null,
};
}
return {
items: items.concat(extraItems),
next_page_params: prevData.next_page_params,
};
});
}, [ hash, queryClient ]);
const handleTokenBalancesErc20Message: SocketMessage.AddressTokenBalancesErc20['handler'] = React.useCallback((payload) => {
updateTokensData('ERC-20', payload);
}, [ updateTokensData ]);
const handleTokenBalancesErc721Message: SocketMessage.AddressTokenBalancesErc721['handler'] = React.useCallback((payload) => {
updateTokensData('ERC-721', payload);
}, [ updateTokensData ]);
const handleTokenBalancesErc1155Message: SocketMessage.AddressTokenBalancesErc1155['handler'] = React.useCallback((payload) => {
updateTokensData('ERC-1155', payload);
}, [ updateTokensData ]);
const channel = useSocketChannel({
topic: `addresses:${ hash?.toLowerCase() }`,
isDisabled: Boolean(hash) && (erc20query.isPlaceholderData || erc721query.isPlaceholderData || erc1155query.isPlaceholderData),
});
useSocketMessage({
channel,
event: 'updated_token_balances_erc_20',
handler: handleTokenBalancesErc20Message,
});
useSocketMessage({
channel,
event: 'updated_token_balances_erc_721',
handler: handleTokenBalancesErc721Message,
});
useSocketMessage({
channel,
event: 'updated_token_balances_erc_1155',
handler: handleTokenBalancesErc1155Message,
});
const data = React.useMemo(() => { const data = React.useMemo(() => {
return { return {
...@@ -52,6 +120,5 @@ export default function useFetchTokens({ hash }: Props) { ...@@ -52,6 +120,5 @@ export default function useFetchTokens({ hash }: Props) {
isPending: erc20query.isPending || erc721query.isPending || erc1155query.isPending, isPending: erc20query.isPending || erc721query.isPending || erc1155query.isPending,
isError: erc20query.isError || erc721query.isError || erc1155query.isError, isError: erc20query.isError || erc721query.isError || erc1155query.isError,
data, data,
refetch,
}; };
} }
...@@ -77,7 +77,7 @@ const Stats = () => { ...@@ -77,7 +77,7 @@ const Stats = () => {
<StatsItem <StatsItem
icon={ clockIcon } icon={ clockIcon }
title="Average block time" title="Average block time"
value={ `${ (data.average_block_time / 1000).toFixed(1) } s` } value={ `${ (data.average_block_time / 1000).toFixed(1) }s` }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
/> />
) } ) }
......
...@@ -3,13 +3,11 @@ import React from 'react'; ...@@ -3,13 +3,11 @@ import React from 'react';
import type { TimeChartData } from 'ui/shared/chart/types'; import type { TimeChartData } from 'ui/shared/chart/types';
import useClientRect from 'lib/hooks/useClientRect';
import ChartArea from 'ui/shared/chart/ChartArea'; import ChartArea from 'ui/shared/chart/ChartArea';
import ChartLine from 'ui/shared/chart/ChartLine'; import ChartLine from 'ui/shared/chart/ChartLine';
import ChartOverlay from 'ui/shared/chart/ChartOverlay'; import ChartOverlay from 'ui/shared/chart/ChartOverlay';
import ChartTooltip from 'ui/shared/chart/ChartTooltip'; import ChartTooltip from 'ui/shared/chart/ChartTooltip';
import useTimeChartController from 'ui/shared/chart/useTimeChartController'; import useTimeChartController from 'ui/shared/chart/useTimeChartController';
import calculateInnerSize from 'ui/shared/chart/utils/calculateInnerSize';
interface Props { interface Props {
data: TimeChartData; data: TimeChartData;
...@@ -22,12 +20,17 @@ const ChainIndicatorChart = ({ data }: Props) => { ...@@ -22,12 +20,17 @@ const ChainIndicatorChart = ({ data }: Props) => {
const overlayRef = React.useRef<SVGRectElement>(null); const overlayRef = React.useRef<SVGRectElement>(null);
const lineColor = useToken('colors', 'blue.500'); const lineColor = useToken('colors', 'blue.500');
const [ rect, ref ] = useClientRect<SVGSVGElement>(); const axesConfig = React.useMemo(() => {
const { innerWidth, innerHeight } = calculateInnerSize(rect, CHART_MARGIN); return {
const { xScale, yScale } = useTimeChartController({ x: { ticks: 4 },
y: { ticks: 3, nice: true },
};
}, [ ]);
const { rect, ref, axis, innerWidth, innerHeight } = useTimeChartController({
data, data,
width: innerWidth, margin: CHART_MARGIN,
height: innerHeight, axesConfig,
}); });
return ( return (
...@@ -35,13 +38,13 @@ const ChainIndicatorChart = ({ data }: Props) => { ...@@ -35,13 +38,13 @@ const ChainIndicatorChart = ({ data }: Props) => {
<g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` } opacity={ rect ? 1 : 0 }> <g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` } opacity={ rect ? 1 : 0 }>
<ChartArea <ChartArea
data={ data[0].items } data={ data[0].items }
xScale={ xScale } xScale={ axis.x.scale }
yScale={ yScale } yScale={ axis.y.scale }
/> />
<ChartLine <ChartLine
data={ data[0].items } data={ data[0].items }
xScale={ xScale } xScale={ axis.x.scale }
yScale={ yScale } yScale={ axis.y.scale }
stroke={ lineColor } stroke={ lineColor }
animation="left" animation="left"
strokeWidth={ 3 } strokeWidth={ 3 }
...@@ -51,8 +54,8 @@ const ChainIndicatorChart = ({ data }: Props) => { ...@@ -51,8 +54,8 @@ const ChainIndicatorChart = ({ data }: Props) => {
anchorEl={ overlayRef.current } anchorEl={ overlayRef.current }
width={ innerWidth } width={ innerWidth }
height={ innerHeight } height={ innerHeight }
xScale={ xScale } xScale={ axis.x.scale }
yScale={ yScale } yScale={ axis.y.scale }
data={ data } data={ data }
/> />
</ChartOverlay> </ChartOverlay>
......
...@@ -15,7 +15,7 @@ const dailyTxsIndicator: TChainIndicator<'homepage_chart_txs'> = { ...@@ -15,7 +15,7 @@ const dailyTxsIndicator: TChainIndicator<'homepage_chart_txs'> = {
title: 'Daily transactions', title: 'Daily transactions',
value: (stats) => Number(stats.transactions_today).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }), value: (stats) => Number(stats.transactions_today).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }),
icon: <Icon as={ txIcon } boxSize={ 6 } bgColor="#56ACD1" borderRadius="base" color="white"/>, icon: <Icon as={ txIcon } boxSize={ 6 } bgColor="#56ACD1" borderRadius="base" color="white"/>,
hint: `The total daily number of transactions on the blockchain for the last month.`, hint: `Number of transactions yesterday (0:00 - 23:59 UTC). The chart displays daily transactions for the past 30 days.`,
api: { api: {
resourceName: 'homepage_chart_txs', resourceName: 'homepage_chart_txs',
dataFn: (response) => ([ { dataFn: (response) => ([ {
......
...@@ -84,7 +84,6 @@ const MarketplaceAppCard = ({ ...@@ -84,7 +84,6 @@ const MarketplaceAppCard = ({
marginBottom={ 4 } marginBottom={ 4 }
w={{ base: '64px', sm: '96px' }} w={{ base: '64px', sm: '96px' }}
h={{ base: '64px', sm: '96px' }} h={{ base: '64px', sm: '96px' }}
borderRadius={ 8 }
display="flex" display="flex"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
...@@ -92,6 +91,7 @@ const MarketplaceAppCard = ({ ...@@ -92,6 +91,7 @@ const MarketplaceAppCard = ({
<Image <Image
src={ isLoading ? undefined : logoUrl } src={ isLoading ? undefined : logoUrl }
alt={ `${ title } app icon` } alt={ `${ title } app icon` }
borderRadius="8px"
/> />
</Skeleton> </Skeleton>
......
import { Box, Flex, Icon } from '@chakra-ui/react'; import { Box, Flex, HStack, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/token';
import type { RoutedTab } from 'ui/shared/Tabs/types'; import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
...@@ -25,6 +24,7 @@ import AddressTxs from 'ui/address/AddressTxs'; ...@@ -25,6 +24,7 @@ import AddressTxs from 'ui/address/AddressTxs';
import AddressWithdrawals from 'ui/address/AddressWithdrawals'; import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton'; import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode'; import AddressQrCode from 'ui/address/details/AddressQrCode';
import SolidityscanReport from 'ui/address/SolidityscanReport';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
...@@ -35,13 +35,7 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -35,13 +35,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
export const tokenTabsByType: Record<TokenType, string> = { const TOKEN_TABS = [ 'tokens_erc20', 'tokens_nfts', 'tokens_nfts_collection', 'tokens_nfts_list' ];
'ERC-20': 'tokens_erc20',
'ERC-721': 'tokens_erc721',
'ERC-1155': 'tokens_erc1155',
} as const;
const TOKEN_TABS = Object.values(tokenTabsByType);
const AddressPageContent = () => { const AddressPageContent = () => {
const router = useRouter(); const router = useRouter();
...@@ -194,7 +188,9 @@ const AddressPageContent = () => { ...@@ -194,7 +188,9 @@ const AddressPageContent = () => {
) } ) }
<AddressQrCode address={{ hash }} isLoading={ isLoading }/> <AddressQrCode address={{ hash }} isLoading={ isLoading }/>
<AccountActionsMenu isLoading={ isLoading }/> <AccountActionsMenu isLoading={ isLoading }/>
<NetworkExplorers type="address" pathParam={ hash } ml="auto"/> <HStack ml="auto" gap={ 2 }/>
{ addressQuery.data?.is_contract && config.UI.views.address.solidityscanEnabled && <SolidityscanReport hash={ hash }/> }
<NetworkExplorers type="address" pathParam={ hash }/>
</Flex> </Flex>
); );
......
...@@ -56,6 +56,7 @@ const TokenPageContent = () => { ...@@ -56,6 +56,7 @@ const TokenPageContent = () => {
const hashString = getQueryParamString(router.query.hash); const hashString = getQueryParamString(router.query.hash);
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
const ownerFilter = getQueryParamString(router.query.holder_address_hash) || undefined;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
...@@ -140,6 +141,7 @@ const TokenPageContent = () => { ...@@ -140,6 +141,7 @@ const TokenPageContent = () => {
const inventoryQuery = useQueryWithPages({ const inventoryQuery = useQueryWithPages({
resourceName: 'token_inventory', resourceName: 'token_inventory',
pathParams: { hash: hashString }, pathParams: { hash: hashString },
filters: ownerFilter ? { holder_address_hash: ownerFilter } : {},
scrollRef, scrollRef,
options: { options: {
enabled: Boolean( enabled: Boolean(
...@@ -150,7 +152,7 @@ const TokenPageContent = () => { ...@@ -150,7 +152,7 @@ const TokenPageContent = () => {
tab === 'inventory' tab === 'inventory'
), ),
), ),
placeholderData: generateListStub<'token_inventory'>(tokenStubs.TOKEN_INSTANCE, 50, { next_page_params: null }), placeholderData: generateListStub<'token_inventory'>(tokenStubs.TOKEN_INSTANCE, 50, { next_page_params: { unique_token: 1 } }),
}, },
}); });
...@@ -173,9 +175,11 @@ const TokenPageContent = () => { ...@@ -173,9 +175,11 @@ const TokenPageContent = () => {
const contractTabs = useContractTabs(contractQuery.data); const contractTabs = useContractTabs(contractQuery.data);
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
(tokenQuery.data?.type === 'ERC-1155' || tokenQuery.data?.type === 'ERC-721') ? (tokenQuery.data?.type === 'ERC-1155' || tokenQuery.data?.type === 'ERC-721') ? {
{ id: 'inventory', title: 'Inventory', component: <TokenInventory inventoryQuery={ inventoryQuery }/> } : id: 'inventory',
undefined, title: 'Inventory',
component: <TokenInventory inventoryQuery={ inventoryQuery } tokenQuery={ tokenQuery } ownerFilter={ ownerFilter }/>,
} : undefined,
{ id: 'token_transfers', title: 'Token transfers', component: <TokenTransfer transfersQuery={ transfersQuery } token={ tokenQuery.data }/> }, { id: 'token_transfers', title: 'Token transfers', component: <TokenTransfer transfersQuery={ transfersQuery } token={ tokenQuery.data }/> },
{ id: 'holders', title: 'Holders', component: <TokenHolders token={ tokenQuery.data } holdersQuery={ holdersQuery }/> }, { id: 'holders', title: 'Holders', component: <TokenHolders token={ tokenQuery.data } holdersQuery={ holdersQuery }/> },
contractQuery.data?.is_contract ? { contractQuery.data?.is_contract ? {
......
...@@ -10,7 +10,7 @@ import { useAppContext } from 'lib/contexts/app'; ...@@ -10,7 +10,7 @@ import { useAppContext } from 'lib/contexts/app';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata'; import * as metadata from 'lib/metadata';
import * as regexp from 'lib/regexp'; import * as regexp from 'lib/regexp';
import { TOKEN_INSTANCE } from 'stubs/token'; import { TOKEN_INSTANCE, TOKEN_INFO_ERC_1155 } from 'stubs/token';
import * as tokenStubs from 'stubs/token'; import * as tokenStubs from 'stubs/token';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import AddressQrCode from 'ui/address/details/AddressQrCode'; import AddressQrCode from 'ui/address/details/AddressQrCode';
...@@ -43,6 +43,14 @@ const TokenInstanceContent = () => { ...@@ -43,6 +43,14 @@ const TokenInstanceContent = () => {
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenQuery = useApiQuery('token', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash && id),
placeholderData: TOKEN_INFO_ERC_1155,
},
});
const tokenInstanceQuery = useApiQuery('token_instance', { const tokenInstanceQuery = useApiQuery('token_instance', {
pathParams: { hash, id }, pathParams: { hash, id },
queryOptions: { queryOptions: {
...@@ -58,14 +66,18 @@ const TokenInstanceContent = () => { ...@@ -58,14 +66,18 @@ const TokenInstanceContent = () => {
options: { options: {
enabled: Boolean(hash && id && (!tab || tab === 'token_transfers') && !tokenInstanceQuery.isPlaceholderData && tokenInstanceQuery.data), enabled: Boolean(hash && id && (!tab || tab === 'token_transfers') && !tokenInstanceQuery.isPlaceholderData && tokenInstanceQuery.data),
placeholderData: generateListStub<'token_instance_transfers'>( placeholderData: generateListStub<'token_instance_transfers'>(
tokenInstanceQuery.data?.token.type === 'ERC-1155' ? tokenStubs.TOKEN_TRANSFER_ERC_1155 : tokenStubs.TOKEN_TRANSFER_ERC_721, tokenQuery.data?.type === 'ERC-1155' ? tokenStubs.TOKEN_TRANSFER_ERC_1155 : tokenStubs.TOKEN_TRANSFER_ERC_721,
10, 10,
{ next_page_params: null }, { next_page_params: null },
), ),
}, },
}); });
const shouldFetchHolders = !tokenInstanceQuery.isPlaceholderData && tokenInstanceQuery.data && !tokenInstanceQuery.data.is_unique; const shouldFetchHolders =
!tokenQuery.isPlaceholderData &&
!tokenInstanceQuery.isPlaceholderData &&
tokenInstanceQuery.data &&
!tokenInstanceQuery.data.is_unique;
const holdersQuery = useQueryWithPages({ const holdersQuery = useQueryWithPages({
resourceName: 'token_instance_holders', resourceName: 'token_instance_holders',
...@@ -74,18 +86,18 @@ const TokenInstanceContent = () => { ...@@ -74,18 +86,18 @@ const TokenInstanceContent = () => {
options: { options: {
enabled: Boolean(hash && tab === 'holders' && shouldFetchHolders), enabled: Boolean(hash && tab === 'holders' && shouldFetchHolders),
placeholderData: generateListStub<'token_instance_holders'>( placeholderData: generateListStub<'token_instance_holders'>(
tokenInstanceQuery.data?.token.type === 'ERC-1155' ? tokenStubs.TOKEN_HOLDER_ERC_1155 : tokenStubs.TOKEN_HOLDER_ERC_20, 10, { next_page_params: null }), tokenQuery.data?.type === 'ERC-1155' ? tokenStubs.TOKEN_HOLDER_ERC_1155 : tokenStubs.TOKEN_HOLDER_ERC_20, 10, { next_page_params: null }),
}, },
}); });
React.useEffect(() => { React.useEffect(() => {
if (tokenInstanceQuery.data && !tokenInstanceQuery.isPlaceholderData) { if (tokenInstanceQuery.data && !tokenInstanceQuery.isPlaceholderData && tokenQuery.data && !tokenQuery.isPlaceholderData) {
metadata.update( metadata.update(
{ pathname: '/token/[hash]/instance/[id]', query: { hash: tokenInstanceQuery.data.token.address, id: tokenInstanceQuery.data.id } }, { pathname: '/token/[hash]/instance/[id]', query: { hash: tokenQuery.data.address, id: tokenInstanceQuery.data.id } },
{ symbol: tokenInstanceQuery.data.token.symbol ?? '' }, { symbol: tokenQuery.data.symbol ?? '' },
); );
} }
}, [ tokenInstanceQuery.data, tokenInstanceQuery.isPlaceholderData ]); }, [ tokenInstanceQuery.data, tokenInstanceQuery.isPlaceholderData, tokenQuery.data, tokenQuery.isPlaceholderData ]);
const backLink = React.useMemo(() => { const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance'); const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
...@@ -104,10 +116,10 @@ const TokenInstanceContent = () => { ...@@ -104,10 +116,10 @@ const TokenInstanceContent = () => {
{ {
id: 'token_transfers', id: 'token_transfers',
title: 'Token transfers', title: 'Token transfers',
component: <TokenTransfer transfersQuery={ transfersQuery } tokenId={ id } token={ tokenInstanceQuery.data?.token }/>, component: <TokenTransfer transfersQuery={ transfersQuery } tokenId={ id } token={ tokenQuery.data }/>,
}, },
shouldFetchHolders ? shouldFetchHolders ?
{ id: 'holders', title: 'Holders', component: <TokenHolders holdersQuery={ holdersQuery } token={ tokenInstanceQuery.data?.token }/> } : { id: 'holders', title: 'Holders', component: <TokenHolders holdersQuery={ holdersQuery } token={ tokenQuery.data }/> } :
undefined, undefined,
{ id: 'metadata', title: 'Metadata', component: ( { id: 'metadata', title: 'Metadata', component: (
<TokenInstanceMetadata <TokenInstanceMetadata
...@@ -121,7 +133,7 @@ const TokenInstanceContent = () => { ...@@ -121,7 +133,7 @@ const TokenInstanceContent = () => {
throw Error('Token instance fetch failed', { cause: tokenInstanceQuery.error }); throw Error('Token instance fetch failed', { cause: tokenInstanceQuery.error });
} }
const tokenTag = <Tag isLoading={ tokenInstanceQuery.isPlaceholderData }>{ tokenInstanceQuery.data?.token.type }</Tag>; const tokenTag = <Tag isLoading={ tokenInstanceQuery.isPlaceholderData }>{ tokenQuery.data?.type }</Tag>;
const address = { const address = {
hash: hash || '', hash: hash || '',
...@@ -131,7 +143,7 @@ const TokenInstanceContent = () => { ...@@ -131,7 +143,7 @@ const TokenInstanceContent = () => {
watchlist_address_id: null, watchlist_address_id: null,
}; };
const isLoading = tokenInstanceQuery.isPlaceholderData; const isLoading = tokenInstanceQuery.isPlaceholderData || tokenQuery.isPlaceholderData;
const appLink = (() => { const appLink = (() => {
if (!tokenInstanceQuery.data?.external_app_url) { if (!tokenInstanceQuery.data?.external_app_url) {
...@@ -168,7 +180,7 @@ const TokenInstanceContent = () => { ...@@ -168,7 +180,7 @@ const TokenInstanceContent = () => {
const titleSecondRow = ( const titleSecondRow = (
<Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}> <Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
<TokenEntity <TokenEntity
token={ tokenInstanceQuery.data?.token } token={ tokenQuery.data }
isLoading={ isLoading } isLoading={ isLoading }
noSymbol noSymbol
noCopy noCopy
...@@ -179,7 +191,7 @@ const TokenInstanceContent = () => { ...@@ -179,7 +191,7 @@ const TokenInstanceContent = () => {
w="auto" w="auto"
maxW="700px" maxW="700px"
/> />
{ !isLoading && tokenInstanceQuery.data && <AddressAddToWallet token={ tokenInstanceQuery.data.token } variant="button"/> } { !isLoading && tokenInstanceQuery.data && <AddressAddToWallet token={ tokenQuery.data } variant="button"/> }
<AddressQrCode address={ address } isLoading={ isLoading }/> <AddressQrCode address={ address } isLoading={ isLoading }/>
<AccountActionsMenu isLoading={ isLoading }/> <AccountActionsMenu isLoading={ isLoading }/>
{ appLink } { appLink }
...@@ -197,7 +209,7 @@ const TokenInstanceContent = () => { ...@@ -197,7 +209,7 @@ const TokenInstanceContent = () => {
isLoading={ isLoading } isLoading={ isLoading }
/> />
<TokenInstanceDetails data={ tokenInstanceQuery?.data } isLoading={ isLoading } scrollRef={ scrollRef }/> <TokenInstanceDetails data={ tokenInstanceQuery?.data } isLoading={ isLoading } scrollRef={ scrollRef } token={ tokenQuery.data }/>
{ /* should stay before tabs to scroll up with pagination */ } { /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box> <Box ref={ scrollRef }></Box>
......
...@@ -105,7 +105,7 @@ const Tokens = () => { ...@@ -105,7 +105,7 @@ const Tokens = () => {
</PopoverFilter> </PopoverFilter>
) : ( ) : (
<PopoverFilter isActive={ tokenTypes && tokenTypes.length > 0 } contentProps={{ w: '200px' }} appliedFiltersNum={ tokenTypes?.length }> <PopoverFilter isActive={ tokenTypes && tokenTypes.length > 0 } contentProps={{ w: '200px' }} appliedFiltersNum={ tokenTypes?.length }>
<TokenTypeFilter onChange={ handleTokenTypesChange } defaultValue={ tokenTypes }/> <TokenTypeFilter<TokenType> onChange={ handleTokenTypesChange } defaultValue={ tokenTypes } nftOnly={ false }/>
</PopoverFilter> </PopoverFilter>
); );
......
...@@ -14,13 +14,13 @@ const GoogleAnalytics = () => { ...@@ -14,13 +14,13 @@ const GoogleAnalytics = () => {
return ( return (
<> <>
<Script src={ `https://www.googletagmanager.com/gtag/js?id=${ id }` }/> <Script strategy="lazyOnload" src={ `https://www.googletagmanager.com/gtag/js?id=${ id }` }/>
<Script id="google-analytics"> <Script strategy="lazyOnload" id="google-analytics">
{ ` { `
window.dataLayer = window.dataLayer || []; window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);} function gtag(){dataLayer.push(arguments);}
gtag('js', new Date()); gtag('js', new Date());
gtag('config', '${ id }'); gtag('config', window.__envs.NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID);
` } ` }
</Script> </Script>
</> </>
......
import { Tooltip, Flex, Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import crossIcon from 'icons/cross.svg';
type Props = {
onClick: () => void;
}
const ResetIconButton = ({ onClick }: Props) => {
const resetTokenIconColor = useColorModeValue('blue.600', 'blue.300');
const resetTokenIconHoverColor = useColorModeValue('blue.400', 'blue.200');
return (
<Tooltip label="Reset filter">
<Flex>
<Icon
as={ crossIcon }
boxSize={ 5 }
ml={ 1 }
color={ resetTokenIconColor }
cursor="pointer"
_hover={{ color: resetTokenIconHoverColor }}
onClick={ onClick }
/>
</Flex>
</Tooltip>
);
};
export default ResetIconButton;
...@@ -56,7 +56,7 @@ const TokenTransferFilter = ({ ...@@ -56,7 +56,7 @@ const TokenTransferFilter = ({
</> </>
) } ) }
<Text variant="secondary" fontWeight={ 600 }>Type</Text> <Text variant="secondary" fontWeight={ 600 }>Type</Text>
<TokenTypeFilter onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters }/> <TokenTypeFilter<TokenType> onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters } nftOnly={ false }/>
</PopoverFilter> </PopoverFilter>
); );
}; };
......
import { Flex, Skeleton } from '@chakra-ui/react'; import { Flex, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import eastArrowIcon from 'icons/arrows/east.svg'; import eastArrowIcon from 'icons/arrows/east.svg';
import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Icon from 'ui/shared/chakra/Icon'; import Icon from 'ui/shared/chakra/Icon';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -37,15 +37,14 @@ const TokenTransferListItem = ({ ...@@ -37,15 +37,14 @@ const TokenTransferListItem = ({
enableTimeIncrement, enableTimeIncrement,
isLoading, isLoading,
}: Props) => { }: Props) => {
const value = (() => {
if (!('value' in total)) {
return null;
}
return BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat();
})();
const timeAgo = useTimeAgoIncrement(timestamp, enableTimeIncrement); const timeAgo = useTimeAgoIncrement(timestamp, enableTimeIncrement);
const { usd, valueStr } = 'value' in total ? getCurrencyValue({
value: total.value,
exchangeRate: token.exchange_rate,
accuracy: 8,
accuracyUsd: 2,
decimals: total.decimals || '0',
}) : { usd: null, valueStr: null };
const addressWidth = `calc((100% - ${ baseAddress ? '50px - 24px' : '24px - 24px' }) / 2)`; const addressWidth = `calc((100% - ${ baseAddress ? '50px - 24px' : '24px - 24px' }) / 2)`;
return ( return (
...@@ -112,10 +111,13 @@ const TokenTransferListItem = ({ ...@@ -112,10 +111,13 @@ const TokenTransferListItem = ({
width={ addressWidth } width={ addressWidth }
/> />
</Flex> </Flex>
{ value && ( { valueStr && (
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !isLoading } fontWeight={ 500 } flexShrink={ 0 }>Value</Skeleton> <Skeleton isLoaded={ !isLoading } fontWeight={ 500 } flexShrink={ 0 }>Value</Skeleton>
<Skeleton isLoaded={ !isLoading } color="text_secondary"><span>{ value }</span></Skeleton> <Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ valueStr }</span>
{ usd && <span> (${ usd })</span> }
</Skeleton>
</Flex> </Flex>
) } ) }
</ListItemMobile> </ListItemMobile>
......
import { Tr, Td, Flex, Skeleton, Box } from '@chakra-ui/react'; import { Tr, Td, Flex, Skeleton, Box } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
...@@ -35,6 +35,13 @@ const TokenTransferTableItem = ({ ...@@ -35,6 +35,13 @@ const TokenTransferTableItem = ({
isLoading, isLoading,
}: Props) => { }: Props) => {
const timeAgo = useTimeAgoIncrement(timestamp, enableTimeIncrement); const timeAgo = useTimeAgoIncrement(timestamp, enableTimeIncrement);
const { usd, valueStr } = 'value' in total ? getCurrencyValue({
value: total.value,
exchangeRate: token.exchange_rate,
accuracy: 8,
accuracyUsd: 2,
decimals: total.decimals || '0',
}) : { usd: null, valueStr: null };
return ( return (
<Tr alignItems="top"> <Tr alignItems="top">
...@@ -111,9 +118,16 @@ const TokenTransferTableItem = ({ ...@@ -111,9 +118,16 @@ const TokenTransferTableItem = ({
/> />
</Td> </Td>
<Td isNumeric verticalAlign="top"> <Td isNumeric verticalAlign="top">
<Skeleton isLoaded={ !isLoading } display="inline-block" my="7px" wordBreak="break-all"> { valueStr && (
{ 'value' in total && BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat() } <Skeleton isLoaded={ !isLoading } display="inline-block" mt="7px" wordBreak="break-all">
</Skeleton> { valueStr }
</Skeleton>
) }
{ usd && (
<Skeleton isLoaded={ !isLoading } color="text_secondary" mt="10px" ml="auto" w="min-content">
<span>${ usd }</span>
</Skeleton>
) }
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -47,8 +47,8 @@ const AdbutlerBanner = ({ className }: { className?: string }) => { ...@@ -47,8 +47,8 @@ const AdbutlerBanner = ({ className }: { className?: string }) => {
return ( return (
<Flex className={ className } id="adBanner" h={{ base: '100px', lg: '90px' }}> <Flex className={ className } id="adBanner" h={{ base: '100px', lg: '90px' }}>
<Script id="ad-butler-1">{ connectAdbutler }</Script> <Script strategy="lazyOnload" id="ad-butler-1">{ connectAdbutler }</Script>
<Script id="ad-butler-2">{ placeAd }</Script> <Script strategy="lazyOnload" id="ad-butler-2">{ placeAd }</Script>
<div id="ad-banner"></div> <div id="ad-banner"></div>
</Flex> </Flex>
); );
......
...@@ -21,7 +21,7 @@ const CoinzillaBanner = ({ className }: { className?: string }) => { ...@@ -21,7 +21,7 @@ const CoinzillaBanner = ({ className }: { className?: string }) => {
return ( return (
<Flex className={ className } id="adBanner" h={{ base: '100px', lg: '90px' }}> <Flex className={ className } id="adBanner" h={{ base: '100px', lg: '90px' }}>
<Script src="https://coinzillatag.com/lib/display.js"/> <Script strategy="lazyOnload" src="https://coinzillatag.com/lib/display.js"/>
<div className="coinzilla" data-zone="C-26660bf627543e46851"></div> <div className="coinzilla" data-zone="C-26660bf627543e46851"></div>
</Flex> </Flex>
); );
......
import { useToken } from '@chakra-ui/react'; import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3'; import * as d3 from 'd3';
import React, { useEffect, useMemo } from 'react'; import React from 'react';
import type { ChartMargin, TimeChartItem } from 'ui/shared/chart/types'; import type { ChartMargin, TimeChartItem } from 'ui/shared/chart/types';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import useClientRect from 'lib/hooks/useClientRect';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import ChartArea from 'ui/shared/chart/ChartArea'; import ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis'; import ChartAxis from 'ui/shared/chart/ChartAxis';
...@@ -15,7 +14,6 @@ import ChartOverlay from 'ui/shared/chart/ChartOverlay'; ...@@ -15,7 +14,6 @@ import ChartOverlay from 'ui/shared/chart/ChartOverlay';
import ChartSelectionX from 'ui/shared/chart/ChartSelectionX'; import ChartSelectionX from 'ui/shared/chart/ChartSelectionX';
import ChartTooltip from 'ui/shared/chart/ChartTooltip'; import ChartTooltip from 'ui/shared/chart/ChartTooltip';
import useTimeChartController from 'ui/shared/chart/useTimeChartController'; import useTimeChartController from 'ui/shared/chart/useTimeChartController';
import calculateInnerSize from 'ui/shared/chart/utils/calculateInnerSize';
interface Props { interface Props {
isEnlarged?: boolean; isEnlarged?: boolean;
...@@ -31,36 +29,54 @@ interface Props { ...@@ -31,36 +29,54 @@ interface Props {
const MAX_SHOW_ITEMS = 100_000_000_000; const MAX_SHOW_ITEMS = 100_000_000_000;
const DEFAULT_CHART_MARGIN = { bottom: 20, left: 40, right: 20, top: 10 }; const DEFAULT_CHART_MARGIN = { bottom: 20, left: 40, right: 20, top: 10 };
const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin, units }: Props) => { const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin: marginProps, units }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const color = useToken('colors', 'blue.200'); const color = useToken('colors', 'blue.200');
const overlayRef = React.useRef<SVGRectElement>(null); const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
const [ rect, ref ] = useClientRect<SVGSVGElement>(); const overlayRef = React.useRef<SVGRectElement>(null);
const chartMargin = { ...DEFAULT_CHART_MARGIN, ...margin };
const { innerWidth, innerHeight } = calculateInnerSize(rect, chartMargin);
const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
const [ range, setRange ] = React.useState<[ Date, Date ]>([ items[0].date, items[items.length - 1].date ]); const [ range, setRange ] = React.useState<[ Date, Date ]>([ items[0].date, items[items.length - 1].date ]);
const rangedItems = useMemo(() => const rangedItems = React.useMemo(() =>
items.filter((item) => item.date >= range[0] && item.date <= range[1]), items.filter((item) => item.date >= range[0] && item.date <= range[1]),
[ items, range ]); [ items, range ]);
const isGroupedValues = rangedItems.length > MAX_SHOW_ITEMS; const isGroupedValues = rangedItems.length > MAX_SHOW_ITEMS;
const displayedData = useMemo(() => { const displayedData = React.useMemo(() => {
if (isGroupedValues) { if (isGroupedValues) {
return groupChartItemsByWeekNumber(rangedItems); return groupChartItemsByWeekNumber(rangedItems);
} else { } else {
return rangedItems; return rangedItems;
} }
}, [ isGroupedValues, rangedItems ]); }, [ isGroupedValues, rangedItems ]);
const chartData = React.useMemo(() => ([ { items: displayedData, name: 'Value', color, units } ]), [ color, displayedData, units ]); const chartData = React.useMemo(() => ([ { items: displayedData, name: 'Value', color, units } ]), [ color, displayedData, units ]);
const { xTickFormat, yTickFormat, xScale, yScale } = useTimeChartController({ const margin: ChartMargin = React.useMemo(() => ({ ...DEFAULT_CHART_MARGIN, ...marginProps }), [ marginProps ]);
data: [ { items: displayedData, name: title, color } ], const axesConfig = React.useMemo(() => {
width: innerWidth, return {
height: innerHeight, x: {
ticks: isEnlarged ? 8 : 4,
},
y: {
ticks: isEnlarged ? 6 : 3,
nice: true,
},
};
}, [ isEnlarged ]);
const {
ref,
rect,
innerWidth,
innerHeight,
chartMargin,
axis,
} = useTimeChartController({
data: chartData,
margin,
axesConfig,
}); });
const handleRangeSelect = React.useCallback((nextRange: [ Date, Date ]) => { const handleRangeSelect = React.useCallback((nextRange: [ Date, Date ]) => {
...@@ -68,7 +84,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -68,7 +84,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
onZoom(); onZoom();
}, [ onZoom ]); }, [ onZoom ]);
useEffect(() => { React.useEffect(() => {
if (isZoomResetInitial) { if (isZoomResetInitial) {
setRange([ items[0].date, items[items.length - 1].date ]); setRange([ items[0].date, items[items.length - 1].date ]);
} }
...@@ -80,8 +96,8 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -80,8 +96,8 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
<g transform={ `translate(${ chartMargin?.left || 0 },${ chartMargin?.top || 0 })` }> <g transform={ `translate(${ chartMargin?.left || 0 },${ chartMargin?.top || 0 })` }>
<ChartGridLine <ChartGridLine
type="horizontal" type="horizontal"
scale={ yScale } scale={ axis.y.scale }
ticks={ isEnlarged ? 6 : 3 } ticks={ axesConfig.y.ticks }
size={ innerWidth } size={ innerWidth }
disableAnimation disableAnimation
/> />
...@@ -90,14 +106,14 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -90,14 +106,14 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
id={ chartId } id={ chartId }
data={ displayedData } data={ displayedData }
color={ color } color={ color }
xScale={ xScale } xScale={ axis.x.scale }
yScale={ yScale } yScale={ axis.y.scale }
/> />
<ChartLine <ChartLine
data={ displayedData } data={ displayedData }
xScale={ xScale } xScale={ axis.x.scale }
yScale={ yScale } yScale={ axis.y.scale }
stroke={ color } stroke={ color }
animation="none" animation="none"
strokeWidth={ isMobile ? 1 : 2 } strokeWidth={ isMobile ? 1 : 2 }
...@@ -105,19 +121,19 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -105,19 +121,19 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
<ChartAxis <ChartAxis
type="left" type="left"
scale={ yScale } scale={ axis.y.scale }
ticks={ isEnlarged ? 6 : 3 } ticks={ axesConfig.y.ticks }
tickFormatGenerator={ yTickFormat } tickFormatGenerator={ axis.y.tickFormatter }
disableAnimation disableAnimation
/> />
<ChartAxis <ChartAxis
type="bottom" type="bottom"
scale={ xScale } scale={ axis.x.scale }
transform={ `translate(0, ${ innerHeight })` } transform={ `translate(0, ${ innerHeight })` }
ticks={ isMobile ? 1 : 4 } ticks={ axesConfig.x.ticks }
anchorEl={ overlayRef.current } anchorEl={ overlayRef.current }
tickFormatGenerator={ xTickFormat } tickFormatGenerator={ axis.x.tickFormatter }
disableAnimation disableAnimation
/> />
...@@ -127,15 +143,15 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -127,15 +143,15 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
width={ innerWidth } width={ innerWidth }
tooltipWidth={ isGroupedValues ? 280 : 200 } tooltipWidth={ isGroupedValues ? 280 : 200 }
height={ innerHeight } height={ innerHeight }
xScale={ xScale } xScale={ axis.x.scale }
yScale={ yScale } yScale={ axis.y.scale }
data={ chartData } data={ chartData }
/> />
<ChartSelectionX <ChartSelectionX
anchorEl={ overlayRef.current } anchorEl={ overlayRef.current }
height={ innerHeight } height={ innerHeight }
scale={ xScale } scale={ axis.x.scale }
data={ chartData } data={ chartData }
onSelect={ handleRangeSelect } onSelect={ handleRangeSelect }
/> />
......
...@@ -25,3 +25,13 @@ export interface TimeChartDataItem { ...@@ -25,3 +25,13 @@ export interface TimeChartDataItem {
} }
export type TimeChartData = Array<TimeChartDataItem>; export type TimeChartData = Array<TimeChartDataItem>;
export interface AxisConfig {
ticks?: number;
nice?: boolean;
}
export interface AxesConfig {
x?: AxisConfig;
y?: AxisConfig;
}
import * as d3 from 'd3'; import React from 'react';
import { useMemo } from 'react';
import type { TimeChartData } from 'ui/shared/chart/types'; import type { AxesConfig, ChartMargin, TimeChartData } from 'ui/shared/chart/types';
import { WEEK, MONTH, YEAR } from 'lib/consts'; import useClientRect from 'lib/hooks/useClientRect';
import calculateInnerSize from './utils/calculateInnerSize';
import { getAxisParams, DEFAULT_MAXIMUM_SIGNIFICANT_DIGITS } from './utils/timeChartAxis';
interface Props { interface Props {
data: TimeChartData; data: TimeChartData;
width: number; margin?: ChartMargin;
height: number; axesConfig?: AxesConfig;
} }
export default function useTimeChartController({ data, width, height }: Props) { export default function useTimeChartController({ data, margin, axesConfig }: Props) {
const xMin = useMemo( const [ rect, ref ] = useClientRect<SVGSVGElement>();
() => d3.min(data, ({ items }) => d3.min(items, ({ date }) => date)) || new Date(),
[ data ], // we need to recalculate the axis scale whenever the rect width changes
); // eslint-disable-next-line react-hooks/exhaustive-deps
const axisParams = React.useMemo(() => getAxisParams(data, axesConfig), [ data, axesConfig, rect?.width ]);
const xMax = useMemo(
() => d3.max(data, ({ items }) => d3.max(items, ({ date }) => date)) || new Date(), const chartMargin = React.useMemo(() => {
[ data ], const exceedingDigits = (axisParams.y.labelFormatParams.maximumSignificantDigits ?? DEFAULT_MAXIMUM_SIGNIFICANT_DIGITS) -
); DEFAULT_MAXIMUM_SIGNIFICANT_DIGITS;
const PIXELS_PER_DIGIT = 7;
const xScale = useMemo( const leftShift = PIXELS_PER_DIGIT * exceedingDigits;
() => d3.scaleTime().domain([ xMin, xMax ]).range([ 0, width ]),
[ xMin, xMax, width ], return {
); ...margin,
left: (margin?.left ?? 0) + leftShift,
const yMin = useMemo( };
() => d3.min(data, ({ items }) => d3.min(items, ({ value }) => value)) || 0, }, [ axisParams.y.labelFormatParams.maximumSignificantDigits, margin ]);
[ data ],
); const { innerWidth, innerHeight } = calculateInnerSize(rect, chartMargin);
const yMax = useMemo( const xScale = React.useMemo(() => {
() => d3.max(data, ({ items }) => d3.max(items, ({ value }) => value)) || 0, return axisParams.x.scale.range([ 0, innerWidth ]);
[ data ], }, [ axisParams.x.scale, innerWidth ]);
);
const yScale = React.useMemo(() => {
const yScale = useMemo(() => { return axisParams.y.scale.range([ innerHeight, 0 ]);
const indention = (yMax - yMin) * 0.15; }, [ axisParams.y.scale, innerHeight ]);
return d3.scaleLinear() return React.useMemo(() => {
.domain([ yMin >= 0 && yMin - indention <= 0 ? 0 : yMin - indention, yMax + indention ]) return {
.range([ height, 0 ]); rect,
}, [ height, yMin, yMax ]); ref,
chartMargin,
const yScaleForAxis = useMemo( innerWidth,
() => d3.scaleBand().domain([ String(yMin), String(yMax) ]).range([ height, 0 ]), innerHeight,
[ height, yMin, yMax ], axis: {
); x: {
tickFormatter: axisParams.x.tickFormatter,
const xTickFormat = (axis: d3.Axis<d3.NumberValue>) => (d: d3.AxisDomain) => { scale: xScale,
let format: (date: Date) => string; },
const scale = axis.scale(); y: {
const extent = scale.domain(); tickFormatter: axisParams.y.tickFormatter,
scale: yScale,
const span = Number(extent[1]) - Number(extent[0]); },
},
if (span > YEAR) { };
format = d3.timeFormat('%Y'); }, [ axisParams.x.tickFormatter, axisParams.y.tickFormatter, chartMargin, innerHeight, innerWidth, rect, ref, xScale, yScale ]);
} else if (span > 2 * MONTH) {
format = d3.timeFormat('%b');
} else if (span > WEEK) {
format = d3.timeFormat('%b %d');
} else {
format = d3.timeFormat('%a %d');
}
return format(d as Date);
};
const yTickFormat = () => (d: d3.AxisDomain) => {
const num = Number(d);
const maximumFractionDigits = (() => {
if (num < 1) {
return 3;
}
if (num < 10) {
return 2;
}
if (num < 100) {
return 1;
}
return 0;
})();
return Number(d).toLocaleString(undefined, { maximumFractionDigits, notation: 'compact' });
};
return {
xTickFormat,
yTickFormat,
xScale,
yScale,
yScaleForAxis,
};
} }
import * as d3 from 'd3';
import _unique from 'lodash/uniq';
import type { AxesConfig, AxisConfig, TimeChartData } from '../types';
import { WEEK, MONTH, YEAR } from 'lib/consts';
export const DEFAULT_MAXIMUM_SIGNIFICANT_DIGITS = 2;
export const DEFAULT_MAXIMUM_FRACTION_DIGITS = 3;
export const MAXIMUM_SIGNIFICANT_DIGITS_LIMIT = 8;
export function getAxisParams(data: TimeChartData, axesConfig?: AxesConfig) {
const { labelFormatParams: labelFormatParamsY, scale: yScale } = getAxisParamsY(data, axesConfig?.y);
return {
x: {
scale: getAxisParamsX(data).scale,
tickFormatter: tickFormatterX,
},
y: {
scale: yScale,
labelFormatParams: labelFormatParamsY,
tickFormatter: getTickFormatterY(labelFormatParamsY),
},
};
}
function getAxisParamsX(data: TimeChartData) {
const min = d3.min(data, ({ items }) => d3.min(items, ({ date }) => date)) ?? new Date();
const max = d3.max(data, ({ items }) => d3.max(items, ({ date }) => date)) ?? new Date();
const scale = d3.scaleTime().domain([ min, max ]);
return { min, max, scale };
}
const tickFormatterX = (axis: d3.Axis<d3.NumberValue>) => (d: d3.AxisDomain) => {
let format: (date: Date) => string;
const scale = axis.scale();
const extent = scale.domain();
const span = Number(extent[1]) - Number(extent[0]);
if (span > YEAR) {
format = d3.timeFormat('%Y');
} else if (span > 2 * MONTH) {
format = d3.timeFormat('%b');
} else if (span > WEEK) {
format = d3.timeFormat('%b %d');
} else {
format = d3.timeFormat('%a %d');
}
return format(d as Date);
};
function getAxisParamsY(data: TimeChartData, config?: AxisConfig) {
const DEFAULT_TICKS_NUM = 3;
const min = d3.min(data, ({ items }) => d3.min(items, ({ value }) => value)) ?? 0;
const max = d3.max(data, ({ items }) => d3.max(items, ({ value }) => value)) ?? 0;
const scale = config?.nice ?
d3.scaleLinear()
.domain([ min, max ])
.nice(config?.ticks ?? DEFAULT_TICKS_NUM) :
d3.scaleLinear()
.domain([ min, max ]);
const ticks = scale.ticks(config?.ticks ?? DEFAULT_TICKS_NUM);
const labelFormatParams = getYLabelFormatParams(ticks);
return { min, max, scale, labelFormatParams };
}
const getTickFormatterY = (params: Intl.NumberFormatOptions) => () => (d: d3.AxisDomain) => {
const num = Number(d);
if (num < 1) {
// for small number there are no algorithm to format label right now
// so we set it to 3 digits after dot maximum
return num.toLocaleString(undefined, { maximumFractionDigits: 3 });
}
return num.toLocaleString(undefined, params);
};
function getYLabelFormatParams(ticks: Array<number>, maximumSignificantDigits = DEFAULT_MAXIMUM_SIGNIFICANT_DIGITS): Intl.NumberFormatOptions {
const params = {
maximumFractionDigits: 3,
maximumSignificantDigits,
notation: 'compact' as const,
};
const uniqTicksStr = _unique(ticks.map((tick) => tick.toLocaleString(undefined, params)));
if (uniqTicksStr.length === ticks.length || maximumSignificantDigits === MAXIMUM_SIGNIFICANT_DIGITS_LIMIT) {
return params;
}
return getYLabelFormatParams(ticks, maximumSignificantDigits + 1);
}
...@@ -19,11 +19,11 @@ const Icon = dynamic( ...@@ -19,11 +19,11 @@ const Icon = dynamic(
} }
case 'blockie': { case 'blockie': {
const makeBlockie = (await import('ethereum-blockies-base64')).default; const { blo } = (await import('blo'));
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
return (props: IconProps) => { return (props: IconProps) => {
const data = makeBlockie(props.hash); const data = blo(props.hash as `0x${ string }`, props.size);
return ( return (
<Image <Image
src={ data } src={ data }
......
...@@ -36,13 +36,13 @@ const Icon = (props: IconProps) => { ...@@ -36,13 +36,13 @@ const Icon = (props: IconProps) => {
); );
}; };
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'hash'>; type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'hash' | 'text'>;
const Content = chakra((props: ContentProps) => { const Content = chakra((props: ContentProps) => {
return ( return (
<EntityBase.Content <EntityBase.Content
{ ...props } { ...props }
text={ props.hash } text={ props.text ?? props.hash }
/> />
); );
}); });
...@@ -64,6 +64,7 @@ const Container = EntityBase.Container; ...@@ -64,6 +64,7 @@ const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps { export interface EntityProps extends EntityBase.EntityBaseProps {
hash: string; hash: string;
text?: string;
} }
const TxEntity = (props: EntityProps) => { const TxEntity = (props: EntityProps) => {
......
import { CheckboxGroup, Checkbox, Text, Flex, Link, useCheckboxGroup } from '@chakra-ui/react'; import { CheckboxGroup, Checkbox, Text, Flex, Link, useCheckboxGroup } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/token'; import type { NFTTokenType, TokenType } from 'types/api/token';
import { TOKEN_TYPES } from 'lib/token/tokenTypes'; import { NFT_TOKEN_TYPES, TOKEN_TYPES } from 'lib/token/tokenTypes';
type Props = { type Props<T extends TokenType | NFTTokenType> = {
onChange: (nextValue: Array<TokenType>) => void; onChange: (nextValue: Array<T>) => void;
defaultValue?: Array<TokenType>; defaultValue?: Array<T>;
nftOnly: T extends NFTTokenType ? true : false;
} }
const TokenTypeFilter = <T extends TokenType | NFTTokenType>({ nftOnly, onChange, defaultValue }: Props<T>) => {
const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
const { value, setValue } = useCheckboxGroup({ defaultValue }); const { value, setValue } = useCheckboxGroup({ defaultValue });
const handleReset = React.useCallback(() => { const handleReset = React.useCallback(() => {
...@@ -21,7 +21,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => { ...@@ -21,7 +21,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
onChange([]); onChange([]);
}, [ onChange, setValue, value.length ]); }, [ onChange, setValue, value.length ]);
const handleChange = React.useCallback((nextValue: Array<TokenType>) => { const handleChange = React.useCallback((nextValue: Array<T>) => {
setValue(nextValue); setValue(nextValue);
onChange(nextValue); onChange(nextValue);
}, [ onChange, setValue ]); }, [ onChange, setValue ]);
...@@ -32,6 +32,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => { ...@@ -32,6 +32,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
<Text fontWeight={ 600 } variant="secondary">Type</Text> <Text fontWeight={ 600 } variant="secondary">Type</Text>
<Link <Link
onClick={ handleReset } onClick={ handleReset }
cursor={ value.length > 0 ? 'pointer' : 'unset' }
color={ value.length > 0 ? 'link' : 'text_secondary' } color={ value.length > 0 ? 'link' : 'text_secondary' }
_hover={{ _hover={{
color: value.length > 0 ? 'link_hovered' : 'text_secondary', color: value.length > 0 ? 'link_hovered' : 'text_secondary',
...@@ -41,7 +42,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => { ...@@ -41,7 +42,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
</Link> </Link>
</Flex> </Flex>
<CheckboxGroup size="lg" onChange={ handleChange } value={ value }> <CheckboxGroup size="lg" onChange={ handleChange } value={ value }>
{ TOKEN_TYPES.map(({ title, id }) => ( { (nftOnly ? NFT_TOKEN_TYPES : TOKEN_TYPES).map(({ title, id }) => (
<Checkbox key={ id } value={ id }> <Checkbox key={ id } value={ id }>
<Text fontSize="md">{ title }</Text> <Text fontSize="md">{ title }</Text>
</Checkbox> </Checkbox>
......
import type { SystemStyleObject } from '@chakra-ui/react'; import type { SystemStyleObject } from '@chakra-ui/react';
import { Box, useColorMode, Flex, useToken } from '@chakra-ui/react'; import { Box, useColorMode, Flex, useToken, Center } from '@chakra-ui/react';
import type { EditorProps } from '@monaco-editor/react'; import type { EditorProps } from '@monaco-editor/react';
import MonacoEditor from '@monaco-editor/react'; import MonacoEditor from '@monaco-editor/react';
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
...@@ -11,6 +11,7 @@ import type { SmartContractExternalLibrary } from 'types/api/contract'; ...@@ -11,6 +11,7 @@ import type { SmartContractExternalLibrary } from 'types/api/contract';
import useClientRect from 'lib/hooks/useClientRect'; import useClientRect from 'lib/hooks/useClientRect';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import isMetaKey from 'lib/isMetaKey'; import isMetaKey from 'lib/isMetaKey';
import ErrorBoundary from 'ui/shared/ErrorBoundary';
import CodeEditorBreadcrumbs from './CodeEditorBreadcrumbs'; import CodeEditorBreadcrumbs from './CodeEditorBreadcrumbs';
import CodeEditorLoading from './CodeEditorLoading'; import CodeEditorLoading from './CodeEditorLoading';
...@@ -207,6 +208,10 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props) ...@@ -207,6 +208,10 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
}, },
}), [ editorWidth, themeColors, borderRadius ]); }), [ editorWidth, themeColors, borderRadius ]);
const renderErrorScreen = React.useCallback(() => {
return <Center bgColor={ themeColors['editor.background'] } w="100%" borderRadius="md">Oops! Something went wrong!</Center>;
}, [ themeColors ]);
if (data.length === 1) { if (data.length === 1) {
const sx = { const sx = {
...containerSx, ...containerSx,
...@@ -220,15 +225,17 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props) ...@@ -220,15 +225,17 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
return ( return (
<Box height={ `${ EDITOR_HEIGHT }px` } width="100%" sx={ sx } ref={ containerNodeRef }> <Box height={ `${ EDITOR_HEIGHT }px` } width="100%" sx={ sx } ref={ containerNodeRef }>
<MonacoEditor <ErrorBoundary renderErrorScreen={ renderErrorScreen }>
className="editor-container" <MonacoEditor
language={ editorLanguage } className="editor-container"
path={ data[index].file_path } language={ editorLanguage }
defaultValue={ data[index].source_code } path={ data[index].file_path }
options={ EDITOR_OPTIONS } defaultValue={ data[index].source_code }
onMount={ handleEditorDidMount } options={ EDITOR_OPTIONS }
loading={ <CodeEditorLoading borderRadius="md"/> } onMount={ handleEditorDidMount }
/> loading={ <CodeEditorLoading borderRadius="md"/> }
/>
</ErrorBoundary>
</Box> </Box>
); );
} }
...@@ -247,34 +254,36 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props) ...@@ -247,34 +254,36 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
onKeyDown={ handleKeyDown } onKeyDown={ handleKeyDown }
onKeyUp={ handleKeyUp } onKeyUp={ handleKeyUp }
> >
<Box flexGrow={ 1 }> <ErrorBoundary renderErrorScreen={ renderErrorScreen }>
<CodeEditorTabs <Box flexGrow={ 1 }>
tabs={ tabs } <CodeEditorTabs
activeTab={ data[index].file_path } tabs={ tabs }
activeTab={ data[index].file_path }
mainFile={ mainFile }
onTabSelect={ handleTabSelect }
onTabClose={ handleTabClose }
/>
<CodeEditorBreadcrumbs path={ data[index].file_path }/>
<MonacoEditor
className="editor-container"
height={ `${ EDITOR_HEIGHT }px` }
language={ editorLanguage }
path={ data[index].file_path }
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading borderBottomLeftRadius="md"/> }
/>
</Box>
<CodeEditorSideBar
data={ data }
onFileSelect={ handleSelectFile }
monaco={ instance }
editor={ editor }
selectedFile={ data[index].file_path }
mainFile={ mainFile } mainFile={ mainFile }
onTabSelect={ handleTabSelect }
onTabClose={ handleTabClose }
/> />
<CodeEditorBreadcrumbs path={ data[index].file_path }/> </ErrorBoundary>
<MonacoEditor
className="editor-container"
height={ `${ EDITOR_HEIGHT }px` }
language={ editorLanguage }
path={ data[index].file_path }
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading borderBottomLeftRadius="md"/> }
/>
</Box>
<CodeEditorSideBar
data={ data }
onFileSelect={ handleSelectFile }
monaco={ instance }
editor={ editor }
selectedFile={ data[index].file_path }
mainFile={ mainFile }
/>
</Flex> </Flex>
); );
}; };
......
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import RadioButtonGroupTest from './specs/RadioButtonGroupTest';
test('radio button group', async({ mount }) => {
const component = await mount(
<TestApp>
<Box w="400px" p="10px">
<RadioButtonGroupTest/>
</Box>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { ButtonGroup, Button, Flex, Icon, useRadio, useRadioGroup, useColorModeValue } from '@chakra-ui/react';
import type { UseRadioProps } from '@chakra-ui/react';
import React from 'react';
type RadioItemProps = {
title: string;
icon?: React.FC<React.SVGAttributes<SVGElement>>;
onlyIcon: false | undefined;
} | {
title: string;
icon: React.FC<React.SVGAttributes<SVGElement>>;
onlyIcon: true;
}
type RadioButtonProps = UseRadioProps & RadioItemProps;
const RadioButton = (props: RadioButtonProps) => {
const { getInputProps, getRadioProps } = useRadio(props);
const buttonColor = useColorModeValue('blue.50', 'gray.800');
const checkedTextColor = useColorModeValue('blue.700', 'gray.50');
const input = getInputProps();
const checkbox = getRadioProps();
const styleProps = {
flex: 1,
variant: 'outline',
fontWeight: 500,
cursor: props.isChecked ? 'initial' : 'pointer',
borderColor: buttonColor,
backgroundColor: props.isChecked ? buttonColor : 'none',
_hover: {
borderColor: buttonColor,
...(props.isChecked ? {} : { color: 'link_hovered' }),
},
_active: {
backgroundColor: 'none',
},
...(props.isChecked ? { color: checkedTextColor } : {}),
};
if (props.onlyIcon) {
return (
<Button
as="label"
aria-label={ props.title }
{ ...styleProps }
>
<input { ...input }/>
<Flex
{ ...checkbox }
>
<Icon as={ props.icon } boxSize={ 5 }/>
</Flex>
</Button>
);
}
return (
<Button
as="label"
leftIcon={ props.icon ? <Icon as={ props.icon } boxSize={ 5 } mr={ -1 }/> : undefined }
{ ...styleProps }
>
<input { ...input }/>
<Flex
{ ...checkbox }
>
{ props.title }
</Flex>
</Button>
);
};
type RadioButtonGroupProps<T extends string> = {
onChange: (value: T) => void;
name: string;
defaultValue: string;
options: Array<{ value: T } & RadioItemProps>;
}
const RadioButtonGroup = <T extends string>({ onChange, name, defaultValue, options }: RadioButtonGroupProps<T>) => {
const { getRootProps, getRadioProps } = useRadioGroup({ name, defaultValue, onChange });
const group = getRootProps();
return (
<ButtonGroup { ...group } isAttached size="sm" display="grid" gridTemplateColumns={ `repeat(${ options.length }, 1fr)` }>
{ options.map((option) => {
const props = getRadioProps({ value: option.value });
return <RadioButton { ...props } key={ option.value } { ...option }/>;
}) }
</ButtonGroup>
);
};
export default RadioButtonGroup;
import React from 'react';
import RadioButtonGroup from '../RadioButtonGroup';
const TestIcon = ({ className }: {className?: string}) => {
return (
<svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" className={ className }>
{ /* eslint-disable-next-line max-len */ }
<path fillRule="evenodd" clipRule="evenodd" d="M3.5 11a7.5 7.5 0 1 1 15 0 7.5 7.5 0 0 1-15 0ZM11 1C5.477 1 1 5.477 1 11s4.477 10 10 10 10-4.477 10-10S16.523 1 11 1Zm1.25 5a1.25 1.25 0 1 0-2.5 0v5c0 .69.56 1.25 1.25 1.25h5a1.25 1.25 0 1 0 0-2.5h-3.75V6Z" fill="currentColor" stroke="transparent" strokeWidth=".6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
};
type Test = 'v1' | 'v2' | 'v3';
const RadioButtonGroupTest = () => {
return (
<RadioButtonGroup<Test>
// eslint-disable-next-line react/jsx-no-bind
onChange={ () => {} }
defaultValue="v1"
name="test"
options={ [
{ value: 'v1', title: 'test option 1', icon: TestIcon, onlyIcon: false },
{ value: 'v2', title: 'test 2', onlyIcon: false },
{ value: 'v2', title: 'test 2', icon: TestIcon, onlyIcon: true },
] }
/>
);
};
export default RadioButtonGroupTest;
...@@ -27,7 +27,7 @@ const TxFeeStability = ({ data, isLoading, hideUsd, accuracy, className }: Props ...@@ -27,7 +27,7 @@ const TxFeeStability = ({ data, isLoading, hideUsd, accuracy, className }: Props
return ( return (
<Skeleton whiteSpace="pre" isLoaded={ !isLoading } display="flex" className={ className }> <Skeleton whiteSpace="pre" isLoaded={ !isLoading } display="flex" className={ className }>
<span>{ valueStr } </span> <span>{ valueStr } </span>
{ valueStr !== '0' && <TokenEntity token={ data.token } noIcon noCopy onlySymbol w="auto"/> } { valueStr !== '0' && <TokenEntity token={ data.token } noCopy onlySymbol w="auto" ml={ 1 }/> }
{ usd && !hideUsd && <chakra.span color="text_secondary"> (${ usd })</chakra.span> } { usd && !hideUsd && <chakra.span color="text_secondary"> (${ usd })</chakra.span> }
</Skeleton> </Skeleton>
); );
......
import { Text, Icon, HStack } from '@chakra-ui/react'; import { Text, Icon, HStack } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { Step } from './types';
import arrowIcon from 'icons/arrows/east.svg'; import arrowIcon from 'icons/arrows/east.svg';
import finalizedIcon from 'icons/finalized.svg'; import finalizedIcon from 'icons/finalized.svg';
import unfinalizedIcon from 'icons/unfinalized.svg'; import unfinalizedIcon from 'icons/unfinalized.svg';
type Props = { type Props = {
step: string; step: Step;
isLast: boolean; isLast: boolean;
isPassed: boolean; isPassed: boolean;
} }
...@@ -17,7 +19,7 @@ const VerificationStep = ({ step, isLast, isPassed }: Props) => { ...@@ -17,7 +19,7 @@ const VerificationStep = ({ step, isLast, isPassed }: Props) => {
return ( return (
<HStack gap={ 2 } color={ stepColor }> <HStack gap={ 2 } color={ stepColor }>
<Icon as={ isPassed ? finalizedIcon : unfinalizedIcon } boxSize={ 5 }/> <Icon as={ isPassed ? finalizedIcon : unfinalizedIcon } boxSize={ 5 }/>
<Text color={ stepColor }>{ step }</Text> <Text color={ stepColor }>{ typeof step === 'string' ? step : step.content }</Text>
{ !isLast && <Icon as={ arrowIcon } boxSize={ 5 }/> } { !isLast && <Icon as={ arrowIcon } boxSize={ 5 }/> }
</HStack> </HStack>
); );
......
...@@ -13,7 +13,7 @@ test('first step +@mobile +@dark-mode', async({ mount }) => { ...@@ -13,7 +13,7 @@ test('first step +@mobile +@dark-mode', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<Box p={ 10 }> <Box p={ 10 }>
<VerificationSteps step={ ZKEVM_L2_TX_STATUSES[0] } steps={ ZKEVM_L2_TX_STATUSES }/> <VerificationSteps currentStep={ ZKEVM_L2_TX_STATUSES[0] } steps={ ZKEVM_L2_TX_STATUSES }/>
</Box> </Box>
</TestApp>, </TestApp>,
); );
...@@ -25,7 +25,7 @@ test('second status', async({ mount }) => { ...@@ -25,7 +25,7 @@ test('second status', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<VerificationSteps step={ ZKEVM_L2_TX_STATUSES[1] } steps={ ZKEVM_L2_TX_STATUSES }/> <VerificationSteps currentStep={ ZKEVM_L2_TX_STATUSES[1] } steps={ ZKEVM_L2_TX_STATUSES }/>
</TestApp>, </TestApp>,
); );
......
import { Skeleton, chakra } from '@chakra-ui/react'; import { Skeleton, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { Step } from './types';
import VerificationStep from './VerificationStep'; import VerificationStep from './VerificationStep';
export interface Props<T extends string> { export interface Props {
step: T; currentStep: string;
steps: Array<T>; steps: Array<Step>;
isLoading?: boolean; isLoading?: boolean;
rightSlot?: React.ReactNode; rightSlot?: React.ReactNode;
className?: string; className?: string;
} }
const VerificationSteps = <T extends string>({ step, steps, isLoading, rightSlot, className }: Props<T>) => { const VerificationSteps = ({ currentStep, steps, isLoading, rightSlot, className }: Props) => {
const currentStepIndex = steps.indexOf(step); const currentStepIndex = steps.findIndex((step) => {
const label = typeof step === 'string' ? step : step.label;
return label === currentStep;
});
return ( return (
<Skeleton <Skeleton
...@@ -24,7 +29,12 @@ const VerificationSteps = <T extends string>({ step, steps, isLoading, rightSlot ...@@ -24,7 +29,12 @@ const VerificationSteps = <T extends string>({ step, steps, isLoading, rightSlot
flexWrap="wrap" flexWrap="wrap"
> >
{ steps.map((step, index) => ( { steps.map((step, index) => (
<VerificationStep step={ step } isLast={ index === steps.length - 1 && !rightSlot } isPassed={ index <= currentStepIndex } key={ step }/> <VerificationStep
key={ currentStep }
step={ step }
isLast={ index === steps.length - 1 && !rightSlot }
isPassed={ index <= currentStepIndex }
/>
)) } )) }
{ rightSlot } { rightSlot }
</Skeleton> </Skeleton>
......
export type Step = string | { label: string; content: React.ReactNode };
...@@ -64,7 +64,7 @@ const Footer = () => { ...@@ -64,7 +64,7 @@ const Footer = () => {
}, },
{ {
icon: discordIcon, icon: discordIcon,
iconSize: '18px', iconSize: '24px',
text: 'Discord', text: 'Discord',
url: 'https://discord.gg/blockscout', url: 'https://discord.gg/blockscout',
}, },
......
import { Link, Text, HStack, Tooltip, Box, useBreakpointValue, chakra, shouldForwardProp } from '@chakra-ui/react'; import { Link, Text, HStack, Tooltip, Box, useBreakpointValue, chakra, shouldForwardProp, Icon } from '@chakra-ui/react';
import NextLink from 'next/link'; import NextLink from 'next/link';
import React from 'react'; import React from 'react';
...@@ -6,6 +6,7 @@ import type { NavItem } from 'types/client/navigation-items'; ...@@ -6,6 +6,7 @@ import type { NavItem } from 'types/client/navigation-items';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import arrowIcon from 'icons/arrows/north-east.svg';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import { isInternalItem } from 'lib/hooks/useNavItems'; import { isInternalItem } from 'lib/hooks/useNavItems';
...@@ -43,6 +44,11 @@ const NavLink = ({ item, isCollapsed, px, className, onClick }: Props) => { ...@@ -43,6 +44,11 @@ const NavLink = ({ item, isCollapsed, px, className, onClick }: Props) => {
aria-label={ `${ item.text } link` } aria-label={ `${ item.text } link` }
whiteSpace="nowrap" whiteSpace="nowrap"
onClick={ onClick } onClick={ onClick }
_hover={{
'& *': {
color: 'link_hovered',
},
}}
> >
<Tooltip <Tooltip
label={ item.text } label={ item.text }
...@@ -56,7 +62,8 @@ const NavLink = ({ item, isCollapsed, px, className, onClick }: Props) => { ...@@ -56,7 +62,8 @@ const NavLink = ({ item, isCollapsed, px, className, onClick }: Props) => {
<HStack spacing={ 3 } overflow="hidden"> <HStack spacing={ 3 } overflow="hidden">
<NavLinkIcon item={ item }/> <NavLinkIcon item={ item }/>
<Text { ...styleProps.textProps }> <Text { ...styleProps.textProps }>
{ item.text } <span>{ item.text }</span>
{ !isInternalLink && <Icon as={ arrowIcon } boxSize={ 4 } color="text_secondary" verticalAlign="middle"/> }
</Text> </Text>
</HStack> </HStack>
</Tooltip> </Tooltip>
......
...@@ -110,6 +110,7 @@ test.describe('with tooltips', () => { ...@@ -110,6 +110,7 @@ test.describe('with tooltips', () => {
{ hooksConfig }, { hooksConfig },
); );
await component.locator('header').hover();
await page.locator('svg[aria-label="Expand/Collapse menu"]').click(); await page.locator('svg[aria-label="Expand/Collapse menu"]').click();
await page.locator('a[aria-label="Tokens link"]').hover(); await page.locator('a[aria-label="Tokens link"]').hover();
...@@ -212,3 +213,37 @@ base.describe('cookie set to true', () => { ...@@ -212,3 +213,37 @@ base.describe('cookie set to true', () => {
expect(await networkMenu.isVisible()).toBe(false); expect(await networkMenu.isVisible()).toBe(false);
}); });
}); });
test('hover +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Box bgColor="lightpink" w="100%"/>
</Flex>
</TestApp>,
{ hooksConfig },
);
await component.locator('header').hover();
await expect(component).toHaveScreenshot();
});
test.describe('hover xl screen', () => {
test.use({ viewport: configs.viewport.xl });
test('+@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Box bgColor="lightpink" w="100%"/>
</Flex>
</TestApp>,
{ hooksConfig },
);
await component.locator('header').hover();
await expect(component).toHaveScreenshot();
});
});
...@@ -59,6 +59,11 @@ const NavigationDesktop = () => { ...@@ -59,6 +59,11 @@ const NavigationDesktop = () => {
py={ 12 } py={ 12 }
width={{ lg: isExpanded ? '229px' : '92px', xl: isCollapsed ? '92px' : '229px' }} width={{ lg: isExpanded ? '229px' : '92px', xl: isCollapsed ? '92px' : '229px' }}
{ ...getDefaultTransitionProps({ transitionProperty: 'width, padding' }) } { ...getDefaultTransitionProps({ transitionProperty: 'width, padding' }) }
sx={{
'&:hover #expand-icon': {
display: 'block',
},
}}
> >
{ config.chain.isTestnet && <Icon as={ testnetIcon } h="14px" w="auto" color="red.400" position="absolute" pl={ 3 } top="34px"/> } { config.chain.isTestnet && <Icon as={ testnetIcon } h="14px" w="auto" color="red.400" position="absolute" pl={ 3 } top="34px"/> }
<Box <Box
...@@ -113,6 +118,8 @@ const NavigationDesktop = () => { ...@@ -113,6 +118,8 @@ const NavigationDesktop = () => {
cursor="pointer" cursor="pointer"
onClick={ handleTogglerClick } onClick={ handleTogglerClick }
aria-label="Expand/Collapse menu" aria-label="Expand/Collapse menu"
id="expand-icon"
display="none"
/> />
</Flex> </Flex>
); );
......
...@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react'; ...@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react'; import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import { tokenInfoERC721a } from 'mocks/tokens/tokenInfo';
import { base as tokenInstanse } from 'mocks/tokens/tokenInstance'; import { base as tokenInstanse } from 'mocks/tokens/tokenInstance';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
...@@ -23,6 +24,11 @@ test('base view +@mobile', async({ mount }) => { ...@@ -23,6 +24,11 @@ test('base view +@mobile', async({ mount }) => {
// @ts-ignore: // @ts-ignore:
pagination: { page: 1, isVisible: true }, pagination: { page: 1, isVisible: true },
}} }}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
tokenQuery={{
data: tokenInfoERC721a,
}}
/> />
</TestApp>, </TestApp>,
); );
......
import { Grid } from '@chakra-ui/react'; import { Flex, Grid, Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/token';
import type { ResourceError } from 'lib/api/resources';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import ResetIconButton from 'ui/shared/ResetIconButton';
import TokenInventoryItem from './TokenInventoryItem'; import TokenInventoryItem from './TokenInventoryItem';
type Props = { type Props = {
inventoryQuery: QueryWithPagesResult<'token_inventory'>; inventoryQuery: QueryWithPagesResult<'token_inventory'>;
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
ownerFilter?: string;
} }
const TokenInventory = ({ inventoryQuery }: Props) => { const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const actionBar = isMobile && inventoryQuery.pagination.isVisible && ( const resetOwnerFilter = React.useCallback(() => {
<ActionBar mt={ -6 }> inventoryQuery.onFilterChange({});
<Pagination ml="auto" { ...inventoryQuery.pagination }/> }, [ inventoryQuery ]);
</ActionBar>
const isActionBarHidden = !ownerFilter && !inventoryQuery.data?.items.length;
const ownerFilterComponent = ownerFilter && (
<Flex
alignItems="center"
flexWrap="wrap"
mb={{ base: isActionBarHidden ? 3 : 6, lg: 3 }}
mr={ 4 }
>
<Text whiteSpace="nowrap" mr={ 2 } py={ 1 }>Filtered by owner</Text>
<Flex alignItems="center" py={ 1 }>
<AddressEntity address={{ hash: ownerFilter }} truncation={ isMobile ? 'constant' : 'none' }/>
<ResetIconButton onClick={ resetOwnerFilter }/>
</Flex>
</Flex>
);
const actionBar = !isActionBarHidden && (
<>
{ ownerFilterComponent }
<ActionBar mt={ -6 }>
{ isMobile && <Pagination ml="auto" { ...inventoryQuery.pagination }/> }
</ActionBar>
</>
); );
const items = inventoryQuery.data?.items; const items = inventoryQuery.data?.items;
const token = tokenQuery.data;
const content = items ? ( const content = items && token ? (
<Grid <Grid
w="100%" w="100%"
columnGap={{ base: 3, lg: 6 }} columnGap={{ base: 3, lg: 6 }}
...@@ -33,9 +66,10 @@ const TokenInventory = ({ inventoryQuery }: Props) => { ...@@ -33,9 +66,10 @@ const TokenInventory = ({ inventoryQuery }: Props) => {
> >
{ items.map((item, index) => ( { items.map((item, index) => (
<TokenInventoryItem <TokenInventoryItem
key={ item.token.address + '_' + item.id + (inventoryQuery.isPlaceholderData ? '_' + index : '') } key={ token.address + '_' + item.id + (inventoryQuery.isPlaceholderData ? '_' + index : '') }
item={ item } item={ item }
isLoading={ inventoryQuery.isPlaceholderData } isLoading={ inventoryQuery.isPlaceholderData || tokenQuery.isPlaceholderData }
token={ token }
/> />
)) } )) }
</Grid> </Grid>
......
import { Box, Flex, Text, Link, useColorModeValue, Skeleton } from '@chakra-ui/react'; import { Box, Flex, Text, Link, useColorModeValue, Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token'; import type { TokenInfo, TokenInstance } from 'types/api/token';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -11,9 +11,9 @@ import LinkInternal from 'ui/shared/LinkInternal'; ...@@ -11,9 +11,9 @@ import LinkInternal from 'ui/shared/LinkInternal';
import NftMedia from 'ui/shared/nft/NftMedia'; import NftMedia from 'ui/shared/nft/NftMedia';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
type Props = { item: TokenInstance; isLoading: boolean }; type Props = { item: TokenInstance; token: TokenInfo; isLoading: boolean };
const NFTItem = ({ item, isLoading }: Props) => { const TokenInventoryItem = ({ item, token, isLoading }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -25,7 +25,7 @@ const NFTItem = ({ item, isLoading }: Props) => { ...@@ -25,7 +25,7 @@ const NFTItem = ({ item, isLoading }: Props) => {
/> />
); );
const url = route({ pathname: '/token/[hash]/instance/[id]', query: { hash: item.token.address, id: item.id } }); const url = route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: item.id } });
return ( return (
<Box <Box
...@@ -76,4 +76,4 @@ const NFTItem = ({ item, isLoading }: Props) => { ...@@ -76,4 +76,4 @@ const NFTItem = ({ item, isLoading }: Props) => {
); );
}; };
export default NFTItem; export default TokenInventoryItem;
import { Flex, Skeleton } from '@chakra-ui/react'; import { Flex, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import eastArrowIcon from 'icons/arrows/east.svg'; import eastArrowIcon from 'icons/arrows/east.svg';
import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Icon from 'ui/shared/chakra/Icon'; import Icon from 'ui/shared/chakra/Icon';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -27,15 +27,14 @@ const TokenTransferListItem = ({ ...@@ -27,15 +27,14 @@ const TokenTransferListItem = ({
tokenId, tokenId,
isLoading, isLoading,
}: Props) => { }: Props) => {
const value = (() => {
if (!('value' in total)) {
return null;
}
return BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat();
})();
const timeAgo = useTimeAgoIncrement(timestamp, true); const timeAgo = useTimeAgoIncrement(timestamp, true);
const { usd, valueStr } = 'value' in total ? getCurrencyValue({
value: total.value,
exchangeRate: token.exchange_rate,
accuracy: 8,
accuracyUsd: 2,
decimals: total.decimals || '0',
}) : { usd: null, valueStr: null };
return ( return (
<ListItemMobile rowGap={ 3 } isAnimated> <ListItemMobile rowGap={ 3 } isAnimated>
...@@ -72,15 +71,16 @@ const TokenTransferListItem = ({ ...@@ -72,15 +71,16 @@ const TokenTransferListItem = ({
fontWeight="500" fontWeight="500"
/> />
</Flex> </Flex>
{ value && (token.type === 'ERC-20' || token.type === 'ERC-1155') && ( { valueStr && (token.type === 'ERC-20' || token.type === 'ERC-1155') && (
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !isLoading } flexShrink={ 0 } fontWeight={ 500 }> <Skeleton isLoaded={ !isLoading } flexShrink={ 0 } fontWeight={ 500 }>
Value Value
</Skeleton> </Skeleton>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ value }</span> <span>{ valueStr }</span>
</Skeleton> </Skeleton>
{ token.symbol && <TruncatedValue isLoading={ isLoading } value={ token.symbol }/> } { token.symbol && <TruncatedValue isLoading={ isLoading } value={ token.symbol }/> }
{ usd && <Skeleton isLoaded={ !isLoading } color="text_secondary"><span>(${ usd })</span></Skeleton> }
</Flex> </Flex>
) } ) }
{ 'token_id' in total && (token.type === 'ERC-721' || token.type === 'ERC-1155') && ( { 'token_id' in total && (token.type === 'ERC-721' || token.type === 'ERC-1155') && (
......
import { Tr, Td, Grid, Skeleton, Box } from '@chakra-ui/react'; import { Tr, Td, Grid, Skeleton, Box } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import eastArrowIcon from 'icons/arrows/east.svg'; import eastArrowIcon from 'icons/arrows/east.svg';
import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Icon from 'ui/shared/chakra/Icon'; import Icon from 'ui/shared/chakra/Icon';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -26,6 +26,13 @@ const TokenTransferTableItem = ({ ...@@ -26,6 +26,13 @@ const TokenTransferTableItem = ({
isLoading, isLoading,
}: Props) => { }: Props) => {
const timeAgo = useTimeAgoIncrement(timestamp, true); const timeAgo = useTimeAgoIncrement(timestamp, true);
const { usd, valueStr } = 'value' in total ? getCurrencyValue({
value: total.value,
exchangeRate: token.exchange_rate,
accuracy: 8,
accuracyUsd: 2,
decimals: total.decimals || '0',
}) : { usd: null, valueStr: null };
return ( return (
<Tr alignItems="top"> <Tr alignItems="top">
...@@ -91,9 +98,16 @@ const TokenTransferTableItem = ({ ...@@ -91,9 +98,16 @@ const TokenTransferTableItem = ({
) } ) }
{ (token.type === 'ERC-20' || token.type === 'ERC-1155') && ( { (token.type === 'ERC-20' || token.type === 'ERC-1155') && (
<Td isNumeric verticalAlign="top"> <Td isNumeric verticalAlign="top">
<Skeleton isLoaded={ !isLoading } my="7px"> { valueStr && (
{ 'value' in total && BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat() } <Skeleton isLoaded={ !isLoading } display="inline-block" mt="7px" wordBreak="break-all">
</Skeleton> { valueStr }
</Skeleton>
) }
{ usd && (
<Skeleton isLoaded={ !isLoading } color="text_secondary" mt="10px" wordBreak="break-all">
<span>${ usd }</span>
</Skeleton>
) }
</Td> </Td>
) } ) }
</Tr> </Tr>
......
...@@ -2,6 +2,7 @@ import { test, expect } from '@playwright/experimental-ct-react'; ...@@ -2,6 +2,7 @@ import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as addressMock from 'mocks/address/address'; import * as addressMock from 'mocks/address/address';
import { tokenInfoERC721a } from 'mocks/tokens/tokenInfo';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance'; import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
...@@ -9,10 +10,12 @@ import * as configs from 'playwright/utils/configs'; ...@@ -9,10 +10,12 @@ import * as configs from 'playwright/utils/configs';
import TokenInstanceDetails from './TokenInstanceDetails'; import TokenInstanceDetails from './TokenInstanceDetails';
const API_URL_ADDRESS = buildApiUrl('address', { hash: tokenInstanceMock.base.token.address }); const hash = tokenInfoERC721a.address;
const API_URL_ADDRESS = buildApiUrl('address', { hash });
const API_URL_TOKEN_TRANSFERS_COUNT = buildApiUrl('token_instance_transfers_count', { const API_URL_TOKEN_TRANSFERS_COUNT = buildApiUrl('token_instance_transfers_count', {
id: tokenInstanceMock.unique.id, id: tokenInstanceMock.unique.id,
hash: tokenInstanceMock.unique.token.address, hash,
}); });
test('base view +@dark-mode +@mobile', async({ mount, page }) => { test('base view +@dark-mode +@mobile', async({ mount, page }) => {
...@@ -31,7 +34,7 @@ test('base view +@dark-mode +@mobile', async({ mount, page }) => { ...@@ -31,7 +34,7 @@ test('base view +@dark-mode +@mobile', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TokenInstanceDetails data={ tokenInstanceMock.unique }/> <TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>
</TestApp>, </TestApp>,
); );
......
import { Flex, Grid, Skeleton } from '@chakra-ui/react'; import { Flex, Grid, Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token'; import type { TokenInfo, TokenInstance } from 'types/api/token';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
...@@ -18,11 +18,12 @@ import TokenInstanceTransfersCount from './details/TokenInstanceTransfersCount'; ...@@ -18,11 +18,12 @@ import TokenInstanceTransfersCount from './details/TokenInstanceTransfersCount';
interface Props { interface Props {
data?: TokenInstance; data?: TokenInstance;
token?: TokenInfo;
isLoading?: boolean; isLoading?: boolean;
scrollRef?: React.RefObject<HTMLDivElement>; scrollRef?: React.RefObject<HTMLDivElement>;
} }
const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => { const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
const handleCounterItemClick = React.useCallback(() => { const handleCounterItemClick = React.useCallback(() => {
window.setTimeout(() => { window.setTimeout(() => {
// cannot do scroll instantly, have to wait a little // cannot do scroll instantly, have to wait a little
...@@ -30,7 +31,7 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => { ...@@ -30,7 +31,7 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
}, 500); }, 500);
}, [ scrollRef ]); }, [ scrollRef ]);
if (!data) { if (!data || !token) {
return null; return null;
} }
...@@ -56,7 +57,7 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => { ...@@ -56,7 +57,7 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
/> />
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
<TokenInstanceCreatorAddress hash={ isLoading ? '' : data.token.address }/> <TokenInstanceCreatorAddress hash={ isLoading ? '' : token.address }/>
<DetailsInfoItem <DetailsInfoItem
title="Token ID" title="Token ID"
hint="This token instance unique token ID" hint="This token instance unique token ID"
...@@ -69,8 +70,8 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => { ...@@ -69,8 +70,8 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
<CopyToClipboard text={ data.id } isLoading={ isLoading }/> <CopyToClipboard text={ data.id } isLoading={ isLoading }/>
</Flex> </Flex>
</DetailsInfoItem> </DetailsInfoItem>
<TokenInstanceTransfersCount hash={ isLoading ? '' : data.token.address } id={ isLoading ? '' : data.id } onClick={ handleCounterItemClick }/> <TokenInstanceTransfersCount hash={ isLoading ? '' : token.address } id={ isLoading ? '' : data.id } onClick={ handleCounterItemClick }/>
<TokenNftMarketplaces isLoading={ isLoading } hash={ data.token.address } id={ data.id }/> <TokenNftMarketplaces isLoading={ isLoading } hash={ token.address } id={ data.id }/>
</Grid> </Grid>
<NftMedia <NftMedia
url={ data.animation_url || data.image_url } url={ data.animation_url || data.image_url }
......
...@@ -167,7 +167,7 @@ const TxDetails = () => { ...@@ -167,7 +167,7 @@ const TxDetails = () => {
hint="Status of the transaction confirmation path to L1" hint="Status of the transaction confirmation path to L1"
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<VerificationSteps step={ data.zkevm_status } steps={ ZKEVM_L2_TX_STATUSES } isLoading={ isPlaceholderData }/> <VerificationSteps currentStep={ data.zkevm_status } steps={ ZKEVM_L2_TX_STATUSES } isLoading={ isPlaceholderData }/>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ data.revert_reason && ( { data.revert_reason && (
...@@ -312,7 +312,7 @@ const TxDetails = () => { ...@@ -312,7 +312,7 @@ const TxDetails = () => {
<span>[ Contract creation ]</span> <span>[ Contract creation ]</span>
) } ) }
</DetailsInfoItem> </DetailsInfoItem>
{ data.token_transfers && <TxDetailsTokenTransfers data={ data.token_transfers } txHash={ data.hash }/> } { data.token_transfers && <TxDetailsTokenTransfers data={ data.token_transfers } txHash={ data.hash } isOverflow={ data.token_transfers_overflow }/> }
<DetailsInfoItemDivider/> <DetailsInfoItemDivider/>
......
...@@ -14,6 +14,7 @@ import TxDetailsTokenTransfer from './TxDetailsTokenTransfer'; ...@@ -14,6 +14,7 @@ import TxDetailsTokenTransfer from './TxDetailsTokenTransfer';
interface Props { interface Props {
data: Array<TokenTransfer>; data: Array<TokenTransfer>;
txHash: string; txHash: string;
isOverflow: boolean;
} }
const TOKEN_TRANSFERS_TYPES = [ const TOKEN_TRANSFERS_TYPES = [
...@@ -22,16 +23,14 @@ const TOKEN_TRANSFERS_TYPES = [ ...@@ -22,16 +23,14 @@ const TOKEN_TRANSFERS_TYPES = [
{ title: 'Tokens burnt', hint: 'List of tokens burnt in the transaction', type: 'token_burning' }, { title: 'Tokens burnt', hint: 'List of tokens burnt in the transaction', type: 'token_burning' },
{ title: 'Tokens created', hint: 'List of tokens created in the transaction', type: 'token_spawning' }, { title: 'Tokens created', hint: 'List of tokens created in the transaction', type: 'token_spawning' },
]; ];
const VISIBLE_ITEMS_NUM = 3;
const TxDetailsTokenTransfers = ({ data, txHash }: Props) => { const TxDetailsTokenTransfers = ({ data, txHash, isOverflow }: Props) => {
const viewAllUrl = route({ pathname: '/tx/[hash]', query: { hash: txHash, tab: 'token_transfers' } }); const viewAllUrl = route({ pathname: '/tx/[hash]', query: { hash: txHash, tab: 'token_transfers' } });
const transferGroups = TOKEN_TRANSFERS_TYPES.map((group) => ({ const transferGroups = TOKEN_TRANSFERS_TYPES.map((group) => ({
...group, ...group,
items: data?.filter((token) => token.type === group.type) || [], items: data?.filter((token) => token.type === group.type) || [],
})); }));
const showViewAllLink = transferGroups.some(({ items }) => items.length > VISIBLE_ITEMS_NUM);
return ( return (
<> <>
...@@ -54,12 +53,12 @@ const TxDetailsTokenTransfers = ({ data, txHash }: Props) => { ...@@ -54,12 +53,12 @@ const TxDetailsTokenTransfers = ({ data, txHash }: Props) => {
w="100%" w="100%"
overflow="hidden" overflow="hidden"
> >
{ items.slice(0, VISIBLE_ITEMS_NUM).map((item, index) => <TxDetailsTokenTransfer key={ index } data={ item }/>) } { items.map((item, index) => <TxDetailsTokenTransfer key={ index } data={ item }/>) }
</Flex> </Flex>
</DetailsInfoItem> </DetailsInfoItem>
); );
}) } }) }
{ showViewAllLink && ( { isOverflow && (
<> <>
<Show above="lg" ssr={ false }><GridItem></GridItem></Show> <Show above="lg" ssr={ false }><GridItem></GridItem></Show>
<GridItem fontSize="sm" alignItems="center" display="inline-flex" pl={{ base: '28px', lg: 0 }}> <GridItem fontSize="sm" alignItems="center" display="inline-flex" pl={{ base: '28px', lg: 0 }}>
......
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { L2WithdrawalStatus } from 'types/api/l2Withdrawals';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import * as configs from 'playwright/utils/configs';
import TxDetailsWithdrawalStatus from './TxDetailsWithdrawalStatus';
const statuses: Array<L2WithdrawalStatus> = [
'Waiting for state root',
'Ready for relay',
'Relayed',
];
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
statuses.forEach((status) => {
test(`status="${ status }"`, async({ mount }) => {
const component = await mount(
<TestApp>
<TxDetailsWithdrawalStatus status={ status } l1TxHash="0x7d93a59a228e97d084a635181c3053e324237d07566ec12287eae6da2bcf9456"/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
});
...@@ -25,30 +25,40 @@ const TxDetailsWithdrawalStatus = ({ status, l1TxHash }: Props) => { ...@@ -25,30 +25,40 @@ const TxDetailsWithdrawalStatus = ({ status, l1TxHash }: Props) => {
const hasClaimButton = status === 'Ready for relay'; const hasClaimButton = status === 'Ready for relay';
const steps = hasClaimButton ? WITHDRAWAL_STATUSES.slice(0, -1) : WITHDRAWAL_STATUSES; const steps = (() => {
switch (status) {
case 'Ready for relay':
return WITHDRAWAL_STATUSES.slice(0, -1);
case 'Relayed': {
if (l1TxHash) {
return WITHDRAWAL_STATUSES.map((status) => {
return status === 'Relayed' ? {
content: <TxEntityL1 hash={ l1TxHash } truncation="constant" text="Relayed" noIcon/>,
label: status,
} : status;
});
}
const rightSlot = (() => { return WITHDRAWAL_STATUSES;
if (status === 'Relayed' && l1TxHash) { }
return <TxEntityL1 hash={ l1TxHash } truncation="constant"/>;
}
if (hasClaimButton) { default:
return ( return WITHDRAWAL_STATUSES;
<Button
variant="outline"
size="sm"
as="a"
href="https://app.optimism.io/bridge/withdraw"
target="_blank"
>
Claim funds
</Button>
);
} }
return null;
})(); })();
const rightSlot = hasClaimButton ? (
<Button
variant="outline"
size="sm"
as="a"
href="https://app.optimism.io/bridge/withdraw"
target="_blank"
>
Claim funds
</Button>
) : null;
return ( return (
<DetailsInfoItem <DetailsInfoItem
title="Withdrawal status" title="Withdrawal status"
...@@ -56,7 +66,7 @@ const TxDetailsWithdrawalStatus = ({ status, l1TxHash }: Props) => { ...@@ -56,7 +66,7 @@ const TxDetailsWithdrawalStatus = ({ status, l1TxHash }: Props) => {
> >
<VerificationSteps <VerificationSteps
steps={ steps as unknown as Array<L2WithdrawalStatus> } steps={ steps as unknown as Array<L2WithdrawalStatus> }
step={ status } currentStep={ status }
rightSlot={ rightSlot } rightSlot={ rightSlot }
my={ hasClaimButton ? '-6px' : 0 } my={ hasClaimButton ? '-6px' : 0 }
lineHeight={ hasClaimButton ? 8 : undefined } lineHeight={ hasClaimButton ? 8 : undefined }
......
import { Link, Table, Tbody, Tr, Th, Icon } from '@chakra-ui/react'; import { Link, Table, Tbody, Tr, Th, Icon, Show, Hide } from '@chakra-ui/react';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import React from 'react'; import React from 'react';
...@@ -48,9 +48,14 @@ const TxsTable = ({ ...@@ -48,9 +48,14 @@ const TxsTable = ({
<Th width="160px">Type</Th> <Th width="160px">Type</Th>
<Th width="20%">Method</Th> <Th width="20%">Method</Th>
{ showBlockInfo && <Th width="18%">Block</Th> } { showBlockInfo && <Th width="18%">Block</Th> }
<Th width={{ xl: '152px', base: '86px' }}>From</Th> <Th width={{ xl: '152px', base: '86px' }}>
<Show above="xl" ssr={ false }>From</Show>
<Hide above="xl" ssr={ false }>From / To</Hide>
</Th>
<Th width={{ xl: currentAddress ? '48px' : '36px', base: currentAddress ? '52px' : '28px' }}></Th> <Th width={{ xl: currentAddress ? '48px' : '36px', base: currentAddress ? '52px' : '28px' }}></Th>
<Th width={{ xl: '152px', base: '86px' }}>To</Th> <Th width={{ xl: '152px', base: '86px' }}>
<Show above="xl" ssr={ false }>To</Show>
</Th>
{ !config.UI.views.tx.hiddenFields?.value && ( { !config.UI.views.tx.hiddenFields?.value && (
<Th width="20%" isNumeric> <Th width="20%" isNumeric>
<Link onClick={ sort('val') } display="flex" justifyContent="end"> <Link onClick={ sort('val') } display="flex" justifyContent="end">
......
...@@ -86,7 +86,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => { ...@@ -86,7 +86,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
title="Status" title="Status"
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<VerificationSteps steps={ ZKEVM_L2_TX_BATCH_STATUSES } step={ data.status } isLoading={ isPlaceholderData }/> <VerificationSteps steps={ ZKEVM_L2_TX_BATCH_STATUSES } currentStep={ data.status } isLoading={ isPlaceholderData }/>
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem <DetailsInfoItem
title="Timestamp" title="Timestamp"
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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