Skip to content

Commit 3ddce51

Browse files
committed
chore: split ViewProjectDetails components into separate files
1 parent 0dc819c commit 3ddce51

16 files changed

Lines changed: 446 additions & 427 deletions

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

Lines changed: 15 additions & 426 deletions
Large diffs are not rendered by default.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { BaseQuestion, Round, RoundApplicationQuestion } from "data-layer";
2+
import { GrantApplicationFormAnswer } from "../../../api/types";
3+
import { renderToHTML } from "common";
4+
5+
export function ApplicationFormAnswers(props: {
6+
answers: GrantApplicationFormAnswer[];
7+
round: Round | undefined;
8+
}) {
9+
const roundQuestions = props.round?.applicationQuestions as (BaseQuestion &
10+
RoundApplicationQuestion)[];
11+
let answers: GrantApplicationFormAnswer[] = [];
12+
if (roundQuestions) {
13+
answers = roundQuestions
14+
.filter((q) => !q.hidden && !q.encrypted)
15+
.map((q) => ({
16+
...props.answers.find(
17+
(a) =>
18+
a.questionId === q.id && a.question === q.title && a.type === q.type
19+
),
20+
question: q.title,
21+
}))
22+
.filter((a): a is GrantApplicationFormAnswer => !!a.answer);
23+
}
24+
25+
if (answers.length === 0) {
26+
answers = props.answers.filter((a) => !!a.answer && !a.hidden);
27+
}
28+
29+
if (answers.length === 0) {
30+
return null;
31+
}
32+
33+
return (
34+
<div>
35+
<h1 className="text-2xl mt-8 font-thin text-blue-800">
36+
Additional Information
37+
</h1>
38+
<div>
39+
{answers.map((answer) => {
40+
const answerText = Array.isArray(answer.answer)
41+
? answer.answer.join(", ")
42+
: answer.answer;
43+
return (
44+
<div key={answer.questionId}>
45+
<p className="text-md mt-8 mb-3 font-semibold text-blue-800">
46+
{answer.question}
47+
</p>
48+
{answer.type === "paragraph" ? (
49+
<p
50+
dangerouslySetInnerHTML={{
51+
__html: renderToHTML(answerText.replace(/\n/g, "\n\n")),
52+
}}
53+
className="text-md prose prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-a:text-blue-600"
54+
></p>
55+
) : (
56+
<p
57+
className="text-base text-blue-800"
58+
dangerouslySetInnerHTML={{
59+
__html: renderToHTML(answerText.replace(/\n/g, "\n\n")),
60+
}}
61+
></p>
62+
)}
63+
</div>
64+
);
65+
})}
66+
</div>
67+
</div>
68+
);
69+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export const CalendarIcon = (props: React.SVGProps<SVGSVGElement>) => {
2+
return (
3+
<svg
4+
width="16"
5+
height="16"
6+
viewBox="0 0 16 16"
7+
fill="none"
8+
xmlns="http://www.w3.org/2000/svg"
9+
{...props}
10+
>
11+
<path
12+
fillRule="evenodd"
13+
clipRule="evenodd"
14+
d="M4 0C3.44772 0 3 0.447715 3 1V2H2C0.895431 2 0 2.89543 0 4V14C0 15.1046 0.895431 16 2 16H14C15.1046 16 16 15.1046 16 14V4C16 2.89543 15.1046 2 14 2H13V1C13 0.447715 12.5523 0 12 0C11.4477 0 11 0.447715 11 1V2H5V1C5 0.447715 4.55228 0 4 0ZM4 5C3.44772 5 3 5.44772 3 6C3 6.55228 3.44772 7 4 7H12C12.5523 7 13 6.55228 13 6C13 5.44772 12.5523 5 12 5H4Z"
15+
fill="currentColor"
16+
/>
17+
</svg>
18+
);
19+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { CheckIcon, ShoppingCartIcon } from "@heroicons/react/24/outline";
2+
3+
export function CartButtonToggle(props: {
4+
isAlreadyInCart: boolean;
5+
addToCart: () => void;
6+
removeFromCart: () => void;
7+
}) {
8+
return (
9+
<button
10+
className="font-mono bg-blue-100 hover:bg-blue-300 hover:text-grey-50 transition-all w-full items-center justify-center rounded-b-3xl rounded-t-none p-4 inline-flex gap-2"
11+
data-testid={props.isAlreadyInCart ? "remove-from-cart" : "add-to-cart"}
12+
onClick={() =>
13+
props.isAlreadyInCart ? props.removeFromCart() : props.addToCart()
14+
}
15+
>
16+
{props.isAlreadyInCart ? (
17+
<CheckIcon className="w-5 h-5" />
18+
) : (
19+
<ShoppingCartIcon className="w-5 h-5" />
20+
)}
21+
{props.isAlreadyInCart ? "Added to cart" : "Add to cart"}
22+
</button>
23+
);
24+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { renderToHTML } from "common";
2+
3+
export function Detail(props: { text: string; testID: string }) {
4+
return (
5+
<p
6+
dangerouslySetInnerHTML={{
7+
__html: renderToHTML(props.text.replace(/\n/g, "\n\n")),
8+
}}
9+
className="text-blue-800 text-md prose prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-a:text-blue-600 max-w-full"
10+
data-testid={props.testID}
11+
/>
12+
);
13+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Box, Tab, Tabs } from "@chakra-ui/react";
2+
3+
export function ProjectDetailsTabs(props: {
4+
tabs: string[];
5+
onChange?: (tabIndex: number) => void;
6+
selected: number;
7+
}) {
8+
return (
9+
<Box className="" bottom={0.5}>
10+
{props.tabs.length > 0 && (
11+
<Tabs
12+
display="flex"
13+
onChange={props.onChange}
14+
defaultIndex={props.selected}
15+
>
16+
{props.tabs.map((tab, index) => (
17+
<Tab key={index}>{tab}</Tab>
18+
))}
19+
</Tabs>
20+
)}
21+
</Box>
22+
);
23+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React, {
2+
ComponentPropsWithRef,
3+
createElement,
4+
FunctionComponent,
5+
PropsWithChildren,
6+
} from "react";
7+
import { VerifiedBadge } from "./VerifiedBadge";
8+
9+
export function ProjectLink({
10+
icon,
11+
children,
12+
url,
13+
isVerified,
14+
}: PropsWithChildren<{
15+
icon: FunctionComponent<ComponentPropsWithRef<"svg">>;
16+
url?: string;
17+
isVerified?: boolean;
18+
}>) {
19+
const Component = url ? "a" : "div";
20+
return children ? (
21+
<div className="flex items-center gap-2">
22+
<div>{createElement(icon, { className: "w-4 h-4 text-grey-400" })}</div>
23+
<div className="flex gap-2">
24+
<Component
25+
href={url}
26+
target="_blank"
27+
className={url && "text-blue-300 hover:underline"}
28+
>
29+
{children}
30+
</Component>
31+
{isVerified && <VerifiedBadge />}
32+
</div>
33+
</div>
34+
) : null;
35+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { ReactComponent as GithubIcon } from "../../../../assets/github-logo.svg";
2+
import { ReactComponent as TwitterIcon } from "../../../../assets/twitter-logo.svg";
3+
import { ReactComponent as EthereumIcon } from "../../../../assets/icons/ethereum-icon.svg";
4+
import { ReactComponent as GlobeIcon } from "../../../../assets/icons/globe-icon.svg";
5+
import { Project } from "../../../api/types";
6+
import { formatDateWithOrdinal, useValidateCredential } from "common";
7+
import { useEnsName } from "wagmi";
8+
import { ProjectLink } from "./ProjectLink";
9+
import CopyToClipboard from "../../CopyToClipboard";
10+
import { CalendarIcon } from "./CalendarIcon";
11+
12+
export function ProjectLinks({ project }: { project?: Project }) {
13+
const {
14+
recipient,
15+
projectMetadata: {
16+
createdAt,
17+
website,
18+
projectTwitter,
19+
projectGithub,
20+
userGithub,
21+
credentials,
22+
},
23+
} = project ?? { projectMetadata: {} };
24+
25+
// @ts-expect-error Temp until viem (could also cast recipient as Address or update the type)
26+
const ens = useEnsName({ address: recipient, enabled: Boolean(recipient) });
27+
28+
const { isValid: validTwitterCredential } = useValidateCredential(
29+
credentials?.twitter,
30+
projectTwitter
31+
);
32+
33+
const { isValid: validGithubCredential } = useValidateCredential(
34+
credentials?.github,
35+
projectGithub
36+
);
37+
38+
const createdOn =
39+
createdAt &&
40+
`Created on: ${formatDateWithOrdinal(new Date(createdAt ?? 0))}`;
41+
42+
return (
43+
<div
44+
className={`grid md:grid-cols-2 gap-4 border-y-[2px] py-4 my-4 ${
45+
// isLoading?
46+
createdAt ? "" : "bg-grey-100 animate-pulse"
47+
}`}
48+
>
49+
<ProjectLink icon={EthereumIcon}>
50+
<CopyToClipboard text={ens.data || recipient} />
51+
</ProjectLink>
52+
<ProjectLink icon={CalendarIcon}>{createdOn}</ProjectLink>
53+
<ProjectLink url={website} icon={GlobeIcon}>
54+
{website}
55+
</ProjectLink>
56+
{projectTwitter !== undefined && (
57+
<ProjectLink
58+
url={`https://twitter.com/${projectTwitter}`}
59+
icon={TwitterIcon}
60+
isVerified={validTwitterCredential}
61+
>
62+
{projectTwitter}
63+
</ProjectLink>
64+
)}
65+
{projectGithub !== undefined && (
66+
<ProjectLink
67+
url={`https://github.com/${projectGithub}`}
68+
icon={GithubIcon}
69+
isVerified={validGithubCredential}
70+
>
71+
{projectGithub}
72+
</ProjectLink>
73+
)}
74+
{userGithub !== undefined && (
75+
<ProjectLink url={`https://github.com/${userGithub}`} icon={GithubIcon}>
76+
{userGithub}
77+
</ProjectLink>
78+
)}
79+
</div>
80+
);
81+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import DefaultLogoImage from "../../../../assets/default_logo.png";
2+
3+
const ipfsGateway = process.env.REACT_APP_IPFS_BASE_URL;
4+
5+
export function ProjectLogo({ logoImg }: { logoImg?: string }) {
6+
const src = logoImg ? `${ipfsGateway}/ipfs/${logoImg}` : DefaultLogoImage;
7+
8+
return (
9+
<img
10+
className={"-mt-16 h-32 w-32 rounded-full ring-4 ring-white bg-white"}
11+
src={src}
12+
alt="Project Logo"
13+
/>
14+
);
15+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Stat } from "./Stat";
2+
import { formatDistanceToNowStrict } from "date-fns";
3+
import { Application } from "data-layer";
4+
import { useProjectDetailsParams } from "../hooks/useProjectDetailsParams";
5+
import { useRoundById } from "../../../../context/RoundContext";
6+
import { isInfiniteDate } from "../../../api/utils";
7+
8+
export function ProjectStats(props: { application: Application | undefined }) {
9+
const { chainId, roundId } = useProjectDetailsParams();
10+
const { round } = useRoundById(Number(chainId), roundId);
11+
const application = props.application;
12+
13+
const timeRemaining =
14+
round?.roundEndTime && !isInfiniteDate(round?.roundEndTime)
15+
? formatDistanceToNowStrict(round.roundEndTime)
16+
: null;
17+
const isBeforeRoundEndDate =
18+
round &&
19+
(isInfiniteDate(round.roundEndTime) || round.roundEndTime > new Date());
20+
21+
return (
22+
<div className="rounded-3xl flex-auto p-3 md:p-4 gap-4 flex flex-col text-blue-800">
23+
<Stat
24+
isLoading={!application}
25+
value={`$${application?.totalAmountDonatedInUsd.toFixed(2)}`}
26+
>
27+
funding received in current round
28+
</Stat>
29+
<Stat isLoading={!application} value={application?.uniqueDonorsCount}>
30+
contributors
31+
</Stat>
32+
33+
<Stat
34+
isLoading={isBeforeRoundEndDate === undefined}
35+
value={timeRemaining}
36+
className={
37+
// Explicitly check for true - could be undefined if round hasn't been loaded yet
38+
isBeforeRoundEndDate === true || isBeforeRoundEndDate === undefined
39+
? ""
40+
: "flex-col-reverse"
41+
}
42+
>
43+
{
44+
// If loading - render empty
45+
isBeforeRoundEndDate === undefined
46+
? ""
47+
: isBeforeRoundEndDate
48+
? "to go"
49+
: "Round ended"
50+
}
51+
</Stat>
52+
</div>
53+
);
54+
}

0 commit comments

Comments
 (0)