diff --git a/packages/grant-explorer/src/features/api/utils.ts b/packages/grant-explorer/src/features/api/utils.ts index 3869ada4a..59f2bd853 100644 --- a/packages/grant-explorer/src/features/api/utils.ts +++ b/packages/grant-explorer/src/features/api/utils.ts @@ -77,25 +77,53 @@ export const pinToIPFS = (obj: IPFSObject) => { } }; -export const getDaysLeft = (fromNowToTimestampStr: string) => { +export interface TimeLeft { + days?: number; + hours?: number; + minutes?: number; + seconds?: number; +} + +export const getTimeLeft = (fromNowToTimestampStr: string): TimeLeft => { const targetTimestamp = Number(fromNowToTimestampStr); // Some timestamps are returned as overflowed (1.15e+77) // We parse these into undefined to show as "No end date" rather than make the date diff calculation if (targetTimestamp > Number.MAX_SAFE_INTEGER) { - return undefined; + return {}; } // TODO replace with differenceInCalendarDays from 'date-fns' const currentTimestampInSeconds = Math.floor(Date.now() / 1000); // current timestamp in seconds const secondsPerDay = 60 * 60 * 24; // number of seconds per day + const secondsPerHour = 60 * 60; // number of seconds per day + const secondsPerMinute = 60; const differenceInSeconds = targetTimestamp - currentTimestampInSeconds; - const differenceInDays = Math.floor(differenceInSeconds / secondsPerDay); - return differenceInDays; + const days = Math.floor(differenceInSeconds / secondsPerDay); + const hours = Math.floor(differenceInSeconds / secondsPerHour) % 24; // % 24 to substract total days + const minutes = Math.floor(differenceInSeconds / secondsPerMinute) % 60; // % 60 to substract total hours + const seconds = Math.floor(differenceInSeconds) % 60; // % 60 to substract total minutes + + return { days, hours, minutes, seconds }; +}; + +export const parseTimeLeftString = (timeLeft: TimeLeft): string => { + const { days = 0, hours = 0, minutes = 0 } = timeLeft; + + const daysString = days > 0 ? `${days} ${days === 1 ? "day" : "days"}, ` : ""; + const hoursString = + hours > 0 ? `${hours} ${hours === 1 ? "hour" : "hours"}, ` : ""; + const minutesString = + minutes > 0 ? `${minutes} ${minutes === 1 ? "minute" : "minutes"}` : ""; + + return `${daysString}${hoursString}${minutesString}`; }; +export const getDaysLeft = (fromNowToTimestampStr: string) => + getTimeLeft(fromNowToTimestampStr).days; + export const isDirectRound = (round: Round) => // @ts-expect-error support old rounds round.payoutStrategy.strategyName === ROUND_PAYOUT_DIRECT_OLD || diff --git a/packages/grant-explorer/src/features/common/styles.tsx b/packages/grant-explorer/src/features/common/styles.tsx index 192a98deb..e6d427ce8 100644 --- a/packages/grant-explorer/src/features/common/styles.tsx +++ b/packages/grant-explorer/src/features/common/styles.tsx @@ -71,6 +71,7 @@ const colorMap = { grey: "bg-grey-100", yellow: "bg-yellow-100", orange: "bg-orange-100", + rainbow: "bg-rainbow-gradient", } as const; const roundedMap = { diff --git a/packages/grant-explorer/src/features/round/ApplicationsCountdownBanner.tsx b/packages/grant-explorer/src/features/round/ApplicationsCountdownBanner.tsx new file mode 100644 index 000000000..49f7d249d --- /dev/null +++ b/packages/grant-explorer/src/features/round/ApplicationsCountdownBanner.tsx @@ -0,0 +1,95 @@ +import { Button } from "common/src/styles"; +import { TimeLeft, getTimeLeft, parseTimeLeftString } from "../api/utils"; + +type ApplicationPeriodStatus = + | "pre-application" + | "post-application" + | "during-application"; + +const generateCountdownString = ( + targetDate: Date | undefined +): string | undefined => { + if (!targetDate) return undefined; + + const targetDateString = Math.round(targetDate.getTime() / 1000).toString(); + const timeLeft: TimeLeft = getTimeLeft(targetDateString); + + const timeLeftString: string = parseTimeLeftString(timeLeft); + + return timeLeftString; +}; + +const generateBannerString = ( + status: ApplicationPeriodStatus, + targetDate: Date | undefined +): string => { + switch (status) { + case "pre-application": + return `Applications open in ${generateCountdownString(targetDate)}!`; + case "during-application": + return `Applications close in ${generateCountdownString(targetDate)}!`; + case "post-application": + return `Applications are closed`; + default: + throw new Error("Unknown ApplicationPeriodStatus"); + } +}; + +function ApplicationsCountdownBanner(props: { + startDate: Date; + endDate: Date; + applicationURL: string; +}) { + const { startDate, endDate, applicationURL } = props; + + let targetDate: Date | undefined = undefined; + let status: ApplicationPeriodStatus = "post-application"; + + const currentTime = new Date(); + + const isBeforeApplicationPeriod = currentTime < startDate; + const isDuringApplicationPeriod = + currentTime >= startDate && currentTime < endDate; + + if (isDuringApplicationPeriod) { + targetDate = endDate; + status = "during-application"; + } else if (isBeforeApplicationPeriod) { + targetDate = startDate; + status = "pre-application"; + } + + const bannerString = generateBannerString(status, targetDate); + + return ( +
+

{bannerString}

+ +
+ ); +} + +const ApplyButton = (props: { + status: ApplicationPeriodStatus; + applicationURL: string; + testid?: string; +}) => { + const { status, applicationURL } = props; + + return ( + + ); +}; + +export default ApplicationsCountdownBanner; diff --git a/packages/grant-explorer/src/features/round/RoundStartCountdownBadge.tsx b/packages/grant-explorer/src/features/round/RoundStartCountdownBadge.tsx new file mode 100644 index 000000000..214279e92 --- /dev/null +++ b/packages/grant-explorer/src/features/round/RoundStartCountdownBadge.tsx @@ -0,0 +1,26 @@ +import { TimeLeft, getTimeLeft, parseTimeLeftString } from "../api/utils"; +import { Badge } from "../common/styles"; + +function RoundStartCountdownBadge(props: { targetDate: Date }) { + const { targetDate } = props; + + const targetDateString = Math.round(targetDate.getTime() / 1000).toString(); + + const timeLeft: TimeLeft = getTimeLeft(targetDateString); + const timeLeftString: string = parseTimeLeftString(timeLeft); + + const badgeString = `Donations start in ${timeLeftString}`; + + return ( + + {badgeString} + + ); +} + +export default RoundStartCountdownBadge; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx index 50ec06f9b..2da94b784 100644 --- a/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx @@ -139,6 +139,12 @@ export default function ViewProjectDetails() { ? false : round && round.roundEndTime <= currentTime); + const isBeforeRoundStartDate = + round && + (isInfiniteDate(round.roundStartTime) + ? false + : round && currentTime < round.roundStartTime); + const alloVersion = getAlloVersion(); useEffect(() => { @@ -154,7 +160,8 @@ export default function ViewProjectDetails() { const disableAddToCartButton = (alloVersion === "allo-v2" && roundId.startsWith("0x")) || - isAfterRoundEndDate; + isAfterRoundEndDate || + isBeforeRoundStartDate; const { projects, add, remove } = useCartStorage(); const isAlreadyInCart = projects.some( diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage.tsx index 0191729b2..af52f9865 100644 --- a/packages/grant-explorer/src/features/round/ViewRoundPage.tsx +++ b/packages/grant-explorer/src/features/round/ViewRoundPage.tsx @@ -14,15 +14,13 @@ import { getLocalTime, formatLocalDateAsISOString, renderToPlainText, - truncateDescription, useTokenPrice, TToken, getTokensByChainId, - getTokens, stringToBlobUrl, getChainById, } from "common"; -import { Button, Input } from "common/src/styles"; +import { Input } from "common/src/styles"; import AlloV1 from "common/src/icons/AlloV1"; import AlloV2 from "common/src/icons/AlloV2"; @@ -33,12 +31,10 @@ import { ReactComponent as WarpcastIcon } from "../../assets/warpcast-logo.svg"; import { ReactComponent as TwitterBlueIcon } from "../../assets/x-logo.svg"; import { useRoundById } from "../../context/RoundContext"; -import { CartProject, Project, Requirement, Round } from "../api/types"; +import { CartProject, Project, Round } from "../api/types"; import { getDaysLeft, isDirectRound, isInfiniteDate } from "../api/utils"; import { PassportWidget } from "../common/PassportWidget"; -import Footer from "common/src/components/Footer"; -import Navbar from "../common/Navbar"; import NotFoundPage from "../common/NotFoundPage"; import { ProjectBanner } from "../common/ProjectBanner"; import RoundEndedBanner from "../common/RoundEndedBanner"; @@ -72,6 +68,8 @@ import { } from "@heroicons/react/24/outline"; import { Box, Tab, Tabs } from "@chakra-ui/react"; import GenericModal from "../common/GenericModal"; +import RoundStartCountdownBadge from "./RoundStartCountdownBadge"; +import ApplicationsCountdownBanner from "./ApplicationsCountdownBanner"; export default function ViewRound() { datadogLogs.logger.info("====> Route: /round/:chainId/:roundId"); @@ -132,25 +130,15 @@ export default function ViewRound() { ) : ( <> {round && chainId && roundId ? ( - <> - {isBeforeRoundStartDate && ( - - )} - - {isAfterRoundStartDate && ( - - )} - + ) : ( )} @@ -181,41 +169,14 @@ export function AlloVersionBanner({ roundId }: { roundId: string }) { ); } -function BeforeRoundStart(props: { - round: Round; - chainId: string; - roundId: string; -}) { - const { round, chainId, roundId } = props; - - return ( - <> - -
-
- ( -
  • {req.requirement}
  • - )} - /> -
    -
    -
    -
    -
    - - ); -} - const alloVersion = getAlloVersion(); -function AfterRoundStart(props: { +function RoundPage(props: { round: Round; chainId: number; roundId: string; + isBeforeRoundStartDate?: boolean; + isAfterRoundStartDate?: boolean; isBeforeRoundEndDate?: boolean; isAfterRoundEndDate?: boolean; }) { @@ -239,6 +200,9 @@ function AfterRoundStart(props: { (alloVersion === "allo-v2" && roundId.startsWith("0x")) || props.isAfterRoundEndDate; + const showProjectCardFooter = + !isDirectRound(round) && props.isAfterRoundStartDate; + useEffect(() => { if (showCartNotification) { setTimeout(() => { @@ -369,6 +333,7 @@ function AfterRoundStart(props: { } {!isAlloV1 && } +

    {round.roundMetadata?.name}

    - {!props.isAfterRoundEndDate ? ( + {props.isBeforeRoundStartDate ? ( + + ) : !props.isAfterRoundEndDate ? ( )}
    + -
    -

    +

    + {isBeforeApplicationEndDate && ( +

    + Apply + + + + + {formatLocalDateAsISOString( + round.applicationsStartTime + )} + + {getLocalTime(round.applicationsStartTime)} + + - + + {!isInfiniteDate(roundEnd) ? ( + <> + + {formatLocalDateAsISOString( + round.applicationsEndTime + )} + + + {getLocalTime(roundEnd)} + + ) : ( + No End Date + )} + + +

    + )} +

    Donate @@ -527,7 +536,10 @@ function AfterRoundStart(props: {

    {!isDirectRound(round) && ( -
    +

    {round.roundMetadata?.quadraticFundingConfig?.matchingFundsAvailable.toLocaleString()}   @@ -541,36 +553,42 @@ function AfterRoundStart(props: {

    {round.roundMetadata?.eligibility?.description}

    - - {isDirectRound(round) && isBeforeApplicationEndDate && ( - - )}
    -
    - - {selectedTab === 0 && ( -
    - - ) => - setSearchQuery(e.target.value) - } - /> -
    +
    + {isBeforeApplicationEndDate && ( + )} -
    -
    {projectDetailsTabs[selectedTab].content}
    +
    + + {selectedTab === 0 && ( +
    + + ) => + setSearchQuery(e.target.value) + } + /> +
    + )} +
    + +
    {projectDetailsTabs[selectedTab].content}
    +
    ); @@ -623,6 +641,7 @@ function RoundTabs(props: { const ProjectList = (props: { projects?: Project[]; roundRoutePath: string; + showProjectCardFooter?: boolean; isBeforeRoundEndDate?: boolean; roundId: string; round: Round; @@ -675,6 +694,7 @@ const ProjectList = (props: { key={project.projectRegistryId} project={project} roundRoutePath={roundRoutePath} + showProjectCardFooter={props.showProjectCardFooter} isBeforeRoundEndDate={props.isBeforeRoundEndDate} roundId={props.roundId} round={props.round} @@ -708,6 +728,7 @@ const ProjectList = (props: { function ProjectCard(props: { project: Project; roundRoutePath: string; + showProjectCardFooter?: boolean; isBeforeRoundEndDate?: boolean; roundId: string; round: Round; @@ -717,7 +738,7 @@ function ProjectCard(props: { crowdfundedUSD: number; uniqueContributorsCount: number; }) { - const { project, roundRoutePath, round } = props; + const { project, roundRoutePath } = props; const projectRecipient = project.recipient.slice(0, 5) + "..." + project.recipient.slice(-4); @@ -735,7 +756,10 @@ function ProjectCard(props: { cartProject.chainId = Number(props.chainId); return ( - + - {truncateDescription( - renderToPlainText(project.projectMetadata.description), - 90 - )} + {renderToPlainText(project.projectMetadata.description)} - {!isDirectRound(round) && ( + {props.showProjectCardFooter && (
    @@ -1191,213 +1212,3 @@ const ShareStatsButton = ({ ); }; - -function PreRoundPage(props: { - round: Round; - chainId: string; - roundId: string; - element: (req: Requirement, index: number) => JSX.Element; -}) { - const { round, chainId, roundId, element } = props; - - const applicationURL = `${builderURL}/#/chains/${chainId}/rounds/${roundId}`; - - const currentTime = new Date(); - const isBeforeApplicationStartDate = - round && round.applicationsStartTime >= currentTime; - // covers infinite dates for applicationsEndTime - const isDuringApplicationPeriod = - (round && currentTime >= round.applicationsStartTime) || - currentTime <= - (isInfiniteDate(round.applicationsEndTime) || round.applicationsEndTime); - - const isAfterApplicationEndDateAndBeforeRoundStartDate = - round && - round.roundStartTime >= currentTime && - (isInfiniteDate(round.applicationsEndTime) || - round.applicationsEndTime <= currentTime); - - const { data } = useToken({ - address: getAddress(props.round.token), - chainId: Number(chainId), - }); - - const tokenData = getTokensByChainId(Number(chainId)).find( - (t) => t.address.toLowerCase() === props.round.token.toLowerCase() - ); - - return ( -
    -
    -
    -
    -

    - {round.roundMetadata?.name} -

    -

    - Application Period: - - - {formatLocalDateAsISOString(round.applicationsStartTime)} - - - ( {getLocalTime(round.applicationsStartTime)} ) - - - - - {!isInfiniteDate(round.applicationsEndTime) && ( - <> - - {formatLocalDateAsISOString(round.applicationsEndTime)} - - - {getLocalTime(round.applicationsEndTime)} - - )} - {isInfiniteDate(round.applicationsEndTime) && ( - <> - No End Date - - )} - -

    -

    - Round Period: - - - {formatLocalDateAsISOString(round.roundStartTime)} - - - ( {getLocalTime(round.roundStartTime)} ) - - - - - {!isInfiniteDate(round.roundEndTime) && ( - <> - - {formatLocalDateAsISOString(round.roundEndTime)} - - - {getLocalTime(round.roundEndTime)} - - )} - {isInfiniteDate(round.roundEndTime) && ( - <> - No End Date - - )} - -

    - {!isDirectRound(round) && ( -
    -

    - Matching Funds Available: - - {" "} -   - {round.roundMetadata?.quadraticFundingConfig?.matchingFundsAvailable.toLocaleString()} -   - {tokenData?.code ?? "..."} - -

    -

    - Matching Cap: - {round.roundMetadata?.quadraticFundingConfig - ?.matchingCapAmount ? ( - - {" "} -   - { - round.roundMetadata?.quadraticFundingConfig - ?.matchingCapAmount - } -   - {"%"} - - ) : ( - None - )} -

    -
    - )} -

    - {round.roundMetadata?.eligibility.description} -

    -

    - Round Eligibility -

    -
    -
      - {round.roundMetadata?.eligibility.requirements?.map(element)} -
    -
    -
    - {isBeforeApplicationStartDate && ( - - )} - - {isDuringApplicationPeriod && ( - - )} - - {isAfterApplicationEndDateAndBeforeRoundStartDate && ( - - )} -
    -
    -
    -
    -
    - ); -} - -const ApplyButton = (props: { applicationURL: string }) => { - const { applicationURL } = props; - - return ( - - ); -}; - -const InactiveButton = (props: { label: string; testid: string }) => { - const { label, testid } = props; - - return ( - - ); -}; diff --git a/packages/grant-explorer/src/features/round/__tests__/ViewRoundPage.test.tsx b/packages/grant-explorer/src/features/round/__tests__/ViewRoundPage.test.tsx index 58116d60d..dd600b833 100644 --- a/packages/grant-explorer/src/features/round/__tests__/ViewRoundPage.test.tsx +++ b/packages/grant-explorer/src/features/round/__tests__/ViewRoundPage.test.tsx @@ -103,15 +103,14 @@ describe(" in case of before the application start date", () => { }); }); - it("Should show grayed out Applications Open buttom", async () => { + it("Should show View Requirements Button", async () => { renderWithContext(, { roundState: { rounds: [stubRound], isLoading: false }, dataLayer: mockDataLayer, }); - const AppSubmissionButton = screen.getByTestId("applications-open-button"); + const AppSubmissionButton = screen.getByTestId("view-requirements-button"); expect(AppSubmissionButton).toBeInTheDocument(); - expect(AppSubmissionButton).toBeDisabled(); }); }); @@ -154,14 +153,13 @@ describe(" in case of during the application period", () => { }); // expect that components / text / dates / etc. specific to application view page - expect(screen.getByText(stubRound.roundMetadata!.name)).toBeInTheDocument(); + expect(screen.getAllByText(stubRound.roundMetadata!.name)).toHaveLength(2); expect(screen.getByTestId("application-period")).toBeInTheDocument(); expect(screen.getByTestId("round-period")).toBeInTheDocument(); expect(screen.getByTestId("matching-funds")).toBeInTheDocument(); expect( screen.getByText(stubRound.roundMetadata!.eligibility!.description) ).toBeInTheDocument(); - expect(screen.getByTestId("round-eligibility")).toBeInTheDocument(); }); it("Should show apply to round button", async () => { @@ -169,9 +167,7 @@ describe(" in case of during the application period", () => { roundState: { rounds: [stubRound], isLoading: false }, dataLayer: mockDataLayer, }); - const AppSubmissionButton = await screen.findAllByText( - "Apply to Grant Round" - ); + const AppSubmissionButton = await screen.findAllByText("Apply now!"); expect(AppSubmissionButton[0]).toBeInTheDocument(); }); }); @@ -198,17 +194,15 @@ describe(" in case of post application end date & before round star }); }); - it("Should show Applications Closed button", async () => { + it("Should show Donations countdown badge", async () => { renderWithContext(, { roundState: { rounds: [stubRound], isLoading: false }, dataLayer: mockDataLayer, }); - - const AppSubmissionButton = screen.getByTestId( - "applications-closed-button" + const DonationsBadge = await screen.getByTestId( + "donations-countdown-badge" ); - expect(AppSubmissionButton).toBeInTheDocument(); - expect(AppSubmissionButton).toBeDisabled(); + expect(DonationsBadge).toBeInTheDocument(); }); }); diff --git a/packages/grant-explorer/tailwind.config.js b/packages/grant-explorer/tailwind.config.js index 8826979e4..21d209e25 100644 --- a/packages/grant-explorer/tailwind.config.js +++ b/packages/grant-explorer/tailwind.config.js @@ -12,6 +12,10 @@ module.exports = { animation: { "pulse-scale": "pulse-scale 2s ease-in-out infinite", }, + backgroundImage: { + "rainbow-gradient": + "linear-gradient(170deg, #FFD6C9 10%, #B8D9E7 40%, #ABE3EB 60%, #F2DD9E 90%)", + }, colors: { transparent: "transparent", black: "#000", @@ -37,7 +41,7 @@ module.exports = { }, green: { ...colors.green, - 50: "#DCF5F2", + 50: "#DCF5F2", 100: "#ADEDE5", 200: "#47A095", 300: "rgba(0, 67, 59, 1)",