Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DAO-743 Implemented holders section #267

Merged
merged 2 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/images/treasury/holders.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions src/app/treasury/HoldersSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Table } from '@/components/Table'
import { HeaderTitle, Span } from '@/components/Typography'
import { EXPLORER_URL, STRIF_ADDRESS } from '@/lib/constants'
import { RxExternalLink } from 'react-icons/rx'
import { useFetchTokenHolders } from '@/app/treasury/hooks/useFetchTokenHolders'
import { formatBalanceToHuman } from '@/app/user/Balances/balanceUtils'
import Image from 'next/image'
import { LoadingSpinner } from '@/components/LoadingSpinner'

interface HolderColumnProps {
address: string
rns?: string
}
const HolderColumn = ({ address, rns }: HolderColumnProps) => {
return (
<a
href={`${EXPLORER_URL}/address/${address}`}
target="_blank"
className="mt-2 flex items-center gap-1.5 text-white"
>
<Image src="/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>
)
}

export const HoldersSection = () => {
// Fetch st rif holders
const { currentResults, paginationElement, isLoading } = useFetchTokenHolders(STRIF_ADDRESS)

const holders = currentResults.map(({ address, value }) => ({
holder: <HolderColumn address={address.hash} rns={address.ens_domain_name} />,
quantity: `${formatBalanceToHuman(value)} stRIF`,
}))

return (
<div>
<HeaderTitle className="mb-4">Holders</HeaderTitle>
{holders && holders?.length > 0 && <Table data={holders} />}
{paginationElement}
{isLoading && <LoadingSpinner />}
</div>
)
}
20 changes: 20 additions & 0 deletions src/app/treasury/hooks/useFetchTokenHolders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { fetchTokenHoldersOfAddress } from '@/app/user/Balances/actions'
import { Address } from 'viem'
import { NextPageParams } from '@/app/user/Balances/types'
import { usePagination } from '@/shared/hooks/usePagination'
import { usePaginationUi } from '@/shared/hooks/usePaginationUi'

export const useFetchTokenHolders = (address: Address) => {
const query = usePagination({
queryKey: ['tokenHolders'],
queryFn: ({ pageParam }) => fetchTokenHoldersOfAddress(address, pageParam as NextPageParams),
getNextPageParam: lastPage => lastPage.next_page_params,
initialPageParam: null,
resultsPerTablePage: 10,
hasMorePagesProperty: 'next_page_params',
})

const ui = usePaginationUi(query)

return { ...query, ...ui }
}
2 changes: 2 additions & 0 deletions src/app/treasury/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TreasuryContextProviderWithPrices } from '@/app/treasury/TreasuryContex
import { TreasurySection } from '@/app/treasury/TreasurySection'
import { TotalTokenHoldingsSection } from '@/app/treasury/TotalTokenHoldingsSection'
import { MetricsSection } from '@/app/treasury/MetricsSection'
import { HoldersSection } from '@/app/treasury/HoldersSection'

export default function Treasury() {
return (
Expand All @@ -13,6 +14,7 @@ export default function Treasury() {
<TreasurySection />
<TotalTokenHoldingsSection />
<MetricsSection />
<HoldersSection />
</div>
</TreasuryContextProviderWithPrices>
</MainContainer>
Expand Down
13 changes: 13 additions & 0 deletions src/app/user/Balances/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
fetchPricesEndpoint,
fetchProposalsCreatedByGovernorAddress,
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'

export const fetchAddressTokens = (address: string, chainId = 31) =>
axiosInstance
Expand Down Expand Up @@ -123,3 +125,14 @@ export async function fetchIpfsUri(

export const fetchNftInfo = (address: string) =>
axiosInstance.get(getNftInfo.replace('{{nftAddress}}', address))

export const fetchTokenHoldersOfAddress = async (address: string, nextParams: NextPageParams | null) => {
const { data } = await axiosInstance.get<ServerResponseV2<TokenHoldersResponse>>(
getTokenHoldersOfAddress.replace('{{address}}', address),
{ params: nextParams },
)
if (data.error) {
throw new Error(data.error)
}
return data
}
55 changes: 55 additions & 0 deletions src/app/user/Balances/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export interface TokenHolderAddress {
ens_domain_name: string
hash: string
implementations: any[]
is_contract: boolean
is_verified: boolean
metadata: null
name: null
private_tags: any[]
proxy_type: null
public_tags: any[]
watchlist_names: any[]
}

export interface TokenHolderToken {
address: string
circulating_market_cap: null
decimals: string
exchange_rate: null
holders: string
icon_url: null
name: string
symbol: string
total_supply: string
type: string
volume_24h: null
}

export interface TokenHoldersResponse {
address: TokenHolderAddress
token: TokenHolderToken
token_id: null
value: string
}

export interface NextPageParams {
contract_address_hash?: string
holder_count?: number
is_name_null?: boolean
items_count?: number
market_cap?: string
name?: string
block_number?: number
fee?: string
hash?: string
index?: number
inserted_at?: string
value?: string
}

export interface ServerResponseV2<T> {
items: T[]
next_page_params: NextPageParams | null
error?: string
}
10 changes: 8 additions & 2 deletions src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import { FaSpinner } from 'react-icons/fa6'
import { Span } from '../Typography'
import { DivWithGradient } from '@/components/Button/DivWithGradient'

export const BUTTON_DEFAULT_CLASSES = 'px-[24px] py-[12px] flex gap-x-1 items-center relative'
export const BUTTON_DEFAULT_CLASSES = 'px-[24px] py-[12px] flex gap-x-1 items-center relative rounded-[6px]'

const DEFAULT_PAGINATION_CLASSES = 'w-[32px] h-[32px] p-0'

const DEFAULT_PAGINATION_ACTIVE_CLASSES = [DEFAULT_PAGINATION_CLASSES, 'bg-primary text-black'].join(' ')

interface Props {
children: string
children: ReactNode
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
startIcon?: ReactNode
variant?: ButtonVariants
Expand Down Expand Up @@ -55,6 +59,8 @@ export const Button: FC<Props> = ({
'justify-start': !centerContent,
'justify-center': centerContent,
'cursor-not-allowed': disabled,
[DEFAULT_PAGINATION_CLASSES]: variant === 'pagination',
[DEFAULT_PAGINATION_ACTIVE_CLASSES]: variant === 'pagination-active',
[className]: true,
})

Expand Down
2 changes: 2 additions & 0 deletions src/components/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export type ButtonVariants =
| 'outlined'
| 'white'
| 'sidebar-active'
| 'pagination'
| 'pagination-active'
2 changes: 2 additions & 0 deletions src/lib/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ export const fetchProposalsCreatedByGovernorAddress =

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}`
110 changes: 110 additions & 0 deletions src/shared/hooks/usePagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { InfiniteData, useInfiniteQuery, UseInfiniteQueryResult } from '@tanstack/react-query'
import { useState, useCallback, useMemo, useEffect } from 'react'

interface PaginatedResponse<T> {
items: T[]
[key: string]: any
}

interface UsePaginatedQueryOptions<T> {
queryKey: string[]
queryFn: (pageParam: any) => Promise<PaginatedResponse<T>>
getNextPageParam: (lastPage: PaginatedResponse<T>) => any
initialPageParam: any
resultsPerTablePage: number
hasMorePagesProperty: keyof PaginatedResponse<T>
}

export interface UsePaginatedQueryResult<T>
extends Omit<UseInfiniteQueryResult<InfiniteData<PaginatedResponse<T>>>, 'data'> {
currentResults: T[]
tablePage: number
totalPages: number
nextTablePage: () => void
previousTablePage: () => void
isFirstFetch: boolean
hasMorePages: boolean
}

export function usePagination<T>({
queryKey,
queryFn,
getNextPageParam,
initialPageParam,
resultsPerTablePage,
hasMorePagesProperty,
}: UsePaginatedQueryOptions<T>): UsePaginatedQueryResult<T> {
const [tablePage, setTablePage] = useState(0)
const [isFirstFetch, setIsFirstFetch] = useState(true)

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, ...restQueryResult } = useInfiniteQuery<
PaginatedResponse<T>,
Error
>({
queryKey,
queryFn,
getNextPageParam,
initialPageParam,
refetchOnWindowFocus: false,
})

const allItems = useMemo(() => {
return data?.pages.flatMap(page => page.items) || []
}, [data])

const totalItems = allItems.length
const totalPages = Math.ceil(totalItems / resultsPerTablePage)

const currentResults = useMemo(() => {
const start = tablePage * resultsPerTablePage
const end = start + resultsPerTablePage
return allItems.slice(start, end)
}, [allItems, tablePage, resultsPerTablePage])

const nextTablePage = useCallback(() => {
if (tablePage < totalPages - 1) {
setTablePage(prev => prev + 1)
}
}, [tablePage, totalPages])

const previousTablePage = useCallback(() => {
if (tablePage > 0) {
setTablePage(prev => prev - 1)
}
}, [tablePage])

const hasMorePages = useMemo(() => {
if (!data || data.pages.length === 0) return true
const lastPage = data.pages[data.pages.length - 1]
return lastPage[hasMorePagesProperty] !== null
}, [data, hasMorePagesProperty])

useEffect(() => {
// Check if we need to fetch the next page
const currentItemIndex = (tablePage + 1) * resultsPerTablePage
if (currentItemIndex >= totalItems && hasMorePages && !isFetchingNextPage) {
fetchNextPage()
}
}, [tablePage, resultsPerTablePage, totalItems, hasMorePages, isFetchingNextPage, fetchNextPage])

useEffect(() => {
// Update isFirstFetch
if (isFirstFetch && data && data.pages.length > 0) {
setIsFirstFetch(false)
}
}, [data, isFirstFetch])

return {
currentResults,
totalPages,
tablePage,
nextTablePage,
previousTablePage,
isFirstFetch,
hasMorePages,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
...restQueryResult,
}
}
78 changes: 78 additions & 0 deletions src/shared/hooks/usePaginationUi.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ReactNode, useMemo } from 'react'
import { UsePaginatedQueryResult } from '@/shared/hooks/usePagination'
import { Button } from '@/components/Button'
import { ChevronLeft, ChevronRight } from 'lucide-react'

interface UseSimplePaginationResult<T> {
paginationElement: ReactNode
currentResults: T[]
}

export function usePaginationUi<T>(
paginationResult: UsePaginatedQueryResult<T>,
): UseSimplePaginationResult<T> {
const { currentResults, totalPages, tablePage, nextTablePage, previousTablePage, isLoading } =
paginationResult

const paginationElement = useMemo(() => {
const getPageNumbers = () => {
const maxVisiblePages = 5
const pages = []
const start = Math.max(0, Math.min(tablePage - 2, totalPages - maxVisiblePages))
const end = Math.min(start + maxVisiblePages, totalPages)

for (let i = start; i < end; i++) {
pages.push(i)
}

return pages
}

return (
<div className="flex gap-x-1 items-center justify-center">
<PaginationButton
text={<ChevronLeft />}
onClick={previousTablePage}
disabled={tablePage === 0 || isLoading}
/>
{getPageNumbers().map(pageNumber => (
<PaginationButton
key={pageNumber}
onClick={() =>
tablePage !== pageNumber && (pageNumber > tablePage ? nextTablePage() : previousTablePage())
}
disabled={isLoading}
text={pageNumber + 1}
isActive={pageNumber === tablePage}
/>
))}
<PaginationButton
text={<ChevronRight />}
onClick={nextTablePage}
disabled={tablePage === totalPages - 1 || isLoading}
/>
</div>
)
}, [tablePage, totalPages, isLoading, nextTablePage, previousTablePage])

return {
paginationElement,
currentResults,
}
}

const PaginationButton = ({
text,
onClick,
disabled,
isActive,
}: {
text: ReactNode
onClick: () => void
disabled?: boolean
isActive?: boolean
}) => (
<Button onClick={onClick} disabled={disabled} variant={isActive ? 'pagination-active' : 'pagination'}>
{text}
</Button>
)
Loading