diff --git a/public/images/holders-square.png b/public/images/holders-square.png new file mode 100644 index 00000000..a9d64bbf Binary files /dev/null and b/public/images/holders-square.png differ diff --git a/src/app/communities/NftHoldersSection.tsx b/src/app/communities/NftHoldersSection.tsx new file mode 100644 index 00000000..a9d1b33a --- /dev/null +++ b/src/app/communities/NftHoldersSection.tsx @@ -0,0 +1,153 @@ +import { Address } from 'viem' +import { useFetchNftHolders } from '@/shared/hooks/useFetchNftHolders' +import { HeaderTitle, Paragraph, Span } from '@/components/Typography' +import { Table } from '@/components/Table' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { EXPLORER_URL } from '@/lib/constants' +import { RxExternalLink } from 'react-icons/rx' +import { truncateMiddle } from '@/lib/utils' +import { useState } from 'react' +import { TableIcon } from '@/app/communities/TableIcon' +import { SquareIcon } from '@/app/communities/SquareIcon' + +interface HolderColumnProps { + address: string + rns?: string + image?: string +} +const HolderColumn = ({ address, rns, image }: HolderColumnProps) => { + return ( + + Holders Image + + {rns || address} + + + + ) +} + +interface IdNumberColumnProps { + id: string + image?: string +} +const IdNumberColumn = ({ id, image }: IdNumberColumnProps) => { + return ( +
+ Holders Image Square + #{id} +
+ ) +} + +interface HoldersSectionProps { + address: Address +} + +const CardHolderParagraph = ({ address }: { address: string }) => ( + + + HOLDER + + Holders Image + + {truncateMiddle(address, 5, 5)} + + + +) + +interface CardProps { + image: string + id: string + holderAddress: string +} + +const Card = ({ image, id, holderAddress }: CardProps) => { + return ( +
+ NFT +
+ + ID# {id} + + +
+
+ ) +} + +interface CardViewProps { + nfts: { image_url: string; id: string; owner: string }[] +} + +const CardView = ({ nfts }: CardViewProps) => ( +
+ {nfts.map(({ image_url, id, owner }) => ( + + ))} +
+) + +type ViewState = 'images' | 'table' + +const ViewIconHandler = ({ + view, + onChangeView, +}: { + view: ViewState + onChangeView: (view: ViewState) => void +}) => ( + +
onChangeView('table')} + > + +
+
onChangeView('images')} + > + +
+
+) + +export const NftHoldersSection = ({ address }: HoldersSectionProps) => { + const { currentResults, paginationElement, isLoading } = useFetchNftHolders(address) + + const [view, setView] = useState('table') + + const onChangeView = (selectedView: ViewState) => { + setView(selectedView) + } + + const holders = currentResults.map(({ owner, ens_domain_name, id, image_url }) => ({ + holder: , + 'ID Number': , + })) + + return ( +
+ + Holders + + + {view === 'table' && holders && holders?.length > 0 && } + {view === 'images' && currentResults && currentResults?.length > 0 && ( + + )} + {isLoading && } +
{paginationElement}
+ + ) +} diff --git a/src/app/communities/SquareIcon.tsx b/src/app/communities/SquareIcon.tsx new file mode 100644 index 00000000..fea85102 --- /dev/null +++ b/src/app/communities/SquareIcon.tsx @@ -0,0 +1,11 @@ +export const SquareIcon = ({ color }: { color: string }) => ( + + + +) diff --git a/src/app/communities/TableIcon.tsx b/src/app/communities/TableIcon.tsx new file mode 100644 index 00000000..391f3701 --- /dev/null +++ b/src/app/communities/TableIcon.tsx @@ -0,0 +1,11 @@ +export const TableIcon = ({ color }: { color: string }) => ( + + + +) diff --git a/src/app/user/Balances/actions.ts b/src/app/user/Balances/actions.ts index b634270c..fc665830 100644 --- a/src/app/user/Balances/actions.ts +++ b/src/app/user/Balances/actions.ts @@ -5,13 +5,19 @@ import { fetchNFTsOwnedByAddressAndNftAddress, fetchPricesEndpoint, fetchProposalsCreatedByGovernorAddress, + getNftHolders, getNftInfo, getTokenHoldersOfAddress, } from '@/lib/endpoints' import { tokenContracts, GovernorAddress } from '@/lib/contracts' import { NftMeta } from '@/shared/types' import { ipfsGateways } from '@/config' -import { NextPageParams, ServerResponseV2, TokenHoldersResponse } from '@/app/user/Balances/types' +import { + NextPageParams, + NftHolderItem, + ServerResponseV2, + TokenHoldersResponse, +} from '@/app/user/Balances/types' export const fetchAddressTokens = (address: string, chainId = 31) => axiosInstance @@ -126,6 +132,17 @@ export async function fetchIpfsUri( export const fetchNftInfo = (address: string) => axiosInstance.get(getNftInfo.replace('{{nftAddress}}', address)) +export const fetchNftHoldersOfAddress = async (address: string, params: NextPageParams | null) => { + const { data } = await axiosInstance.get>( + getNftHolders.replace('{{address}}', address), + { params }, + ) + if (data.error) { + throw new Error(data.error) + } + return data +} + export const fetchTokenHoldersOfAddress = async (address: string, nextParams: NextPageParams | null) => { const { data } = await axiosInstance.get>( getTokenHoldersOfAddress.replace('{{address}}', address), diff --git a/src/app/user/Balances/types.ts b/src/app/user/Balances/types.ts index 704cf7a4..d25a5e25 100644 --- a/src/app/user/Balances/types.ts +++ b/src/app/user/Balances/types.ts @@ -53,3 +53,19 @@ export interface ServerResponseV2 { next_page_params: NextPageParams | null error?: string } + +export type NftHolderItem = { + owner: string + ens_domain_name: null + id: string + image_url: string + metadata: Metadata +} + +export type Metadata = { + creator: string + description: string + external_url: string + image: string + name: string +} diff --git a/src/lib/endpoints.ts b/src/lib/endpoints.ts index fabf5a60..e4da2eb9 100644 --- a/src/lib/endpoints.ts +++ b/src/lib/endpoints.ts @@ -20,3 +20,5 @@ export const getNftInfo = process.env.NEXT_PUBLIC_API_RWS_NFT_INFO || `/nfts/{{nftAddress}}?chainId=${CHAIN_ID}` export const getTokenHoldersOfAddress = `/address/{{address}}/holders?chainId=${CHAIN_ID}` + +export const getNftHolders = `/nfts/{{address}}/holders?chainId=${CHAIN_ID}` diff --git a/src/pages/communities/nft/[address].tsx b/src/pages/communities/nft/[address].tsx index f28897de..7af985b1 100644 --- a/src/pages/communities/nft/[address].tsx +++ b/src/pages/communities/nft/[address].tsx @@ -13,6 +13,7 @@ import { useAccount } from 'wagmi' import { useCommunity } from '@/shared/hooks/useCommunity' import { useStRif } from '@/shared/hooks/useStRIf' import { CopyButton } from '@/components/CopyButton' +import { NftHoldersSection } from '@/app/communities/NftHoldersSection' /** * Name of the local storage variable with information about whether the token was added to the wallet @@ -324,6 +325,8 @@ export default function Page() { + {/* Holders list */} + ) } diff --git a/src/shared/hooks/useFetchNftHolders.ts b/src/shared/hooks/useFetchNftHolders.ts new file mode 100644 index 00000000..b68f388a --- /dev/null +++ b/src/shared/hooks/useFetchNftHolders.ts @@ -0,0 +1,19 @@ +import { Address } from 'viem' +import { fetchNftHoldersOfAddress } from '@/app/user/Balances/actions' +import { usePagination } from '@/shared/hooks/usePagination' +import { usePaginationUi } from '@/shared/hooks/usePaginationUi' + +export const useFetchNftHolders = (address: Address) => { + const query = usePagination({ + queryKey: ['nft_holders'], + queryFn: ({ pageParam }) => fetchNftHoldersOfAddress(address, pageParam), + initialPageParam: null, + resultsPerTablePage: 10, + hasMorePagesProperty: 'next_page_params', + getNextPageParam: lastPage => lastPage.next_page_params, + }) + + const ui = usePaginationUi(query) + + return { ...query, ...ui } +} diff --git a/src/shared/hooks/usePagination.ts b/src/shared/hooks/usePagination.ts index 88142690..df8d3876 100644 --- a/src/shared/hooks/usePagination.ts +++ b/src/shared/hooks/usePagination.ts @@ -24,6 +24,7 @@ export interface UsePaginatedQueryResult previousTablePage: () => void isFirstFetch: boolean hasMorePages: boolean + goToTablePage: (pageNumber: number) => void } export function usePagination({ @@ -73,6 +74,8 @@ export function usePagination({ } }, [tablePage]) + const goToTablePage = useCallback((pageNumber: number) => setTablePage(pageNumber), []) + const hasMorePages = useMemo(() => { if (!data || data.pages.length === 0) return true const lastPage = data.pages[data.pages.length - 1] @@ -105,6 +108,7 @@ export function usePagination({ fetchNextPage, hasNextPage, isFetchingNextPage, + goToTablePage, ...restQueryResult, } } diff --git a/src/shared/hooks/usePaginationUi.tsx b/src/shared/hooks/usePaginationUi.tsx index c2362b17..627aaf6b 100644 --- a/src/shared/hooks/usePaginationUi.tsx +++ b/src/shared/hooks/usePaginationUi.tsx @@ -11,8 +11,15 @@ interface UseSimplePaginationResult { export function usePaginationUi( paginationResult: UsePaginatedQueryResult, ): UseSimplePaginationResult { - const { currentResults, totalPages, tablePage, nextTablePage, previousTablePage, isLoading } = - paginationResult + const { + currentResults, + totalPages, + tablePage, + nextTablePage, + previousTablePage, + isLoading, + goToTablePage, + } = paginationResult const paginationElement = useMemo(() => { const getPageNumbers = () => { @@ -38,9 +45,7 @@ export function usePaginationUi( {getPageNumbers().map(pageNumber => ( - tablePage !== pageNumber && (pageNumber > tablePage ? nextTablePage() : previousTablePage()) - } + onClick={() => goToTablePage(pageNumber)} disabled={isLoading} text={pageNumber + 1} isActive={pageNumber === tablePage} @@ -53,7 +58,7 @@ export function usePaginationUi( /> ) - }, [tablePage, totalPages, isLoading, nextTablePage, previousTablePage]) + }, [tablePage, totalPages, isLoading, nextTablePage, previousTablePage, goToTablePage]) return { paginationElement,