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 (
+
+
+
+ {rns || address}
+
+
+
+ )
+}
+
+interface IdNumberColumnProps {
+ id: string
+ image?: string
+}
+const IdNumberColumn = ({ id, image }: IdNumberColumnProps) => {
+ return (
+
+
+
#{id}
+
+ )
+}
+
+interface HoldersSectionProps {
+ address: Address
+}
+
+const CardHolderParagraph = ({ address }: { address: string }) => (
+
+
+ HOLDER
+
+
+
+ {truncateMiddle(address, 5, 5)}
+
+
+
+)
+
+interface CardProps {
+ image: string
+ id: string
+ holderAddress: string
+}
+
+const Card = ({ image, id, holderAddress }: CardProps) => {
+ return (
+
+
+
+
+ )
+}
+
+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,