Commit a2b9c418 authored by Max Alekseenko's avatar Max Alekseenko Committed by GitHub

Address widgets (#2788)

* add address widgets

* Update ENVS.md

* fix long values

* fix responsive size and hover border color

* improve url template formatting, add chainId mapping

* add hover animation

* add widgets tab

* filter widgets by address type

* fix link style

* add mixpanel event

* fix stub

* fix result check

* add tests

* fix tabs loading

* Check the workflow: generate chakra types if the node_modules cache is hit

* fix loading

* fix loading view all link

* fix loading issue

* post-review changes

* rename envs and feature

* rename components and hook

* rename config prop and change url template format

* change validation schema

* add chain_id to request params

* update tests

* rename, improve

* improve grid logic

* improve formatting func

* simplify grid logic

* update hint text

* duplicate widgets in env for tests

* update mocks and snapshots

* fix text

* fix grid one more time

* update envs with test config

* update snapshots

* fix grid max width

* update mock and snapshots

* use separator

* Update .env.eth

* fix

* update snapshots

---------
Co-authored-by: default avatartom <tom@ohhhh.me>
parent 54a35a82
...@@ -45,6 +45,10 @@ jobs: ...@@ -45,6 +45,10 @@ jobs:
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Generate Chakra types
if: steps.cache-node-modules.outputs.cache-hit == 'true'
run: yarn chakra:typegen
- name: Run ESLint - name: Run ESLint
run: yarn lint:eslint run: yarn lint:eslint
...@@ -77,6 +81,10 @@ jobs: ...@@ -77,6 +81,10 @@ jobs:
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Generate Chakra types
if: steps.cache-node-modules.outputs.cache-hit == 'true'
run: yarn chakra:typegen
- name: Install package dependencies - name: Install package dependencies
run: | run: |
cd ./toolkit/package cd ./toolkit/package
...@@ -136,6 +144,10 @@ jobs: ...@@ -136,6 +144,10 @@ jobs:
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Generate Chakra types
if: steps.cache-node-modules.outputs.cache-hit == 'true'
run: yarn chakra:typegen
- name: Install script dependencies - name: Install script dependencies
run: cd ./deploy/tools/envs-validator && yarn --frozen-lockfile run: cd ./deploy/tools/envs-validator && yarn --frozen-lockfile
...@@ -175,6 +187,10 @@ jobs: ...@@ -175,6 +187,10 @@ jobs:
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Generate Chakra types
if: steps.cache-node-modules.outputs.cache-hit == 'true'
run: yarn chakra:typegen
- name: Run Jest - name: Run Jest
run: yarn test:jest ${{ github.event_name == 'pull_request' && '--changedSince=origin/main' || '' }} --passWithNoTests run: yarn test:jest ${{ github.event_name == 'pull_request' && '--changedSince=origin/main' || '' }} --passWithNoTests
...@@ -207,6 +223,10 @@ jobs: ...@@ -207,6 +223,10 @@ jobs:
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Generate Chakra types
if: steps.cache-node-modules.outputs.cache-hit == 'true'
run: yarn chakra:typegen
- name: Install script dependencies - name: Install script dependencies
run: cd ./deploy/tools/affected-tests && yarn --frozen-lockfile run: cd ./deploy/tools/affected-tests && yarn --frozen-lockfile
...@@ -264,6 +284,10 @@ jobs: ...@@ -264,6 +284,10 @@ jobs:
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Generate Chakra types
if: steps.cache-node-modules.outputs.cache-hit == 'true'
run: yarn chakra:typegen
- name: Download affected tests list - name: Download affected tests list
if: ${{ needs.pw_affected_tests.result == 'success' }} if: ${{ needs.pw_affected_tests.result == 'success' }}
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
......
...@@ -37,6 +37,10 @@ jobs: ...@@ -37,6 +37,10 @@ jobs:
if: steps.cache-node-modules.outputs.cache-hit != 'true' if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Generate Chakra types
if: steps.cache-node-modules.outputs.cache-hit == 'true'
run: yarn chakra:typegen
- name: Make production build with source maps - name: Make production build with source maps
run: yarn build run: yarn build
env: env:
......
import type { Feature } from './types';
import { getEnvValue, getExternalAssetFilePath, parseEnvJson } from '../utils';
// config file will be downloaded at run-time and saved in the public folder
const widgets = parseEnvJson<Array<string>>(getEnvValue('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS'));
const configUrl = getExternalAssetFilePath('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL');
const title = 'Address 3rd party widgets';
const config: Feature<{ widgets: Array<string>; configUrl: string }> = (() => {
if (widgets && widgets.length > 0 && configUrl) {
return Object.freeze({
title,
isEnabled: true,
widgets,
configUrl,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
...@@ -2,6 +2,7 @@ export { default as advancedFilter } from './advancedFilter'; ...@@ -2,6 +2,7 @@ export { default as advancedFilter } from './advancedFilter';
export { default as account } from './account'; export { default as account } from './account';
export { default as addressVerification } from './addressVerification'; export { default as addressVerification } from './addressVerification';
export { default as addressMetadata } from './addressMetadata'; export { default as addressMetadata } from './addressMetadata';
export { default as address3rdPartyWidgets } from './address3rdPartyWidgets';
export { default as adsBanner } from './adsBanner'; export { default as adsBanner } from './adsBanner';
export { default as adsText } from './adsText'; export { default as adsText } from './adsText';
export { default as beaconChain } from './beaconChain'; export { default as beaconChain } from './beaconChain';
......
...@@ -69,3 +69,5 @@ NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https:// ...@@ -69,3 +69,5 @@ NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao', 'humanpassport', 'trustblock', 'bankless']
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json
...@@ -26,6 +26,7 @@ ASSETS_ENVS=( ...@@ -26,6 +26,7 @@ ASSETS_ENVS=(
"NEXT_PUBLIC_NETWORK_ICON" "NEXT_PUBLIC_NETWORK_ICON"
"NEXT_PUBLIC_NETWORK_ICON_DARK" "NEXT_PUBLIC_NETWORK_ICON_DARK"
"NEXT_PUBLIC_OG_IMAGE_URL" "NEXT_PUBLIC_OG_IMAGE_URL"
"NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL"
) )
# Create the assets directory if it doesn't exist # Create the assets directory if it doesn't exist
......
...@@ -42,6 +42,7 @@ async function validateEnvs(appEnvs: Record<string, string>) { ...@@ -42,6 +42,7 @@ async function validateEnvs(appEnvs: Record<string, string>) {
'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL',
'NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL', 'NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL',
'NEXT_PUBLIC_FOOTER_LINKS', 'NEXT_PUBLIC_FOOTER_LINKS',
'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL',
]; ];
for await (const envName of envsWithJsonConfig) { for await (const envName of envsWithJsonConfig) {
......
...@@ -35,8 +35,8 @@ import type { ChainIndicatorId, HeroBannerButtonState, HeroBannerConfig, HomeSta ...@@ -35,8 +35,8 @@ import type { ChainIndicatorId, HeroBannerButtonState, HeroBannerConfig, HomeSta
import { type NetworkVerificationTypeEnvs, type NetworkExplorer, type FeaturedNetwork, NETWORK_GROUPS } from '../../../types/networks'; import { type NetworkVerificationTypeEnvs, type NetworkExplorer, type FeaturedNetwork, NETWORK_GROUPS } from '../../../types/networks';
import { COLOR_THEME_IDS } from '../../../types/settings'; import { COLOR_THEME_IDS } from '../../../types/settings';
import type { FontFamily } from '../../../types/ui'; import type { FontFamily } from '../../../types/ui';
import type { AddressFormat, AddressViewId } from '../../../types/views/address'; import type { AddressFormat, AddressViewId, Address3rdPartyWidget } from '../../../types/views/address';
import { ADDRESS_FORMATS, ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address'; import { ADDRESS_FORMATS, ADDRESS_VIEWS_IDS, IDENTICON_TYPES, ADDRESS_3RD_PARTY_WIDGET_PAGES } from '../../../types/views/address';
import { BLOCK_FIELDS_IDS } from '../../../types/views/block'; import { BLOCK_FIELDS_IDS } from '../../../types/views/block';
import type { BlockFieldId } from '../../../types/views/block'; import type { BlockFieldId } from '../../../types/views/block';
import type { NftMarketplaceItem } from '../../../types/views/nft'; import type { NftMarketplaceItem } from '../../../types/views/nft';
...@@ -700,6 +700,46 @@ const externalTxsConfigSchema: yup.ObjectSchema<TxExternalTxsConfig> = yup.objec ...@@ -700,6 +700,46 @@ const externalTxsConfigSchema: yup.ObjectSchema<TxExternalTxsConfig> = yup.objec
explorer_url_template: yup.string().required(), explorer_url_template: yup.string().required(),
}); });
const address3rdPartyWidgetsConfigSchema = yup
.object()
.shape({
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL: yup
.mixed()
.test('shape', 'Invalid schema were provided for NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL, it should have name, url, icon, title, value', (data) => {
const isUndefined = data === undefined;
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
const valueSchema = yup.lazy((objValue) => {
let schema = yup.object();
Object.keys(objValue).forEach((key) => {
schema = schema.shape({
[key]: yup.object<Address3rdPartyWidget>().shape({
name: yup.string().required(),
url: yup.string().required(),
icon: yup.string().required(),
title: yup.string().required(),
hint: yup.string().optional(),
valuePath: yup.string().required(),
pages: yup.array().of(yup.string().oneOf(ADDRESS_3RD_PARTY_WIDGET_PAGES)).required(),
chainIds: yup.object<Record<string, string>>().optional(),
}),
});
});
return schema;
});
return isUndefined || valueSchema.isValidSync(parsedData);
}),
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS: yup
.array()
.transform(replaceQuotes)
.json()
.of(yup.string())
.when('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', {
is: (value: string) => value,
then: (schema) => schema,
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS cannot not be used if NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL is not provided'),
}),
});
const schema = yup const schema = yup
.object() .object()
.noUnknown(true, (params) => { .noUnknown(true, (params) => {
...@@ -1117,6 +1157,7 @@ const schema = yup ...@@ -1117,6 +1157,7 @@ const schema = yup
.concat(bridgedTokensSchema) .concat(bridgedTokensSchema)
.concat(sentrySchema) .concat(sentrySchema)
.concat(tacSchema) .concat(tacSchema)
.concat(address3rdPartyWidgetsConfigSchema)
.concat(addressMetadataSchema); .concat(addressMetadataSchema);
export default schema; export default schema;
...@@ -89,3 +89,5 @@ NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG=[{'name': 'zerion', 'url_template ...@@ -89,3 +89,5 @@ NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG=[{'name': 'zerion', 'url_template
NEXT_PUBLIC_GAS_REFUEL_PROVIDER_CONFIG={'name': 'Need gas?', 'dapp_id': 'smol-refuel', 'url_template': 'https://smolrefuel.com/?outboundChain={chainId}&partner=blockscout&utm_source=blockscout&utm_medium=address&disableBridges=true', 'logo': 'https://blockscout-content.s3.amazonaws.com/smolrefuel-logo-action-button.png'} NEXT_PUBLIC_GAS_REFUEL_PROVIDER_CONFIG={'name': 'Need gas?', 'dapp_id': 'smol-refuel', 'url_template': 'https://smolrefuel.com/?outboundChain={chainId}&partner=blockscout&utm_source=blockscout&utm_medium=address&disableBridges=true', 'logo': 'https://blockscout-content.s3.amazonaws.com/smolrefuel-logo-action-button.png'}
NEXT_PUBLIC_SAVE_ON_GAS_ENABLED=true NEXT_PUBLIC_SAVE_ON_GAS_ENABLED=true
NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=https://example.com NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['widget-1', 'widget-2']
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://example.com
{
"widget-1": {
"name": "Widget 1",
"url": "https://example.com/widget-1/{address}",
"icon": "https://example.com/icon.svg",
"title": "Widget 1",
"hint": "Widget 1 hint",
"valuePath": "result.value",
"pages": [ "eoa", "contract", "token" ]
},
"widget-2": {
"name": "Widget 2",
"url": "https://example.com/widget-2/{address}",
"icon": "https://example.com/icon.svg",
"title": "Widget 2",
"valuePath": "value",
"pages": [ "eoa" ]
},
"widget-3": {
"name": "Widget 3",
"url": "https://example.com/widget-3/{address}?chainId={chainId}",
"icon": "https://example.com/icon.svg",
"title": "Widget 3",
"valuePath": "result.length",
"pages": [ "token" ],
"chainIds": {
"1": "eth",
"10": "op"
}
}
}
...@@ -76,6 +76,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d ...@@ -76,6 +76,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d
- [Save on gas with GasHawk](#save-on-gas-with-gashawk) - [Save on gas with GasHawk](#save-on-gas-with-gashawk)
- [Rewards service API](#rewards-service-api) - [Rewards service API](#rewards-service-api)
- [DEX pools](#dex-pools) - [DEX pools](#dex-pools)
- [Address 3rd party widgets](#address-3rd-party-widgets)
- [3rd party services configuration](#external-services-configuration) - [3rd party services configuration](#external-services-configuration)
&nbsp; &nbsp;
...@@ -917,6 +918,30 @@ This feature enables Blockscout Merits program. It requires that the [My account ...@@ -917,6 +918,30 @@ This feature enables Blockscout Merits program. It requires that the [My account
&nbsp; &nbsp;
### Address 3rd party widgets
This feature allows to display widgets on the address page with data from 3rd party services.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS | `Array<string>` | Array of widget ids to be displayed | - | - | `['widget-1', 'widget-2']` | v2.2.0+ |
| NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL | `string` | URL of configuration file (`.json` format only) which contains mapping of widget names to their configuration. See [below](#address-3rd-party-widget-configuration-properties) list of available properties for a widget. | - | - | `https://example.com/address_3rd_party_widgets_config.json` | v2.2.0+ |
#### Address 3rd party widget configuration properties
| Property | Type | Description | Compulsoriness | Example value |
| --- | --- | --- | --- | --- |
| name | `string` | Displayed name of the widget | Required | - | `'Widget'` |
| url | `string` | Link URL for widget card. Can contain `{address}`, `{addressLowercase}` and `{chainId}` variables | Required | - | `'https://example.com/widget/{address}?chainId={chainId}'` |
| icon | `string` | Widget icon URL | Required | - | `'https://example.com/icon.svg'` |
| title | `string` | Title of displayed data | Required | - | `'Multichain balance'` |
| hint | `string` | Hint for displayed data | - | - | `'Widget hint'` |
| valuePath | `string` | Path to the field in the API response that contains the value to be displayed | Required | - | `'result.balance'` |
| pages | `Array<'eoa' \| 'contract' \| 'token'>` | List of pages where the widget should be displayed | Required | - | `['eoa']` |
| chainIds | `Record<string, string>` | Mapping of chain IDs to custom values that will be used in `url` template | - | - | `{'1': 'eth', '10': 'op'}` |
&nbsp;
### Badge claim link ### Badge claim link
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
......
...@@ -125,6 +125,11 @@ export const GENERAL_API_ADDRESS_RESOURCES = { ...@@ -125,6 +125,11 @@ export const GENERAL_API_ADDRESS_RESOURCES = {
path: '/api/v2/proxy/3dparty/xname/addresses/:hash', path: '/api/v2/proxy/3dparty/xname/addresses/:hash',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
}, },
address_3rd_party_info: {
path: '/api/v2/proxy/3dparty/:name',
pathParams: [ 'name' as const ],
filterFields: [ 'address' as const, 'chain_id' as const ],
},
// CSV EXPORTS // CSV EXPORTS
address_csv_export_txs: { address_csv_export_txs: {
...@@ -171,6 +176,7 @@ R extends 'general:address_collections' ? AddressCollectionsResponse : ...@@ -171,6 +176,7 @@ R extends 'general:address_collections' ? AddressCollectionsResponse :
R extends 'general:address_withdrawals' ? AddressWithdrawalsResponse : R extends 'general:address_withdrawals' ? AddressWithdrawalsResponse :
R extends 'general:address_epoch_rewards' ? AddressEpochRewardsResponse : R extends 'general:address_epoch_rewards' ? AddressEpochRewardsResponse :
R extends 'general:address_xstar_score' ? AddressXStarResponse : R extends 'general:address_xstar_score' ? AddressXStarResponse :
R extends 'general:address_3rd_party_info' ? unknown :
never; never;
/* eslint-enable @stylistic/indent */ /* eslint-enable @stylistic/indent */
......
...@@ -24,6 +24,7 @@ export enum EventTypes { ...@@ -24,6 +24,7 @@ export enum EventTypes {
BUTTON_CLICK = 'Button click', BUTTON_CLICK = 'Button click',
PROMO_BANNER = 'Promo banner', PROMO_BANNER = 'Promo banner',
APP_FEEDBACK = 'App feedback', APP_FEEDBACK = 'App feedback',
ADDRESS_WIDGET = 'Address widget',
} }
/* eslint-disable @stylistic/indent */ /* eslint-disable @stylistic/indent */
...@@ -169,5 +170,8 @@ Type extends EventTypes.APP_FEEDBACK ? { ...@@ -169,5 +170,8 @@ Type extends EventTypes.APP_FEEDBACK ? {
AppId: string; AppId: string;
Score: number; Score: number;
} : } :
Type extends EventTypes.ADDRESS_WIDGET ? {
Name: string;
} :
undefined; undefined;
/* eslint-enable @stylistic/indent */ /* eslint-enable @stylistic/indent */
import type { Address3rdPartyWidget } from 'types/views/address';
export const widgets = [
'widget-1',
'widget-2',
'widget-3',
'widget-4',
'widget-5',
'widget-6',
'widget-7',
'widget-8',
'widget-9',
] as const;
export const values = [ 0, 2534783, 75.34, undefined, 1553.5, 100, 0.99, 333, undefined ];
export const config: Record<string, Address3rdPartyWidget> = {
'widget-1': {
name: 'Widget 1',
url: 'https://www.example.com',
pages: [ 'eoa', 'contract', 'token' ],
icon: 'http://localhost:3000/widget-logo.png',
title: 'Value',
hint: 'Hint',
valuePath: 'value',
},
'widget-2': {
name: 'Widget 2',
url: 'https://www.example.com',
pages: [ 'eoa', 'contract', 'token' ],
icon: 'http://localhost:3000/widget-logo.png',
title: 'Another value',
valuePath: 'value',
},
'widget-3': {
name: 'Widget 3',
url: 'https://www.example.com',
pages: [ 'eoa', 'contract', 'token' ],
icon: 'http://localhost:3000/widget-logo.png',
title: 'One more value',
hint: 'Hint',
valuePath: 'value',
},
'widget-4': {
name: 'Widget 4',
url: 'https://www.example.com',
pages: [ 'eoa', 'contract', 'token' ],
icon: 'http://localhost:3000/widget-logo.png',
title: 'Empty value',
valuePath: 'another_value',
},
'widget-5': {
name: 'Widget 5',
url: 'https://www.example.com',
pages: [ 'eoa', 'contract', 'token' ],
icon: 'http://localhost:3000/widget-logo.png',
title: 'Test value',
hint: 'Hint',
valuePath: 'value',
},
'widget-6': {
name: 'Widget 6',
url: 'https://www.example.com',
pages: [ 'eoa', 'contract', 'token' ],
icon: 'http://localhost:3000/widget-logo.png',
title: 'Another test value',
valuePath: 'value',
},
'widget-7': {
name: 'Widget 7',
url: 'https://www.example.com',
pages: [ 'eoa', 'contract', 'token' ],
icon: 'http://localhost:3000/widget-logo.png',
title: 'One more test value',
hint: 'Hint',
valuePath: 'value',
},
'widget-8': {
name: 'Widget 8',
url: 'https://www.example.com',
pages: [ 'eoa', 'contract', 'token' ],
icon: 'http://localhost:3000/widget-logo.png',
title: 'Final test value',
valuePath: 'value',
},
'widget-9': {
name: 'Widget 9',
url: 'https://www.example.com',
pages: [ 'eoa', 'contract', 'token' ],
icon: 'http://localhost:3000/widget-logo.png',
title: 'Another empty value',
hint: 'Hint',
valuePath: 'value',
},
};
import type { Address3rdPartyWidget } from 'types/views/address';
export const WIDGET_CONFIG: Address3rdPartyWidget = {
name: 'name',
url: 'url',
icon: 'icon',
title: 'title',
hint: 'hint',
pages: [ 'eoa' ],
valuePath: 'valuePath',
};
...@@ -18,3 +18,16 @@ export type AddressViewId = ArrayElement<typeof ADDRESS_VIEWS_IDS>; ...@@ -18,3 +18,16 @@ export type AddressViewId = ArrayElement<typeof ADDRESS_VIEWS_IDS>;
export const ADDRESS_FORMATS = [ 'base16', 'bech32' ] as const; export const ADDRESS_FORMATS = [ 'base16', 'bech32' ] as const;
export type AddressFormat = typeof ADDRESS_FORMATS[ number ]; export type AddressFormat = typeof ADDRESS_FORMATS[ number ];
export const ADDRESS_3RD_PARTY_WIDGET_PAGES = [ 'eoa', 'contract', 'token' ] as const;
export type Address3rdPartyWidget = {
name: string;
url: string;
icon: string;
title: string;
hint?: string;
valuePath: string;
pages: Array<typeof ADDRESS_3RD_PARTY_WIDGET_PAGES[number]>;
chainIds?: Record<string, string>;
};
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as widgetsMock from 'mocks/address/widgets';
import type { TestFnArgs } from 'playwright/lib';
import { test, expect, devices } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import Address3rdPartyWidgets from './Address3rdPartyWidgets';
const WIDGETS_CONFIG_URL = 'http://localhost:4000/address-3rd-party-widgets-config.json';
const ADDRESS_HASH = addressMock.hash;
const hooksConfig = {
router: {
query: { hash: ADDRESS_HASH },
},
};
const testFn = (isMobile: boolean) => async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockApiResponse, page }: TestFnArgs) => {
await mockEnvs([
[ 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS', JSON.stringify(widgetsMock.widgets) ],
[ 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', WIDGETS_CONFIG_URL ],
]);
await mockConfigResponse('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', WIDGETS_CONFIG_URL, widgetsMock.config);
await Promise.all(widgetsMock.widgets.map((widget, i) =>
mockApiResponse(
'general:address_3rd_party_info',
{ value: widgetsMock.values[i] },
{ pathParams: { name: widget }, queryParams: { address: ADDRESS_HASH, chain_id: '1' } },
),
));
await mockAssetResponse(widgetsMock.config[widgetsMock.widgets[0]].icon, './playwright/mocks/image_s.jpg');
const component = await render(<Address3rdPartyWidgets showAll addressType="contract"/>, { hooksConfig });
if (!isMobile) {
await page.getByText(widgetsMock.config[widgetsMock.widgets[0]].name).hover({ force: true }); // eslint-disable-line playwright/no-force-option
}
await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
};
test('base view +@dark-mode', testFn(false));
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', testFn(true));
});
import { Grid, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import type { Address3rdPartyWidget } from 'types/views/address';
import { route } from 'nextjs-routes';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { Link } from 'toolkit/chakra/link';
import Address3rdPartyWidgetCard from './address3rdPartyWidgets/Address3rdPartyWidgetCard';
import useAddress3rdPartyWidgets from './address3rdPartyWidgets/useAddress3rdPartyWidgets';
type Props = {
shouldRender?: boolean;
isQueryEnabled?: boolean;
addressType: Address3rdPartyWidget['pages'][number];
showAll?: boolean;
isLoading?: boolean;
};
const NUMBER_OF_WIDGETS_TO_DISPLAY = 8;
const Address3rdPartyWidgets = ({ shouldRender = true, isQueryEnabled = true, addressType, isLoading = false, showAll }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const addressHash = getQueryParamString(router.query.hash);
const { items: widgets, configQuery } = useAddress3rdPartyWidgets(addressType, isLoading, isQueryEnabled);
const displayedWidgets = useMemo(() => {
return showAll ? widgets : widgets.slice(0, NUMBER_OF_WIDGETS_TO_DISPLAY);
}, [ widgets, showAll ]);
const shouldShowViewAllLink = !showAll && !isLoading && !configQuery.isPlaceholderData && widgets.length > displayedWidgets.length;
if (!isMounted || !shouldRender) {
return null;
}
return (
<Flex w="full" direction="column" alignItems="flex-start" gap={ 3 }>
<Grid
gap={ 3 }
templateColumns={{
base: 'repeat(auto-fit, minmax(238px, 1fr))',
xl: 'repeat(auto-fit, minmax(248px, 1fr))',
}}
w="full"
maxW={
widgets.length < 5 ?
`${ (widgets.length * (360 + 12)) - 12 }px` : // 360px - max widget width, 12px - gap
undefined
}
>
{ displayedWidgets.map((name) => (
<Address3rdPartyWidgetCard
key={ name }
name={ name }
config={ configQuery.data?.[name] }
address={ addressHash }
isLoading={ configQuery.isPlaceholderData || isLoading }
/>
)) }
</Grid>
{ shouldShowViewAllLink && (
<Link
href={ route({ pathname: '/address/[hash]', query: { hash: addressHash, tab: 'widgets' } }) }
textStyle="sm"
ml={ 0.5 }
>
View all
</Link>
) }
</Flex>
);
};
export default Address3rdPartyWidgets;
...@@ -3,6 +3,8 @@ import React from 'react'; ...@@ -3,6 +3,8 @@ import React from 'react';
import * as addressMock from 'mocks/address/address'; import * as addressMock from 'mocks/address/address';
import * as countersMock from 'mocks/address/counters'; import * as countersMock from 'mocks/address/counters';
import * as tokensMock from 'mocks/address/tokens'; import * as tokensMock from 'mocks/address/tokens';
import * as widgetsMock from 'mocks/address/widgets';
import type { TestFnArgs } from 'playwright/lib';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config'; import * as pwConfig from 'playwright/utils/config';
...@@ -11,6 +13,7 @@ import MockAddressPage from './testUtils/MockAddressPage'; ...@@ -11,6 +13,7 @@ import MockAddressPage from './testUtils/MockAddressPage';
import type { AddressCountersQuery } from './utils/useAddressCountersQuery'; import type { AddressCountersQuery } from './utils/useAddressCountersQuery';
import type { AddressQuery } from './utils/useAddressQuery'; import type { AddressQuery } from './utils/useAddressQuery';
const WIDGETS_CONFIG_URL = 'http://localhost:4000/address-3rd-party-widgets-config.json';
const ADDRESS_HASH = addressMock.hash; const ADDRESS_HASH = addressMock.hash;
const hooksConfig = { const hooksConfig = {
router: { router: {
...@@ -18,6 +21,42 @@ const hooksConfig = { ...@@ -18,6 +21,42 @@ const hooksConfig = {
}, },
}; };
const testWidgetsFn = (isMobile: boolean) => async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockApiResponse, page }: TestFnArgs) => {
await mockEnvs([
[ 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS', JSON.stringify(widgetsMock.widgets) ],
[ 'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', WIDGETS_CONFIG_URL ],
]);
await mockConfigResponse('NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL', WIDGETS_CONFIG_URL, widgetsMock.config);
await mockApiResponse('general:address', addressMock.contract, { pathParams: { hash: ADDRESS_HASH } });
await mockApiResponse('general:address_counters', countersMock.forContract, { pathParams: { hash: ADDRESS_HASH } });
await Promise.all(widgetsMock.widgets.map((widget, i) =>
mockApiResponse(
'general:address_3rd_party_info',
{ value: widgetsMock.values[i] },
{ pathParams: { name: widget }, queryParams: { address: ADDRESS_HASH, chain_id: '1' } },
),
));
await mockAssetResponse(widgetsMock.config[widgetsMock.widgets[0]].icon, './playwright/mocks/image_s.jpg');
const component = await render(
<AddressDetails
addressQuery={{ data: addressMock.contract } as AddressQuery}
countersQuery={{ data: countersMock.forContract } as AddressCountersQuery}
/>,
{ hooksConfig },
);
if (!isMobile) {
await page.getByText(widgetsMock.config[widgetsMock.widgets[0]].name).hover({ force: true }); // eslint-disable-line playwright/no-force-option
}
await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
};
test.describe('mobile', () => { test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport }); test.use({ viewport: devices['iPhone 13 Pro'].viewport });
...@@ -71,6 +110,8 @@ test.describe('mobile', () => { ...@@ -71,6 +110,8 @@ test.describe('mobile', () => {
maskColor: pwConfig.maskColor, maskColor: pwConfig.maskColor,
}); });
}); });
test('with widgets', testWidgetsFn(true));
}); });
test('contract', async({ render, page, mockApiResponse }) => { test('contract', async({ render, page, mockApiResponse }) => {
...@@ -145,3 +186,5 @@ test('filecoin', async({ render, mockApiResponse, page }) => { ...@@ -145,3 +186,5 @@ test('filecoin', async({ render, mockApiResponse, page }) => {
maskColor: pwConfig.maskColor, maskColor: pwConfig.maskColor,
}); });
}); });
test('with widgets', testWidgetsFn(false));
...@@ -19,6 +19,8 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity'; ...@@ -19,6 +19,8 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ContractCreationStatus from 'ui/shared/statusTag/ContractCreationStatus'; import ContractCreationStatus from 'ui/shared/statusTag/ContractCreationStatus';
import Address3rdPartyWidgets from './Address3rdPartyWidgets';
import useAddress3rdPartyWidgets from './address3rdPartyWidgets/useAddress3rdPartyWidgets';
import AddressAlternativeFormat from './details/AddressAlternativeFormat'; import AddressAlternativeFormat from './details/AddressAlternativeFormat';
import AddressBalance from './details/AddressBalance'; import AddressBalance from './details/AddressBalance';
import AddressImplementations from './details/AddressImplementations'; import AddressImplementations from './details/AddressImplementations';
...@@ -41,6 +43,8 @@ const AddressDetails = ({ addressQuery, countersQuery, isLoading }: Props) => { ...@@ -41,6 +43,8 @@ const AddressDetails = ({ addressQuery, countersQuery, isLoading }: Props) => {
const addressHash = getQueryParamString(router.query.hash); const addressHash = getQueryParamString(router.query.hash);
const address3rdPartyWidgets = useAddress3rdPartyWidgets(addressQuery.data?.is_contract ? 'contract' : 'eoa', addressQuery.isPlaceholderData);
const error404Data = React.useMemo(() => ({ const error404Data = React.useMemo(() => ({
hash: addressHash || '', hash: addressHash || '',
is_contract: false, is_contract: false,
...@@ -302,6 +306,23 @@ const AddressDetails = ({ addressQuery, countersQuery, isLoading }: Props) => { ...@@ -302,6 +306,23 @@ const AddressDetails = ({ addressQuery, countersQuery, isLoading }: Props) => {
) } ) }
<DetailedInfoSponsoredItem isLoading={ isLoading }/> <DetailedInfoSponsoredItem isLoading={ isLoading }/>
{ (address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.items.length > 0) && (
<>
<DetailedInfo.ItemLabel
hint="Metrics provided by third party partners"
isLoading={ address3rdPartyWidgets.configQuery.isPlaceholderData }
>
Widgets
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue pl={{ base: 0, sm: 7, lg: 0 }}>
<Address3rdPartyWidgets
addressType={ data.is_contract ? 'contract' : 'eoa' }
isLoading={ addressQuery.isPlaceholderData }
/>
</DetailedInfo.ItemValue>
</>
) }
</DetailedInfo.Container> </DetailedInfo.Container>
</> </>
); );
......
import { Flex, Text, chakra, Separator } from '@chakra-ui/react';
import { useCallback } from 'react';
import type { Address3rdPartyWidget } from 'types/views/address';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
import { Image } from 'toolkit/chakra/image';
import { LinkBox, LinkOverlay } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { Hint } from 'toolkit/components/Hint/Hint';
import { ndash } from 'toolkit/utils/htmlEntities';
import IconSvg from 'ui/shared/IconSvg';
import useWidgetData from './useWidgetData';
type Props = {
name: string;
config: Address3rdPartyWidget | undefined;
address: string;
isLoading: boolean;
};
const chainId = config.chain.id || '';
function formatUrl(tpl: string, ctx: Record<string, string>) {
return tpl.replace(/\{\s*(\w+)\s*\}/g, (_, key) => ctx[key] ?? '');
}
const Address3rdPartyWidgetCard = ({ name, config, address, ...props }: Props) => {
const { data, isLoading: isDataLoading } = useWidgetData(name, config?.valuePath, address, props.isLoading);
const isLoading = props.isLoading || isDataLoading;
const handleClick = useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.ADDRESS_WIDGET, { Name: name });
}, [ name ]);
if (!config) {
return null;
}
const url = formatUrl(config.url, {
address,
addressLowercase: address.toLowerCase(),
chainId: config.chainIds?.[chainId] ?? chainId,
});
const [ integer, decimal ] = data?.split('.') || [];
const content = isLoading ? (
<>
<Skeleton loading w="88px" h="40px" mb={ 1 }/>
<Skeleton loading w="178px" h="20px"/>
<Separator mt={ 3 } mb={ 2 } borderColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.100' }}/>
<Flex alignItems="center" gap={ 2 }>
<Skeleton loading w="20px" h="20px"/>
<Skeleton loading w="80px" h="20px"/>
</Flex>
</>
) : (
<>
<LinkOverlay href={ url } external onClick={ handleClick }/>
{ data ? (
<Text
textStyle="heading.xl"
color={ integer === '0' && !decimal ? 'text.secondary' : 'text.primary' }
textOverflow="ellipsis"
whiteSpace="nowrap"
overflow="hidden"
>
{ integer }
{ decimal && (
<>
.
<chakra.span color="text.secondary">
{ decimal }
</chakra.span>
</>
) }
</Text>
) : (
<Text textStyle="heading.xl" color="gray.500" opacity={ 0.2 }>{ ndash }</Text>
) }
<Flex alignItems="center" gap={ 1 } mt={ 1 }>
<Text textStyle="sm">{ config.title }</Text>
{ config.hint && (
<Hint
label={ config.hint }
tooltipProps={{ positioning: { placement: 'bottom' } }}
/>
) }
</Flex>
<Separator mt={ 3 } mb={ 2 } borderColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.100' }}/>
<Flex alignItems="center" gap={ 2 }>
<Image src={ config.icon } alt={ config.name } boxSize={ 5 }/>
<Flex
alignItems="center"
justifyContent="space-between"
flex={ 1 }
>
<Text
textStyle="xs"
color="text.secondary"
_groupHover={{ color: 'blue.400' }}
>
{ config.name }
</Text>
<IconSvg
name="link_external"
boxSize={ 3 }
color="blue.400"
display="none"
_groupHover={{ display: 'block' }}
/>
</Flex>
</Flex>
</>
);
return (
<LinkBox className="group">
<Flex
flexDirection="column"
p={ 3 }
bgColor={ isLoading ? 'transparent' : { _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' } }
borderRadius="md"
border="1px solid"
borderColor={ isLoading ? { _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' } : 'transparent' }
_groupHover={{
borderColor: isLoading ? 'default' : { _light: 'blackAlpha.50', _dark: 'whiteAlpha.100' },
scale: 1.02,
}}
transition="all 0.2s ease-in-out"
scale={ 1 }
cursor={ isLoading ? 'default' : 'pointer' }
>
{ content }
</Flex>
</LinkBox>
);
};
export default Address3rdPartyWidgetCard;
import { useMemo } from 'react';
import type { Address3rdPartyWidget } from 'types/views/address';
import config from 'configs/app';
import useWidgetsConfigQuery from './useWidgetsConfigQuery';
const feature = config.features.address3rdPartyWidgets;
const widgets = (feature.isEnabled && feature.widgets) || [];
export default function useAddress3rdPartyWidgets(
addressType: Address3rdPartyWidget['pages'][number],
isLoading = false,
isQueryEnabled = true,
) {
const configQuery = useWidgetsConfigQuery(isQueryEnabled);
const items = useMemo(() => {
if (configQuery.isPlaceholderData || isLoading) {
return widgets;
}
return widgets.filter((widget) => configQuery.data?.[widget]?.pages.includes(addressType));
}, [ configQuery, isLoading, addressType ]);
return {
isEnabled: feature.isEnabled,
items,
configQuery,
};
}
import { get } from 'es-toolkit/compat';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
const RESOURCE_NAME = 'general:address_3rd_party_info';
const formatValue = (value: unknown): string | undefined => {
if (typeof value !== 'number' && typeof value !== 'string') {
return undefined;
}
const num = Number(value);
if (!isNaN(num)) {
return num.toLocaleString();
}
return String(value);
};
export default function useWidgetData(name: string, valuePath: string | undefined, address: string, isLoading: boolean) {
const query = useApiQuery<typeof RESOURCE_NAME, unknown, string | undefined>(RESOURCE_NAME, {
pathParams: { name },
queryParams: { address, chain_id: config.chain.id },
queryOptions: {
select: (response) => {
try {
const value = get(response, valuePath || '');
return formatValue(value);
} catch {
return undefined;
}
},
enabled: !isLoading && Boolean(valuePath),
},
});
return query;
}
import { useQuery } from '@tanstack/react-query';
import type { Address3rdPartyWidget } from 'types/views/address';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/hooks/useFetch';
import { WIDGET_CONFIG } from 'stubs/address3rdPartyWidgets';
const feature = config.features.address3rdPartyWidgets;
const configUrl = (feature.isEnabled && feature.configUrl) || '';
const widgets = (feature.isEnabled && feature.widgets) || [];
export default function useWidgetsConfigQuery(isQueryEnabled = true) {
const apiFetch = useApiFetch();
return useQuery<unknown, ResourceError<unknown>, Record<string, Address3rdPartyWidget>>({
queryKey: [ 'address-3rd-party-widgets-config' ],
queryFn: async() => apiFetch(configUrl, undefined, { resource: 'address-3rd-party-widgets-config' }),
placeholderData: widgets.reduce((acc, widget) => ({ ...acc, [widget]: WIDGET_CONFIG }), {}),
staleTime: Infinity,
enabled: Boolean(configUrl) && isQueryEnabled,
});
}
...@@ -23,6 +23,8 @@ import useFetchXStarScore from 'lib/xStarScore/useFetchXStarScore'; ...@@ -23,6 +23,8 @@ import useFetchXStarScore from 'lib/xStarScore/useFetchXStarScore';
import { ADDRESS_TABS_COUNTERS } from 'stubs/address'; import { ADDRESS_TABS_COUNTERS } from 'stubs/address';
import { USER_OPS_ACCOUNT } from 'stubs/userOps'; import { USER_OPS_ACCOUNT } from 'stubs/userOps';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs'; import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import Address3rdPartyWidgets from 'ui/address/Address3rdPartyWidgets';
import useAddress3rdPartyWidgets from 'ui/address/address3rdPartyWidgets/useAddress3rdPartyWidgets';
import AddressAccountHistory from 'ui/address/AddressAccountHistory'; import AddressAccountHistory from 'ui/address/AddressAccountHistory';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated'; import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance'; import AddressCoinBalance from 'ui/address/AddressCoinBalance';
...@@ -132,10 +134,17 @@ const AddressPageContent = () => { ...@@ -132,10 +134,17 @@ const AddressPageContent = () => {
addressEnsDomainsQuery.data?.items.find((domain) => domain.name === addressQuery.data?.ens_domain_name) : addressEnsDomainsQuery.data?.items.find((domain) => domain.name === addressQuery.data?.ens_domain_name) :
undefined; undefined;
const address3rdPartyWidgets = useAddress3rdPartyWidgets(
addressQuery.data?.is_contract ? 'contract' : 'eoa',
addressQuery.isPlaceholderData,
areQueriesEnabled,
);
const isLoading = addressQuery.isPlaceholderData; const isLoading = addressQuery.isPlaceholderData;
const isTabsLoading = const isTabsLoading =
isLoading || isLoading ||
addressTabsCountersQuery.isPlaceholderData || addressTabsCountersQuery.isPlaceholderData ||
(address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.configQuery.isPlaceholderData) ||
(config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData) || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData) ||
(config.features.mudFramework.isEnabled && mudTablesCountQuery.isPlaceholderData); (config.features.mudFramework.isEnabled && mudTablesCountQuery.isPlaceholderData);
...@@ -282,6 +291,20 @@ const AddressPageContent = () => { ...@@ -282,6 +291,20 @@ const AddressPageContent = () => {
component: <AddressLogs shouldRender={ !isTabsLoading } isQueryEnabled={ areQueriesEnabled }/>, component: <AddressLogs shouldRender={ !isTabsLoading } isQueryEnabled={ areQueriesEnabled }/>,
} : } :
undefined, undefined,
(address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.items.length > 0) ? {
id: 'widgets',
title: 'Widgets',
count: address3rdPartyWidgets.items.length,
component: (
<Address3rdPartyWidgets
addressType={ addressQuery.data?.is_contract ? 'contract' : 'eoa' }
isLoading={ addressQuery.isPlaceholderData }
shouldRender={ !isTabsLoading }
isQueryEnabled={ areQueriesEnabled }
showAll
/>
),
} : undefined,
].filter(Boolean); ].filter(Boolean);
}, [ }, [
addressQuery, addressQuery,
...@@ -292,6 +315,7 @@ const AddressPageContent = () => { ...@@ -292,6 +315,7 @@ const AddressPageContent = () => {
isTabsLoading, isTabsLoading,
areQueriesEnabled, areQueriesEnabled,
mudTablesCountQuery.data, mudTablesCountQuery.data,
address3rdPartyWidgets,
]); ]);
const usernameApiTag = userPropfileApiQuery.data?.user_profile?.username; const usernameApiTag = userPropfileApiQuery.data?.user_profile?.username;
......
...@@ -22,6 +22,8 @@ import * as tokenStubs from 'stubs/token'; ...@@ -22,6 +22,8 @@ import * as tokenStubs from 'stubs/token';
import { getTokenHoldersStub } from 'stubs/token'; import { getTokenHoldersStub } from 'stubs/token';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs'; import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import Address3rdPartyWidgets from 'ui/address/Address3rdPartyWidgets';
import useAddress3rdPartyWidgets from 'ui/address/address3rdPartyWidgets/useAddress3rdPartyWidgets';
import AddressContract from 'ui/address/AddressContract'; import AddressContract from 'ui/address/AddressContract';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink'; import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import useContractTabs from 'ui/address/contract/useContractTabs'; import useContractTabs from 'ui/address/contract/useContractTabs';
...@@ -162,7 +164,13 @@ const TokenPageContent = () => { ...@@ -162,7 +164,13 @@ const TokenPageContent = () => {
}, },
}); });
const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData; const address3rdPartyWidgets = useAddress3rdPartyWidgets('token', false, isQueryEnabled);
const isLoading =
tokenQuery.isPlaceholderData ||
addressQuery.isPlaceholderData ||
(address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.configQuery.isPlaceholderData);
const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData); const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData);
const tabs: Array<TabItemRegular> = [ const tabs: Array<TabItemRegular> = [
...@@ -198,6 +206,12 @@ const TokenPageContent = () => { ...@@ -198,6 +206,12 @@ const TokenPageContent = () => {
component: <AddressContract tabs={ contractTabs.tabs } isLoading={ contractTabs.isLoading } shouldRender={ !isLoading }/>, component: <AddressContract tabs={ contractTabs.tabs } isLoading={ contractTabs.isLoading } shouldRender={ !isLoading }/>,
subTabs: CONTRACT_TAB_IDS, subTabs: CONTRACT_TAB_IDS,
} : undefined, } : undefined,
(address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.items.length > 0) ? {
id: 'widgets',
title: 'Widgets',
count: address3rdPartyWidgets.items.length,
component: <Address3rdPartyWidgets shouldRender={ !isLoading } addressType="token" showAll/>,
} : undefined,
].filter(Boolean); ].filter(Boolean);
let pagination: PaginationParams | undefined; let pagination: PaginationParams | undefined;
......
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