Skip to content

Feat/staking explorer integration #3802

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

Merged
merged 8 commits into from
Mar 31, 2025
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,4 @@ REACT_APP_GRANT_EXPLORER=https://explorer.gitcoin.co
REACT_APP_COINGECKO_API_KEY=
# ---------------------------

REACT_APP_STAKING_APP=https://staking-hub-mu.vercel.app
REACT_APP_STAKING_APP=https://boost.explorer.gitcoin.co/
24 changes: 24 additions & 0 deletions packages/grant-explorer/src/features/common/DefaultLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,30 @@ export function DefaultLayout({
);
}

export function RoundViewLayout({
showWalletInteraction = true,
children,
infoCard,
}: LayoutProps & { infoCard?: React.ReactNode }) {
return (
<main className={"font-sans min-h-screen text-grey-500"}>
<Navbar showWalletInteraction={showWalletInteraction} />
{infoCard && (
<div className="relative pt-16 w-full items-center">{infoCard}</div>
)}
<div
className={classNames(
"container pt-16 relative z-10 mx-auto px-4 sm:px-6 lg:px-20 max-w-screen-2xl",
infoCard ? "pt-0" : "pt-16"
)}
>
{children}
</div>
<Footer />
</main>
);
}

export function GradientLayout({
showWalletInteraction = true,
showAlloVersionBanner = false,
Expand Down
8 changes: 7 additions & 1 deletion packages/grant-explorer/src/features/common/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type DropdownProps<T> = PropsWithChildren<{
keepOpen?: boolean;
renderItem: (p: { active: boolean; close: () => void } & T) => ReactElement;
headerElement?: (close: () => void) => ReactElement;
labelClassName?: string;
}>;

export function Dropdown<T>({
Expand All @@ -17,14 +18,19 @@ export function Dropdown<T>({
keepOpen,
renderItem,
headerElement,
labelClassName,
}: DropdownProps<T>) {
return (
<Menu as="div" className="md:relative inline-block text-left z-20">
{({ close }) => (
<>
<div>
<Menu.Button className="inline-flex gap-2 items-center">
<span className="text-white py-2">{label}</span>
<span
className={`${labelClassName ? labelClassName : "text-white py-2 "}`}
>
{label}
</span>
<ChevronDownIcon
className="h-5 w-5 text-black"
aria-hidden="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useQuery } from "@tanstack/react-query";
import { RoundWithApplications } from "data-layer";

export const useRoundStakingSummary = (
alloPoolId: string,
chainId: string,
isStakableRound: boolean
) => {
const query = useQuery({
enabled: isStakableRound,
queryKey: ["poolSummary", alloPoolId, chainId],
queryFn: () => getPoolSummary(alloPoolId, Number(chainId)),
});

return {
data: query.data,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: query.refetch,
};
};

export interface RoundWithStakes extends RoundWithApplications {
stakes: Stake[];
totalStakesByAnchorAddress: Record<string, string>;
}

export interface Stake {
chainId: number;
amount: string;
poolId: string;
recipient: string;
sender: string;
blockTimestamp: string;
}

const GET = async (url: string) => {
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Error: ${response.status} - ${errorData.message || "Unknown error"}`
);
}

return response.json();
};

export async function getPoolSummary(
alloPoolId: string,
chainId: number
): Promise<RoundWithStakes> {
try {
const url = `${process.env.REACT_APP_STAKING_HUB_ENDPOINT}/api/pools/${chainId}/${alloPoolId}/summary`;
const response: RoundWithStakes = await GET(url);
return response;
} catch (error) {
console.error("Error fetching pool info and stakes:", error);
throw error;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useMemo } from "react";
import { getAddress } from "viem";
import { RoundWithStakes } from "./useRoundStakingSummary";
import { Application } from "data-layer";

export type ApplicationData = Application & {
totalStaked: number;
numberOfContributors: number;
totalDonations: number;
};

export type SortOption =
| "totalStakedDesc"
| "totalDonationsDesc"
| "totalContributorsDesc"
| "totalStakedAsc"
| "totalDonationsAsc"
| "totalContributorsAsc";

export const useSortApplications = (
poolSummary: RoundWithStakes | undefined,
chainId: string | undefined,
roundId: string | undefined,
sortOption?: SortOption
) => {
return useMemo(() => {
if (!poolSummary || !chainId || !roundId) return [];
const applications =
poolSummary.applications.map((application) => {
application.project.metadata = application.metadata.application.project;
return application;
}) ?? [];

const mappedProjects = applications.map((app) => {
return {
...app,
totalStaked:
Number(
poolSummary.totalStakesByAnchorAddress[
getAddress(app.anchorAddress ?? "")
] ?? 0
) / 1e18,
uniqueDonorsCount: Number(app.uniqueDonorsCount),
numberOfContributors: Number(app.totalDonationsCount),
totalDonations: app.totalAmountDonatedInUsd,
};
});

// Sort based on selected option and update ranks
return sortProjects(mappedProjects, sortOption ?? "totalStakedDesc");
}, [poolSummary, chainId, roundId, sortOption]);
};

export const sortProjects = (
projects: ApplicationData[],
sortOption: SortOption
): ApplicationData[] => {
// First sort the projects
const sortedProjects = [...projects].sort((a, b) => {
switch (sortOption) {
case "totalStakedDesc":
// If one has stakes and the other doesn't, the one with stakes ranks higher
if (a.totalStaked > 0 && b.totalStaked === 0) return -1;
if (b.totalStaked > 0 && a.totalStaked === 0) return 1;
// If both have stakes, compare by stake amount
if (a.totalStaked !== b.totalStaked) {
return b.totalStaked - a.totalStaked;
}
// If stakes are equal, sort by contributor count
return b.uniqueDonorsCount - a.uniqueDonorsCount;

case "totalDonationsDesc":
return b.totalDonations - a.totalDonations;

case "totalContributorsDesc":
return b.uniqueDonorsCount - a.uniqueDonorsCount;

case "totalStakedAsc":
return a.totalStaked - b.totalStaked;

case "totalDonationsAsc":
return a.totalDonations - b.totalDonations;

case "totalContributorsAsc":
return a.uniqueDonorsCount - b.uniqueDonorsCount;

default:
return 0;
}
});

// Then update the ranks based on the new order
return sortedProjects.map((project, index) => ({
...project,
rank: index + 1,
}));
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,54 @@ import { PropsWithChildren } from "react";

const STAKING_BANNER_TITLE =
"🔥 Boost grants, earn rewards, and shape the round!";

const STAKING_BANNER_TITLE_ROUND_VIEW =
"🔥 Boost grants, earn rewards, and shape the round—staking is available only during GG23!";

const STAKING_BANNER_TITLE_ROUND_VIEW_CLAIM_PERIOD =
"🎉 Staking’s a wrap! If you staked GTC, it’s time to claim your rewards!";

const STAKING_BANNER_TITLE_CLAIM_PERIOD =
"Staking’s done—time to claim your rewards! 🎉";

const STAKING_BANNER_TEXT =
"Stake GTC during GG23 to upvote your favorite grants and increase their visibility in the round. The more you stake, the higher they rank—and the more rewards you can claim from the 3% rewards pool!";

export const StakingBanner = ({ children }: PropsWithChildren) => {
const STAKING_BANNER_TEXT_CLAIM_PERIOD =
"Staking is closed! If you staked GTC during GG23, it’s time to claim your rewards from the 3% rewards pool. Thanks for boosting your favorite grants!";

export const StakingBanner = ({
children,
isRoundView,
isClaimPeriod,
}: PropsWithChildren<{ isRoundView?: boolean; isClaimPeriod?: boolean }>) => {
return (
<div className="bg-[#F2FBF8] rounded-3xl p-6 flex flex-col xl:flex-row items-center justify-between w-full gap-4">
<div className="flex flex-col gap-4 font-sans text-black max-w-[609px]">
<h3 className="text-2xl font-medium">{STAKING_BANNER_TITLE}</h3>
<p className="text-base/[1.75rem] font-normal">{STAKING_BANNER_TEXT}</p>
<div
className={`p-6 flex flex-col xl:flex-row items-center w-full gap-6 ${
isRoundView ? "justify-center" : "rounded-3xl justify-between"
} ${isClaimPeriod ? "bg-[#F5F4FE]" : "bg-[#F2FBF8]"}`}
>
<div
className={`flex flex-col gap-4 font-sans text-black ${
isRoundView ? "items-center" : "max-w-[609px]"
}`}
>
<h3 className={`font-medium ${isRoundView ? "text-xl" : "text-2xl"}`}>
{isRoundView
? isClaimPeriod
? STAKING_BANNER_TITLE_ROUND_VIEW_CLAIM_PERIOD
: STAKING_BANNER_TITLE_ROUND_VIEW
: isClaimPeriod
? STAKING_BANNER_TITLE_CLAIM_PERIOD
: STAKING_BANNER_TITLE}
</h3>
{!isRoundView && (
<p className="text-base/[1.75rem] font-normal">
{isClaimPeriod
? STAKING_BANNER_TEXT_CLAIM_PERIOD
: STAKING_BANNER_TEXT}
</p>
)}
</div>
{children}
</div>
Expand Down
Loading