diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 000000000..c10072d77 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,7 @@ +bracketSpacing: true +printWidth: 100 +proseWrap: "always" +singleQuote: false +tabWidth: 2 +trailingComma: "all" +useTabs: false diff --git a/README.md b/README.md index 0ebb4ef69..fd0325828 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ ## Getting Started with GAP frontend + +## Pre-Requisites + +- [node.js](https://nodejs.org/en/download/package-manager) installed (>=18.17.0) +- typescript installed (developed on v5.3.3) +- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#windows-stable) +- Web3 Wallet installed in your browser + +## Installation + +### Forkin the repository + +Fork this repository by: + +1. Clicking in the arrow aside from the `fork` +2. Unmark the option `Copy the main branch only` +3. Click `Create fork` + +### Cloning the repository + +Open the VsCode or other IDE editor, and enter the following command in the cmd: + +```bash +git clone https://github.com/YourUserName/gap-app-v2.git +``` + +### Instaling dependencies + +Install all package dependencies by running: + +```bash +yarn install +``` + +### Configure the `.env.example` file + +1. Copy the `.env.example` file and paste it in the root directory +2. Remove the `.example` from the `.env.example` file name +3. Add your API keys in the `.env` file +4. Creating the keys to fulfill the `.env` file + 1. **ALCHEMY_KEY:** Follow [this](https://docs.alchemy.com/docs/alchemy-quickstart-guide) tutorial of the Alchemy Docs and fill the following keys: `NEXT_PUBLIC_ALCHEMY_KEY`(use base sepolia for this one), `NEXT_PUBLIC_RPC_OPTIMISM`, `NEXT_PUBLIC_RPC_ARBITRUM`, `NEXT_PUBLIC_RPC_SEPOLIA` and `NEXT_PUBLIC_RPC_OPTIMISM_SEPOLIA` + 2. **PROJECT_ID:** Create your account in [this](https://walletconnect.com/) link and follow the instructions to generate a key + 3. **NEXT_PUBLIC_MIXPANEL_KEY:** Create your account in [this](https://mixpanel.com/login/) and follow [this](https://www.storylane.io/tutorials/how-to-get-mixpanel-project-token) tutorial + +### Running the application + +First, run the development server: + +```bash +yarn run dev +``` + +Open http://localhost:3000 with your browser. + +@blockful_io diff --git a/components/Icons/StarReview.tsx b/components/Icons/StarReview.tsx new file mode 100644 index 000000000..06c496823 --- /dev/null +++ b/components/Icons/StarReview.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/utilities/tailwind"; +import type { SVGProps } from "react"; + +export const StarReviewIcon = ({ + props, + pathProps, + score, + isHovered = true, +}: { + props?: SVGProps; + pathProps?: React.SVGProps; + score?: number; + isHovered?: boolean; +}) => { + return ( + + + {score !== undefined && ( + + {score} + + )} + + ); +}; diff --git a/components/Pages/ProgramRegistry/ProgramList.tsx b/components/Pages/ProgramRegistry/ProgramList.tsx index e5ad9902c..7cc6cad5b 100644 --- a/components/Pages/ProgramRegistry/ProgramList.tsx +++ b/components/Pages/ProgramRegistry/ProgramList.tsx @@ -10,15 +10,9 @@ import { DiscussionIcon } from "@/components/Icons/Discussion"; import { BlogIcon } from "@/components/Icons/Blog"; import { OrganizationIcon } from "@/components/Icons/Organization"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { - ColumnDef, - Row, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; +import { ColumnDef, Row, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { Button } from "@/components/Utilities/Button"; -import { formatDate } from "@/utilities/formatDate"; +import { StarReviewIcon } from "@/components/Icons/StarReview"; export type GrantProgram = { _id: { @@ -74,6 +68,7 @@ export type GrantProgram = { registryAddress?: string; anchorAddress?: string; programId?: string; + programScore?: number; chainID?: number; isValid?: boolean; txHash?: string; @@ -86,10 +81,7 @@ interface ProgramListProps { selectProgram: (program: GrantProgram) => void; } -export const ProgramList: FC = ({ - grantPrograms, - selectProgram, -}) => { +export const ProgramList: FC = ({ grantPrograms, selectProgram }) => { const columns = useMemo[]>( () => [ { @@ -110,10 +102,7 @@ export const ProgramList: FC = ({
{grant.metadata?.socialLinks?.website ? ( - + = ({ ) : null} {grant.metadata?.socialLinks?.twitter ? ( - + ) : null} {grant.metadata?.socialLinks?.discord ? ( - + ) : null} {grant.metadata?.socialLinks?.forum ? ( - + ) : null} {grant.metadata?.socialLinks?.blog ? ( - + ) : null} {grant.metadata?.socialLinks?.orgWebsite ? ( - + ) : null} @@ -188,10 +162,7 @@ export const ProgramList: FC = ({ const grant = info.row.original; return (
-
+
= ({ {network} {network} @@ -280,17 +243,11 @@ export const ProgramList: FC = ({ >
- {registryHelper.networkImages[ - network.toLowerCase() - ] ? ( + {registryHelper.networkImages[network.toLowerCase()] ? ( {network} @@ -327,21 +284,12 @@ export const ProgramList: FC = ({ >
{restNetworks.map((item) => ( -
- {registryHelper.networkImages[ - item.toLowerCase() - ] ? ( +
+ {registryHelper.networkImages[item.toLowerCase()] ? ( {item} @@ -420,6 +368,54 @@ export const ProgramList: FC = ({
), }, + { + accessorFn: (row) => row, + id: "AverageScore", + cell: (info) => { + const grant = info.row.original; + + return ( +
+ + {grant.programScore ? ( + + + + + + + + + {`Score: ${grant.programScore} out of 5`} + + + + + ) : ( + <> + )} + +
+ ); + }, + header: () => ( +
+ Score +
+ ), + }, { accessorFn: (row) => row, id: "Apply", @@ -439,7 +435,7 @@ export const ProgramList: FC = ({ header: () =>
, }, ], - [] + [], ); const table = useReactTable({ @@ -479,12 +475,7 @@ export const ProgramList: FC = ({ style={{ width: header.getSize() }} > {header.isPlaceholder ? null : ( -
- {flexRender( - header.column.columnDef.header, - header.getContext() - )} -
+
{flexRender(header.column.columnDef.header, header.getContext())}
)} ); @@ -500,18 +491,13 @@ export const ProgramList: FC = ({ key={row.id} style={{ height: `${virtualRow.size}px`, - transform: `translateY(${ - virtualRow.start - index * virtualRow.size - }px)`, + transform: `translateY(${virtualRow.start - index * virtualRow.size}px)`, }} > {row.getVisibleCells().map((cell) => { return ( - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} + {flexRender(cell.column.columnDef.cell, cell.getContext())} ); })} diff --git a/components/Pages/ProgramRegistry/ProgramsExplorer.tsx b/components/Pages/ProgramRegistry/ProgramsExplorer.tsx index 3faae9164..eaa692729 100644 --- a/components/Pages/ProgramRegistry/ProgramsExplorer.tsx +++ b/components/Pages/ProgramRegistry/ProgramsExplorer.tsx @@ -1,23 +1,17 @@ "use client"; /* eslint-disable @next/next/no-img-element */ -import React, { Dispatch, useMemo } from "react"; +import React, { Dispatch } from "react"; import { useState, useEffect } from "react"; import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { CheckIcon } from "@heroicons/react/24/solid"; import { Spinner } from "@/components/Utilities/Spinner"; -import Link from "next/link"; -import { PAGES } from "@/utilities/pages"; import debounce from "lodash.debounce"; import fetchData from "@/utilities/fetchData"; import { INDEXER } from "@/utilities/indexer"; -import { - GrantProgram, - ProgramList, -} from "@/components/Pages/ProgramRegistry/ProgramList"; +import { GrantProgram, ProgramList } from "@/components/Pages/ProgramRegistry/ProgramList"; import { registryHelper } from "@/components/Pages/ProgramRegistry/helper"; import { SearchDropdown } from "@/components/Pages/ProgramRegistry/SearchDropdown"; import { useQueryState } from "nuqs"; -import { useAuthStore } from "@/store/auth"; import { useAccount } from "wagmi"; import Pagination from "@/components/Utilities/Pagination"; import { ProgramDetailsDialog } from "@/components/Pages/ProgramRegistry/ProgramDetailsDialog"; @@ -26,18 +20,16 @@ import { checkIsPoolManager } from "@/utilities/registry/checkIsPoolManager"; import { useSearchParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { ExternalLink } from "@/components/Utilities/ExternalLink"; -import { KarmaLogo } from "@/components/Icons/Karma"; import { useRegistryStore } from "@/store/registry"; +import { getGrantProgramAverageScore } from "@/utilities/review/getGrantProgramAverageScore"; const statuses = ["Active", "Inactive"]; const links = { funding_block: "https://tally.so/r/w2rJ8M", add_program: "/funding-map/add-program", - cryptographer: - "https://sovs.notion.site/Cartographer-Syndicate-a574b48ae162451cb73c17326f471b6a", - notion: - "https://www.notion.so/sovs/Onchain-Grant-Registry-8fde2610cf6c4422a07216d4b2506c73", + cryptographer: "https://sovs.notion.site/Cartographer-Syndicate-a574b48ae162451cb73c17326f471b6a", + notion: "https://www.notion.so/sovs/Onchain-Grant-Registry-8fde2610cf6c4422a07216d4b2506c73", }; export const ProgramsExplorer = () => { @@ -93,31 +85,23 @@ export const ProgramsExplorer = () => { serialize: (value) => (value.length ? value?.join(",") : ""), parse: (value) => (value.length > 0 ? value.split(",") : []), }); - const [selectedEcosystems, setSelectedEcosystems] = useQueryState( - "ecosystems", - { - defaultValue: defaultEcosystems, - serialize: (value) => (value.length ? value?.join(",") : ""), - parse: (value) => (value.length > 0 ? value.split(",") : []), - } - ); - const [selectedGrantTypes, setSelectedGrantTypes] = useQueryState( - "grantTypes", - { - defaultValue: defaultGrantTypes, - serialize: (value) => (value.length ? value?.join(",") : ""), - parse: (value) => (value.length > 0 ? value.split(",") : []), - } - ); + const [selectedEcosystems, setSelectedEcosystems] = useQueryState("ecosystems", { + defaultValue: defaultEcosystems, + serialize: (value) => (value.length ? value?.join(",") : ""), + parse: (value) => (value.length > 0 ? value.split(",") : []), + }); + const [selectedGrantTypes, setSelectedGrantTypes] = useQueryState("grantTypes", { + defaultValue: defaultGrantTypes, + serialize: (value) => (value.length ? value?.join(",") : ""), + parse: (value) => (value.length > 0 ? value.split(",") : []), + }); const [programId, setProgramId] = useQueryState("programId", { defaultValue: defaultProgramId, throttleMs: 500, }); - const [selectedProgram, setSelectedProgram] = useState( - null - ); + const [selectedProgram, setSelectedProgram] = useState(null); const debouncedSearch = debounce((value: string) => { setPage(1); @@ -126,7 +110,7 @@ export const ProgramsExplorer = () => { const onChangeGeneric = ( value: string, - setToChange: Dispatch> + setToChange: Dispatch>, ) => { setToChange((oldArray) => { setPage(1); @@ -183,21 +167,11 @@ export const ProgramsExplorer = () => { INDEXER.REGISTRY.GET_ALL + `?limit=${pageSize}&offset=${(page - 1) * pageSize}${ searchInput ? `&name=${searchInput}` : "" - }${ - selectedGrantTypes.length ? `&grantTypes=${selectedGrantTypes}` : "" - }${status ? `&status=${status}` : ""}${ - selectedNetworks.length - ? `&networks=${selectedNetworks.join(",")}` - : "" - }${ - selectedEcosystems.length - ? `&ecosystems=${selectedEcosystems.join(",")}` - : "" - }${ - selectedCategory.length - ? `&categories=${selectedCategory.join(",")}` - : "" - }` + }${selectedGrantTypes.length ? `&grantTypes=${selectedGrantTypes}` : ""}${ + status ? `&status=${status}` : "" + }${selectedNetworks.length ? `&networks=${selectedNetworks.join(",")}` : ""}${ + selectedEcosystems.length ? `&ecosystems=${selectedEcosystems.join(",")}` : "" + }${selectedCategory.length ? `&categories=${selectedCategory.join(",")}` : ""}`, ); if (error) { throw new Error(error); @@ -208,6 +182,52 @@ export const ProgramsExplorer = () => { const grantPrograms = data?.programs || []; const totalPrograms = data?.count || 0; + const [grantProgramsWithScore, setGrantProgramsWithScore] = useState( + undefined, + ); + useEffect(() => { + setLoading(true); + + const programsWithScore = (grantPrograms as GrantProgram[]).map( + async (program: GrantProgram) => { + let programScore: undefined | number; + if (program?.programId) { + try { + /** + * Refer to getGrantProgramAverageScore.ts to identify the cases when the score is undefined. + */ + programScore = + (await getGrantProgramAverageScore(Number(program.programId))) || undefined; + } catch { + programScore = undefined; + } + } + + return { ...program, programScore }; + }, + ); + + Promise.allSettled(programsWithScore) + .then((scoredPrograms) => { + const grantProgramsWithScore: GrantProgram[] = []; + + scoredPrograms.forEach((program, idx) => { + if (program.status === "fulfilled") { + grantProgramsWithScore.push(program.value); + } else { + grantProgramsWithScore.push(grantPrograms[idx]); + } + }); + + setGrantProgramsWithScore(grantProgramsWithScore); + }) + .catch((error) => { + setGrantProgramsWithScore([...grantPrograms]); + }); + + setLoading(false); + }, [grantPrograms]); + useEffect(() => { setLoading(isLoading); }, [isLoading]); @@ -216,7 +236,7 @@ export const ProgramsExplorer = () => { const searchProgramById = async (id: string) => { try { const [data, error] = await fetchData( - INDEXER.REGISTRY.FIND_BY_ID(id, registryHelper.supportedNetworks) + INDEXER.REGISTRY.FIND_BY_ID(id, registryHelper.supportedNetworks), ); if (data) { setSelectedProgram(data); @@ -249,23 +269,16 @@ export const ProgramsExplorer = () => { {`The best grant program directory you’ll find`}

- Explore our curated list of grant programs for innovators and - creators: from tech pioneers to community leaders, there is a - grant program to elevate your project. Find and apply for a grant - now! + Explore our curated list of grant programs for innovators and creators: from tech + pioneers to community leaders, there is a grant program to elevate your project. Find + and apply for a grant now!

- Funding + Funding
-

- Looking for funding? -

+

Looking for funding?

{

- Reward + Reward
-

- Are we missing a grant program? -

+

Are we missing a grant program?

{

- Karma Logo + Karma Logo

Our vision and roadmap for the funding map. @@ -373,9 +376,7 @@ export const ProgramsExplorer = () => { }`} > All categories - {!selectedCategory.length ? ( - - ) : null} + {!selectedCategory.length ? : null} {registryHelper.categories.map((type) => ( ))}

@@ -461,9 +460,7 @@ export const ProgramsExplorer = () => { - onChangeGeneric(value, setSelectedNetworks) - } + onSelectFunction={(value: string) => onChangeGeneric(value, setSelectedNetworks)} cleanFunction={() => { setSelectedNetworks([]); }} @@ -473,9 +470,7 @@ export const ProgramsExplorer = () => { /> - onChangeGeneric(value, setSelectedEcosystems) - } + onSelectFunction={(value: string) => onChangeGeneric(value, setSelectedEcosystems)} cleanFunction={() => { setSelectedEcosystems([]); }} @@ -485,9 +480,7 @@ export const ProgramsExplorer = () => { /> - onChangeGeneric(value, setSelectedGrantTypes) - } + onSelectFunction={(value: string) => onChangeGeneric(value, setSelectedGrantTypes)} cleanFunction={() => { setSelectedGrantTypes([]); }} @@ -498,17 +491,17 @@ export const ProgramsExplorer = () => {
- {!loading ? ( + {!loading && typeof grantProgramsWithScore !== "undefined" ? (
{grantPrograms.length ? (
-
+
{ setSelectedProgram(program); setProgramId(program.programId || ""); diff --git a/components/Pages/Project/ProjectGrantsPage.tsx b/components/Pages/Project/ProjectGrantsPage.tsx index c74da1a46..4b589287d 100644 --- a/components/Pages/Project/ProjectGrantsPage.tsx +++ b/components/Pages/Project/ProjectGrantsPage.tsx @@ -4,20 +4,14 @@ import React, { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useOwnerStore, useProjectStore } from "@/store"; import { ExternalLink } from "@/components/Utilities/ExternalLink"; -import { - ArrowTopRightOnSquareIcon, - PencilSquareIcon, -} from "@heroicons/react/24/outline"; +import { ArrowTopRightOnSquareIcon, PencilSquareIcon } from "@heroicons/react/24/outline"; import formatCurrency from "@/utilities/formatCurrency"; import { Hex } from "viem"; import markdownStyles from "@/styles/markdown.module.css"; import { CheckCircleIcon, PlusIcon } from "@heroicons/react/20/solid"; import { Button } from "@/components/Utilities/Button"; -import { - EmptyGrantsSection, - NewGrant, -} from "@/components/Pages/GrantMilestonesAndUpdates/screens"; +import { EmptyGrantsSection, NewGrant } from "@/components/Pages/GrantMilestonesAndUpdates/screens"; import { useRouter } from "next/navigation"; import { GrantScreen } from "@/types/grant"; import { NewMilestone } from "@/components/Pages/GrantMilestonesAndUpdates/screens/NewMilestone"; @@ -34,12 +28,7 @@ import { ReviewGrant } from "@/components/Pages/ReviewGrant"; import { GenerateImpactReportDialog } from "@/components/Dialogs/GenerateImpactReportDialog"; import { useQueryState } from "nuqs"; -import { - getMetadata, - getQuestionsOf, - getReviewsOf, - isCommunityAdminOf, -} from "@/utilities/sdk"; +import { getMetadata, getQuestionsOf, getReviewsOf, isCommunityAdminOf } from "@/utilities/sdk"; import { zeroUID } from "@/utilities/commons"; import { cn } from "@/utilities/tailwind"; import { MESSAGES } from "@/utilities/messages"; @@ -56,6 +45,8 @@ import { PAGES } from "@/utilities/pages"; import { IGrantResponse } from "@show-karma/karma-gap-sdk/core/class/karma-indexer/api/types"; import { gapIndexerApi } from "@/utilities/gapIndexerApi"; import { GrantsGenieDialog } from "@/components/Dialogs/GrantGenieDialog"; +import { ReviewSection } from "@/components/Pages/Project/Review/"; +import { useReviewStore } from "@/store/review"; interface Tab { name: string; @@ -77,42 +68,38 @@ export const ProjectGrantsPage = () => { const grantIdFromQueryParam = searchParams?.get("grantId"); const [currentTab, setCurrentTab] = useState("overview"); const [grant, setGrant] = useState(undefined); - const project = useProjectStore((state) => state.project); + const project = useProjectStore((state: any) => state.project); const navigation = - project?.grants?.map((item) => ({ + project?.grants?.map((item: any) => ({ uid: item.uid, name: item.details?.data?.title || "", - href: PAGES.PROJECT.GRANT( - project.details?.data?.slug || project.uid, - item.uid - ), + href: PAGES.PROJECT.GRANT(project.details?.data?.slug || project.uid, item.uid), icon: item.community?.details?.data?.imageURL || "", current: item.uid === grantIdFromQueryParam || item.uid === grant?.uid, completed: item.completed, })) || []; const [tabs, setTabs] = useState([]); const router = useRouter(); + const { address } = useAccount(); - const isProjectOwner = useProjectStore((state) => state.isProjectOwner); - const isContractOwner = useOwnerStore((state) => state.isOwner); - const isCommunityAdmin = useCommunityAdminStore( - (state) => state.isCommunityAdmin - ); + const isProjectOwner = useProjectStore((state: any) => state.isProjectOwner); + const isContractOwner = useOwnerStore((state: any) => state.isOwner); + const isCommunityAdmin = useCommunityAdminStore((state: any) => state.isCommunityAdmin); const { communities } = useCommunitiesStore(); const isCommunityAdminOfSome = communities.length !== 0; const isAuthorized = isProjectOwner || isContractOwner || isCommunityAdmin; const [, changeTab] = useQueryState("tab"); const [, changeGrantId] = useQueryState("grantId"); - const { address } = useAccount(); + + const setGrantUID = useReviewStore((state: any) => state.setGrantUID); + const setStories = useReviewStore((state: any) => state.setStories); + const setIsStarSelected = useReviewStore((state: any) => state.setIsStarSelected); + const setBadges = useReviewStore((state: any) => state.setBadges); // UseEffect to check if current URL changes useEffect(() => { if (tabFromQueryParam) { - if ( - !isAuthorized && - currentTab && - authorizedViews.includes(currentTab as GrantScreen) - ) { + if (!isAuthorized && currentTab && authorizedViews.includes(currentTab as GrantScreen)) { setCurrentTab("overview"); } else { setCurrentTab(tabFromQueryParam); @@ -124,8 +111,7 @@ export const ProjectGrantsPage = () => { if (project) { if (grantIdFromQueryParam) { const grantFound = project?.grants?.find( - (grant) => - grant.uid?.toLowerCase() === grantIdFromQueryParam?.toLowerCase() + (grant: any) => grant.uid?.toLowerCase() === grantIdFromQueryParam?.toLowerCase(), ); if (grantFound) { setGrant(grantFound); @@ -133,6 +119,13 @@ export const ProjectGrantsPage = () => { } } setGrant(project?.grants?.[0]); + // We need to set this UID here because sometimes the project loads without the + // search parameters and the page assumes the zeroth index grant is the one to be + // displayed, therefore we set it here in this case + setGrantUID(project?.grants?.[0]?.uid); + setBadges(null); + setStories(null); + setIsStarSelected(0); } }, [project, grantIdFromQueryParam]); @@ -157,17 +150,18 @@ export const ProjectGrantsPage = () => { tabName: "impact-criteria", current: false, }, + { + name: "Review", + tabName: "review", + current: false, + }, ]; useEffect(() => { const mountTabs = async () => { const firstTabs: Tab[] = [...defaultTabs]; - if ( - !grant || - !grant.categories?.length || - grant.categories?.length <= 0 - ) { + if (!grant || !grant.categories?.length || grant.categories?.length <= 0) { setTabs(firstTabs); return; } @@ -214,11 +208,9 @@ export const ProjectGrantsPage = () => { mountTabs(); }, [grant?.uid]); - const setIsCommunityAdmin = useCommunityAdminStore( - (state) => state.setIsCommunityAdmin - ); + const setIsCommunityAdmin = useCommunityAdminStore((state: any) => state.setIsCommunityAdmin); const setIsCommunityAdminLoading = useCommunityAdminStore( - (state) => state.setIsCommunityAdminLoading + (state: any) => state.setIsCommunityAdminLoading, ); const signer = useSigner(); @@ -238,11 +230,7 @@ export const ProjectGrantsPage = () => { const community = await gapIndexerApi .communityBySlug(grant.data.communityUID) .then((res) => res.data); - const result = await isCommunityAdminOf( - community, - address as string, - signer - ); + const result = await isCommunityAdminOf(community, address as string, signer); setIsCommunityAdmin(result); } catch (error) { console.log(error); @@ -263,7 +251,7 @@ export const ProjectGrantsPage = () => {
- {navigation.map((item) => ( + {navigation.map((item: any) => (
+
+ + ) : ( +
+ +
+ )} +
+
+ ); +}; diff --git a/components/Pages/Project/Review/CardReview.tsx b/components/Pages/Project/Review/CardReview.tsx new file mode 100644 index 000000000..3e3fcd901 --- /dev/null +++ b/components/Pages/Project/Review/CardReview.tsx @@ -0,0 +1,66 @@ +"use client"; +/* eslint-disable @next/next/no-img-element */ +import { useEffect } from "react"; +import { useReviewStore } from "@/store/review"; + +import { Badge, GrantStory, ReviewMode } from "@/types/review"; +import { addPrefixToIPFSLink } from "@/utilities/review/constants/utilitary"; +import { getBadge } from "@/utilities/review/getBadge"; + +import { DynamicStarsReview } from "./DynamicStarsReview"; +import { Hex } from "viem"; + +export const CardReview = ({ storie }: { storie: GrantStory }) => { + const badges = useReviewStore((state: any) => state.badges); + const setBadges = useReviewStore((state: any) => state.setBadges); + + useEffect(() => { + if (storie && storie.badgeIds.length > 0 && badges === null) { + handleBadges(); + } + }, [storie]); + + const handleBadges = async () => { + const badgesIds = storie.badgeIds; + const badges = await Promise.all( + badgesIds.map(async (badgeId: Hex): Promise => { + return await getBadge(badgeId); + }), + ); + setBadges(badges); + }; + + return ( +
+
+ {storie && + badges && + badges.map((badge: Badge, index: number) => ( +
+
+ Badge Metadata +
+
+
{badge.name}
+
{badge.description}
+
+
+ {}} + mode={ReviewMode.READ} + /> +
+
+
+
+ ))} +
+
+ ); +}; diff --git a/components/Pages/Project/Review/DynamicStarsReview.tsx b/components/Pages/Project/Review/DynamicStarsReview.tsx new file mode 100644 index 000000000..6041f3d25 --- /dev/null +++ b/components/Pages/Project/Review/DynamicStarsReview.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; +import { StarReviewIcon } from "@/components/Icons/StarReview"; +import { ReviewMode } from "@/types/review"; + +interface DynamicStarsReviewProps { + totalStars: number; + rating: number; + setRating: (rating: number) => void; + mode: ReviewMode; +} + +export const DynamicStarsReview = ({ + totalStars, + rating, + setRating, + mode, +}: DynamicStarsReviewProps) => { + const [hover, setHover] = useState(null); + + const handleStarClick = (index: number) => { + if (mode == ReviewMode.WRITE) { + setRating(index); + } + }; + + return ( +
+ {[...Array(totalStars)].map((_, index) => { + const currentRating = index + 1; + const isHoveredOrRated = currentRating <= (hover || rating || 0); + + return ( + handleStarClick(currentRating) + : undefined, + style: { + fill: isHoveredOrRated ? "#004EEB" : "none", + stroke: isHoveredOrRated ? "#004EEB" : "#98A2B3", + }, + onMouseEnter: + mode === ReviewMode.WRITE + ? () => setHover(currentRating) + : undefined, + onMouseLeave: + mode === ReviewMode.WRITE ? () => setHover(null) : undefined, + }} + /> + ); + })} +
+ ); +}; diff --git a/components/Pages/Project/Review/NavbarReview.tsx b/components/Pages/Project/Review/NavbarReview.tsx new file mode 100644 index 000000000..fb152a505 --- /dev/null +++ b/components/Pages/Project/Review/NavbarReview.tsx @@ -0,0 +1,105 @@ +"use client"; +import { useEffect } from "react"; +import { useReviewStore } from "@/store/review"; +import { useSearchParams } from "next/navigation"; + +import { GrantStory } from "@/types/review"; + +import { StarReviewIcon } from "@/components/Icons/StarReview"; +import { CardReview } from "@/components/Pages/Project/Review/CardReview"; +import { ChevronDown } from "@/components/Icons"; + +import { formatDate } from "@/utilities/formatDate"; +import { getGrantStories } from "@/utilities/review/getGrantStories"; +import { SCORER_DECIMALS } from "@/utilities/review/constants/constants"; + +export const NavbarReview = () => { + const isStarSelected = useReviewStore((state: any) => state.isStarSelected); + const stories = useReviewStore((state: any) => state.stories); + const grantUID = useReviewStore((state: any) => state.grantUID); + const setGrantUID = useReviewStore((state: any) => state.setGrantUID); + const setBadges = useReviewStore((state: any) => state.setBadges); + const setStories = useReviewStore((state: any) => state.setStories); + const setIsStarSelected = useReviewStore((state: any) => state.setIsStarSelected); + + const searchParams = useSearchParams(); + + useEffect(() => { + const grantIdFromQueryParam = searchParams?.get("grantId"); + if (grantIdFromQueryParam && grantUID !== grantIdFromQueryParam) { + setBadges(null); + setStories(null); + setIsStarSelected(0); + setGrantUID(grantIdFromQueryParam); + } + + if (grantUID && !stories) { + fetchGrantStories(); + } + }, [grantUID, stories]); + + const fetchGrantStories = async () => { + const grantStories = await getGrantStories(grantUID); + setStories(grantStories); + }; + + const handleToggleReviewSelected = (id: number) => { + const currentSelection = useReviewStore.getState().isStarSelected; + useReviewStore.setState({ + isStarSelected: currentSelection === id ? null : id, + }); + }; + + return ( +
+
+ {stories && stories.length > 0 ? ( + stories + .sort((a: any, b: any) => Number(b.timestamp) - Number(a.timestamp)) + .map((storie: GrantStory, index: number) => ( +
+

{formatDate(new Date(Number(storie.timestamp) * 1000))}

+
+ { + setBadges(null); + handleToggleReviewSelected(index); + }, + }} + /> +

{(Number(storie.averageScore) / 10 ** SCORER_DECIMALS).toFixed(1)}

+ {isStarSelected === index && ( +
+ +
+ )} + {index < stories.length - 1 && ( +
+ )} +
+
+ )) + ) : ( +
+

+ Be the first to share your thoughts! Reach out to the grantee and encourage them to + leave a review. +

+
+ )} +
+
+ {isStarSelected !== null && stories && } +
+
+ ); +}; diff --git a/components/Pages/Project/Review/index.tsx b/components/Pages/Project/Review/index.tsx new file mode 100644 index 000000000..cb399b582 --- /dev/null +++ b/components/Pages/Project/Review/index.tsx @@ -0,0 +1,131 @@ +"use client"; +/* eslint-disable @next/next/no-img-element */ +import toast from "react-hot-toast"; +import { useProjectStore } from "@/store"; +import { useReviewStore } from "@/store/review"; + +import { isAddressEqual } from "viem"; +import { useAccount, useSwitchChain } from "wagmi"; +import { arbitrum } from "@wagmi/core/chains"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; + +import { IGrantResponse } from "@show-karma/karma-gap-sdk/core/class/karma-indexer/api/types"; + +import { ReviewMode } from "@/types/review"; +import { StarIcon } from "@/components/Icons"; +import { Spinner } from "@/components/Utilities/Spinner"; +import { Button } from "@/components/Utilities/Button"; +import { NavbarReview } from "@/components/Pages/Project/Review/NavbarReview"; + +import { SCORER_ID } from "@/utilities/review/constants/constants"; +import { getBadgeIds } from "@/utilities/review/getBadgeIds"; +import { getBadge } from "@/utilities/review/getBadge"; + +import { XMarkIcon } from "@heroicons/react/24/solid"; +import { CardNewReview } from "./CardNewReview"; + +interface GrantAllReviewsProps { + grant: IGrantResponse | undefined; +} + +export const ReviewSection = ({ grant }: GrantAllReviewsProps) => { + const isProjectLoading = useProjectStore((state: any) => state.loading); + if (isProjectLoading || !grant) { +
+ +
; + } + const project = useProjectStore((state: any) => state.project); + const isOpenReview = useReviewStore((state: any) => state.isOpenReview); + const setIsOpenReview = useReviewStore((state: any) => state.setIsOpenReview); + + const setActiveBadges = useReviewStore((state: any) => state.setActiveBadges); + const setActiveBadgeIds = useReviewStore((state: any) => state.setActiveBadgeIds); + + const { openConnectModal } = useConnectModal(); + const { isConnected, address, chainId } = useAccount(); + const { switchChain } = useSwitchChain(); + + const handleReviewButton = () => { + if (!isConnected && openConnectModal) { + openConnectModal(); + } else { + if (chainId != arbitrum.id) { + switchChain({ chainId: arbitrum.id }); + toast.error("Must connect to Arbitrum to review"); + } else { + setIsOpenReview(ReviewMode.WRITE); + handleStoryBadges(); + } + } + }; + + // Grab all recent badges and save on state + const handleStoryBadges = async () => { + const badgeIds = await getBadgeIds(SCORER_ID); + const badges = badgeIds && (await Promise.all(badgeIds.map((id) => getBadge(id)))); + setActiveBadgeIds(badgeIds); + setActiveBadges(badges); + }; + + return ( +
+
+
+ {isOpenReview === ReviewMode.WRITE ? ( + <> +
+

Write a new review

+ +
+ + + ) : ( + isOpenReview === ReviewMode.READ && ( + <> +
+
+

+ All reviews of {grant?.details?.data?.title} +

+ {isConnected && + project?.recipient && + address /** This conditional enables any user to review grant projects */ ? ( + + ) : ( + !isConnected && ( + + ) + )} +
+
+ + + ) + )} +
+
+
+ ); +}; diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml index ee93bbd6e..39e7cf207 100644 --- a/public/sitemap-0.xml +++ b/public/sitemap-0.xml @@ -1,13 +1,13 @@ -https://gap.karmahq.xyz/sitemaps/communities/sitemap.xml2024-08-03T19:27:36.490Zdaily0.7 -https://gap.karmahq.xyz/sitemaps/projects/sitemap.xml2024-08-03T19:27:36.490Zdaily0.7 -https://gap.karmahq.xyz/api/sponsored-txn2024-08-03T19:27:36.490Zdaily0.7 -https://gap.karmahq.xyz/admin2024-08-03T19:27:36.490Zdaily0.7 -https://gap.karmahq.xyz/my-projects2024-08-03T19:27:36.490Zdaily0.7 -https://gap.karmahq.xyz/funding-map2024-08-03T19:27:36.490Zdaily0.7 -https://gap.karmahq.xyz/admin/communities2024-08-03T19:27:36.490Zdaily0.7 -https://gap.karmahq.xyz/funding-map/manage-programs2024-08-03T19:27:36.490Zdaily0.7 -https://gap.karmahq.xyz2024-08-03T19:27:36.490Zdaily0.7 -https://gap.karmahq.xyz/funding-map/add-program2024-08-03T19:27:36.490Zdaily0.7 +https://gap.karmahq.xyz/sitemaps/projects/sitemap.xml2024-08-29T18:33:59.402Zdaily0.7 +https://gap.karmahq.xyz/api/sponsored-txn2024-08-29T18:33:59.403Zdaily0.7 +https://gap.karmahq.xyz/admin2024-08-29T18:33:59.403Zdaily0.7 +https://gap.karmahq.xyz/admin/communities2024-08-29T18:33:59.403Zdaily0.7 +https://gap.karmahq.xyz/my-projects2024-08-29T18:33:59.403Zdaily0.7 +https://gap.karmahq.xyz/funding-map/add-program2024-08-29T18:33:59.403Zdaily0.7 +https://gap.karmahq.xyz2024-08-29T18:33:59.403Zdaily0.7 +https://gap.karmahq.xyz/funding-map/manage-programs2024-08-29T18:33:59.403Zdaily0.7 +https://gap.karmahq.xyz/funding-map2024-08-29T18:33:59.403Zdaily0.7 +https://gap.karmahq.xyz/sitemaps/communities/sitemap.xml2024-08-29T18:33:59.403Zdaily0.7 \ No newline at end of file diff --git a/store/review.ts b/store/review.ts new file mode 100644 index 000000000..0b03762a7 --- /dev/null +++ b/store/review.ts @@ -0,0 +1,57 @@ +import { ReviewMode, Badge, GrantStory } from "@/types/review"; +import { Hex } from "viem"; +import { create } from "zustand"; + +interface ReviewStore { + /// UI + isOpenReview: ReviewMode; + setIsOpenReview: (isOpenReview: ReviewMode) => void; + isStarSelected: number | null; + setIsStarSelected: (isStarSelected: number | null) => void; + + /// This are setters used by the blockchain calls + stories: GrantStory[] | null; + setStories: (stories: GrantStory[] | null) => void; + + /// Used to store the badges of a story + badges: Badge[] | null; + setBadges: (badges: Badge[] | null) => void; + + /// The grant UID that is being reviewed at current context + grantUID: string | null; + setGrantUID: (grantUID: string | null) => void; + + /// Used to store the badges that are currently active in the Scorer to write a new review + activeBadges: Badge[] | null; + setActiveBadges: (activeBadges: Badge[] | null) => void; + + /// Used to store the array of badgeIds that are currently active in the Scorer + activeBadgeIds: Hex[] | null; + setActiveBadgeIds: (activeBadgeIds: Hex[] | null) => void; + + // Used to store the array of scores that the user has given to each active badge + badgeScores: number[]; + setBadgeScores: (badgeScores: number[]) => void; +} + +export const useReviewStore = create((set: any, get: any) => ({ + isOpenReview: ReviewMode.READ, + setIsOpenReview: (isOpenReview: ReviewMode) => set((state: any) => ({ ...state, isOpenReview })), + isStarSelected: null, + setIsStarSelected: (isStarSelected: number | null) => + set((state: any) => ({ ...state, isStarSelected })), + stories: null, + setStories: (stories: GrantStory[] | null) => set((state: any) => ({ ...state, stories })), + badges: null, + setBadges: (badges: Badge[] | null) => set((state: any) => ({ ...state, badges })), + grantUID: null, + setGrantUID: (grantUID: string | null) => set((state: any) => ({ ...state, grantUID })), + activeBadges: null, + setActiveBadges: (activeBadges: Badge[] | null) => + set((state: any) => ({ ...state, activeBadges })), + activeBadgeIds: null, + setActiveBadgeIds: (activeBadgeIds: Badge[] | null) => + set((state: any) => ({ ...state, activeBadgeIds })), + badgeScores: [], + setBadgeScores: (badgeScores: number[]) => set((state: any) => ({ ...state, badgeScores })), +})); diff --git a/styles/globals.css b/styles/globals.css index d3b776e01..e33bd359c 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -149,3 +149,7 @@ label { @apply list-disc list-inside; } } + + .scroller { + scrollbar-width: thin; + } \ No newline at end of file diff --git a/types/grant.ts b/types/grant.ts index 1423584c9..04891230f 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -6,4 +6,5 @@ export type GrantScreen = | "grant-update" | "impact-criteria" | "overview" - | "complete-grant"; + | "complete-grant" + | "review"; diff --git a/types/review.ts b/types/review.ts new file mode 100644 index 000000000..54cbcc164 --- /dev/null +++ b/types/review.ts @@ -0,0 +1,21 @@ +import { Hex } from "viem"; + +export interface Badge { + name: string; + description: string; + metadata: string; // Image IPFS + data: string; +} + +export interface GrantStory { + timestamp: number; + txUID: string; + badgeIds: Hex[]; + badgeScores: number[]; + averageScore: number; +} + +export enum ReviewMode { + READ = "READ", + WRITE = "WRITE", +} diff --git a/utilities/review/attest.ts b/utilities/review/attest.ts new file mode 100644 index 000000000..c98b5e912 --- /dev/null +++ b/utilities/review/attest.ts @@ -0,0 +1,125 @@ +import { getWalletClient } from "@wagmi/core"; +import { config } from "@/utilities/wagmi/config"; +import { createPublicClient, encodeFunctionData, Hex, http, type TransactionReceipt } from "viem"; +import { sendTransaction, estimateGas, waitForTransactionReceipt } from "viem/actions"; +import { arbitrum } from "viem/chains"; +import { ARB_ONE_EAS } from "./constants/constants"; + +export interface AttestationRequestData { + recipient: Hex; + expirationTime: bigint; + revocable: boolean; + refUID: Hex; + data: Hex; + value: bigint; +} + +export interface AttestationRequest { + schema: Hex; + data: AttestationRequestData; +} + +const publicClient = createPublicClient({ + chain: arbitrum, + transport: http(), +}); + +export async function submitAttest( + from: Hex, + schemaUID: Hex, + recipient: Hex, + expirationTime: bigint, + revocable: boolean, + refUID: Hex, + data: Hex, +): Promise { + const walletClient = await getWalletClient(config); + let gasLimit; + + const attestationRequestData: AttestationRequestData = { + recipient: recipient, + expirationTime: expirationTime, + revocable: revocable, + refUID: refUID, + data: data, + value: BigInt(0), + }; + + const AttestationRequest: AttestationRequest = { + schema: schemaUID, + data: attestationRequestData, + }; + + const encodedData = encodeFunctionData({ + abi: [ + { + inputs: [ + { + components: [ + { internalType: "bytes32", name: "schema", type: "bytes32" }, + { + components: [ + { + internalType: "address", + name: "recipient", + type: "address", + }, + { + internalType: "uint64", + name: "expirationTime", + type: "uint64", + }, + { internalType: "bool", name: "revocable", type: "bool" }, + { internalType: "bytes32", name: "refUID", type: "bytes32" }, + { internalType: "bytes", name: "data", type: "bytes" }, + { internalType: "uint256", name: "value", type: "uint256" }, + ], + internalType: "struct AttestationRequestData", + name: "data", + type: "tuple", + }, + ], + internalType: "struct AttestationRequest", + name: "request", + type: "tuple", + }, + ], + name: "attest", + outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }], + stateMutability: "payable", + type: "function", + }, + ], + + args: [AttestationRequest], + }); + try { + gasLimit = await estimateGas(publicClient, { + account: from as Hex, + to: ARB_ONE_EAS as Hex, + data: encodedData, + value: BigInt(0), + }); + } catch (error) { + return Error("Error estimating gas."); + } + + try { + const transactionHash = await sendTransaction(walletClient, { + account: from as Hex, + to: ARB_ONE_EAS as Hex, + gasLimit: gasLimit, + data: encodedData, + value: BigInt(0), + chain: walletClient.chain, + }); + + const transactionReceipt: TransactionReceipt = await waitForTransactionReceipt(publicClient, { + hash: transactionHash, + }); + + return transactionReceipt; + } catch (error) { + return Error(`Error sending transaction. ${error}`); + } +} diff --git a/utilities/review/constants/constants.ts b/utilities/review/constants/constants.ts new file mode 100644 index 000000000..bf4a2c0ed --- /dev/null +++ b/utilities/review/constants/constants.ts @@ -0,0 +1,18 @@ +// EAS contracts +export const ARB_ONE_EAS = "0xbD75f629A22Dc1ceD33dDA0b68c546A1c035c458"; +export const ARB_ONE_SCHEMA_REGISTRY = "0xA310da9c5B885E7fb3fbA9D66E9Ba6Df512b78eB"; + +// The schema UID for EAS +export const KARMA_EAS_SCHEMA_UID = + "0x437af01ab9afa40d82cf8515a45af25883405e0ff97c01b361f3b7eeb7de45a5"; +// The Karma-Gap scorer ID constant +export const SCORER_ID = 1; +// The Karma-Gap scorer decimals amount +export const SCORER_DECIMALS = 18; + +// The Trustful-Karma contracts +export const GRANT_REGISTRY = "0x09Bd0bcc469BfC6E07F2ab047d71014bA5c9dcaf"; +export const BADGE_REGISTRY = "0x00E1d342b1F630ab4EB59a4358C70f46b480cF52"; +export const TRUSTFUL_SCORER = "0xbffd6F18e78d31A6259B5dDA0147D4c35426c1d6"; +export const RESOLVER_EAS = "0xBB9A942Df948B53aC528993C6d67FB8fEe44d3c4"; +export const RESOLVER_TRUSTFUL = "0xb3412009e8739624B3e0CAB56bfbb4307ecd8A8F"; diff --git a/utilities/review/constants/utilitary.ts b/utilities/review/constants/utilitary.ts new file mode 100644 index 000000000..f09395bcd --- /dev/null +++ b/utilities/review/constants/utilitary.ts @@ -0,0 +1,7 @@ +export const addPrefixToIPFSLink = (link: string) => { + if (link.startsWith("ipfs://")) { + return link.replace("ipfs://", "https://ipfs.io/ipfs/"); + } else { + return link; + } +}; diff --git a/utilities/review/getBadge.ts b/utilities/review/getBadge.ts new file mode 100644 index 000000000..c3b0e123f --- /dev/null +++ b/utilities/review/getBadge.ts @@ -0,0 +1,52 @@ +import { createPublicClient, Hex, http } from "viem"; +import { readContract } from "viem/actions"; +import { arbitrum } from "viem/chains"; + +import { Badge } from "@/types/review"; + +import { BADGE_REGISTRY } from "./constants/constants"; + +const publicClient = createPublicClient({ + chain: arbitrum, + transport: http(), +}); + +/// See {BadgeRegistry-getBadge} in the contract. +/// Retrieves a badge by its ID. +export async function getBadge(badgeId: Hex): Promise { + const abi = [ + { + inputs: [{ internalType: "bytes32", name: "badgeId", type: "bytes32" }], + name: "getBadge", + outputs: [ + { + components: [ + { internalType: "string", name: "name", type: "string" }, + { internalType: "string", name: "description", type: "string" }, + { internalType: "string", name: "metadata", type: "string" }, + { internalType: "bytes", name: "data", type: "bytes" }, + ], + internalType: "struct IBadgeRegistry.Badge", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + ]; + + try { + const badgeData = await readContract(publicClient, { + address: BADGE_REGISTRY, + functionName: "getBadge", + abi: abi, + args: [badgeId], + }); + + return badgeData as Badge; + } catch (error) { + console.log(`Error when reading the contract. Probably CORS. ${error}`); + } + return null; +} diff --git a/utilities/review/getBadgeIds.ts b/utilities/review/getBadgeIds.ts new file mode 100644 index 000000000..2bbd26f80 --- /dev/null +++ b/utilities/review/getBadgeIds.ts @@ -0,0 +1,38 @@ +import { createPublicClient, http, Hex } from "viem"; +import { readContract } from "viem/actions"; +import { arbitrum } from "viem/chains"; + +import { TRUSTFUL_SCORER } from "./constants/constants"; + +const publicClient = createPublicClient({ + chain: arbitrum, + transport: http(), +}); + +/// See {TrustfulScorer-getBadgeIds} in the contract. +/// Returns the badge IDs contained in a scorer. +export async function getBadgeIds(scorerId: number): Promise { + const abi = [ + { + inputs: [{ internalType: "uint256", name: "scorerId", type: "uint256" }], + name: "getBadgeIds", + outputs: [{ internalType: "bytes32[]", name: "", type: "bytes32[]" }], + stateMutability: "view", + type: "function", + }, + ]; + + try { + const badgeIds = await readContract(publicClient, { + address: TRUSTFUL_SCORER, + functionName: "getBadgeIds", + abi: abi, + args: [scorerId], + }); + + return badgeIds as Hex[]; + } catch (error) { + console.log(`Error when reading the contract. ${error}`); + } + return null; +} diff --git a/utilities/review/getGrantProgramAverageScore.ts b/utilities/review/getGrantProgramAverageScore.ts new file mode 100644 index 000000000..3ac9c142a --- /dev/null +++ b/utilities/review/getGrantProgramAverageScore.ts @@ -0,0 +1,58 @@ +import { readContract } from "viem/actions"; +import { createPublicClient, http } from "viem"; +import { arbitrum } from "viem/chains"; +import { RESOLVER_TRUSTFUL, SCORER_DECIMALS } from "./constants/constants"; + +const publicClient = createPublicClient({ + chain: arbitrum, + transport: http(), +}); + +/// See {TrustfulResolver-getGrantProgramAverageScore} in the contract. +/// Gets the average score of a grant program. +/// +/// Requirement: +/// - The grant program must exist +/// - The grant program must have at least one review. +/// +/// NOTE: The result will be multiplied by the decimals in the Scorer. +/// Solidity can't handle floating points, so you can get the decimals by +/// calling {ITrustfulScorer.getScorerDecimals} and dividing the result. +export async function getGrantProgramAverageScore(grantUID: number): Promise { + const abi = [ + { + inputs: [{ internalType: "uint256", name: "grantProgramUID", type: "uint256" }], + name: "getGrantProgramAverageScore", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + ]; + + try { + const grantProgramScore = await readContract(publicClient, { + address: RESOLVER_TRUSTFUL, + functionName: "getGrantProgramAverageScore", + abi: abi, + args: [grantUID], + }); + + try { + const grantStoryAsANumber = Number(grantProgramScore); + + if (Number.isNaN(grantStoryAsANumber)) { + console.log("The result can't be NaN: getGrantProgramAverageScore"); + } + } catch (error) { + console.log("Error when converting the result to a number: getGrantProgramAverageScore"); + } + + // format big number to floated point number + const floatedNumber = (Number(grantProgramScore) / 10 ** SCORER_DECIMALS).toFixed(1); + + return Number(floatedNumber); + } catch (error) { + console.log("Error when reading the contract"); + } + return null; +} diff --git a/utilities/review/getGrantStories.ts b/utilities/review/getGrantStories.ts new file mode 100644 index 000000000..908430807 --- /dev/null +++ b/utilities/review/getGrantStories.ts @@ -0,0 +1,51 @@ +import { readContract } from "viem/actions"; +import { createPublicClient, http } from "viem"; +import { arbitrum } from "viem/chains"; +import { RESOLVER_TRUSTFUL } from "./constants/constants"; +import { GrantStory } from "@/types/review"; + +const publicClient = createPublicClient({ + chain: arbitrum, + transport: http(), +}); + +/// See {TrustfulResolver-getGrantStories} in the contract. +/// Get the stories of the grant in the timeline. +export async function getGrantStories(grantUID: string): Promise { + const abi = [ + { + inputs: [{ internalType: "bytes32", name: "grantUID", type: "bytes32" }], + name: "getGrantStories", + outputs: [ + { + components: [ + { internalType: "uint256", name: "timestamp", type: "uint256" }, + { internalType: "bytes32", name: "txUID", type: "bytes32" }, + { internalType: "bytes32[]", name: "badgeIds", type: "bytes32[]" }, + { internalType: "uint8[]", name: "badgeScores", type: "uint8[]" }, + { internalType: "uint256", name: "averageScore", type: "uint256" }, + ], + internalType: "struct IResolver.GrantStory[]", + name: "", + type: "tuple[]", + }, + ], + stateMutability: "view", + type: "function", + }, + ]; + + try { + const grantStory = await readContract(publicClient, { + address: RESOLVER_TRUSTFUL, + functionName: "getGrantStories", + abi: abi, + args: [grantUID], + }); + + return grantStory as GrantStory[]; + } catch (error) { + console.log("Error when reading the contract"); + } + return null; +}