Skip to content

Commit eca09ca

Browse files
committed
chore: added staking countdown functionality
1 parent 5b48fab commit eca09ca

File tree

9 files changed

+278
-88
lines changed

9 files changed

+278
-88
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,18 @@
1-
import { Button } from "common/src/styles";
1+
import { PropsWithChildren } from "react";
22

33
const STAKING_BANNER_TITLE =
44
"🔥 Boost grants, earn rewards, and shape the round!";
55
const STAKING_BANNER_TEXT =
66
"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!";
7-
const STAKING_BUTTON_TEXT = "Stake on this project";
8-
const STAKING_BUTTON_TEXT_MOBILE = "Stake";
97

10-
export const StakingBanner = ({ onClick }: { onClick?: () => void }) => {
8+
export const StakingBanner = ({ children }: PropsWithChildren) => {
119
return (
12-
<div className="bg-[#F2FBF8] rounded-3xl p-6 flex items-center justify-between w-full gap-4">
10+
<div className="bg-[#F2FBF8] rounded-3xl p-6 flex flex-col xl:flex-row items-center justify-between w-full gap-4">
1311
<div className="flex flex-col gap-4 font-sans text-black max-w-[609px]">
1412
<h3 className="text-2xl font-medium">{STAKING_BANNER_TITLE}</h3>
1513
<p className="text-base/[1.75rem] font-normal">{STAKING_BANNER_TEXT}</p>
1614
</div>
17-
<Button
18-
className="text-white bg-[#22635A] max-h-[40px] font-mono"
19-
onClick={onClick}
20-
>
21-
<span className="block lg:hidden whitespace-nowrap">
22-
{STAKING_BUTTON_TEXT_MOBILE}
23-
</span>
24-
<span className="hidden lg:block whitespace-nowrap">
25-
{STAKING_BUTTON_TEXT}
26-
</span>
27-
</Button>
15+
{children}
2816
</div>
2917
);
3018
};

packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBannerAndModal.tsx

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ import { useCallback, useState } from "react";
33
import { StakingBanner } from "./StakingBanner";
44
import { useProjectDetailsParams } from "../../hooks/useProjectDetailsParams";
55
import { useIsStakable } from "./hooks/useIsStakable";
6+
import { useDonationPeriod } from "./hooks/useDonationPeriod";
7+
import { StakingButton } from "./StakingButton";
8+
import { StakingCountDownLabel } from "./StakingCountDownLabel";
9+
10+
const STAKING_APP_URL = "https://staking-hub-mu.vercel.app"; // TODO: from env
11+
12+
const COUNTDOWN_DAYS = 3;
13+
const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
14+
const COUNTDOWN_STARTS_IN_MILLISECONDS = COUNTDOWN_DAYS * DAY_IN_MILLISECONDS;
15+
const COUNTDOWN_LABEL = "Staking begins in";
16+
const COUNTDOWN_LIMIT_MINUTES = 3;
617

718
export const StakingBannerAndModal = () => {
819
const [isOpen, setIsOpen] = useState(false);
@@ -17,8 +28,7 @@ export const StakingBannerAndModal = () => {
1728
? paramApplicationId.split("-")[1]
1829
: paramApplicationId;
1930

20-
const stakingAppUrl = "https://staking-hub-mu.vercel.app"; // TODO: from env
21-
const stakeProjectUrl = `${stakingAppUrl}/#/staking-round/${chainId}/${roundId}?id=${applicationId}`;
31+
const stakeProjectUrl = `${STAKING_APP_URL}/#/staking-round/${chainId}/${roundId}?id=${applicationId}`;
2232

2333
const handleCloseModal = useCallback(() => {
2434
setIsOpen(false);
@@ -38,21 +48,47 @@ export const StakingBannerAndModal = () => {
3848
const isStakable = useIsStakable({
3949
chainId: chainIdNumber,
4050
roundId,
51+
});
52+
53+
const { isDonationPeriod, timeToDonationStart } = useDonationPeriod({
54+
chainId: chainIdNumber,
55+
roundId,
4156
applicationId,
57+
refreshInterval: 60 * 1000, // 1 minute
4258
});
4359

44-
if (!isStakable) {
45-
return null;
60+
const isCountDownToStartPeriod =
61+
timeToDonationStart &&
62+
timeToDonationStart.totalMilliseconds > 0 &&
63+
timeToDonationStart.totalMilliseconds < COUNTDOWN_STARTS_IN_MILLISECONDS;
64+
65+
if (isStakable && isCountDownToStartPeriod) {
66+
return (
67+
<div className="mt-2 mb-6">
68+
<StakingBanner>
69+
<StakingCountDownLabel
70+
timeLeft={timeToDonationStart}
71+
label={COUNTDOWN_LABEL}
72+
limitMinutes={COUNTDOWN_LIMIT_MINUTES}
73+
/>
74+
</StakingBanner>
75+
</div>
76+
);
4677
}
4778

48-
return (
49-
<div className="mt-2 mb-6">
50-
<StakingBanner onClick={handleOpenModal} />
51-
<StakingModal
52-
isOpen={isOpen}
53-
onClose={handleCloseModal}
54-
onStake={handleStake}
55-
/>
56-
</div>
57-
);
79+
if (isStakable && isDonationPeriod) {
80+
return (
81+
<div className="mt-2 mb-6">
82+
<StakingBanner>
83+
<StakingButton onClick={handleOpenModal} />
84+
</StakingBanner>
85+
<StakingModal
86+
isOpen={isOpen}
87+
onClose={handleCloseModal}
88+
onStake={handleStake}
89+
/>
90+
</div>
91+
);
92+
}
93+
return null;
5894
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Button } from "common/src/styles";
2+
3+
const STAKING_BUTTON_TEXT = "Stake on this project";
4+
5+
export const StakingButton = ({ onClick }: { onClick?: () => void }) => {
6+
return (
7+
<Button
8+
className="text-white bg-[#22635A] max-h-[40px] font-mono whitespace-nowrap"
9+
onClick={onClick}
10+
>
11+
{STAKING_BUTTON_TEXT}
12+
</Button>
13+
);
14+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { TimeRemaining } from "./types";
2+
3+
const generateCountDownLabel = ({
4+
days,
5+
hours,
6+
minutes,
7+
limitMinutes,
8+
}: {
9+
days: number;
10+
hours: number;
11+
minutes: number;
12+
limitMinutes: number;
13+
}) => {
14+
if (days > 0) {
15+
const dayText = days === 1 ? "day" : "days";
16+
if (hours === 0) {
17+
return `${days} ${dayText}`;
18+
}
19+
const hourText = hours === 1 ? "hour" : "hours";
20+
return `${days} ${dayText}, ${hours} ${hourText}`;
21+
} else if (hours > 0) {
22+
const hourText = hours === 1 ? "hour" : "hours";
23+
if (minutes === 0) {
24+
return `${hours} ${hourText}`;
25+
}
26+
const minuteText = minutes === 1 ? "minute" : "minutes";
27+
return `${hours} ${hourText}, ${minutes} ${minuteText}`;
28+
} else if (minutes >= limitMinutes && minutes > 0) {
29+
const minuteText = minutes === 1 ? "minute" : "minutes";
30+
return `${minutes} ${minuteText}`;
31+
} else if (minutes > 0) {
32+
return "in a few minutes";
33+
} else {
34+
return "in less than a minute";
35+
}
36+
};
37+
38+
export const StakingCountDownLabel = ({
39+
label = "Staking begins in",
40+
timeLeft,
41+
limitMinutes = 3,
42+
}: {
43+
label?: string;
44+
timeLeft: TimeRemaining;
45+
limitMinutes?: number;
46+
}) => {
47+
if (timeLeft.totalMilliseconds <= 0) {
48+
return null;
49+
}
50+
return (
51+
<div className="py-4 px-8 rounded-2xl text-white bg-[#22635A] font-sans text-lg font-medium flex flex-row xl:flex-col items-center gap-1">
52+
<div className="whitespace-nowrap">{label}</div>
53+
<div className="whitespace-nowrap">
54+
{generateCountDownLabel({ ...timeLeft, limitMinutes })}
55+
</div>
56+
</div>
57+
);
58+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useApplication } from "../../../../../projects/hooks/useApplication";
2+
import { useDataLayer } from "data-layer";
3+
import { useEffect, useMemo, useState } from "react";
4+
import { DonationPeriodResult, TimeRemaining } from "../types";
5+
import { calculateDonationPeriod, isValidStringDate } from "../utils";
6+
7+
interface Params {
8+
chainId: number;
9+
roundId: string;
10+
applicationId: string;
11+
refreshInterval?: number;
12+
}
13+
14+
interface Result {
15+
isDonationPeriod?: boolean;
16+
timeToDonationStart?: TimeRemaining;
17+
timeToDonationEnd?: TimeRemaining;
18+
}
19+
20+
// Returns undefined if the application is not stakable or if the donation period is not valid
21+
export const useDonationPeriod = ({
22+
chainId,
23+
roundId,
24+
applicationId,
25+
refreshInterval = 5 * 60 * 1000,
26+
}: Params): Result => {
27+
const dataLayer = useDataLayer();
28+
const { data: application } = useApplication(
29+
{
30+
chainId,
31+
roundId,
32+
applicationId,
33+
},
34+
dataLayer
35+
);
36+
37+
const hasValidDonationDates = useMemo(() => {
38+
if (!application) return false;
39+
const { donationsStartTime, donationsEndTime } = application?.round ?? {};
40+
return (
41+
isValidStringDate(donationsStartTime) &&
42+
isValidStringDate(donationsEndTime)
43+
);
44+
}, [application]);
45+
46+
const [currentTime, setCurrentTime] = useState(new Date());
47+
48+
useEffect(() => {
49+
const interval = setInterval(() => {
50+
if (hasValidDonationDates) setCurrentTime(new Date());
51+
}, refreshInterval);
52+
53+
return () => clearInterval(interval);
54+
}, [hasValidDonationDates, refreshInterval]);
55+
56+
const { isDonationPeriod, timeToDonationStart, timeToDonationEnd } =
57+
useMemo<DonationPeriodResult>(
58+
() =>
59+
calculateDonationPeriod({
60+
application,
61+
currentTime,
62+
hasValidDonationDates,
63+
}),
64+
[application, currentTime, hasValidDonationDates]
65+
);
66+
return { isDonationPeriod, timeToDonationStart, timeToDonationEnd };
67+
};

packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsDonationPeriod.tsx

Lines changed: 0 additions & 47 deletions
This file was deleted.

packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable.tsx

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useMemo } from "react";
2-
import { useIsDonationPeriod } from "./useIsDonationPeriod";
32

43
// TODO: either from metadata or from env value
54
// ONLY GITCOIN ROUNDS OF GG23
@@ -21,24 +20,15 @@ const STAKABLE_ROUNDS: Array<{ chainId: number; roundId: string }> = [
2120
export const useIsStakable = ({
2221
chainId,
2322
roundId,
24-
applicationId,
2523
}: {
2624
chainId: number;
2725
roundId: string;
28-
applicationId: string;
2926
}) => {
30-
const isDonationPeriod = useIsDonationPeriod({
31-
chainId,
32-
roundId,
33-
applicationId,
34-
});
35-
3627
const isStakable = useMemo(() => {
37-
if (!isDonationPeriod) return false;
3828
return STAKABLE_ROUNDS.some(
3929
(round) => round.chainId === chainId && round.roundId === roundId
4030
);
41-
}, [isDonationPeriod, chainId, roundId]);
31+
}, [chainId, roundId]);
4232

4333
return isStakable;
4434
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export interface TimeRemaining {
2+
days: number;
3+
hours: number;
4+
minutes: number;
5+
seconds: number;
6+
totalMilliseconds: number;
7+
}
8+
9+
export type DonationPeriodResult = {
10+
isDonationPeriod?: boolean;
11+
timeToDonationStart?: TimeRemaining;
12+
timeToDonationEnd?: TimeRemaining;
13+
};

0 commit comments

Comments
 (0)