diff --git a/packages/grant-explorer/src/features/api/round.ts b/packages/grant-explorer/src/features/api/round.ts index 334be6d88f..27affaa397 100644 --- a/packages/grant-explorer/src/features/api/round.ts +++ b/packages/grant-explorer/src/features/api/round.ts @@ -1,66 +1,7 @@ import { ApplicationStatus } from "./types"; -import { useEffect, useState } from "react"; -import { Address, getAddress } from "viem"; -import { Contribution, useDataLayer } from "data-layer"; -import { dateToEthereumTimestamp } from "common"; export type RoundProject = { id: string; status: ApplicationStatus; payoutAddress: string; }; - -export type ContributionHistoryState = - | { type: "loading" } - | { - type: "loaded"; - data: { chainIds: number[]; data: Contribution[] }; - } - | { type: "error"; error: string }; - -export const useContributionHistory = ( - chainIds: number[], - rawAddress: string -) => { - const [state, setState] = useState({ - type: "loading", - }); - const dataLayer = useDataLayer(); - - useEffect(() => { - const fetchContributions = async () => { - let address: Address = "0x"; - try { - address = getAddress(rawAddress.toLowerCase()); - } catch (e) { - return Promise.resolve({ - chainIds, - error: "Invalid address", - data: [], - }); - } - - const contributions = await dataLayer.getDonationsByDonorAddress({ - address, - chainIds, - }); - - setState({ - type: "loaded", - data: { - chainIds: chainIds, - data: contributions.map((contribution) => ({ - ...contribution, - timestamp: dateToEthereumTimestamp( - new Date(contribution.timestamp) - ).toString(), - })), - }, - }); - }; - - fetchContributions(); - }, [chainIds, dataLayer, rawAddress]); - - return state; -}; diff --git a/packages/grant-explorer/src/features/common/CopyToClipboardButton.tsx b/packages/grant-explorer/src/features/common/CopyToClipboardButton.tsx index 85959ea557..5380b5f1cd 100644 --- a/packages/grant-explorer/src/features/common/CopyToClipboardButton.tsx +++ b/packages/grant-explorer/src/features/common/CopyToClipboardButton.tsx @@ -14,9 +14,7 @@ export default function CopyToClipboardButton(props: CopyToClipboardType) { return ( ); } diff --git a/packages/grant-explorer/src/features/common/StatCard.tsx b/packages/grant-explorer/src/features/common/StatCard.tsx index fe0d47839f..cba6213f83 100644 --- a/packages/grant-explorer/src/features/common/StatCard.tsx +++ b/packages/grant-explorer/src/features/common/StatCard.tsx @@ -1,8 +1,8 @@ export function StatCard(props: { title: string; value: string | undefined }) { return ( -
+
{props.value}
-
{props.title}
+
{props.title}
); } diff --git a/packages/grant-explorer/src/features/contributors/DirectDonationsTable.tsx b/packages/grant-explorer/src/features/contributors/DirectDonationsTable.tsx deleted file mode 100644 index fd0c53f3d6..0000000000 --- a/packages/grant-explorer/src/features/contributors/DirectDonationsTable.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { InformationCircleIcon } from "@heroicons/react/24/solid"; -import ReactTooltip from "react-tooltip"; -import { Link } from "react-router-dom"; -import { getTokenByChainIdAndAddress } from "common"; -import { Hex, formatUnits } from "viem"; -import { Contribution } from "data-layer"; -import moment from "moment"; -import { TransactionButton } from "./TransactionButton"; - -export function DirectDonationsTable(props: { contributions: Contribution[] }) { - return ( - <> - - - {props.contributions.length === 0 && ( -
- Direct donations made to projects will appear here. -
- )} - - ); -} - -function TableHeader() { - return ( -
- - - - - - - -
Project -
-
Total Donation
-
- - -

- The displayed amount in USD reflects
- the value at the time of your donation. -

-
-
-
-
Transaction Information
- ); -} - -function Table(props: { contributions: Contribution[] }) { - return ( -
-
-
-
- - - {props.contributions.length > 0 && - props.contributions - .flat() - .sort( - (a, b) => - (Number(b.timestamp) || Number.MAX_SAFE_INTEGER) - - (Number(a.timestamp) || Number.MAX_SAFE_INTEGER) - ) - - .map((contribution) => { - const token = getTokenByChainIdAndAddress( - contribution.chainId, - contribution.tokenAddress as Hex - ); - - let formattedAmount = "N/A"; - - if (token) { - formattedAmount = `${formatUnits( - BigInt(contribution.amount), - token.decimals - )} ${token.code}`; - } - - return ( - - - {/* Display donations */} - - - - ); - })} - -
-
-
- {/* Link to the project */} - - {contribution.projectId.trim()} - -
-
- {/* Display contribution timestamp */} -
- {timeAgo(Number(contribution.timestamp))} -
-
- - {formattedAmount}{" "} - - - / ${contribution.amountInUsd.toFixed(2)} - - -
- -
-
-
-
-
-
- ); -} - -function timeAgo(timestamp: number) { - return moment(timestamp * 1000).fromNow(); -} diff --git a/packages/grant-explorer/src/features/contributors/DonationsTable.tsx b/packages/grant-explorer/src/features/contributors/DonationsTable.tsx deleted file mode 100644 index f9dec91464..0000000000 --- a/packages/grant-explorer/src/features/contributors/DonationsTable.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import { InformationCircleIcon } from "@heroicons/react/24/solid"; -import ReactTooltip from "react-tooltip"; -import { Link } from "react-router-dom"; -import { TransactionButton } from "./TransactionButton"; -import { getChainById, getTokenByChainIdAndAddress, stringToBlobUrl } from "common"; -import { Hex, formatUnits } from "viem"; -import { Contribution } from "data-layer"; -import { - Accordion, - AccordionButton, - AccordionIcon, - AccordionItem, - AccordionPanel, -} from "@chakra-ui/react"; -import { useState } from "react"; -import moment from "moment"; - -export function DonationsTable(props: { - contributions: Contribution[]; - activeRound: boolean; -}) { - return ( - <> - - - {props.contributions.length === 0 && ( -
- {props.activeRound - ? "Donations made during active rounds will appear here." - : "Donations made during past rounds will appear here."} -
- )} - - ); -} - -function RoundsTableWithAccordian(props: { - contributions: Contribution[]; - activeRound: boolean; -}) { - const nestedContributionsForRound = props.contributions.reduce( - (acc: Record, contribution) => { - const roundId = contribution.roundId; - - if (!acc[roundId]) { - acc[roundId] = []; - } - acc[roundId].push(contribution); - return acc; - }, - {} - ); - - const [defaultIndex, setDefaultIndex] = useState< - number | number[] | undefined - >(undefined); - - for (const key in nestedContributionsForRound) { - return ( -
- {Object.entries(nestedContributionsForRound).map( - ([_roundId, contributionsForRound], _index) => { - const sortedContributions = contributionsForRound - .flat() - .sort( - (a, b) => - (Number(b.timestamp) || Number.MAX_SAFE_INTEGER) - - (Number(a.timestamp) || Number.MAX_SAFE_INTEGER) - ); - - return ( - { - setDefaultIndex(index); - }} - > - -

- - - - - - - - - - - ); - } - )} - - ); - } -} - -function TableHeader() { - return ( -
- - - - - - - -
Round -
-
Total Donation
-
- - -

- The displayed amount in USD reflects
- the value at the time of your donation. -

-
-
-
-
Transaction Information
- ); -} - -function InnerTable(props: { - contributions: Contribution[]; - activeRound: boolean; -}) { - return ( -
-
-
-
- - - - - - - - - {props.contributions.length > 0 && - props.contributions - .flat() - .sort( - (a, b) => - (Number(b.timestamp) || Number.MAX_SAFE_INTEGER) - - (Number(a.timestamp) || Number.MAX_SAFE_INTEGER) - ) - - .map((contribution) => { - - const token = getTokenByChainIdAndAddress( - contribution.chainId, - contribution.tokenAddress as Hex - ); - - let formattedAmount = "N/A"; - - if (token) { - formattedAmount = `${formatUnits( - BigInt(contribution.amount), - token.decimals - )} ${token.code}`; - } - - return ( - - - {/* Display donations */} - - - ); - })} - -
ProjectDonation
-
-
- {/* Link to the project */} - - {contribution.application.project.name} - -
-
- {/* Display contribution timestamp */} -
- {timeAgo(Number(contribution.timestamp))} -
-
- - {formattedAmount}{" "} - - - / ${contribution.amountInUsd.toFixed(2)} - -
-
-
-
-
- ); -} - -function Table(props: { - contributions: Contribution[]; - activeRound: boolean; -}) { - const roundInfo = props.contributions[0]; - const chainId = roundInfo.chainId; - const chain = getChainById(chainId); - - const chainLogo = stringToBlobUrl(chain.icon); - const roundName = roundInfo.round.roundMetadata.name; - - const sortedContributions = props.contributions; - const lastUpdated = sortedContributions[0].timestamp; - - let formattedAmount = "N/A"; - let totalContributionAmountInUsd = 0; - let totalContributionInMatchingToken = 0; - - // Get the total contribution amount in USD and matching token - sortedContributions.forEach((contribution) => { - totalContributionAmountInUsd += contribution.amountInUsd; - totalContributionInMatchingToken += Number(contribution.amount); - }); - - // Get the formatted amount & token name - sortedContributions.map((contribution) => { - - const token = getTokenByChainIdAndAddress( - contribution.chainId, - contribution.tokenAddress as Hex - ); - - if (token) { - formattedAmount = `${formatUnits( - BigInt(totalContributionInMatchingToken), - token.decimals - )} ${token.code}`; - } - }); - - return ( - - - - - {/* Display donations */} - - - - -
-
-
-
- {/* Network Icon */} - Round Chain Logo - {/* Link to the round */} - - {roundName} - -
-
-
- {/* Display contribution timestamp */} -
- {timeAgo(Number(lastUpdated))} -
-
- {formattedAmount} - - / ${totalContributionAmountInUsd.toFixed(2)} - - -
- -
-
- ); -} - -function timeAgo(timestamp: number) { - return moment(timestamp * 1000).fromNow(); -} diff --git a/packages/grant-explorer/src/features/contributors/ViewContributionHistory.tsx b/packages/grant-explorer/src/features/contributors/ViewContributionHistory.tsx deleted file mode 100644 index f35eb6561c..0000000000 --- a/packages/grant-explorer/src/features/contributors/ViewContributionHistory.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import { useAccount, useEnsAddress, useEnsAvatar, useEnsName } from "wagmi"; -import { lazy, useMemo } from "react"; -import { useParams } from "react-router-dom"; -import Navbar from "../common/Navbar"; -import blockies from "ethereum-blockies"; -import CopyToClipboardButton from "../common/CopyToClipboardButton"; -import Footer from "common/src/components/Footer"; -import Breadcrumb, { BreadcrumbItem } from "../common/Breadcrumb"; -import { useContributionHistory } from "../api/round"; -import { StatCard } from "../common/StatCard"; -import { DonationsTable } from "./DonationsTable"; -import { Hex, isAddress } from "viem"; -import { - dateToEthereumTimestamp, - getChains, - getTokenByChainIdAndAddress, -} from "common"; -import { Contribution } from "data-layer"; -import { normalize } from "viem/ens"; -import { DirectDonationsTable } from "./DirectDonationsTable"; - -const DonationHistoryBanner = lazy( - () => import("../../assets/DonationHistoryBanner") -); - -export function ViewContributionHistoryPage() { - const params = useParams(); - const chainIds = getChains().map((chain) => chain.id); - - const { data: ensResolvedAddress } = useEnsAddress({ - /* If params.address is actually an address, don't resolve the ens address for it*/ - name: isAddress(params.address ?? "") ? undefined : params.address, - chainId: 1, - }); - - if (params.address === undefined) { - return null; - } - - return ( - <> - - - - ); -} - -function ViewContributionHistoryFetcher(props: { - address: string; - chainIds: number[]; -}) { - const contributionHistory = useContributionHistory( - props.chainIds, - props.address - ); - console.log("contributions", contributionHistory); - - const { data: ensName } = useEnsName({ - /* If props.address is an ENS name, don't pass in anything, as we already have the ens name*/ - address: isAddress(props.address) ? props.address : undefined, - chainId: 1, - }); - - const { data: ensAvatar } = useEnsAvatar({ - name: ensName ? normalize(ensName) : undefined, - chainId: 1, - }); - - const breadCrumbs = [ - { - name: "Explorer Home", - path: "/", - }, - { - name: "Donations", - path: `/contributors/${props.address}`, - }, - ] as BreadcrumbItem[]; - - const addressLogo = useMemo(() => { - return ( - ensAvatar ?? - blockies.create({ seed: props.address.toLowerCase() }).toDataURL() - ); - }, [props.address, ensAvatar]); - - if (contributionHistory.type === "loading") { - return
Loading...
; - } else if (contributionHistory.type === "error") { - console.error("Error", contributionHistory); - return ( - - ); - } else { - return ( - - ); - } -} - -export function ViewContributionHistory(props: { - contributions: { chainIds: number[]; data: Contribution[] }; - address: string; - addressLogo: string; - ensName?: string | null; - breadCrumbs: BreadcrumbItem[]; -}) { - const currentOrigin = window.location.origin; - const [totalDonations, totalUniqueContributions, totalProjectsFunded] = - useMemo(() => { - let totalDonations = 0; - let totalUniqueContributions = 0; - const projects: string[] = []; - - props.contributions.data.forEach((contribution) => { - const token = getTokenByChainIdAndAddress( - contribution.chainId, - contribution.tokenAddress as Hex - ); - - if (token) { - totalDonations += contribution.amountInUsd; - totalUniqueContributions += 1; - const project = contribution.projectId; - if (!projects.includes(project)) { - projects.push(project); - } - } - }); - - return [totalDonations, totalUniqueContributions, projects.length]; - }, [props.contributions]); - - const activeRoundDonations = useMemo(() => { - const now = Date.now(); - - const filteredRoundDonations = props.contributions.data.filter( - (contribution) => { - const formattedRoundEndTime = - Number( - dateToEthereumTimestamp( - new Date(contribution.round.donationsEndTime) - ) - ) * 1000; - return ( - formattedRoundEndTime >= now && - contribution.round.strategyName !== "allov2.DirectAllocationStrategy" - ); - } - ); - if (filteredRoundDonations.length === 0) { - return []; - } - return filteredRoundDonations; - }, [props.contributions]); - - const pastRoundDonations = useMemo(() => { - const now = Date.now(); - - const filteredRoundDonations = props.contributions.data.filter( - (contribution) => { - const formattedRoundEndTime = - Number( - dateToEthereumTimestamp( - new Date(contribution.round.donationsEndTime) - ) - ) * 1000; - return ( - formattedRoundEndTime < now && - contribution.round.strategyName !== "allov2.DirectAllocationStrategy" - ); - } - ); - if (filteredRoundDonations.length === 0) { - return []; - } - - return filteredRoundDonations; - }, [props.contributions]); - - const directAllocationDonations = useMemo(() => { - const filteredRoundDonations = props.contributions.data.filter( - (contribution) => { - return ( - contribution.round.strategyName === "allov2.DirectAllocationStrategy" - ); - } - ); - if (filteredRoundDonations.length === 0) { - return []; - } - - return filteredRoundDonations; - }, [props.contributions]); - - return ( -
-
- -
-
-
-
- Address Logo -
- {props.ensName || - props.address.slice(0, 6) + "..." + props.address.slice(-6)} -
-
-
- {/* todo: removed until site is stable */} - {/* */} - -
-
-
- * Please note that your recent transactions may take a short while to - reflect in your donation history, as processing times may vary. -
-
Donation Impact
-
-
- -
-
- -
-
- -
-
-
Donation History
-
- Active Rounds -
- -
- Past Rounds -
- -
- Direct Donations -
- -
-
-
-
-
- ); -} - -export function ViewContributionHistoryWithoutDonations(props: { - address: string; - addressLogo: string; - ensName?: string; - breadCrumbs: BreadcrumbItem[]; -}) { - const currentOrigin = window.location.origin; - const { address: walletAddress } = useAccount(); - return ( -
-
- -
-
-
-
- Address Logo -
- {props.ensName || - props.address.slice(0, 6) + "..." + props.address.slice(-6)} -
-
- -
-
Donation History
-
-
- {props.address === walletAddress ? ( - <> -

- This is your donation history page, where you can keep track - of all the public goods you've funded. -

-

- As you make donations, your transaction history will appear - here. -

- - ) : ( - <> -

- This is{" "} - {props.ensName || - props.address.slice(0, 6) + "..." + props.address.slice(-6)} - ’s donation history page, showcasing their contributions - towards public goods. -

-

- As they make donations, their transaction history will appear - here. -

- - )} -
-
-
-
- {" "} - -
-
-
-
-
-
- ); -} diff --git a/packages/grant-explorer/src/features/contributors/ViewContributionHistoryPage.tsx b/packages/grant-explorer/src/features/contributors/ViewContributionHistoryPage.tsx new file mode 100644 index 0000000000..a22e1deae2 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/ViewContributionHistoryPage.tsx @@ -0,0 +1,53 @@ +import { useEnsAddress } from "wagmi"; +import { useParams } from "react-router-dom"; +import { isAddress } from "viem"; + +import { getChains } from "common"; +import Footer from "common/src/components/Footer"; + +import { ContributionHistoryContainer } from "./components/ContributionHistoryContainer"; +import Navbar from "../common/Navbar"; +import Breadcrumb, { BreadcrumbItem } from "../common/Breadcrumb"; + +// TODO: what to display if the address is not correct? '/contributors/123' +export function ViewContributionHistoryPage() { + const { address: paramsAddress = "" } = useParams(); + const chainIds = getChains().map((chain) => chain.id); + + const { data: ensResolvedAddress } = useEnsAddress({ + name: isAddress(paramsAddress) ? undefined : paramsAddress, + chainId: 1, + }); + + const address = ensResolvedAddress ?? paramsAddress; + + const breadCrumbs = [ + { + name: "Explorer Home", + path: "/", + }, + { + name: "Donation history", + path: `/contributors/${address}`, + }, + ] as BreadcrumbItem[]; + + if (!paramsAddress) { + return null; + } + + return ( +
+ +
+
+ +
+ +
+
+
+
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/__tests__/ViewContributionHistory.test.tsx b/packages/grant-explorer/src/features/contributors/__tests__/ViewContributionHistory.test.tsx deleted file mode 100644 index d4982abe21..0000000000 --- a/packages/grant-explorer/src/features/contributors/__tests__/ViewContributionHistory.test.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { faker } from "@faker-js/faker"; -import { - ViewContributionHistory, - ViewContributionHistoryWithoutDonations, -} from "../ViewContributionHistory"; -import { MemoryRouter } from "react-router-dom"; -import { BreadcrumbItem } from "../../common/Breadcrumb"; -import { zeroAddress } from "viem"; - -import { Contribution } from "data-layer"; - -const mockAddress = faker.finance.ethereumAddress(); - -const mockContributions: Contribution[] = [ - { - id: "1", - chainId: 1, - projectId: "project1", - roundId: "round1", - recipientAddress: "recipient1", - applicationId: "0", - tokenAddress: "ETH", - donorAddress: "voter1", - amount: "10", - amountInUsd: 100, - transactionHash: "transaction1", - blockNumber: 12345, - round: { - roundMetadata: { - name: "Round 1", - roundType: "public", - eligibility: { - description: "Eligibility description", - requirements: [{ requirement: "Requirement 1" }], - }, - programContractAddress: "0x1", - }, - donationsStartTime: faker.date.past().toISOString(), - donationsEndTime: faker.date.future().toISOString(), - strategyName: "", - }, - application: { - project: { - name: "Project 1", - }, - }, - timestamp: "0", - }, - { - id: "2", - chainId: 1, - projectId: "project2", - roundId: "round1", - recipientAddress: "recipient2", - applicationId: "1", - tokenAddress: "ETH", - donorAddress: "voter2", - amount: "20", - amountInUsd: 200, - transactionHash: "transaction2", - blockNumber: 12346, - round: { - roundMetadata: { - name: "Round 2", - roundType: "public", - eligibility: { - description: "Eligibility description", - requirements: [{ requirement: "Requirement 1" }], - }, - programContractAddress: "0x1", - }, - donationsStartTime: faker.date.past().toISOString(), - donationsEndTime: faker.date.past().toISOString(), - strategyName: "", - }, - application: { - project: { - name: "Project 2", - }, - }, - timestamp: "0", - }, -]; - -const breadCrumbs = [ - { - name: "Explorer Home", - path: "/", - }, - { - name: "Contributors", - path: `/contributors/${mockAddress}`, - }, -] as BreadcrumbItem[]; - -Object.defineProperty(window, "scrollTo", { value: () => {}, writable: true }); - -vi.mock("../../common/Navbar"); -vi.mock("../../common/Auth"); - -vi.mock("@rainbow-me/rainbowkit", () => ({ - ConnectButton: vi.fn(), -})); - -vi.mock("react-router-dom", async () => { - const actual = - await vi.importActual( - "react-router-dom" - ); - - return { - ...actual, - useNavigate: () => vi.fn(), - useParams: () => ({ address: zeroAddress }), - }; -}); - -vi.mock("wagmi", async () => { - const actual = await vi.importActual("wagmi"); - return { - ...actual, - useSigner: () => ({ - data: {}, - }), - useEnsName: vi.fn().mockReturnValue({ data: "" }), - useAccount: vi.fn().mockReturnValue({ data: "mockedAccount" }), - }; -}); - -Object.assign(navigator, { - clipboard: { writeText: vi.fn() }, -}); - -describe("", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("Should show donation impact & donation history", async () => { - render( - - - - ); - - expect(screen.getByText("Donation Impact")).toBeInTheDocument(); - expect(screen.getByText("Donation History")).toBeInTheDocument(); - expect(screen.getByText("Active Rounds")).toBeInTheDocument(); - expect(screen.getByText("Past Rounds")).toBeInTheDocument(); - expect( - screen.getByText(mockAddress.slice(0, 6) + "..." + mockAddress.slice(-6)) - ).toBeInTheDocument(); - expect(screen.getByText("Share Profile")).toBeInTheDocument(); - - for (const contribution of mockContributions) { - expect( - screen.getByText(contribution.round.roundMetadata.name) - ).toBeInTheDocument(); - expect( - screen.getByText(contribution.application.project.name) - ).toBeInTheDocument(); - expect(screen.getAllByText("View").length).toBeGreaterThan(0); - } - }); -}); - -describe("", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("Should show donation history", async () => { - render( - - - - ); - - await waitFor(() => { - expect(screen.getByText("Donation History")).toBeInTheDocument(); - }); - expect( - screen.getByText(mockAddress.slice(0, 6) + "..." + mockAddress.slice(-6)) - ).toBeInTheDocument(); - expect(screen.getByText("Share Profile")).toBeInTheDocument(); - - fireEvent.click(screen.getByText("Share Profile")); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringMatching("http://localhost:3000/#/contributors/") - ); - }); -}); diff --git a/packages/grant-explorer/src/features/contributors/components/AvatarWithTitle.tsx b/packages/grant-explorer/src/features/contributors/components/AvatarWithTitle.tsx new file mode 100644 index 0000000000..21d2d78ed7 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/AvatarWithTitle.tsx @@ -0,0 +1,29 @@ +export function AvatarWithTitle({ + avatarSrc, + avatarAlt, + title, + dataTestId, +}: { + avatarSrc: string; + avatarAlt?: string; + title: string; + dataTestId?: { title?: string; avatar?: string }; +}) { + return ( +
+ {avatarAlt +
+ {title} +
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/Buttons/MintDonattionButton.tsx b/packages/grant-explorer/src/features/contributors/components/Buttons/MintDonattionButton.tsx new file mode 100644 index 0000000000..371ce23f6e --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/Buttons/MintDonattionButton.tsx @@ -0,0 +1,25 @@ +import { Button } from "common/src/styles"; + +export function MintDonationButton({ + onClick = () => null, + disabled = false, +}: { + onClick?: () => void; + disabled?: boolean; +}) { + return ( +
+ +
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/Buttons/MintingActionButton.tsx b/packages/grant-explorer/src/features/contributors/components/Buttons/MintingActionButton.tsx new file mode 100644 index 0000000000..40a53cae00 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/Buttons/MintingActionButton.tsx @@ -0,0 +1,29 @@ +import { MintDonationButton } from "./MintDonattionButton"; +import { ViewAttestationButton } from "./ViewAttestationButton"; + +export function MintingActionButton({ + transaction, +}: { + transaction: { + hash: string; + chainId: number; + }; +}) { + const isMinted = false; + const canMint = true; + + return isMinted ? ( + { + console.log(transaction.hash, "View attestation clicked"); + }} + /> + ) : ( + { + console.log(transaction.hash, "Mint donation clicked"); + }} + /> + ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/Buttons/ViewAttestationButton.tsx b/packages/grant-explorer/src/features/contributors/components/Buttons/ViewAttestationButton.tsx new file mode 100644 index 0000000000..ca241b2006 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/Buttons/ViewAttestationButton.tsx @@ -0,0 +1,21 @@ +import { Button } from "common/src/styles"; + +export function ViewAttestationButton({ + onClick = () => null, + disabled = false, +}: { + onClick?: () => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/Buttons/index.ts b/packages/grant-explorer/src/features/contributors/components/Buttons/index.ts new file mode 100644 index 0000000000..e52f4a475f --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/Buttons/index.ts @@ -0,0 +1 @@ +export { MintingActionButton } from "./MintingActionButton"; diff --git a/packages/grant-explorer/src/features/contributors/components/ContributionHistory.tsx b/packages/grant-explorer/src/features/contributors/components/ContributionHistory.tsx new file mode 100644 index 0000000000..6c8f24d0d8 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/ContributionHistory.tsx @@ -0,0 +1,34 @@ +import { ContributorProfile } from "./ContributorProfile"; +import { DonationImpactCards } from "./DonationImpactCards"; +import { DonationsHistory } from "./DonationsHistory"; + +import type { ContributionsData } from "../types"; + +export function ContributionHistory({ + contributionsData, + address, + ensName, +}: { + contributionsData?: ContributionsData; + address: string; + ensName?: string | null; +}) { + const totals = contributionsData?.totals; + + return ( +
+
+
+ +
+ Please note that your recent transactions may take a short while to + reflect in your donation history, as processing times may vary. +
+
+ +
Donation History
+ +
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/ContributionHistoryContainer.tsx b/packages/grant-explorer/src/features/contributors/components/ContributionHistoryContainer.tsx new file mode 100644 index 0000000000..73b7d9c232 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/ContributionHistoryContainer.tsx @@ -0,0 +1,44 @@ +import { useEnsName } from "wagmi"; +import { isAddress } from "viem"; + +import { ContributionHistory } from "./ContributionHistory"; + +import { useContributionHistory } from "../hooks/useContributionHistory"; +import { ContributionHistoryError } from "./ContributionHistoryError"; + +export function ContributionHistoryContainer(props: { + address: string; + chainIds: number[]; +}) { + const { status, error, data } = useContributionHistory( + props.chainIds, + props.address + ); + + const isLoading = status === "loading"; + const isError = status === "error"; + + const { data: ensName } = useEnsName({ + /* If props.address is an ENS name, don't pass in anything, as we already have the ens name*/ + address: isAddress(props.address) ? props.address : undefined, + chainId: 1, + }); + + if (isLoading) { + return
Loading...
; + } + if (isError) { + console.error("Error", error); + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/ContributionHistoryError.tsx b/packages/grant-explorer/src/features/contributors/components/ContributionHistoryError.tsx new file mode 100644 index 0000000000..848aca797e --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/ContributionHistoryError.tsx @@ -0,0 +1,17 @@ +import { ContributorProfile } from "./ContributorProfile"; +import { UnknownOrNoContributions } from "./UnknownOrNoContributions"; + +export function ContributionHistoryError({ + address, + ensName, +}: { + address: string; + ensName?: string | null; +}) { + return ( +
+ + +
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/ContributorProfile.tsx b/packages/grant-explorer/src/features/contributors/components/ContributorProfile.tsx new file mode 100644 index 0000000000..10170aad8a --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/ContributorProfile.tsx @@ -0,0 +1,48 @@ +import { useMemo } from "react"; +import { useEnsAvatar } from "wagmi"; +import { normalize } from "viem/ens"; +import blockies from "ethereum-blockies"; + +import { AvatarWithTitle } from "./AvatarWithTitle"; +import { truncateAddress } from "../utils/address"; +import CopyToClipboardButton from "../../common/CopyToClipboardButton"; + +export function ContributorProfile({ + address, + ensName, +}: { + address: string; + ensName?: string | null; +}) { + const { data: ensAvatar } = useEnsAvatar({ + name: ensName ? normalize(ensName) : undefined, + chainId: 1, + }); + + const addressLogo = useMemo(() => { + return ( + ensAvatar ?? blockies.create({ seed: address.toLowerCase() }).toDataURL() + ); + }, [address, ensAvatar]); + + const partialAddress = truncateAddress(address); + + const currentOrigin = window.location.origin; + + return ( +
+ +
+ +
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/DirectDonations/DirectDonations.tsx b/packages/grant-explorer/src/features/contributors/components/DirectDonations/DirectDonations.tsx new file mode 100644 index 0000000000..508d6a9f8b --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/DirectDonations/DirectDonations.tsx @@ -0,0 +1,13 @@ +import { Contribution } from "data-layer"; + +import { DirectDonationsHeader } from "./DirectDonationsHeader"; +import { DirectDonationsList } from "./DirectDonationsList"; + +export function DirectDonations(props: { contributions: Contribution[] }) { + return ( +
+ + +
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/DirectDonations/DirectDonationsHeader.tsx b/packages/grant-explorer/src/features/contributors/components/DirectDonations/DirectDonationsHeader.tsx new file mode 100644 index 0000000000..2e8ad6f3b9 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/DirectDonations/DirectDonationsHeader.tsx @@ -0,0 +1,35 @@ +import { InformationCircleIcon } from "@heroicons/react/24/solid"; +import ReactTooltip from "react-tooltip"; + +export function DirectDonationsHeader() { + return ( +
+
Project
+
+
Total Donation
+
+ + +

+ The displayed amount in USD reflects
+ the value at the time of your donation. +

+
+
+
+
Transaction
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/DirectDonations/DirectDonationsList.tsx b/packages/grant-explorer/src/features/contributors/components/DirectDonations/DirectDonationsList.tsx new file mode 100644 index 0000000000..c971604572 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/DirectDonations/DirectDonationsList.tsx @@ -0,0 +1,74 @@ +import { Link } from "react-router-dom"; +import { Hex, formatUnits } from "viem"; +import moment from "moment"; + +import { getTokenByChainIdAndAddress } from "common"; +import { Contribution } from "data-layer"; + +import { TransactionButton } from "./TransactionButton"; + +export function DirectDonationsList(props: { contributions: Contribution[] }) { + const contributions = props.contributions + .flat() + .sort( + (a, b) => + (Number(b.timestamp) || Number.MAX_SAFE_INTEGER) - + (Number(a.timestamp) || Number.MAX_SAFE_INTEGER) + ); + return ( + <> + {contributions.length > 0 && + contributions.map((contribution) => { + const token = getTokenByChainIdAndAddress( + contribution.chainId, + contribution.tokenAddress as Hex + ); + + let formattedAmount = "N/A"; + + if (token) { + formattedAmount = `${formatUnits( + BigInt(contribution.amount), + token.decimals + )} ${token.code}`; + } + + return ( +
+
+ + {contribution.application?.project?.name ?? + `Project Id: ${contribution.projectId.slice(0, 6) + "..." + contribution.projectId.slice(-6)}`} + + {/* Display contribution timestamp */} +
+ {timeAgo(Number(contribution.timestamp))} +
+
+
+ {formattedAmount} + + / ${contribution.amountInUsd.toFixed(2)} + +
+
+ +
+
+ ); + })} + + ); +} + +function timeAgo(timestamp: number) { + return moment(timestamp * 1000).fromNow(); +} diff --git a/packages/grant-explorer/src/features/contributors/TransactionButton.tsx b/packages/grant-explorer/src/features/contributors/components/DirectDonations/TransactionButton.tsx similarity index 65% rename from packages/grant-explorer/src/features/contributors/TransactionButton.tsx rename to packages/grant-explorer/src/features/contributors/components/DirectDonations/TransactionButton.tsx index b4f9320520..7efad8285c 100644 --- a/packages/grant-explorer/src/features/contributors/TransactionButton.tsx +++ b/packages/grant-explorer/src/features/contributors/components/DirectDonations/TransactionButton.tsx @@ -1,6 +1,7 @@ +import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; + import { Button } from "common/src/styles"; import { getTxBlockExplorerLink } from "common"; -import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; export function TransactionButton(props: { chainId: number; txHash: string }) { return ( @@ -11,10 +12,10 @@ export function TransactionButton(props: { chainId: number; txHash: string }) { ); diff --git a/packages/grant-explorer/src/features/contributors/components/DirectDonations/index.ts b/packages/grant-explorer/src/features/contributors/components/DirectDonations/index.ts new file mode 100644 index 0000000000..19842b10a5 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/DirectDonations/index.ts @@ -0,0 +1 @@ +export { DirectDonations } from "./DirectDonations"; diff --git a/packages/grant-explorer/src/features/contributors/components/DonationImpactCards.tsx b/packages/grant-explorer/src/features/contributors/components/DonationImpactCards.tsx new file mode 100644 index 0000000000..d3a1089590 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/DonationImpactCards.tsx @@ -0,0 +1,36 @@ +import { StatCard } from "../../common/StatCard"; + +export function DonationImpactCards({ + totals = { + totalDonations: 0, + totalUniqueContributions: 0, + totalProjectsFunded: 0, + }, +}: { + totals?: { + totalDonations: number; + totalUniqueContributions: number; + totalProjectsFunded: number; + }; +}) { + const totalDonations = totals.totalDonations.toFixed(2).toString(); + const totalUniqueContributions = totals.totalUniqueContributions.toString(); + const totalProjectsFunded = totals.totalProjectsFunded.toString(); + + return ( +
+
Donation Impact
+
+
+ +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/DonationsHistory.tsx b/packages/grant-explorer/src/features/contributors/components/DonationsHistory.tsx new file mode 100644 index 0000000000..2d658b2f31 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/DonationsHistory.tsx @@ -0,0 +1,40 @@ +import { DirectDonations } from "./DirectDonations/DirectDonations"; +import { RoundDonations } from "./RoundDonations"; +import { ContributionsData } from "../types"; + +export function DonationsHistory({ + contributionsData, +}: { + contributionsData?: ContributionsData; +}) { + const { + contributionsByStatusAndHashAndRoundId, + contributionsToDirectGrants, + } = contributionsData ?? {}; + + const activeRoundDonations = contributionsByStatusAndHashAndRoundId?.active; + const pastRoundDonations = contributionsByStatusAndHashAndRoundId?.past; + const directAllocationDonations = contributionsToDirectGrants; + + const directAllocationDonationsArray = directAllocationDonations + ? Object.values(directAllocationDonations).flat() + : []; + + return ( +
+ + + {directAllocationDonationsArray.length > 0 && ( +
+
+ Direct Donations +
+ +
+ )} +
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordion.tsx b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordion.tsx new file mode 100644 index 0000000000..cc0be7fd85 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordion.tsx @@ -0,0 +1,103 @@ +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, +} from "@chakra-ui/react"; +import { useState } from "react"; +import { Hex, formatUnits } from "viem"; + +import { Contribution } from "data-layer"; +import { + getChainById, + getTokenByChainIdAndAddress, + stringToBlobUrl, +} from "common"; + +import { RoundAccordionPanel } from "./RoundAccordionPanel"; +import { RoundAccordionTitle } from "./RoundAccordionTitle"; + +export function RoundAccordion({ + contributions, +}: { + contributions: Contribution[]; +}) { + const [defaultIndex, setDefaultIndex] = useState< + number | number[] | undefined + >(undefined); + + const sortedContributions = contributions.sort( + (a, b) => b.amountInUsd - a.amountInUsd // Sort by amountInUsd in descending order + ); + + const firstContribution = sortedContributions[0]; + const chainId = firstContribution.chainId; + const chain = getChainById(chainId); + const chainLogo = stringToBlobUrl(chain.icon); + const roundId = firstContribution.roundId; + const roundName = firstContribution.round.roundMetadata.name; + const lastUpdated = firstContribution.timestamp; + + let formattedAmount = "N/A"; + let totalContributionAmountInUsd = 0; + let totalContributionInMatchingToken = 0; + + // Get the total contribution amount in USD and matching token + // Get the formatted amount & token name + sortedContributions.map((contribution) => { + totalContributionAmountInUsd += contribution.amountInUsd; + totalContributionInMatchingToken += Number(contribution.amount); + const token = getTokenByChainIdAndAddress( + contribution.chainId, + contribution.tokenAddress as Hex + ); + + if (token) { + formattedAmount = `${formatUnits( + BigInt(totalContributionInMatchingToken), + token.decimals + )} ${token.code}`; + } + }); + + return ( + { + setDefaultIndex(index); + }} + > + +

+ + + + +

+ + + +
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordionContribution.tsx b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordionContribution.tsx new file mode 100644 index 0000000000..bb33f2bfff --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordionContribution.tsx @@ -0,0 +1,55 @@ +import { Link } from "react-router-dom"; +import { Hex, formatUnits } from "viem"; + +import { Contribution } from "data-layer"; +import { getTokenByChainIdAndAddress } from "common"; + +export function RoundAccordionContribution({ + contribution, +}: { + contribution: Contribution; +}) { + const { chainId, roundId, applicationId, amount } = contribution; + + const projectName = contribution.application.project.name; + const amountInUsd = contribution.amountInUsd.toFixed(2); + + const linkToRound = `/round/${chainId}/${roundId.toString().toLowerCase()}/${applicationId}`; + + let formattedAmount = "N/A"; + + const token = getTokenByChainIdAndAddress( + contribution.chainId, + contribution.tokenAddress as Hex + ); + + if (token) { + formattedAmount = `${formatUnits( + BigInt(amount), + token.decimals + )} ${token.code}`; + } + + return ( +
+
+
+ {/* Link to the project */} + + {projectName} + +
+
+
+ {formattedAmount} + / ${amountInUsd} +
+
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordionPanel.tsx b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordionPanel.tsx new file mode 100644 index 0000000000..2c305c0d31 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordionPanel.tsx @@ -0,0 +1,33 @@ +import { Contribution } from "data-layer"; +import { RoundAccordionContribution } from "./RoundAccordionContribution"; + +export function RoundAccordionPanel({ + contributions, +}: { + contributions: Contribution[]; +}) { + return ( +
+
+ Project + Donation +
+
+ {contributions.length > 0 && + contributions + .flat() + .sort( + (a, b) => + (Number(b.timestamp) || Number.MAX_SAFE_INTEGER) - + (Number(a.timestamp) || Number.MAX_SAFE_INTEGER) + ) + + .map((contribution) => ( + + ))} +
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordionTitle.tsx b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordionTitle.tsx new file mode 100644 index 0000000000..730554b78e --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/RoundAccordionTitle.tsx @@ -0,0 +1,53 @@ +import { Link } from "react-router-dom"; + +import { formatTimeAgo } from "../../utils/time"; + +export function RoundAccordionTitle({ + chainLogo, + roundName, + chainId, + roundId, + lastUpdated, + formattedAmount, + totalContributionAmountInUsd, +}: { + chainLogo: string; + roundName: string; + chainId: number; + roundId: string; + lastUpdated: string; + formattedAmount: string; + totalContributionAmountInUsd: number; +}) { + return ( +
+
+
+ Round Chain Logo + + {roundName} + +
+
+ {formatTimeAgo(Number(lastUpdated))} +
+
+
+ {formattedAmount} + + / ${totalContributionAmountInUsd.toFixed(2)} + +
+
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/RoundAccordion/index.ts b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/index.ts new file mode 100644 index 0000000000..ecf5daaf68 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundAccordion/index.ts @@ -0,0 +1 @@ +export { RoundAccordion } from "./RoundAccordion"; diff --git a/packages/grant-explorer/src/features/contributors/components/RoundDonations/DonationsTransactions.tsx b/packages/grant-explorer/src/features/contributors/components/RoundDonations/DonationsTransactions.tsx new file mode 100644 index 0000000000..862d0ad822 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundDonations/DonationsTransactions.tsx @@ -0,0 +1,32 @@ +import { RoundHeader } from "./RoundHeader"; +import { TransactionHeader } from "./TransactionHeader"; +import { RoundAccordion } from "../RoundAccordion"; +import { ContributionsByRoundId } from "../../types"; + +export function DonationsTransactions({ + transactionHash, + contributions = {}, +}: { + transactionHash: string; + contributions?: ContributionsByRoundId; +}) { + const roundIds = Object.keys(contributions); + const nRounds = roundIds.length; + + if (nRounds === 0) return null; + + const transactionChainId = contributions[roundIds[0]][0].chainId; + + return ( +
+ + + {roundIds.map((roundId) => ( + + ))} +
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/RoundDonations/RoundDonations.tsx b/packages/grant-explorer/src/features/contributors/components/RoundDonations/RoundDonations.tsx new file mode 100644 index 0000000000..071b603632 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundDonations/RoundDonations.tsx @@ -0,0 +1,31 @@ +import { DonationsTransactions } from "./DonationsTransactions"; +import { ContributionsByHashAndRoundId } from "../../types"; + +export function RoundDonations({ + title, + contributions = {}, +}: { + title: string; + contributions?: ContributionsByHashAndRoundId; +}) { + const transactionHashes = Object.keys(contributions); + + return ( +
+
+ {title} +
+ {transactionHashes.length === 0 ? ( +
No Donations found
+ ) : ( + transactionHashes.map((transactionHash) => ( + + )) + )} +
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/RoundDonations/RoundHeader.tsx b/packages/grant-explorer/src/features/contributors/components/RoundDonations/RoundHeader.tsx new file mode 100644 index 0000000000..f987f9f66a --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundDonations/RoundHeader.tsx @@ -0,0 +1,35 @@ +import { InformationCircleIcon } from "@heroicons/react/24/solid"; +import ReactTooltip from "react-tooltip"; + +export function RoundHeader() { + return ( +
+
Round
+
+
Total Donation
+
+ + +

+ The displayed amount in USD reflects
+ the value at the time of your donation. +

+
+
+
+
+
+ ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/RoundDonations/TransactionHeader.tsx b/packages/grant-explorer/src/features/contributors/components/RoundDonations/TransactionHeader.tsx new file mode 100644 index 0000000000..6b1d2d2f4f --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundDonations/TransactionHeader.tsx @@ -0,0 +1,38 @@ +import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; + +import { getTxBlockExplorerLink } from "common"; + +import { truncateAddress } from "../../utils/address"; +import { MintingActionButton } from "../Buttons"; + +export function TransactionHeader({ + transactionHash, + transactionChainId, +}: { + transactionHash: string; + transactionChainId: number; +}) { + const transactionLink = getTxBlockExplorerLink( + transactionChainId, + transactionHash + ); + const parcialTransactionHash = truncateAddress(transactionHash, 5); + + return ( + + ); +} diff --git a/packages/grant-explorer/src/features/contributors/components/RoundDonations/index.ts b/packages/grant-explorer/src/features/contributors/components/RoundDonations/index.ts new file mode 100644 index 0000000000..e4be08698f --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/RoundDonations/index.ts @@ -0,0 +1 @@ +export { RoundDonations } from "./RoundDonations"; diff --git a/packages/grant-explorer/src/features/contributors/components/UnknownOrNoContributions.tsx b/packages/grant-explorer/src/features/contributors/components/UnknownOrNoContributions.tsx new file mode 100644 index 0000000000..7b3768537a --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/components/UnknownOrNoContributions.tsx @@ -0,0 +1,54 @@ +import { useAccount } from "wagmi"; + +import DonationHistoryBanner from "../../../assets/DonationHistoryBanner"; +import { truncateAddress } from "../utils/address"; + +export function UnknownOrNoContributions({ + address, + ensName, +}: { + address: string; + ensName?: string | null; +}) { + const { address: walletAddress } = useAccount(); + + const partialAddress = truncateAddress(address); + + return ( + <> +
+
+ {address === walletAddress ? ( + <> +

+ This is your donation history page, where you can keep track of + all the public goods you've funded. +

+

+ As you make donations, your transaction history will appear + here. +

+ + ) : ( + <> +

+ {`This is ${ + ensName || partialAddress + }’s donation history page, showcasing their contributions + towards public goods.`} +

+

+ As they make donations, their transaction history will appear + here. +

+ + )} +
+
+
+
+ +
+ + ); +} diff --git a/packages/grant-explorer/src/features/contributors/hooks/useContributionHistory.tsx b/packages/grant-explorer/src/features/contributors/hooks/useContributionHistory.tsx new file mode 100644 index 0000000000..72a1962807 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/hooks/useContributionHistory.tsx @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useState } from "react"; +import { Address, getAddress } from "viem"; + +import { Contribution, useDataLayer } from "data-layer"; +import { dateToEthereumTimestamp } from "common"; + +import type { ContributionsData } from "../types"; +import { calculateTotalContributions } from "../utils/calculateTotalContributions"; +import { getContributionRoundStatus } from "../utils/getCountributionRoundStatus"; + +export type ContributionHistoryState = { + status: "success" | "error" | "loading"; + data?: ContributionsData; + error?: string; +}; + +const processContribution = (contribution: Contribution) => { + const timestamp = dateToEthereumTimestamp( + new Date(contribution.timestamp) + ).toString(); + const contributionRoundStatus = getContributionRoundStatus(contribution); + return { ...contribution, timestamp, contributionRoundStatus }; +}; + +const aggregateContributions = (contributions: Contribution[]) => { + return contributions.reduce<{ + contributions: ContributionsData["contributions"]; + contributionsById: ContributionsData["contributionsById"]; + contributionsByStatusAndHashAndRoundId: ContributionsData["contributionsByStatusAndHashAndRoundId"]; + contributionsToDirectGrants: ContributionsData["contributionsToDirectGrants"]; + }>( + (acc, contribution) => { + const processedContribution = processContribution(contribution); + + acc.contributions.push(processedContribution); + + acc.contributionsById[contribution.id] = processedContribution; + + const roundStatus = processedContribution.contributionRoundStatus; + const roundId = processedContribution.roundId; + const transactionHash = processedContribution.transactionHash; + + if (roundStatus === "direct") { + acc.contributionsToDirectGrants.push(processedContribution); + } else { + acc.contributionsByStatusAndHashAndRoundId = { + ...acc.contributionsByStatusAndHashAndRoundId, + [roundStatus]: { + ...acc.contributionsByStatusAndHashAndRoundId[roundStatus], + [transactionHash]: { + ...acc.contributionsByStatusAndHashAndRoundId[roundStatus]?.[ + transactionHash + ], + [roundId]: [ + ...(acc.contributionsByStatusAndHashAndRoundId[roundStatus]?.[ + transactionHash + ]?.[roundId] || []), + processedContribution, + ], + }, + }, + }; + } + + return acc; + }, + { + contributions: [], + contributionsById: {}, + contributionsByStatusAndHashAndRoundId: {}, + contributionsToDirectGrants: [], + } + ); +}; + +export const useContributionHistory = ( + chainIds: number[], + rawAddress: string +): ContributionHistoryState => { + const [state, setState] = useState({ + status: "loading", + }); + + const dataLayer = useDataLayer(); + + const fetchContributions = useCallback(async () => { + setState({ status: "loading" }); + try { + const address: Address = getAddress(rawAddress.toLowerCase()); + const contributionsResponse = await dataLayer.getDonationsByDonorAddress({ + address, + chainIds, + }); + + const { + contributions, + contributionsById, + contributionsByStatusAndHashAndRoundId, + contributionsToDirectGrants, + } = aggregateContributions(contributionsResponse); + + const totals = calculateTotalContributions(contributions); + + const contributionsData: ContributionsData = { + chainIds, + contributions, + totals, + contributionsById, + contributionsByStatusAndHashAndRoundId, + contributionsToDirectGrants, + }; + + setState({ + status: "success", + data: contributionsData, + }); + } catch (error) { + setState({ + status: "error", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }, [chainIds, dataLayer, rawAddress]); + + useEffect(() => { + fetchContributions(); + }, [fetchContributions]); + + return state; +}; diff --git a/packages/grant-explorer/src/features/contributors/index.ts b/packages/grant-explorer/src/features/contributors/index.ts new file mode 100644 index 0000000000..d6b0dcb7b6 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/index.ts @@ -0,0 +1 @@ +export { ViewContributionHistoryPage } from "./ViewContributionHistoryPage"; diff --git a/packages/grant-explorer/src/features/contributors/types.ts b/packages/grant-explorer/src/features/contributors/types.ts new file mode 100644 index 0000000000..aeac4cae9a --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/types.ts @@ -0,0 +1,32 @@ +import { Contribution } from "data-layer"; + +export type ContributionsData = { + chainIds: number[]; + contributions: Contribution[]; + totals?: { + totalDonations: number; + totalUniqueContributions: number; + totalProjectsFunded: number; + }; + contributionsById: ContributionsById; + contributionsByStatusAndHashAndRoundId: ContributionsByStatusAndHashAndRoundId; + contributionsToDirectGrants: Contribution[]; +}; + +export type ContributionsByTxnHash = Record; +export type ContributionsById = Record; +export type ContributionsByRoundStatus = Partial< + Record +>; +export type ContributionsByRoundId = Record; +export type ContributionsByHashAndRoundId = Record< + string, + ContributionsByRoundId +>; +export type ContributionsByStatusAndHashAndRoundId = { + [K in ContributionRoundStatus]?: K extends "direct" + ? Contribution[] + : ContributionsByHashAndRoundId; +}; + +export type ContributionRoundStatus = "direct" | "active" | "past"; diff --git a/packages/grant-explorer/src/features/contributors/utils/address.ts b/packages/grant-explorer/src/features/contributors/utils/address.ts new file mode 100644 index 0000000000..1c041936f9 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/utils/address.ts @@ -0,0 +1,4 @@ +export const truncateAddress = (address: string, length = 6): string => { + if (!address) return ""; + return address.slice(0, length) + "..." + address.slice(-length); +}; diff --git a/packages/grant-explorer/src/features/contributors/utils/calculateTotalContributions.ts b/packages/grant-explorer/src/features/contributors/utils/calculateTotalContributions.ts new file mode 100644 index 0000000000..7b4077d86a --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/utils/calculateTotalContributions.ts @@ -0,0 +1,31 @@ +import { Hex } from "viem"; +import { getTokenByChainIdAndAddress } from "common"; +import { Contribution } from "data-layer"; + +export const calculateTotalContributions = (contributions?: Contribution[]) => { + let totalDonations = 0; + let totalUniqueContributions = 0; + const projects: string[] = []; + + contributions?.forEach((contribution) => { + const token = getTokenByChainIdAndAddress( + contribution.chainId, + contribution.tokenAddress as Hex + ); + + if (token) { + totalDonations += contribution.amountInUsd; + totalUniqueContributions += 1; + const project = contribution.projectId; + if (!projects.includes(project)) { + projects.push(project); + } + } + }); + + return { + totalDonations, + totalUniqueContributions, + totalProjectsFunded: projects.length, + }; +}; diff --git a/packages/grant-explorer/src/features/contributors/utils/getCountributionRoundStatus.ts b/packages/grant-explorer/src/features/contributors/utils/getCountributionRoundStatus.ts new file mode 100644 index 0000000000..fd8e6b9403 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/utils/getCountributionRoundStatus.ts @@ -0,0 +1,24 @@ +import { dateToEthereumTimestamp } from "common"; +import { Contribution } from "data-layer"; +import { ContributionRoundStatus } from "../types"; + +export const getContributionRoundStatus = ( + contribution: Contribution +): ContributionRoundStatus => { + if (contribution.round.strategyName === "allov2.DirectAllocationStrategy") { + return "direct"; + } + + const now = Date.now(); + + const formattedRoundEndTime = + Number( + dateToEthereumTimestamp(new Date(contribution.round.donationsEndTime)) + ) * 1000; + + if (formattedRoundEndTime >= now) { + return "active"; + } else { + return "past"; + } +}; diff --git a/packages/grant-explorer/src/features/contributors/utils/time.ts b/packages/grant-explorer/src/features/contributors/utils/time.ts new file mode 100644 index 0000000000..341ae35318 --- /dev/null +++ b/packages/grant-explorer/src/features/contributors/utils/time.ts @@ -0,0 +1,5 @@ +import moment from "moment"; + +export function formatTimeAgo(timestamp: number) { + return moment(timestamp * 1000).fromNow(); +} diff --git a/packages/grant-explorer/src/index.css b/packages/grant-explorer/src/index.css index 8d50d6aa77..8d5b501d38 100644 --- a/packages/grant-explorer/src/index.css +++ b/packages/grant-explorer/src/index.css @@ -105,3 +105,7 @@ background: linear-gradient(0deg, rgba(255, 255, 255, 0.60) 0%, rgba(255, 255, 255, 0.60) 100%), linear-gradient(124deg, #FF9776 17.77%, #5F94BC 35.47%, #25BDCE 59.3%, #DEAB0C 91.61%); box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05); } + +.chakra-accordion .chakra-accordion__item{ + border: 0; +} diff --git a/packages/grant-explorer/src/index.tsx b/packages/grant-explorer/src/index.tsx index 1c0dcd86b6..e715b66d2f 100644 --- a/packages/grant-explorer/src/index.tsx +++ b/packages/grant-explorer/src/index.tsx @@ -24,7 +24,7 @@ import { ChakraProvider } from "@chakra-ui/react"; import AccessDenied from "./features/common/AccessDenied"; import Auth from "./features/common/Auth"; import NotFound from "./features/common/NotFoundPage"; -import { ViewContributionHistoryPage } from "./features/contributors/ViewContributionHistory"; +import { ViewContributionHistoryPage } from "./features/contributors"; import ExploreRoundsPage from "./features/discovery/ExploreRoundsPage"; import LandingPage from "./features/discovery/LandingPage"; import ThankYou from "./features/round/ThankYou"; diff --git a/packages/grant-explorer/tailwind.config.js b/packages/grant-explorer/tailwind.config.js index 21d209e253..4fbcf2bb7e 100644 --- a/packages/grant-explorer/tailwind.config.js +++ b/packages/grant-explorer/tailwind.config.js @@ -1,7 +1,13 @@ -const colors = require("tailwindcss/colors"); -const defaultTheme = require("tailwindcss/defaultTheme"); +import colors from "tailwindcss/colors"; +import defaultTheme from "tailwindcss/defaultTheme"; +import typography from "@tailwindcss/typography"; +import forms from "@tailwindcss/forms"; +import lineClamp from "@tailwindcss/line-clamp"; -module.exports = { +export const rainbowGradient = + "linear-gradient(170deg, #FFD6C9 10%, #B8D9E7 40%, #ABE3EB 60%, #F2DD9E 90%)"; + +export default { content: ["./src/**/*.{js,jsx,ts,tsx}", "../common/src/**/*.{js,jsx,ts,tsx}"], theme: { fontFamily: { @@ -13,8 +19,7 @@ module.exports = { "pulse-scale": "pulse-scale 2s ease-in-out infinite", }, backgroundImage: { - "rainbow-gradient": - "linear-gradient(170deg, #FFD6C9 10%, #B8D9E7 40%, #ABE3EB 60%, #F2DD9E 90%)", + "rainbow-gradient": rainbowGradient, }, colors: { transparent: "transparent", @@ -88,6 +93,7 @@ module.exports = { 400: "#6F3FF5", 500: "#5932C4", }, + "rainbow-gradient": rainbowGradient, }, keyframes: { violetTransition: { @@ -121,9 +127,5 @@ module.exports = { }, }, }, - plugins: [ - require("@tailwindcss/typography"), - require("@tailwindcss/forms"), - require("@tailwindcss/line-clamp"), - ], + plugins: [typography, forms, lineClamp], };