mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
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: tom <[email protected]>
This commit is contained in:
committed by
GitHub
co-authored by
tom
parent
06acc48e35
commit
6d44e159b5
@@ -45,6 +45,10 @@ jobs:
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Generate Chakra types
|
||||
if: steps.cache-node-modules.outputs.cache-hit == 'true'
|
||||
run: yarn chakra:typegen
|
||||
|
||||
- name: Run ESLint
|
||||
run: yarn lint:eslint
|
||||
|
||||
@@ -77,6 +81,10 @@ jobs:
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
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
|
||||
run: |
|
||||
cd ./toolkit/package
|
||||
@@ -136,6 +144,10 @@ jobs:
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
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
|
||||
run: cd ./deploy/tools/envs-validator && yarn --frozen-lockfile
|
||||
|
||||
@@ -175,6 +187,10 @@ jobs:
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Generate Chakra types
|
||||
if: steps.cache-node-modules.outputs.cache-hit == 'true'
|
||||
run: yarn chakra:typegen
|
||||
|
||||
- name: Run Jest
|
||||
run: yarn test:jest ${{ github.event_name == 'pull_request' && '--changedSince=origin/main' || '' }} --passWithNoTests
|
||||
|
||||
@@ -207,6 +223,10 @@ jobs:
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
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
|
||||
run: cd ./deploy/tools/affected-tests && yarn --frozen-lockfile
|
||||
|
||||
@@ -264,6 +284,10 @@ jobs:
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
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
|
||||
if: ${{ needs.pw_affected_tests.result == 'success' }}
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
@@ -37,6 +37,10 @@ jobs:
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
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
|
||||
run: yarn build
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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';
|
||||
export { default as account } from './account';
|
||||
export { default as addressVerification } from './addressVerification';
|
||||
export { default as addressMetadata } from './addressMetadata';
|
||||
export { default as address3rdPartyWidgets } from './address3rdPartyWidgets';
|
||||
export { default as adsBanner } from './adsBanner';
|
||||
export { default as adsText } from './adsText';
|
||||
export { default as beaconChain } from './beaconChain';
|
||||
|
||||
@@ -68,4 +68,6 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
|
||||
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'https://opensea.io/static/images/logos/opensea-logo.svg'},{'name':'Rarible','collection_url':'https://rarible.com/collection/{hash}/items','instance_url':'https://rarible.com/token/{hash}:{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/rarible.png'},{'name':'Blur','collection_url':'https://blur.io/eth/collection/{hash}','instance_url':'https://blur.io/eth/asset/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/blur.png'},{'name':'MagicEden','collection_url':'https://magiceden.io/collections/ethereum/{hash}','instance_url':'https://magiceden.io/item-details/ethereum/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/magiceden.png'}]
|
||||
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
|
||||
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=(
|
||||
"NEXT_PUBLIC_NETWORK_ICON"
|
||||
"NEXT_PUBLIC_NETWORK_ICON_DARK"
|
||||
"NEXT_PUBLIC_OG_IMAGE_URL"
|
||||
"NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL"
|
||||
)
|
||||
|
||||
# Create the assets directory if it doesn't exist
|
||||
|
||||
@@ -42,6 +42,7 @@ async function validateEnvs(appEnvs: Record<string, string>) {
|
||||
'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL',
|
||||
'NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL',
|
||||
'NEXT_PUBLIC_FOOTER_LINKS',
|
||||
'NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL',
|
||||
];
|
||||
|
||||
for await (const envName of envsWithJsonConfig) {
|
||||
|
||||
@@ -35,8 +35,8 @@ import type { ChainIndicatorId, HeroBannerButtonState, HeroBannerConfig, HomeSta
|
||||
import { type NetworkVerificationTypeEnvs, type NetworkExplorer, type FeaturedNetwork, NETWORK_GROUPS } from '../../../types/networks';
|
||||
import { COLOR_THEME_IDS } from '../../../types/settings';
|
||||
import type { FontFamily } from '../../../types/ui';
|
||||
import type { AddressFormat, AddressViewId } from '../../../types/views/address';
|
||||
import { ADDRESS_FORMATS, ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address';
|
||||
import type { AddressFormat, AddressViewId, Address3rdPartyWidget } 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 type { BlockFieldId } from '../../../types/views/block';
|
||||
import type { NftMarketplaceItem } from '../../../types/views/nft';
|
||||
@@ -71,12 +71,12 @@ const urlTest: yup.TestConfig = {
|
||||
exclusive: true,
|
||||
};
|
||||
|
||||
const getYupValidationErrorMessage = (error: unknown) =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'errors' in error &&
|
||||
Array.isArray(error.errors) ?
|
||||
error.errors.join(', ') :
|
||||
const getYupValidationErrorMessage = (error: unknown) =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'errors' in error &&
|
||||
Array.isArray(error.errors) ?
|
||||
error.errors.join(', ') :
|
||||
'';
|
||||
|
||||
const marketplaceAppSchema: yup.ObjectSchema<MarketplaceAppOverview> = yup
|
||||
@@ -700,6 +700,46 @@ const externalTxsConfigSchema: yup.ObjectSchema<TxExternalTxsConfig> = yup.objec
|
||||
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
|
||||
.object()
|
||||
.noUnknown(true, (params) => {
|
||||
@@ -1117,6 +1157,7 @@ const schema = yup
|
||||
.concat(bridgedTokensSchema)
|
||||
.concat(sentrySchema)
|
||||
.concat(tacSchema)
|
||||
.concat(address3rdPartyWidgetsConfigSchema)
|
||||
.concat(addressMetadataSchema);
|
||||
|
||||
export default schema;
|
||||
|
||||
@@ -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_SAVE_ON_GAS_ENABLED=true
|
||||
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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"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
|
||||
- [Save on gas with GasHawk](#save-on-gas-with-gashawk)
|
||||
- [Rewards service API](#rewards-service-api)
|
||||
- [DEX pools](#dex-pools)
|
||||
- [Address 3rd party widgets](#address-3rd-party-widgets)
|
||||
- [3rd party services configuration](#external-services-configuration)
|
||||
|
||||
|
||||
@@ -917,6 +918,30 @@ This feature enables Blockscout Merits program. It requires that the [My account
|
||||
|
||||
|
||||
|
||||
### 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'}` |
|
||||
|
||||
|
||||
|
||||
### Badge claim link
|
||||
|
||||
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
|
||||
|
||||
@@ -125,6 +125,11 @@ export const GENERAL_API_ADDRESS_RESOURCES = {
|
||||
path: '/api/v2/proxy/3dparty/xname/addresses/:hash',
|
||||
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
|
||||
address_csv_export_txs: {
|
||||
@@ -171,6 +176,7 @@ R extends 'general:address_collections' ? AddressCollectionsResponse :
|
||||
R extends 'general:address_withdrawals' ? AddressWithdrawalsResponse :
|
||||
R extends 'general:address_epoch_rewards' ? AddressEpochRewardsResponse :
|
||||
R extends 'general:address_xstar_score' ? AddressXStarResponse :
|
||||
R extends 'general:address_3rd_party_info' ? unknown :
|
||||
never;
|
||||
/* eslint-enable @stylistic/indent */
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export enum EventTypes {
|
||||
BUTTON_CLICK = 'Button click',
|
||||
PROMO_BANNER = 'Promo banner',
|
||||
APP_FEEDBACK = 'App feedback',
|
||||
ADDRESS_WIDGET = 'Address widget',
|
||||
}
|
||||
|
||||
/* eslint-disable @stylistic/indent */
|
||||
@@ -169,5 +170,8 @@ Type extends EventTypes.APP_FEEDBACK ? {
|
||||
AppId: string;
|
||||
Score: number;
|
||||
} :
|
||||
Type extends EventTypes.ADDRESS_WIDGET ? {
|
||||
Name: string;
|
||||
} :
|
||||
undefined;
|
||||
/* eslint-enable @stylistic/indent */
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
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>;
|
||||
|
||||
export const ADDRESS_FORMATS = [ 'base16', 'bech32' ] as const;
|
||||
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>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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));
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
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';
|
||||
import * as addressMock from 'mocks/address/address';
|
||||
import * as countersMock from 'mocks/address/counters';
|
||||
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 * as pwConfig from 'playwright/utils/config';
|
||||
|
||||
@@ -11,6 +13,7 @@ import MockAddressPage from './testUtils/MockAddressPage';
|
||||
import type { AddressCountersQuery } from './utils/useAddressCountersQuery';
|
||||
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 hooksConfig = {
|
||||
router: {
|
||||
@@ -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.use({ viewport: devices['iPhone 13 Pro'].viewport });
|
||||
|
||||
@@ -71,6 +110,8 @@ test.describe('mobile', () => {
|
||||
maskColor: pwConfig.maskColor,
|
||||
});
|
||||
});
|
||||
|
||||
test('with widgets', testWidgetsFn(true));
|
||||
});
|
||||
|
||||
test('contract', async({ render, page, mockApiResponse }) => {
|
||||
@@ -145,3 +186,5 @@ test('filecoin', async({ render, mockApiResponse, page }) => {
|
||||
maskColor: pwConfig.maskColor,
|
||||
});
|
||||
});
|
||||
|
||||
test('with widgets', testWidgetsFn(false));
|
||||
|
||||
@@ -19,6 +19,8 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity';
|
||||
import TxEntity from 'ui/shared/entities/tx/TxEntity';
|
||||
import ContractCreationStatus from 'ui/shared/statusTag/ContractCreationStatus';
|
||||
|
||||
import Address3rdPartyWidgets from './Address3rdPartyWidgets';
|
||||
import useAddress3rdPartyWidgets from './address3rdPartyWidgets/useAddress3rdPartyWidgets';
|
||||
import AddressAlternativeFormat from './details/AddressAlternativeFormat';
|
||||
import AddressBalance from './details/AddressBalance';
|
||||
import AddressImplementations from './details/AddressImplementations';
|
||||
@@ -41,6 +43,8 @@ const AddressDetails = ({ addressQuery, countersQuery, isLoading }: Props) => {
|
||||
|
||||
const addressHash = getQueryParamString(router.query.hash);
|
||||
|
||||
const address3rdPartyWidgets = useAddress3rdPartyWidgets(addressQuery.data?.is_contract ? 'contract' : 'eoa', addressQuery.isPlaceholderData);
|
||||
|
||||
const error404Data = React.useMemo(() => ({
|
||||
hash: addressHash || '',
|
||||
is_contract: false,
|
||||
@@ -302,6 +306,23 @@ const AddressDetails = ({ addressQuery, countersQuery, isLoading }: Props) => {
|
||||
) }
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -0,0 +1,144 @@
|
||||
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;
|
||||
@@ -0,0 +1,31 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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';
|
||||
import { ADDRESS_TABS_COUNTERS } from 'stubs/address';
|
||||
import { USER_OPS_ACCOUNT } from 'stubs/userOps';
|
||||
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 AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
|
||||
import AddressCoinBalance from 'ui/address/AddressCoinBalance';
|
||||
@@ -132,10 +134,17 @@ const AddressPageContent = () => {
|
||||
addressEnsDomainsQuery.data?.items.find((domain) => domain.name === addressQuery.data?.ens_domain_name) :
|
||||
undefined;
|
||||
|
||||
const address3rdPartyWidgets = useAddress3rdPartyWidgets(
|
||||
addressQuery.data?.is_contract ? 'contract' : 'eoa',
|
||||
addressQuery.isPlaceholderData,
|
||||
areQueriesEnabled,
|
||||
);
|
||||
|
||||
const isLoading = addressQuery.isPlaceholderData;
|
||||
const isTabsLoading =
|
||||
isLoading ||
|
||||
addressTabsCountersQuery.isPlaceholderData ||
|
||||
(address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.configQuery.isPlaceholderData) ||
|
||||
(config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData) ||
|
||||
(config.features.mudFramework.isEnabled && mudTablesCountQuery.isPlaceholderData);
|
||||
|
||||
@@ -282,6 +291,20 @@ const AddressPageContent = () => {
|
||||
component: <AddressLogs shouldRender={ !isTabsLoading } isQueryEnabled={ areQueriesEnabled }/>,
|
||||
} :
|
||||
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);
|
||||
}, [
|
||||
addressQuery,
|
||||
@@ -292,6 +315,7 @@ const AddressPageContent = () => {
|
||||
isTabsLoading,
|
||||
areQueriesEnabled,
|
||||
mudTablesCountQuery.data,
|
||||
address3rdPartyWidgets,
|
||||
]);
|
||||
|
||||
const usernameApiTag = userPropfileApiQuery.data?.user_profile?.username;
|
||||
|
||||
+15
-1
@@ -22,6 +22,8 @@ import * as tokenStubs from 'stubs/token';
|
||||
import { getTokenHoldersStub } from 'stubs/token';
|
||||
import { generateListStub } from 'stubs/utils';
|
||||
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 AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
|
||||
import useContractTabs from 'ui/address/contract/useContractTabs';
|
||||
@@ -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 tabs: Array<TabItemRegular> = [
|
||||
@@ -198,6 +206,12 @@ const TokenPageContent = () => {
|
||||
component: <AddressContract tabs={ contractTabs.tabs } isLoading={ contractTabs.isLoading } shouldRender={ !isLoading }/>,
|
||||
subTabs: CONTRACT_TAB_IDS,
|
||||
} : 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);
|
||||
|
||||
let pagination: PaginationParams | undefined;
|
||||
|
||||
Reference in New Issue
Block a user