diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 26686f287c..e0c3ceb008 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -52,6 +52,7 @@ import type { EnsDomainEventsResponse, EnsDomainLookupFilters, EnsDomainLookupResponse, + EnsDomainProtocolsResponse, EnsLookupSorting, } from 'types/api/ens'; import type { IndexingStatus } from 'types/api/indexingStatus'; @@ -219,7 +220,7 @@ export const RESOURCES = { pathParams: [ 'chainId' as const ], endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint, basePath: getFeaturePayload(config.features.nameService)?.api.basePath, - filterFields: [ 'address' as const, 'resolved_to' as const, 'owned_by' as const, 'only_active' as const ], + filterFields: [ 'address' as const, 'resolved_to' as const, 'owned_by' as const, 'only_active' as const, 'protocols' as const ], }, domain_info: { path: '/api/v1/:chainId/domains/:name', @@ -238,7 +239,13 @@ export const RESOURCES = { pathParams: [ 'chainId' as const ], endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint, basePath: getFeaturePayload(config.features.nameService)?.api.basePath, - filterFields: [ 'name' as const, 'only_active' as const ], + filterFields: [ 'name' as const, 'only_active' as const, 'protocols' as const ], + }, + domain_protocols: { + path: '/api/v1/:chainId/protocols', + pathParams: [ 'chainId' as const ], + endpoint: getFeaturePayload(config.features.nameService)?.api.endpoint, + basePath: getFeaturePayload(config.features.nameService)?.api.basePath, }, // METADATA SERVICE & PUBLIC TAGS @@ -1008,6 +1015,7 @@ Q extends 'addresses_lookup' ? EnsAddressLookupResponse : Q extends 'domain_info' ? EnsDomainDetailed : Q extends 'domain_events' ? EnsDomainEventsResponse : Q extends 'domains_lookup' ? EnsDomainLookupResponse : +Q extends 'domain_protocols' ? EnsDomainProtocolsResponse : Q extends 'user_ops' ? UserOpsResponse : Q extends 'user_op' ? UserOp : Q extends 'user_ops_account' ? UserOpsAccount : diff --git a/mocks/ens/domain.ts b/mocks/ens/domain.ts index 3126aaecf5..828a61d546 100644 --- a/mocks/ens/domain.ts +++ b/mocks/ens/domain.ts @@ -1,4 +1,4 @@ -import type { EnsDomainDetailed } from 'types/api/ens'; +import type { EnsDomainDetailed, EnsDomainProtocol } from 'types/api/ens'; const domainTokenA = { id: '97352314626701792030827861137068748433918254309635329404916858191911576754327', @@ -11,6 +11,34 @@ const domainTokenB = { type: 'WRAPPED_DOMAIN_TOKEN' as const, }; +export const protocolA: EnsDomainProtocol = { + id: 'ens', + short_name: 'ENS', + title: 'Ethereum Name Service', + description: 'The Ethereum Name Service (ENS) is a distributed, open, and extensible naming system based on the Ethereum blockchain.', + tld_list: [ + 'eth', + 'xyz', + ], + icon_url: 'https://i.imgur.com/GOfUwCb.jpeg', + docs_url: 'https://docs.ens.domains/', + deployment_blockscout_base_url: 'http://localhost:3200/', +}; + +export const protocolB: EnsDomainProtocol = { + id: 'duck', + short_name: 'DUCK', + title: 'Duck Name Service', + description: '"Duck Name Service" is a cutting-edge blockchain naming service, providing seamless naming for crypto and decentralized applications. 🦆', + tld_list: [ + 'duck', + 'quack', + ], + icon_url: 'https://localhost:3000/duck.jpg', + docs_url: 'https://docs.duck.domains/', + deployment_blockscout_base_url: '', +}; + export const ensDomainA: EnsDomainDetailed = { id: '0xb140bf9645e54f02ed3c1bcc225566b515a98d1688f10494a5c3bc5b447936a7', tokens: [ @@ -35,6 +63,7 @@ export const ensDomainA: EnsDomainDetailed = { GNO: 'DDAfbb505ad214D7b80b1f830fcCc89B60fb7A83', NEAR: 'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.factory.bridge.near', }, + protocol: protocolA, }; export const ensDomainB: EnsDomainDetailed = { @@ -52,6 +81,7 @@ export const ensDomainB: EnsDomainDetailed = { registration_date: '2023-08-13T13:01:12.000Z', expiry_date: null, other_addresses: {}, + protocol: null, }; export const ensDomainC: EnsDomainDetailed = { @@ -71,6 +101,7 @@ export const ensDomainC: EnsDomainDetailed = { registration_date: '2022-04-24T07:34:44.000Z', expiry_date: '2022-11-01T13:10:36.000Z', other_addresses: {}, + protocol: null, }; export const ensDomainD: EnsDomainDetailed = { @@ -88,4 +119,5 @@ export const ensDomainD: EnsDomainDetailed = { registration_date: '2022-04-24T07:34:44.000Z', expiry_date: '2027-09-23T13:10:36.000Z', other_addresses: {}, + protocol: null, }; diff --git a/stubs/ENS.ts b/stubs/ENS.ts index 4fbd654adf..e02eb8e7ef 100644 --- a/stubs/ENS.ts +++ b/stubs/ENS.ts @@ -22,6 +22,7 @@ export const ENS_DOMAIN: EnsDomainDetailed = { other_addresses: { ETH: ADDRESS_HASH, }, + protocol: null, }; export const ENS_DOMAIN_EVENT: EnsDomainEvent = { diff --git a/types/api/ens.ts b/types/api/ens.ts index 6ee12f846b..7c7c8eeae3 100644 --- a/types/api/ens.ts +++ b/types/api/ens.ts @@ -12,6 +12,18 @@ export interface EnsDomain { } | null; registration_date?: string; expiry_date: string | null; + protocol: EnsDomainProtocol | null; +} + +export interface EnsDomainProtocol { + title: string; + description: string; + deployment_blockscout_base_url: string; + docs_url?: string; + icon_url?: string; + id: string; + short_name: string; + tld_list: Array; } export interface EnsDomainDetailed extends EnsDomain { @@ -43,6 +55,10 @@ export interface EnsDomainEventsResponse { items: Array; } +export interface EnsDomainProtocolsResponse { + items: Array; +} + export interface EnsDomainLookupResponse { items: Array; next_page_params: { @@ -56,11 +72,13 @@ export interface EnsAddressLookupFilters { resolved_to: boolean; owned_by: boolean; only_active: boolean; + protocols: Array | undefined; } export interface EnsDomainLookupFilters { name: string | null; only_active: boolean; + protocols: Array | undefined; } export interface EnsLookupSorting { diff --git a/ui/address/ensDomains/AddressEnsDomains.pw.tsx b/ui/address/ensDomains/AddressEnsDomains.pw.tsx index 2836b0ee67..89eaf3aff3 100644 --- a/ui/address/ensDomains/AddressEnsDomains.pw.tsx +++ b/ui/address/ensDomains/AddressEnsDomains.pw.tsx @@ -1,6 +1,9 @@ +import type { UseQueryResult } from '@tanstack/react-query'; import React from 'react'; -import config from 'configs/app'; +import type { EnsAddressLookupResponse } from 'types/api/ens'; + +import type { ResourceError } from 'lib/api/resources'; import * as ensDomainMock from 'mocks/ens/domain'; import { test, expect } from 'playwright/lib'; @@ -8,10 +11,9 @@ import AddressEnsDomains from './AddressEnsDomains'; const ADDRESS_HASH = ensDomainMock.ensDomainA.owner?.hash as string; -test('base view', async({ render, mockApiResponse, page }) => { - await mockApiResponse( - 'addresses_lookup', - { +test('base view', async({ render, page, mockAssetResponse }) => { + const query = { + data: { items: [ ensDomainMock.ensDomainA, ensDomainMock.ensDomainB, @@ -20,18 +22,18 @@ test('base view', async({ render, mockApiResponse, page }) => { ], next_page_params: null, }, - { - pathParams: { chainId: config.chain.id }, - queryParams: { - address: ADDRESS_HASH, - resolved_to: true, - owned_by: true, - only_active: true, - order: 'ASC', - }, - }, + isPending: false, + isError: false, + } as unknown as UseQueryResult>; + await mockAssetResponse(ensDomainMock.ensDomainA.protocol?.icon_url as string, './playwright/mocks/image_s.jpg'); + + const component = await render( + , ); - const component = await render(); await component.getByText('4').click(); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 550, height: 350 } }); }); diff --git a/ui/address/ensDomains/AddressEnsDomains.tsx b/ui/address/ensDomains/AddressEnsDomains.tsx index f84626809d..bea1029868 100644 --- a/ui/address/ensDomains/AddressEnsDomains.tsx +++ b/ui/address/ensDomains/AddressEnsDomains.tsx @@ -13,15 +13,15 @@ import { useDisclosure, chakra, } from '@chakra-ui/react'; +import type { UseQueryResult } from '@tanstack/react-query'; import _clamp from 'lodash/clamp'; import React from 'react'; -import type { EnsDomain } from 'types/api/ens'; +import type { EnsAddressLookupResponse, EnsDomain } from 'types/api/ens'; import { route } from 'nextjs-routes'; -import config from 'configs/app'; -import useApiQuery from 'lib/api/useApiQuery'; +import type { ResourceError } from 'lib/api/resources'; import dayjs from 'lib/date/dayjs'; import EnsEntity from 'ui/shared/entities/ens/EnsEntity'; import IconSvg from 'ui/shared/IconSvg'; @@ -29,6 +29,7 @@ import LinkInternal from 'ui/shared/links/LinkInternal'; import PopoverTriggerTooltip from 'ui/shared/PopoverTriggerTooltip'; interface Props { + query: UseQueryResult>; addressHash: string; mainDomainName: string | null; } @@ -41,24 +42,15 @@ const DomainsGrid = ({ data }: { data: Array }) => { rowGap={ 4 } mt={ 2 } > - { data.slice(0, 9).map((domain) => ) } + { data.slice(0, 9).map((domain) => ) } ); }; -const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => { +const AddressEnsDomains = ({ query, addressHash, mainDomainName }: Props) => { const { isOpen, onToggle, onClose } = useDisclosure(); - const { data, isPending, isError } = useApiQuery('addresses_lookup', { - pathParams: { chainId: config.chain.id }, - queryParams: { - address: addressHash, - resolved_to: true, - owned_by: true, - only_active: true, - order: 'ASC', - }, - }); + const { data, isPending, isError } = query; if (isError) { return null; @@ -134,7 +126,7 @@ const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => { Primary* - + { mainDomain.expiry_date && (expires { dayjs(mainDomain.expiry_date).fromNow() }) } diff --git a/ui/address/ensDomains/__screenshots__/AddressEnsDomains.pw.tsx_default_base-view-1.png b/ui/address/ensDomains/__screenshots__/AddressEnsDomains.pw.tsx_default_base-view-1.png index 7f5315bbb9..6d67d4be0c 100644 Binary files a/ui/address/ensDomains/__screenshots__/AddressEnsDomains.pw.tsx_default_base-view-1.png and b/ui/address/ensDomains/__screenshots__/AddressEnsDomains.pw.tsx_default_base-view-1.png differ diff --git a/ui/nameDomain/NameDomainDetails.tsx b/ui/nameDomain/NameDomainDetails.tsx index 09a8d403dc..dfdd0dd607 100644 --- a/ui/nameDomain/NameDomainDetails.tsx +++ b/ui/nameDomain/NameDomainDetails.tsx @@ -6,8 +6,10 @@ import type { EnsDomainDetailed } from 'types/api/ens'; import { route } from 'nextjs-routes'; +import config from 'configs/app'; import type { ResourceError } from 'lib/api/resources'; import dayjs from 'lib/date/dayjs'; +import stripTrailingSlash from 'lib/stripTrailingSlash'; import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import NftEntity from 'ui/shared/entities/nft/NftEntity'; @@ -163,22 +165,33 @@ const NameDomainDetails = ({ query }: Props) => { ) } - { query.data?.tokens.map((token) => ( - - - { token.type === 'WRAPPED_DOMAIN_TOKEN' ? 'Wrapped token ID' : 'Token ID' } - - - - - - )) } + { query.data?.tokens.map((token) => { + const isProtocolBaseChain = stripTrailingSlash(query.data.protocol?.deployment_blockscout_base_url ?? '') === config.app.baseUrl; + const entityProps = { + isExternal: !isProtocolBaseChain ? true : false, + href: !isProtocolBaseChain ? ( + stripTrailingSlash(query.data.protocol?.deployment_blockscout_base_url ?? '') + + route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.contract_hash, id: token.id } }) + ) : undefined, + }; + + return ( + + + { token.type === 'WRAPPED_DOMAIN_TOKEN' ? 'Wrapped token ID' : 'Token ID' } + + + + + + ); + }) } { otherAddresses.length > 0 && ( <> diff --git a/ui/nameDomain/NameDomainHistory.tsx b/ui/nameDomain/NameDomainHistory.tsx index ba5747ad46..8147efcf4a 100644 --- a/ui/nameDomain/NameDomainHistory.tsx +++ b/ui/nameDomain/NameDomainHistory.tsx @@ -2,6 +2,8 @@ import { Box, Hide, Show } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import React from 'react'; +import type { EnsDomainDetailed } from 'types/api/ens'; + import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; import getQueryParamString from 'lib/router/getQueryParamString'; @@ -12,7 +14,11 @@ import NameDomainHistoryListItem from './history/NameDomainHistoryListItem'; import NameDomainHistoryTable from './history/NameDomainHistoryTable'; import { getNextSortValue, type Sort, type SortField } from './history/utils'; -const NameDomainHistory = () => { +interface Props { + domain: EnsDomainDetailed | undefined; +} + +const NameDomainHistory = ({ domain }: Props) => { const router = useRouter(); const domainName = getQueryParamString(router.query.name); @@ -40,12 +46,20 @@ const NameDomainHistory = () => { <> - { data?.items.map((item, index) => ) } + { data?.items.map((item, index) => ( + + )) } { +const NameDomainHistoryListItem = ({ isLoading, domain, event }: Props) => { + const isProtocolBaseChain = stripTrailingSlash(domain?.protocol?.deployment_blockscout_base_url ?? '') === config.app.baseUrl; + const txEntityProps = { + isExternal: !isProtocolBaseChain ? true : false, + href: !isProtocolBaseChain ? ( + stripTrailingSlash(domain?.protocol?.deployment_blockscout_base_url ?? '') + + route({ pathname: '/tx/[hash]', query: { hash: event.transaction_hash } }) + ) : undefined, + }; + return ( Txn hash - + Age - { dayjs(timestamp).fromNow() } + { dayjs(event.timestamp).fromNow() } - { fromAddress && ( + { event.from_address && ( <> From - + ) } - { action && ( + { event.action && ( <> Method - { action } + { event.action } ) } diff --git a/ui/nameDomain/history/NameDomainHistoryTable.tsx b/ui/nameDomain/history/NameDomainHistoryTable.tsx index f6560372eb..a73bf3333a 100644 --- a/ui/nameDomain/history/NameDomainHistoryTable.tsx +++ b/ui/nameDomain/history/NameDomainHistoryTable.tsx @@ -1,7 +1,7 @@ import { Table, Tbody, Tr, Th, Link } from '@chakra-ui/react'; import React from 'react'; -import type { EnsDomainEventsResponse } from 'types/api/ens'; +import type { EnsDomainDetailed, EnsDomainEventsResponse } from 'types/api/ens'; import IconSvg from 'ui/shared/IconSvg'; import { default as Thead } from 'ui/shared/TheadSticky'; @@ -11,13 +11,14 @@ import type { Sort } from './utils'; import { sortFn } from './utils'; interface Props { - data: EnsDomainEventsResponse | undefined; + history: EnsDomainEventsResponse | undefined; + domain: EnsDomainDetailed | undefined; isLoading?: boolean; sort: Sort | undefined; onSortToggle: (event: React.MouseEvent) => void; } -const NameDomainHistoryTable = ({ data, isLoading, sort, onSortToggle }: Props) => { +const NameDomainHistoryTable = ({ history, domain, isLoading, sort, onSortToggle }: Props) => { const sortIconTransform = sort?.includes('asc') ? 'rotate(-90deg)' : 'rotate(90deg)'; return ( @@ -47,10 +48,10 @@ const NameDomainHistoryTable = ({ data, isLoading, sort, onSortToggle }: Props) { - data?.items + history?.items .slice() .sort(sortFn(sort)) - .map((item, index) => ) + .map((item, index) => ) } diff --git a/ui/nameDomain/history/NameDomainHistoryTableItem.tsx b/ui/nameDomain/history/NameDomainHistoryTableItem.tsx index ff28b0ffe3..34504c7911 100644 --- a/ui/nameDomain/history/NameDomainHistoryTableItem.tsx +++ b/ui/nameDomain/history/NameDomainHistoryTableItem.tsx @@ -1,24 +1,39 @@ import { Tr, Td, Skeleton } from '@chakra-ui/react'; import React from 'react'; -import type { EnsDomainEvent } from 'types/api/ens'; +import type { EnsDomainDetailed, EnsDomainEvent } from 'types/api/ens'; +import { route } from 'nextjs-routes'; + +import config from 'configs/app'; import dayjs from 'lib/date/dayjs'; +import stripTrailingSlash from 'lib/stripTrailingSlash'; import Tag from 'ui/shared/chakra/Tag'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; -type Props = EnsDomainEvent & { +interface Props { + event: EnsDomainEvent; + domain: EnsDomainDetailed | undefined; isLoading?: boolean; } -const NameDomainHistoryTableItem = ({ isLoading, transaction_hash: transactionHash, from_address: fromAddress, action, timestamp }: Props) => { +const NameDomainHistoryTableItem = ({ isLoading, event, domain }: Props) => { + const isProtocolBaseChain = stripTrailingSlash(domain?.protocol?.deployment_blockscout_base_url ?? '') === config.app.baseUrl; + const txEntityProps = { + isExternal: !isProtocolBaseChain ? true : false, + href: !isProtocolBaseChain ? ( + stripTrailingSlash(domain?.protocol?.deployment_blockscout_base_url ?? '') + + route({ pathname: '/tx/[hash]', query: { hash: event.transaction_hash } }) + ) : undefined, + }; return ( - { dayjs(timestamp).fromNow() } + { dayjs(event.timestamp).fromNow() } - { fromAddress && } + { event.from_address && } - { action && { action } } + { event.action && { event.action } } ); diff --git a/ui/nameDomains/NameDomainsActionBar.tsx b/ui/nameDomains/NameDomainsActionBar.tsx index ff57107799..cba88c79bd 100644 --- a/ui/nameDomains/NameDomainsActionBar.tsx +++ b/ui/nameDomains/NameDomainsActionBar.tsx @@ -1,13 +1,14 @@ -import { Checkbox, CheckboxGroup, HStack, Text } from '@chakra-ui/react'; +import { Box, Checkbox, CheckboxGroup, Flex, HStack, Image, Link, Text, VStack, chakra } from '@chakra-ui/react'; import React from 'react'; -import type { EnsDomainLookupFiltersOptions } from 'types/api/ens'; +import type { EnsDomainLookupFiltersOptions, EnsDomainProtocol } from 'types/api/ens'; import type { PaginationParams } from 'ui/shared/pagination/types'; import useIsInitialLoading from 'lib/hooks/useIsInitialLoading'; import ActionBar from 'ui/shared/ActionBar'; import FilterInput from 'ui/shared/filters/FilterInput'; import PopoverFilter from 'ui/shared/filters/PopoverFilter'; +import IconSvg from 'ui/shared/IconSvg'; import Pagination from 'ui/shared/pagination/Pagination'; import Sort from 'ui/shared/sort/Sort'; @@ -20,6 +21,9 @@ interface Props { onSearchChange: (value: string) => void; filterValue: EnsDomainLookupFiltersOptions; onFilterValueChange: (nextValue: EnsDomainLookupFiltersOptions) => void; + protocolsData: Array | undefined; + protocolsFilterValue: Array; + onProtocolsFilterChange: (nextValue: Array) => void; sort: TSort | undefined; onSortChange: (nextValue: TSort | undefined) => void; isLoading: boolean; @@ -36,6 +40,9 @@ const NameDomainsActionBar = ({ isLoading, isAddressSearch, pagination, + protocolsData, + protocolsFilterValue, + onProtocolsFilterChange, }: Props) => { const isInitialLoading = useIsInitialLoading(isLoading); @@ -51,26 +58,72 @@ const NameDomainsActionBar = ({ /> ); + const handleProtocolReset = React.useCallback(() => { + onProtocolsFilterChange([]); + }, [ onProtocolsFilterChange ]); + + const filterGroupDivider = ; + + const appliedFiltersNum = filterValue.length + (protocolsData && protocolsData.length > 1 ? protocolsFilterValue.length : 0); + const filter = ( - +
+ { protocolsData && protocolsData.length > 1 && ( + <> + + Protocol + 0 ? 'link' : 'text_secondary' } + _hover={{ + color: protocolsData.length > 0 ? 'link_hovered' : 'text_secondary', + }} + > + Reset + + + + + { protocolsData.map((protocol) => { + const topLevelDomains = protocol.tld_list.map((domain) => `.${ domain }`).join(' '); + return ( + + + { } + fallbackStrategy={ protocol.icon_url ? 'onError' : 'beforeLoadOrError' } + /> + { protocol.short_name } + { topLevelDomains } + + + ); + }) } + + + { filterGroupDivider } + + ) } Address - + Owned by Resolved to address + { filterGroupDivider } Status Include expired diff --git a/ui/nameDomains/NameDomainsListItem.tsx b/ui/nameDomains/NameDomainsListItem.tsx index ac7275d9cc..db46ea6805 100644 --- a/ui/nameDomains/NameDomainsListItem.tsx +++ b/ui/nameDomains/NameDomainsListItem.tsx @@ -13,12 +13,19 @@ interface Props extends EnsDomain { isLoading: boolean; } -const NameDomainsListItem = ({ name, isLoading, resolved_address: resolvedAddress, registration_date: registrationDate, expiry_date: expiryDate }: Props) => { +const NameDomainsListItem = ({ + name, + isLoading, + resolved_address: resolvedAddress, + registration_date: registrationDate, + expiry_date: expiryDate, + protocol, +}: Props) => { return ( Domain - + { resolvedAddress && ( diff --git a/ui/nameDomains/NameDomainsTableItem.tsx b/ui/nameDomains/NameDomainsTableItem.tsx index a4147ae8cf..51de259e66 100644 --- a/ui/nameDomains/NameDomainsTableItem.tsx +++ b/ui/nameDomains/NameDomainsTableItem.tsx @@ -12,12 +12,19 @@ type Props = EnsDomain & { isLoading?: boolean; } -const NameDomainsTableItem = ({ isLoading, name, resolved_address: resolvedAddress, registration_date: registrationDate, expiry_date: expiryDate }: Props) => { +const NameDomainsTableItem = ({ + isLoading, + name, + resolved_address: resolvedAddress, + registration_date: registrationDate, + expiry_date: expiryDate, + protocol, +}: Props) => { return ( - + { resolvedAddress && } diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index b3a00033d4..64fcbb5a4f 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -78,6 +78,23 @@ const AddressPageContent = () => { const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]); const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery); + const addressEnsDomainsQuery = useApiQuery('addresses_lookup', { + pathParams: { chainId: config.chain.id }, + queryParams: { + address: hash, + resolved_to: true, + owned_by: true, + only_active: true, + order: 'ASC', + }, + queryOptions: { + enabled: Boolean(hash) && config.features.nameService.isEnabled, + }, + }); + const addressMainDomain = !addressQuery.isPlaceholderData ? + addressEnsDomainsQuery.data?.items.find((domain) => domain.name === addressQuery.data?.ens_domain_name) : + undefined; + const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData); const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData; @@ -238,6 +255,7 @@ const AddressPageContent = () => { { addressQuery.data?.ens_domain_name && ( { { !isLoading && addressQuery.data?.is_contract && addressQuery.data?.is_verified && config.UI.views.address.solidityscanEnabled && } { !isLoading && addressQuery.data && config.features.nameService.isEnabled && - } + } ); diff --git a/ui/pages/NameDomain.tsx b/ui/pages/NameDomain.tsx index 25a6aad07e..c1192b0312 100644 --- a/ui/pages/NameDomain.tsx +++ b/ui/pages/NameDomain.tsx @@ -36,7 +36,7 @@ const NameDomain = () => { const tabs: Array = [ { id: 'details', title: 'Details', component: }, - { id: 'history', title: 'History', component: }, + { id: 'history', title: 'History', component: }, ]; const tabIndex = useTabIndexFromQuery(tabs); @@ -58,6 +58,7 @@ const NameDomain = () => { > { +test.beforeEach(async({ mockApiResponse, mockAssetResponse, mockTextAd }) => { await mockTextAd(); + await mockAssetResponse(ensDomainMock.protocolA.icon_url as string, './playwright/mocks/image_s.jpg'); + await mockAssetResponse(ensDomainMock.protocolB.icon_url as string, './playwright/mocks/image_md.jpg'); await mockApiResponse('domains_lookup', { items: [ ensDomainMock.ensDomainA, @@ -23,7 +25,21 @@ test('default view +@mobile', async({ render, mockApiResponse, mockTextAd }) => pathParams: { chainId: config.chain.id }, queryParams: { only_active: true }, }); + await mockApiResponse('domain_protocols', { + items: [ ensDomainMock.protocolA, ensDomainMock.protocolB ], + }, { + pathParams: { chainId: config.chain.id }, + }); +}); +test('default view +@mobile', async({ render }) => { const component = await render(); await expect(component).toHaveScreenshot(); }); + +test('filters', async({ render, page }) => { + const component = await render(); + + await component.getByRole('button', { name: 'Filter' }).click(); + await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 500 } }); +}); diff --git a/ui/pages/NameDomains.tsx b/ui/pages/NameDomains.tsx index 30a5c4fdb3..3167f4f3d0 100644 --- a/ui/pages/NameDomains.tsx +++ b/ui/pages/NameDomains.tsx @@ -5,6 +5,7 @@ import React from 'react'; import type { EnsDomainLookupFiltersOptions, EnsLookupSorting } from 'types/api/ens'; import config from 'configs/app'; +import useApiQuery from 'lib/api/useApiQuery'; import useDebounce from 'lib/hooks/useDebounce'; import { apos } from 'lib/html-entities'; import getQueryParamString from 'lib/router/getQueryParamString'; @@ -29,6 +30,7 @@ const NameDomains = () => { const ownedBy = getQueryParamString(router.query.owned_by); const resolvedTo = getQueryParamString(router.query.resolved_to); const onlyActive = getQueryParamString(router.query.only_active); + const protocols = Array.isArray(router.query.protocols) ? router.query.protocols : (router.query.protocols ?? '').split(',').filter(Boolean); const initialFilters: EnsDomainLookupFiltersOptions = [ ownedBy === 'true' ? 'owned_by' as const : undefined, @@ -40,6 +42,7 @@ const NameDomains = () => { const [ searchTerm, setSearchTerm ] = React.useState(q || ''); const [ filterValue, setFilterValue ] = React.useState(initialFilters); const [ sort, setSort ] = React.useState(initialSort); + const [ protocolsFilter, setProtocolsFilter ] = React.useState>(protocols); const debouncedSearchTerm = useDebounce(searchTerm, 300); const isAddressSearch = React.useMemo(() => ADDRESS_REGEXP.test(debouncedSearchTerm), [ debouncedSearchTerm ]); @@ -53,6 +56,7 @@ const NameDomains = () => { resolved_to: filterValue.includes('resolved_to'), owned_by: filterValue.includes('owned_by'), only_active: !filterValue.includes('with_inactive'), + protocols: protocolsFilter.length > 0 ? protocolsFilter : undefined, }, sorting: sortParams, options: { @@ -67,6 +71,7 @@ const NameDomains = () => { filters: { name: debouncedSearchTerm, only_active: !filterValue.includes('with_inactive'), + protocols: protocolsFilter.length > 0 ? protocolsFilter : undefined, }, sorting: sortParams, options: { @@ -75,6 +80,10 @@ const NameDomains = () => { }, }); + const protocolsQuery = useApiQuery('domain_protocols', { + pathParams: { chainId: config.chain.id }, + }); + const query = isAddressSearch ? addressesLookupQuery : domainsLookupQuery; const { data, isError, isPlaceholderData: isLoading, onFilterChange, onSortingChange } = query; @@ -87,12 +96,14 @@ const NameDomains = () => { resolved_to: true, owned_by: true, only_active: !hasInactiveFilter, + protocols: protocolsFilter, }); } else { setFilterValue([ hasInactiveFilter ? 'with_inactive' as const : undefined ].filter(Boolean)); onFilterChange<'domains_lookup'>({ name: debouncedSearchTerm, only_active: !hasInactiveFilter, + protocols: protocolsFilter, }); } // should run only the type of search changes @@ -123,14 +134,16 @@ const NameDomains = () => { resolved_to: filterValue.includes('resolved_to'), owned_by: filterValue.includes('owned_by'), only_active: !filterValue.includes('with_inactive'), + protocols: protocolsFilter, }); } else { onFilterChange<'domains_lookup'>({ name: value, only_active: !filterValue.includes('with_inactive'), + protocols: protocolsFilter, }); } - }, [ onFilterChange, filterValue ]); + }, [ onFilterChange, filterValue, protocolsFilter ]); const handleFilterValueChange = React.useCallback((value: EnsDomainLookupFiltersOptions) => { setFilterValue(value); @@ -142,16 +155,40 @@ const NameDomains = () => { resolved_to: value.includes('resolved_to'), owned_by: value.includes('owned_by'), only_active: !value.includes('with_inactive'), + protocols: protocolsFilter, }); } else { onFilterChange<'domains_lookup'>({ name: debouncedSearchTerm, only_active: !value.includes('with_inactive'), + protocols: protocolsFilter, + }); + } + }, [ debouncedSearchTerm, onFilterChange, protocolsFilter ]); + + const handleProtocolsFilterChange = React.useCallback((nextValue: Array) => { + setProtocolsFilter(nextValue); + + const isAddressSearch = ADDRESS_REGEXP.test(debouncedSearchTerm); + if (isAddressSearch) { + onFilterChange<'addresses_lookup'>({ + address: debouncedSearchTerm, + resolved_to: filterValue.includes('resolved_to'), + owned_by: filterValue.includes('owned_by'), + only_active: !filterValue.includes('with_inactive'), + protocols: nextValue, + }); + } else { + onFilterChange<'domains_lookup'>({ + name: debouncedSearchTerm, + only_active: !filterValue.includes('with_inactive'), + protocols: nextValue, }); } - }, [ debouncedSearchTerm, onFilterChange ]); + }, [ debouncedSearchTerm, filterValue, onFilterChange ]); - const hasActiveFilters = Boolean(debouncedSearchTerm) || filterValue.length > 0; + const hasActiveFilters = Boolean(debouncedSearchTerm) || filterValue.length > 0 || + (protocolsQuery.data && protocolsQuery.data.items.length > 1 ? protocolsFilter.length > 0 : false); const content = ( <> @@ -184,6 +221,9 @@ const NameDomains = () => { onSearchChange={ handleSearchTermChange } filterValue={ filterValue } onFilterValueChange={ handleFilterValueChange } + protocolsData={ protocolsQuery.data?.items } + protocolsFilterValue={ protocolsFilter } + onProtocolsFilterChange={ handleProtocolsFilterChange } sort={ sort } onSortChange={ setSort } isAddressSearch={ isAddressSearch } diff --git a/ui/pages/__screenshots__/NameDomain.pw.tsx_default_details-tab-1.png b/ui/pages/__screenshots__/NameDomain.pw.tsx_default_details-tab-1.png index a4d480e1dc..db086b2239 100644 Binary files a/ui/pages/__screenshots__/NameDomain.pw.tsx_default_details-tab-1.png and b/ui/pages/__screenshots__/NameDomain.pw.tsx_default_details-tab-1.png differ diff --git a/ui/pages/__screenshots__/NameDomain.pw.tsx_default_history-tab-mobile-1.png b/ui/pages/__screenshots__/NameDomain.pw.tsx_default_history-tab-mobile-1.png index 9acf3006f6..1be771d769 100644 Binary files a/ui/pages/__screenshots__/NameDomain.pw.tsx_default_history-tab-mobile-1.png and b/ui/pages/__screenshots__/NameDomain.pw.tsx_default_history-tab-mobile-1.png differ diff --git a/ui/pages/__screenshots__/NameDomain.pw.tsx_mobile_history-tab-mobile-1.png b/ui/pages/__screenshots__/NameDomain.pw.tsx_mobile_history-tab-mobile-1.png index d37225d13f..767f0cdadd 100644 Binary files a/ui/pages/__screenshots__/NameDomain.pw.tsx_mobile_history-tab-mobile-1.png and b/ui/pages/__screenshots__/NameDomain.pw.tsx_mobile_history-tab-mobile-1.png differ diff --git a/ui/pages/__screenshots__/NameDomains.pw.tsx_default_default-view-mobile-1.png b/ui/pages/__screenshots__/NameDomains.pw.tsx_default_default-view-mobile-1.png index f9edb3652a..49dedc7bc7 100644 Binary files a/ui/pages/__screenshots__/NameDomains.pw.tsx_default_default-view-mobile-1.png and b/ui/pages/__screenshots__/NameDomains.pw.tsx_default_default-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/NameDomains.pw.tsx_default_filters-1.png b/ui/pages/__screenshots__/NameDomains.pw.tsx_default_filters-1.png new file mode 100644 index 0000000000..4f4408f614 Binary files /dev/null and b/ui/pages/__screenshots__/NameDomains.pw.tsx_default_filters-1.png differ diff --git a/ui/pages/__screenshots__/NameDomains.pw.tsx_mobile_default-view-mobile-1.png b/ui/pages/__screenshots__/NameDomains.pw.tsx_mobile_default-view-mobile-1.png index 414b2a8021..2af4ecf5ae 100644 Binary files a/ui/pages/__screenshots__/NameDomains.pw.tsx_mobile_default-view-mobile-1.png and b/ui/pages/__screenshots__/NameDomains.pw.tsx_mobile_default-view-mobile-1.png differ diff --git a/ui/shared/entities/ens/EnsEntity.pw.tsx b/ui/shared/entities/ens/EnsEntity.pw.tsx index 3ce477e19c..bdc3ddffc6 100644 --- a/ui/shared/entities/ens/EnsEntity.pw.tsx +++ b/ui/shared/entities/ens/EnsEntity.pw.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import * as domainMock from 'mocks/ens/domain'; import { test, expect } from 'playwright/lib'; import EnsEntity from './EnsEntity'; @@ -59,3 +60,22 @@ test('customization', async({ render }) => { await expect(component).toHaveScreenshot(); }); + +test.describe('', () => { + test.use({ viewport: { width: 300, height: 400 } }); + test('with protocol info', async({ render, page, mockAssetResponse }) => { + await mockAssetResponse(domainMock.ensDomainA.protocol?.icon_url as string, './playwright/mocks/image_s.jpg'); + + const component = await render( + , + ); + + await component.getByAltText(`${ domainMock.protocolA.title } protocol icon`).first().hover(); + + await expect(page.getByText(domainMock.protocolA.description)).toBeVisible(); + await expect(page).toHaveScreenshot(); + }); +}); diff --git a/ui/shared/entities/ens/EnsEntity.tsx b/ui/shared/entities/ens/EnsEntity.tsx index c0623426c1..21371ba2c0 100644 --- a/ui/shared/entities/ens/EnsEntity.tsx +++ b/ui/shared/entities/ens/EnsEntity.tsx @@ -1,12 +1,18 @@ -import { chakra } from '@chakra-ui/react'; +import { chakra, Flex, Image, Popover, PopoverBody, PopoverContent, PopoverTrigger, Portal, Skeleton, Text } from '@chakra-ui/react'; import _omit from 'lodash/omit'; import React from 'react'; +import type { EnsDomainProtocol } from 'types/api/ens'; + import { route } from 'nextjs-routes'; import * as EntityBase from 'ui/shared/entities/base/components'; +import IconSvg from 'ui/shared/IconSvg'; +import LinkExternal from 'ui/shared/links/LinkExternal'; import TruncatedValue from 'ui/shared/TruncatedValue'; +import { getIconProps } from '../base/utils'; + type LinkProps = EntityBase.LinkBaseProps & Pick; const Link = chakra((props: LinkProps) => { @@ -22,17 +28,73 @@ const Link = chakra((props: LinkProps) => { ); }); -type IconProps = Omit & { +type IconProps = Omit & Pick & { iconName?: EntityBase.IconBaseProps['name']; }; const Icon = (props: IconProps) => { - return ( - - ); + const icon = ; + + if (props.protocol) { + const styles = getIconProps(props.iconSize); + + if (props.isLoading) { + return ; + } + + return ( + + +
+ { +
+
+ + + + + { +
+ { props.protocol.short_name } + { props.protocol.tld_list.map((tld) => `.${ tld }`).join((' ')) } +
+
+ { props.protocol.description } + { props.protocol.docs_url && ( + + + Documentation + + ) } +
+
+
+
+ ); + } + + return icon; }; type ContentProps = Omit & Pick; @@ -61,6 +123,7 @@ const Container = EntityBase.Container; export interface EntityProps extends EntityBase.EntityBaseProps { name: string; + protocol?: EnsDomainProtocol | null; } const EnsEntity = (props: EntityProps) => { diff --git a/ui/shared/entities/ens/__screenshots__/EnsEntity.pw.tsx_default_with-protocol-info-1.png b/ui/shared/entities/ens/__screenshots__/EnsEntity.pw.tsx_default_with-protocol-info-1.png new file mode 100644 index 0000000000..daff6ae33b Binary files /dev/null and b/ui/shared/entities/ens/__screenshots__/EnsEntity.pw.tsx_default_with-protocol-info-1.png differ