From 5aaa3310a698e25197564b5c4d290f27134dd8a3 Mon Sep 17 00:00:00 2001 From: Huss Martinez Date: Mon, 24 Mar 2025 20:15:57 +0700 Subject: [PATCH 1/2] chore: split ViewRoundPage components in different files --- .../src/features/round/ViewRoundPage.tsx | 1221 ----------------- .../round/ViewRoundPage/AlloVersionBanner.tsx | 24 + .../round/ViewRoundPage/CartButton.tsx | 25 + .../round/ViewRoundPage/CartButtonToggle.tsx | 40 + .../round/ViewRoundPage/ProjectCard.tsx | 133 ++ .../round/ViewRoundPage/ProjectList.tsx | 96 ++ .../round/ViewRoundPage/RoundPage.tsx | 465 +++++++ .../ViewRoundPage/RoundStatsTabContent.tsx | 126 ++ .../round/ViewRoundPage/RoundTabs.tsx | 47 + .../round/ViewRoundPage/ShareButton.tsx | 70 + .../round/ViewRoundPage/ShareStatsButton.tsx | 18 + .../features/round/ViewRoundPage/StatCard.tsx | 32 + .../features/round/ViewRoundPage/Stats.tsx | 89 ++ .../round/ViewRoundPage/ViewRoundPage.tsx | 87 ++ .../src/features/round/ViewRoundPage/index.ts | 4 + .../src/features/round/ViewRoundPage/utils.ts | 6 + 16 files changed, 1262 insertions(+), 1221 deletions(-) delete mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/AlloVersionBanner.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/CartButton.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/CartButtonToggle.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/ProjectCard.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/ProjectList.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/RoundPage.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/RoundStatsTabContent.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/RoundTabs.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/ShareButton.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/ShareStatsButton.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/StatCard.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/Stats.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/ViewRoundPage.tsx create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/index.ts create mode 100644 packages/grant-explorer/src/features/round/ViewRoundPage/utils.ts diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage.tsx deleted file mode 100644 index 4e8bf324c5..0000000000 --- a/packages/grant-explorer/src/features/round/ViewRoundPage.tsx +++ /dev/null @@ -1,1221 +0,0 @@ -import { datadogLogs } from "@datadog/browser-logs"; -import { Link, useParams } from "react-router-dom"; -import { - ComponentPropsWithRef, - FunctionComponent, - createElement, - useEffect, - useMemo, - useState, -} from "react"; -import { - CalendarIcon, - getRoundStrategyTitle, - getLocalTime, - formatLocalDateAsISOString, - renderToPlainText, - useTokenPrice, - TToken, - getTokensByChainId, - stringToBlobUrl, - getChainById, -} from "common"; -import { Input } from "common/src/styles"; -import AlloV1 from "common/src/icons/AlloV1"; -import AlloV2 from "common/src/icons/AlloV2"; - -import { ReactComponent as CartCircleIcon } from "../../assets/icons/cart-circle.svg"; -import { ReactComponent as CheckedCircleIcon } from "../../assets/icons/checked-circle.svg"; -import { ReactComponent as Search } from "../../assets/search-grey.svg"; -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, Round } from "../api/types"; -import { getDaysLeft, isDirectRound, isInfiniteDate } from "../api/utils"; -import { PassportWidget } from "../common/PassportWidget"; - -import NotFoundPage from "../common/NotFoundPage"; -import { ProjectBanner, ProjectLogo } from "../common/ProjectBanner"; -import RoundEndedBanner from "../common/RoundEndedBanner"; -import { Spinner } from "../common/Spinner"; -import { - Badge, - BasicCard, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "../common/styles"; -import Breadcrumb, { BreadcrumbItem } from "../common/Breadcrumb"; - -const builderURL = process.env.REACT_APP_BUILDER_URL; -import CartNotification from "../common/CartNotification"; -import { useCartStorage } from "../../store"; -import { useAccount, useToken } from "wagmi"; -import { getAddress } from "viem"; -import { getAlloVersion } from "common/src/config"; -import { ExclamationCircleIcon } from "@heroicons/react/24/solid"; -import { DefaultLayout } from "../common/DefaultLayout"; -import { getUnixTime } from "date-fns"; -import { Application, useDataLayer } from "data-layer"; -import { useRoundApprovedApplications } from "../projects/hooks/useRoundApplications"; -import { - LinkIcon, - PresentationChartBarIcon, -} 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"; -import { createFarcasterShareUrl } from "../common/ShareButtons"; - -export default function ViewRound() { - datadogLogs.logger.info("====> Route: /round/:chainId/:roundId"); - datadogLogs.logger.info(`====> URL: ${window.location.href}`); - - const { chainId, roundId } = useParams(); - - const { round, isLoading } = useRoundById( - Number(chainId), - roundId?.toLowerCase() as string - ); - - const currentTime = new Date(); - const isBeforeRoundStartDate = - round && - (isDirectRound(round) - ? round.applicationsStartTime - : round.roundStartTime) >= currentTime; - const isAfterRoundStartDate = - round && - (isDirectRound(round) - ? round.applicationsStartTime - : round.roundStartTime) <= currentTime; - // covers infinte dates for roundEndDate - const isAfterRoundEndDate = - round && - (isInfiniteDate( - isDirectRound(round) ? round.applicationsEndTime : round.roundEndTime - ) - ? false - : round && - (isDirectRound(round) - ? round.applicationsEndTime - : round.roundEndTime) <= currentTime); - const isBeforeRoundEndDate = - round && - (isInfiniteDate( - isDirectRound(round) ? round.applicationsEndTime : round.roundEndTime - ) || - (isDirectRound(round) ? round.applicationsEndTime : round.roundEndTime) > - currentTime); - - const alloVersion = getAlloVersion(); - - useEffect(() => { - if ( - isAfterRoundEndDate !== undefined && - roundId?.startsWith("0x") && - alloVersion === "allo-v2" && - !isAfterRoundEndDate - ) { - window.location.href = `https://explorer-v1.gitcoin.co${window.location.pathname}${window.location.hash}`; - } - }, [roundId, alloVersion, isAfterRoundEndDate]); - - return isLoading ? ( - - ) : ( - <> - {round && chainId && roundId ? ( - - ) : ( - - )} - - ); -} - -export function AlloVersionBanner({ roundId }: { roundId: string }) { - const isAlloV1 = roundId.startsWith("0x"); - - return ( - <> -
- - - This round has been deployed on Allo {isAlloV1 ? "v1" : "v2"}. Any - projects that you add to your cart will have to be donated to - separately from projects on rounds deployed on Allo{" "} - {isAlloV1 ? "v2" : "v1"}. Learn more{" "} - - here - - . - -
-
- - ); -} - -const alloVersion = getAlloVersion(); - -function RoundPage(props: { - round: Round; - chainId: number; - roundId: string; - isBeforeRoundStartDate?: boolean; - isAfterRoundStartDate?: boolean; - isBeforeRoundEndDate?: boolean; - isAfterRoundEndDate?: boolean; -}) { - const { round, chainId, roundId } = props; - - const [searchQuery, setSearchQuery] = useState(""); - const [projects, setProjects] = useState(); - const [randomizedProjects, setRandomizedProjects] = useState(); - const { address: walletAddress } = useAccount(); - const isSybilDefenseEnabled = - round?.roundMetadata?.quadraticFundingConfig?.sybilDefense === true || - round?.roundMetadata?.quadraticFundingConfig?.sybilDefense !== "none"; - - const [showCartNotification, setShowCartNotification] = useState(false); - const [currentProjectAddedToCart, setCurrentProjectAddedToCart] = - useState({} as Project); - const [isProjectsLoading, setIsProjectsLoading] = useState(true); - const [selectedTab, setSelectedTab] = useState(0); - - const disableAddToCartButton = - (alloVersion === "allo-v2" && roundId.startsWith("0x")) || - props.isAfterRoundEndDate; - - const showProjectCardFooter = - !isDirectRound(round) && props.isAfterRoundStartDate; - - useEffect(() => { - if (showCartNotification) { - setTimeout(() => { - setShowCartNotification(false); - }, 3000); - } - }, [showCartNotification]); - - const renderCartNotification = () => { - return ( - - ); - }; - - useEffect(() => { - let projects = round?.approvedProjects; - - // shuffle projects - projects = projects?.sort(() => Math.random() - 0.5); - setRandomizedProjects(projects); - setProjects(projects); - setIsProjectsLoading(false); - }, [round]); - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { - if (searchQuery) { - const timeOutId = setTimeout( - () => filterProjectsByTitle(searchQuery), - 300 - ); - return () => clearTimeout(timeOutId); - } else { - setProjects(randomizedProjects); - setIsProjectsLoading(false); - } - }); - - const filterProjectsByTitle = (query: string) => { - // filter by exact title matches first - // e.g if searchString is "ether" then "ether grant" comes before "ethereum grant" - const projects = round?.approvedProjects; - - const exactMatches = projects?.filter( - (project) => - project.projectMetadata.title.toLocaleLowerCase() === - query.toLocaleLowerCase() - ); - const nonExactMatches = projects?.filter( - (project) => - project.projectMetadata.title - .toLocaleLowerCase() - .includes(query.toLocaleLowerCase()) && - project.projectMetadata.title.toLocaleLowerCase() !== - query.toLocaleLowerCase() - ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - setProjects([...exactMatches!, ...nonExactMatches!]); - setIsProjectsLoading(false); - }; - - const { data } = useToken({ - address: getAddress(props.round.token), - chainId: Number(props.chainId), - }); - - const nativePayoutToken = getTokensByChainId(props.chainId).find( - (t) => t.address === getAddress(props.round.token) - ); - - const tokenData = data ?? { - ...nativePayoutToken, - symbol: nativePayoutToken?.code ?? "ETH", - }; - - const breadCrumbs = [ - { - name: "Explorer Home", - path: "/", - }, - { - name: round.roundMetadata?.name, - path: `/round/${chainId}/${roundId}`, - }, - ] as BreadcrumbItem[]; - - const applicationURL = `${builderURL}/#/chains/${chainId}/rounds/${roundId}`; - const currentTime = new Date(); - const isBeforeApplicationEndDate = - round && - (isInfiniteDate(round.applicationsEndTime) || - round.applicationsEndTime >= currentTime); - - const isAlloV1 = roundId.startsWith("0x"); - - const getRoundEndsText = () => { - if (!round.roundEndTime) return; - - const roundEndsIn = - round.roundEndTime === undefined - ? undefined - : getDaysLeft(getUnixTime(round.roundEndTime).toString()); - - if (roundEndsIn === undefined || roundEndsIn < 0) return; - - if (roundEndsIn === 0) return "Ends today"; - - return `${roundEndsIn} ${roundEndsIn === 1 ? "day" : "days"} left`; - }; - - const roundEndsText = getRoundEndsText(); - - const handleTabChange = (tabIndex: number) => { - setSelectedTab(tabIndex); - }; - - const projectDetailsTabs = useMemo(() => { - const projectsTab = { - name: isDirectRound(round) - ? "Approved Projects" - : `All Projects (${projects?.length ?? 0})`, - content: ( - <> - - - ), - }; - const statsTab = { - name: props.isBeforeRoundEndDate ? "Stats" : "Results", - icon: PresentationChartBarIcon, - content: ( - <> - - - ), - }; - - return [projectsTab, statsTab]; - }, [ - projects, - round, - props.isBeforeRoundEndDate, - chainId, - disableAddToCartButton, - isProjectsLoading, - nativePayoutToken, - roundId, - tokenData.symbol, - showProjectCardFooter, - ]); - - const roundStart = isDirectRound(round) - ? round.applicationsStartTime - : round.roundStartTime; - const roundEnd = isDirectRound(round) - ? round.applicationsEndTime - : round.roundEndTime; - - const chain = getChainById(chainId); - - return ( - <> - - {showCartNotification && renderCartNotification()} - {props.isAfterRoundEndDate && ( -
- -
- )} -
-
- -
- {walletAddress && isSybilDefenseEnabled && ( -
- -
- )} -
- -
-
-
-
- {isAlloV1 && } - {!isAlloV1 && } -
- -
-

- {round.roundMetadata?.name} -

- {props.isBeforeRoundStartDate ? ( - - ) : !props.isAfterRoundEndDate ? ( - - {roundEndsText} - - ) : ( - - Round ended - - )} -
- - - - {round.payoutStrategy?.strategyName && - getRoundStrategyTitle(round.payoutStrategy?.strategyName)} - - - -
- on -
- Round Chain Logo - {chain.prettyName} -
-
- -
- {isBeforeApplicationEndDate && ( -

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

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

- Donate - - - - - {formatLocalDateAsISOString(roundStart)} - - {getLocalTime(roundStart)} - - - - - {!isInfiniteDate(roundEnd) ? ( - <> - - {formatLocalDateAsISOString(roundEnd)} - - - {getLocalTime(roundEnd)} - - ) : ( - No End Date - )} - - -

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

- {round.roundMetadata?.quadraticFundingConfig?.matchingFundsAvailable.toLocaleString()} -   - {tokenData?.symbol ?? "..."} -

-

Matching Pool

-
- )} -
- -

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

-
-
- -
- {isBeforeApplicationEndDate && ( - - )} - -
- - {selectedTab === 0 && ( -
- - ) => - setSearchQuery(e.target.value) - } - /> -
- )} -
- -
{projectDetailsTabs[selectedTab].content}
-
-
- - ); -} - -type Tab = { - name: string; - icon?: FunctionComponent>; - content: JSX.Element; -}; - -function RoundTabs(props: { - tabs: Tab[]; - onChange?: (tabIndex: number) => void; - selected: number; -}) { - return ( - - {props.tabs.length > 0 && ( - - {props.tabs.map((tab, index) => ( - - {tab.icon && ( -
- {createElement(tab.icon, { - className: "w-4 h-4", - })} -
- )} - {tab.name} -
- ))} -
- )} -
- ); -} - -const ProjectList = (props: { - projects?: Project[]; - roundRoutePath: string; - showProjectCardFooter?: boolean; - isBeforeRoundEndDate?: boolean; - roundId: string; - round: Round; - chainId: number; - isProjectsLoading: boolean; - setCurrentProjectAddedToCart: React.Dispatch>; - setShowCartNotification: React.Dispatch>; -}): JSX.Element => { - const { projects, roundRoutePath, chainId, roundId } = props; - const dataLayer = useDataLayer(); - - const { data: applications } = useRoundApprovedApplications( - { - chainId, - roundId, - }, - dataLayer - ); - - const applicationsMapByGrantApplicationId: - | Map - | undefined = useMemo(() => { - if (!applications) return; - const map: Map = new Map(); - applications.forEach((application) => - map.set(application.projectId, application) - ); - return map; - }, [applications]); - - return ( - <> -
- {props.isProjectsLoading ? ( - <> - {Array(6) - .fill("") - .map((item, index) => ( - - ))} - - ) : projects?.length ? ( - <> - {projects.map((project) => { - return ( - - ); - })} - - ) : ( -

No projects

- )} -
- - ); -}; - -function ProjectCard(props: { - project: Project; - roundRoutePath: string; - showProjectCardFooter?: boolean; - isBeforeRoundEndDate?: boolean; - roundId: string; - round: Round; - chainId: number; - setCurrentProjectAddedToCart: React.Dispatch>; - setShowCartNotification: React.Dispatch>; - crowdfundedUSD: number; - uniqueContributorsCount: number; -}) { - const { project, roundRoutePath } = props; - const projectRecipient = - project.recipient.slice(0, 5) + "..." + project.recipient.slice(-4); - - const { projects, add, remove } = useCartStorage(); - - const isAlreadyInCart = projects.some( - (cartProject) => - cartProject.chainId === Number(props.chainId) && - cartProject.grantApplicationId === project.grantApplicationId && - cartProject.roundId === props.roundId - ); - - const cartProject = project as CartProject; - cartProject.roundId = props.roundId; - cartProject.chainId = Number(props.chainId); - - return ( - - - - - - - - {project.projectMetadata.logoImg && ( - - )} -
- - {project.projectMetadata.title} - - - by {projectRecipient} - -
- - {renderToPlainText(project.projectMetadata.description)} - -
- - {props.showProjectCardFooter && ( - - -
-
-

- $ - {props.crowdfundedUSD?.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -

-

- total raised by {props.uniqueContributorsCount} contributors -

-
- {props.isBeforeRoundEndDate && ( - { - remove(cartProject); - }} - addToCart={() => { - add(cartProject); - }} - setCurrentProjectAddedToCart={ - props.setCurrentProjectAddedToCart - } - setShowCartNotification={props.setShowCartNotification} - /> - )} -
-
-
- )} -
- ); -} - -function CartButton(props: { - project: Project; - isAlreadyInCart: boolean; - removeFromCart: () => void; - addToCart: () => void; - setCurrentProjectAddedToCart: React.Dispatch>; - setShowCartNotification: React.Dispatch>; -}) { - return ( -
- -
- ); -} - -export function CartButtonToggle(props: { - project: Project; - isAlreadyInCart: boolean; - addToCart: () => void; - removeFromCart: () => void; - setCurrentProjectAddedToCart: React.Dispatch>; - setShowCartNotification: React.Dispatch>; -}) { - // if the project is not added, show the add to cart button - // if the project is added to the cart, show the remove from cart button - if (props.isAlreadyInCart) { - return ( -
- -
- ); - } - return ( -
{ - props.addToCart(); - props.setCurrentProjectAddedToCart(props.project); - props.setShowCartNotification(true); - }} - > - -
- ); -} - -const RoundStatsTabContent = ({ - roundId, - chainId, - round, - token, - tokenSymbol, -}: { - roundId: string; - round: Round; - chainId: number; - token?: TToken; - tokenSymbol?: string; -}): JSX.Element => { - const [isShareModalOpen, setIsShareModalOpen] = useState(false); - const dataLayer = useDataLayer(); - const { data: applications, isLoading: isGetApplicationsLoading } = - useRoundApprovedApplications( - { - chainId, - roundId, - }, - dataLayer - ); - - const totalUSDCrowdfunded = useMemo(() => { - return ( - applications - ?.map((application) => application.totalAmountDonatedInUsd) - .reduce((acc, amount) => acc + amount, 0) ?? 0 - ); - }, [applications]); - - const totalDonations = useMemo(() => { - return ( - applications - ?.map((application) => Number(application.totalDonationsCount ?? 0)) - .reduce((acc, amount) => acc + amount, 0) ?? 0 - ); - }, [applications]); - - const ShareModal = () => { - const ShareModalBody = () => ( -
- - -
- ); - - return ( - } - isOpen={isShareModalOpen} - setIsOpen={setIsShareModalOpen} - /> - ); - }; - - return ( - <> -
-
-
- setIsShareModalOpen(true)} /> -
-
- -
-
- -
-

- Want to check out more stats? -

- - - Round report card - -
- - -
- - ); -}; - -const formatAmount = (amount: string | number, noDigits?: boolean) => { - return Number(amount).toLocaleString("en-US", { - maximumFractionDigits: noDigits ? 0 : 2, - minimumFractionDigits: noDigits ? 0 : 2, - }); -}; - -const Stats = ({ - round, - totalCrowdfunded, - totalProjects, - token, - tokenSymbol, - totalDonations, - totalDonors, - statsLoading, -}: { - round: Round; - totalCrowdfunded: number; - totalProjects: number; - chainId: number; - token?: TToken; - tokenSymbol?: string; - totalDonations: number; - totalDonors: number; - statsLoading: boolean; -}): JSX.Element => { - const tokenAmount = - round.roundMetadata?.quadraticFundingConfig?.matchingFundsAvailable ?? 0; - - const { data: poolTokenPrice } = useTokenPrice( - token?.redstoneTokenId, - token?.priceSource - ); - - const matchingPoolUSD = poolTokenPrice - ? Number(poolTokenPrice) * tokenAmount - : undefined; - const matchingCapPercent = - round.roundMetadata?.quadraticFundingConfig?.matchingCapAmount ?? 0; - const matchingCapTokenValue = (tokenAmount * matchingCapPercent) / 100; - - return ( -
-
- - - {!!matchingCapPercent && ( - - )} - - - - - -
-
- ); -}; - -const StatCard = ({ - statValue, - secondaryStatValue, - statName, - isValueLoading, -}: { - statValue: string; - secondaryStatValue?: string; - statName: string; - isValueLoading?: boolean; -}): JSX.Element => { - return ( -
- {isValueLoading ? ( -
- ) : ( -
-

- {statValue} -

- {!!secondaryStatValue?.length && ( -

- {secondaryStatValue} -

- )} -
- )} - -

{statName}

-
- ); -}; - -const ShareButton = ({ - round, - tokenSymbol, - totalUSDCrowdfunded, - totalDonations, - type, -}: { - round: Round; - tokenSymbol?: string; - totalUSDCrowdfunded: number; - totalDonations: number; - - type: "TWITTER" | "FARCASTER"; -}) => { - const roundName = round.roundMetadata?.name; - const tokenAmount = - round.roundMetadata?.quadraticFundingConfig?.matchingFundsAvailable ?? 0; - - const shareText = `🌐 ${formatAmount( - tokenAmount, - true - )} ${tokenSymbol} matching pool -πŸ“ˆ $${formatAmount(totalUSDCrowdfunded.toFixed(2))} funded so far -🀝 ${formatAmount(totalDonations, true)} donations -πŸ‘€ Check out ${roundName}’s stats! -`; - - const twitterShareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( - shareText + window.location.href - )}`; - - const farcasterShareUrl = createFarcasterShareUrl( - encodeURIComponent(shareText), - [window.location.href] - ); - - return ( - <> - {type === "TWITTER" ? ( - - ) : ( - - )} - - ); -}; - -const ShareStatsButton = ({ - handleClick, -}: { - handleClick: () => void; -}): JSX.Element => { - return ( - - ); -}; diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/AlloVersionBanner.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/AlloVersionBanner.tsx new file mode 100644 index 0000000000..a977683ceb --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/AlloVersionBanner.tsx @@ -0,0 +1,24 @@ +import { ExclamationCircleIcon } from "@heroicons/react/24/solid"; + +export function AlloVersionBanner({ roundId }: { roundId: string }) { + const isAlloV1 = roundId.startsWith("0x"); + + return ( + <> +
+ + + This round has been deployed on Allo {isAlloV1 ? "v1" : "v2"}. Any + projects that you add to your cart will have to be donated to + separately from projects on rounds deployed on Allo{" "} + {isAlloV1 ? "v2" : "v1"}. Learn more{" "} + + here + + . + +
+
+ + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/CartButton.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/CartButton.tsx new file mode 100644 index 0000000000..f5900f4b37 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/CartButton.tsx @@ -0,0 +1,25 @@ +import { Project } from "../../api/types"; + +import { CartButtonToggle } from "./CartButtonToggle"; + +export function CartButton(props: { + project: Project; + isAlreadyInCart: boolean; + removeFromCart: () => void; + addToCart: () => void; + setCurrentProjectAddedToCart: React.Dispatch>; + setShowCartNotification: React.Dispatch>; +}) { + return ( +
+ +
+ ); +} diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/CartButtonToggle.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/CartButtonToggle.tsx new file mode 100644 index 0000000000..0f6e4e820e --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/CartButtonToggle.tsx @@ -0,0 +1,40 @@ +import { ReactComponent as CartCircleIcon } from "../../../assets/icons/cart-circle.svg"; +import { ReactComponent as CheckedCircleIcon } from "../../../assets/icons/checked-circle.svg"; +import { Project } from "../../api/types"; + +export function CartButtonToggle(props: { + project: Project; + isAlreadyInCart: boolean; + addToCart: () => void; + removeFromCart: () => void; + setCurrentProjectAddedToCart: React.Dispatch>; + setShowCartNotification: React.Dispatch>; +}) { + // if the project is not added, show the add to cart button + // if the project is added to the cart, show the remove from cart button + if (props.isAlreadyInCart) { + return ( +
+ +
+ ); + } + return ( +
{ + props.addToCart(); + props.setCurrentProjectAddedToCart(props.project); + props.setShowCartNotification(true); + }} + > + +
+ ); +} diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectCard.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectCard.tsx new file mode 100644 index 0000000000..518e5115de --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectCard.tsx @@ -0,0 +1,133 @@ +import { Link } from "react-router-dom"; +import { renderToPlainText, useTokenPrice, TToken } from "common"; + +import { CartProject, Project, Round } from "../../api/types"; + +import { ProjectBanner, ProjectLogo } from "../../common/ProjectBanner"; +import { + BasicCard, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../../common/styles"; + +import { useCartStorage } from "../../../store"; +import { CartButton } from "./CartButton"; + +export function ProjectCard(props: { + project: Project; + roundRoutePath: string; + showProjectCardFooter?: boolean; + isBeforeRoundEndDate?: boolean; + roundId: string; + round: Round; + chainId: number; + setCurrentProjectAddedToCart: React.Dispatch>; + setShowCartNotification: React.Dispatch>; + crowdfundedUSD: number; + uniqueContributorsCount: number; +}) { + const { project, roundRoutePath } = props; + const projectRecipient = + project.recipient.slice(0, 5) + "..." + project.recipient.slice(-4); + + const { projects, add, remove } = useCartStorage(); + + const isAlreadyInCart = projects.some( + (cartProject) => + cartProject.chainId === Number(props.chainId) && + cartProject.grantApplicationId === project.grantApplicationId && + cartProject.roundId === props.roundId + ); + + const cartProject = project as CartProject; + cartProject.roundId = props.roundId; + cartProject.chainId = Number(props.chainId); + + return ( + + + + + + + + {project.projectMetadata.logoImg && ( + + )} +
+ + {project.projectMetadata.title} + + + by {projectRecipient} + +
+ + {renderToPlainText(project.projectMetadata.description)} + +
+ + {props.showProjectCardFooter && ( + + +
+
+

+ $ + {props.crowdfundedUSD?.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+

+ total raised by {props.uniqueContributorsCount} contributors +

+
+ {props.isBeforeRoundEndDate && ( + { + remove(cartProject); + }} + addToCart={() => { + add(cartProject); + }} + setCurrentProjectAddedToCart={ + props.setCurrentProjectAddedToCart + } + setShowCartNotification={props.setShowCartNotification} + /> + )} +
+
+
+ )} +
+ ); +} diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectList.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectList.tsx new file mode 100644 index 0000000000..78ee3c3a69 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/ProjectList.tsx @@ -0,0 +1,96 @@ +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 { ProjectCard } from "./ProjectCard"; + +export const ProjectList = (props: { + projects?: Project[]; + roundRoutePath: string; + showProjectCardFooter?: boolean; + isBeforeRoundEndDate?: boolean; + roundId: string; + round: Round; + chainId: number; + isProjectsLoading: boolean; + setCurrentProjectAddedToCart: React.Dispatch>; + setShowCartNotification: React.Dispatch>; +}): JSX.Element => { + const { projects, roundRoutePath, chainId, roundId } = props; + const dataLayer = useDataLayer(); + + const { data: applications } = useRoundApprovedApplications( + { + chainId, + roundId, + }, + dataLayer + ); + + const applicationsMapByGrantApplicationId: + | Map + | undefined = useMemo(() => { + if (!applications) return; + const map: Map = new Map(); + applications.forEach((application) => + map.set(application.projectId, application) + ); + return map; + }, [applications]); + + return ( + <> +
+ {props.isProjectsLoading ? ( + <> + {Array(6) + .fill("") + .map((item, index) => ( + + ))} + + ) : projects?.length ? ( + <> + {projects.map((project) => { + return ( + + ); + })} + + ) : ( +

No projects

+ )} +
+ + ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/RoundPage.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/RoundPage.tsx new file mode 100644 index 0000000000..a9ff035cc2 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/RoundPage.tsx @@ -0,0 +1,465 @@ +import { useEffect, useMemo, useState } from "react"; +import { + CalendarIcon, + getRoundStrategyTitle, + getLocalTime, + formatLocalDateAsISOString, + getTokensByChainId, + stringToBlobUrl, + getChainById, +} from "common"; +import { Input } from "common/src/styles"; +import AlloV1 from "common/src/icons/AlloV1"; +import AlloV2 from "common/src/icons/AlloV2"; + +import { ReactComponent as Search } from "../../../assets/search-grey.svg"; + +import { Project, Round } from "../../api/types"; +import { getDaysLeft, isDirectRound, isInfiniteDate } from "../../api/utils"; +import { PassportWidget } from "../../common/PassportWidget"; + +import RoundEndedBanner from "../../common/RoundEndedBanner"; +import { Badge } from "../../common/styles"; +import Breadcrumb, { BreadcrumbItem } from "../../common/Breadcrumb"; + +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 { getUnixTime } from "date-fns"; +import { PresentationChartBarIcon } from "@heroicons/react/24/outline"; + +import RoundStartCountdownBadge from "../RoundStartCountdownBadge"; +import ApplicationsCountdownBanner from "../ApplicationsCountdownBanner"; +import { ProjectList } from "./ProjectList"; +import { RoundStatsTabContent } from "./RoundStatsTabContent"; +import { RoundTabs } from "./RoundTabs"; +import { getAlloVersion } from "common/src/config"; + +const alloVersion = getAlloVersion(); + +export function RoundPage(props: { + round: Round; + chainId: number; + roundId: string; + isBeforeRoundStartDate?: boolean; + isAfterRoundStartDate?: boolean; + isBeforeRoundEndDate?: boolean; + isAfterRoundEndDate?: boolean; +}) { + const { round, chainId, roundId } = props; + + const [searchQuery, setSearchQuery] = useState(""); + const [projects, setProjects] = useState(); + const [randomizedProjects, setRandomizedProjects] = useState(); + const { address: walletAddress } = useAccount(); + const isSybilDefenseEnabled = + round?.roundMetadata?.quadraticFundingConfig?.sybilDefense === true || + round?.roundMetadata?.quadraticFundingConfig?.sybilDefense !== "none"; + + const [showCartNotification, setShowCartNotification] = useState(false); + const [currentProjectAddedToCart, setCurrentProjectAddedToCart] = + useState({} as Project); + const [isProjectsLoading, setIsProjectsLoading] = useState(true); + const [selectedTab, setSelectedTab] = useState(0); + + const disableAddToCartButton = + (alloVersion === "allo-v2" && roundId.startsWith("0x")) || + props.isAfterRoundEndDate; + + const showProjectCardFooter = + !isDirectRound(round) && props.isAfterRoundStartDate; + + useEffect(() => { + if (showCartNotification) { + setTimeout(() => { + setShowCartNotification(false); + }, 3000); + } + }, [showCartNotification]); + + const renderCartNotification = () => { + return ( + + ); + }; + + useEffect(() => { + let projects = round?.approvedProjects; + + // shuffle projects + projects = projects?.sort(() => Math.random() - 0.5); + setRandomizedProjects(projects); + setProjects(projects); + setIsProjectsLoading(false); + }, [round]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (searchQuery) { + const timeOutId = setTimeout( + () => filterProjectsByTitle(searchQuery), + 300 + ); + return () => clearTimeout(timeOutId); + } else { + setProjects(randomizedProjects); + setIsProjectsLoading(false); + } + }); + + const filterProjectsByTitle = (query: string) => { + // filter by exact title matches first + // e.g if searchString is "ether" then "ether grant" comes before "ethereum grant" + const projects = round?.approvedProjects; + + const exactMatches = projects?.filter( + (project) => + project.projectMetadata.title.toLocaleLowerCase() === + query.toLocaleLowerCase() + ); + const nonExactMatches = projects?.filter( + (project) => + project.projectMetadata.title + .toLocaleLowerCase() + .includes(query.toLocaleLowerCase()) && + project.projectMetadata.title.toLocaleLowerCase() !== + query.toLocaleLowerCase() + ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setProjects([...exactMatches!, ...nonExactMatches!]); + setIsProjectsLoading(false); + }; + + const { data } = useToken({ + address: getAddress(props.round.token), + chainId: Number(props.chainId), + }); + + const nativePayoutToken = getTokensByChainId(props.chainId).find( + (t) => t.address === getAddress(props.round.token) + ); + + const tokenData = data ?? { + ...nativePayoutToken, + symbol: nativePayoutToken?.code ?? "ETH", + }; + + const breadCrumbs = [ + { + name: "Explorer Home", + path: "/", + }, + { + name: round.roundMetadata?.name, + path: `/round/${chainId}/${roundId}`, + }, + ] as BreadcrumbItem[]; + + const applicationURL = `${builderURL}/#/chains/${chainId}/rounds/${roundId}`; + const currentTime = new Date(); + const isBeforeApplicationEndDate = + round && + (isInfiniteDate(round.applicationsEndTime) || + round.applicationsEndTime >= currentTime); + + const isAlloV1 = roundId.startsWith("0x"); + + const getRoundEndsText = () => { + if (!round.roundEndTime) return; + + const roundEndsIn = + round.roundEndTime === undefined + ? undefined + : getDaysLeft(getUnixTime(round.roundEndTime).toString()); + + if (roundEndsIn === undefined || roundEndsIn < 0) return; + + if (roundEndsIn === 0) return "Ends today"; + + return `${roundEndsIn} ${roundEndsIn === 1 ? "day" : "days"} left`; + }; + + const roundEndsText = getRoundEndsText(); + + const handleTabChange = (tabIndex: number) => { + setSelectedTab(tabIndex); + }; + + const projectDetailsTabs = useMemo(() => { + const projectsTab = { + name: isDirectRound(round) + ? "Approved Projects" + : `All Projects (${projects?.length ?? 0})`, + content: ( + <> + + + ), + }; + const statsTab = { + name: props.isBeforeRoundEndDate ? "Stats" : "Results", + icon: PresentationChartBarIcon, + content: ( + <> + + + ), + }; + + return [projectsTab, statsTab]; + }, [ + projects, + round, + props.isBeforeRoundEndDate, + chainId, + disableAddToCartButton, + isProjectsLoading, + nativePayoutToken, + roundId, + tokenData.symbol, + showProjectCardFooter, + ]); + + const roundStart = isDirectRound(round) + ? round.applicationsStartTime + : round.roundStartTime; + const roundEnd = isDirectRound(round) + ? round.applicationsEndTime + : round.roundEndTime; + + const chain = getChainById(chainId); + + return ( + <> + + {showCartNotification && renderCartNotification()} + {props.isAfterRoundEndDate && ( +
+ +
+ )} +
+
+ +
+ {walletAddress && isSybilDefenseEnabled && ( +
+ +
+ )} +
+ +
+
+
+
+ {isAlloV1 && } + {!isAlloV1 && } +
+ +
+

+ {round.roundMetadata?.name} +

+ {props.isBeforeRoundStartDate ? ( + + ) : !props.isAfterRoundEndDate ? ( + + {roundEndsText} + + ) : ( + + Round ended + + )} +
+ + + + {round.payoutStrategy?.strategyName && + getRoundStrategyTitle(round.payoutStrategy?.strategyName)} + + + +
+ on +
+ Round Chain Logo + {chain.prettyName} +
+
+ +
+ {isBeforeApplicationEndDate && ( +

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

+ )} + {!isDirectRound(round) && ( +

+ Donate + + + + + {formatLocalDateAsISOString(roundStart)} + + {getLocalTime(roundStart)} + + - + + {!isInfiniteDate(roundEnd) ? ( + <> + + {formatLocalDateAsISOString(roundEnd)} + + + {getLocalTime(roundEnd)} + + ) : ( + No End Date + )} + + +

+ )} +
+
+ + {!isDirectRound(round) && ( +
+

+ {round.roundMetadata?.quadraticFundingConfig?.matchingFundsAvailable.toLocaleString()} +   + {tokenData?.symbol ?? "..."} +

+

Matching Pool

+
+ )} +
+ +

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

+
+
+ +
+ {isBeforeApplicationEndDate && ( + + )} + +
+ + {selectedTab === 0 && ( +
+ + ) => + setSearchQuery(e.target.value) + } + /> +
+ )} +
+ +
{projectDetailsTabs[selectedTab].content}
+
+
+ + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/RoundStatsTabContent.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/RoundStatsTabContent.tsx new file mode 100644 index 0000000000..a22cb22083 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/RoundStatsTabContent.tsx @@ -0,0 +1,126 @@ +import { useMemo, useState } from "react"; +import { TToken } from "common"; + +import { Round } from "../../api/types"; +import { useDataLayer } from "data-layer"; +import { useRoundApprovedApplications } from "../../projects/hooks/useRoundApplications"; +import { PresentationChartBarIcon } from "@heroicons/react/24/outline"; +import GenericModal from "../../common/GenericModal"; + +import { ShareButton } from "./ShareButton"; +import { ShareStatsButton } from "./ShareStatsButton"; +import { Stats } from "./Stats"; + +export const RoundStatsTabContent = ({ + roundId, + chainId, + round, + token, + tokenSymbol, +}: { + roundId: string; + round: Round; + chainId: number; + token?: TToken; + tokenSymbol?: string; +}): JSX.Element => { + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + const dataLayer = useDataLayer(); + const { data: applications, isLoading: isGetApplicationsLoading } = + useRoundApprovedApplications( + { + chainId, + roundId, + }, + dataLayer + ); + + const totalUSDCrowdfunded = useMemo(() => { + return ( + applications + ?.map((application) => application.totalAmountDonatedInUsd) + .reduce((acc, amount) => acc + amount, 0) ?? 0 + ); + }, [applications]); + + const totalDonations = useMemo(() => { + return ( + applications + ?.map((application) => Number(application.totalDonationsCount ?? 0)) + .reduce((acc, amount) => acc + amount, 0) ?? 0 + ); + }, [applications]); + + const ShareModal = () => { + const ShareModalBody = () => ( +
+ + +
+ ); + + return ( + } + isOpen={isShareModalOpen} + setIsOpen={setIsShareModalOpen} + /> + ); + }; + + return ( + <> +
+
+
+ setIsShareModalOpen(true)} /> +
+
+ +
+
+ +
+

+ Want to check out more stats? +

+ + + Round report card + +
+ + +
+ + ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/RoundTabs.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/RoundTabs.tsx new file mode 100644 index 0000000000..3bbade6727 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/RoundTabs.tsx @@ -0,0 +1,47 @@ +import { ComponentPropsWithRef, FunctionComponent, createElement } from "react"; + +import { Box, Tab, Tabs } from "@chakra-ui/react"; + +type Tab = { + name: string; + icon?: FunctionComponent>; + content: JSX.Element; +}; + +export function RoundTabs(props: { + tabs: Tab[]; + onChange?: (tabIndex: number) => void; + selected: number; +}) { + return ( + + {props.tabs.length > 0 && ( + + {props.tabs.map((tab, index) => ( + + {tab.icon && ( +
+ {createElement(tab.icon, { + className: "w-4 h-4", + })} +
+ )} + {tab.name} +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/ShareButton.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/ShareButton.tsx new file mode 100644 index 0000000000..4ecc8b5880 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/ShareButton.tsx @@ -0,0 +1,70 @@ +import { ReactComponent as WarpcastIcon } from "../../../assets/warpcast-logo.svg"; +import { ReactComponent as TwitterBlueIcon } from "../../../assets/x-logo.svg"; + +import { Round } from "../../api/types"; + +import { createFarcasterShareUrl } from "../../common/ShareButtons"; +import { formatAmount } from "./utils"; + +export const ShareButton = ({ + round, + tokenSymbol, + totalUSDCrowdfunded, + totalDonations, + type, +}: { + round: Round; + tokenSymbol?: string; + totalUSDCrowdfunded: number; + totalDonations: number; + + type: "TWITTER" | "FARCASTER"; +}) => { + const roundName = round.roundMetadata?.name; + const tokenAmount = + round.roundMetadata?.quadraticFundingConfig?.matchingFundsAvailable ?? 0; + + const shareText = `🌐 ${formatAmount( + tokenAmount, + true + )} ${tokenSymbol} matching pool +πŸ“ˆ $${formatAmount(totalUSDCrowdfunded.toFixed(2))} funded so far +🀝 ${formatAmount(totalDonations, true)} donations +πŸ‘€ Check out ${roundName}’s stats! +`; + + const twitterShareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + shareText + window.location.href + )}`; + + const farcasterShareUrl = createFarcasterShareUrl( + encodeURIComponent(shareText), + [window.location.href] + ); + + return ( + <> + {type === "TWITTER" ? ( + + ) : ( + + )} + + ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/ShareStatsButton.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/ShareStatsButton.tsx new file mode 100644 index 0000000000..500a822557 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/ShareStatsButton.tsx @@ -0,0 +1,18 @@ +import { LinkIcon } from "@heroicons/react/24/outline"; + +export const ShareStatsButton = ({ + handleClick, +}: { + handleClick: () => void; +}): JSX.Element => { + return ( + + ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/StatCard.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/StatCard.tsx new file mode 100644 index 0000000000..48cc890216 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/StatCard.tsx @@ -0,0 +1,32 @@ +export const StatCard = ({ + statValue, + secondaryStatValue, + statName, + isValueLoading, +}: { + statValue: string; + secondaryStatValue?: string; + statName: string; + isValueLoading?: boolean; +}): JSX.Element => { + return ( +
+ {isValueLoading ? ( +
+ ) : ( +
+

+ {statValue} +

+ {!!secondaryStatValue?.length && ( +

+ {secondaryStatValue} +

+ )} +
+ )} + +

{statName}

+
+ ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/Stats.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/Stats.tsx new file mode 100644 index 0000000000..62e04c4c67 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/Stats.tsx @@ -0,0 +1,89 @@ +import { useTokenPrice, TToken } from "common"; + +import { Round } from "../../api/types"; +import { StatCard } from "./StatCard"; +import { formatAmount } from "./utils"; + +export const Stats = ({ + round, + totalCrowdfunded, + totalProjects, + token, + tokenSymbol, + totalDonations, + totalDonors, + statsLoading, +}: { + round: Round; + totalCrowdfunded: number; + totalProjects: number; + chainId: number; + token?: TToken; + tokenSymbol?: string; + totalDonations: number; + totalDonors: number; + statsLoading: boolean; +}): JSX.Element => { + const tokenAmount = + round.roundMetadata?.quadraticFundingConfig?.matchingFundsAvailable ?? 0; + + const { data: poolTokenPrice } = useTokenPrice( + token?.redstoneTokenId, + token?.priceSource + ); + + const matchingPoolUSD = poolTokenPrice + ? Number(poolTokenPrice) * tokenAmount + : undefined; + const matchingCapPercent = + round.roundMetadata?.quadraticFundingConfig?.matchingCapAmount ?? 0; + const matchingCapTokenValue = (tokenAmount * matchingCapPercent) / 100; + + return ( +
+
+ + + {!!matchingCapPercent && ( + + )} + + + + + +
+
+ ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/ViewRoundPage.tsx b/packages/grant-explorer/src/features/round/ViewRoundPage/ViewRoundPage.tsx new file mode 100644 index 0000000000..14a50eaae6 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/ViewRoundPage.tsx @@ -0,0 +1,87 @@ +import { datadogLogs } from "@datadog/browser-logs"; +import { useParams } from "react-router-dom"; +import { useEffect } from "react"; +import { useRoundById } from "../../../context/RoundContext"; +import { isDirectRound, isInfiniteDate } from "../../api/utils"; + +import NotFoundPage from "../../common/NotFoundPage"; +import { Spinner } from "../../common/Spinner"; + +import { getAlloVersion } from "common/src/config"; + +import { RoundPage } from "./RoundPage"; + +export default function ViewRound() { + datadogLogs.logger.info("====> Route: /round/:chainId/:roundId"); + datadogLogs.logger.info(`====> URL: ${window.location.href}`); + + const { chainId, roundId } = useParams(); + + const { round, isLoading } = useRoundById( + Number(chainId), + roundId?.toLowerCase() as string + ); + + const currentTime = new Date(); + const isBeforeRoundStartDate = + round && + (isDirectRound(round) + ? round.applicationsStartTime + : round.roundStartTime) >= currentTime; + const isAfterRoundStartDate = + round && + (isDirectRound(round) + ? round.applicationsStartTime + : round.roundStartTime) <= currentTime; + // covers infinte dates for roundEndDate + const isAfterRoundEndDate = + round && + (isInfiniteDate( + isDirectRound(round) ? round.applicationsEndTime : round.roundEndTime + ) + ? false + : round && + (isDirectRound(round) + ? round.applicationsEndTime + : round.roundEndTime) <= currentTime); + const isBeforeRoundEndDate = + round && + (isInfiniteDate( + isDirectRound(round) ? round.applicationsEndTime : round.roundEndTime + ) || + (isDirectRound(round) ? round.applicationsEndTime : round.roundEndTime) > + currentTime); + + const alloVersion = getAlloVersion(); + + useEffect(() => { + if ( + isAfterRoundEndDate !== undefined && + roundId?.startsWith("0x") && + alloVersion === "allo-v2" && + !isAfterRoundEndDate + ) { + window.location.href = `https://explorer-v1.gitcoin.co${window.location.pathname}${window.location.hash}`; + } + }, [roundId, alloVersion, isAfterRoundEndDate]); + + return isLoading ? ( + + ) : ( + <> + {round && chainId && roundId ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/index.ts b/packages/grant-explorer/src/features/round/ViewRoundPage/index.ts new file mode 100644 index 0000000000..a5bc44e599 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/index.ts @@ -0,0 +1,4 @@ +import ViewRoundPage from "./ViewRoundPage"; +export * from "./AlloVersionBanner"; +export * from "./CartButtonToggle"; +export default ViewRoundPage; diff --git a/packages/grant-explorer/src/features/round/ViewRoundPage/utils.ts b/packages/grant-explorer/src/features/round/ViewRoundPage/utils.ts new file mode 100644 index 0000000000..0d16884d99 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewRoundPage/utils.ts @@ -0,0 +1,6 @@ +export const formatAmount = (amount: string | number, noDigits?: boolean) => { + return Number(amount).toLocaleString("en-US", { + maximumFractionDigits: noDigits ? 0 : 2, + minimumFractionDigits: noDigits ? 0 : 2, + }); +}; From 85887d515feaaee4dff23a588c3734e40fcbac87 Mon Sep 17 00:00:00 2001 From: Huss Martinez Date: Mon, 24 Mar 2025 20:16:41 +0700 Subject: [PATCH 2/2] chore: get project metadata from application metadata --- packages/data-layer/src/data-layer.ts | 28 ++++++++++++++------------- packages/data-layer/src/data.types.ts | 1 + 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/data-layer/src/data-layer.ts b/packages/data-layer/src/data-layer.ts index 145e8fedb3..640980500e 100644 --- a/packages/data-layer/src/data-layer.ts +++ b/packages/data-layer/src/data-layer.ts @@ -59,7 +59,7 @@ import { getRoundsForManagerByAddress, getDirectDonationsByProjectId, } from "./queries"; -import { mergeCanonicalAndLinkedProjects, orderByMapping } from "./utils"; +import { orderByMapping } from "./utils"; import { AttestationService, type MintingAttestationIdsData, @@ -719,18 +719,20 @@ export class DataLayer { anchorAddress: application.anchorAddress, recipient: application.metadata.application.recipient, projectMetadata: { - title: application.project.metadata.title, - description: application.project.metadata.description, - website: application.project.metadata.website, - logoImg: application.project.metadata.logoImg, - bannerImg: application.project.metadata.bannerImg, - projectTwitter: application.project.metadata.projectTwitter, - userGithub: application.project.metadata.userGithub, - projectGithub: application.project.metadata.projectGithub, - credentials: application.project.metadata.credentials, - owners: application.project.metadata.owners, - createdAt: application.project.metadata.createdAt, - lastUpdated: application.project.metadata.lastUpdated, + title: application.metadata.application.project.title, + description: application.metadata.application.project.description, + website: application.metadata.application.project.website, + logoImg: application.metadata.application.project.logoImg, + bannerImg: application.metadata.application.project.bannerImg, + projectTwitter: + application.metadata.application.project.projectTwitter, + userGithub: application.metadata.application.project.userGithub, + projectGithub: + application.metadata.application.project.projectGithub, + credentials: application.metadata.application.project.credentials, + owners: application.metadata.application.project.owners, + createdAt: application.metadata.application.project.createdAt, + lastUpdated: application.metadata.application.project.lastUpdated, }, grantApplicationFormAnswers: application.metadata.application.answers.map((answer) => ({ diff --git a/packages/data-layer/src/data.types.ts b/packages/data-layer/src/data.types.ts index babafe064a..32c27cefed 100644 --- a/packages/data-layer/src/data.types.ts +++ b/packages/data-layer/src/data.types.ts @@ -765,6 +765,7 @@ export type Application = { application: { recipient: string; answers: GrantApplicationFormAnswer[]; + project: ProjectMetadata & { id: string }; }; }; };