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 = {
paths: [
{ 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: '@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 = {
},
},
{
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: {
// for configs allow to consume env variables from process.env directly
'no-restricted-properties': [ 0 ],
......
......@@ -40,10 +40,9 @@ jobs:
run: yarn build
env:
NODE_ENV: production
GENERATE_SOURCEMAPS: true
- name: Inject Sentry debug ID
run: yarn sentry-cli sourcemaps inject ./.next
- name: Upload source maps to Sentry
run: yarn sentry-cli sourcemaps upload --release=${{ github.ref_name }} --validate ./.next
\ No newline at end of file
run: yarn sentry-cli sourcemaps upload --release=${{ github.ref_name }} --url-prefix=~/_next/ --validate ./.next
\ No newline at end of file
......@@ -14,6 +14,7 @@
/out/
/public/assets/
/public/envs.js
/analyze
# production
/build
......
......@@ -27,6 +27,7 @@ const hiddenViews = (() => {
const config = Object.freeze({
identiconType,
hiddenViews,
solidityscanEnabled: getEnvValue('NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED') === 'true',
});
export default config;
......@@ -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_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true'
#meta
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
.transform(replaceQuotes)
.json()
.of(yup.string<AddressViewId>().oneOf(ADDRESS_VIEWS_IDS)),
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED: yup.boolean(),
NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS: yup
.array()
.transform(replaceQuotes)
......
......@@ -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_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_API_HOST: blockscout-main.k8s-dev.blockscout.com
NEXT_PUBLIC_STATS_API_HOST: https://stats-test.k8s-dev.blockscout.com/
NEXT_PUBLIC_API_HOST: eth-goerli.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_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
......@@ -67,10 +67,14 @@ frontend:
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE: gradient_avatar
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_ADDITIONAL_FIELDS: "['fee_per_gas']"
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'}]"
OTEL_SDK_ENABLED: true
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger-collector.jaeger.svc.cluster.local:4318
NEXT_OTEL_VERBOSE: 1
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
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
## Local development
1. Prepare your environment variables:
- 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. 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;
2. Run your local dev server:
- if you picked up option "A" above, use `yarn dev` command
- if your options is "B", use `yarn dev:<config_name>` command
3. In browser navigate to the URL from the command output (by default, it is `http://localhost:3000`)
To develop locally, follow one of the two paths outlined below:
A. Custom configuration:
1. Create `.env.local` file in the root folder and include all required environment variables from the [list](./ENVS.md)
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.
3. Use `yarn dev` command to start the dev server.
4. Open your browser and navigate to the URL provided in the command line 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;
......
......@@ -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)
- [SUAVE chain](ENVS.md#suave-chain)
- [Sentry error monitoring](ENVS.md#sentry-error-monitoring)
- [OpenTelemetry](ENVS.md#opentelemetry)
- [3rd party services configuration](ENVS.md#external-services-configuration)
&nbsp;
......@@ -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_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
| Id | Description |
......@@ -536,6 +538,16 @@ For blockchains that implementing SUAVE architecture additional fields will be s
&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
### 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">
<mask id="discord_svg__a" width="20" height="15" x="0" y="0" fill="#000" maskUnits="userSpaceOnUse">
<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 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<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"/>
</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 {
AddressTokensFilter,
AddressTokensResponse,
AddressWithdrawalsResponse,
AddressNFTsResponse,
AddressCollectionsResponse,
AddressNFTTokensFilter,
} from 'types/api/address';
import type { AddressesResponse } from 'types/api/addresses';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block';
import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
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 { IndexingStatus } from 'types/api/indexingStatus';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
......@@ -51,6 +54,7 @@ import type {
TokenInstance,
TokenInstanceTransfersCount,
TokenVerifiedInfo,
TokenInventoryFilters,
} from 'types/api/token';
import type { TokensResponse, TokensFilters, TokensSorting, TokenInstanceTransferResponse, TokensBridgedFilters } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
......@@ -77,16 +81,16 @@ export const SORTING_FIELDS = [ 'sort', 'order' ];
export const RESOURCES = {
// ACCOUNT
csrf: {
path: '/api/account/v1/get_csrf',
path: '/api/account/v2/get_csrf',
},
user_info: {
path: '/api/account/v1/user/info',
path: '/api/account/v2/user/info',
},
email_resend: {
path: '/api/account/v1/email/resend',
path: '/api/account/v2/email/resend',
},
custom_abi: {
path: '/api/account/v1/user/custom_abis/:id?',
path: '/api/account/v2/user/custom_abis/:id?',
pathParams: [ 'id' as const ],
},
watchlist: {
......@@ -95,7 +99,7 @@ export const RESOURCES = {
filterFields: [ ],
},
public_tags: {
path: '/api/account/v1/user/public_tags/:id?',
path: '/api/account/v2/user/public_tags/:id?',
pathParams: [ 'id' as const ],
},
private_tags_address: {
......@@ -109,7 +113,7 @@ export const RESOURCES = {
filterFields: [ ],
},
api_keys: {
path: '/api/account/v1/user/api_keys/:id?',
path: '/api/account/v2/user/api_keys/:id?',
pathParams: [ 'id' as const ],
},
......@@ -305,6 +309,16 @@ export const RESOURCES = {
pathParams: [ 'hash' 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: {
path: '/api/v2/addresses/:hash/withdrawals',
pathParams: [ 'hash' as const ],
......@@ -343,6 +357,10 @@ export const RESOURCES = {
path: '/api/v2/smart-contracts/:hash/verification/via/:method',
pathParams: [ 'hash' as const, 'method' as const ],
},
contract_solidityscan_report: {
path: '/api/v2/smart-contracts/:hash/solidityscan-report',
pathParams: [ 'hash' as const ],
},
verified_contracts: {
path: '/api/v2/smart-contracts',
......@@ -380,7 +398,7 @@ export const RESOURCES = {
token_inventory: {
path: '/api/v2/tokens/:hash/instances',
pathParams: [ 'hash' as const ],
filterFields: [],
filterFields: [ 'holder_address_hash' as const ],
},
tokens: {
path: '/api/v2/tokens',
......@@ -576,7 +594,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'addresses' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' |
'address_logs' | 'address_tokens' |
'address_logs' | 'address_tokens' | 'address_nfts' | 'address_collections' |
'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens' | 'tokens_bridged' |
'token_instance_transfers' | 'token_instance_holders' |
'verified_contracts' |
......@@ -638,6 +656,8 @@ Q extends 'address_coin_balance' ? AddressCoinBalanceHistoryResponse :
Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart :
Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'address_tokens' ? AddressTokensResponse :
Q extends 'address_nfts' ? AddressNFTsResponse :
Q extends 'address_collections' ? AddressCollectionsResponse :
Q extends 'address_withdrawals' ? AddressWithdrawalsResponse :
Q extends 'token' ? TokenInfo :
Q extends 'token_verified_info' ? TokenVerifiedInfo :
......@@ -659,6 +679,7 @@ Q extends 'contract_methods_read' ? Array<SmartContractReadMethod> :
Q extends 'contract_methods_read_proxy' ? Array<SmartContractReadMethod> :
Q extends 'contract_methods_write' ? 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_counters' ? VerifiedContractsCounters :
Q extends 'visualize_sol2uml' ? VisualizedContract :
......@@ -690,7 +711,10 @@ Q extends 'token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
Q extends 'address_token_transfers' ? AddressTokenTransferFilters :
Q extends 'address_tokens' ? AddressTokensFilter :
Q extends 'address_nfts' ? AddressNFTTokensFilter :
Q extends 'address_collections' ? AddressNFTTokensFilter :
Q extends 'search' ? SearchResultFilters :
Q extends 'token_inventory' ? TokenInventoryFilters :
Q extends 'tokens' ? TokensFilters :
Q extends 'tokens_bridged' ? TokensBridgedFilters :
Q extends 'verified_contracts' ? VerifiedContractsFilters :
......
......@@ -12,6 +12,7 @@ export enum NAMES {
INDEXING_ALERT='indexing_alert',
ADBLOCK_DETECTED='adblock_detected',
MIXPANEL_DEBUG='_mixpanel_debug',
ADDRESS_NFT_DISPLAY_TYPE='address_nft_display_type'
}
export function get(name?: NAMES | undefined | null, serverCookie?: string) {
......
import type * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
import appConfig from 'configs/app';
......@@ -10,12 +11,13 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
}
const tracesSampleRate: number | undefined = (() => {
if (feature.environment === 'staging') {
return 1;
}
if (feature.environment === 'production' && feature.instance === 'eth') {
return 0.2;
switch (feature.environment) {
case 'development':
return 1;
case 'staging':
return 0.75;
case 'production':
return 0.2;
}
})();
......@@ -25,6 +27,7 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
release: feature.release,
enableTracing: feature.enableTracing,
tracesSampleRate,
integrations: feature.enableTracing ? [ new BrowserTracing() ] : undefined,
// error filtering settings
// 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 }> = [
{ title: 'ERC-20', id: 'ERC-20' },
export const NFT_TOKEN_TYPES: Array<{ title: string; id: NFTTokenType }> = [
{ title: 'ERC-721', id: 'ERC-721' },
{ 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);
import { WindowPostMessageStream } from '@metamask/post-message-stream';
import { initializeProvider } from '@metamask/providers';
import React from 'react';
import type { WindowProvider } from 'wagmi';
......@@ -15,13 +13,16 @@ export default function useProvider() {
const [ provider, setProvider ] = React.useState<WindowProvider>();
const [ wallet, setWallet ] = React.useState<WalletType>();
React.useEffect(() => {
const initializeProvider = React.useMemo(() => async() => {
if (!feature.isEnabled) {
return;
}
if (!('ethereum' in window && window.ethereum)) {
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
// 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
......@@ -73,5 +74,9 @@ export default function useProvider() {
}
}, []);
React.useEffect(() => {
initializeProvider();
}, [ initializeProvider ]);
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 tokenInstance from 'mocks/tokens/tokenInstance';
......@@ -117,3 +117,51 @@ export const erc1155List = {
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 @@
import type { TokenInstance } from 'types/api/token';
import * as addressMock from '../address/address';
import { tokenInfoERC721a } from './tokenInfo';
export const base: TokenInstance = {
animation_url: null,
......@@ -74,7 +73,6 @@ export const base: TokenInstance = {
name: 'GENESIS #188848, 22a5f8bbb1602995. Blockchain pixel PFP NFT + "on music video" trait inspired by God',
},
owner: addressMock.withName,
token: tokenInfoERC721a,
};
export const withRichMetadata: TokenInstance = {
......
......@@ -25,7 +25,7 @@ export const erc20: TokenTransfer = {
address: '0x55d536e4d6c1993d8ef2e2a4ef77f02088419420',
circulating_market_cap: '117629601.61913824',
decimals: '18',
exchange_rate: null,
exchange_rate: '42',
holders: '46554',
name: 'ARIANEE',
symbol: 'ARIA',
......
......@@ -124,6 +124,7 @@ export const withTokenTransfer: Transaction = {
tokenTransferMock.erc1155C,
tokenTransferMock.erc1155D,
],
token_transfers_overflow: true,
tx_types: [
'token_transfer',
],
......
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.BUNDLE_ANALYZER === 'true',
});
const withRoutes = require('nextjs-routes/config')({
outDir: 'nextjs',
});
......@@ -38,7 +42,10 @@ const moduleExports = {
redirects,
headers,
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 {
],
'script-src': [
// inline script hash, see ui/shared/GoogleAnalytics.tsx
'\'sha256-NTmEg2dBnojQfTYrYJEmp3nG7V66756qPbQMCIBrctk=\'',
'\'sha256-WXRwCtfSfMoCPzPUIOUAosSaADdGgct0/Lhmnbm7MCA=\'',
'https://www.googletagmanager.com',
'*.google-analytics.com',
'*.analytics.google.com',
......
......@@ -8,7 +8,7 @@
"npm": "8"
},
"scripts": {
"dev": "next dev",
"dev": "./tools/scripts/dev.sh",
"dev:preset": "./tools/scripts/dev.preset.sh",
"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 ./",
......@@ -37,8 +37,17 @@
"@metamask/post-message-stream": "^7.0.0",
"@metamask/providers": "^10.2.1",
"@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/react": "^7.72.0",
"@sentry/react": "7.24.0",
"@sentry/tracing": "7.24.0",
"@slise/embed-react": "^2.2.0",
"@tanstack/react-query": "^5.4.3",
"@tanstack/react-query-devtools": "^5.4.3",
......@@ -47,13 +56,13 @@
"@web3modal/ethereum": "^2.6.2",
"@web3modal/react": "^2.6.2",
"bignumber.js": "^9.1.0",
"blo": "^1.1.1",
"chakra-react-select": "^4.4.3",
"crypto-js": "^4.1.1",
"d3": "^7.6.1",
"dappscout-iframe": "^0.1.0",
"dayjs": "^1.11.5",
"dom-to-image": "^2.6.0",
"ethereum-blockies-base64": "^1.0.2",
"framer-motion": "^6.5.1",
"gradient-avatar": "^1.0.2",
"graphiql": "^2.2.0",
......
......@@ -22,7 +22,7 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi
return 'video';
}
if (contentType === 'text/html') {
if (contentType?.startsWith('text/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 { ADDRESS_HASH } from './addressParams';
......@@ -80,16 +88,22 @@ export const ADDRESS_TOKEN_BALANCE_ERC_20: AddressTokenBalance = {
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_id: null,
token_instance: null,
value: '176',
value: '1',
...TOKEN_INSTANCE,
};
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_id: '188882',
token_instance: TOKEN_INSTANCE,
value: '176',
amount: '4',
token_instances: Array(4).fill(TOKEN_INSTANCE),
};
import type { SmartContract } from 'types/api/contract';
import type { SmartContract, SolidityscanReport } from 'types/api/contract';
import type { VerifiedContract } from 'types/api/contracts';
import { ADDRESS_PARAMS } from './addressParams';
......@@ -53,3 +53,25 @@ export const VERIFIED_CONTRACT_INFO: VerifiedContract = {
tx_count: 565058,
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 = {
name: 'GENESIS #188882, 8a77ca1bcaa4036f. Blockchain pixel PFP NFT + "on music video" trait inspired by God',
},
owner: ADDRESS_PARAMS,
token: TOKEN_INFO_ERC_1155,
holder_address_hash: ADDRESS_HASH,
};
......@@ -14,11 +14,6 @@ if [ ! -f "$config_file" ]; then
exit 1
fi
if [ ! -f "$secrets_file" ]; then
echo "Error: File '$secrets_file' not found."
exit 1
fi
# download assets for the running instance
dotenv \
-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 @@
"incremental": true,
"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"],
}
......@@ -3,7 +3,7 @@ import type { Transaction } from 'types/api/transaction';
import type { UserTags } from './addressParams';
import type { Block } from './block';
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';
export interface Address extends UserTags {
......@@ -49,17 +49,47 @@ export interface AddressTokenBalance {
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 {
items: Array<AddressTokenBalance>;
next_page_params: {
items_count: number;
token_name: 'string' | null;
token_name: string | null;
token_type: TokenType;
value: number;
fiat_value: string | 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 {
overflow: boolean;
token_balances: Array<AddressTokenBalance>;
......@@ -97,6 +127,10 @@ export type AddressTokensFilter = {
type: TokenType;
}
export type AddressNFTTokensFilter = {
type: Array<NFTTokenType> | undefined;
}
export interface AddressCoinBalanceHistoryItem {
block_number: number;
block_timestamp: string;
......
......@@ -156,3 +156,25 @@ export interface SmartContractVerificationError {
constructor_arguments?: 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 { 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> {
address: string;
......@@ -61,7 +62,6 @@ export interface TokenInstance {
external_app_url: string | null;
metadata: Record<string, unknown> | null;
owner: AddressParam | null;
token: TokenInfo;
}
export interface TokenInstanceTransfersCount {
......@@ -78,3 +78,7 @@ export type TokenInventoryPagination = {
}
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 { useRouter } from 'next/router';
import React from 'react';
......@@ -9,7 +9,6 @@ import type { AddressFromToFilter, AddressTokenTransferResponse } from 'types/ap
import type { TokenType } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import crossIcon from 'icons/cross.svg';
import { getResourceKey } from 'lib/api/useApiQuery';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
......@@ -26,6 +25,7 @@ import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import HashStringShorten from 'ui/shared/HashStringShorten';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import ResetIconButton from 'ui/shared/ResetIconButton';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
......@@ -115,9 +115,6 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr
onFilterChange({});
}, [ onFilterChange ]);
const resetTokenIconColor = useColorModeValue('blue.600', 'blue.300');
const resetTokenIconHoverColor = useColorModeValue('blue.400', 'blue.200');
const handleNewSocketMessage: SocketMessage.AddressTokenTransfer['handler'] = (payload) => {
setSocketAlert('');
......@@ -235,19 +232,7 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr
<Flex alignItems="center" py={ 1 }>
<TokenEntity.Icon token={ tokenData } isLoading={ isPlaceholderData }/>
{ isMobile ? <HashStringShorten hash={ tokenFilter }/> : tokenFilter }
<Tooltip label="Reset filter">
<Flex>
<Icon
as={ crossIcon }
boxSize={ 5 }
ml={ 1 }
color={ resetTokenIconColor }
cursor="pointer"
_hover={{ color: resetTokenIconHoverColor }}
onClick={ resetTokenFilter }
/>
</Flex>
</Tooltip>
<ResetIconButton onClick={ resetTokenFilter }/>
</Flex>
</Flex>
);
......
......@@ -13,6 +13,8 @@ import AddressTokens from './AddressTokens';
const ADDRESS_HASH = addressMock.withName.hash;
const API_URL_ADDRESS = buildApiUrl('address', { 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 = {
items_count: 50,
......@@ -52,6 +54,14 @@ const test = base.extend({
status: 200,
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);
},
......@@ -76,10 +86,10 @@ test('erc20 +@dark-mode', async({ mount }) => {
await expect(component).toHaveScreenshot();
});
test('erc721 +@dark-mode', async({ mount }) => {
test('collections +@dark-mode', async({ mount }) => {
const hooksConfig = {
router: {
query: { hash: ADDRESS_HASH, tab: 'tokens_erc721' },
query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' },
isReady: true,
},
};
......@@ -95,10 +105,10 @@ test('erc721 +@dark-mode', async({ mount }) => {
await expect(component).toHaveScreenshot();
});
test('erc1155 +@dark-mode', async({ mount }) => {
test('nfts +@dark-mode', async({ mount }) => {
const hooksConfig = {
router: {
query: { hash: ADDRESS_HASH, tab: 'tokens_erc1155' },
query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' },
isReady: true,
},
};
......@@ -111,6 +121,8 @@ test('erc1155 +@dark-mode', async({ mount }) => {
{ hooksConfig },
);
await component.getByText('List').click();
await expect(component).toHaveScreenshot();
});
......@@ -136,10 +148,10 @@ test.describe('mobile', () => {
await expect(component).toHaveScreenshot();
});
test('erc721', async({ mount }) => {
test('nfts', async({ mount }) => {
const hooksConfig = {
router: {
query: { hash: ADDRESS_HASH, tab: 'tokens_erc721' },
query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' },
isReady: true,
},
};
......@@ -152,13 +164,15 @@ test.describe('mobile', () => {
{ hooksConfig },
);
await component.getByLabel('list').click();
await expect(component).toHaveScreenshot();
});
test('erc1155', async({ mount }) => {
test('collections', async({ mount }) => {
const hooksConfig = {
router: {
query: { hash: ADDRESS_HASH, tab: 'tokens_erc1155' },
query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' },
isReady: true,
},
};
......
import { Box } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { Box, HStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { AddressTokenBalance, AddressTokensBalancesSocketMessage, AddressTokensResponse } from 'types/api/address';
import type { TokenType } from 'types/api/token';
import type { NFTTokenType } from 'types/api/token';
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 getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { ADDRESS_TOKEN_BALANCE_ERC_1155, ADDRESS_TOKEN_BALANCE_ERC_20, ADDRESS_TOKEN_BALANCE_ERC_721 } from 'stubs/address';
import { NFT_TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import { ADDRESS_TOKEN_BALANCE_ERC_20, ADDRESS_NFT_1155, ADDRESS_COLLECTION } from 'stubs/address';
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 useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RadioButtonGroup from 'ui/shared/radioButtonGroup/RadioButtonGroup';
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 ERC721Tokens from './tokens/ERC721Tokens';
import TokenBalances from './tokens/TokenBalances';
type TNftDisplayType = 'collection' | 'list';
const TAB_LIST_PROPS = {
marginBottom: 0,
my: 3,
py: 5,
marginTop: 3,
columnGap: 3,
};
const TAB_LIST_PROPS_MOBILE = {
mt: 8,
my: 8,
columnGap: 3,
};
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
));
const getTokenFilterValue = (getFilterValuesFromQuery<NFTTokenType>).bind(null, NFT_TOKEN_TYPE_IDS);
const AddressTokens = () => {
const router = useRouter();
......@@ -49,6 +48,10 @@ const AddressTokens = () => {
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 hash = getQueryParamString(router.query.hash);
......@@ -58,111 +61,100 @@ const AddressTokens = () => {
filters: { type: 'ERC-20' },
scrollRef,
options: {
enabled: !tab || tab === 'tokens_erc20',
refetchOnMount: false,
placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_20, 10, { next_page_params: null }),
},
});
const erc721Query = useQueryWithPages({
resourceName: 'address_tokens',
const collectionsQuery = useQueryWithPages({
resourceName: 'address_collections',
pathParams: { hash },
filters: { type: 'ERC-721' },
scrollRef,
options: {
refetchOnMount: false,
placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_721, 10, { next_page_params: null }),
enabled: tab === 'tokens_nfts' && nftDisplayType === 'collection',
placeholderData: generateListStub<'address_collections'>(ADDRESS_COLLECTION, 10, { next_page_params: null }),
},
filters: { type: tokenTypes },
});
const erc1155Query = useQueryWithPages({
resourceName: 'address_tokens',
const nftsQuery = useQueryWithPages({
resourceName: 'address_nfts',
pathParams: { hash },
filters: { type: 'ERC-1155' },
scrollRef,
options: {
refetchOnMount: false,
placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_1155, 10, { next_page_params: null }),
enabled: tab === 'tokens_nfts' && nftDisplayType === 'list',
placeholderData: generateListStub<'address_nfts'>(ADDRESS_NFT_1155, 10, { next_page_params: null }),
},
filters: { type: tokenTypes },
});
const queryClient = useQueryClient();
const updateTokensData = React.useCallback((type: TokenType, payload: AddressTokensBalancesSocketMessage) => {
const queryKey = getResourceKey('address_tokens', { pathParams: { hash }, queryParams: { type } });
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: erc20Query.isPlaceholderData || erc721Query.isPlaceholderData || erc1155Query.isPlaceholderData,
});
const handleNFTsDisplayTypeChange = React.useCallback((val: TNftDisplayType) => {
cookies.set(cookies.NAMES.ADDRESS_NFT_DISPLAY_TYPE, val);
setNftDisplayType(val);
}, []);
const handleTokenTypesChange = React.useCallback((value: Array<NFTTokenType>) => {
nftsQuery.onFilterChange({ type: value });
collectionsQuery.onFilterChange({ type: value });
setTokenTypes(value);
}, [ nftsQuery, collectionsQuery ]);
const nftTypeFilter = (
<PopoverFilter isActive={ tokenTypes && tokenTypes.length > 0 } contentProps={{ w: '200px' }} appliedFiltersNum={ tokenTypes?.length }>
<TokenTypeFilter<NFTTokenType> nftOnly onChange={ handleTokenTypesChange } defaultValue={ tokenTypes }/>
</PopoverFilter>
);
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 hasActiveFilters = Boolean(tokenTypes?.length);
const tabs = [
{ id: tokenTabsByType['ERC-20'], 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_erc20', title: 'ERC-20', component: <ERC20Tokens tokensQuery={ erc20Query }/> },
{
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;
if (tab === tokenTabsByType['ERC-1155']) {
pagination = erc1155Query.pagination;
} else if (tab === tokenTabsByType['ERC-721']) {
pagination = erc721Query.pagination;
if (tab === 'tokens_nfts') {
pagination = nftDisplayType === 'list' ? nftsQuery.pagination : collectionsQuery.pagination;
} else {
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 (
<>
<TokenBalances/>
......@@ -174,7 +166,8 @@ const AddressTokens = () => {
colorScheme="gray"
size="sm"
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 }
/>
</>
......
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>) => {
return BigInt(0);
}
return BigInt(Number(_value));
return BigInt(_value);
};
export const addZeroesAllowed = (valueType: string) => {
......
......@@ -2,10 +2,8 @@ import { Flex } from '@chakra-ui/react';
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import * as coinBalanceMock from 'mocks/address/coinBalanceHistory';
import * as tokensMock from 'mocks/address/tokens';
import { tokenInfoERC20a } from 'mocks/tokens/tokenInfo';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import MockAddressPage from 'ui/address/testUtils/MockAddressPage';
......@@ -175,76 +173,3 @@ base('long values', async({ mount, page }) => {
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';
import { useRouter } from 'next/router';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { Address } from 'types/api/address';
import walletIcon from 'icons/wallet.svg';
......@@ -13,8 +12,6 @@ import { getResourceKey } from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import useFetchTokens from '../utils/useFetchTokens';
import TokenSelectDesktop from './TokenSelectDesktop';
......@@ -28,14 +25,13 @@ const TokenSelect = ({ onClick }: Props) => {
const router = useRouter();
const isMobile = useIsMobile();
const queryClient = useQueryClient();
const [ blockNumber, setBlockNumber ] = React.useState<number>();
const addressHash = getQueryParamString(router.query.hash);
const addressResourceKey = getResourceKey('address', { pathParams: { hash: addressHash } });
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 tokensIsFetching = useIsFetching({ queryKey: tokensResourceKey });
......@@ -44,34 +40,6 @@ const TokenSelect = ({ onClick }: Props) => {
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) {
return (
<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';
import React from 'react';
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 Pagination from 'ui/shared/pagination/Pagination';
......@@ -10,10 +11,11 @@ import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPage
import NFTItem from './NFTItem';
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 { isError, isPlaceholderData, data, pagination } = tokensQuery;
......@@ -32,13 +34,14 @@ const ERC1155Tokens = ({ tokensQuery }: Props) => {
gridTemplateColumns={{ base: 'repeat(2, calc((100% - 12px)/2))', lg: 'repeat(auto-fill, minmax(210px, 1fr))' }}
>
{ 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 (
<NFTItem
key={ key }
{ ...item }
isLoading={ isPlaceholderData }
withTokenLink
/>
);
}) }
......@@ -52,8 +55,12 @@ const ERC1155Tokens = ({ tokensQuery }: Props) => {
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 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 type { AddressTokenBalance } from 'types/api/address';
import type { AddressNFT } from 'types/api/address';
import { route } from 'nextjs-routes';
......@@ -9,22 +9,20 @@ import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
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) => {
const tokenInstanceLink = tokenId ? route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: tokenId } }) : undefined;
type Props = AddressNFT & { isLoading: boolean; withTokenLink?: boolean };
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 (
<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"
>
<NFTItemContainer position="relative">
<Skeleton isLoaded={ !isLoading }>
<LightMode><Tag background="gray.50" zIndex={ 1 } position="absolute" top="18px" right="18px">{ token.type }</Tag></LightMode>
</Skeleton>
<Link href={ isLoading ? undefined : tokenInstanceLink }>
<NftMedia
mb="18px"
......@@ -32,19 +30,25 @@ const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance, isLo
isLoading={ isLoading }
/>
</Link>
{ tokenId && (
<Flex mb={ 2 } ml={ 1 }>
<Flex justifyContent="space-between" w="100%">
<Flex ml={ 1 } overflow="hidden">
<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>
<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
token={ token }
isLoading={ isLoading }
noCopy
noSymbol
/>
</Box>
</NFTItemContainer>
);
};
......
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 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 {
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) {
const erc20query = useApiQuery('address_tokens', {
pathParams: { hash },
......@@ -25,11 +37,67 @@ export default function useFetchTokens({ hash }: Props) {
queryOptions: { enabled: Boolean(hash), refetchOnMount: false },
});
const refetch = React.useCallback(() => {
erc20query.refetch();
erc721query.refetch();
erc1155query.refetch();
}, [ erc1155query, erc20query, erc721query ]);
const queryClient = useQueryClient();
const updateTokensData = React.useCallback((type: TokenType, payload: AddressTokensBalancesSocketMessage) => {
const queryKey = getResourceKey('address_tokens', { pathParams: { hash }, queryParams: { type } });
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(() => {
return {
......@@ -52,6 +120,5 @@ export default function useFetchTokens({ hash }: Props) {
isPending: erc20query.isPending || erc721query.isPending || erc1155query.isPending,
isError: erc20query.isError || erc721query.isError || erc1155query.isError,
data,
refetch,
};
}
......@@ -77,7 +77,7 @@ const Stats = () => {
<StatsItem
icon={ clockIcon }
title="Average block time"
value={ `${ (data.average_block_time / 1000).toFixed(1) } s` }
value={ `${ (data.average_block_time / 1000).toFixed(1) }s` }
isLoading={ isPlaceholderData }
/>
) }
......
......@@ -3,13 +3,11 @@ import React from 'react';
import type { TimeChartData } from 'ui/shared/chart/types';
import useClientRect from 'lib/hooks/useClientRect';
import ChartArea from 'ui/shared/chart/ChartArea';
import ChartLine from 'ui/shared/chart/ChartLine';
import ChartOverlay from 'ui/shared/chart/ChartOverlay';
import ChartTooltip from 'ui/shared/chart/ChartTooltip';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
import calculateInnerSize from 'ui/shared/chart/utils/calculateInnerSize';
interface Props {
data: TimeChartData;
......@@ -22,12 +20,17 @@ const ChainIndicatorChart = ({ data }: Props) => {
const overlayRef = React.useRef<SVGRectElement>(null);
const lineColor = useToken('colors', 'blue.500');
const [ rect, ref ] = useClientRect<SVGSVGElement>();
const { innerWidth, innerHeight } = calculateInnerSize(rect, CHART_MARGIN);
const { xScale, yScale } = useTimeChartController({
const axesConfig = React.useMemo(() => {
return {
x: { ticks: 4 },
y: { ticks: 3, nice: true },
};
}, [ ]);
const { rect, ref, axis, innerWidth, innerHeight } = useTimeChartController({
data,
width: innerWidth,
height: innerHeight,
margin: CHART_MARGIN,
axesConfig,
});
return (
......@@ -35,13 +38,13 @@ const ChainIndicatorChart = ({ data }: Props) => {
<g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` } opacity={ rect ? 1 : 0 }>
<ChartArea
data={ data[0].items }
xScale={ xScale }
yScale={ yScale }
xScale={ axis.x.scale }
yScale={ axis.y.scale }
/>
<ChartLine
data={ data[0].items }
xScale={ xScale }
yScale={ yScale }
xScale={ axis.x.scale }
yScale={ axis.y.scale }
stroke={ lineColor }
animation="left"
strokeWidth={ 3 }
......@@ -51,8 +54,8 @@ const ChainIndicatorChart = ({ data }: Props) => {
anchorEl={ overlayRef.current }
width={ innerWidth }
height={ innerHeight }
xScale={ xScale }
yScale={ yScale }
xScale={ axis.x.scale }
yScale={ axis.y.scale }
data={ data }
/>
</ChartOverlay>
......
......@@ -15,7 +15,7 @@ const dailyTxsIndicator: TChainIndicator<'homepage_chart_txs'> = {
title: 'Daily transactions',
value: (stats) => Number(stats.transactions_today).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }),
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: {
resourceName: 'homepage_chart_txs',
dataFn: (response) => ([ {
......
......@@ -84,7 +84,6 @@ const MarketplaceAppCard = ({
marginBottom={ 4 }
w={{ base: '64px', sm: '96px' }}
h={{ base: '64px', sm: '96px' }}
borderRadius={ 8 }
display="flex"
alignItems="center"
justifyContent="center"
......@@ -92,6 +91,7 @@ const MarketplaceAppCard = ({
<Image
src={ isLoading ? undefined : logoUrl }
alt={ `${ title } app icon` }
borderRadius="8px"
/>
</Skeleton>
......
import { Box, Flex, Icon } from '@chakra-ui/react';
import { Box, Flex, HStack, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { TokenType } from 'types/api/token';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
......@@ -25,6 +24,7 @@ import AddressTxs from 'ui/address/AddressTxs';
import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import SolidityscanReport from 'ui/address/SolidityscanReport';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import TextAd from 'ui/shared/ad/TextAd';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
......@@ -35,13 +35,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
export const tokenTabsByType: Record<TokenType, string> = {
'ERC-20': 'tokens_erc20',
'ERC-721': 'tokens_erc721',
'ERC-1155': 'tokens_erc1155',
} as const;
const TOKEN_TABS = Object.values(tokenTabsByType);
const TOKEN_TABS = [ 'tokens_erc20', 'tokens_nfts', 'tokens_nfts_collection', 'tokens_nfts_list' ];
const AddressPageContent = () => {
const router = useRouter();
......@@ -194,7 +188,9 @@ const AddressPageContent = () => {
) }
<AddressQrCode address={{ hash }} 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>
);
......
......@@ -56,6 +56,7 @@ const TokenPageContent = () => {
const hashString = getQueryParamString(router.query.hash);
const tab = getQueryParamString(router.query.tab);
const ownerFilter = getQueryParamString(router.query.holder_address_hash) || undefined;
const queryClient = useQueryClient();
......@@ -140,6 +141,7 @@ const TokenPageContent = () => {
const inventoryQuery = useQueryWithPages({
resourceName: 'token_inventory',
pathParams: { hash: hashString },
filters: ownerFilter ? { holder_address_hash: ownerFilter } : {},
scrollRef,
options: {
enabled: Boolean(
......@@ -150,7 +152,7 @@ const TokenPageContent = () => {
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 = () => {
const contractTabs = useContractTabs(contractQuery.data);
const tabs: Array<RoutedTab> = [
(tokenQuery.data?.type === 'ERC-1155' || tokenQuery.data?.type === 'ERC-721') ?
{ id: 'inventory', title: 'Inventory', component: <TokenInventory inventoryQuery={ inventoryQuery }/> } :
undefined,
(tokenQuery.data?.type === 'ERC-1155' || tokenQuery.data?.type === 'ERC-721') ? {
id: 'inventory',
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: 'holders', title: 'Holders', component: <TokenHolders token={ tokenQuery.data } holdersQuery={ holdersQuery }/> },
contractQuery.data?.is_contract ? {
......
......@@ -10,7 +10,7 @@ import { useAppContext } from 'lib/contexts/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata';
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 { generateListStub } from 'stubs/utils';
import AddressQrCode from 'ui/address/details/AddressQrCode';
......@@ -43,6 +43,14 @@ const TokenInstanceContent = () => {
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', {
pathParams: { hash, id },
queryOptions: {
......@@ -58,14 +66,18 @@ const TokenInstanceContent = () => {
options: {
enabled: Boolean(hash && id && (!tab || tab === 'token_transfers') && !tokenInstanceQuery.isPlaceholderData && tokenInstanceQuery.data),
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,
{ 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({
resourceName: 'token_instance_holders',
......@@ -74,18 +86,18 @@ const TokenInstanceContent = () => {
options: {
enabled: Boolean(hash && tab === 'holders' && shouldFetchHolders),
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(() => {
if (tokenInstanceQuery.data && !tokenInstanceQuery.isPlaceholderData) {
if (tokenInstanceQuery.data && !tokenInstanceQuery.isPlaceholderData && tokenQuery.data && !tokenQuery.isPlaceholderData) {
metadata.update(
{ pathname: '/token/[hash]/instance/[id]', query: { hash: tokenInstanceQuery.data.token.address, id: tokenInstanceQuery.data.id } },
{ symbol: tokenInstanceQuery.data.token.symbol ?? '' },
{ pathname: '/token/[hash]/instance/[id]', query: { hash: tokenQuery.data.address, id: tokenInstanceQuery.data.id } },
{ symbol: tokenQuery.data.symbol ?? '' },
);
}
}, [ tokenInstanceQuery.data, tokenInstanceQuery.isPlaceholderData ]);
}, [ tokenInstanceQuery.data, tokenInstanceQuery.isPlaceholderData, tokenQuery.data, tokenQuery.isPlaceholderData ]);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
......@@ -104,10 +116,10 @@ const TokenInstanceContent = () => {
{
id: '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 ?
{ id: 'holders', title: 'Holders', component: <TokenHolders holdersQuery={ holdersQuery } token={ tokenInstanceQuery.data?.token }/> } :
{ id: 'holders', title: 'Holders', component: <TokenHolders holdersQuery={ holdersQuery } token={ tokenQuery.data }/> } :
undefined,
{ id: 'metadata', title: 'Metadata', component: (
<TokenInstanceMetadata
......@@ -121,7 +133,7 @@ const TokenInstanceContent = () => {
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 = {
hash: hash || '',
......@@ -131,7 +143,7 @@ const TokenInstanceContent = () => {
watchlist_address_id: null,
};
const isLoading = tokenInstanceQuery.isPlaceholderData;
const isLoading = tokenInstanceQuery.isPlaceholderData || tokenQuery.isPlaceholderData;
const appLink = (() => {
if (!tokenInstanceQuery.data?.external_app_url) {
......@@ -168,7 +180,7 @@ const TokenInstanceContent = () => {
const titleSecondRow = (
<Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
<TokenEntity
token={ tokenInstanceQuery.data?.token }
token={ tokenQuery.data }
isLoading={ isLoading }
noSymbol
noCopy
......@@ -179,7 +191,7 @@ const TokenInstanceContent = () => {
w="auto"
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 }/>
<AccountActionsMenu isLoading={ isLoading }/>
{ appLink }
......@@ -197,7 +209,7 @@ const TokenInstanceContent = () => {
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 */ }
<Box ref={ scrollRef }></Box>
......
......@@ -105,7 +105,7 @@ const Tokens = () => {
</PopoverFilter>
) : (
<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>
);
......
......@@ -14,13 +14,13 @@ const GoogleAnalytics = () => {
return (
<>
<Script src={ `https://www.googletagmanager.com/gtag/js?id=${ id }` }/>
<Script id="google-analytics">
<Script strategy="lazyOnload" src={ `https://www.googletagmanager.com/gtag/js?id=${ id }` }/>
<Script strategy="lazyOnload" id="google-analytics">
{ `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${ id }');
gtag('config', window.__envs.NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID);
` }
</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 = ({
</>
) }
<Text variant="secondary" fontWeight={ 600 }>Type</Text>
<TokenTypeFilter onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters }/>
<TokenTypeFilter<TokenType> onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters } nftOnly={ false }/>
</PopoverFilter>
);
};
......
import { Flex, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import eastArrowIcon from 'icons/arrows/east.svg';
import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Icon from 'ui/shared/chakra/Icon';
import Tag from 'ui/shared/chakra/Tag';
......@@ -37,15 +37,14 @@ const TokenTransferListItem = ({
enableTimeIncrement,
isLoading,
}: 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 { 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)`;
return (
......@@ -112,10 +111,13 @@ const TokenTransferListItem = ({
width={ addressWidth }
/>
</Flex>
{ value && (
{ valueStr && (
<Flex columnGap={ 2 } w="100%">
<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>
) }
</ListItemMobile>
......
import { Tr, Td, Flex, Skeleton, Box } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
......@@ -35,6 +35,13 @@ const TokenTransferTableItem = ({
isLoading,
}: Props) => {
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 (
<Tr alignItems="top">
......@@ -111,9 +118,16 @@ const TokenTransferTableItem = ({
/>
</Td>
<Td isNumeric verticalAlign="top">
<Skeleton isLoaded={ !isLoading } display="inline-block" my="7px" wordBreak="break-all">
{ 'value' in total && BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat() }
</Skeleton>
{ valueStr && (
<Skeleton isLoaded={ !isLoading } display="inline-block" mt="7px" wordBreak="break-all">
{ valueStr }
</Skeleton>
) }
{ usd && (
<Skeleton isLoaded={ !isLoading } color="text_secondary" mt="10px" ml="auto" w="min-content">
<span>${ usd }</span>
</Skeleton>
) }
</Td>
</Tr>
);
......
......@@ -47,8 +47,8 @@ const AdbutlerBanner = ({ className }: { className?: string }) => {
return (
<Flex className={ className } id="adBanner" h={{ base: '100px', lg: '90px' }}>
<Script id="ad-butler-1">{ connectAdbutler }</Script>
<Script id="ad-butler-2">{ placeAd }</Script>
<Script strategy="lazyOnload" id="ad-butler-1">{ connectAdbutler }</Script>
<Script strategy="lazyOnload" id="ad-butler-2">{ placeAd }</Script>
<div id="ad-banner"></div>
</Flex>
);
......
......@@ -21,7 +21,7 @@ const CoinzillaBanner = ({ className }: { className?: string }) => {
return (
<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>
</Flex>
);
......
import { useToken } from '@chakra-ui/react';
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 dayjs from 'lib/date/dayjs';
import useClientRect from 'lib/hooks/useClientRect';
import useIsMobile from 'lib/hooks/useIsMobile';
import ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis';
......@@ -15,7 +14,6 @@ import ChartOverlay from 'ui/shared/chart/ChartOverlay';
import ChartSelectionX from 'ui/shared/chart/ChartSelectionX';
import ChartTooltip from 'ui/shared/chart/ChartTooltip';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
import calculateInnerSize from 'ui/shared/chart/utils/calculateInnerSize';
interface Props {
isEnlarged?: boolean;
......@@ -31,36 +29,54 @@ interface Props {
const MAX_SHOW_ITEMS = 100_000_000_000;
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 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 chartMargin = { ...DEFAULT_CHART_MARGIN, ...margin };
const { innerWidth, innerHeight } = calculateInnerSize(rect, chartMargin);
const overlayRef = React.useRef<SVGRectElement>(null);
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 rangedItems = useMemo(() =>
const rangedItems = React.useMemo(() =>
items.filter((item) => item.date >= range[0] && item.date <= range[1]),
[ items, range ]);
const isGroupedValues = rangedItems.length > MAX_SHOW_ITEMS;
const displayedData = useMemo(() => {
const displayedData = React.useMemo(() => {
if (isGroupedValues) {
return groupChartItemsByWeekNumber(rangedItems);
} else {
return rangedItems;
}
}, [ isGroupedValues, rangedItems ]);
const chartData = React.useMemo(() => ([ { items: displayedData, name: 'Value', color, units } ]), [ color, displayedData, units ]);
const { xTickFormat, yTickFormat, xScale, yScale } = useTimeChartController({
data: [ { items: displayedData, name: title, color } ],
width: innerWidth,
height: innerHeight,
const margin: ChartMargin = React.useMemo(() => ({ ...DEFAULT_CHART_MARGIN, ...marginProps }), [ marginProps ]);
const axesConfig = React.useMemo(() => {
return {
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 ]) => {
......@@ -68,7 +84,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
onZoom();
}, [ onZoom ]);
useEffect(() => {
React.useEffect(() => {
if (isZoomResetInitial) {
setRange([ items[0].date, items[items.length - 1].date ]);
}
......@@ -80,8 +96,8 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
<g transform={ `translate(${ chartMargin?.left || 0 },${ chartMargin?.top || 0 })` }>
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ isEnlarged ? 6 : 3 }
scale={ axis.y.scale }
ticks={ axesConfig.y.ticks }
size={ innerWidth }
disableAnimation
/>
......@@ -90,14 +106,14 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
id={ chartId }
data={ displayedData }
color={ color }
xScale={ xScale }
yScale={ yScale }
xScale={ axis.x.scale }
yScale={ axis.y.scale }
/>
<ChartLine
data={ displayedData }
xScale={ xScale }
yScale={ yScale }
xScale={ axis.x.scale }
yScale={ axis.y.scale }
stroke={ color }
animation="none"
strokeWidth={ isMobile ? 1 : 2 }
......@@ -105,19 +121,19 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
<ChartAxis
type="left"
scale={ yScale }
ticks={ isEnlarged ? 6 : 3 }
tickFormatGenerator={ yTickFormat }
scale={ axis.y.scale }
ticks={ axesConfig.y.ticks }
tickFormatGenerator={ axis.y.tickFormatter }
disableAnimation
/>
<ChartAxis
type="bottom"
scale={ xScale }
scale={ axis.x.scale }
transform={ `translate(0, ${ innerHeight })` }
ticks={ isMobile ? 1 : 4 }
ticks={ axesConfig.x.ticks }
anchorEl={ overlayRef.current }
tickFormatGenerator={ xTickFormat }
tickFormatGenerator={ axis.x.tickFormatter }
disableAnimation
/>
......@@ -127,15 +143,15 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
width={ innerWidth }
tooltipWidth={ isGroupedValues ? 280 : 200 }
height={ innerHeight }
xScale={ xScale }
yScale={ yScale }
xScale={ axis.x.scale }
yScale={ axis.y.scale }
data={ chartData }
/>
<ChartSelectionX
anchorEl={ overlayRef.current }
height={ innerHeight }
scale={ xScale }
scale={ axis.x.scale }
data={ chartData }
onSelect={ handleRangeSelect }
/>
......
......@@ -25,3 +25,13 @@ export interface 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 { useMemo } from 'react';
import React 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 {
data: TimeChartData;
width: number;
height: number;
margin?: ChartMargin;
axesConfig?: AxesConfig;
}
export default function useTimeChartController({ data, width, height }: Props) {
const xMin = useMemo(
() => d3.min(data, ({ items }) => d3.min(items, ({ date }) => date)) || new Date(),
[ data ],
);
const xMax = useMemo(
() => d3.max(data, ({ items }) => d3.max(items, ({ date }) => date)) || new Date(),
[ data ],
);
const xScale = useMemo(
() => d3.scaleTime().domain([ xMin, xMax ]).range([ 0, width ]),
[ xMin, xMax, width ],
);
const yMin = useMemo(
() => d3.min(data, ({ items }) => d3.min(items, ({ value }) => value)) || 0,
[ data ],
);
const yMax = useMemo(
() => d3.max(data, ({ items }) => d3.max(items, ({ value }) => value)) || 0,
[ data ],
);
const yScale = useMemo(() => {
const indention = (yMax - yMin) * 0.15;
return d3.scaleLinear()
.domain([ yMin >= 0 && yMin - indention <= 0 ? 0 : yMin - indention, yMax + indention ])
.range([ height, 0 ]);
}, [ height, yMin, yMax ]);
const yScaleForAxis = useMemo(
() => d3.scaleBand().domain([ String(yMin), String(yMax) ]).range([ height, 0 ]),
[ height, yMin, yMax ],
);
const xTickFormat = (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);
};
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,
};
export default function useTimeChartController({ data, margin, axesConfig }: Props) {
const [ rect, ref ] = useClientRect<SVGSVGElement>();
// 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 chartMargin = React.useMemo(() => {
const exceedingDigits = (axisParams.y.labelFormatParams.maximumSignificantDigits ?? DEFAULT_MAXIMUM_SIGNIFICANT_DIGITS) -
DEFAULT_MAXIMUM_SIGNIFICANT_DIGITS;
const PIXELS_PER_DIGIT = 7;
const leftShift = PIXELS_PER_DIGIT * exceedingDigits;
return {
...margin,
left: (margin?.left ?? 0) + leftShift,
};
}, [ axisParams.y.labelFormatParams.maximumSignificantDigits, margin ]);
const { innerWidth, innerHeight } = calculateInnerSize(rect, chartMargin);
const xScale = React.useMemo(() => {
return axisParams.x.scale.range([ 0, innerWidth ]);
}, [ axisParams.x.scale, innerWidth ]);
const yScale = React.useMemo(() => {
return axisParams.y.scale.range([ innerHeight, 0 ]);
}, [ axisParams.y.scale, innerHeight ]);
return React.useMemo(() => {
return {
rect,
ref,
chartMargin,
innerWidth,
innerHeight,
axis: {
x: {
tickFormatter: axisParams.x.tickFormatter,
scale: xScale,
},
y: {
tickFormatter: axisParams.y.tickFormatter,
scale: yScale,
},
},
};
}, [ axisParams.x.tickFormatter, axisParams.y.tickFormatter, chartMargin, innerHeight, innerWidth, rect, ref, xScale, yScale ]);
}
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(
}
case 'blockie': {
const makeBlockie = (await import('ethereum-blockies-base64')).default;
const { blo } = (await import('blo'));
// eslint-disable-next-line react/display-name
return (props: IconProps) => {
const data = makeBlockie(props.hash);
const data = blo(props.hash as `0x${ string }`, props.size);
return (
<Image
src={ data }
......
......@@ -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) => {
return (
<EntityBase.Content
{ ...props }
text={ props.hash }
text={ props.text ?? props.hash }
/>
);
});
......@@ -64,6 +64,7 @@ const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
hash: string;
text?: string;
}
const TxEntity = (props: EntityProps) => {
......
import { CheckboxGroup, Checkbox, Text, Flex, Link, useCheckboxGroup } from '@chakra-ui/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 = {
onChange: (nextValue: Array<TokenType>) => void;
defaultValue?: Array<TokenType>;
type Props<T extends TokenType | NFTTokenType> = {
onChange: (nextValue: Array<T>) => void;
defaultValue?: Array<T>;
nftOnly: T extends NFTTokenType ? true : false;
}
const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
const TokenTypeFilter = <T extends TokenType | NFTTokenType>({ nftOnly, onChange, defaultValue }: Props<T>) => {
const { value, setValue } = useCheckboxGroup({ defaultValue });
const handleReset = React.useCallback(() => {
......@@ -21,7 +21,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
onChange([]);
}, [ onChange, setValue, value.length ]);
const handleChange = React.useCallback((nextValue: Array<TokenType>) => {
const handleChange = React.useCallback((nextValue: Array<T>) => {
setValue(nextValue);
onChange(nextValue);
}, [ onChange, setValue ]);
......@@ -32,6 +32,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
<Text fontWeight={ 600 } variant="secondary">Type</Text>
<Link
onClick={ handleReset }
cursor={ value.length > 0 ? 'pointer' : 'unset' }
color={ value.length > 0 ? 'link' : 'text_secondary' }
_hover={{
color: value.length > 0 ? 'link_hovered' : 'text_secondary',
......@@ -41,7 +42,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
</Link>
</Flex>
<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 }>
<Text fontSize="md">{ title }</Text>
</Checkbox>
......
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 MonacoEditor from '@monaco-editor/react';
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
......@@ -11,6 +11,7 @@ import type { SmartContractExternalLibrary } from 'types/api/contract';
import useClientRect from 'lib/hooks/useClientRect';
import useIsMobile from 'lib/hooks/useIsMobile';
import isMetaKey from 'lib/isMetaKey';
import ErrorBoundary from 'ui/shared/ErrorBoundary';
import CodeEditorBreadcrumbs from './CodeEditorBreadcrumbs';
import CodeEditorLoading from './CodeEditorLoading';
......@@ -207,6 +208,10 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
},
}), [ 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) {
const sx = {
...containerSx,
......@@ -220,15 +225,17 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
return (
<Box height={ `${ EDITOR_HEIGHT }px` } width="100%" sx={ sx } ref={ containerNodeRef }>
<MonacoEditor
className="editor-container"
language={ editorLanguage }
path={ data[index].file_path }
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading borderRadius="md"/> }
/>
<ErrorBoundary renderErrorScreen={ renderErrorScreen }>
<MonacoEditor
className="editor-container"
language={ editorLanguage }
path={ data[index].file_path }
defaultValue={ data[index].source_code }
options={ EDITOR_OPTIONS }
onMount={ handleEditorDidMount }
loading={ <CodeEditorLoading borderRadius="md"/> }
/>
</ErrorBoundary>
</Box>
);
}
......@@ -247,34 +254,36 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
onKeyDown={ handleKeyDown }
onKeyUp={ handleKeyUp }
>
<Box flexGrow={ 1 }>
<CodeEditorTabs
tabs={ tabs }
activeTab={ data[index].file_path }
<ErrorBoundary renderErrorScreen={ renderErrorScreen }>
<Box flexGrow={ 1 }>
<CodeEditorTabs
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 }
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 }
/>
</ErrorBoundary>
</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
return (
<Skeleton whiteSpace="pre" isLoaded={ !isLoading } display="flex" className={ className }>
<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> }
</Skeleton>
);
......
import { Text, Icon, HStack } from '@chakra-ui/react';
import React from 'react';
import type { Step } from './types';
import arrowIcon from 'icons/arrows/east.svg';
import finalizedIcon from 'icons/finalized.svg';
import unfinalizedIcon from 'icons/unfinalized.svg';
type Props = {
step: string;
step: Step;
isLast: boolean;
isPassed: boolean;
}
......@@ -17,7 +19,7 @@ const VerificationStep = ({ step, isLast, isPassed }: Props) => {
return (
<HStack gap={ 2 } color={ stepColor }>
<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 }/> }
</HStack>
);
......
......@@ -13,7 +13,7 @@ test('first step +@mobile +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<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>
</TestApp>,
);
......@@ -25,7 +25,7 @@ test('second status', async({ mount }) => {
const component = await mount(
<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>,
);
......
import { Skeleton, chakra } from '@chakra-ui/react';
import React from 'react';
import type { Step } from './types';
import VerificationStep from './VerificationStep';
export interface Props<T extends string> {
step: T;
steps: Array<T>;
export interface Props {
currentStep: string;
steps: Array<Step>;
isLoading?: boolean;
rightSlot?: React.ReactNode;
className?: string;
}
const VerificationSteps = <T extends string>({ step, steps, isLoading, rightSlot, className }: Props<T>) => {
const currentStepIndex = steps.indexOf(step);
const VerificationSteps = ({ currentStep, steps, isLoading, rightSlot, className }: Props) => {
const currentStepIndex = steps.findIndex((step) => {
const label = typeof step === 'string' ? step : step.label;
return label === currentStep;
});
return (
<Skeleton
......@@ -24,7 +29,12 @@ const VerificationSteps = <T extends string>({ step, steps, isLoading, rightSlot
flexWrap="wrap"
>
{ 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 }
</Skeleton>
......
export type Step = string | { label: string; content: React.ReactNode };
......@@ -64,7 +64,7 @@ const Footer = () => {
},
{
icon: discordIcon,
iconSize: '18px',
iconSize: '24px',
text: 'Discord',
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 React from 'react';
......@@ -6,6 +6,7 @@ import type { NavItem } from 'types/client/navigation-items';
import { route } from 'nextjs-routes';
import arrowIcon from 'icons/arrows/north-east.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import { isInternalItem } from 'lib/hooks/useNavItems';
......@@ -43,6 +44,11 @@ const NavLink = ({ item, isCollapsed, px, className, onClick }: Props) => {
aria-label={ `${ item.text } link` }
whiteSpace="nowrap"
onClick={ onClick }
_hover={{
'& *': {
color: 'link_hovered',
},
}}
>
<Tooltip
label={ item.text }
......@@ -56,7 +62,8 @@ const NavLink = ({ item, isCollapsed, px, className, onClick }: Props) => {
<HStack spacing={ 3 } overflow="hidden">
<NavLinkIcon item={ item }/>
<Text { ...styleProps.textProps }>
{ item.text }
<span>{ item.text }</span>
{ !isInternalLink && <Icon as={ arrowIcon } boxSize={ 4 } color="text_secondary" verticalAlign="middle"/> }
</Text>
</HStack>
</Tooltip>
......
......@@ -110,6 +110,7 @@ test.describe('with tooltips', () => {
{ hooksConfig },
);
await component.locator('header').hover();
await page.locator('svg[aria-label="Expand/Collapse menu"]').click();
await page.locator('a[aria-label="Tokens link"]').hover();
......@@ -212,3 +213,37 @@ base.describe('cookie set to true', () => {
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 = () => {
py={ 12 }
width={{ lg: isExpanded ? '229px' : '92px', xl: isCollapsed ? '92px' : '229px' }}
{ ...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"/> }
<Box
......@@ -113,6 +118,8 @@ const NavigationDesktop = () => {
cursor="pointer"
onClick={ handleTogglerClick }
aria-label="Expand/Collapse menu"
id="expand-icon"
display="none"
/>
</Flex>
);
......
......@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { tokenInfoERC721a } from 'mocks/tokens/tokenInfo';
import { base as tokenInstanse } from 'mocks/tokens/tokenInstance';
import TestApp from 'playwright/TestApp';
......@@ -23,6 +24,11 @@ test('base view +@mobile', async({ mount }) => {
// @ts-ignore:
pagination: { page: 1, isVisible: true },
}}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
tokenQuery={{
data: tokenInfoERC721a,
}}
/>
</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 type { TokenInfo } from 'types/api/token';
import type { ResourceError } from 'lib/api/resources';
import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import Pagination from 'ui/shared/pagination/Pagination';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import ResetIconButton from 'ui/shared/ResetIconButton';
import TokenInventoryItem from './TokenInventoryItem';
type Props = {
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 actionBar = isMobile && inventoryQuery.pagination.isVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...inventoryQuery.pagination }/>
</ActionBar>
const resetOwnerFilter = React.useCallback(() => {
inventoryQuery.onFilterChange({});
}, [ inventoryQuery ]);
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 token = tokenQuery.data;
const content = items ? (
const content = items && token ? (
<Grid
w="100%"
columnGap={{ base: 3, lg: 6 }}
......@@ -33,9 +66,10 @@ const TokenInventory = ({ inventoryQuery }: Props) => {
>
{ items.map((item, index) => (
<TokenInventoryItem
key={ item.token.address + '_' + item.id + (inventoryQuery.isPlaceholderData ? '_' + index : '') }
key={ token.address + '_' + item.id + (inventoryQuery.isPlaceholderData ? '_' + index : '') }
item={ item }
isLoading={ inventoryQuery.isPlaceholderData }
isLoading={ inventoryQuery.isPlaceholderData || tokenQuery.isPlaceholderData }
token={ token }
/>
)) }
</Grid>
......
import { Box, Flex, Text, Link, useColorModeValue, Skeleton } from '@chakra-ui/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';
......@@ -11,9 +11,9 @@ import LinkInternal from 'ui/shared/LinkInternal';
import NftMedia from 'ui/shared/nft/NftMedia';
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();
......@@ -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 (
<Box
......@@ -76,4 +76,4 @@ const NFTItem = ({ item, isLoading }: Props) => {
);
};
export default NFTItem;
export default TokenInventoryItem;
import { Flex, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import eastArrowIcon from 'icons/arrows/east.svg';
import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Icon from 'ui/shared/chakra/Icon';
import Tag from 'ui/shared/chakra/Tag';
......@@ -27,15 +27,14 @@ const TokenTransferListItem = ({
tokenId,
isLoading,
}: 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 { 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 (
<ListItemMobile rowGap={ 3 } isAnimated>
......@@ -72,15 +71,16 @@ const TokenTransferListItem = ({
fontWeight="500"
/>
</Flex>
{ value && (token.type === 'ERC-20' || token.type === 'ERC-1155') && (
{ valueStr && (token.type === 'ERC-20' || token.type === 'ERC-1155') && (
<Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !isLoading } flexShrink={ 0 } fontWeight={ 500 }>
Value
</Skeleton>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ value }</span>
<span>{ valueStr }</span>
</Skeleton>
{ token.symbol && <TruncatedValue isLoading={ isLoading } value={ token.symbol }/> }
{ usd && <Skeleton isLoaded={ !isLoading } color="text_secondary"><span>(${ usd })</span></Skeleton> }
</Flex>
) }
{ 'token_id' in total && (token.type === 'ERC-721' || token.type === 'ERC-1155') && (
......
import { Tr, Td, Grid, Skeleton, Box } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import eastArrowIcon from 'icons/arrows/east.svg';
import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Icon from 'ui/shared/chakra/Icon';
import Tag from 'ui/shared/chakra/Tag';
......@@ -26,6 +26,13 @@ const TokenTransferTableItem = ({
isLoading,
}: Props) => {
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 (
<Tr alignItems="top">
......@@ -91,9 +98,16 @@ const TokenTransferTableItem = ({
) }
{ (token.type === 'ERC-20' || token.type === 'ERC-1155') && (
<Td isNumeric verticalAlign="top">
<Skeleton isLoaded={ !isLoading } my="7px">
{ 'value' in total && BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat() }
</Skeleton>
{ valueStr && (
<Skeleton isLoaded={ !isLoading } display="inline-block" mt="7px" wordBreak="break-all">
{ valueStr }
</Skeleton>
) }
{ usd && (
<Skeleton isLoaded={ !isLoading } color="text_secondary" mt="10px" wordBreak="break-all">
<span>${ usd }</span>
</Skeleton>
) }
</Td>
) }
</Tr>
......
......@@ -2,6 +2,7 @@ import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as addressMock from 'mocks/address/address';
import { tokenInfoERC721a } from 'mocks/tokens/tokenInfo';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
......@@ -9,10 +10,12 @@ import * as configs from 'playwright/utils/configs';
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', {
id: tokenInstanceMock.unique.id,
hash: tokenInstanceMock.unique.token.address,
hash,
});
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(
<TestApp>
<TokenInstanceDetails data={ tokenInstanceMock.unique }/>
<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>
</TestApp>,
);
......
import { Flex, Grid, Skeleton } from '@chakra-ui/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 DetailsInfoItem from 'ui/shared/DetailsInfoItem';
......@@ -18,11 +18,12 @@ import TokenInstanceTransfersCount from './details/TokenInstanceTransfersCount';
interface Props {
data?: TokenInstance;
token?: TokenInfo;
isLoading?: boolean;
scrollRef?: React.RefObject<HTMLDivElement>;
}
const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
const handleCounterItemClick = React.useCallback(() => {
window.setTimeout(() => {
// cannot do scroll instantly, have to wait a little
......@@ -30,7 +31,7 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
}, 500);
}, [ scrollRef ]);
if (!data) {
if (!data || !token) {
return null;
}
......@@ -56,7 +57,7 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
/>
</DetailsInfoItem>
) }
<TokenInstanceCreatorAddress hash={ isLoading ? '' : data.token.address }/>
<TokenInstanceCreatorAddress hash={ isLoading ? '' : token.address }/>
<DetailsInfoItem
title="Token ID"
hint="This token instance unique token ID"
......@@ -69,8 +70,8 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
<CopyToClipboard text={ data.id } isLoading={ isLoading }/>
</Flex>
</DetailsInfoItem>
<TokenInstanceTransfersCount hash={ isLoading ? '' : data.token.address } id={ isLoading ? '' : data.id } onClick={ handleCounterItemClick }/>
<TokenNftMarketplaces isLoading={ isLoading } hash={ data.token.address } id={ data.id }/>
<TokenInstanceTransfersCount hash={ isLoading ? '' : token.address } id={ isLoading ? '' : data.id } onClick={ handleCounterItemClick }/>
<TokenNftMarketplaces isLoading={ isLoading } hash={ token.address } id={ data.id }/>
</Grid>
<NftMedia
url={ data.animation_url || data.image_url }
......
......@@ -167,7 +167,7 @@ const TxDetails = () => {
hint="Status of the transaction confirmation path to L1"
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>
) }
{ data.revert_reason && (
......@@ -312,7 +312,7 @@ const TxDetails = () => {
<span>[ Contract creation ]</span>
) }
</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/>
......
......@@ -14,6 +14,7 @@ import TxDetailsTokenTransfer from './TxDetailsTokenTransfer';
interface Props {
data: Array<TokenTransfer>;
txHash: string;
isOverflow: boolean;
}
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 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 transferGroups = TOKEN_TRANSFERS_TYPES.map((group) => ({
...group,
items: data?.filter((token) => token.type === group.type) || [],
}));
const showViewAllLink = transferGroups.some(({ items }) => items.length > VISIBLE_ITEMS_NUM);
return (
<>
......@@ -54,12 +53,12 @@ const TxDetailsTokenTransfers = ({ data, txHash }: Props) => {
w="100%"
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>
</DetailsInfoItem>
);
}) }
{ showViewAllLink && (
{ isOverflow && (
<>
<Show above="lg" ssr={ false }><GridItem></GridItem></Show>
<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) => {
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 = (() => {
if (status === 'Relayed' && l1TxHash) {
return <TxEntityL1 hash={ l1TxHash } truncation="constant"/>;
}
return WITHDRAWAL_STATUSES;
}
if (hasClaimButton) {
return (
<Button
variant="outline"
size="sm"
as="a"
href="https://app.optimism.io/bridge/withdraw"
target="_blank"
>
Claim funds
</Button>
);
default:
return WITHDRAWAL_STATUSES;
}
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 (
<DetailsInfoItem
title="Withdrawal status"
......@@ -56,7 +66,7 @@ const TxDetailsWithdrawalStatus = ({ status, l1TxHash }: Props) => {
>
<VerificationSteps
steps={ steps as unknown as Array<L2WithdrawalStatus> }
step={ status }
currentStep={ status }
rightSlot={ rightSlot }
my={ hasClaimButton ? '-6px' : 0 }
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 React from 'react';
......@@ -48,9 +48,14 @@ const TxsTable = ({
<Th width="160px">Type</Th>
<Th width="20%">Method</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: '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 && (
<Th width="20%" isNumeric>
<Link onClick={ sort('val') } display="flex" justifyContent="end">
......
......@@ -86,7 +86,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
title="Status"
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
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