From eca09ca5f61b250d8957c050f131fbec209f5cd4 Mon Sep 17 00:00:00 2001 From: Huss Martinez Date: Thu, 27 Mar 2025 23:07:04 +0700 Subject: [PATCH 1/2] chore: added staking countdown functionality --- .../StakingBannerAndModal/StakingBanner.tsx | 20 ++---- .../StakingBannerAndModal.tsx | 64 +++++++++++++---- .../StakingBannerAndModal/StakingButton.tsx | 14 ++++ .../StakingCountDownLabel.tsx | 58 +++++++++++++++ .../hooks/useDonationPeriod.tsx | 67 +++++++++++++++++ .../hooks/useIsDonationPeriod.tsx | 47 ------------ .../hooks/useIsStakable.tsx | 12 +--- .../components/StakingBannerAndModal/types.ts | 13 ++++ .../components/StakingBannerAndModal/utils.ts | 71 +++++++++++++++++++ 9 files changed, 278 insertions(+), 88 deletions(-) create mode 100644 packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingButton.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingCountDownLabel.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useDonationPeriod.tsx delete mode 100644 packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsDonationPeriod.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/types.ts create mode 100644 packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/utils.ts diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBanner.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBanner.tsx index 2ac4b2019a..da917b5758 100644 --- a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBanner.tsx +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBanner.tsx @@ -1,30 +1,18 @@ -import { Button } from "common/src/styles"; +import { PropsWithChildren } from "react"; const STAKING_BANNER_TITLE = "🔥 Boost grants, earn rewards, and shape the round!"; 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!"; -const STAKING_BUTTON_TEXT = "Stake on this project"; -const STAKING_BUTTON_TEXT_MOBILE = "Stake"; -export const StakingBanner = ({ onClick }: { onClick?: () => void }) => { +export const StakingBanner = ({ children }: PropsWithChildren) => { return ( -
+

{STAKING_BANNER_TITLE}

{STAKING_BANNER_TEXT}

- + {children}
); }; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBannerAndModal.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBannerAndModal.tsx index 1667fa567d..156074f2b4 100644 --- a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBannerAndModal.tsx +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBannerAndModal.tsx @@ -3,6 +3,17 @@ import { useCallback, useState } from "react"; import { StakingBanner } from "./StakingBanner"; import { useProjectDetailsParams } from "../../hooks/useProjectDetailsParams"; import { useIsStakable } from "./hooks/useIsStakable"; +import { useDonationPeriod } from "./hooks/useDonationPeriod"; +import { StakingButton } from "./StakingButton"; +import { StakingCountDownLabel } from "./StakingCountDownLabel"; + +const STAKING_APP_URL = "https://staking-hub-mu.vercel.app"; // TODO: from env + +const COUNTDOWN_DAYS = 3; +const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; +const COUNTDOWN_STARTS_IN_MILLISECONDS = COUNTDOWN_DAYS * DAY_IN_MILLISECONDS; +const COUNTDOWN_LABEL = "Staking begins in"; +const COUNTDOWN_LIMIT_MINUTES = 3; export const StakingBannerAndModal = () => { const [isOpen, setIsOpen] = useState(false); @@ -17,8 +28,7 @@ export const StakingBannerAndModal = () => { ? paramApplicationId.split("-")[1] : paramApplicationId; - const stakingAppUrl = "https://staking-hub-mu.vercel.app"; // TODO: from env - const stakeProjectUrl = `${stakingAppUrl}/#/staking-round/${chainId}/${roundId}?id=${applicationId}`; + const stakeProjectUrl = `${STAKING_APP_URL}/#/staking-round/${chainId}/${roundId}?id=${applicationId}`; const handleCloseModal = useCallback(() => { setIsOpen(false); @@ -38,21 +48,47 @@ export const StakingBannerAndModal = () => { const isStakable = useIsStakable({ chainId: chainIdNumber, roundId, + }); + + const { isDonationPeriod, timeToDonationStart } = useDonationPeriod({ + chainId: chainIdNumber, + roundId, applicationId, + refreshInterval: 60 * 1000, // 1 minute }); - if (!isStakable) { - return null; + const isCountDownToStartPeriod = + timeToDonationStart && + timeToDonationStart.totalMilliseconds > 0 && + timeToDonationStart.totalMilliseconds < COUNTDOWN_STARTS_IN_MILLISECONDS; + + if (isStakable && isCountDownToStartPeriod) { + return ( +
+ + + +
+ ); } - return ( -
- - -
- ); + if (isStakable && isDonationPeriod) { + return ( +
+ + + + +
+ ); + } + return null; }; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingButton.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingButton.tsx new file mode 100644 index 0000000000..2ffc10c55c --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingButton.tsx @@ -0,0 +1,14 @@ +import { Button } from "common/src/styles"; + +const STAKING_BUTTON_TEXT = "Stake on this project"; + +export const StakingButton = ({ onClick }: { onClick?: () => void }) => { + return ( + + ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingCountDownLabel.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingCountDownLabel.tsx new file mode 100644 index 0000000000..de3c31533d --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingCountDownLabel.tsx @@ -0,0 +1,58 @@ +import { TimeRemaining } from "./types"; + +const generateCountDownLabel = ({ + days, + hours, + minutes, + limitMinutes, +}: { + days: number; + hours: number; + minutes: number; + limitMinutes: number; +}) => { + if (days > 0) { + const dayText = days === 1 ? "day" : "days"; + if (hours === 0) { + return `${days} ${dayText}`; + } + const hourText = hours === 1 ? "hour" : "hours"; + return `${days} ${dayText}, ${hours} ${hourText}`; + } else if (hours > 0) { + const hourText = hours === 1 ? "hour" : "hours"; + if (minutes === 0) { + return `${hours} ${hourText}`; + } + const minuteText = minutes === 1 ? "minute" : "minutes"; + return `${hours} ${hourText}, ${minutes} ${minuteText}`; + } else if (minutes >= limitMinutes && minutes > 0) { + const minuteText = minutes === 1 ? "minute" : "minutes"; + return `${minutes} ${minuteText}`; + } else if (minutes > 0) { + return "in a few minutes"; + } else { + return "in less than a minute"; + } +}; + +export const StakingCountDownLabel = ({ + label = "Staking begins in", + timeLeft, + limitMinutes = 3, +}: { + label?: string; + timeLeft: TimeRemaining; + limitMinutes?: number; +}) => { + if (timeLeft.totalMilliseconds <= 0) { + return null; + } + return ( +
+
{label}
+
+ {generateCountDownLabel({ ...timeLeft, limitMinutes })} +
+
+ ); +}; 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..a38d906251 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useDonationPeriod.tsx @@ -0,0 +1,67 @@ +import { useApplication } from "../../../../../projects/hooks/useApplication"; +import { useDataLayer } from "data-layer"; +import { useEffect, useMemo, useState } from "react"; +import { DonationPeriodResult, TimeRemaining } from "../types"; +import { calculateDonationPeriod, isValidStringDate } from "../utils"; + +interface Params { + chainId: number; + roundId: string; + applicationId: 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, + applicationId, + refreshInterval = 5 * 60 * 1000, +}: Params): Result => { + const dataLayer = useDataLayer(); + const { data: application } = useApplication( + { + chainId, + roundId, + applicationId, + }, + dataLayer + ); + + const hasValidDonationDates = useMemo(() => { + if (!application) return false; + const { donationsStartTime, donationsEndTime } = application?.round ?? {}; + return ( + isValidStringDate(donationsStartTime) && + isValidStringDate(donationsEndTime) + ); + }, [application]); + + 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({ + application, + currentTime, + hasValidDonationDates, + }), + [application, 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..efad9ceb5d 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 @@ -21,24 +20,15 @@ const STAKABLE_ROUNDS: Array<{ chainId: number; roundId: string }> = [ 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..304f4a0e95 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/utils.ts @@ -0,0 +1,71 @@ +import { isInfiniteDate } from "common"; +import { DonationPeriodResult, TimeRemaining } from "./types"; +import { Application } from "data-layer"; + +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, + }; +}; + +export const calculateDonationPeriod = ({ + application, + currentTime, + hasValidDonationDates, +}: { + application?: Application; + currentTime: Date; + hasValidDonationDates: boolean; +}): DonationPeriodResult => { + const { donationsStartTime, donationsEndTime } = application?.round ?? {}; + if (!hasValidDonationDates || !donationsStartTime || !donationsEndTime) { + return { + isDonationPeriod: undefined, + timeToDonationStart: undefined, + timeToDonationEnd: undefined, + }; + } + + const donationsStartTimeDate = new Date(donationsStartTime); + const donationsEndTimeDate = new Date(donationsEndTime); + + const isBeforeDonationPeriod = currentTime < donationsStartTimeDate; + const isAfterDonationPeriod = currentTime > donationsEndTimeDate; + const isDonationPeriod = !isBeforeDonationPeriod && !isAfterDonationPeriod; + + if (isAfterDonationPeriod) { + return { + isDonationPeriod: false, + timeToDonationStart: undefined, + timeToDonationEnd: undefined, + }; + } + + const timeToDonationStart = calculateTimeRemaining( + donationsStartTimeDate, + currentTime + ); + + const timeToDonationEnd = calculateTimeRemaining( + donationsEndTimeDate, + currentTime + ); + + return { + isDonationPeriod, + timeToDonationStart, + timeToDonationEnd, + }; +}; From 9ebdcb2ad5060aa7f5f8d28a610b4577cfb814b8 Mon Sep 17 00:00:00 2001 From: Nick Lionis Date: Mon, 31 Mar 2025 13:02:07 +0300 Subject: [PATCH 2/2] feat: staking explorer integration (#3802) * chore: update env.example * feat: new RoundViewLayout * chore: small fix on dropdown component * feat: updated stakingBannerAndModal to support all the different variations * feat: added staking hooks * fix: project card to support a staking banner * feat: staking sort dropdown * feat: integrates staking round view --- .env.example | 2 +- .../src/features/common/DefaultLayout.tsx | 24 ++++ .../src/features/common/Dropdown.tsx | 8 +- .../projects/hooks/useRoundStakingSummary.tsx | 68 ++++++++++ .../projects/hooks/useSortApplications.ts | 97 +++++++++++++++ .../StakingBannerAndModal/StakingBanner.tsx | 49 +++++++- .../StakingBannerAndModal.tsx | 68 +++++++--- .../StakingBannerAndModal/StakingButton.tsx | 21 +++- .../StakingCountDownLabel.tsx | 10 +- .../StakingBannerAndModal/StakingModal.tsx | 13 +- .../hooks/useDonationPeriod.tsx | 35 +++--- .../hooks/useIsStakable.tsx | 11 +- .../components/StakingBannerAndModal/utils.ts | 30 +++-- .../round/ViewRoundPage/ProjectCard.tsx | 59 ++++++++- .../round/ViewRoundPage/ProjectList.tsx | 117 +++++++++++++++--- .../round/ViewRoundPage/RoundPage.tsx | 44 ++++--- .../round/ViewRoundPage/SortDropdown.tsx | 82 ++++++++++++ 17 files changed, 624 insertions(+), 114 deletions(-) create mode 100644 packages/grant-explorer/src/features/projects/hooks/useRoundStakingSummary.tsx create mode 100644 packages/grant-explorer/src/features/projects/hooks/useSortApplications.ts create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/SortDropdown.tsx diff --git a/.env.example b/.env.example index 7303185128..ba05492980 100644 --- a/.env.example +++ b/.env.example @@ -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/ diff --git a/packages/grant-explorer/src/features/common/DefaultLayout.tsx b/packages/grant-explorer/src/features/common/DefaultLayout.tsx index f1ddf69feb..056348ba7f 100644 --- a/packages/grant-explorer/src/features/common/DefaultLayout.tsx +++ b/packages/grant-explorer/src/features/common/DefaultLayout.tsx @@ -24,6 +24,30 @@ export function DefaultLayout({ ); } +export function RoundViewLayout({ + showWalletInteraction = true, + children, + infoCard, +}: LayoutProps & { infoCard?: React.ReactNode }) { + return ( +
+ + {infoCard && ( +
{infoCard}
+ )} +
+ {children} +
+
+
+ ); +} + export function GradientLayout({ showWalletInteraction = true, showAlloVersionBanner = false, diff --git a/packages/grant-explorer/src/features/common/Dropdown.tsx b/packages/grant-explorer/src/features/common/Dropdown.tsx index 96a591ce55..2a110d7b0c 100644 --- a/packages/grant-explorer/src/features/common/Dropdown.tsx +++ b/packages/grant-explorer/src/features/common/Dropdown.tsx @@ -9,6 +9,7 @@ type DropdownProps = PropsWithChildren<{ keepOpen?: boolean; renderItem: (p: { active: boolean; close: () => void } & T) => ReactElement; headerElement?: (close: () => void) => ReactElement; + labelClassName?: string; }>; export function Dropdown({ @@ -17,6 +18,7 @@ export function Dropdown({ keepOpen, renderItem, headerElement, + labelClassName, }: DropdownProps) { return ( @@ -24,7 +26,11 @@ export function Dropdown({ <>
- {label} + + {label} +