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} +
); }; 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..634811a7de 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,8 +3,20 @@ 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"; -export const StakingBannerAndModal = () => { +const STAKING_APP_URL = process.env.REACT_APP_STAKING_APP; + +const COUNTDOWN_LABEL = "Staking begins in"; +const COUNTDOWN_LIMIT_MINUTES = 3; + +export const StakingBannerAndModal = ({ + isRoundView, +}: { + isRoundView?: boolean; +}) => { const [isOpen, setIsOpen] = useState(false); const { @@ -13,12 +25,15 @@ export const StakingBannerAndModal = () => { applicationId: paramApplicationId, } = useProjectDetailsParams(); - const applicationId = paramApplicationId.includes("-") + const applicationId = paramApplicationId?.includes("-") ? 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 stakeRoundUrl = `${STAKING_APP_URL}/#/staking-round/${chainId}/${roundId}`; + + const claimRewardsUrl = `${STAKING_APP_URL}/#/claim-rewards`; const handleCloseModal = useCallback(() => { setIsOpen(false); @@ -33,26 +48,77 @@ export const StakingBannerAndModal = () => { handleCloseModal(); }, [handleCloseModal, stakeProjectUrl]); + const handleStakeRound = useCallback(() => { + window.open(stakeRoundUrl, "_blank"); + handleCloseModal(); + }, [handleCloseModal, stakeRoundUrl]); + + const handleClaimRewards = useCallback(() => { + window.open(claimRewardsUrl, "_blank"); + }, [claimRewardsUrl]); + const chainIdNumber = chainId ? parseInt(chainId, 10) : 0; const isStakable = useIsStakable({ chainId: chainIdNumber, roundId, - applicationId, }); - if (!isStakable) { - return null; + const { isDonationPeriod, timeToDonationStart, timeToDonationEnd } = + useDonationPeriod({ + chainId: chainIdNumber, + roundId, + refreshInterval: 60 * 1000, // 1 minute + }); + + const isCountDownToStartPeriod = + timeToDonationStart && timeToDonationStart.totalMilliseconds > 0; + + const isRoundEnded = + timeToDonationEnd && timeToDonationEnd.totalMilliseconds < 0; + + if (isStakable && isRoundEnded) { + return ( +
+ + + +
+ ); + } + 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..1c4faf22db --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingButton.tsx @@ -0,0 +1,27 @@ +import { Button } from "common/src/styles"; + +const STAKING_BUTTON_TEXT = "Stake on this project"; +const STAKING_BUTTON_TEXT_ROUND_VIEW = "Stake GTC"; +const STAKING_BUTTON_TEXT_CLAIM_PERIOD = "Claim rewards"; +export const StakingButton = ({ + onClick, + isRoundView, + isClaimPeriod, +}: { + onClick?: () => void; + isRoundView?: boolean; + isClaimPeriod?: boolean; +}) => { + 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..8fc09263b7 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingCountDownLabel.tsx @@ -0,0 +1,66 @@ +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, + isRoundView = false, +}: { + label?: string; + timeLeft: TimeRemaining; + limitMinutes?: number; + isRoundView?: boolean; +}) => { + if (timeLeft.totalMilliseconds <= 0) { + return null; + } + return ( +
+
{label}
+
+ {generateCountDownLabel({ ...timeLeft, limitMinutes })} +
+
+ ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingModal.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingModal.tsx index 6fd078efe6..4aa4d1ca04 100644 --- a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingModal.tsx +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingModal.tsx @@ -3,6 +3,7 @@ import { BaseModal } from "../../../../common/BaseModal"; import { ArrowRightIcon } from "@heroicons/react/20/solid"; const TITLE = "You're about to stake GTC on this project"; +const TITLE_ROUND_VIEW = "You're about to stake GTC in this round!"; const DESCRIPTION = "To complete your stake, you’ll be redirected to a new tab. Once you confirm your transaction, your support will be reflected on the round page."; @@ -11,10 +12,10 @@ const CHECK_POINTS = [ "Earn a share of the 3% rewards pool", ]; -const Title = () => ( +const Title = ({ isRoundView }: { isRoundView: boolean }) => (
- {TITLE} + {isRoundView ? TITLE_ROUND_VIEW : TITLE}
{DESCRIPTION} @@ -33,9 +34,9 @@ const CheckPoints = () => (
); -const Content = () => ( +const Content = ({ isRoundView }: { isRoundView: boolean }) => (
- + <Title isRoundView={isRoundView} /> <CheckPoints /> </div> ); @@ -74,15 +75,17 @@ export const StakingModal = ({ isOpen, onClose, onStake, + isRoundView, }: { isOpen: boolean; onClose: () => void; onStake: () => void; + isRoundView: boolean; }) => { return ( <BaseModal isOpen={isOpen} onClose={onClose} size="2xl"> <div className="flex flex-col gap-8"> - <Content /> + <Content isRoundView={isRoundView} /> <ActionButtons onCancel={onClose} onStake={onStake} /> </div> </BaseModal> 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<DonationPeriodResult>( + () => + 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<boolean | undefined>(() => { - 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<React.SetStateAction<boolean>>; 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" > - <CardHeader> + <CardHeader className="relative"> <ProjectBanner bannerImgCid={project.projectMetadata.bannerImg ?? null} classNameOverride={ @@ -63,6 +72,11 @@ export function ProjectCard(props: { } resizeHeight={108} /> + {isStakableRound && + props.totalStaked !== undefined && + isStakingPeriodStarted && ( + <StakedAmountCard totalStaked={props.totalStaked} /> + )} </CardHeader> <CardContent className="px-2 relative"> @@ -131,3 +145,42 @@ export function ProjectCard(props: { </BasicCard> ); } + +const StakedAmountCard = ({ totalStaked }: { totalStaked: number }) => { + return ( + <div className="p-2 bg-white bg-opacity-80 rounded-2xl backdrop-blur-sm inline-flex justify-start items-center gap-2 absolute top-4 right-4"> + <div className="w-5 h-5 relative overflow-hidden"> + <div data-svg-wrapper className="left-[3.33px] top-[2.50px] absolute"> + <svg + width="16" + height="18" + viewBox="0 0 16 18" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8.83337 7.33333V1.5L1.33337 10.6667H7.16671L7.16671 16.5L14.6667 7.33333L8.83337 7.33333Z" + stroke="#7D67EB" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + /> + </svg> + </div> + </div> + <div className="inline-flex flex-col justify-start items-start"> + <div className="self-stretch inline-flex justify-start items-center gap-1"> + <div className="justify-start text-text-primary text-sm font-medium font-mono leading-normal"> + {totalStaked} + </div> + <div className="justify-start text-text-primary text-sm font-medium font-mono leading-normal"> + GTC + </div> + </div> + <div className="self-stretch justify-start text-text-primary text-xs font-normal font-mono leading-[14px]"> + Total staked + </div> + </div> + </div> + ); +}; 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<React.SetStateAction<Project>>; setShowCartNotification: React.Dispatch<React.SetStateAction<boolean>>; }): 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<string, Application> + | Map<string, Application & { totalStaked?: number }> | undefined = useMemo(() => { if (!applications) return; - const map: Map<string, Application> = new Map(); + const map: Map<string, Application & { totalStaked?: number }> = new Map(); applications.forEach((application) => map.set(application.projectId, application) ); @@ -43,16 +112,21 @@ export const ProjectList = (props: { }, [applications]); return ( - <> + <div className="flex flex-col gap-y-8"> + {LeaderboardTitle && !props.isProjectsLoading && !isLoading && ( + <span className="text-[32px]/[39px] font-modern-era-medium"> + {LeaderboardTitle} + </span> + )} <div className="grid gap-x-6 gap-y-12 gap-5 justify-around md:justify-start sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 w-full"> - {props.isProjectsLoading ? ( + {props.isProjectsLoading || isLoading ? ( <> {Array(6) .fill("") .map((item, index) => ( - <BasicCard + <div key={index} - className="relative animate-pulse bg-grey-100" + className="relative animate-pulse bg-grey-100 w-[326px] rounded-3xl h-[370px]" /> ))} </> @@ -61,7 +135,7 @@ export const ProjectList = (props: { {projects.map((project) => { return ( <ProjectCard - key={project.projectRegistryId} + key={project?.projectRegistryId} project={project} roundRoutePath={roundRoutePath} showProjectCardFooter={props.showProjectCardFooter} @@ -75,14 +149,19 @@ export const ProjectList = (props: { setShowCartNotification={props.setShowCartNotification} crowdfundedUSD={ applicationsMapByGrantApplicationId?.get( - project.projectRegistryId + project?.projectRegistryId ?? "" )?.totalAmountDonatedInUsd ?? 0 } uniqueContributorsCount={ applicationsMapByGrantApplicationId?.get( - project.projectRegistryId + project?.projectRegistryId ?? "" )?.uniqueDonorsCount ?? 0 } + totalStaked={ + applicationsMapByGrantApplicationId?.get( + project?.projectRegistryId ?? "" + )?.totalStaked ?? 0 + } /> ); })} @@ -91,6 +170,6 @@ export const ProjectList = (props: { <p>No projects</p> )} </div> - </> + </div> ); }; 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<Project[]>(); const [randomizedProjects, setRandomizedProjects] = useState<Project[]>(); 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 ( <> - <DefaultLayout> + <RoundViewLayout infoCard={<StakingBannerAndModal isRoundView={true} />}> {showCartNotification && renderCartNotification()} {props.isAfterRoundEndDate && ( <div className="relative top-6"> @@ -442,24 +450,32 @@ export function RoundPage(props: { onChange={handleTabChange} /> {selectedTab === 0 && ( - <div className="relative"> - <Search className="absolute h-4 w-4 mt-3 ml-3 " /> - <Input - className="w-full lg:w-64 h-8 rounded-full pl-10 font-mono" - type="text" - placeholder="Search" - value={searchQuery} - onChange={(e: React.ChangeEvent<HTMLInputElement>) => - setSearchQuery(e.target.value) - } - /> + <div className="relative flex items-center gap-2"> + <div className="relative"> + <Search className="absolute h-4 w-4 mt-3 ml-3 " /> + <Input + className="w-full lg:w-64 h-8 rounded-full pl-10 font-mono" + type="text" + placeholder="Search" + value={searchQuery} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => + setSearchQuery(e.target.value) + } + /> + </div> + {isStakableRound && props.isAfterRoundStartDate && ( + <div className="flex gap-2 items-center"> + <div>Sort by</div> + <SortDropdown /> + </div> + )} </div> )} </div> <div>{projectDetailsTabs[selectedTab].content}</div> </div> - </DefaultLayout> + </RoundViewLayout> </> ); } 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<RoundApplicationsSortParams> = {} +): 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 ( + <Dropdown + labelClassName="text-[14px]/[21px] font-normal text-red-500" + label={selected?.label ?? "Most GTC Staked"} + options={SORT_OPTIONS} + renderItem={({ label, orderBy, close }) => ( + <DropdownItem + onClick={(e: React.MouseEvent) => { + e.preventDefault(); + close(); + // Remove any existing query parameters from pathname + const basePath = pathname.split("?")[0]; + window.location.hash = `${basePath}?orderBy=${orderBy}`; + }} + > + {label} + </DropdownItem> + )} + /> + ); +}