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 };
};
};
};
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
-
-
-
{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)} />
-
-
-
-
-
-
-
-
-
-
- >
- );
-};
-
-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" ? (
- window.open(twitterShareUrl, "_blank")}
- className="w-full flex items-center justify-center gap-2 font-mono hover:opacity-70 transition-all shadow-sm border px-4 py-2 rounded-lg border-black hover:shadow-md"
- >
-
- Share on X
-
- ) : (
- window.open(farcasterShareUrl, "_blank")}
- className="w-full flex items-center justify-center gap-2 font-mono hover:opacity-70 transition-all shadow-sm border px-4 py-2 rounded-lg border-black hover:shadow-md"
- >
-
-
-
- Share on Warpcast
-
- )}
- >
- );
-};
-
-const ShareStatsButton = ({
- handleClick,
-}: {
- handleClick: () => void;
-}): JSX.Element => {
- return (
-
-
- Share
-
- );
-};
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
+
+
+
{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)} />
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
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" ? (
+ window.open(twitterShareUrl, "_blank")}
+ className="w-full flex items-center justify-center gap-2 font-mono hover:opacity-70 transition-all shadow-sm border px-4 py-2 rounded-lg border-black hover:shadow-md"
+ >
+
+ Share on X
+
+ ) : (
+ window.open(farcasterShareUrl, "_blank")}
+ className="w-full flex items-center justify-center gap-2 font-mono hover:opacity-70 transition-all shadow-sm border px-4 py-2 rounded-lg border-black hover:shadow-md"
+ >
+
+
+
+ Share on Warpcast
+
+ )}
+ >
+ );
+};
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 (
+
+
+ Share
+
+ );
+};
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,
+ });
+};