- {TITLE}
+ {isRoundView ? TITLE_ROUND_VIEW : TITLE}
{DESCRIPTION}
@@ -33,9 +34,9 @@ const CheckPoints = () => (
);
-const Content = () => (
+const Content = ({ isRoundView }: { isRoundView: boolean }) => (
-
+
);
@@ -74,15 +75,17 @@ export const StakingModal = ({
isOpen,
onClose,
onStake,
+ isRoundView,
}: {
isOpen: boolean;
onClose: () => void;
onStake: () => void;
+ isRoundView: boolean;
}) => {
return (
diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useDonationPeriod.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useDonationPeriod.tsx
new file mode 100644
index 0000000000..369c49894b
--- /dev/null
+++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useDonationPeriod.tsx
@@ -0,0 +1,62 @@
+import { useEffect, useMemo, useState } from "react";
+import { DonationPeriodResult, TimeRemaining } from "../types";
+import { calculateDonationPeriod, isValidStringDate } from "../utils";
+import { useRoundById } from "../../../../../../context/RoundContext";
+
+interface Params {
+ chainId: number;
+ roundId: string;
+ refreshInterval?: number;
+}
+
+interface Result {
+ isDonationPeriod?: boolean;
+ timeToDonationStart?: TimeRemaining;
+ timeToDonationEnd?: TimeRemaining;
+}
+
+// Returns undefined if the application is not stakable or if the donation period is not valid
+export const useDonationPeriod = ({
+ chainId,
+ roundId,
+ refreshInterval = 5 * 60 * 1000,
+}: Params): Result => {
+ const { round } = useRoundById(chainId, roundId);
+
+ const hasValidDonationDates = useMemo(() => {
+ if (!round) return false;
+ const {
+ roundStartTime: donationsStartTime,
+ roundEndTime: donationsEndTime,
+ } = round;
+ return (
+ isValidStringDate(donationsStartTime.toISOString()) &&
+ isValidStringDate(donationsEndTime.toISOString())
+ );
+ }, [round]);
+
+ const [currentTime, setCurrentTime] = useState(new Date());
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ if (hasValidDonationDates) setCurrentTime(new Date());
+ }, refreshInterval);
+
+ return () => clearInterval(interval);
+ }, [hasValidDonationDates, refreshInterval]);
+
+ const { isDonationPeriod, timeToDonationStart, timeToDonationEnd } =
+ useMemo
(
+ () =>
+ calculateDonationPeriod({
+ roundDonationPeriod: {
+ roundStartTime: round?.roundStartTime.toISOString() ?? "",
+ roundEndTime: round?.roundEndTime.toISOString() ?? "",
+ },
+ currentTime,
+ hasValidDonationDates,
+ }),
+ [round, currentTime, hasValidDonationDates]
+ );
+ return { isDonationPeriod, timeToDonationStart, timeToDonationEnd };
+};
diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsDonationPeriod.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsDonationPeriod.tsx
deleted file mode 100644
index 055b6a3723..0000000000
--- a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsDonationPeriod.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { useApplication } from "../../../../../projects/hooks/useApplication";
-import { useDataLayer } from "data-layer";
-import { isInfiniteDate } from "../../../../../api/utils";
-import { useMemo } from "react";
-
-export const useIsDonationPeriod = ({
- chainId,
- roundId,
- applicationId,
-}: {
- chainId: number;
- roundId: string;
- applicationId: string;
-}) => {
- const dataLayer = useDataLayer();
-
- const { data: application } = useApplication(
- {
- chainId,
- roundId,
- applicationId,
- },
- dataLayer
- );
-
- const isDonationPeriod = useMemo(() => {
- if (!application) return undefined;
- const { donationsStartTime, donationsEndTime } = application?.round ?? {};
- if (
- !donationsStartTime ||
- !donationsEndTime ||
- isInfiniteDate(new Date(donationsStartTime)) ||
- isInfiniteDate(new Date(donationsEndTime))
- )
- return false;
-
- const currentTime = new Date();
- const donationsStartTimeDate = new Date("2025-03-02T12:00:00+00:00");
- const donationsEndTimeDate = new Date(donationsEndTime);
- return (
- currentTime >= donationsStartTimeDate &&
- currentTime <= donationsEndTimeDate
- );
- }, [application]);
-
- return isDonationPeriod;
-};
diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable.tsx
index cd50afeb3a..f12e77ebf1 100644
--- a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable.tsx
+++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable.tsx
@@ -1,5 +1,4 @@
import { useMemo } from "react";
-import { useIsDonationPeriod } from "./useIsDonationPeriod";
// TODO: either from metadata or from env value
// ONLY GITCOIN ROUNDS OF GG23
@@ -7,38 +6,22 @@ const STAKABLE_ROUNDS: Array<{ chainId: number; roundId: string }> = [
{ chainId: 42161, roundId: "863" },
{ chainId: 42161, roundId: "865" },
{ chainId: 42161, roundId: "867" },
- // { chainId: 42220, roundId: "27" },
- // { chainId: 42220, roundId: "28" },
- // { chainId: 42220, roundId: "29" },
- // { chainId: 42220, roundId: "30" },
- // { chainId: 42220, roundId: "31" },
- // { chainId: 42220, roundId: "32" },
- // { chainId: 42220, roundId: "33" },
- // { chainId: 42220, roundId: "34" },
- // { chainId: 42220, roundId: "35" },
+ { chainId: 11155111, roundId: "709" },
+ { chainId: 11155111, roundId: "710" },
];
export const useIsStakable = ({
chainId,
roundId,
- applicationId,
}: {
chainId: number;
roundId: string;
- applicationId: string;
}) => {
- const isDonationPeriod = useIsDonationPeriod({
- chainId,
- roundId,
- applicationId,
- });
-
const isStakable = useMemo(() => {
- if (!isDonationPeriod) return false;
return STAKABLE_ROUNDS.some(
(round) => round.chainId === chainId && round.roundId === roundId
);
- }, [isDonationPeriod, chainId, roundId]);
+ }, [chainId, roundId]);
return isStakable;
};
diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/types.ts b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/types.ts
new file mode 100644
index 0000000000..b486578a45
--- /dev/null
+++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/types.ts
@@ -0,0 +1,13 @@
+export interface TimeRemaining {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+ totalMilliseconds: number;
+}
+
+export type DonationPeriodResult = {
+ isDonationPeriod?: boolean;
+ timeToDonationStart?: TimeRemaining;
+ timeToDonationEnd?: TimeRemaining;
+};
diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/utils.ts b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/utils.ts
new file mode 100644
index 0000000000..7e0ef8ff47
--- /dev/null
+++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/utils.ts
@@ -0,0 +1,75 @@
+import { isInfiniteDate } from "common";
+import { DonationPeriodResult, TimeRemaining } from "./types";
+
+export const isValidStringDate = (date?: string): boolean => {
+ return !!date && !isInfiniteDate(new Date(date));
+};
+
+export const calculateTimeRemaining = (
+ targetDate: Date,
+ currentTime: Date
+): TimeRemaining => {
+ const difference = targetDate.getTime() - currentTime.getTime();
+ return {
+ days: Math.floor(difference / (1000 * 60 * 60 * 24)),
+ hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
+ minutes: Math.floor((difference / 1000 / 60) % 60),
+ seconds: Math.floor((difference / 1000) % 60),
+ totalMilliseconds: difference,
+ };
+};
+
+interface RoundDonationPeriod {
+ roundStartTime: string;
+ roundEndTime: string;
+}
+
+export const calculateDonationPeriod = ({
+ roundDonationPeriod,
+ currentTime,
+ hasValidDonationDates,
+}: {
+ roundDonationPeriod?: RoundDonationPeriod;
+ currentTime: Date;
+ hasValidDonationDates: boolean;
+}): DonationPeriodResult => {
+ const { roundStartTime, roundEndTime } = roundDonationPeriod ?? {};
+ if (!hasValidDonationDates || !roundStartTime || !roundEndTime) {
+ return {
+ isDonationPeriod: undefined,
+ timeToDonationStart: undefined,
+ timeToDonationEnd: undefined,
+ };
+ }
+
+ const donationsStartTimeDate = new Date(roundStartTime);
+ const donationsEndTimeDate = new Date(roundEndTime);
+
+ const isBeforeDonationPeriod = currentTime < donationsStartTimeDate;
+ const isAfterDonationPeriod = currentTime > donationsEndTimeDate;
+ const isDonationPeriod = !isBeforeDonationPeriod && !isAfterDonationPeriod;
+
+ const timeToDonationEnd = calculateTimeRemaining(
+ donationsEndTimeDate,
+ currentTime
+ );
+
+ if (isAfterDonationPeriod) {
+ return {
+ isDonationPeriod: false,
+ timeToDonationStart: undefined,
+ timeToDonationEnd,
+ };
+ }
+
+ const timeToDonationStart = calculateTimeRemaining(
+ donationsStartTimeDate,
+ currentTime
+ );
+
+ return {
+ isDonationPeriod,
+ timeToDonationStart,
+ timeToDonationEnd,
+ };
+};
diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectCard.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectCard.tsx
index 8d94cc6e1c..6d0adef240 100644
--- a/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectCard.tsx
+++ b/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectCard.tsx
@@ -15,6 +15,7 @@ import {
import { useCartStorage } from "../../../store";
import { CartButton } from "./CartButton";
+import { useIsStakable } from "../ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable";
export function ProjectCard(props: {
project: Project;
@@ -28,12 +29,17 @@ export function ProjectCard(props: {
setShowCartNotification: React.Dispatch>;
crowdfundedUSD: number;
uniqueContributorsCount: number;
+ totalStaked?: number;
}) {
const { project, roundRoutePath } = props;
- const projectRecipient =
- project.recipient.slice(0, 5) + "..." + project.recipient.slice(-4);
const { projects, add, remove } = useCartStorage();
+ const isStakableRound = useIsStakable({
+ chainId: Number(props.chainId),
+ roundId: props.roundId,
+ });
+
+ const isStakingPeriodStarted = props.showProjectCardFooter;
const isAlreadyInCart = projects.some(
(cartProject) =>
@@ -41,6 +47,9 @@ export function ProjectCard(props: {
cartProject.grantApplicationId === project.grantApplicationId &&
cartProject.roundId === props.roundId
);
+ if (!project) return null;
+ const projectRecipient =
+ project.recipient.slice(0, 5) + "..." + project.recipient.slice(-4);
const cartProject = project as CartProject;
cartProject.roundId = props.roundId;
@@ -55,7 +64,7 @@ export function ProjectCard(props: {
to={`${roundRoutePath}/${project.grantApplicationId}`}
data-testid="project-detail-link"
>
-
+
+ {isStakableRound &&
+ props.totalStaked !== undefined &&
+ isStakingPeriodStarted && (
+
+ )}
@@ -131,3 +145,42 @@ export function ProjectCard(props: {
);
}
+
+const StakedAmountCard = ({ totalStaked }: { totalStaked: number }) => {
+ return (
+
+
+
+
+
+ {totalStaked}
+
+
+ GTC
+
+
+
+ Total staked
+
+
+
+ );
+};
diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectList.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectList.tsx
index 78ee3c3a69..1bab2907d6 100644
--- a/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectList.tsx
+++ b/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectList.tsx
@@ -1,12 +1,16 @@
import { useMemo } from "react";
import { Project, Round } from "../../api/types";
-
-import { BasicCard } from "../../common/styles";
-
import { Application, useDataLayer } from "data-layer";
import { useRoundApprovedApplications } from "../../projects/hooks/useRoundApplications";
+import { useRoundStakingSummary } from "../../projects/hooks/useRoundStakingSummary";
+import {
+ SortOption,
+ useSortApplications,
+} from "../../projects/hooks/useSortApplications";
import { ProjectCard } from "./ProjectCard";
+import { useSearchParams } from "react-router-dom";
+import { useIsStakable } from "../ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable";
export const ProjectList = (props: {
projects?: Project[];
@@ -20,22 +24,87 @@ export const ProjectList = (props: {
setCurrentProjectAddedToCart: React.Dispatch>;
setShowCartNotification: React.Dispatch>;
}): JSX.Element => {
- const { projects, roundRoutePath, chainId, roundId } = props;
+ const { projects: _projects, roundRoutePath, chainId, roundId } = props;
const dataLayer = useDataLayer();
+ const [searchParams] = useSearchParams();
+ const sortOption = searchParams.get("orderBy");
+ const isStakableRound = useIsStakable({
+ chainId,
+ roundId,
+ });
- const { data: applications } = useRoundApprovedApplications(
- {
- chainId,
- roundId,
- },
+ enum SortOptionEnum {
+ TOTAL_STAKED_DESC = "totalStakedDesc",
+ TOTAL_DONATIONS_DESC = "totalDonationsDesc",
+ TOTAL_CONTRIBUTORS_DESC = "totalContributorsDesc",
+ TOTAL_STAKED_ASC = "totalStakedAsc",
+ TOTAL_CONTRIBUTORS_ASC = "totalContributorsAsc",
+ TOTAL_DONATIONS_ASC = "totalDonationsAsc",
+ }
+ const params = isStakableRound
+ ? {}
+ : {
+ chainId,
+ roundId,
+ };
+
+ const { data: _applications } = useRoundApprovedApplications(
+ params,
dataLayer
);
+ const { data: poolSummary, isLoading } = useRoundStakingSummary(
+ roundId,
+ chainId.toString(),
+ isStakableRound
+ );
+
+ const _stakedApplications = useSortApplications(
+ poolSummary,
+ chainId.toString(),
+ roundId,
+ SortOptionEnum[sortOption as keyof typeof SortOptionEnum] as SortOption
+ );
+
+ const applications = useMemo(() => {
+ return _applications?.length ? _applications : (_stakedApplications ?? []);
+ }, [_stakedApplications, _applications]);
+
+ const isDonationPeriodStarted = props.showProjectCardFooter;
+
+ const LeaderboardTitle = useMemo(() => {
+ return sortOption === "TOTAL_STAKED_DESC"
+ ? "Leaderboard - Most GTC Staked"
+ : sortOption === "TOTAL_DONATIONS_DESC"
+ ? "Leaderboard - Most Donations"
+ : sortOption === "TOTAL_CONTRIBUTORS_DESC"
+ ? "Leaderboard - Most Contributors"
+ : sortOption === "TOTAL_STAKED_ASC"
+ ? "Leaderboard - Least GTC Staked"
+ : sortOption === "TOTAL_CONTRIBUTORS_ASC"
+ ? "Leaderboard - Least Contributors"
+ : sortOption === "TOTAL_DONATIONS_ASC"
+ ? "Leaderboard - Least Donations"
+ : isStakableRound && isDonationPeriodStarted
+ ? "Leaderboard - Most GTC Staked"
+ : "";
+ }, [sortOption, isStakableRound, isDonationPeriodStarted]);
+
+ const projects = useMemo(() => {
+ return (applications.map((application) => {
+ return _projects?.find(
+ (project) =>
+ project.anchorAddress?.toLowerCase() ===
+ application.anchorAddress?.toLowerCase()
+ );
+ }) ?? []) as Project[];
+ }, [applications, _projects]);
+
const applicationsMapByGrantApplicationId:
- | Map
+ | Map
| undefined = useMemo(() => {
if (!applications) return;
- const map: Map = new Map();
+ const map: Map = new Map();
applications.forEach((application) =>
map.set(application.projectId, application)
);
@@ -43,16 +112,21 @@ export const ProjectList = (props: {
}, [applications]);
return (
- <>
+
+ {LeaderboardTitle && !props.isProjectsLoading && !isLoading && (
+
+ {LeaderboardTitle}
+
+ )}
- {props.isProjectsLoading ? (
+ {props.isProjectsLoading || isLoading ? (
<>
{Array(6)
.fill("")
.map((item, index) => (
-
))}
>
@@ -61,7 +135,7 @@ export const ProjectList = (props: {
{projects.map((project) => {
return (
);
})}
@@ -91,6 +170,6 @@ export const ProjectList = (props: {
No projects
)}
- >
+
);
};
diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/RoundPage.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/RoundPage.tsx
index a9ff035cc2..a82a9f1d63 100644
--- a/packages/grant-explorer/src/features/round/ViewRoundPage/RoundPage.tsx
+++ b/packages/grant-explorer/src/features/round/ViewRoundPage/RoundPage.tsx
@@ -26,7 +26,7 @@ const builderURL = process.env.REACT_APP_BUILDER_URL;
import CartNotification from "../../common/CartNotification";
import { useAccount, useToken } from "wagmi";
import { getAddress } from "viem";
-import { DefaultLayout } from "../../common/DefaultLayout";
+import { RoundViewLayout } from "../../common/DefaultLayout";
import { getUnixTime } from "date-fns";
import { PresentationChartBarIcon } from "@heroicons/react/24/outline";
@@ -36,7 +36,10 @@ import { ProjectList } from "./ProjectList";
import { RoundStatsTabContent } from "./RoundStatsTabContent";
import { RoundTabs } from "./RoundTabs";
import { getAlloVersion } from "common/src/config";
+import { StakingBannerAndModal } from "../ViewProjectDetails/components/StakingBannerAndModal";
+import { useIsStakable } from "../ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable";
+import { SortDropdown } from "./SortDropdown";
const alloVersion = getAlloVersion();
export function RoundPage(props: {
@@ -54,6 +57,11 @@ export function RoundPage(props: {
const [projects, setProjects] = useState();
const [randomizedProjects, setRandomizedProjects] = useState();
const { address: walletAddress } = useAccount();
+
+ const isStakableRound = useIsStakable({
+ chainId: Number(chainId),
+ roundId,
+ });
const isSybilDefenseEnabled =
round?.roundMetadata?.quadraticFundingConfig?.sybilDefense === true ||
round?.roundMetadata?.quadraticFundingConfig?.sybilDefense !== "none";
@@ -254,7 +262,7 @@ export function RoundPage(props: {
return (
<>
-
+ }>
{showCartNotification && renderCartNotification()}
{props.isAfterRoundEndDate && (
@@ -442,24 +450,32 @@ export function RoundPage(props: {
onChange={handleTabChange}
/>
{selectedTab === 0 && (
-
-
-
) =>
- setSearchQuery(e.target.value)
- }
- />
+
+
+
+ ) =>
+ setSearchQuery(e.target.value)
+ }
+ />
+
+ {isStakableRound && props.isAfterRoundStartDate && (
+
+ )}
)}
{projectDetailsTabs[selectedTab].content}
-
+
>
);
}
diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/SortDropdown.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/SortDropdown.tsx
new file mode 100644
index 0000000000..bcd7a0a314
--- /dev/null
+++ b/packages/grant-explorer/src/features/round/ViewRoundPage/SortDropdown.tsx
@@ -0,0 +1,82 @@
+import { useSearchParams } from "react-router-dom";
+import { Dropdown, DropdownItem } from "../../common/Dropdown";
+
+type SortOption =
+ | "TOTAL_STAKED_DESC"
+ | "TOTAL_CONTRIBUTORS_DESC"
+ | "TOTAL_DONATIONS_DESC"
+ | "TOTAL_STAKED_ASC"
+ | "TOTAL_CONTRIBUTORS_ASC"
+ | "TOTAL_DONATIONS_ASC";
+
+export type RoundApplicationsSortParams = {
+ orderBy: SortOption;
+};
+
+export const toQueryString = (
+ filterParams: Partial = {}
+): string => new URLSearchParams(filterParams).toString();
+
+interface RoundApplicationsSortOption {
+ label: string;
+ orderBy: SortOption;
+}
+
+export const SORT_OPTIONS: RoundApplicationsSortOption[] = [
+ {
+ label: "Most GTC Staked",
+ orderBy: "TOTAL_STAKED_DESC",
+ },
+ {
+ label: "Most contributors",
+ orderBy: "TOTAL_CONTRIBUTORS_DESC",
+ },
+ {
+ label: "Most donations",
+ orderBy: "TOTAL_DONATIONS_DESC",
+ },
+ {
+ label: "Least GTC Staked",
+ orderBy: "TOTAL_STAKED_ASC",
+ },
+ {
+ label: "Least contributors",
+ orderBy: "TOTAL_CONTRIBUTORS_ASC",
+ },
+ {
+ label: "Least donations",
+ orderBy: "TOTAL_DONATIONS_ASC",
+ },
+];
+
+const getSortOptionFromUrlParams = (params: URLSearchParams) => {
+ const orderBy = params.get("orderBy");
+ return SORT_OPTIONS.find((option) => option.orderBy === orderBy);
+};
+
+export function SortDropdown() {
+ const [params] = useSearchParams();
+ const selected = getSortOptionFromUrlParams(params);
+ const pathname = window.location.hash.substring(1);
+
+ return (
+ (
+ {
+ e.preventDefault();
+ close();
+ // Remove any existing query parameters from pathname
+ const basePath = pathname.split("?")[0];
+ window.location.hash = `${basePath}?orderBy=${orderBy}`;
+ }}
+ >
+ {label}
+
+ )}
+ />
+ );
+}