diff --git a/.gitignore b/.gitignore index 23e6d2504..1eee3377f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ next-env.d.ts dist +# version manager .tool-versions diff --git a/src/app/api/common/citizens/getCitizens.ts b/src/app/api/common/citizens/getCitizens.ts index e3646f136..e821120ed 100644 --- a/src/app/api/common/citizens/getCitizens.ts +++ b/src/app/api/common/citizens/getCitizens.ts @@ -15,7 +15,7 @@ async function getCitizens({ sort = "shuffle", seed, }: { - pagination: PaginationParams; + pagination?: PaginationParams; sort: string; seed?: number; }): Promise> { @@ -26,7 +26,7 @@ async function getCitizens({ if (sort === "shuffle") { return prisma.$queryRawUnsafe( ` - SELECT + SELECT citizens.address AS delegate, delegate.voting_power, advanced_vp, @@ -54,7 +54,7 @@ async function getCitizens({ WHERE citizens.retro_funding_round = (SELECT MAX(retro_funding_round) FROM agora.citizens) ORDER BY md5(CAST(citizens.address AS TEXT) || CAST($2 AS TEXT)) OFFSET $3 - LIMIT $4; + LIMIT $4; `, slug, seed, @@ -64,7 +64,7 @@ async function getCitizens({ } else { return prisma.$queryRawUnsafe( ` - SELECT + SELECT citizens.address AS delegate, delegate.voting_power, direct_vp, @@ -92,7 +92,7 @@ async function getCitizens({ AND citizens.dao_slug = $1::config.dao_slug WHERE citizens.retro_funding_round = (SELECT MAX(retro_funding_round) FROM agora.citizens) ORDER BY COALESCE(delegate.voting_power, 0) DESC, - citizens.address ASC + citizens.address ASC OFFSET $2 LIMIT $3; `, @@ -114,6 +114,7 @@ async function getCitizens({ direct: citizen.direct_vp?.toFixed(0) || "0", advanced: citizen.advanced_vp?.toFixed(0) || "0", }, + votingParticipation: 0, citizen: citizen.citizen, statement: citizen.statement, })), diff --git a/src/app/api/common/delegates/getDelegates.ts b/src/app/api/common/delegates/getDelegates.ts index c1aa75b7d..0343685ae 100644 --- a/src/app/api/common/delegates/getDelegates.ts +++ b/src/app/api/common/delegates/getDelegates.ts @@ -18,6 +18,7 @@ import { fetchCurrentQuorum } from "@/app/api/common/quorum/getQuorum"; import { fetchVotableSupply } from "@/app/api/common/votableSupply/getVotableSupply"; import { doInSpan } from "@/app/lib/logging"; import { TENANT_NAMESPACES } from "@/lib/constants"; +import { getProxyAddress } from "@/lib/alligatorUtils"; /* * Fetches a list of delegates @@ -35,10 +36,11 @@ async function getDelegates({ seed, filters, }: { - pagination: PaginationParams; + pagination?: PaginationParams; sort: string; seed?: number; filters?: { + delegator?: `0x${string}`; issues?: string; stakeholders?: string; endorsed?: boolean; @@ -102,22 +104,57 @@ async function getDelegates({ : ""; let delegateUniverseCTE: string; - const tokenAddress = contracts.token.address; + const proxyAddress = filters?.delegator + ? await getProxyAddress(filters?.delegator?.toLowerCase()) + : null; - delegateUniverseCTE = `with del_statements as (select address from agora.delegate_statements where dao_slug='${slug}'), - del_with_del as (select * from ${namespace + ".delegates"} d where contract = '${tokenAddress}'), - del_card_universe as (select COALESCE(d.delegate, ds.address) as delegate, - coalesce(d.num_of_delegators, 0) as num_of_delegators, - coalesce(d.direct_vp, 0) as direct_vp, - coalesce(d.advanced_vp, 0) as advanced_vp, - coalesce(d.voting_power, 0) as voting_power - from del_with_del d full join del_statements ds on d.delegate = ds.address)`; + delegateUniverseCTE = ` + with del_statements as ( + select address + from agora.delegate_statements + where dao_slug='${slug}' + ), + filtered_delegates as ( + select d.* + from ${namespace}.delegates d + where d.contract = '${tokenAddress}' + ${ + filters?.delegator + ? ` + AND d.delegate IN ( + SELECT delegatee + FROM ( + SELECT delegatee, block_number + FROM ${namespace}.delegatees + WHERE delegator = '${filters.delegator.toLowerCase()}' + ${proxyAddress ? `AND delegatee <> '${proxyAddress.toLowerCase()}'` : ""} + AND contract = '${tokenAddress}' + UNION ALL + SELECT "to" as delegatee, block_number + FROM ${namespace}.advanced_delegatees + WHERE "from" = '${filters.delegator.toLowerCase()}' + AND delegated_amount > 0 + AND contract = '${contracts.alligator?.address || tokenAddress}' + ) combined_delegations + ORDER BY block_number DESC + ) + ` + : "" + } + ), + del_card_universe as ( + select + d.delegate as delegate, + d.num_of_delegators as num_of_delegators, + d.direct_vp as direct_vp, + d.advanced_vp as advanced_vp, + d.voting_power as voting_power + from filtered_delegates d + )`; // Applies allow-list filtering to the delegate list const paginatedAllowlistQuery = async (skip: number, take: number) => { - console.log(sort); - const allowListString = allowList.map((value) => `'${value}'`).join(", "); switch (sort) { @@ -154,13 +191,12 @@ async function getDelegates({ ) AS statement FROM del_card_universe d WHERE num_of_delegators IS NOT NULL - AND (ARRAY_LENGTH(ARRAY[${allowListString}]::text[], 1) IS NULL OR delegate = ANY(ARRAY[${allowListString}]::text[])) + AND (ARRAY_LENGTH(ARRAY[${allowListString}]::text[], 1) IS NULL OR d.delegate = ANY(ARRAY[${allowListString}]::text[])) ${delegateStatementFiler} ORDER BY num_of_delegators DESC OFFSET $1 LIMIT $2; - `; - // console.log(QRY1); + `; return prisma.$queryRawUnsafe(QRY1, skip, take); case "weighted_random": @@ -202,7 +238,6 @@ async function getDelegates({ OFFSET $1 LIMIT $2; `; - // console.log(QRY2); return prisma.$queryRawUnsafe(QRY2, skip, take); default: @@ -243,7 +278,6 @@ async function getDelegates({ OFFSET $1 LIMIT $2; `; - // console.log(QRY3); return prisma.$queryRawUnsafe(QRY3, skip, take); } }; @@ -436,5 +470,30 @@ async function getDelegate(addressOrENSName: string): Promise { }; } +async function getVoterStats(addressOrENSName: string): Promise { + const { namespace, contracts } = Tenant.current(); + const address = isAddress(addressOrENSName) + ? addressOrENSName.toLowerCase() + : await resolveENSName(addressOrENSName); + + const statsQuery = await prisma.$queryRawUnsafe< + Pick[] + >( + ` + SELECT + voter, + participation_rate, + last_10_props + FROM ${namespace + ".voter_stats"} + WHERE voter = $1 AND contract = $2 + `, + address, + contracts.governor.address + ); + + return statsQuery?.[0] || undefined; +} + export const fetchDelegates = cache(getDelegates); export const fetchDelegate = cache(getDelegate); +export const fetchVoterStats = cache(getVoterStats); diff --git a/src/app/api/common/delegations/getDelegations.ts b/src/app/api/common/delegations/getDelegations.ts index eb845bd91..ec6b76cea 100644 --- a/src/app/api/common/delegations/getDelegations.ts +++ b/src/app/api/common/delegations/getDelegations.ts @@ -151,51 +151,51 @@ async function getCurrentDelegatorsForAddress({ // Replace with the Agora Governor flag if (contracts.alligator || namespace === TENANT_NAMESPACES.SCROLL) { - advancedDelegatorsSubQry = `SELECT + advancedDelegatorsSubQry = `SELECT "from", "to", delegated_amount as allowance, - 'ADVANCED' AS type, + 'ADVANCED' AS type, block_number, CASE WHEN delegated_share >= 1 THEN 'FULL' ELSE 'PARTIAL' END as amount, transaction_hash - FROM + FROM ${namespace}.advanced_delegatees ad - WHERE + WHERE ad."to" = $1 - AND delegated_amount > 0 + AND delegated_amount > 0 AND contract = $2`; } else { - advancedDelegatorsSubQry = `WITH ghost as (SELECT + advancedDelegatorsSubQry = `WITH ghost as (SELECT null::text as "from", null::text as "to", null::numeric as allowance, - 'ADVANCED' AS type, + 'ADVANCED' AS type, null::numeric as block_number, 'FULL' as amount, null::text as transaction_hash) select * from ghost - WHERE + WHERE ghost."to" = $1 AND ghost."from" = $2`; } if (namespace == TENANT_NAMESPACES.SCROLL) { - directDelegatorsSubQry = `WITH ghost as (SELECT + directDelegatorsSubQry = `WITH ghost as (SELECT null::text as "from", null::text as "to", null::numeric as allowance, - 'DIRECT' AS type, + 'DIRECT' AS type, null::numeric as block_number, 'FULL' as amount, null::text as transaction_hash) select * from ghost - WHERE + WHERE ghost."to" = $3 AND ghost."from" = $3`; } else { directDelegatorsSubQry = ` - SELECT + SELECT "from", "to", null::numeric as allowance, @@ -251,7 +251,7 @@ async function getCurrentDelegatorsForAddress({ >( ` WITH advanced_delegatees AS ( ${advancedDelegatorsSubQry} ) - + , direct_delegatees AS ( ${directDelegatorsSubQry} ) SELECT * FROM advanced_delegatees UNION ALL @@ -357,10 +357,13 @@ const getDirectDelegateeForAddress = async ({ }: { address: string; }) => { - const { namespace } = Tenant.current(); + const { namespace, contracts } = Tenant.current(); const delegatee = await prisma[`${namespace}Delegatees`].findFirst({ - where: { delegator: address.toLowerCase() }, + where: { + delegator: address.toLowerCase(), + contract: contracts.token.address.toLowerCase(), + }, }); if (namespace === TENANT_NAMESPACES.OPTIMISM) { diff --git a/src/app/api/common/proposals/proposal.d.ts b/src/app/api/common/proposals/proposal.d.ts index 3622bb35e..c742ce32d 100644 --- a/src/app/api/common/proposals/proposal.d.ts +++ b/src/app/api/common/proposals/proposal.d.ts @@ -20,8 +20,8 @@ export type Proposal = { queuedTime: Date | null; markdowntitle: string; description: string | null; - quorum: BigNumberish | null; - approvalThreshold: BigNumberish | null; + quorum: bigint | null; + approvalThreshold: bigint | null; proposalData: ParsedProposalData[ProposalType]["kind"]; unformattedProposalData: `0x${string}` | null | any; proposalResults: ParsedProposalResults[ProposalType]["kind"]; diff --git a/src/app/api/common/votes/getVotes.ts b/src/app/api/common/votes/getVotes.ts index 446453ebc..fd27275f1 100644 --- a/src/app/api/common/votes/getVotes.ts +++ b/src/app/api/common/votes/getVotes.ts @@ -193,6 +193,64 @@ async function getSnapshotVotesForDelegateForAddress({ } } +async function getVotersWhoHaveNotVotedForProposal({ + proposalId, + pagination = { offset: 0, limit: 20 }, +}: { + proposalId: string; + pagination?: PaginationParams; +}) { + const { namespace, contracts, slug } = Tenant.current(); + + const queryFunction = (skip: number, take: number) => { + const notVotedQuery = ` + SELECT + del.*, + ds.twitter, + ds.discord, + ds.warpcast + FROM ${namespace + ".delegates"} del + LEFT JOIN agora.delegate_statements ds + ON del.delegate = ds.address + AND ds.dao_slug = '${slug}' + WHERE del.delegate NOT IN ( + SELECT voter FROM ${namespace + ".vote_cast_events"} WHERE proposal_id = $1 + ) + AND del.contract = $2 + ORDER BY del.voting_power DESC + `; + + return prisma.$queryRawUnsafe( + `${notVotedQuery} + OFFSET $3 + LIMIT $4;`, + proposalId, + contracts.token.address.toLowerCase(), + skip, + take + ); + }; + + const [{ meta, data: nonVoters }, latestBlock] = await Promise.all([ + doInSpan({ name: "getVotersWhoHaveNotVotedForProposal" }, async () => + paginateResult(queryFunction, pagination) + ), + contracts.token.provider.getBlock("latest"), + ]); + + if (!nonVoters || nonVoters.length === 0) { + return { + meta, + data: [], + }; + } + + return { + meta, + data: nonVoters, + }; +} + async function getVotesForProposal({ proposalId, pagination = { offset: 0, limit: 20 }, @@ -255,8 +313,8 @@ async function getVotesForProposal({ ) q ORDER BY ${sort} DESC OFFSET $3 - LIMIT $4; - `; + LIMIT $4;`; + return prisma.$queryRawUnsafe( query, proposalId, @@ -372,3 +430,6 @@ export const fetchUserVotesForProposal = cache(getUserVotesForProposal); export const fetchVotesForProposalAndDelegate = cache( getVotesForProposalAndDelegate ); +export const fetchVotersWhoHaveNotVotedForProposal = cache( + getVotersWhoHaveNotVotedForProposal +); diff --git a/src/app/delegates/[addressOrENSName]/page.tsx b/src/app/delegates/[addressOrENSName]/page.tsx index 5a2174d68..f33e37456 100644 --- a/src/app/delegates/[addressOrENSName]/page.tsx +++ b/src/app/delegates/[addressOrENSName]/page.tsx @@ -1,21 +1,7 @@ -/* - * Show page for a single delegate - * Takes in the delegate address as a parameter - */ import { Metadata, ResolvingMetadata } from "next"; import DelegateCard from "@/components/Delegates/DelegateCard/DelegateCard"; -import DelegateVotes from "@/components/Delegates/DelegateVotes/DelegateVotes"; -import DelegationsContainer from "@/components/Delegates/Delegations/DelegationsContainer"; import ResourceNotFound from "@/components/shared/ResourceNotFound/ResourceNotFound"; -import DelegateStatementContainer from "@/components/Delegates/DelegateStatement/DelegateStatementContainer"; -import TopIssues from "@/components/Delegates/DelegateStatement/TopIssues"; -import { - fetchCurrentDelegatees, - fetchCurrentDelegators, - fetchDelegate, - fetchVotesForDelegate, -} from "@/app/delegates/actions"; -import { fetchSnapshotVotesForDelegate } from "@/app/api/common/votes/getVotes"; +import { fetchDelegate } from "@/app/delegates/actions"; import { formatNumber } from "@/lib/tokenUtils"; import { processAddressOrEnsName, @@ -23,10 +9,16 @@ import { resolveENSProfileImage, } from "@/app/lib/ENSUtils"; import Tenant from "@/lib/tenant/tenant"; -import TopStakeholders from "@/components/Delegates/DelegateStatement/TopStakeholders"; -import SnapshotVotes from "@/components/Delegates/DelegateVotes/SnapshotVotes"; -import VotesContainer from "@/components/Delegates/DelegateVotes/VotesContainer"; -import { PaginationParams } from "@/app/lib/pagination"; +import { Suspense } from "react"; +import DelegateStatementWrapper, { + DelegateStatementSkeleton, +} from "@/components/Delegates/DelegateStatement/DelegateStatementWrapper"; +import DelegationsContainerWrapper, { + DelegationsContainerSkeleton, +} from "@/components/Delegates/Delegations/DelegationsContainerWrapper"; +import VotesContainerWrapper, { + VotesContainerSkeleton, +} from "@/components/Delegates/DelegateVotes/VotesContainerWrapper"; export async function generateMetadata( { params }: { params: { addressOrENSName: string } }, @@ -90,16 +82,7 @@ export default async function Page({ params: { addressOrENSName: string }; }) { const address = (await resolveENSName(addressOrENSName)) || addressOrENSName; - const [delegate, delegateVotes, delegates, delegators, snapshotVotes] = - await Promise.all([ - fetchDelegate(address), - fetchVotesForDelegate(address), - fetchCurrentDelegatees(address), - fetchCurrentDelegators(address), - fetchSnapshotVotesForDelegate({ addressOrENSName: address }), - ]); - - const statement = delegate.statement; + const delegate = await fetchDelegate(address); if (!delegate) { return ( @@ -112,75 +95,16 @@ export default async function Page({
-
- - - {statement && ( - <> - - - - )} - - { - "use server"; - - return fetchCurrentDelegators(addressOrENSName, pagination); - }} - /> - - {delegateVotes && delegateVotes.data.length > 0 ? ( -
- { - "use server"; - return fetchVotesForDelegate( - addressOrENSName, - pagination - ); - }} - /> -
- ) : ( -
- No past votes available. -
- )} - - } - snapshotVotes={ - <> - {snapshotVotes && snapshotVotes.data.length > 0 ? ( - { - "use server"; - return await fetchSnapshotVotesForDelegate({ - addressOrENSName: addressOrENSName, - pagination, - }); - }} - /> - ) : ( -
- No past votes available. -
- )} - - } - /> + }> + + + }> + + + }> + +
); diff --git a/src/app/delegates/actions.ts b/src/app/delegates/actions.ts index 36a841e05..e818a236a 100644 --- a/src/app/delegates/actions.ts +++ b/src/app/delegates/actions.ts @@ -9,7 +9,10 @@ import { fetchVotingPowerAvailableForDirectDelegation, fetchVotingPowerAvailableForSubdelegation, } from "@/app/api/common/voting-power/getVotingPower"; -import { fetchDelegate as apiFetchDelegate } from "@/app/api/common/delegates/getDelegates"; +import { + fetchDelegate as apiFetchDelegate, + fetchVoterStats as apiFetchVoterStats, +} from "@/app/api/common/delegates/getDelegates"; import { fetchDelegateStatement as apiFetchDelegateStatement } from "@/app/api/common/delegateStatement/getDelegateStatement"; import { fetchAllDelegatorsInChains, @@ -25,6 +28,10 @@ export async function fetchDelegate(address: string) { return apiFetchDelegate(address); } +export async function fetchVoterStats(address: string) { + return apiFetchVoterStats(address); +} + export async function fetchDelegateStatement(address: string) { return apiFetchDelegateStatement(address); } diff --git a/src/app/delegates/components/DelegatorFilter.tsx b/src/app/delegates/components/DelegatorFilter.tsx new file mode 100644 index 000000000..e7cef4077 --- /dev/null +++ b/src/app/delegates/components/DelegatorFilter.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { Listbox } from "@headlessui/react"; +import { Fragment } from "react"; +import { ChevronDown } from "lucide-react"; +import { useAddSearchParam, useDeleteSearchParam } from "@/hooks"; +import { useAgoraContext } from "@/contexts/AgoraContext"; +import { useAccount } from "wagmi"; + +const FILTER_PARAM = "delegatorFilter"; +const DEFAULT_FILTER = "all_delegates"; + +export default function DelegatorFilter() { + const router = useRouter(); + const searchParams = useSearchParams(); + const addSearchParam = useAddSearchParam(); + const deleteSearchParam = useDeleteSearchParam(); + const { setIsDelegatesFiltering } = useAgoraContext(); + const { address } = useAccount(); + + const filterParam = searchParams?.get(FILTER_PARAM) || DEFAULT_FILTER; + const delegateeFilterOptions: { value: string; sort: string }[] = [ + { + value: "All delegates", + sort: "all_delegates", + }, + { + value: "My delegates", + sort: "my_delegates", + }, + ]; + + const handleChange = (value: string) => { + setIsDelegatesFiltering(true); + router.push( + value === DEFAULT_FILTER + ? deleteSearchParam({ name: FILTER_PARAM }) + : addSearchParam({ name: FILTER_PARAM, value: address || "" }), + { scroll: false } + ); + }; + + if (!address) return null; + + return ( + handleChange(value)} + > + {() => ( + <> + + + {filterParam === DEFAULT_FILTER + ? "All delegates" + : "My delegates"} + + + + + {delegateeFilterOptions.map((key) => ( + + {(selected) => { + return ( +
  • + {key.value} +
  • + ); + }} +
    + ))} +
    + + )} +
    + ); +} diff --git a/src/app/delegates/page.jsx b/src/app/delegates/page.jsx index ce5f395f1..96dd1a204 100644 --- a/src/app/delegates/page.jsx +++ b/src/app/delegates/page.jsx @@ -1,32 +1,9 @@ -import { fetchCitizens as apiFetchCitizens } from "@/app/api/common/citizens/getCitizens"; -import { fetchDelegates as apiFetchDelegates } from "@/app/api/common/delegates/getDelegates"; -import { fetchCurrentDelegators as apiFetchCurrentDelegators } from "@/app/api/common/delegations/getDelegations"; -import DelegateCardList from "@/components/Delegates/DelegateCardList/DelegateCardList"; -import CitizenCardList from "@/components/Delegates/DelegateCardList/CitzenCardList"; -import DelegateTabs from "@/components/Delegates/DelegatesTabs/DelegatesTabs"; -import Hero from "@/components/Hero/Hero"; -import { TabsContent } from "@/components/ui/tabs"; -import { citizensFilterOptions, delegatesFilterOptions } from "@/lib/constants"; import Tenant from "@/lib/tenant/tenant"; -import React from "react"; - -async function fetchCitizens(sort, seed, pagination) { - "use server"; - - return apiFetchCitizens({ pagination, seed, sort }); -} - -async function fetchDelegates(sort, seed, filters, pagination) { - "use server"; - - return apiFetchDelegates({ pagination, seed, sort, filters }); -} - -async function fetchDelegators(address) { - "use server"; - - return apiFetchCurrentDelegators(address); -} +import React, { Suspense } from "react"; +import DelegateCardWrapper, { + DelegateCardLoadingState, +} from "@/components/Delegates/DelegateCardList/DelegateCardWrapper"; +import Hero from "@/components/Hero/Hero"; export async function generateMetadata({}, parent) { const { ui } = Tenant.current(); @@ -58,66 +35,12 @@ export async function generateMetadata({}, parent) { } export default async function Page({ searchParams }) { - const { ui } = Tenant.current(); - - const sort = - delegatesFilterOptions[searchParams.orderBy]?.sort || - delegatesFilterOptions.weightedRandom.sort; - const citizensSort = - citizensFilterOptions[searchParams.citizensOrderBy]?.value || - citizensFilterOptions.shuffle.sort; - - const filters = { - ...(searchParams.issueFilter && { issues: searchParams.issueFilter }), - ...(searchParams.stakeholderFilter && { - stakeholders: searchParams.stakeholderFilter, - }), - }; - - const endorsedToggle = ui.toggle("delegates/endorsed-filter"); - if (endorsedToggle?.enabled) { - const defaultFilter = endorsedToggle.config.defaultFilter; - filters.endorsed = - searchParams?.endorsedFilter === undefined - ? defaultFilter - : searchParams.endorsedFilter === "true"; - } - - const tab = searchParams.tab; - const seed = Math.random(); - const delegates = - tab === "citizens" - ? await fetchCitizens(citizensSort, seed) - : await fetchDelegates(sort, seed, filters); - return (
    - - - { - "use server"; - return apiFetchDelegates({ pagination, seed, sort, filters }); - }} - fetchDelegators={fetchDelegators} - /> - - - { - "use server"; - - return apiFetchCitizens({ pagination, seed, sort: citizensSort }); - }} - fetchDelegators={fetchDelegators} - />{" "} - - + }> + +
    ); } diff --git a/src/app/lib/hooks/useIsAdvancedUser.ts b/src/app/lib/hooks/useIsAdvancedUser.ts index 2cf5a2e6c..fa09e9669 100644 --- a/src/app/lib/hooks/useIsAdvancedUser.ts +++ b/src/app/lib/hooks/useIsAdvancedUser.ts @@ -66,6 +66,7 @@ const useIsAdvancedUser = () => { "0x30b6e0b4f29FA72E8C7D014B6309668024ceB881", // QA 10 "0x9b3d738C07Cd0E45eE98a792bA48ba67Bb5dAbca", // QA 11 "0x416a0343470ac6694D39e2fCd6C494eeEF39BeEB", // SAFE QA 3 + "0x648BFC4dB7e43e799a84d0f607aF0b4298F932DB", // Michael test ] as `0x${string}`[]; const { data: balance, isFetched: isBalanceFetched } = useReadContract({ diff --git a/src/app/proposals/actions.tsx b/src/app/proposals/actions.tsx index 3a5eb2917..184c6d333 100644 --- a/src/app/proposals/actions.tsx +++ b/src/app/proposals/actions.tsx @@ -4,16 +4,25 @@ import { fetchAllForVoting as apiFetchAllForVoting } from "@/app/api/votes/getVo import { fetchUserVotesForProposal as apiFetchUserVotesForProposal, fetchVotesForProposal as apiFetchVotesForProposal, + fetchVotersWhoHaveNotVotedForProposal as apiFetchVotersWhoHaveNotVotedForProposal, } from "@/app/api/common/votes/getVotes"; import { PaginationParams } from "../lib/pagination"; +import { VotesSort } from "../api/common/votes/vote"; -export const fetchProposalVotes = ( +export const fetchVotersWhoHaveNotVotedForProposal = ( proposalId: string, pagination?: PaginationParams +) => apiFetchVotersWhoHaveNotVotedForProposal({ proposalId, pagination }); + +export const fetchProposalVotes = ( + proposalId: string, + pagination?: PaginationParams, + sort?: VotesSort ) => apiFetchVotesForProposal({ proposalId, pagination, + sort, }); export const fetchUserVotesForProposal = ( diff --git a/src/app/proposals/draft/components/stages/DraftForm/DraftFormClient.tsx b/src/app/proposals/draft/components/stages/DraftForm/DraftFormClient.tsx index 8b75ca530..ea9f6cef7 100644 --- a/src/app/proposals/draft/components/stages/DraftForm/DraftFormClient.tsx +++ b/src/app/proposals/draft/components/stages/DraftForm/DraftFormClient.tsx @@ -89,13 +89,13 @@ const DraftFormClient = ({ const { watch, handleSubmit, control } = methods; - const proposalType = watch("type"); + const votingModuleType = watch("type"); const stageIndex = getStageIndexForTenant("DRAFTING") as number; useEffect(() => { const newValidProposalTypes = getValidProposalTypesForVotingType( proposalTypes, - proposalType + votingModuleType ); setValidProposalTypes(newValidProposalTypes); @@ -106,7 +106,7 @@ const DraftFormClient = ({ newValidProposalTypes[0].proposal_type_id ); } - }, [proposalType, proposalTypes, methods]); + }, [votingModuleType, proposalTypes, methods]); const onSubmit = async (data: z.output) => { setIsPending(true); @@ -153,7 +153,7 @@ const DraftFormClient = ({ />

    - {ProposalTypeMetadata[proposalType].description} + {ProposalTypeMetadata[votingModuleType].description}

    @@ -197,7 +197,7 @@ const DraftFormClient = ({ {(() => { - switch (proposalType) { + switch (votingModuleType) { case ProposalType.BASIC: return ; case ProposalType.SOCIAL: @@ -207,7 +207,7 @@ const DraftFormClient = ({ case ProposalType.OPTIMISTIC: return ; default: - const exhaustiveCheck: never = proposalType; + const exhaustiveCheck: never = votingModuleType; return exhaustiveCheck; } })()} diff --git a/src/app/staking/components/PanelClaimRewards.tsx b/src/app/staking/components/PanelClaimRewards.tsx index 9d24a303e..6473a144e 100644 --- a/src/app/staking/components/PanelClaimRewards.tsx +++ b/src/app/staking/components/PanelClaimRewards.tsx @@ -26,7 +26,7 @@ export const PanelClaimRewards = () => { Available to collect

    - {formatNumber(0, 18)} WETH + {formatNumber(0n, 18)} WETH
    diff --git a/src/app/staking/components/StakingStats.tsx b/src/app/staking/components/StakingStats.tsx index 4bde8df96..f8386c2fd 100644 --- a/src/app/staking/components/StakingStats.tsx +++ b/src/app/staking/components/StakingStats.tsx @@ -1,12 +1,11 @@ import React from "react"; -import { BigNumberish } from "ethers"; import TokenAmountDisplay from "@/components/shared/TokenAmountDisplay"; interface StakingStatsProps { rewardDuration: string; - rewardPerToken: BigNumberish; - totalStaked: BigNumberish; - totalSupply: BigNumberish; + rewardPerToken: bigint; + totalStaked: bigint; + totalSupply: bigint; } export const StakingStats = ({ diff --git a/src/components/Admin/ProposalType.tsx b/src/components/Admin/ProposalType.tsx index 7ca6ea328..c61c53d4b 100644 --- a/src/components/Admin/ProposalType.tsx +++ b/src/components/Admin/ProposalType.tsx @@ -196,7 +196,7 @@ export default function ProposalType({ {formatNumber( Math.floor( (formattedVotableSupply * formValues.quorum) / 100 - ), + ).toString(), 0, 1 )}{" "} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 5187e5d93..2f35a60ea 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -51,7 +51,7 @@ export function UpdatedButton({ className={cn( className, type === "primary" && - "bg-agora-stone-900 hover:bg-agora-stone-900/90 text-white transition-colors", + "bg-primary hover:bg-primary/90 text-white transition-colors", type === "secondary" && "", type === "link" && "", variant === "rounded" && "rounded-full", @@ -67,10 +67,11 @@ export function UpdatedButton({ )} diff --git a/src/components/Delegates/DelegateCard/DelegateButton.tsx b/src/components/Delegates/DelegateCard/DelegateButton.tsx index 4081bbf99..3658e1a8d 100644 --- a/src/components/Delegates/DelegateCard/DelegateButton.tsx +++ b/src/components/Delegates/DelegateCard/DelegateButton.tsx @@ -1,4 +1,4 @@ -import { Button } from "@/components/Button"; +import { UpdatedButton } from "@/components/Button"; import { useOpenDialog } from "@/components/Dialogs/DialogProvider/DialogProvider"; import { DelegateChunk } from "@/app/api/common/delegates/delegate"; import { @@ -16,7 +16,8 @@ export function DelegateButton({ const openDialog = useOpenDialog(); return ( - + ); } diff --git a/src/components/Delegates/DelegateCard/DelegateCard.tsx b/src/components/Delegates/DelegateCard/DelegateCard.tsx index 91bbb320c..6494d4377 100644 --- a/src/components/Delegates/DelegateCard/DelegateCard.tsx +++ b/src/components/Delegates/DelegateCard/DelegateCard.tsx @@ -1,13 +1,60 @@ -import { bpsToString, pluralizeAddresses } from "@/lib/utils"; import { DelegateProfileImage } from "./DelegateProfileImage"; import DelegateCardClient from "./DelegateCardClient"; +import { formatNumber } from "@/lib/tokenUtils"; import { Delegate } from "@/app/api/common/delegates/delegate"; +const CardHeader = ({ + title, + cornerTitle, + subtitle, +}: { + title: string; + cornerTitle: string; + subtitle: string; +}) => { + return ( +
    +
    +
    +

    {title}

    + {cornerTitle} +
    +

    {subtitle}

    +
    +
    + ); +}; + +const ActiveHeader = ({ outOfTen }: { outOfTen: string }) => { + return ( + + ); +}; + +const InactiveHeader = ({ outOfTen }: { outOfTen: string }) => { + return ( + + ); +}; + export default function DelegateCard({ delegate }: { delegate: Delegate }) { return (
    + {parseInt(delegate.lastTenProps) > 5 ? ( + + ) : ( + + )}
    -
    +
    -
    +
    + {/* - - */} + {/* */} + + + {delegate.votedFor} + + + {delegate.votedAgainst} + + + {delegate.votedAbstain} + +
    + } />
    @@ -66,8 +130,16 @@ export const PanelRow = ({ }) => { return (
    - {title} - {detail} + {title} + + {detail} +
    ); }; + +export const DelegateCardSkeleton = () => { + return ( +
    + ); +}; diff --git a/src/components/Delegates/DelegateCard/DelegateProfileImage.tsx b/src/components/Delegates/DelegateCard/DelegateProfileImage.tsx index 394f12e95..d028517b4 100644 --- a/src/components/Delegates/DelegateCard/DelegateProfileImage.tsx +++ b/src/components/Delegates/DelegateCard/DelegateProfileImage.tsx @@ -18,6 +18,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { UIEndorsedConfig } from "@/lib/tenant/tenantUI"; +import { truncateAddress } from "@/app/lib/utils/text"; interface Props { address: string; @@ -36,7 +37,6 @@ export function DelegateProfileImage({ }: Props) { const { ui } = Tenant.current(); const { refetchDelegate, setRefetchDelegate } = useConnectButtonContext(); - const { token } = Tenant.current(); const formattedNumber = useMemo(() => { return formatNumber(votingPower); }, [votingPower]); @@ -80,7 +80,7 @@ export function DelegateProfileImage({ }, [address, formattedNumber, refetchDelegate, setRefetchDelegate]); return ( -
    +
    {citizen && ( )}
    -
    - {formattedNumber} {token.symbol} -
    ); diff --git a/src/components/Delegates/DelegateCard/UndelegateButton.tsx b/src/components/Delegates/DelegateCard/UndelegateButton.tsx new file mode 100644 index 000000000..8a00b8a1b --- /dev/null +++ b/src/components/Delegates/DelegateCard/UndelegateButton.tsx @@ -0,0 +1,37 @@ +import { UpdatedButton } from "@/components/Button"; +import { useOpenDialog } from "@/components/Dialogs/DialogProvider/DialogProvider"; +import { DelegateChunk } from "@/app/api/common/delegates/delegate"; +import { + fetchDirectDelegatee, + fetchBalanceForDirectDelegation, +} from "@/app/delegates/actions"; + +export function UndelegateButton({ + full, + delegate, +}: { + full: boolean; + delegate: DelegateChunk; +}) { + const openDialog = useOpenDialog(); + + return ( + { + e.preventDefault(); + openDialog({ + type: "UNDELEGATE", + params: { + delegate, + fetchBalanceForDirectDelegation, + fetchDirectDelegatee, + }, + }); + }} + className={full ? "w-full" : undefined} + > + Undelegate + + ); +} diff --git a/src/components/Delegates/DelegateCardList/CitzenCardList.tsx b/src/components/Delegates/DelegateCardList/CitzenCardList.tsx index 5615fd13e..511b886a7 100644 --- a/src/components/Delegates/DelegateCardList/CitzenCardList.tsx +++ b/src/components/Delegates/DelegateCardList/CitzenCardList.tsx @@ -12,15 +12,15 @@ import useConnectedDelegate from "@/hooks/useConnectedDelegate"; import { cn } from "@/lib/utils"; import { useAgoraContext } from "@/contexts/AgoraContext"; import { PaginatedResult, PaginationParams } from "@/app/lib/pagination"; -import { Delegate } from "@/app/api/common/delegates/delegate"; +import { DelegateChunk } from "@/app/api/common/delegates/delegate"; interface Props { isDelegatesCitizensFetching: boolean; - initialDelegates: PaginatedResult; + initialDelegates: PaginatedResult; fetchDelegates: ( pagination: PaginationParams, seed: number - ) => Promise>; + ) => Promise>; fetchDelegators: (addressOrENSName: string) => Promise; } diff --git a/src/components/Delegates/DelegateCardList/DelegateCard.tsx b/src/components/Delegates/DelegateCardList/DelegateCard.tsx new file mode 100644 index 000000000..7967f7461 --- /dev/null +++ b/src/components/Delegates/DelegateCardList/DelegateCard.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; +import { DelegateChunk } from "@/app/api/common/delegates/delegate"; +import { cn } from "@/lib/utils"; +import { formatNumber } from "@/lib/tokenUtils"; +import { DelegateProfileImage } from "../DelegateCard/DelegateProfileImage"; +import { DelegateActions } from "../DelegateCard/DelegateActions"; +import Tenant from "@/lib/tenant/tenant"; +import useConnectedDelegate from "@/hooks/useConnectedDelegate"; +import { useVotingStats } from "@/hooks/useVotingStats"; + +const DelegateCard = ({ + delegate, + isDelegatesCitizensFetching, + isDelegatesFiltering, + isAdvancedUser, + truncatedStatement, +}: { + delegate: DelegateChunk; + isDelegatesCitizensFetching: boolean; + isDelegatesFiltering: boolean; + isAdvancedUser: boolean; + truncatedStatement: string; +}) => { + const { token } = Tenant.current(); + const { advancedDelegators } = useConnectedDelegate(); + const { data: votingStats, isLoading: isVotingStatsLoading } = useVotingStats( + { + address: delegate.address as `0x${string}`, + } + ); + + return ( +
    + +
    +
    +
    + +
    +
    + + {formatNumber(delegate.votingPower.total)} {token.symbol} + + {votingStats && ( + + {(votingStats?.last_10_props || 0) * 10}% Participation + + )} +
    +

    + {truncatedStatement} +

    +
    +
    + +
    +
    + +
    + ); +}; + +export default DelegateCard; diff --git a/src/components/Delegates/DelegateCardList/DelegateCardList.tsx b/src/components/Delegates/DelegateCardList/DelegateCardList.tsx index b337a8a91..a8c74f4b4 100644 --- a/src/components/Delegates/DelegateCardList/DelegateCardList.tsx +++ b/src/components/Delegates/DelegateCardList/DelegateCardList.tsx @@ -2,17 +2,15 @@ import { useEffect, useRef, useState } from "react"; import InfiniteScroll from "react-infinite-scroller"; -import { DelegateActions } from "../DelegateCard/DelegateActions"; -import { DelegateProfileImage } from "../DelegateCard/DelegateProfileImage"; import { DialogProvider } from "@/components/Dialogs/DialogProvider/DialogProvider"; import { DelegateChunk } from "@/app/api/common/delegates/delegate"; import useIsAdvancedUser from "@/app/lib/hooks/useIsAdvancedUser"; -import Link from "next/link"; import { Delegation } from "@/app/api/common/delegations/delegation"; import useConnectedDelegate from "@/hooks/useConnectedDelegate"; -import { cn } from "@/lib/utils"; import { useAgoraContext } from "@/contexts/AgoraContext"; import { PaginatedResult, PaginationParams } from "@/app/lib/pagination"; +import Tenant from "@/lib/tenant/tenant"; +import DelegateCard from "./DelegateCard"; interface Props { isDelegatesCitizensFetching: boolean; @@ -29,6 +27,7 @@ export default function DelegateCardList({ fetchDelegates, isDelegatesCitizensFetching, }: Props) { + const { token } = Tenant.current(); const fetching = useRef(false); const [meta, setMeta] = useState(initialDelegates.meta); const [delegates, setDelegates] = useState(initialDelegates.data); @@ -74,7 +73,7 @@ export default function DelegateCardList({ } element="div" > - {delegates?.map((delegate) => { + {delegates?.map((delegate, idx) => { let truncatedStatement = ""; if (delegate?.statement?.payload) { @@ -85,38 +84,14 @@ export default function DelegateCardList({ } return ( -
    - -
    -
    - -

    - {truncatedStatement} -

    -
    -
    - -
    -
    - -
    + ); })} diff --git a/src/components/Delegates/DelegateCardList/DelegateCardWrapper.tsx b/src/components/Delegates/DelegateCardList/DelegateCardWrapper.tsx new file mode 100644 index 000000000..1611cc10b --- /dev/null +++ b/src/components/Delegates/DelegateCardList/DelegateCardWrapper.tsx @@ -0,0 +1,137 @@ +import { fetchCitizens as apiFetchCitizens } from "@/app/api/common/citizens/getCitizens"; +import { fetchDelegates as apiFetchDelegates } from "@/app/api/common/delegates/getDelegates"; +import { fetchCurrentDelegators as apiFetchCurrentDelegators } from "@/app/api/common/delegations/getDelegations"; +import DelegateCardList from "@/components/Delegates/DelegateCardList/DelegateCardList"; +import CitizenCardList from "@/components/Delegates/DelegateCardList/CitzenCardList"; +import DelegateTabs from "@/components/Delegates/DelegatesTabs/DelegatesTabs"; +import { TabsContent } from "@/components/ui/tabs"; +import { citizensFilterOptions, delegatesFilterOptions } from "@/lib/constants"; +import Tenant from "@/lib/tenant/tenant"; +import React from "react"; +import { PaginationParams } from "@/app/lib/pagination"; +import { UIEndorsedConfig } from "@/lib/tenant/tenantUI"; + +async function fetchCitizens( + sort: string, + seed: number, + pagination?: PaginationParams +) { + "use server"; + + return apiFetchCitizens({ pagination, seed, sort }); +} + +async function fetchDelegates( + sort: string, + seed: number, + filters: any, + pagination?: PaginationParams +) { + "use server"; + + return apiFetchDelegates({ pagination, seed, sort, filters }); +} + +async function fetchDelegators(address: string) { + "use server"; + + return apiFetchCurrentDelegators(address); +} + +const DelegateCardWrapper = async ({ searchParams }: { searchParams: any }) => { + const { ui } = Tenant.current(); + + const sort = + delegatesFilterOptions[ + searchParams.orderBy as keyof typeof delegatesFilterOptions + ]?.sort || delegatesFilterOptions.weightedRandom.sort; + const citizensSort = + citizensFilterOptions[ + searchParams.citizensOrderBy as keyof typeof citizensFilterOptions + ]?.value || citizensFilterOptions.shuffle.sort; + + const filters = { + ...(searchParams.delegatorFilter && { + delegator: searchParams.delegatorFilter, + }), + ...(searchParams.issueFilter && { issues: searchParams.issueFilter }), + ...(searchParams.stakeholderFilter && { + stakeholders: searchParams.stakeholderFilter, + }), + }; + + const endorsedToggle = ui.toggle("delegates/endorsed-filter"); + if (endorsedToggle?.enabled) { + const defaultFilter = (endorsedToggle.config as UIEndorsedConfig) + .defaultFilter; + filters.endorsed = + searchParams?.endorsedFilter === undefined + ? defaultFilter + : searchParams.endorsedFilter === "true"; + } + + const tab = searchParams.tab; + const seed = Math.random(); + const delegates = + tab === "citizens" + ? await fetchCitizens(citizensSort, seed) + : await fetchDelegates(sort, seed, filters); + + return ( + + + { + "use server"; + return apiFetchDelegates({ pagination, seed, sort, filters }); + }} + // @ts-ignore + fetchDelegators={fetchDelegators} + /> + + + { + "use server"; + + return apiFetchCitizens({ pagination, seed, sort: citizensSort }); + }} + // @ts-ignore + fetchDelegators={fetchDelegators} + />{" "} + + + ); +}; + +export const DelegateCardLoadingState = () => { + return ( +
    +
    +

    Delegates

    +
    + + + +
    +
    +
    + + + + + + + + + +
    +
    + ); +}; + +export default DelegateCardWrapper; diff --git a/src/components/Delegates/DelegateStatement/DelegateStatementContainer.tsx b/src/components/Delegates/DelegateStatement/DelegateStatementContainer.tsx index 4d921e2eb..d28423151 100644 --- a/src/components/Delegates/DelegateStatement/DelegateStatementContainer.tsx +++ b/src/components/Delegates/DelegateStatement/DelegateStatementContainer.tsx @@ -5,6 +5,7 @@ import { useAccount } from "wagmi"; import { useSearchParams } from "next/navigation"; import DelegateStatement from "./DelegateStatement"; import { DelegateStatement as DelegateStatementType } from "@/app/api/common/delegateStatement/delegateStatement"; +import { fetchDelegate } from "@/app/delegates/actions"; export default function DelegateStatementContainer({ addressOrENSName, diff --git a/src/components/Delegates/DelegateStatement/DelegateStatementWrapper.tsx b/src/components/Delegates/DelegateStatement/DelegateStatementWrapper.tsx new file mode 100644 index 000000000..010ad86a0 --- /dev/null +++ b/src/components/Delegates/DelegateStatement/DelegateStatementWrapper.tsx @@ -0,0 +1,40 @@ +import { fetchDelegate } from "@/app/delegates/actions"; +import { resolveENSName } from "@/app/lib/ENSUtils"; +import DelegateStatementContainer from "./DelegateStatementContainer"; +import TopStakeholders from "./TopStakeholders"; +import TopIssues from "./TopIssues"; + +const DelegateStatementWrapper = async ({ + addressOrENSName, +}: { + addressOrENSName: string; +}) => { + const address = await resolveENSName(addressOrENSName); + const delegate = await fetchDelegate(address); + + return ( + <> + + {delegate.statement && ( + <> + + + + )} + + ); +}; + +export const DelegateStatementSkeleton = () => { + return ( +
    +
    +
    +
    + ); +}; + +export default DelegateStatementWrapper; diff --git a/src/components/Delegates/DelegateVotes/VotesContainerWrapper.tsx b/src/components/Delegates/DelegateVotes/VotesContainerWrapper.tsx new file mode 100644 index 000000000..8a737290d --- /dev/null +++ b/src/components/Delegates/DelegateVotes/VotesContainerWrapper.tsx @@ -0,0 +1,74 @@ +import { resolveENSName } from "@/app/lib/ENSUtils"; +import VotesContainer from "./VotesContainer"; +import { fetchVotesForDelegate } from "@/app/delegates/actions"; +import { fetchSnapshotVotesForDelegate } from "@/app/api/common/votes/getVotes"; +import { PaginationParams } from "@/app/lib/pagination"; +import DelegateVotes from "./DelegateVotes"; +import SnapshotVotes from "./SnapshotVotes"; + +const VotesContainerWrapper = async ({ + addressOrENSName, +}: { + addressOrENSName: string; +}) => { + const address = (await resolveENSName(addressOrENSName)) || addressOrENSName; + const [delegateVotes, snapshotVotes] = await Promise.all([ + fetchVotesForDelegate(address), + fetchSnapshotVotesForDelegate({ addressOrENSName: address }), + ]); + + return ( + + {delegateVotes && delegateVotes.data.length > 0 ? ( +
    + { + "use server"; + return fetchVotesForDelegate(addressOrENSName, pagination); + }} + /> +
    + ) : ( +
    + No past votes available. +
    + )} + + } + snapshotVotes={ + <> + {snapshotVotes && snapshotVotes.data.length > 0 ? ( + { + "use server"; + return await fetchSnapshotVotesForDelegate({ + addressOrENSName: addressOrENSName, + pagination, + }); + }} + /> + ) : ( +
    + No past votes available. +
    + )} + + } + /> + ); +}; + +export const VotesContainerSkeleton = () => { + return ( +
    +
    +
    +
    + ); +}; + +export default VotesContainerWrapper; diff --git a/src/components/Delegates/DelegatesTabs/DelegatesTabs.tsx b/src/components/Delegates/DelegatesTabs/DelegatesTabs.tsx index 4d7965999..6edc6fff5 100644 --- a/src/components/Delegates/DelegatesTabs/DelegatesTabs.tsx +++ b/src/components/Delegates/DelegatesTabs/DelegatesTabs.tsx @@ -11,6 +11,7 @@ import { useAddSearchParam, useDeleteSearchParam } from "@/hooks"; import StakeholdersFilter from "@/app/delegates/components/StakeholdersFilter"; import IssuesFilter from "@/app/delegates/components/IssuesFilter"; import EndorsedFilter from "@/app/delegates/components/EndorsedFilter"; +import DelegateeFilter from "@/app/delegates/components/DelegatorFilter"; export default function DelegateTabs({ children }: { children: ReactNode }) { const { ui } = Tenant.current(); @@ -25,6 +26,10 @@ export default function DelegateTabs({ children }: { children: ReactNode }) { ui.toggle("delegates/endorsed-filter")?.enabled ); + const hasMyDelegatesFilter = Boolean( + ui.toggle("delegates/my-delegates-filter")?.enabled + ); + const router = useRouter(); const searchParams = useSearchParams(); const addSearchParam = useAddSearchParam(); @@ -61,6 +66,7 @@ export default function DelegateTabs({ children }: { children: ReactNode }) {
    + {hasMyDelegatesFilter && } {hasStakeholdersFilter && tabParam !== "citizens" && ( )} diff --git a/src/components/Delegates/Delegations/DelegationsContainerWrapper.tsx b/src/components/Delegates/Delegations/DelegationsContainerWrapper.tsx new file mode 100644 index 000000000..947bfabed --- /dev/null +++ b/src/components/Delegates/Delegations/DelegationsContainerWrapper.tsx @@ -0,0 +1,40 @@ +import { PaginationParams } from "@/app/lib/pagination"; +import DelegationsContainer from "./DelegationsContainer"; +import { resolveENSName } from "@/app/lib/ENSUtils"; +import { + fetchCurrentDelegatees, + fetchCurrentDelegators, +} from "@/app/delegates/actions"; + +const DelegationsContainerWrapper = async ({ + addressOrENSName, +}: { + addressOrENSName: string; +}) => { + const address = (await resolveENSName(addressOrENSName)) || addressOrENSName; + const [delegatees, delegators] = await Promise.all([ + fetchCurrentDelegatees(address), + fetchCurrentDelegators(address), + ]); + return ( + { + "use server"; + return fetchCurrentDelegators(addressOrENSName, pagination); + }} + /> + ); +}; + +export const DelegationsContainerSkeleton = () => { + return ( +
    +
    +
    +
    + ); +}; + +export default DelegationsContainerWrapper; diff --git a/src/components/Dialogs/DialogProvider/dialogs.tsx b/src/components/Dialogs/DialogProvider/dialogs.tsx index de6b946d8..d6a361768 100644 --- a/src/components/Dialogs/DialogProvider/dialogs.tsx +++ b/src/components/Dialogs/DialogProvider/dialogs.tsx @@ -1,5 +1,6 @@ import { DialogDefinitions } from "./types"; import { DelegateDialog } from "../DelegateDialog/DelegateDialog"; +import { UndelegateDialog } from "../UndelegateDialog/UndelegateDialog"; import { SwitchNetwork } from "../SwitchNetworkDialog/SwitchNetworkDialog"; import { CastProposalDialog } from "@/components/Proposals/ProposalCreation/CastProposalDialog"; import { @@ -25,14 +26,12 @@ import SponsorOnchainProposalDialog from "@/app/proposals/draft/components/dialo import SponsorSnapshotProposalDialog from "@/app/proposals/draft/components/dialogs/SponsorSnapshotProposalDialog"; import AddGithubPRDialog from "@/app/proposals/draft/components/dialogs/AddGithubPRDialog"; import { StakedDeposit } from "@/lib/types"; -import { - fetchAllForAdvancedDelegation, - fetchCurrentDelegatees, -} from "@/app/delegates/actions"; +import { fetchAllForAdvancedDelegation } from "@/app/delegates/actions"; import { PartialDelegationDialog } from "@/components/Dialogs/PartialDelegateDialog/PartialDelegationDialog"; export type DialogType = | DelegateDialogType + | UndelegateDialogType | CastProposalDialogType | CastVoteDialogType | AdvancedDelegateDialogType @@ -61,6 +60,19 @@ export type DelegateDialogType = { }; }; +export type UndelegateDialogType = { + type: "UNDELEGATE"; + params: { + delegate: DelegateChunk; + fetchBalanceForDirectDelegation: ( + addressOrENSName: string + ) => Promise; + fetchDirectDelegatee: ( + addressOrENSName: string + ) => Promise; + }; +}; + export type AdvancedDelegateDialogType = { type: "ADVANCED_DELEGATE"; params: { @@ -197,6 +209,18 @@ export const dialogs: DialogDefinitions = { /> ); }, + UNDELEGATE: ( + { delegate, fetchBalanceForDirectDelegation, fetchDirectDelegatee }, + closeDialog + ) => { + return ( + + ); + }, PARTIAL_DELEGATE: ({ delegate, fetchCurrentDelegatees }, closeDialog) => { return ( { + return formatNumber(amount); + }, [amount]); + + return ( + + {token.symbol} + {formattedNumber} {token.symbol} + + ); +} diff --git a/src/components/Dialogs/UndelegateDialog/UndelegateDialog.tsx b/src/components/Dialogs/UndelegateDialog/UndelegateDialog.tsx new file mode 100644 index 000000000..ed034b46e --- /dev/null +++ b/src/components/Dialogs/UndelegateDialog/UndelegateDialog.tsx @@ -0,0 +1,232 @@ +import { + useAccount, + useWriteContract, + useEnsName, + useWaitForTransactionReceipt, +} from "wagmi"; +import { ArrowDownIcon } from "@heroicons/react/20/solid"; +import { Button } from "@/components/Button"; +import { Button as ShadcnButton } from "@/components/ui/button"; +import { DelegateChunk } from "@/app/api/common/delegates/delegate"; +import { useCallback, useEffect, useState } from "react"; +import { + AgoraLoaderSmall, + LogoLoader, +} from "@/components/shared/AgoraLoader/AgoraLoader"; +import ENSAvatar from "@/components/shared/ENSAvatar"; +import ENSName from "@/components/shared/ENSName"; +import BlockScanUrls from "@/components/shared/BlockScanUrl"; +import { useConnectButtonContext } from "@/contexts/ConnectButtonContext"; +import { DelegateePayload } from "@/app/api/common/delegations/delegation"; +import Tenant from "@/lib/tenant/tenant"; +import { revalidateData } from "./revalidateAction"; +import { zeroAddress } from "viem"; + +export function UndelegateDialog({ + delegate, + fetchBalanceForDirectDelegation, + fetchDirectDelegatee, +}: { + delegate: DelegateChunk; + fetchBalanceForDirectDelegation: ( + addressOrENSName: string + ) => Promise; + fetchDirectDelegatee: ( + addressOrENSName: string + ) => Promise; +}) { + const { ui, contracts, token } = Tenant.current(); + const shouldHideAgoraBranding = ui.hideAgoraBranding; + const { address: accountAddress } = useAccount(); + const [votingPower, setVotingPower] = useState(""); + const [delegatee, setDelegatee] = useState(null); + const [isReady, setIsReady] = useState(false); + const { setRefetchDelegate } = useConnectButtonContext(); + const sameDelegatee = + delegate.address.toLowerCase() === accountAddress?.toLowerCase(); + + const isDisabledInTenant = ui.toggle("delegates/delegate")?.enabled === false; + + const { data: accountEnsName } = useEnsName({ + chainId: 1, + address: accountAddress as `0x${string}`, + }); + + const { data: delegateeEnsName } = useEnsName({ + chainId: 1, + address: delegatee?.delegatee as `0x${string}`, + }); + + const { isError, writeContract: write, data } = useWriteContract(); + + const { + isLoading: isProcessingDelegation, + isSuccess: didProcessDelegation, + isError: didFailDelegation, + } = useWaitForTransactionReceipt({ + hash: data, + }); + + const fetchData = useCallback(async () => { + setIsReady(false); + if (!accountAddress) return; + + try { + const vp = await fetchBalanceForDirectDelegation(accountAddress); + setVotingPower(vp.toString()); + + const direct = await fetchDirectDelegatee(accountAddress); + setDelegatee(direct); + } finally { + setIsReady(true); + } + }, [fetchBalanceForDirectDelegation, accountAddress, fetchDirectDelegatee]); + + const renderActionButtons = () => { + if (isDisabledInTenant) { + return ( + + ); + } + + if (sameDelegatee) { + return ( + + You are already delegated to yourself + + ); + } + + if (isError || didFailDelegation) { + return ( + + ); + } + + if (isProcessingDelegation) { + return ( + + ); + } + + if (didProcessDelegation) { + return ( +
    + + +
    + ); + } + + return ( + + write({ + address: contracts.token.address as any, + abi: contracts.token.abi, + functionName: "delegate", + args: [zeroAddress], + }) + } + > + Undelegate + + ); + }; + + useEffect(() => { + if (!isReady) { + fetchData(); + } + + if (didProcessDelegation) { + // Refresh delegation + if (Number(votingPower) > 0) { + setRefetchDelegate({ + address: delegate.address, + prevVotingPowerDelegatee: delegate.votingPower.total, + }); + } + revalidateData(); + } + }, [isReady, fetchData, didProcessDelegation, delegate, votingPower]); + + if (!isReady) { + return ( +
    + {shouldHideAgoraBranding ? : } +
    + ); + } + + return ( +
    +
    + {delegatee ? ( +
    +
    +

    + Remove as your + delegate +

    +

    + This delegate will no longer be able to vote on your behalf. + Your votes will be returned to you. +

    +
    +
    +
    + +
    +

    + Currently delegated to +

    +
    + +
    +
    +
    +
    + +
    +
    +
    +

    + Remove your delegate votes +

    +
    + +
    +
    +
    +
    +
    + ) : ( +
    +

    + You are not currently delegating any votes. +

    +
    + )} + + {renderActionButtons()} +
    +
    + ); +} diff --git a/src/components/Dialogs/UndelegateDialog/revalidateAction.tsx b/src/components/Dialogs/UndelegateDialog/revalidateAction.tsx new file mode 100644 index 000000000..24958a37e --- /dev/null +++ b/src/components/Dialogs/UndelegateDialog/revalidateAction.tsx @@ -0,0 +1,7 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +export async function revalidateData() { + revalidatePath("/delegates"); +} diff --git a/src/components/Proposals/ProposalPage/OPProposalApprovalPage/ApprovalVotesPanel/ApprovalVotesPanel.tsx b/src/components/Proposals/ProposalPage/OPProposalApprovalPage/ApprovalVotesPanel/ApprovalVotesPanel.tsx index 35143cc8d..468c1fe20 100644 --- a/src/components/Proposals/ProposalPage/OPProposalApprovalPage/ApprovalVotesPanel/ApprovalVotesPanel.tsx +++ b/src/components/Proposals/ProposalPage/OPProposalApprovalPage/ApprovalVotesPanel/ApprovalVotesPanel.tsx @@ -12,10 +12,13 @@ import { Vote } from "@/app/api/common/votes/vote"; import { VotingPowerData } from "@/app/api/common/voting-power/votingPower"; import { Delegate } from "@/app/api/common/delegates/delegate"; import { PaginatedResult, PaginationParams } from "@/app/lib/pagination"; +import ProposalVotesFilter from "@/components/Proposals/ProposalPage/OPProposalPage/ProposalVotesCard/ProposalVotesFilter"; +import ProposalNonVoterList from "@/components/Votes/ProposalVotesList/ProposalNonVoterList"; type Props = { proposal: Proposal; initialProposalVotes: PaginatedResult; + nonVoters: any; fetchVotesForProposal: ( proposalId: string, pagination?: PaginationParams @@ -39,10 +42,12 @@ type Props = { export default function ApprovalVotesPanel({ proposal, initialProposalVotes, + nonVoters, fetchVotesForProposal, fetchAllForVoting, fetchUserVotesForProposal, }: Props) { + const [showVoters, setShowVoters] = useState(true); const [activeTab, setActiveTab] = useState(1); const [isPending, startTransition] = useTransition(); function handleTabsChange(index: number) { @@ -79,12 +84,29 @@ export default function ApprovalVotesPanel({ {activeTab === 1 ? ( ) : ( - + <> +
    + { + setShowVoters(value === "Voters"); + }} + /> +
    + {showVoters ? ( + + ) : ( + + )} + )}
    diff --git a/src/components/Proposals/ProposalPage/OPProposalApprovalPage/OPProposalApprovalPage.tsx b/src/components/Proposals/ProposalPage/OPProposalApprovalPage/OPProposalApprovalPage.tsx index 260d5a511..5aadaf6a2 100644 --- a/src/components/Proposals/ProposalPage/OPProposalApprovalPage/OPProposalApprovalPage.tsx +++ b/src/components/Proposals/ProposalPage/OPProposalApprovalPage/OPProposalApprovalPage.tsx @@ -9,6 +9,7 @@ import { fetchVotesForProposal, } from "@/app/api/common/votes/getVotes"; import { PaginationParams } from "@/app/lib/pagination"; +import { fetchVotersWhoHaveNotVotedForProposal } from "@/app/proposals/actions"; async function fetchProposalVotes( proposalId: string, @@ -50,6 +51,7 @@ export default async function OPProposalApprovalPage({ proposal: Proposal; }) { const proposalVotes = await fetchProposalVotes(proposal.id); + const nonVoters = await fetchVotersWhoHaveNotVotedForProposal(proposal.id); return ( // 2 Colum Layout: Description on left w/ transactions and Votes / voting on the right @@ -72,6 +74,7 @@ export default async function OPProposalApprovalPage({ { const [isClicked, setIsClicked] = useState(false); + const [showVoters, setShowVoters] = useState(true); const handleClick = () => { setIsClicked(!isClicked); // var div = document.getElementsByClassName("mobile-web-scroll-div")[0]; @@ -93,11 +98,26 @@ const OptimisticProposalVotesCard = ({ />
    +
    + { + setShowVoters(value === "Voters"); + }} + /> +
    {/* Show the scrolling list of votes for the proposal */} - + {showVoters ? ( + + ) : ( + + )} {/* Show the input for the user to vote on a proposal if allowed */}

    diff --git a/src/components/Proposals/ProposalPage/OPProposalPage/ProposalVotesCard/ProposalVotesCard.tsx b/src/components/Proposals/ProposalPage/OPProposalPage/ProposalVotesCard/ProposalVotesCard.tsx index f5821056b..71ca8816e 100644 --- a/src/components/Proposals/ProposalPage/OPProposalPage/ProposalVotesCard/ProposalVotesCard.tsx +++ b/src/components/Proposals/ProposalPage/OPProposalPage/ProposalVotesCard/ProposalVotesCard.tsx @@ -8,15 +8,20 @@ import { icons } from "@/assets/icons/icons"; import { Proposal } from "@/app/api/common/proposals/proposal"; import { PaginatedResult } from "@/app/lib/pagination"; import { Vote } from "@/app/api/common/votes/vote"; +import ProposalVotesFilter from "./ProposalVotesFilter"; +import ProposalNonVoterList from "@/components/Votes/ProposalVotesList/ProposalNonVoterList"; const ProposalVotesCard = ({ proposal, proposalVotes, + nonVoters, }: { proposal: Proposal; proposalVotes: PaginatedResult; + nonVoters: PaginatedResult; // TODO: add better types }) => { const [isClicked, setIsClicked] = useState(false); + const [showVoters, setShowVoters] = useState(true); const handleClick = () => { setIsClicked(!isClicked); @@ -38,19 +43,33 @@ const ProposalVotesCard = ({ expand

    -
    -
    Proposal votes
    - +
    +
    Proposal votes
    +
    + { + setShowVoters(value === "Voters"); + }} + /> +
    - + {showVoters ? ( + + ) : ( + + )} {/* Show the input for the user to vote on a proposal if allowed */}
    diff --git a/src/components/Proposals/ProposalPage/OPProposalPage/ProposalVotesCard/ProposalVotesFilter.tsx b/src/components/Proposals/ProposalPage/OPProposalPage/ProposalVotesCard/ProposalVotesFilter.tsx new file mode 100644 index 000000000..48deb7a61 --- /dev/null +++ b/src/components/Proposals/ProposalPage/OPProposalPage/ProposalVotesCard/ProposalVotesFilter.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import { Switch } from "@/components/shared/Switch"; + +const voteFilterOptions = { + ["Voters"]: { + sortKey: "voters", + }, + ["Hasn't voted"]: { + sortKey: "nonVoters", + }, +}; + +const ProposalVotesFilter = ({ + initialSelection, + onSelectionChange, +}: { + initialSelection: string; + onSelectionChange: (value: string) => void; +}) => { + const [value, setValue] = useState(initialSelection); + + return ( + key)} + selection={value} + onSelectionChanged={(value: any) => { + setValue(value); + onSelectionChange(value); + }} + /> + ); +}; + +export default ProposalVotesFilter; diff --git a/src/components/Proposals/ProposalPage/OPProposalPage/StandardProposalPage.tsx b/src/components/Proposals/ProposalPage/OPProposalPage/StandardProposalPage.tsx index 64d7c0c79..06fbef5ae 100644 --- a/src/components/Proposals/ProposalPage/OPProposalPage/StandardProposalPage.tsx +++ b/src/components/Proposals/ProposalPage/OPProposalPage/StandardProposalPage.tsx @@ -1,7 +1,10 @@ import ProposalDescription from "../ProposalDescription/ProposalDescription"; import { Proposal } from "@/app/api/common/proposals/proposal"; import StandardProposalDelete from "./StandardProposalDelete"; -import { fetchProposalVotes } from "@/app/proposals/actions"; +import { + fetchProposalVotes, + fetchVotersWhoHaveNotVotedForProposal, +} from "@/app/proposals/actions"; import ProposalVotesCard from "./ProposalVotesCard/ProposalVotesCard"; import Tenant from "@/lib/tenant/tenant"; import { ProposalStateAdmin } from "@/app/proposals/components/ProposalStateAdmin"; @@ -11,11 +14,12 @@ export default async function StandardProposalPage({ }: { proposal: Proposal; }) { - const { contracts, ui } = Tenant.current(); + const { contracts } = Tenant.current(); // TODO: Replace with governor-level check const isAlligator = Boolean(contracts?.alligator); const proposalVotes = await fetchProposalVotes(proposal.id); + const nonVoters = await fetchVotersWhoHaveNotVotedForProposal(proposal.id); return (
    @@ -30,6 +34,7 @@ export default async function StandardProposalPage({
    diff --git a/src/components/Proposals/ProposalStatus/ProposalStatusDetail.tsx b/src/components/Proposals/ProposalStatus/ProposalStatusDetail.tsx index a350ca03e..7b14fc965 100644 --- a/src/components/Proposals/ProposalStatus/ProposalStatusDetail.tsx +++ b/src/components/Proposals/ProposalStatus/ProposalStatusDetail.tsx @@ -1,4 +1,3 @@ -import { HStack } from "@/components/Layout/Stack"; import ProposalTimeStatus from "@/components/Proposals/Proposal/ProposalTimeStatus"; import { type ProposalStatus } from "@/lib/proposalUtils"; import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; @@ -18,11 +17,7 @@ export default function ProposalStatusDetail({ cancelledTransactionHash: string | null; }) { return ( - +
    {proposalStatus === "ACTIVE" && (

    @@ -75,6 +70,6 @@ export default function ProposalStatusDetail({ proposalCancelledTime={proposalCancelledTime} />

    - +
    ); } diff --git a/src/components/Votes/ApprovalProposalVotesList/ApprovalProposalSingleVote.tsx b/src/components/Votes/ApprovalProposalVotesList/ApprovalProposalSingleVote.tsx index 2065dd32e..9f1803156 100644 --- a/src/components/Votes/ApprovalProposalVotesList/ApprovalProposalSingleVote.tsx +++ b/src/components/Votes/ApprovalProposalVotesList/ApprovalProposalSingleVote.tsx @@ -54,7 +54,7 @@ export default function ApprovalProposalSingleVote({ vote }: { vote: Vote }) { className="mb-2 text-xs leading-4" >
    - + {address?.toLowerCase() === voterAddress && (  (you) diff --git a/src/components/Votes/ProposalVotesList/ProposalNonVoterList.tsx b/src/components/Votes/ProposalVotesList/ProposalNonVoterList.tsx new file mode 100644 index 000000000..0fa265f26 --- /dev/null +++ b/src/components/Votes/ProposalVotesList/ProposalNonVoterList.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useRef, useState, useCallback } from "react"; +import { PaginatedResult } from "@/app/lib/pagination"; +import { fetchVotersWhoHaveNotVotedForProposal } from "@/app/proposals/actions"; +import InfiniteScroll from "react-infinite-scroller"; +import { ProposalSingleNonVoter } from "./ProposalSingleNonVoter"; + +const ProposalNonVoterList = ({ + proposalId, + initialNonVoters, +}: { + proposalId: string; + initialNonVoters: PaginatedResult; // TODO: add better types +}) => { + const fetching = useRef(false); + const [pages, setPages] = useState([initialNonVoters]); + const [meta, setMeta] = useState(initialNonVoters.meta); + + const loadMore = useCallback(async () => { + if (!fetching.current && meta.has_next) { + fetching.current = true; + const data = await fetchVotersWhoHaveNotVotedForProposal(proposalId, { + limit: 20, + offset: meta.next_offset, + }); + setPages((prev) => [...prev, { ...data, votes: data.data }]); + setMeta(data.meta); + fetching.current = false; + } + }, [proposalId, meta]); + + const voters = pages.flatMap((page) => page.data); + + return ( +
    + + Loading more voters... +
    + } + element="main" + > +
      + {voters.map((voter) => ( +
    • + +
    • + ))} +
    + +
    + ); +}; + +export default ProposalNonVoterList; diff --git a/src/components/Votes/ProposalVotesList/ProposalSingleNonVoter.tsx b/src/components/Votes/ProposalVotesList/ProposalSingleNonVoter.tsx new file mode 100644 index 000000000..f3003826e --- /dev/null +++ b/src/components/Votes/ProposalVotesList/ProposalSingleNonVoter.tsx @@ -0,0 +1,104 @@ +import { VStack, HStack } from "@/components/Layout/Stack"; +import HumanAddress from "@/components/shared/HumanAddress"; +import TokenAmountDisplay from "@/components/shared/TokenAmountDisplay"; +import ENSAvatar from "@/components/shared/ENSAvatar"; +import { useEnsName, useAccount } from "wagmi"; +import discordIcon from "@/icons/discord.svg"; +import xIcon from "@/icons/x.svg"; +import warpcastIcon from "@/icons/warpcast.svg"; +import Image from "next/image"; +import { toast } from "react-hot-toast"; + +export function ProposalSingleNonVoter({ + voter, +}: { + voter: { + delegate: string; + direct_vp: string; + twitter: string | null; + discord: string | null; + warpcast: string | null; + }; +}) { + const { address: connectedAddress } = useAccount(); + const { data } = useEnsName({ + chainId: 1, + address: voter.delegate as `0x${string}`, + }); + + console.log(voter); + + return ( + + + + + + {voter.delegate === connectedAddress?.toLowerCase() &&

    (you)

    } + {voter.twitter && ( + + )} + {voter.discord && ( + + )} + {voter.warpcast && ( + + )} +
    + + + +
    +
    + ); +} diff --git a/src/components/shared/TokenAmountDisplay.tsx b/src/components/shared/TokenAmountDisplay.tsx index f341d607c..908cb25ba 100644 --- a/src/components/shared/TokenAmountDisplay.tsx +++ b/src/components/shared/TokenAmountDisplay.tsx @@ -1,5 +1,4 @@ import { formatNumber } from "@/lib/utils"; -import { BigNumberish } from "ethers"; import React, { useMemo } from "react"; import Tenant from "@/lib/tenant/tenant"; const { token } = Tenant.current(); @@ -10,7 +9,7 @@ export default function TokenAmountDisplay({ currency = token.symbol, maximumSignificantDigits = 2, }: { - amount: BigNumberish; + amount: string | bigint; decimals?: number; currency?: string; maximumSignificantDigits?: number; diff --git a/src/hooks/useGetDelegatee.ts b/src/hooks/useGetDelegatee.ts new file mode 100644 index 000000000..2b4b3d92d --- /dev/null +++ b/src/hooks/useGetDelegatee.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchDirectDelegatee } from "@/app/delegates/actions"; + +export const DELEGATEE_QK = "delegatee"; + +export const useGetDelegatee = ({ + address, +}: { + address: `0x${string}` | undefined; +}) => { + const { data, isFetching, isFetched } = useQuery({ + enabled: !!address, + queryKey: [DELEGATEE_QK, address], + queryFn: async () => { + const delegatee = await fetchDirectDelegatee(address as `0x${string}`); + return delegatee; + }, + }); + + return { data, isFetching, isFetched }; +}; diff --git a/src/hooks/useVotingStats.tsx b/src/hooks/useVotingStats.tsx new file mode 100644 index 000000000..5e624a00d --- /dev/null +++ b/src/hooks/useVotingStats.tsx @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchVoterStats } from "@/app/delegates/actions"; + +export const useVotingStats = ({ address }: { address: `0x${string}` }) => { + return useQuery({ + queryKey: ["voting-stats", address], + queryFn: () => fetchVoterStats(address), + }); +}; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 837a5c748..4c62c44e9 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -58,6 +58,7 @@ export const delegatesFilterOptions = { value: "Most delegators", }, }; + export const citizensFilterOptions = { mostVotingPower: { value: "Most voting power", diff --git a/src/lib/tenant/configs/ui/uniswap.ts b/src/lib/tenant/configs/ui/uniswap.ts index b08a55af1..23f3d7ccf 100644 --- a/src/lib/tenant/configs/ui/uniswap.ts +++ b/src/lib/tenant/configs/ui/uniswap.ts @@ -9,8 +9,6 @@ import infoPageCard02 from "@/assets/tenant/uniswap_info_2.png"; import infoPageCard03 from "@/assets/tenant/uniswap_info_3.png"; import infoPageCard04 from "@/assets/tenant/uniswap_info_4.png"; import infoPageHero from "@/assets/tenant/uniswap_info_hero.png"; -import { ProposalStage as PrismaProposalStage } from "@prisma/client"; -import { ProposalGatingType, ProposalType } from "@/app/proposals/draft/types"; export const uniswapTenantUIConfig = new TenantUI({ title: "Uniswap Agora", @@ -200,6 +198,10 @@ export const uniswapTenantUIConfig = new TenantUI({ name: "delegates/edit", enabled: true, }, + { + name: "delegates/my-delegates-filter", + enabled: true, + }, { name: "proposals", enabled: true, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0e182ea31..a100033b8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,4 @@ import { type ClassValue, clsx } from "clsx"; -import { BigNumberish, formatUnits } from "ethers"; import { twMerge } from "tailwind-merge"; import { useMemo } from "react"; import Tenant from "./tenant/tenant"; @@ -159,11 +158,31 @@ export function numberToToken(number: number) { } export function formatNumber( - amount: string | BigNumberish, + amount: string | bigint, decimals: number, maximumSignificantDigits = 4 ) { - const standardUnitAmount = Number(formatUnits(amount, decimals)); + let bigIntAmount: bigint; + + if (typeof amount === "string") { + // Handle potential scientific notation + if (amount.includes("e")) { + bigIntAmount = scientificNotationToPrecision(amount); + } else { + bigIntAmount = BigInt(amount); + } + } else { + bigIntAmount = amount; + } + + // Convert to standard unit + const divisor = BigInt(10) ** BigInt(decimals); + const wholePart = bigIntAmount / divisor; + const fractionalPart = bigIntAmount % divisor; + + // Convert to number for formatting + const standardUnitAmount = + Number(wholePart) + Number(fractionalPart) / Number(divisor); const numberFormat = new Intl.NumberFormat("en", { notation: "compact", @@ -179,7 +198,7 @@ export function TokenAmountDisplay({ currency = token.symbol, maximumSignificantDigits = 2, }: { - amount: string | BigNumberish; + amount: string | bigint; decimals?: number; currency?: string; maximumSignificantDigits?: number;