diff --git a/.changelog/1052.feature.md b/.changelog/1052.feature.md new file mode 100644 index 000000000..40c1421c8 --- /dev/null +++ b/.changelog/1052.feature.md @@ -0,0 +1 @@ +Add NFT feature diff --git a/src/app/pages/AccountDetailsPage/AccountNFTCollectionCard.tsx b/src/app/pages/AccountDetailsPage/AccountNFTCollectionCard.tsx new file mode 100644 index 000000000..aadcee489 --- /dev/null +++ b/src/app/pages/AccountDetailsPage/AccountNFTCollectionCard.tsx @@ -0,0 +1,157 @@ +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { useLoaderData, Link as RouterLink, To } from 'react-router-dom' +import Box from '@mui/material/Box' +import Breadcrumbs from '@mui/material/Breadcrumbs' +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import CardContent from '@mui/material/CardContent' +import Link from '@mui/material/Link' +import Typography from '@mui/material/Typography' +import ImageList from '@mui/material/ImageList' +import ImageListItem from '@mui/material/ImageListItem' +import ImageListItemBar from '@mui/material/ImageListItemBar' +import Skeleton from '@mui/material/Skeleton' +import { ErrorBoundary } from '../../components/ErrorBoundary' +import { LinkableDiv } from '../../components/PageLayout/LinkableDiv' +import { AccountDetailsContext } from './index' +import { AccountLink } from 'app/components/Account/AccountLink' +import { CopyToClipboard } from 'app/components/CopyToClipboard' +import { RouteUtils } from 'app/utils/route-utils' +import { COLORS } from 'styles/theme/colors' +import { ImageListItemImage } from '../TokenDashboardPage/ImageListItemImage' +import { CardEmptyState } from '../AccountDetailsPage/CardEmptyState' +import { TablePagination } from '../../components/Table/TablePagination' +import { useAccountTokenInventory } from '../TokenDashboardPage/hook' +import { EvmNft } from 'oasis-nexus/api' +import { SearchScope } from '../../../types/searchScope' +import { NFTCollectionLink, NFTInstanceLink } from '../TokenDashboardPage/NFTLinks' + +export const accountNFTCollectionContainerId = 'nftCollection' + +export const AccountNFTCollectionCard: FC = ({ scope, address }) => { + const { t } = useTranslation() + const oasisContractAddress = useLoaderData() as string + const { inventory, isFetched, isLoading, isTotalCountClipped, pagination, totalCount } = + useAccountTokenInventory(scope, address, oasisContractAddress) + const firstToken = inventory?.length ? inventory?.[0].token : undefined + + return ( + + + + + + + ) + } + disableTypography + title={ + + + + + {t('nft.accountCollection')} + + + {isFetched && ( + + + {firstToken?.name ? inventory?.[0].token.name : t('common.collection')} + + {!!totalCount && ( + ({`${isTotalCountClipped ? ' > ' : ''}${totalCount}`}) + )} + + )} + + {isLoading && } + + } + /> + + + + + + + + ) +} + +type AccountNFTCollectionProps = { + inventory: EvmNft[] | undefined + isLoading: boolean + isFetched: boolean + isTotalCountClipped: boolean | undefined + totalCount: number | undefined + pagination: { + rowsPerPage: number + selectedPage: number + linkToPage: (pageNumber: number) => To + } + scope: SearchScope +} + +const AccountNFTCollection: FC = ({ + inventory, + isLoading, + isFetched, + isTotalCountClipped, + pagination, + scope, + totalCount, +}) => { + const { t } = useTranslation() + + return ( + <> + {isLoading && } + {isFetched && !totalCount && } + {!!inventory?.length && ( + <> + + {inventory?.map(instance => { + const to = RouteUtils.getNFTInstanceRoute(scope, instance.token?.contract_addr, instance.id) + return ( + + + } + subtitle={} + position="below" + /> + + ) + })} + + {pagination && ( + + + + )} + + )} + + ) +} diff --git a/src/app/pages/AccountDetailsPage/AccountTokensCard.tsx b/src/app/pages/AccountDetailsPage/AccountTokensCard.tsx index b5adb6237..6c336b814 100644 --- a/src/app/pages/AccountDetailsPage/AccountTokensCard.tsx +++ b/src/app/pages/AccountDetailsPage/AccountTokensCard.tsx @@ -1,10 +1,11 @@ import { FC } from 'react' import { useTranslation } from 'react-i18next' -import { useLocation } from 'react-router-dom' +import { useLocation, Link as RouterLink } from 'react-router-dom' import Box from '@mui/material/Box' import Card from '@mui/material/Card' import CardHeader from '@mui/material/CardHeader' import CardContent from '@mui/material/CardContent' +import Link from '@mui/material/Link' import { CardEmptyState } from './CardEmptyState' import { Table, TableCellAlign, TableColProps } from '../../components/Table' import { CopyToClipboard } from '../../components/CopyToClipboard' @@ -43,11 +44,20 @@ export const AccountTokensCard: FC = ({ scope, address, const { t } = useTranslation() const locationHash = useLocation().hash.replace('#', '') const tokenListLabel = getTokenTypePluralName(t, type) + const isERC721 = type === EvmTokenType.ERC721 const tableColumns: TableColProps[] = [ - { key: 'name', content: t('common.name') }, + { key: 'name', content: t(isERC721 ? 'common.collection' : 'common.name') }, { key: 'contract', content: t('common.smartContract') }, - { key: 'balance', align: TableCellAlign.Right, content: t('common.balance') }, + { key: 'balance', align: TableCellAlign.Right, content: t(isERC721 ? 'common.owned' : 'common.balance') }, { key: 'ticker', align: TableCellAlign.Right, content: t('common.ticker') }, + ...(isERC721 + ? [ + { + key: 'link', + content: '', + }, + ] + : []), ] const { layer } = scope if (layer === Layer.consensus) { @@ -86,6 +96,15 @@ export const AccountTokensCard: FC = ({ scope, address, content: item.token_symbol || t('common.missing'), key: 'ticker', }, + { + align: TableCellAlign.Right, + key: 'link', + content: ( + + {t('common.viewAll')} + + ), + }, ], highlight: item.token_contract_addr_eth === locationHash || item.token_contract_addr === locationHash, })) diff --git a/src/app/pages/NFTInstanceDashboardPage/InstanceDetailsCard.tsx b/src/app/pages/NFTInstanceDashboardPage/InstanceDetailsCard.tsx index f17313e74..c583ab371 100644 --- a/src/app/pages/NFTInstanceDashboardPage/InstanceDetailsCard.tsx +++ b/src/app/pages/NFTInstanceDashboardPage/InstanceDetailsCard.tsx @@ -55,7 +55,7 @@ export const InstanceDetailsCard: FC = ({ )}
{t('nft.instanceTokenId')}
{nft.id}
-
{t('nft.collection')}
+
{t('common.collection')}
diff --git a/src/app/pages/TokenDashboardPage/ImageListItemImage.tsx b/src/app/pages/TokenDashboardPage/ImageListItemImage.tsx index 6f1b16f45..89cada5f7 100644 --- a/src/app/pages/TokenDashboardPage/ImageListItemImage.tsx +++ b/src/app/pages/TokenDashboardPage/ImageListItemImage.tsx @@ -12,6 +12,7 @@ const imageSize = '210px' const StyledImage = styled('img')({ width: imageSize, height: imageSize, + objectFit: 'cover', }) type ImageListItemImageProps = { diff --git a/src/app/pages/TokenDashboardPage/NFTLinks.tsx b/src/app/pages/TokenDashboardPage/NFTLinks.tsx new file mode 100644 index 000000000..cde013101 --- /dev/null +++ b/src/app/pages/TokenDashboardPage/NFTLinks.tsx @@ -0,0 +1,56 @@ +import { FC } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Link as RouterLink } from 'react-router-dom' +import { EvmNft } from 'oasis-nexus/api' +import Link from '@mui/material/Link' +import Typography from '@mui/material/Typography' +import { RouteUtils } from '../../utils/route-utils' +import { SearchScope } from '../../../types/searchScope' +import { trimLongString } from '../../utils/trimLongString' + +type NFTLinkProps = { + scope: SearchScope + instance: EvmNft +} + +export const NFTCollectionLink: FC = ({ scope, instance }) => { + const { t } = useTranslation() + const to = RouteUtils.getTokenRoute(scope, instance.token?.contract_addr) + + return ( + + + {instance.token?.name ?? trimLongString(instance.token?.eth_contract_addr, 5, 5)} + + ), + }} + /> + + ) +} + +export const NFTInstanceLink: FC = ({ scope, instance }) => { + const { t } = useTranslation() + const to = RouteUtils.getNFTInstanceRoute(scope, instance.token?.contract_addr, instance.id) + + return ( + + + {instance.id} + + ), + }} + /> + + ) +} diff --git a/src/app/pages/TokenDashboardPage/TokenInventoryCard.tsx b/src/app/pages/TokenDashboardPage/TokenInventoryCard.tsx index ae6fde10a..f5303a66f 100644 --- a/src/app/pages/TokenDashboardPage/TokenInventoryCard.tsx +++ b/src/app/pages/TokenDashboardPage/TokenInventoryCard.tsx @@ -1,6 +1,5 @@ import { FC } from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { Link as RouterLink } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import Box from '@mui/material/Box' import Card from '@mui/material/Card' import CardHeader from '@mui/material/CardHeader' @@ -8,7 +7,6 @@ import CardContent from '@mui/material/CardContent' import ImageList from '@mui/material/ImageList' import ImageListItem from '@mui/material/ImageListItem' import ImageListItemBar from '@mui/material/ImageListItemBar' -import Link from '@mui/material/Link' import { ErrorBoundary } from '../../components/ErrorBoundary' import { LinkableDiv } from '../../components/PageLayout/LinkableDiv' import { CardEmptyState } from '../AccountDetailsPage/CardEmptyState' @@ -18,6 +16,7 @@ import { RouteUtils } from '../../utils/route-utils' import { TablePagination } from '../../components/Table/TablePagination' import { useTokenInventory } from './hook' import { ImageListItemImage } from './ImageListItemImage' +import { NFTInstanceLink } from './NFTLinks' export const tokenInventoryContainerId = 'inventory' @@ -55,19 +54,7 @@ const TokenInventoryView: FC = ({ scope, address }) => { - #{instance.id} - - ), - }} - /> - } + title={} subtitle={ owner ? : undefined } diff --git a/src/app/pages/TokenDashboardPage/hook.ts b/src/app/pages/TokenDashboardPage/hook.ts index 0f0972ea3..91064af01 100644 --- a/src/app/pages/TokenDashboardPage/hook.ts +++ b/src/app/pages/TokenDashboardPage/hook.ts @@ -6,6 +6,7 @@ import { useGetRuntimeEvmTokensAddress, useGetRuntimeEvmTokensAddressHolders, useGetRuntimeEvmTokensAddressNfts, + useGetRuntimeAccountsAddressNfts, } from '../../../oasis-nexus/api' import { AppErrors } from '../../../types/errors' import { SearchScope } from '../../../types/searchScope' @@ -141,3 +142,39 @@ export const useTokenInventory = (scope: SearchScope, address: string) => { totalCount, } } + +export const useAccountTokenInventory = (scope: SearchScope, address: string, tokenAddress: string) => { + const { network, layer } = scope + const pagination = useSearchParamsPagination('page') + const offset = (pagination.selectedPage - 1) * NUMBER_OF_INVENTORY_ITEMS + if (layer === Layer.consensus) { + throw AppErrors.UnsupportedLayer + // There are no tokens on the consensus layer. + } + const query = useGetRuntimeAccountsAddressNfts(network, layer, address, { + limit: NUMBER_OF_INVENTORY_ITEMS, + offset: offset, + token_address: tokenAddress, + }) + const { isFetched, isLoading, data } = query + const inventory = data?.data.evm_nfts + + if (isFetched && pagination.selectedPage > 1 && !inventory?.length) { + throw AppErrors.PageDoesNotExist + } + + const totalCount = data?.data.total_count + const isTotalCountClipped = data?.data.is_total_count_clipped + + return { + isLoading, + isFetched, + inventory, + pagination: { + ...pagination, + rowsPerPage: NUMBER_OF_INVENTORY_ITEMS, + }, + isTotalCountClipped, + totalCount, + } +} diff --git a/src/app/utils/route-utils.ts b/src/app/utils/route-utils.ts index 60f732526..7b22ba368 100644 --- a/src/app/utils/route-utils.ts +++ b/src/app/utils/route-utils.ts @@ -148,6 +148,13 @@ export const addressParamLoader = async ({ params }: LoaderFunctionArgs) => { return address } +export const contractAddressParamLoader = async ({ params }: LoaderFunctionArgs) => { + validateAddressParam(params.contractAddress!) + // TODO: remove conversion when API supports querying by EVM address + const address = await getOasisAddress(params.contractAddress!) + return address +} + export const blockHeightParamLoader = async ({ params }: LoaderFunctionArgs) => { return validateBlockHeightParam(params.blockHeight!) } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4cc94eac9..b52eafed0 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -40,6 +40,7 @@ "block": "Block", "bytes": "{{value, number}}", "cancel": "Cancel", + "collection": "Collection", "copy": "Copy", "data": "Data", "emerald": "Emerald", @@ -67,6 +68,7 @@ "nfts": "NFTs", "not_defined": "Not defined", "oasis": "Oasis", + "owned": "Owned", "paratime": "Paratime", "parentheses": "({{subject}})", "percentage": "Percentage", @@ -122,7 +124,8 @@ } }, "nft": { - "collection": "Collection", + "accountCollection": "ERC-721 Tokens", + "collectionLink": "Collection: ", "instanceIdLink": "ID: ", "instanceTokenId": "Token ID", "instanceTitleSuffix": "(NFT Instance)", diff --git a/src/routes.tsx b/src/routes.tsx index ea48d5987..bff15af7f 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -12,6 +12,7 @@ import { SearchResultsPage } from './app/pages/SearchResultsPage' import { addressParamLoader, blockHeightParamLoader, + contractAddressParamLoader, transactionParamLoader, scopeLoader, } from './app/utils/route-utils' @@ -23,6 +24,7 @@ import { TokensPage } from './app/pages/TokensOverviewPage' import { ContractCodeCard } from './app/pages/AccountDetailsPage/ContractCodeCard' import { TokenDashboardPage, useTokenDashboardProps } from './app/pages/TokenDashboardPage' import { AccountTokenTransfersCard } from './app/pages/AccountDetailsPage/AccountTokenTransfersCard' +import { AccountNFTCollectionCard } from './app/pages/AccountDetailsPage/AccountNFTCollectionCard' import { TokenTransfersCard } from './app/pages/TokenDashboardPage/TokenTransfersCard' import { TokenHoldersCard } from './app/pages/TokenDashboardPage/TokenHoldersCard' import { TokenInventoryCard } from './app/pages/TokenDashboardPage/TokenInventoryCard' @@ -103,6 +105,11 @@ export const routes: RouteObject[] = [ path: '', Component: () => , }, + { + path: ':contractAddress', + Component: () => , + loader: contractAddressParamLoader, + }, ], }, { diff --git a/src/styles/theme/defaultTheme.ts b/src/styles/theme/defaultTheme.ts index 0cec866b9..a5eff0156 100644 --- a/src/styles/theme/defaultTheme.ts +++ b/src/styles/theme/defaultTheme.ts @@ -398,6 +398,19 @@ export const defaultTheme = createTheme({ }, }, }, + MuiBreadcrumbs: { + styleOverrides: { + li: { + fontSize: '24px', + }, + separator: { + color: COLORS.brandDark, + fontSize: '24px', + paddingRight: 3, + paddingLeft: 3, + }, + }, + }, MuiCardHeader: { styleOverrides: { root: ({ theme }) => ({