diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index a61fe756af..5b2623182f 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -4,7 +4,7 @@ imagePullSecrets: - name: regcred config: network: - id: 11155111 + id: "11155111" name: Blockscout shortname: Blockscout currency: diff --git a/lib/api/resources.ts b/lib/api/resources.ts index a1f493274a..ac5d924f12 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -38,7 +38,7 @@ import type { AddressMudRecordsSorting, AddressMudRecord, } from 'types/api/address'; -import type { AddressesResponse } from 'types/api/addresses'; +import type { AddressesResponse, AddressesMetadataSearchResult, AddressesMetadataSearchFilters } from 'types/api/addresses'; import type { AddressMetadataInfo, PublicTagTypesResponse } from 'types/api/addressMetadata'; import type { ArbitrumL2MessagesResponse, @@ -421,6 +421,10 @@ export const RESOURCES = { path: '/api/v2/addresses/', filterFields: [ ], }, + addresses_metadata_search: { + path: '/api/v2/proxy/metadata/addresses', + filterFields: [ 'slug' as const, 'tag_type' as const ], + }, // ADDRESS address: { @@ -979,7 +983,7 @@ export type ResourceErrorAccount = ResourceError<{ errors: T }> export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_rewards' | 'txs_validated' | 'txs_pending' | 'txs_with_blobs' | 'txs_watchlist' | 'txs_execution_node' | 'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' | 'tx_blobs' | -'addresses' | +'addresses' | 'addresses_metadata_search' | 'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' | 'search' | 'address_logs' | 'address_tokens' | 'address_nfts' | 'address_collections' | @@ -1053,6 +1057,7 @@ Q extends 'tx_state_changes' ? TxStateChanges : Q extends 'tx_blobs' ? TxBlobs : Q extends 'tx_interpretation' ? TxInterpretationResponse : Q extends 'addresses' ? AddressesResponse : +Q extends 'addresses_metadata_search' ? AddressesMetadataSearchResult : Q extends 'address' ? Address : Q extends 'address_counters' ? AddressCounters : Q extends 'address_tabs_counters' ? AddressTabsCounters : @@ -1178,6 +1183,7 @@ Q extends 'txs_with_blobs' ? TTxsWithBlobsFilters : Q extends 'tx_token_transfers' ? TokenTransferFilters : Q extends 'token_transfers' ? TokenTransferFilters : Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters : +Q extends 'addresses_metadata_search' ? AddressesMetadataSearchFilters : Q extends 'address_token_transfers' ? AddressTokenTransferFilters : Q extends 'address_tokens' ? AddressTokensFilter : Q extends 'address_nfts' ? AddressNFTTokensFilter : diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index 80f2c9d1f9..1f8498fe69 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -12,6 +12,7 @@ const OG_TYPE_DICT: Record = { '/block/countdown': 'Regular page', '/block/countdown/[height]': 'Regular page', '/accounts': 'Root page', + '/accounts/label/[slug]': 'Root page', '/address/[hash]': 'Regular page', '/verified-contracts': 'Root page', '/contract-verification': 'Root page', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index 77e483bcba..e4d0ccd715 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -16,6 +16,7 @@ const TEMPLATE_MAP: Record = { '/block/countdown': DEFAULT_TEMPLATE, '/block/countdown/[height]': DEFAULT_TEMPLATE, '/accounts': DEFAULT_TEMPLATE, + '/accounts/label/[slug]': DEFAULT_TEMPLATE, '/address/[hash]': 'View the account balance, transactions, and other data for %hash% on the %network_title%', '/verified-contracts': DEFAULT_TEMPLATE, '/contract-verification': DEFAULT_TEMPLATE, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index ce937291c2..e0c9d9e44c 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -12,6 +12,7 @@ const TEMPLATE_MAP: Record = { '/block/countdown': '%network_name% block countdown index', '/block/countdown/[height]': '%network_name% block %height% countdown', '/accounts': '%network_name% top accounts', + '/accounts/label/[slug]': '%network_name% addresses search by label', '/address/[hash]': '%network_name% address details for %hash%', '/verified-contracts': 'Verified %network_name% contracts lookup - %network_name% explorer', '/contract-verification': '%network_name% verify contract', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index c79bf5410d..3fc7896f81 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -10,6 +10,7 @@ export const PAGE_TYPE_DICT: Record = { '/block/countdown': 'Block countdown search', '/block/countdown/[height]': 'Block countdown', '/accounts': 'Top accounts', + '/accounts/label/[slug]': 'Addresses search by label', '/address/[hash]': 'Address details', '/verified-contracts': 'Verified contracts', '/contract-verification': 'Contract verification', diff --git a/nextjs/getServerSideProps.ts b/nextjs/getServerSideProps.ts index 730d962593..c9933fb679 100644 --- a/nextjs/getServerSideProps.ts +++ b/nextjs/getServerSideProps.ts @@ -204,6 +204,16 @@ export const accounts: GetServerSideProps = async(context) => { return base(context); }; +export const accountsLabelSearch: GetServerSideProps = async(context) => { + if (!config.features.addressMetadata.isEnabled || !context.query.tagType) { + return { + notFound: true, + }; + } + + return base(context); +}; + export const userOps: GetServerSideProps = async(context) => { if (!config.features.userOps.isEnabled) { return { diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index a94fb52dbe..2d947f73ec 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -13,6 +13,7 @@ declare module "nextjs-routes" { | StaticRoute<"/account/verified-addresses"> | StaticRoute<"/account/watchlist"> | StaticRoute<"/accounts"> + | DynamicRoute<"/accounts/label/[slug]", { "slug": string }> | DynamicRoute<"/address/[hash]/contract-verification", { "hash": string }> | DynamicRoute<"/address/[hash]", { "hash": string }> | StaticRoute<"/api/config"> diff --git a/pages/accounts.tsx b/pages/accounts/index.tsx similarity index 100% rename from pages/accounts.tsx rename to pages/accounts/index.tsx diff --git a/pages/accounts/label/[slug].tsx b/pages/accounts/label/[slug].tsx new file mode 100644 index 0000000000..e90b38bfe3 --- /dev/null +++ b/pages/accounts/label/[slug].tsx @@ -0,0 +1,19 @@ +import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import PageNextJs from 'nextjs/PageNextJs'; + +const AccountsLabelSearch = dynamic(() => import('ui/pages/AccountsLabelSearch'), { ssr: false }); + +const Page: NextPage = () => { + return ( + + + + ); +}; + +export default Page; + +export { accountsLabelSearch as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/types/api/addresses.ts b/types/api/addresses.ts index 9c76000760..3533b67655 100644 --- a/types/api/addresses.ts +++ b/types/api/addresses.ts @@ -1,6 +1,6 @@ import type { AddressParam } from './addressParams'; -export type AddressesItem = AddressParam &{ tx_count: string; coin_balance: string } +export type AddressesItem = AddressParam & { tx_count: string; coin_balance: string | null } export type AddressesResponse = { items: Array; @@ -11,3 +11,13 @@ export type AddressesResponse = { } | null; total_supply: string; } + +export interface AddressesMetadataSearchResult { + items: Array; + next_page_params: null; +} + +export interface AddressesMetadataSearchFilters { + slug: string; + tag_type: string; +} diff --git a/ui/addresses/AddressesListItem.tsx b/ui/addresses/AddressesListItem.tsx index d147ff7970..462db87c80 100644 --- a/ui/addresses/AddressesListItem.tsx +++ b/ui/addresses/AddressesListItem.tsx @@ -25,7 +25,7 @@ const AddressesListItem = ({ isLoading, }: Props) => { - const addressBalance = BigNumber(item.coin_balance).div(BigNumber(10 ** config.chain.currency.decimals)); + const addressBalance = BigNumber(item.coin_balance || 0).div(BigNumber(10 ** config.chain.currency.decimals)); return ( diff --git a/ui/addresses/AddressesTableItem.tsx b/ui/addresses/AddressesTableItem.tsx index f5696f184f..06d849c211 100644 --- a/ui/addresses/AddressesTableItem.tsx +++ b/ui/addresses/AddressesTableItem.tsx @@ -24,7 +24,7 @@ const AddressesTableItem = ({ isLoading, }: Props) => { - const addressBalance = BigNumber(item.coin_balance).div(BigNumber(10 ** config.chain.currency.decimals)); + const addressBalance = BigNumber(item.coin_balance || 0).div(BigNumber(10 ** config.chain.currency.decimals)); const addressBalanceChunks = addressBalance.dp(8).toFormat().split('.'); return ( diff --git a/ui/addressesLabelSearch/AddressesLabelSearchListItem.tsx b/ui/addressesLabelSearch/AddressesLabelSearchListItem.tsx new file mode 100644 index 0000000000..f2145f93c7 --- /dev/null +++ b/ui/addressesLabelSearch/AddressesLabelSearchListItem.tsx @@ -0,0 +1,48 @@ +import { HStack, Skeleton } from '@chakra-ui/react'; +import BigNumber from 'bignumber.js'; +import React from 'react'; + +import type { AddressesItem } from 'types/api/addresses'; + +import config from 'configs/app'; +import { currencyUnits } from 'lib/units'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; + +type Props = { + item: AddressesItem; + isLoading?: boolean; +} + +const AddressesLabelSearchListItem = ({ + item, + isLoading, +}: Props) => { + + const addressBalance = BigNumber(item.coin_balance || 0).div(BigNumber(10 ** config.chain.currency.decimals)); + + return ( + + + + { `Balance ${ currencyUnits.ether }` } + + { addressBalance.dp(8).toFormat() } + + + + Txn count + + { Number(item.tx_count).toLocaleString() } + + + + ); +}; + +export default React.memo(AddressesLabelSearchListItem); diff --git a/ui/addressesLabelSearch/AddressesLabelSearchTable.tsx b/ui/addressesLabelSearch/AddressesLabelSearchTable.tsx new file mode 100644 index 0000000000..8a91ecc05d --- /dev/null +++ b/ui/addressesLabelSearch/AddressesLabelSearchTable.tsx @@ -0,0 +1,40 @@ +import { Table, Tbody, Tr, Th } from '@chakra-ui/react'; +import React from 'react'; + +import type { AddressesItem } from 'types/api/addresses'; + +import { currencyUnits } from 'lib/units'; +import { default as Thead } from 'ui/shared/TheadSticky'; + +import AddressesLabelSearchTableItem from './AddressesLabelSearchTableItem'; + +interface Props { + items: Array; + top: number; + isLoading?: boolean; +} + +const AddressesLabelSearchTable = ({ items, top, isLoading }: Props) => { + return ( + + + + + + + + + + { items.map((item, index) => ( + + )) } + +
Address{ `Balance ${ currencyUnits.ether }` }Txn count
+ ); +}; + +export default AddressesLabelSearchTable; diff --git a/ui/addressesLabelSearch/AddressesLabelSearchTableItem.tsx b/ui/addressesLabelSearch/AddressesLabelSearchTableItem.tsx new file mode 100644 index 0000000000..b768a56783 --- /dev/null +++ b/ui/addressesLabelSearch/AddressesLabelSearchTableItem.tsx @@ -0,0 +1,48 @@ +import { Tr, Td, Text, Skeleton } from '@chakra-ui/react'; +import BigNumber from 'bignumber.js'; +import React from 'react'; + +import type { AddressesItem } from 'types/api/addresses'; + +import config from 'configs/app'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; + +type Props = { + item: AddressesItem; + isLoading?: boolean; +} + +const AddressesLabelSearchTableItem = ({ + item, + isLoading, +}: Props) => { + + const addressBalance = BigNumber(item.coin_balance || 0).div(BigNumber(10 ** config.chain.currency.decimals)); + const addressBalanceChunks = addressBalance.dp(8).toFormat().split('.'); + + return ( + + + + + + + { addressBalanceChunks[0] + (addressBalanceChunks[1] ? '.' : '') } + { addressBalanceChunks[1] } + + + + + { Number(item.tx_count).toLocaleString() } + + + + ); +}; + +export default React.memo(AddressesLabelSearchTableItem); diff --git a/ui/pages/AccountsLabelSearch.pw.tsx b/ui/pages/AccountsLabelSearch.pw.tsx new file mode 100644 index 0000000000..86f637ca86 --- /dev/null +++ b/ui/pages/AccountsLabelSearch.pw.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import type { AddressesMetadataSearchResult } from 'types/api/addresses'; + +import * as addressMocks from 'mocks/address/address'; +import { test, expect } from 'playwright/lib'; + +import AccountsLabelSearch from './AccountsLabelSearch'; + +const addresses: AddressesMetadataSearchResult = { + items: [ + { + ...addressMocks.withName, + tx_count: '1', + coin_balance: '12345678901234567890000', + }, + { + ...addressMocks.token, + tx_count: '109123890123', + coin_balance: '22222345678901234567890000', + ens_domain_name: null, + }, + { + ...addressMocks.withoutName, + tx_count: '11', + coin_balance: '1000000000000000000', + }, + { + ...addressMocks.eoa, + tx_count: '420', + coin_balance: null, + }, + ], + next_page_params: null, +}; + +const hooksConfig = { + router: { + query: { + slug: 'euler-finance-exploit', + tagType: 'generic', + tagName: 'Euler finance exploit', + }, + }, +}; + +test('base view +@mobile', async({ render, mockTextAd, mockApiResponse }) => { + await mockTextAd(); + await mockApiResponse( + 'addresses_metadata_search', + addresses, + { + queryParams: { + slug: 'euler-finance-exploit', + tag_type: 'generic', + }, + }, + ); + const component = await render(, { hooksConfig }); + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/pages/AccountsLabelSearch.tsx b/ui/pages/AccountsLabelSearch.tsx new file mode 100644 index 0000000000..8ed107858e --- /dev/null +++ b/ui/pages/AccountsLabelSearch.tsx @@ -0,0 +1,113 @@ +import { chakra, Flex, Hide, Show, Skeleton } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import type { EntityTag as TEntityTag, EntityTagType } from 'ui/shared/EntityTags/types'; + +import getQueryParamString from 'lib/router/getQueryParamString'; +import { TOP_ADDRESS } from 'stubs/address'; +import { generateListStub } from 'stubs/utils'; +import AddressesLabelSearchListItem from 'ui/addressesLabelSearch/AddressesLabelSearchListItem'; +import AddressesLabelSearchTable from 'ui/addressesLabelSearch/AddressesLabelSearchTable'; +import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; +import DataListDisplay from 'ui/shared/DataListDisplay'; +import EntityTag from 'ui/shared/EntityTags/EntityTag'; +import PageTitle from 'ui/shared/Page/PageTitle'; +import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; +import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText'; + +const AccountsLabelSearch = () => { + + const router = useRouter(); + const slug = getQueryParamString(router.query.slug); + const tagType = getQueryParamString(router.query.tagType); + const tagName = getQueryParamString(router.query.tagName); + + const { isError, isPlaceholderData, data, pagination } = useQueryWithPages({ + resourceName: 'addresses_metadata_search', + filters: { + slug, + tag_type: tagType, + }, + options: { + placeholderData: generateListStub<'addresses_metadata_search'>( + TOP_ADDRESS, + 50, + { + next_page_params: null, + }, + ), + }, + }); + + const content = data?.items ? ( + <> + + + + + { data.items.map((item, index) => { + return ( + + ); + }) } + + + ) : null; + + const text = (() => { + if (isError) { + return null; + } + + const num = data?.items.length || 0; + + const tagData: TEntityTag = { + tagType: tagType as EntityTagType, + slug, + name: tagName || slug, + ordinal: 0, + }; + + return ( + + + Found{ ' ' } + + { num }{ data?.next_page_params || pagination.page > 1 ? '+' : '' } + { ' ' } + matching result{ num > 1 ? 's' : '' } for + + + + ); + })(); + + const actionBar = ; + + return ( + <> + + + + ); +}; + +export default AccountsLabelSearch; diff --git a/ui/pages/__screenshots__/AccountsLabelSearch.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/AccountsLabelSearch.pw.tsx_default_base-view-mobile-1.png new file mode 100644 index 0000000000..77244dac74 Binary files /dev/null and b/ui/pages/__screenshots__/AccountsLabelSearch.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/AccountsLabelSearch.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/AccountsLabelSearch.pw.tsx_mobile_base-view-mobile-1.png new file mode 100644 index 0000000000..6763055ed2 Binary files /dev/null and b/ui/pages/__screenshots__/AccountsLabelSearch.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/shared/EntityTags/EntityTag.tsx b/ui/shared/EntityTags/EntityTag.tsx index db61ec33f4..477acc087d 100644 --- a/ui/shared/EntityTags/EntityTag.tsx +++ b/ui/shared/EntityTags/EntityTag.tsx @@ -15,15 +15,16 @@ interface Props { data: TEntityTag; isLoading?: boolean; maxW?: ResponsiveValue; + noLink?: boolean; } -const EntityTag = ({ data, isLoading, maxW }: Props) => { +const EntityTag = ({ data, isLoading, maxW, noLink }: Props) => { if (isLoading) { return ; } - const hasLink = Boolean(getTagLinkParams(data)); + const hasLink = !noLink && Boolean(getTagLinkParams(data)); const iconColor = data.meta?.textColor ?? 'gray.400'; const name = (() => { @@ -63,7 +64,7 @@ const EntityTag = ({ data, isLoading, maxW }: Props) => { colorScheme={ hasLink ? 'gray-blue' : 'gray' } _hover={ hasLink ? { opacity: 0.76 } : undefined } > - + { icon } diff --git a/ui/shared/EntityTags/EntityTagLink.tsx b/ui/shared/EntityTags/EntityTagLink.tsx index a496eccd14..e061825286 100644 --- a/ui/shared/EntityTags/EntityTagLink.tsx +++ b/ui/shared/EntityTags/EntityTagLink.tsx @@ -11,11 +11,12 @@ import { getTagLinkParams } from './utils'; interface Props { data: EntityTag; children: React.ReactNode; + noLink?: boolean; } -const EntityTagLink = ({ data, children }: Props) => { +const EntityTagLink = ({ data, children, noLink }: Props) => { - const linkParams = getTagLinkParams(data); + const linkParams = !noLink ? getTagLinkParams(data) : undefined; const handleLinkClick = React.useCallback(() => { if (!linkParams?.href) { diff --git a/ui/shared/EntityTags/utils.ts b/ui/shared/EntityTags/utils.ts index 5a8ce88b4b..4454d87872 100644 --- a/ui/shared/EntityTags/utils.ts +++ b/ui/shared/EntityTags/utils.ts @@ -1,6 +1,6 @@ import type { EntityTag } from './types'; -// import { route } from 'nextjs-routes'; +import { route } from 'nextjs-routes'; export function getTagLinkParams(data: EntityTag): { type: 'external' | 'internal'; href: string } | undefined { if (data.meta?.warpcastHandle) { @@ -17,11 +17,10 @@ export function getTagLinkParams(data: EntityTag): { type: 'external' | 'interna }; } - // Uncomment this block when "Tag search" page is ready - issue #1869 -// if (data.tagType === 'generic' || data.tagType === 'protocol') { -// return { -// type: 'internal', -// href: route({ pathname: '/' }), -// }; -// } + if (data.tagType === 'generic' || data.tagType === 'protocol') { + return { + type: 'internal', + href: route({ pathname: '/accounts/label/[slug]', query: { slug: data.slug, tagType: data.tagType, tagName: data.name } }), + }; + } }