Hot contracts table (#3165)

* page layout

* add interval filter

* fix navigation

* value formatting and test

* fix: correct empty state message from 'pools' to 'hot contracts'

* update screenshots

* add disabled state to tag select and fix some bugs

* fix screenshot

* update select texts
This commit is contained in:
tom goriunov
2025-12-18 16:02:53 +01:00
committed by GitHub
parent fa69b33a8b
commit dc9f400fa8
66 changed files with 681 additions and 52 deletions
+21
View File
@@ -0,0 +1,21 @@
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const title = 'Hot contracts';
const config: Feature<{ isEnabled: true }> = (() => {
if (getEnvValue('NEXT_PUBLIC_HOT_CONTRACTS_ENABLED') === 'true') {
return Object.freeze({
title,
isEnabled: true,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
+1
View File
@@ -22,6 +22,7 @@ export { default as gasTracker } from './gasTracker';
export { default as getGasButton } from './getGasButton';
export { default as googleAnalytics } from './googleAnalytics';
export { default as growthBook } from './growthBook';
export { default as hotContracts } from './hotContracts';
export { default as marketplace } from './marketplace';
export { default as megaEth } from './megaEth';
export { default as metasuites } from './metasuites';
+1
View File
@@ -37,6 +37,7 @@ NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED=false
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['rgba(51, 53, 67, 1)'],'text_color':['rgba(165, 252, 122, 1)']}
NEXT_PUBLIC_HOT_CONTRACTS_ENABLED=true
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
+1
View File
@@ -146,6 +146,7 @@ const schema = yup
return isUndefined || valueSchema.isValidSync(data);
}),
NEXT_PUBLIC_FLASHBLOCKS_SOCKET_URL: yup.string().test(urlTest),
NEXT_PUBLIC_HOT_CONTRACTS_ENABLED: yup.boolean(),
// Misc
NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(),
@@ -38,6 +38,7 @@ NEXT_PUBLIC_MAX_CONTENT_WIDTH_ENABLED=false
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_STATS=['total_blocks','average_block_time','total_txs','wallet_addresses','gas_tracker','current_epoch']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['lightpink'],'text_color':['deepskyblue','white'],'border':['3px solid black']}
NEXT_PUBLIC_HOT_CONTRACTS_ENABLED=true
NEXT_PUBLIC_GAS_TRACKER_ENABLED=true
NEXT_PUBLIC_GAS_TRACKER_UNITS=['gwei']
NEXT_PUBLIC_IS_TESTNET=true
+10
View File
@@ -988,6 +988,16 @@ This feature enables Blockscout Merits program. It requires that the [My account
&nbsp;
### Hot contract
Show the page with aggregate metrics for the most popular contracts.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_HOT_CONTRACTS_ENABLED | `boolean` | Set to true to enable the feature | Required | - | `true` | upcoming |
&nbsp;
### Flashblocks
This feature allows users to view [Flashblocks](https://docs.base.org/base-chain/flashblocks/apps)-related content in the explorer, including the Flashblocks real-time feed. It currently supports only Base chains.
+2 -9
View File
@@ -1,10 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#gas_svg__a)">
<path d="M12 17h1v2H0v-2h1V2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v8h2a2 2 0 0 1 2 2v4a1 1 0 0 0 2 0V9h-2a1 1 0 0 1-1-1V4.414l-1.657-1.657 1.414-1.414 4.95 4.95A.996.996 0 0 1 20 7v9a3 3 0 0 1-6 0v-4h-2v5Zm-9 0h7v-6H3v6ZM3 3v6h7V3H3Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="gas_svg__a">
<path fill="#fff" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.727 5A2.742 2.742 0 0 0 5 7.727V25h12.727v-6.364h1.819v3.637A2.742 2.742 0 0 0 22.273 25 2.742 2.742 0 0 0 25 22.273V13.01c0-.724-.284-1.392-.796-1.903l-4.29-4.29-1.278 1.279 2.586 2.585c-.977.415-1.677 1.378-1.677 2.5a2.742 2.742 0 0 0 2.728 2.727c.32 0 .621-.067.909-.17v6.534c0 .515-.394.909-.91.909a.893.893 0 0 1-.908-.91v-3.636a1.83 1.83 0 0 0-1.819-1.818h-1.818v-9.09A2.742 2.742 0 0 0 15 5H7.727Zm0 1.818H15c.515 0 .91.394.91.91v3.636H6.817V7.727c0-.515.394-.909.91-.909Zm14.546 5.455c.511 0 .909.398.909.909s-.398.909-.91.909a.897.897 0 0 1-.908-.91c0-.51.397-.908.909-.908Zm-15.455.909h9.091v10H6.82v-10Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 732 B

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#gas_slim_svg__a)">
<path d="M12 17h1v2H0v-2h1V2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v8h2a2 2 0 0 1 2 2v4a1 1 0 0 0 2 0V9h-2a1 1 0 0 1-1-1V4.414l-1.657-1.657 1.414-1.414 4.95 4.95A.996.996 0 0 1 20 7v9a3 3 0 0 1-6 0v-4h-2v5Zm-9 0h7v-6H3v6ZM3 3v6h7V3H3Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="gas_slim_svg__a">
<path fill="#fff" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 495 B

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m19.094 2.756.03.007a.955.955 0 0 1 .108.014l.129.035.003.001.021.007.063.023a.996.996 0 0 1 .28.185l5.522 5.297.152.146-.016.021a.986.986 0 0 1 .172.556v11.68a2.778 2.778 0 0 1-2.49 2.76l-.282.014h-1.397v.975a2.778 2.778 0 0 1-2.49 2.758l-.283.015H5.442c-.517 0-.944-.393-.995-.898l-.006-.102V7.499c0-.552.45-1 1-1h3.17V3.75l.006-.103a1 1 0 0 1 .995-.897h9.423a.32.32 0 0 1 .06.006ZM6.001 25.69h13.056l.058-.005c.137-.029.302-.157.453-.398.165-.264.262-.585.262-.81v-.975H9.612a1 1 0 0 1-1-1.001V8.059h-2.61V25.69Zm4.17-3.749h13.056l.08-.003a.774.774 0 0 0 .692-.77V9.607h-3.192c-.733 0-1.329-.182-1.74-.591-.41-.41-.592-1.006-.592-1.74V4.31H10.17V21.94Zm7.207-9.186a.66.66 0 0 1 .465.287l1.332 1.999 1.605-1.282.084-.058a.658.658 0 0 1 .666.009l.083.06c.179.151.267.392.22.628l-.866 4.325a.658.658 0 0 1-.527.517l-.115.01H14.27a.656.656 0 0 1-.643-.527l-.865-4.325a.656.656 0 0 1 .302-.688l.09-.046a.658.658 0 0 1 .66.095l1.604 1.282 1.333-1.999a.658.658 0 0 1 .545-.292l.081.005Zm-1.265 3.608a.658.658 0 0 1-.444.285.658.658 0 0 1-.395-.063l-.115-.073-.76-.607.41 2.034h4.979l.406-2.034-.757.607a.658.658 0 0 1-.374.142l-.136-.006a.66.66 0 0 1-.358-.18l-.087-.105-1.185-1.777-1.184 1.777Zm3.922-9.088.004.08c.04.388.37.692.768.692h1.706l-2.478-2.39v1.618Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+9
View File
@@ -8,6 +8,7 @@ import type { Blob } from 'types/api/blobs';
import type { Block } from 'types/api/block';
import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendVersionConfig, CeloConfig, CsvExportConfig } from 'types/api/configs';
import type { HotContractsFilters, HotContractsResponse, HotContractsSorting } from 'types/api/contracts';
import type { DepositsResponse, DepositsCounters } from 'types/api/deposits';
import type { CeloEpochDetails, CeloEpochElectionRewardDetailsResponse, CeloEpochListResponse } from 'types/api/epochs';
import type { IndexingStatus } from 'types/api/indexingStatus';
@@ -75,6 +76,11 @@ export const GENERAL_API_MISC_RESOURCES = {
stats_charts_secondary_coin_price: {
path: '/api/v2/stats/charts/secondary-coin-market',
},
stats_hot_contracts: {
path: '/api/v2/stats/hot-smart-contracts',
paginated: true,
filterFields: [ 'scale' as const ],
},
// HOMEPAGE
homepage_blocks: {
@@ -271,6 +277,7 @@ R extends 'general:stats' ? HomeStats :
R extends 'general:stats_charts_txs' ? ChartTransactionResponse :
R extends 'general:stats_charts_market' ? ChartMarketResponse :
R extends 'general:stats_charts_secondary_coin_price' ? ChartSecondaryCoinPriceResponse :
R extends 'general:stats_hot_contracts' ? HotContractsResponse :
R extends 'general:homepage_blocks' ? Array<Block> :
R extends 'general:homepage_txs' ? Array<Transaction> :
R extends 'general:homepage_txs_watchlist' ? Array<Transaction> :
@@ -316,6 +323,7 @@ never;
/* eslint-disable @stylistic/indent */
export type GeneralApiMiscPaginationFilters<R extends GeneralApiMiscResourceName> =
R extends 'general:stats_hot_contracts' ? HotContractsFilters :
R extends 'general:search' ? SearchResultFilters :
R extends 'general:user_ops' ? UserOpsFilters :
R extends 'general:validators_stability' ? ValidatorsStabilityFilters :
@@ -325,6 +333,7 @@ never;
/* eslint-disable @stylistic/indent */
export type GeneralApiMiscPaginationSorting<R extends GeneralApiMiscResourceName> =
R extends 'general:stats_hot_contracts' ? HotContractsSorting :
R extends 'general:validators_stability' ? ValidatorsStabilitySorting :
R extends 'general:validators_blackfort' ? ValidatorsBlackfortSorting :
never;
+29 -33
View File
@@ -261,34 +261,34 @@ export default function useNavItems(): ReturnType {
].filter(Boolean);
const statsNavItem = (() => {
const uptimeItem = {
text: 'Uptime',
nextRoute: { pathname: '/uptime' as const },
icon: 'refresh_menu',
isActive: pathname.startsWith('/uptime'),
};
const items = [
config.features.stats.isEnabled && {
text: 'Chain stats',
nextRoute: { pathname: '/stats' as const },
icon: 'graph',
isActive: pathname.startsWith('/stats'),
},
config.features.megaEth.isEnabled && {
text: 'Uptime',
nextRoute: { pathname: '/uptime' as const },
icon: 'refresh_menu',
isActive: pathname.startsWith('/uptime'),
},
config.features.hotContracts.isEnabled && {
text: 'Hot contracts',
nextRoute: { pathname: '/hot-contracts' as const },
icon: 'hot-contracts',
isActive: pathname.startsWith('/hot-contracts'),
},
config.features.gasTracker.isEnabled && {
text: 'Gas tracker',
nextRoute: { pathname: '/gas-tracker' as const },
icon: 'gas',
isActive: pathname.startsWith('/gas-tracker'),
},
].filter(Boolean);
if (config.features.stats.isEnabled && config.features.megaEth.isEnabled) {
return {
text: 'Charts & stats',
icon: 'stats',
isActive: pathname.startsWith('/stats') || pathname.startsWith('/uptime'),
subItems: [
{
text: `${ config.chain.name } stats`,
nextRoute: { pathname: '/stats' as const },
icon: 'graph',
isActive: pathname.startsWith('/stats/'),
},
uptimeItem,
],
};
}
if (!config.features.stats.isEnabled) {
if (config.features.megaEth.isEnabled) {
return uptimeItem;
}
if (items.length === 0) {
return null;
}
@@ -296,7 +296,8 @@ export default function useNavItems(): ReturnType {
text: 'Charts & stats',
nextRoute: { pathname: '/stats' as const },
icon: 'stats',
isActive: pathname.startsWith('/stats'),
isActive: items.some(item => isInternalItem(item) && item.isActive),
subItems: items,
};
})();
@@ -316,11 +317,6 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/contract-verification' as const },
isActive: pathname.startsWith('/contract-verification'),
},
config.features.gasTracker.isEnabled && {
text: 'Gas tracker',
nextRoute: { pathname: '/gas-tracker' as const },
isActive: pathname.startsWith('/gas-tracker'),
},
config.features.publicTagsSubmission.isEnabled && {
text: 'Submit public tag',
nextRoute: { pathname: '/public-tags/submit' as const },
+1
View File
@@ -27,6 +27,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/stats': 'Root page',
'/stats/[id]': 'Regular page',
'/uptime': 'Root page',
'/hot-contracts': 'Root page',
'/api-docs': 'Regular page',
'/search-results': 'Regular page',
'/auth/profile': 'Root page',
+1
View File
@@ -30,6 +30,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/stats': DEFAULT_TEMPLATE,
'/stats/[id]': DEFAULT_TEMPLATE,
'/uptime': DEFAULT_TEMPLATE,
'/hot-contracts': DEFAULT_TEMPLATE,
'/api-docs': DEFAULT_TEMPLATE,
'/search-results': DEFAULT_TEMPLATE,
'/auth/profile': DEFAULT_TEMPLATE,
+1
View File
@@ -31,6 +31,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/stats': '%network_name% stats - %network_name% network insights',
'/stats/[id]': '%network_name% stats - %id% chart',
'/uptime': '%network_name% uptime',
'/hot-contracts': '%network_name% hot contracts',
'/api-docs': '%network_name% API docs - %network_name% developer tools',
'/search-results': '%network_name% search result for %q%',
'/auth/profile': '%network_name% - my profile',
+1
View File
@@ -25,6 +25,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/stats': 'Stats',
'/stats/[id]': 'Stats chart',
'/uptime': 'Uptime',
'/hot-contracts': 'Hot contracts',
'/api-docs': 'REST API',
'/search-results': 'Search results',
'/auth/profile': 'Profile',
+32 -1
View File
@@ -1,4 +1,4 @@
import type { VerifiedContract, VerifiedContractsResponse } from 'types/api/contracts';
import type { HotContractsResponse, VerifiedContract, VerifiedContractsResponse } from 'types/api/contracts';
export const contract1: VerifiedContract = {
address: {
@@ -83,3 +83,34 @@ export const baseResponse: VerifiedContractsResponse = {
smart_contract_id: '172',
},
};
export const hotContractsResponse: HotContractsResponse = {
items: [
{
contract_address: { ...contract1.address, name: null, reputation: 'scam' },
balance: '1000000000000000000',
transactions_count: '1000',
total_gas_used: '100000000',
},
{
contract_address: {
...contract2.address,
metadata: {
reputation: null,
tags: [
{ tagType: 'protocol', name: 'Goose', slug: 'goose', ordinal: 1, meta: null },
],
},
},
balance: '420',
transactions_count: '42',
total_gas_used: '12343566',
},
],
next_page_params: {
items_count: '50',
transactions_count: '50',
total_gas_used: '50',
contract_address_hash: '50',
},
};
+8
View File
@@ -142,6 +142,14 @@ export const gasTracker: Guard = (chainConfig: typeof config) => async() => {
}
};
export const hotContracts: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.hotContracts.isEnabled) {
return {
notFound: true,
};
}
};
export const advancedFilter: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.advancedFilter.isEnabled) {
return {
+1
View File
@@ -22,6 +22,7 @@ export const accountsLabelSearch = factory([ guards.accountsLabelSearch ]);
export const validators = factory([ guards.validators ]);
export const validatorDetails = factory([ guards.validatorDetails ]);
export const gasTracker = factory([ guards.gasTracker ]);
export const hotContracts = factory([ guards.hotContracts ]);
export const advancedFilter = factory([ guards.advancedFilter ]);
export const dataAvailability = factory([ guards.dataAvailability ]);
export const login = factory([ guards.login ]);
+1
View File
@@ -59,6 +59,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/epochs">
| DynamicRoute<"/essential-dapps/[id]", { "id": string }>
| StaticRoute<"/gas-tracker">
| StaticRoute<"/hot-contracts">
| StaticRoute<"/">
| StaticRoute<"/internal-txs">
| StaticRoute<"/interop-messages">
+19
View File
@@ -0,0 +1,19 @@
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const HotContracts = dynamic(() => import('ui/pages/HotContracts'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/hot-contracts">
<HotContracts/>
</PageNextJs>
);
};
export default Page;
export { hotContracts as getServerSideProps } from 'nextjs/getServerSideProps/main';
+2
View File
@@ -76,6 +76,7 @@
| "flame"
| "flashblock"
| "games"
| "gas_slim"
| "gas_xl"
| "gas"
| "gear_slim"
@@ -86,6 +87,7 @@
| "heart_filled"
| "heart_outline"
| "hexagon"
| "hot-contracts"
| "hourglass_slim"
| "hourglass"
| "info_filled"
+8 -1
View File
@@ -1,6 +1,6 @@
import type * as stats from '@blockscout/stats-types';
import type { SmartContract, SmartContractMudSystemsResponse } from 'types/api/contract';
import type { VerifiedContract, VerifiedContractsCounters } from 'types/api/contracts';
import type { HotContract, VerifiedContract, VerifiedContractsCounters } from 'types/api/contracts';
import type { SolidityScanReport } from 'lib/solidityScan/schema';
@@ -117,3 +117,10 @@ export const MUD_SYSTEMS: SmartContractMudSystemsResponse = {
},
],
};
export const HOT_CONTRACTS: HotContract = {
contract_address: VERIFIED_CONTRACT_INFO.address,
balance: '1000000000000000000',
transactions_count: '1000',
total_gas_used: '100000000',
};
+4 -1
View File
@@ -16,6 +16,7 @@ export interface TagProps extends ChakraTag.RootProps {
truncated?: boolean;
loading?: boolean;
selected?: boolean;
disabled?: boolean;
}
export const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
@@ -31,6 +32,7 @@ export const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
truncated = false,
loading,
selected,
disabled,
...rest
} = props;
@@ -48,7 +50,8 @@ export const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
<Skeleton loading={ loading } asChild>
<ChakraTag.Root
ref={ ref }
{ ...(selected && { 'data-selected': true }) }
{ ...(selected && !loading && { 'data-selected': true }) }
{ ...(disabled && { 'data-disabled': true }) }
{ ...rest }
>
{ startElement && (
+5
View File
@@ -14,6 +14,11 @@ export const recipe = defineSlotRecipe({
_loading: {
borderRadius: 'sm',
},
_disabled: {
opacity: 'control.disabled',
pointerEvents: 'none',
cursor: 'not-allowed',
},
},
label: {
lineClamp: '1',
+2
View File
@@ -1,5 +1,6 @@
import type { AddressMetadataTagApi } from './addressMetadata';
import type { SmartContractProxyType } from './contract';
import type { TokenReputation } from './token';
export interface AddressImplementation {
address_hash: string;
@@ -61,6 +62,7 @@ export type AddressParamBasic = {
} | null;
filecoin?: AddressFilecoinParams;
proxy_type?: SmartContractProxyType | null;
reputation?: TokenReputation;
};
export type AddressParam = UserTags & AddressParamBasic;
+32
View File
@@ -39,3 +39,35 @@ export type VerifiedContractsCounters = {
smart_contracts: string;
verified_smart_contracts: string;
};
export interface HotContract {
contract_address: AddressParam ;
balance: string;
transactions_count: string;
total_gas_used: string;
}
export interface HotContractsResponse {
items: Array<HotContract>;
next_page_params: {
items_count: string;
transactions_count: string;
total_gas_used: string;
contract_address_hash: string;
} | null;
}
export interface HotContractsFilters {
scale?: HotContractsInterval;
}
export interface HotContractsSorting {
sort: 'transactions_count' | 'total_gas_used';
order: 'asc' | 'desc';
}
export type HotContractsSortingField = HotContractsSorting['sort'];
export type HotContractsSortingValue = `${ HotContractsSortingField }-${ HotContractsSorting['order'] }` | 'default';
export type HotContractsInterval = '5m' | '1h' | '3h' | '1d' | '7d' | '30d';
+1 -1
View File
@@ -181,7 +181,7 @@ const Stats = () => {
},
hasGasTracker && apiData?.gas_prices && {
id: 'gas_tracker' as const,
icon: 'gas' as const,
icon: 'gas_slim' as const,
label: 'Gas tracker',
value: apiData.gas_prices.average ? <GasPrice data={ apiData.gas_prices.average }/> : 'N/A',
hint: gasInfoTooltip,
@@ -0,0 +1,66 @@
import { createListCollection } from '@chakra-ui/react';
import React from 'react';
import type { HotContractsInterval } from 'types/api/contracts';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import type { SelectOption } from 'toolkit/chakra/select';
import { Select } from 'toolkit/chakra/select';
import type { TagProps } from 'toolkit/chakra/tag';
import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect';
import { INTERVAL_ITEMS } from './utils';
const intervalCollection = createListCollection<SelectOption<string>>({
items: INTERVAL_ITEMS.map((item) => ({
value: item.id,
label: item.labelFull,
})),
});
const intervalItems = INTERVAL_ITEMS.map((item) => ({
id: item.id,
title: item.labelShort,
}));
interface Props {
interval: HotContractsInterval;
onIntervalChange: (newInterval: HotContractsInterval) => void;
isLoading?: boolean;
selectTagSize?: TagProps['size'];
};
const HotContractsIntervalSelect = ({ interval, onIntervalChange, isLoading, selectTagSize }: Props) => {
const isInitialLoading = useIsInitialLoading(isLoading);
const handleItemSelect = React.useCallback(({ value }: { value: Array<string> }) => {
onIntervalChange(value[0] as HotContractsInterval);
}, [ onIntervalChange ]);
return (
<>
<TagGroupSelect<HotContractsInterval>
items={ intervalItems }
onChange={ onIntervalChange }
value={ interval }
tagSize={ selectTagSize }
loading={ isInitialLoading }
disabled={ isLoading }
hideBelow="lg"
/>
<Select
collection={ intervalCollection }
placeholder="Select interval"
value={ [ interval ] }
onValueChange={ handleItemSelect }
hideFrom="lg"
w="100%"
loading={ isInitialLoading }
disabled={ isLoading }
/>
</>
);
};
export default React.memo(HotContractsIntervalSelect);
+63
View File
@@ -0,0 +1,63 @@
import { HStack } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { HotContract } from 'types/api/contracts';
import { Skeleton } from 'toolkit/chakra/skeleton';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import { Reputation } from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags/EntityTags';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import NativeCoinValue from 'ui/shared/value/NativeCoinValue';
interface Props {
data: HotContract;
isLoading?: boolean;
exchangeRate: string | null;
}
const HotContractsListItem = ({ data, isLoading, exchangeRate }: Props) => {
const protocolTags = data?.contract_address?.metadata?.tags?.filter(tag => tag.tagType === 'protocol');
return (
<ListItemMobile rowGap={ 3 } py={ 4 } textStyle="sm">
<HStack justifyContent="space-between" width="100%">
<AddressEntity
address={ data.contract_address }
isLoading={ isLoading }
/>
<Reputation value={ data.contract_address.reputation ?? null }/>
</HStack>
{ protocolTags && protocolTags.length > 0 && (
<EntityTags
isLoading={ isLoading }
tags={ protocolTags }
/>
) }
<HStack>
<Skeleton loading={ isLoading } fontWeight={ 500 } w="100px">Txn count</Skeleton>
<Skeleton loading={ isLoading }>
<span>{ Number(data.transactions_count).toLocaleString() }</span>
</Skeleton>
</HStack>
<HStack>
<Skeleton loading={ isLoading } fontWeight={ 500 } w="100px">Gas used</Skeleton>
<Skeleton loading={ isLoading }>
<span>{ BigNumber(data.total_gas_used || 0).toFormat() }</span>
</Skeleton>
</HStack>
<HStack alignItems="flex-start">
<Skeleton loading={ isLoading } fontWeight={ 500 } w="100px">Balance</Skeleton>
<NativeCoinValue
amount={ data.balance }
loading={ isLoading }
exchangeRate={ exchangeRate }
flexWrap="wrap"
/>
</HStack>
</ListItemMobile>
);
};
export default React.memo(HotContractsListItem);
+65
View File
@@ -0,0 +1,65 @@
import React from 'react';
import type { HotContract, HotContractsSortingField, HotContractsSortingValue } from 'types/api/contracts';
import { currencyUnits } from 'lib/units';
import { TableBody, TableColumnHeader, TableColumnHeaderSortable, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import getNextSortValue from 'ui/shared/sort/getNextSortValue';
import HotContractsTableItem from './HotContractsTableItem';
import { SORT_SEQUENCE } from './utils';
interface Props {
items: Array<HotContract> | undefined;
isLoading?: boolean;
sort: HotContractsSortingValue;
setSorting: ({ value }: { value: Array<string> }) => void;
exchangeRate: string | null;
};
const HotContractsTable = ({ items, isLoading, sort, setSorting, exchangeRate }: Props) => {
const onSortToggle = React.useCallback((field: HotContractsSortingField) => {
const value = getNextSortValue<HotContractsSortingField, HotContractsSortingValue>(SORT_SEQUENCE, field)(sort);
setSorting({ value: [ value ] });
}, [ sort, setSorting ]);
return (
<TableRoot minWidth="900px">
<TableHeaderSticky top={ ACTION_BAR_HEIGHT_DESKTOP }>
<TableRow>
<TableColumnHeader width="25%">Contract</TableColumnHeader>
<TableColumnHeaderSortable
width="25%"
isNumeric
sortField="transactions_count"
sortValue={ sort }
onSortToggle={ onSortToggle }
disabled={ isLoading }
>
Txn count
</TableColumnHeaderSortable>
<TableColumnHeaderSortable
width="25%"
isNumeric
sortField="total_gas_used"
sortValue={ sort }
onSortToggle={ onSortToggle }
disabled={ isLoading }
>
Gas used
</TableColumnHeaderSortable>
<TableColumnHeader width="25%" isNumeric>Balance { currencyUnits.ether }</TableColumnHeader>
</TableRow>
</TableHeaderSticky>
<TableBody>
{ items?.map((item, index) => (
<HotContractsTableItem key={ index } isLoading={ isLoading } data={ item } exchangeRate={ exchangeRate }/>
)) }
</TableBody>
</TableRoot>
);
};
export default HotContractsTable;
+64
View File
@@ -0,0 +1,64 @@
import { HStack } from '@chakra-ui/react';
import { BigNumber } from 'bignumber.js';
import React from 'react';
import type { HotContract } from 'types/api/contracts';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import { TruncatedText } from 'toolkit/components/truncation/TruncatedText';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import { Reputation } from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags/EntityTags';
import NativeCoinValue from 'ui/shared/value/NativeCoinValue';
interface Props {
isLoading?: boolean;
data: HotContract;
exchangeRate: string | null;
};
const HotContractsTableItem = ({
isLoading,
data,
exchangeRate,
}: Props) => {
const protocolTags = data?.contract_address?.metadata?.tags?.filter(tag => tag.tagType === 'protocol');
return (
<TableRow>
<TableCell verticalAlign="middle">
<HStack>
<AddressEntity
address={ data.contract_address }
isLoading={ isLoading }
/>
<Reputation value={ data.contract_address.reputation ?? null } ml={ 0 }/>
</HStack>
{ protocolTags && protocolTags.length > 0 && (
<EntityTags
isLoading={ isLoading }
tags={ protocolTags }
mt="10px"
/>
) }
</TableCell>
<TableCell isNumeric verticalAlign="middle">
<TruncatedText text={ Number(data.transactions_count).toLocaleString() } loading={ isLoading } maxW="100%"/>
</TableCell>
<TableCell isNumeric verticalAlign="middle">
<TruncatedText text={ BigNumber(data.total_gas_used || 0).toFormat() } loading={ isLoading } maxW="100%"/>
</TableCell>
<TableCell isNumeric verticalAlign="middle">
<NativeCoinValue
amount={ data.balance }
loading={ isLoading }
exchangeRate={ exchangeRate }
layout="vertical"
rowGap="10px"
/>
</TableCell>
</TableRow>
);
};
export default HotContractsTableItem;
+38
View File
@@ -0,0 +1,38 @@
import type { HotContractsSortingValue, HotContractsSortingField, HotContractsInterval } from 'types/api/contracts';
import getQueryParamString from 'lib/router/getQueryParamString';
import type { SelectOption } from 'toolkit/chakra/select';
export const SORT_OPTIONS: Array<SelectOption<HotContractsSortingValue>> = [
{ label: 'Default', value: 'default' },
{ label: 'Txs count descending', value: 'transactions_count-desc' },
{ label: 'Txs count ascending', value: 'transactions_count-asc' },
{ label: 'Gas used descending', value: 'total_gas_used-desc' },
{ label: 'Gas used ascending', value: 'total_gas_used-asc' },
];
export const SORT_SEQUENCE: Record<HotContractsSortingField, Array<HotContractsSortingValue>> = {
transactions_count: [ 'transactions_count-desc', 'transactions_count-asc', 'default' ],
total_gas_used: [ 'total_gas_used-desc', 'total_gas_used-asc', 'default' ],
};
export const INTERVAL_ITEMS: Array<{ id: HotContractsInterval; labelShort: string; labelFull: string }> = [
{ id: '5m', labelShort: '5m', labelFull: '5 minutes' },
{ id: '1h', labelShort: '1h', labelFull: '1 hour' },
{ id: '3h', labelShort: '3h', labelFull: '3 hours' },
{ id: '1d', labelShort: '1D', labelFull: '1 day' },
{ id: '7d', labelShort: '1W', labelFull: '1 week' },
{ id: '30d', labelShort: '1M', labelFull: '1 month' },
];
export const getIntervalValueFromQuery = (query: string | Array<string> | undefined): HotContractsInterval => {
const queryString = getQueryParamString(query);
if (queryString) {
const interval = INTERVAL_ITEMS.find(item => item.id === queryString);
if (interval) {
return interval.id;
}
}
return INTERVAL_ITEMS[0].id;
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import * as contractsMock from 'mocks/contracts/index';
import * as statsMock from 'mocks/stats/index';
import { test, expect } from 'playwright/lib';
import { getIntervalValueFromQuery } from 'ui/hotContracts/utils';
import HotContracts from './HotContracts';
test('base view +@mobile', async({ render, mockTextAd, mockApiResponse, mockEnvs }) => {
test.slow();
await mockEnvs([ [ 'NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED', 'true' ] ]);
await mockTextAd();
await mockApiResponse(
'general:stats_hot_contracts',
contractsMock.hotContractsResponse,
{ queryParams: { scale: getIntervalValueFromQuery(undefined) } },
);
await mockApiResponse('general:stats', { ...statsMock.base, coin_price: '3214.42' });
const component = await render(<HotContracts/>);
await expect(component).toHaveScreenshot({ timeout: 10_000 });
});
+137
View File
@@ -0,0 +1,137 @@
import { Box, createListCollection, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { HotContractsInterval, HotContractsSorting, HotContractsSortingField, HotContractsSortingValue } from 'types/api/contracts';
import useApiQuery from 'lib/api/useApiQuery';
import { HOT_CONTRACTS } from 'stubs/contract';
import { HOMEPAGE_STATS } from 'stubs/stats';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { apos } from 'toolkit/utils/htmlEntities';
import HotContractsIntervalSelect from 'ui/hotContracts/HotContractsIntervalSelect';
import HotContractsListItem from 'ui/hotContracts/HotContractsListItem';
import HotContractsTable from 'ui/hotContracts/HotContractsTable';
import { getIntervalValueFromQuery, SORT_OPTIONS } from 'ui/hotContracts/utils';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
import Sort from 'ui/shared/sort/Sort';
const sortCollection = createListCollection({
items: SORT_OPTIONS,
});
const HotContracts = () => {
const router = useRouter();
const [ interval, setInterval ] = React.useState<HotContractsInterval>(getIntervalValueFromQuery(router.query.scale));
const [ sort, setSort ] =
React.useState<HotContractsSortingValue>(getSortValueFromQuery<HotContractsSortingValue>(router.query, SORT_OPTIONS) ?? 'default');
const { data, isError, isPlaceholderData, pagination, onSortingChange, onFilterChange } = useQueryWithPages({
resourceName: 'general:stats_hot_contracts',
filters: { scale: interval },
sorting: getSortParamsFromValue<HotContractsSortingValue, HotContractsSortingField, HotContractsSorting['order']>(sort),
options: {
placeholderData: {
items: Array(50).fill(HOT_CONTRACTS),
next_page_params: { items_count: '50', transactions_count: '50', total_gas_used: '50', contract_address_hash: '50' },
},
},
});
const statsQuery = useApiQuery('general:stats', {
queryOptions: {
placeholderData: HOMEPAGE_STATS,
refetchOnMount: false,
},
});
const handleSortChange = React.useCallback(({ value }: { value: Array<string> }) => {
setSort(value[0] as HotContractsSortingValue);
onSortingChange(value[0] === 'default' ? undefined : getSortParamsFromValue(value[0] as HotContractsSortingValue));
}, [ onSortingChange ]);
const handleIntervalChange = React.useCallback((newInterval: HotContractsInterval) => {
setInterval(newInterval);
onFilterChange({ scale: newInterval });
}, [ onFilterChange ]);
const content = (
<>
<Box hideFrom="lg">
{ data?.items.map((item, index) => (
<HotContractsListItem
key={ item.contract_address.hash + (isPlaceholderData ? index : '') }
isLoading={ isPlaceholderData }
data={ item }
exchangeRate={ statsQuery.data?.coin_price ?? null }
/>
)) }
</Box>
<Box hideBelow="lg">
<HotContractsTable
items={ data?.items }
isLoading={ isPlaceholderData }
sort={ sort }
setSorting={ handleSortChange }
exchangeRate={ statsQuery.data?.coin_price ?? null }
/>
</Box>
</>
);
const actionBar = (
<ActionBar mt={ -6 }>
<Flex alignItems="center">
<HotContractsIntervalSelect
interval={ interval }
onIntervalChange={ handleIntervalChange }
isLoading={ isPlaceholderData }
/>
{ [ '1d', '7d', '30d' ].includes(interval) && (
<Skeleton loading={ isPlaceholderData } color="text.secondary" hideBelow="lg" textStyle="sm" ml={ 6 }>
<span>The data is updated once a day.</span>
</Skeleton>
) }
<Sort
name="hot_contracts_sorting"
defaultValue={ [ sort ] }
collection={ sortCollection }
onValueChange={ handleSortChange }
isLoading={ isPlaceholderData }
hideFrom="lg"
ml={ 2 }
/>
</Flex>
<Pagination { ...pagination } ml="auto"/>
</ActionBar>
);
return (
<>
<PageTitle
title="Hot contracts"
withTextAd
/>
<DataListDisplay
isError={ isError }
itemsNum={ data?.items.length }
emptyText="There are no hot contracts."
actionBar={ actionBar }
filterProps={{
emptyFilteredText: `Couldn${ apos }t find hot contracts that matches your filter query.`,
hasActiveFilters: Boolean(interval),
}}
>
{ content }
</DataListDisplay>
</>
);
};
export default HotContracts;
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+6 -3
View File
@@ -1,6 +1,7 @@
import { chakra } from '@chakra-ui/react';
import React from 'react';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import useIsMobile from 'lib/hooks/useIsMobile';
import { IconButton } from 'toolkit/chakra/icon-button';
import type { SelectRootProps } from 'toolkit/chakra/select';
@@ -14,16 +15,18 @@ export interface Props extends SelectRootProps {
const Sort = (props: Props) => {
const { collection, isLoading, ...rest } = props;
const isMobile = useIsMobile(false);
const isInitialLoading = useIsInitialLoading(isLoading);
const trigger = (() => {
if (isMobile) {
return (
<SelectControl triggerProps={{ asChild: true }} noIndicator>
<IconButton
loadingSkeleton={ isLoading }
loadingSkeleton={ isInitialLoading }
aria-label="sort"
size="md"
variant="dropdown"
disabled={ isLoading }
>
<IconSvg name="arrows/up-down"/>
</IconButton>
@@ -33,7 +36,7 @@ const Sort = (props: Props) => {
return (
<SelectControl
loading={ isLoading }
loading={ isInitialLoading }
_hover={{ color: 'hover' }}
_open={{ color: 'hover' }}
>
@@ -56,7 +59,7 @@ const Sort = (props: Props) => {
})();
return (
<SelectRoot collection={ collection } w="fit-content" variant="plain" { ...rest }>
<SelectRoot collection={ collection } w="fit-content" variant="plain" disabled={ isLoading } { ...rest }>
{ trigger }
<SelectContent>
{ collection.items.map((item) => (
+8 -3
View File
@@ -1,3 +1,4 @@
import type { StackProps } from '@chakra-ui/react';
import { HStack } from '@chakra-ui/react';
import React from 'react';
@@ -7,6 +8,8 @@ import { Tag } from 'toolkit/chakra/tag';
type Props<T extends string> = {
items: Array<{ id: T; title: string }>;
tagSize?: TagProps['size'];
loading?: boolean;
disabled?: boolean;
} & (
{
value?: T;
@@ -17,9 +20,9 @@ type Props<T extends string> = {
onChange: (value: Array<T>) => void;
isMulti: true;
}
);
) & Omit<StackProps, 'onChange'>;
const TagGroupSelect = <T extends string>({ items, value, isMulti, onChange, tagSize, ...rest }: Props<T>) => {
const TagGroupSelect = <T extends string>({ items, value, isMulti, onChange, tagSize, loading, disabled, ...rest }: Props<T>) => {
const onItemClick = React.useCallback((event: React.SyntheticEvent) => {
const itemValue = (event.currentTarget as HTMLDivElement).getAttribute('data-id') as T;
if (isMulti) {
@@ -46,10 +49,12 @@ const TagGroupSelect = <T extends string>({ items, value, isMulti, onChange, tag
data-id={ item.id }
selected={ isSelected }
fontWeight={ 500 }
onClick={ onItemClick }
onClick={ disabled ? undefined : onItemClick }
size={ tagSize }
display="inline-flex"
justifyContent="center"
loading={ loading }
disabled={ disabled }
>
{ item.title }
</Tag>
+4
View File
@@ -65,6 +65,10 @@ const TagShowcase = () => {
<Tag loading>My tag</Tag>
<Tag maxW="150px" truncated loading>Very very very very very looooooonggggg text</Tag>
</Sample>
<Sample label="loading: true, variant: select">
<Tag variant="select" loading>Default</Tag>
<Tag variant="select" selected loading>Selected</Tag>
</Sample>
<Sample label="loading: false">
<Tag>My tag</Tag>
<Tag maxW="150px" truncated>Very very very very very looooooonggggg text</Tag>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB