Skip to content

Commit

Permalink
DAO-745 Implemented nft holders table (#290)
Browse files Browse the repository at this point in the history
* DAO-745 Implemented nft holders table

* Added correct icons

* Fixed pagination movement on new page click

* Set table as default view
  • Loading branch information
Freshenext authored Oct 28, 2024
1 parent 9b7eedb commit 8d3f31f
Show file tree
Hide file tree
Showing 11 changed files with 248 additions and 7 deletions.
Binary file added public/images/holders-square.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
153 changes: 153 additions & 0 deletions src/app/communities/NftHoldersSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<a
href={`${EXPLORER_URL}/address/${address}`}
target="_blank"
className="flex items-center gap-1.5 text-white"
>
<img src={image || '/images/treasury/holders.png'} width={24} height={24} alt="Holders Image" />
<Span className="underline text-left overflow-hidden whitespace-nowrap text-[14px]">
{rns || address}
</Span>
<RxExternalLink size={18} />
</a>
)
}

interface IdNumberColumnProps {
id: string
image?: string
}
const IdNumberColumn = ({ id, image }: IdNumberColumnProps) => {
return (
<div className="flex items-center gap-1.5">
<img src={image || '/images/holders-square.png'} width={24} height={24} alt="Holders Image Square" />
<span className="tracking-widest">#{id}</span>
</div>
)
}

interface HoldersSectionProps {
address: Address
}

const CardHolderParagraph = ({ address }: { address: string }) => (
<a
href={`${EXPLORER_URL}/address/${address}`}
target="_blank"
className="flex gap-1.5 text-white items-center"
>
<Paragraph fontFamily="kk-topo" size="large" className="pt-[6px]">
HOLDER
</Paragraph>
<img src="/images/treasury/holders.png" width={24} height={24} alt="Holders Image" />
<Span className="underline text-left overflow-hidden whitespace-nowrap text-[14px]">
{truncateMiddle(address, 5, 5)}
</Span>
<RxExternalLink size={18} />
</a>
)

interface CardProps {
image: string
id: string
holderAddress: string
}

const Card = ({ image, id, holderAddress }: CardProps) => {
return (
<div className="w-[272px] bg-foreground">
<img src={image} width={272} alt="NFT" />
<div className="px-[8px] py-[16px]">
<Paragraph fontFamily="kk-topo" size="large">
ID# {id}
</Paragraph>
<CardHolderParagraph address={holderAddress} />
</div>
</div>
)
}

interface CardViewProps {
nfts: { image_url: string; id: string; owner: string }[]
}

const CardView = ({ nfts }: CardViewProps) => (
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 lg:grid-cols-2 md:grid-cols-1 gap-y-4">
{nfts.map(({ image_url, id, owner }) => (
<Card key={id} image={image_url} id={id} holderAddress={owner} />
))}
</div>
)

type ViewState = 'images' | 'table'

const ViewIconHandler = ({
view,
onChangeView,
}: {
view: ViewState
onChangeView: (view: ViewState) => void
}) => (
<span className="absolute right-0 top-0 flex">
<div
className={`w-[46px] h-[46px] flex items-center justify-center ${view === 'table' ? 'bg-white' : ''}`}
onClick={() => onChangeView('table')}
>
<TableIcon color={view === 'table' ? 'black' : 'white'} />
</div>
<div
className={`w-[46px] h-[46px] flex items-center justify-center ${view === 'images' ? 'bg-white' : ''}`}
onClick={() => onChangeView('images')}
>
<SquareIcon color={view === 'images' ? 'black' : 'white'} />
</div>
</span>
)

export const NftHoldersSection = ({ address }: HoldersSectionProps) => {
const { currentResults, paginationElement, isLoading } = useFetchNftHolders(address)

const [view, setView] = useState<ViewState>('table')

const onChangeView = (selectedView: ViewState) => {
setView(selectedView)
}

const holders = currentResults.map(({ owner, ens_domain_name, id, image_url }) => ({
holder: <HolderColumn address={owner} rns={ens_domain_name || ''} image={image_url} />,
'ID Number': <IdNumberColumn id={id} image={image_url} />,
}))

return (
<div className="pl-4 relative">
<HeaderTitle className="mb-[24px]">
Holders
<ViewIconHandler view={view} onChangeView={onChangeView} />
</HeaderTitle>
{view === 'table' && holders && holders?.length > 0 && <Table data={holders} />}
{view === 'images' && currentResults && currentResults?.length > 0 && (
<CardView nfts={currentResults} />
)}
{isLoading && <LoadingSpinner />}
<div className="mt-6">{paginationElement}</div>
</div>
)
}
11 changes: 11 additions & 0 deletions src/app/communities/SquareIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const SquareIcon = ({ color }: { color: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={24} height={24} fill="none">
<path
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 12h18m-9-9v18M7.8 3h8.4c1.68 0 2.52 0 3.162.327a3 3 0 0 1 1.311 1.311C21 5.28 21 6.12 21 7.8v8.4c0 1.68 0 2.52-.327 3.162a3 3 0 0 1-1.311 1.311C18.72 21 17.88 21 16.2 21H7.8c-1.68 0-2.52 0-3.162-.327a3 3 0 0 1-1.311-1.311C3 18.72 3 17.88 3 16.2V7.8c0-1.68 0-2.52.327-3.162a3 3 0 0 1 1.311-1.311C5.28 3 6.12 3 7.8 3Z"
/>
</svg>
)
11 changes: 11 additions & 0 deletions src/app/communities/TableIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const TableIcon = ({ color }: { color: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={24} height={24} fill="none">
<path
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.5 17h-11m11-4h-11M3 9h18M7.8 3h8.4c1.68 0 2.52 0 3.162.327a3 3 0 0 1 1.311 1.311C21 5.28 21 6.12 21 7.8v8.4c0 1.68 0 2.52-.327 3.162a3 3 0 0 1-1.311 1.311C18.72 21 17.88 21 16.2 21H7.8c-1.68 0-2.52 0-3.162-.327a3 3 0 0 1-1.311-1.311C3 18.72 3 17.88 3 16.2V7.8c0-1.68 0-2.52.327-3.162a3 3 0 0 1 1.311-1.311C5.28 3 6.12 3 7.8 3Z"
/>
</svg>
)
19 changes: 18 additions & 1 deletion src/app/user/Balances/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ServerResponseV2<NftHolderItem>>(
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<ServerResponseV2<TokenHoldersResponse>>(
getTokenHoldersOfAddress.replace('{{address}}', address),
Expand Down
16 changes: 16 additions & 0 deletions src/app/user/Balances/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,19 @@ export interface ServerResponseV2<T> {
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
}
2 changes: 2 additions & 0 deletions src/lib/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
3 changes: 3 additions & 0 deletions src/pages/communities/nft/[address].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -324,6 +325,8 @@ export default function Page() {
</div>
</div>
</div>
{/* Holders list */}
<NftHoldersSection address={nftAddress} />
</MainContainer>
)
}
Expand Down
19 changes: 19 additions & 0 deletions src/shared/hooks/useFetchNftHolders.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
4 changes: 4 additions & 0 deletions src/shared/hooks/usePagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface UsePaginatedQueryResult<T>
previousTablePage: () => void
isFirstFetch: boolean
hasMorePages: boolean
goToTablePage: (pageNumber: number) => void
}

export function usePagination<T>({
Expand Down Expand Up @@ -73,6 +74,8 @@ export function usePagination<T>({
}
}, [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]
Expand Down Expand Up @@ -105,6 +108,7 @@ export function usePagination<T>({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
goToTablePage,
...restQueryResult,
}
}
17 changes: 11 additions & 6 deletions src/shared/hooks/usePaginationUi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ interface UseSimplePaginationResult<T> {
export function usePaginationUi<T>(
paginationResult: UsePaginatedQueryResult<T>,
): UseSimplePaginationResult<T> {
const { currentResults, totalPages, tablePage, nextTablePage, previousTablePage, isLoading } =
paginationResult
const {
currentResults,
totalPages,
tablePage,
nextTablePage,
previousTablePage,
isLoading,
goToTablePage,
} = paginationResult

const paginationElement = useMemo(() => {
const getPageNumbers = () => {
Expand All @@ -38,9 +45,7 @@ export function usePaginationUi<T>(
{getPageNumbers().map(pageNumber => (
<PaginationButton
key={pageNumber}
onClick={() =>
tablePage !== pageNumber && (pageNumber > tablePage ? nextTablePage() : previousTablePage())
}
onClick={() => goToTablePage(pageNumber)}
disabled={isLoading}
text={pageNumber + 1}
isActive={pageNumber === tablePage}
Expand All @@ -53,7 +58,7 @@ export function usePaginationUi<T>(
/>
</div>
)
}, [tablePage, totalPages, isLoading, nextTablePage, previousTablePage])
}, [tablePage, totalPages, isLoading, nextTablePage, previousTablePage, goToTablePage])

return {
paginationElement,
Expand Down

0 comments on commit 8d3f31f

Please sign in to comment.