diff --git a/.env.example b/.env.example index a9d155aa26..7303185128 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,5 @@ REACT_APP_GRANT_EXPLORER=https://explorer.gitcoin.co # Coingecko REACT_APP_COINGECKO_API_KEY= # --------------------------- + +REACT_APP_STAKING_APP=https://staking-hub-mu.vercel.app diff --git a/packages/grant-explorer/public/index.html b/packages/grant-explorer/public/index.html index 1347db6a63..8b8968e7c1 100644 --- a/packages/grant-explorer/public/index.html +++ b/packages/grant-explorer/public/index.html @@ -40,11 +40,14 @@ --> diff --git a/packages/grant-explorer/src/datadog.tsx b/packages/grant-explorer/src/datadog.tsx index 638b66d254..a40df55936 100644 --- a/packages/grant-explorer/src/datadog.tsx +++ b/packages/grant-explorer/src/datadog.tsx @@ -1,12 +1,15 @@ import { datadogRum } from "@datadog/browser-rum"; import { datadogLogs } from "@datadog/browser-logs"; +const isDevelopment = process.env.NODE_ENV === "development"; /** * Initialize datadog at a global level * - Datadog Real User Monitoring (RUM) : https://www.npmjs.com/package/@datadog/browser-rum * - Datadog Browser Logs : https://www.npmjs.com/package/@datadog/browser-logs */ export const initDatadog = () => { + if (isDevelopment) return; + // Init datadog-rum datadogRum.init({ applicationId: process.env.REACT_APP_DATADOG_APPLICATION_ID || "", diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx deleted file mode 100644 index c2ad65221c..0000000000 --- a/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx +++ /dev/null @@ -1,683 +0,0 @@ -import { datadogLogs } from "@datadog/browser-logs"; -import { - ArrowTrendingUpIcon, - LinkIcon, - ShieldCheckIcon, -} from "@heroicons/react/24/solid"; -import { - formatDateWithOrdinal, - renderToHTML, - useParams, - useValidateCredential, -} from "common"; -import { getAlloVersion } from "common/src/config"; -import { formatDistanceToNowStrict } from "date-fns"; -import React, { - ComponentProps, - ComponentPropsWithRef, - createElement, - FunctionComponent, - PropsWithChildren, - useEffect, - useMemo, - useState, -} from "react"; -import { useAccount, useEnsName } from "wagmi"; -import DefaultLogoImage from "../../assets/default_logo.png"; -import { ReactComponent as GithubIcon } from "../../assets/github-logo.svg"; -import { ReactComponent as TwitterIcon } from "../../assets/twitter-logo.svg"; -import { ReactComponent as EthereumIcon } from "common/src/assets/ethereum-icon.svg"; -import { ReactComponent as GlobeIcon } from "../../assets/icons/globe-icon.svg"; -import { useRoundById } from "../../context/RoundContext"; -import { CartProject, GrantApplicationFormAnswer, Project } from "../api/types"; -import { ProjectBanner } from "../common/ProjectBanner"; -import RoundEndedBanner from "../common/RoundEndedBanner"; -import Breadcrumb, { BreadcrumbItem } from "../common/Breadcrumb"; -import { isDirectRound, isInfiniteDate } from "../api/utils"; -import { useCartStorage } from "../../store"; -import { Box, Skeleton, SkeletonText, Tab, Tabs } from "@chakra-ui/react"; -import { GrantList } from "./KarmaGrant/GrantList"; -import { ImpactList } from "./KarmaGrant/ImpactList"; -import { useGap } from "../api/gap"; -import { StatList } from "./OSO/ImpactStats"; -import { useOSO } from "../api/oso"; -import { CheckIcon, ShoppingCartIcon } from "@heroicons/react/24/outline"; -import { - Application, - BaseQuestion, - Round, - RoundApplicationQuestion, - useDataLayer, -} from "data-layer"; -import { DefaultLayout } from "../common/DefaultLayout"; -import { - mapApplicationToProject, - mapApplicationToRound, - useApplication, -} from "../projects/hooks/useApplication"; -import { PassportWidget } from "../common/PassportWidget"; -import CopyToClipboard from "common/src/components/CopyToClipboard"; - -const CalendarIcon = (props: React.SVGProps) => { - return ( - - - - ); -}; - -const useProjectDetailsParams = useParams<{ - chainId: string; - roundId: string; - applicationId: string; -}>; - -export default function ViewProjectDetails() { - const [selectedTab, setSelectedTab] = useState(0); - - datadogLogs.logger.info( - "====> Route: /round/:chainId/:roundId/:applicationId" - ); - datadogLogs.logger.info(`====> URL: ${window.location.href}`); - const { - chainId, - roundId, - applicationId: paramApplicationId, - } = useProjectDetailsParams(); - const dataLayer = useDataLayer(); - const { address: walletAddress } = useAccount(); - - let applicationId: string; - - /// handle URLs where the application ID is ${roundId}-${applicationId} - if (paramApplicationId.includes("-")) { - applicationId = paramApplicationId.split("-")[1]; - } else { - applicationId = paramApplicationId; - } - - const { - data: application, - error, - isLoading, - } = useApplication( - { - chainId: Number(chainId as string), - roundId, - applicationId: applicationId, - }, - dataLayer - ); - const { round: roundDetails } = useRoundById(Number(chainId), roundId); - - const projectToRender = application && mapApplicationToProject(application); - const round = application && mapApplicationToRound(application); - - round && (round.chainId = Number(chainId)); - const isSybilDefenseEnabled = - round?.roundMetadata?.quadraticFundingConfig?.sybilDefense === true || - round?.roundMetadata?.quadraticFundingConfig?.sybilDefense !== "none"; - - const { grants, impacts } = useGap( - projectToRender?.projectRegistryId as string - ); - const { stats } = useOSO( - projectToRender?.projectMetadata.projectGithub as string - ); - - const currentTime = new Date(); - const isAfterRoundEndDate = - round && - (isInfiniteDate(round.roundEndTime) - ? false - : round && round.roundEndTime <= currentTime); - - const isBeforeRoundStartDate = - round && - (isInfiniteDate(round.roundStartTime) - ? false - : round && currentTime < round.roundStartTime); - - 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]); - - const disableAddToCartButton = - (alloVersion === "allo-v2" && roundId.startsWith("0x")) || - isAfterRoundEndDate || - isBeforeRoundStartDate; - const { projects, add, remove } = useCartStorage(); - - const isAlreadyInCart = projects.some( - (project) => - project.grantApplicationId === applicationId && - project.chainId === Number(chainId) && - project.roundId === roundId - ); - const cartProject = projectToRender as CartProject; - - if (cartProject !== undefined) { - cartProject.roundId = roundId; - cartProject.chainId = Number(chainId); - cartProject.grantApplicationId = applicationId; - } - - const breadCrumbs = [ - { - name: "Explorer Home", - path: "/", - }, - { - name: round?.roundMetadata?.name, - path: `/round/${chainId}/${roundId}`, - }, - { - name: "Project Details", - path: `/round/${chainId}/${roundId}/${applicationId}`, - }, - ] as BreadcrumbItem[]; - - const { - projectMetadata: { title, description = "", bannerImg }, - } = projectToRender ?? { projectMetadata: {} }; - const projectDetailsTabs = useMemo( - () => [ - { - name: "Project details", - content: ( - <> -

- About -

- {projectToRender ? ( - <> - - - - ) : ( - - )} - - ), - }, - { - name: "Impact Measurement", - content: ( - - - 0 && impacts.length === 0 - } - /> - 0} - /> - - ), - }, - ], - [stats, grants, projectToRender, description, impacts, roundDetails] - ); - - const handleTabChange = (tabIndex: number) => { - setSelectedTab(tabIndex); - }; - - return ( - <> - - {isAfterRoundEndDate && ( -
- -
- )} -
-
- -
- {walletAddress && round && isSybilDefenseEnabled && ( -
- -
- )} -
-
- -
-
-
- -
-
-
-
-
- {round && !isDirectRound(round) && ( - { - remove(cartProject); - }} - addToCart={() => { - add(cartProject); - }} - /> - )} -
- {error === undefined && - !isLoading && - projectToRender !== undefined ? ( - <> - -

- {title} -

-
- - tab.name)} - /> -
- {projectDetailsTabs[selectedTab].content} -
- - ) : ( -

Couldn't load project data. It may not exist.

- )} -
-
-
- - ); -} - -function ProjectDetailsTabs(props: { - tabs: string[]; - onChange?: (tabIndex: number) => void; - selected: number; -}) { - return ( - - {props.tabs.length > 0 && ( - - {props.tabs.map((tab, index) => ( - {tab} - ))} - - )} - - ); -} - -function ProjectLinks({ project }: { project?: Project }) { - const { - recipient, - projectMetadata: { - createdAt, - website, - projectTwitter, - projectGithub, - userGithub, - credentials, - }, - } = project ?? { projectMetadata: {} }; - - // @ts-expect-error Temp until viem (could also cast recipient as Address or update the type) - const ens = useEnsName({ address: recipient, enabled: Boolean(recipient) }); - - const { isValid: validTwitterCredential } = useValidateCredential( - credentials?.twitter, - projectTwitter - ); - - const { isValid: validGithubCredential } = useValidateCredential( - credentials?.github, - projectGithub - ); - - const createdOn = - createdAt && - `Created on: ${formatDateWithOrdinal(new Date(createdAt ?? 0))}`; - - return ( -
- - - - {createdOn} - - {website} - - {projectTwitter !== undefined && ( - - {projectTwitter} - - )} - {projectGithub !== undefined && ( - - {projectGithub} - - )} - {userGithub !== undefined && ( - - {userGithub} - - )} -
- ); -} - -function ProjectLink({ - icon, - children, - url, - isVerified, -}: PropsWithChildren<{ - icon: FunctionComponent>; - url?: string; - isVerified?: boolean; -}>) { - const Component = url ? "a" : "div"; - return children ? ( -
-
{createElement(icon, { className: "w-4 h-4 text-grey-400" })}
-
- - {children} - - {isVerified && } -
-
- ) : null; -} - -function VerifiedBadge() { - return ( - - - Verified - - ); -} - -function Detail(props: { text: string; testID: string }) { - return ( -

- ); -} - -function ApplicationFormAnswers(props: { - answers: GrantApplicationFormAnswer[]; - round: Round | undefined; -}) { - const roundQuestions = props.round?.applicationQuestions as (BaseQuestion & - RoundApplicationQuestion)[]; - let answers: GrantApplicationFormAnswer[] = []; - if (roundQuestions) { - answers = roundQuestions - .filter((q) => !q.hidden && !q.encrypted) - .map((q) => ({ - ...props.answers.find( - (a) => - a.questionId === q.id && a.question === q.title && a.type === q.type - ), - question: q.title, - })) - .filter((a): a is GrantApplicationFormAnswer => !!a.answer); - } - - if (answers.length === 0) { - answers = props.answers.filter((a) => !!a.answer && !a.hidden); - } - - if (answers.length === 0) { - return null; - } - - return ( -

-

- Additional Information -

-
- {answers.map((answer) => { - const answerText = Array.isArray(answer.answer) - ? answer.answer.join(", ") - : answer.answer; - return ( -
-

- {answer.question} -

- {answer.type === "paragraph" ? ( -

- ) : ( -

- )} -
- ); - })} -
-
- ); -} - -const ipfsGateway = process.env.REACT_APP_IPFS_BASE_URL; - -function ProjectLogo({ logoImg }: { logoImg?: string }) { - const src = logoImg ? `${ipfsGateway}/ipfs/${logoImg}` : DefaultLogoImage; - - return ( - Project Logo - ); -} - -function Sidebar(props: { - isAlreadyInCart: boolean; - isBeforeRoundEndDate?: boolean; - removeFromCart: () => void; - addToCart: () => void; -}) { - const { chainId, roundId, applicationId } = useProjectDetailsParams(); - const dataLayer = useDataLayer(); - - const { data: application } = useApplication( - { - chainId: Number(chainId as string), - roundId, - applicationId: applicationId, - }, - dataLayer - ); - - return ( -
-
- - {props.isBeforeRoundEndDate && ( - - )} -
- {!props.isBeforeRoundEndDate && ( - - - View history - - - )} -
- ); -} - -export function ProjectStats(props: { application: Application | undefined }) { - const { chainId, roundId } = useProjectDetailsParams(); - const { round } = useRoundById(Number(chainId), roundId); - const application = props.application; - - const timeRemaining = - round?.roundEndTime && !isInfiniteDate(round?.roundEndTime) - ? formatDistanceToNowStrict(round.roundEndTime) - : null; - const isBeforeRoundEndDate = - round && - (isInfiniteDate(round.roundEndTime) || round.roundEndTime > new Date()); - - return ( -
- - funding received in current round - - - contributors - - - - { - // If loading - render empty - isBeforeRoundEndDate === undefined - ? "" - : isBeforeRoundEndDate - ? "to go" - : "Round ended" - } - -
- ); -} - -export function Stat({ - value, - children, - isLoading, - className, -}: { - value?: string | number | null; - isLoading?: boolean; -} & ComponentProps<"div">) { - return ( -
- -

{value}

-
- {children} -
- ); -} - -function CartButtonToggle(props: { - isAlreadyInCart: boolean; - addToCart: () => void; - removeFromCart: () => void; -}) { - return ( - - ); -} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/ViewProjectDetails.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/ViewProjectDetails.tsx new file mode 100644 index 0000000000..31527c64b5 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/ViewProjectDetails.tsx @@ -0,0 +1,277 @@ +import { datadogLogs } from "@datadog/browser-logs"; +import { getAlloVersion } from "common/src/config"; +import React, { useEffect, useMemo, useState } from "react"; +import { useAccount } from "wagmi"; +import { useRoundById } from "../../../context/RoundContext"; +import { CartProject } from "../../api/types"; +import { ProjectBanner } from "../../common/ProjectBanner"; +import RoundEndedBanner from "../../common/RoundEndedBanner"; +import Breadcrumb, { BreadcrumbItem } from "../../common/Breadcrumb"; +import { isDirectRound, isInfiniteDate } from "../../api/utils"; +import { useCartStorage } from "../../../store"; +import { Skeleton, SkeletonText } from "@chakra-ui/react"; +import { GrantList } from "../KarmaGrant/GrantList"; +import { ImpactList } from "../KarmaGrant/ImpactList"; +import { useGap } from "../../api/gap"; +import { StatList } from "../OSO/ImpactStats"; +import { useOSO } from "../../api/oso"; +import { useDataLayer } from "data-layer"; +import { DefaultLayout } from "../../common/DefaultLayout"; +import { + mapApplicationToProject, + mapApplicationToRound, + useApplication, +} from "../../projects/hooks/useApplication"; +import { PassportWidget } from "../../common/PassportWidget"; +import { useProjectDetailsParams } from "./hooks/useProjectDetailsParams"; +import { + ApplicationFormAnswers, + Detail, + ProjectDetailsTabs, + ProjectLinks, + Sidebar, + ProjectLogo, + StakingBannerAndModal, +} from "./components"; + +export default function ViewProjectDetails() { + const [selectedTab, setSelectedTab] = useState(0); + + datadogLogs.logger.info( + "====> Route: /round/:chainId/:roundId/:applicationId" + ); + datadogLogs.logger.info(`====> URL: ${window.location.href}`); + const { + chainId, + roundId, + applicationId: paramApplicationId, + } = useProjectDetailsParams(); + const dataLayer = useDataLayer(); + const { address: walletAddress } = useAccount(); + + let applicationId: string; + + /// handle URLs where the application ID is ${roundId}-${applicationId} + if (paramApplicationId.includes("-")) { + applicationId = paramApplicationId.split("-")[1]; + } else { + applicationId = paramApplicationId; + } + + const { + data: application, + error, + isLoading, + } = useApplication( + { + chainId: Number(chainId as string), + roundId, + applicationId: applicationId, + }, + dataLayer + ); + const { round: roundDetails } = useRoundById(Number(chainId), roundId); + + const projectToRender = application && mapApplicationToProject(application); + const round = application && mapApplicationToRound(application); + + round && (round.chainId = Number(chainId)); + const isSybilDefenseEnabled = + round?.roundMetadata?.quadraticFundingConfig?.sybilDefense === true || + round?.roundMetadata?.quadraticFundingConfig?.sybilDefense !== "none"; + + const { grants, impacts } = useGap( + projectToRender?.projectRegistryId as string + ); + const { stats } = useOSO( + projectToRender?.projectMetadata.projectGithub as string + ); + + const currentTime = new Date(); + const isAfterRoundEndDate = + round && + (isInfiniteDate(round.roundEndTime) + ? false + : round && round.roundEndTime <= currentTime); + + const isBeforeRoundStartDate = + round && + (isInfiniteDate(round.roundStartTime) + ? false + : round && currentTime < round.roundStartTime); + + 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]); + + const disableAddToCartButton = + (alloVersion === "allo-v2" && roundId.startsWith("0x")) || + isAfterRoundEndDate || + isBeforeRoundStartDate; + const { projects, add, remove } = useCartStorage(); + + const isAlreadyInCart = projects.some( + (project) => + project.grantApplicationId === applicationId && + project.chainId === Number(chainId) && + project.roundId === roundId + ); + const cartProject = projectToRender as CartProject; + + if (cartProject !== undefined) { + cartProject.roundId = roundId; + cartProject.chainId = Number(chainId); + cartProject.grantApplicationId = applicationId; + } + + const breadCrumbs = [ + { + name: "Explorer Home", + path: "/", + }, + { + name: round?.roundMetadata?.name, + path: `/round/${chainId}/${roundId}`, + }, + { + name: "Project Details", + path: `/round/${chainId}/${roundId}/${applicationId}`, + }, + ] as BreadcrumbItem[]; + + const { + projectMetadata: { title, description = "", bannerImg }, + } = projectToRender ?? { projectMetadata: {} }; + const projectDetailsTabs = useMemo( + () => [ + { + name: "Project details", + content: ( + <> +

+ About +

+ {projectToRender ? ( + <> + + + + ) : ( + + )} + + ), + }, + { + name: "Impact Measurement", + content: ( + + + 0 && impacts.length === 0 + } + /> + 0} + /> + + ), + }, + ], + [stats, grants, projectToRender, description, impacts, roundDetails] + ); + + const handleTabChange = (tabIndex: number) => { + setSelectedTab(tabIndex); + }; + + return ( + <> + + {isAfterRoundEndDate && ( +
+ +
+ )} +
+
+ +
+ {walletAddress && round && isSybilDefenseEnabled && ( +
+ +
+ )} +
+
+ +
+
+
+ +
+
+
+
+
+ {round && !isDirectRound(round) && ( + { + remove(cartProject); + }} + addToCart={() => { + add(cartProject); + }} + /> + )} +
+ {error === undefined && + !isLoading && + projectToRender !== undefined ? ( + <> + +

+ {title} +

+
+ + + tab.name)} + /> +
+ {projectDetailsTabs[selectedTab].content} +
+ + ) : ( +

Couldn't load project data. It may not exist.

+ )} +
+
+
+ + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ApplicationFormAnswers.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ApplicationFormAnswers.tsx new file mode 100644 index 0000000000..81cc44bf4b --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ApplicationFormAnswers.tsx @@ -0,0 +1,69 @@ +import { BaseQuestion, Round, RoundApplicationQuestion } from "data-layer"; +import { GrantApplicationFormAnswer } from "../../../api/types"; +import { renderToHTML } from "common"; + +export function ApplicationFormAnswers(props: { + answers: GrantApplicationFormAnswer[]; + round: Round | undefined; +}) { + const roundQuestions = props.round?.applicationQuestions as (BaseQuestion & + RoundApplicationQuestion)[]; + let answers: GrantApplicationFormAnswer[] = []; + if (roundQuestions) { + answers = roundQuestions + .filter((q) => !q.hidden && !q.encrypted) + .map((q) => ({ + ...props.answers.find( + (a) => + a.questionId === q.id && a.question === q.title && a.type === q.type + ), + question: q.title, + })) + .filter((a): a is GrantApplicationFormAnswer => !!a.answer); + } + + if (answers.length === 0) { + answers = props.answers.filter((a) => !!a.answer && !a.hidden); + } + + if (answers.length === 0) { + return null; + } + + return ( +
+

+ Additional Information +

+
+ {answers.map((answer) => { + const answerText = Array.isArray(answer.answer) + ? answer.answer.join(", ") + : answer.answer; + return ( +
+

+ {answer.question} +

+ {answer.type === "paragraph" ? ( +

+ ) : ( +

+ )} +
+ ); + })} +
+
+ ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/CalendarIcon.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/CalendarIcon.tsx new file mode 100644 index 0000000000..8d56b1dbaa --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/CalendarIcon.tsx @@ -0,0 +1,19 @@ +export const CalendarIcon = (props: React.SVGProps) => { + return ( + + + + ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/CartButtonToggle.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/CartButtonToggle.tsx new file mode 100644 index 0000000000..72347316dc --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/CartButtonToggle.tsx @@ -0,0 +1,24 @@ +import { CheckIcon, ShoppingCartIcon } from "@heroicons/react/24/outline"; + +export function CartButtonToggle(props: { + isAlreadyInCart: boolean; + addToCart: () => void; + removeFromCart: () => void; +}) { + return ( + + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/Detail.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/Detail.tsx new file mode 100644 index 0000000000..3998147a3e --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/Detail.tsx @@ -0,0 +1,13 @@ +import { renderToHTML } from "common"; + +export function Detail(props: { text: string; testID: string }) { + return ( +

+ ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectDetailsTab.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectDetailsTab.tsx new file mode 100644 index 0000000000..a61591d972 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectDetailsTab.tsx @@ -0,0 +1,23 @@ +import { Box, Tab, Tabs } from "@chakra-ui/react"; + +export function ProjectDetailsTabs(props: { + tabs: string[]; + onChange?: (tabIndex: number) => void; + selected: number; +}) { + return ( + + {props.tabs.length > 0 && ( + + {props.tabs.map((tab, index) => ( + {tab} + ))} + + )} + + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectLink.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectLink.tsx new file mode 100644 index 0000000000..3fb4a9dbf1 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectLink.tsx @@ -0,0 +1,35 @@ +import { + ComponentPropsWithRef, + createElement, + FunctionComponent, + PropsWithChildren, +} from "react"; +import { VerifiedBadge } from "./VerifiedBadge"; + +export function ProjectLink({ + icon, + children, + url, + isVerified, +}: PropsWithChildren<{ + icon: FunctionComponent>; + url?: string; + isVerified?: boolean; +}>) { + const Component = url ? "a" : "div"; + return children ? ( +

+
{createElement(icon, { className: "w-4 h-4 text-grey-400" })}
+
+ + {children} + + {isVerified && } +
+
+ ) : null; +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectLinks.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectLinks.tsx new file mode 100644 index 0000000000..a5f7557182 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectLinks.tsx @@ -0,0 +1,81 @@ +import { ReactComponent as GithubIcon } from "../../../../assets/github-logo.svg"; +import { ReactComponent as TwitterIcon } from "../../../../assets/twitter-logo.svg"; +import { ReactComponent as EthereumIcon } from "common/src/assets/ethereum-icon.svg"; +import { ReactComponent as GlobeIcon } from "../../../../assets/icons/globe-icon.svg"; +import { Project } from "../../../api/types"; +import { formatDateWithOrdinal, useValidateCredential } from "common"; +import { useEnsName } from "wagmi"; +import { ProjectLink } from "./ProjectLink"; +import CopyToClipboard from "common/src/components/CopyToClipboard"; +import { CalendarIcon } from "./CalendarIcon"; + +export function ProjectLinks({ project }: { project?: Project }) { + const { + recipient, + projectMetadata: { + createdAt, + website, + projectTwitter, + projectGithub, + userGithub, + credentials, + }, + } = project ?? { projectMetadata: {} }; + + // @ts-expect-error Temp until viem (could also cast recipient as Address or update the type) + const ens = useEnsName({ address: recipient, enabled: Boolean(recipient) }); + + const { isValid: validTwitterCredential } = useValidateCredential( + credentials?.twitter, + projectTwitter + ); + + const { isValid: validGithubCredential } = useValidateCredential( + credentials?.github, + projectGithub + ); + + const createdOn = + createdAt && + `Created on: ${formatDateWithOrdinal(new Date(createdAt ?? 0))}`; + + return ( +
+ + + + {createdOn} + + {website} + + {projectTwitter !== undefined && ( + + {projectTwitter} + + )} + {projectGithub !== undefined && ( + + {projectGithub} + + )} + {userGithub !== undefined && ( + + {userGithub} + + )} +
+ ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectLogo.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectLogo.tsx new file mode 100644 index 0000000000..28d377a20e --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectLogo.tsx @@ -0,0 +1,15 @@ +import DefaultLogoImage from "../../../../assets/default_logo.png"; + +const ipfsGateway = process.env.REACT_APP_IPFS_BASE_URL; + +export function ProjectLogo({ logoImg }: { logoImg?: string }) { + const src = logoImg ? `${ipfsGateway}/ipfs/${logoImg}` : DefaultLogoImage; + + return ( + Project Logo + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectStats.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectStats.tsx new file mode 100644 index 0000000000..44e7aa120a --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/ProjectStats.tsx @@ -0,0 +1,54 @@ +import { Stat } from "./Stat"; +import { formatDistanceToNowStrict } from "date-fns"; +import { Application } from "data-layer"; +import { useProjectDetailsParams } from "../hooks/useProjectDetailsParams"; +import { useRoundById } from "../../../../context/RoundContext"; +import { isInfiniteDate } from "../../../api/utils"; + +export function ProjectStats(props: { application: Application | undefined }) { + const { chainId, roundId } = useProjectDetailsParams(); + const { round } = useRoundById(Number(chainId), roundId); + const application = props.application; + + const timeRemaining = + round?.roundEndTime && !isInfiniteDate(round?.roundEndTime) + ? formatDistanceToNowStrict(round.roundEndTime) + : null; + const isBeforeRoundEndDate = + round && + (isInfiniteDate(round.roundEndTime) || round.roundEndTime > new Date()); + + return ( +
+ + funding received in current round + + + contributors + + + + { + // If loading - render empty + isBeforeRoundEndDate === undefined + ? "" + : isBeforeRoundEndDate + ? "to go" + : "Round ended" + } + +
+ ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/Sidebar.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/Sidebar.tsx new file mode 100644 index 0000000000..278f3be812 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/Sidebar.tsx @@ -0,0 +1,51 @@ +import { ArrowTrendingUpIcon, LinkIcon } from "@heroicons/react/24/solid"; +import { useProjectDetailsParams } from "../hooks/useProjectDetailsParams"; +import { useDataLayer } from "data-layer"; +import { useApplication } from "../../../projects/hooks/useApplication"; +import { CartButtonToggle } from "./CartButtonToggle"; +import { ProjectStats } from "./ProjectStats"; + +export function Sidebar(props: { + isAlreadyInCart: boolean; + isBeforeRoundEndDate?: boolean; + removeFromCart: () => void; + addToCart: () => void; +}) { + const { chainId, roundId, applicationId } = useProjectDetailsParams(); + const dataLayer = useDataLayer(); + + const { data: application } = useApplication( + { + chainId: Number(chainId as string), + roundId, + applicationId: applicationId, + }, + dataLayer + ); + + return ( +
+
+ + {props.isBeforeRoundEndDate && ( + + )} +
+ {!props.isBeforeRoundEndDate && ( + + + View history + + + )} +
+ ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBanner.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBanner.tsx new file mode 100644 index 0000000000..2ac4b2019a --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBanner.tsx @@ -0,0 +1,30 @@ +import { Button } from "common/src/styles"; + +const STAKING_BANNER_TITLE = + "🔥 Boost grants, earn rewards, and shape the round!"; +const STAKING_BANNER_TEXT = + "Stake GTC during GG23 to upvote your favorite grants and increase their visibility in the round. The more you stake, the higher they rank—and the more rewards you can claim from the 3% rewards pool!"; +const STAKING_BUTTON_TEXT = "Stake on this project"; +const STAKING_BUTTON_TEXT_MOBILE = "Stake"; + +export const StakingBanner = ({ onClick }: { onClick?: () => void }) => { + return ( +
+
+

{STAKING_BANNER_TITLE}

+

{STAKING_BANNER_TEXT}

+
+ +
+ ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBannerAndModal.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBannerAndModal.tsx new file mode 100644 index 0000000000..1667fa567d --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingBannerAndModal.tsx @@ -0,0 +1,58 @@ +import { StakingModal } from "./StakingModal"; +import { useCallback, useState } from "react"; +import { StakingBanner } from "./StakingBanner"; +import { useProjectDetailsParams } from "../../hooks/useProjectDetailsParams"; +import { useIsStakable } from "./hooks/useIsStakable"; + +export const StakingBannerAndModal = () => { + const [isOpen, setIsOpen] = useState(false); + + const { + chainId, + roundId, + applicationId: paramApplicationId, + } = useProjectDetailsParams(); + + const applicationId = paramApplicationId.includes("-") + ? paramApplicationId.split("-")[1] + : paramApplicationId; + + const stakingAppUrl = "https://staking-hub-mu.vercel.app"; // TODO: from env + const stakeProjectUrl = `${stakingAppUrl}/#/staking-round/${chainId}/${roundId}?id=${applicationId}`; + + const handleCloseModal = useCallback(() => { + setIsOpen(false); + }, []); + + const handleOpenModal = useCallback(() => { + setIsOpen(true); + }, []); + + const handleStake = useCallback(() => { + window.open(stakeProjectUrl, "_blank"); + handleCloseModal(); + }, [handleCloseModal, stakeProjectUrl]); + + const chainIdNumber = chainId ? parseInt(chainId, 10) : 0; + + const isStakable = useIsStakable({ + chainId: chainIdNumber, + roundId, + applicationId, + }); + + if (!isStakable) { + return null; + } + + return ( +
+ + +
+ ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingModal.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingModal.tsx new file mode 100644 index 0000000000..6fd078efe6 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/StakingModal.tsx @@ -0,0 +1,90 @@ +import { Button } from "common/src/styles"; +import { BaseModal } from "../../../../common/BaseModal"; +import { ArrowRightIcon } from "@heroicons/react/20/solid"; + +const TITLE = "You're about to stake GTC on this project"; +const DESCRIPTION = + "To complete your stake, you’ll be redirected to a new tab. Once you confirm your transaction, your support will be reflected on the round page."; + +const CHECK_POINTS = [ + "Boost this project’s visibility", + "Earn a share of the 3% rewards pool", +]; + +const Title = () => ( +
+
+ {TITLE} +
+
+ {DESCRIPTION} +
+
+); + +const CheckPoints = () => ( +
+ {CHECK_POINTS.map((point, index) => ( +
{`✅ ${point}`}
+ ))} +
+); + +const Content = () => ( +
+ + <CheckPoints /> + </div> +); + +const ActionButtons = ({ + onCancel, + onStake, +}: { + onCancel: () => void; + onStake: () => void; +}) => ( + <div className="flex justify-center gap-6 font-mono font-medium text-sm leading-6"> + <Button + type="button" + $variant="outline" + className={`inline-flex text-black py-2 px-4`} + onClick={onCancel} + data-testid={"modal-cancel"} + > + Cancel + </Button> + <Button + type="button" + className={`inline-flex bg-[#22635A] text-white py-2 px-4`} + onClick={onStake} + data-testid={"modal-stake"} + > + <div className="flex items-center gap-2"> + Stake GTC <ArrowRightIcon className="w-4 h-4 no-shrink" /> + </div> + </Button> + </div> +); + +export const StakingModal = ({ + isOpen, + onClose, + onStake, +}: { + isOpen: boolean; + onClose: () => void; + onStake: () => void; +}) => { + return ( + <BaseModal isOpen={isOpen} onClose={onClose} size="2xl"> + <div className="flex flex-col gap-8"> + <Content /> + <ActionButtons onCancel={onClose} onStake={onStake} /> + </div> + </BaseModal> + ); +}; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsDonationPeriod.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsDonationPeriod.tsx new file mode 100644 index 0000000000..055b6a3723 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsDonationPeriod.tsx @@ -0,0 +1,47 @@ +import { useApplication } from "../../../../../projects/hooks/useApplication"; +import { useDataLayer } from "data-layer"; +import { isInfiniteDate } from "../../../../../api/utils"; +import { useMemo } from "react"; + +export const useIsDonationPeriod = ({ + chainId, + roundId, + applicationId, +}: { + chainId: number; + roundId: string; + applicationId: string; +}) => { + const dataLayer = useDataLayer(); + + const { data: application } = useApplication( + { + chainId, + roundId, + applicationId, + }, + dataLayer + ); + + const isDonationPeriod = useMemo<boolean | undefined>(() => { + if (!application) return undefined; + const { donationsStartTime, donationsEndTime } = application?.round ?? {}; + if ( + !donationsStartTime || + !donationsEndTime || + isInfiniteDate(new Date(donationsStartTime)) || + isInfiniteDate(new Date(donationsEndTime)) + ) + return false; + + const currentTime = new Date(); + const donationsStartTimeDate = new Date("2025-03-02T12:00:00+00:00"); + const donationsEndTimeDate = new Date(donationsEndTime); + return ( + currentTime >= donationsStartTimeDate && + currentTime <= donationsEndTimeDate + ); + }, [application]); + + return isDonationPeriod; +}; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable.tsx new file mode 100644 index 0000000000..cd50afeb3a --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/hooks/useIsStakable.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { useIsDonationPeriod } from "./useIsDonationPeriod"; + +// TODO: either from metadata or from env value +// ONLY GITCOIN ROUNDS OF GG23 +const STAKABLE_ROUNDS: Array<{ chainId: number; roundId: string }> = [ + { chainId: 42161, roundId: "863" }, + { chainId: 42161, roundId: "865" }, + { chainId: 42161, roundId: "867" }, + // { chainId: 42220, roundId: "27" }, + // { chainId: 42220, roundId: "28" }, + // { chainId: 42220, roundId: "29" }, + // { chainId: 42220, roundId: "30" }, + // { chainId: 42220, roundId: "31" }, + // { chainId: 42220, roundId: "32" }, + // { chainId: 42220, roundId: "33" }, + // { chainId: 42220, roundId: "34" }, + // { chainId: 42220, roundId: "35" }, +]; + +export const useIsStakable = ({ + chainId, + roundId, + applicationId, +}: { + chainId: number; + roundId: string; + applicationId: string; +}) => { + const isDonationPeriod = useIsDonationPeriod({ + chainId, + roundId, + applicationId, + }); + + const isStakable = useMemo(() => { + if (!isDonationPeriod) return false; + return STAKABLE_ROUNDS.some( + (round) => round.chainId === chainId && round.roundId === roundId + ); + }, [isDonationPeriod, chainId, roundId]); + + return isStakable; +}; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/index.ts b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/index.ts new file mode 100644 index 0000000000..86c546ce42 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/StakingBannerAndModal/index.ts @@ -0,0 +1 @@ +export * from "./StakingBannerAndModal"; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/Stat.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/Stat.tsx new file mode 100644 index 0000000000..b09ab79944 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/Stat.tsx @@ -0,0 +1,21 @@ +import { ComponentProps } from "react"; +import { Skeleton } from "@chakra-ui/react"; + +export function Stat({ + value, + children, + isLoading, + className, +}: { + value?: string | number | null; + isLoading?: boolean; +} & ComponentProps<"div">) { + return ( + <div className={`flex flex-col ${className}`}> + <Skeleton isLoaded={!isLoading} height={"36px"}> + <h4 className="text-3xl">{value}</h4> + </Skeleton> + <span className="text-sm md:text-base">{children}</span> + </div> + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/VerifiedBadge.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/VerifiedBadge.tsx new file mode 100644 index 0000000000..0a4ebdb3a1 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/VerifiedBadge.tsx @@ -0,0 +1,10 @@ +import { ShieldCheckIcon } from "@heroicons/react/24/solid"; + +export function VerifiedBadge() { + return ( + <span className="bg-teal-100 flex gap-2 rounded-full px-2 text-xs items-center font-modern-era-medium text-teal-500"> + <ShieldCheckIcon className="w-4 h-4" /> + Verified + </span> + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/components/index.ts b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/index.ts new file mode 100644 index 0000000000..a7c07d89c4 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/components/index.ts @@ -0,0 +1,9 @@ +export { ApplicationFormAnswers } from "./ApplicationFormAnswers"; +export { Detail } from "./Detail"; +export { ProjectDetailsTabs } from "./ProjectDetailsTab"; +export { ProjectLinks } from "./ProjectLinks"; +export { ProjectLogo } from "./ProjectLogo"; +export { Sidebar } from "./Sidebar"; +export { ProjectStats } from "./ProjectStats"; +export { Stat } from "./Stat"; +export { StakingBannerAndModal } from "./StakingBannerAndModal"; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/hooks/useProjectDetailsParams.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails/hooks/useProjectDetailsParams.tsx new file mode 100644 index 0000000000..f60bdff82a --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/hooks/useProjectDetailsParams.tsx @@ -0,0 +1,7 @@ +import { useParams } from "common"; + +export const useProjectDetailsParams = useParams<{ + chainId: string; + roundId: string; + applicationId: string; +}>; diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails/index.ts b/packages/grant-explorer/src/features/round/ViewProjectDetails/index.ts new file mode 100644 index 0000000000..0ba44fda4c --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails/index.ts @@ -0,0 +1,3 @@ +import ViewProjectDetails from "./ViewProjectDetails"; +export { ProjectStats, Stat } from "./components"; +export default ViewProjectDetails;