diff --git a/packages/nextjs/app/admin/_components/GrantReview.tsx b/packages/nextjs/app/admin/_components/GrantReview.tsx index da31696..b46ddbf 100644 --- a/packages/nextjs/app/admin/_components/GrantReview.tsx +++ b/packages/nextjs/app/admin/_components/GrantReview.tsx @@ -101,6 +101,11 @@ export const GrantReview = ({ grant }: { grant: GrantDataWithBuilder }) => {

{grant.title} ({grant.id}) + {grant.link && ( + + View Build + + )}

diff --git a/packages/nextjs/app/api/grants/submit/route.ts b/packages/nextjs/app/api/grants/submit/route.ts new file mode 100644 index 0000000..fff3fce --- /dev/null +++ b/packages/nextjs/app/api/grants/submit/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { recoverTypedDataAddress } from "viem"; +import { getGrantById, submitGrantBuild } from "~~/services/database/grants"; +import { EIP_712_DOMAIN, EIP_712_TYPES__SUBMIT_GRANT } from "~~/utils/eip712"; +import { PROPOSAL_STATUS } from "~~/utils/grants"; + +type ReqBody = { + grantId?: string; + signer?: string; + link?: string; + signature?: `0x${string}`; +}; + +export async function POST(req: Request) { + try { + const { grantId, link, signer, signature } = (await req.json()) as ReqBody; + if (!grantId || !signer || !link || !signature) { + return NextResponse.json({ error: "Invalid form details submitted" }, { status: 400 }); + } + + // Validate Signature + const recoveredAddress = await recoverTypedDataAddress({ + domain: EIP_712_DOMAIN, + types: EIP_712_TYPES__SUBMIT_GRANT, + primaryType: "Message", + message: { + grantId: grantId, + action: "submit", + link, + }, + signature, + }); + if (recoveredAddress !== signer) { + console.error("Signature error", recoveredAddress, signer); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Validate if the grant is approved and owned by the builder + const grant = await getGrantById(grantId); + if (!grant) { + return NextResponse.json({ error: "Invalid grant" }, { status: 400 }); + } + if (grant.builder !== signer) { + return NextResponse.json({ error: "Builder does not own this grant" }, { status: 401 }); + } + if (grant.status !== PROPOSAL_STATUS.APPROVED) { + return NextResponse.json({ error: "Grant is not approved" }, { status: 400 }); + } + + await submitGrantBuild(grantId, link); + + return NextResponse.json({}, { status: 201 }); + } catch (e) { + console.error(e); + return NextResponse.json({ error: "Error processing form" }, { status: 500 }); + } +} diff --git a/packages/nextjs/app/my-grants/_components/SubmitModal.tsx b/packages/nextjs/app/my-grants/_components/SubmitModal.tsx new file mode 100644 index 0000000..1c1f522 --- /dev/null +++ b/packages/nextjs/app/my-grants/_components/SubmitModal.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { mutate } from "swr"; +import useSWRMutation from "swr/mutation"; +import { useAccount, useSignTypedData } from "wagmi"; +import { InformationCircleIcon } from "@heroicons/react/24/outline"; +import { GrantData } from "~~/services/database/schema"; +import { EIP_712_DOMAIN, EIP_712_TYPES__SUBMIT_GRANT } from "~~/utils/eip712"; +import { notification } from "~~/utils/scaffold-eth"; +import { postMutationFetcher } from "~~/utils/swr"; + +type ReqBody = { + grantId?: string; + status?: string; + signer?: string; + link?: string; + signature?: `0x${string}`; +}; + +export const SubmitModal = ({ grant, closeModal }: { grant: GrantData; closeModal: () => void }) => { + const { address: connectedAddress } = useAccount(); + const [buildUrl, setBuildUrl] = useState(""); + const { trigger: submitBuildLink, isMutating: isSubmittingLink } = useSWRMutation( + "/api/grants/submit", + postMutationFetcher, + ); + + const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData(); + + const handleSubmit = async () => { + const urlPattern = new RegExp("^(https://app\\.buidlguidl\\.com/build/)[a-z0-9-]+$"); + + if (!urlPattern.test(buildUrl.toLowerCase())) + return notification.error("You must submit a valid build URL (https://app.buidlguidl.com/build/...)"); + + let signature; + try { + signature = await signTypedDataAsync({ + domain: EIP_712_DOMAIN, + types: EIP_712_TYPES__SUBMIT_GRANT, + primaryType: "Message", + message: { + grantId: grant.id, + action: "submit", + link: buildUrl, + }, + }); + } catch (error) { + console.error("Error signing message", error); + notification.error("Error signing message"); + return; + } + + let notificationId; + try { + notificationId = notification.loading("Submitting build URL"); + await submitBuildLink({ grantId: grant.id, link: buildUrl, signer: connectedAddress, signature }); + await mutate(`/api/builders/${connectedAddress}/grants`); + closeModal(); + notification.remove(notificationId); + notification.success("Build URL submitted successfully"); + } catch (error) { + notification.error("Error submitting build URL"); + } finally { + if (notificationId) notification.remove(notificationId); + } + }; + + return ( +
+
e.stopPropagation()}> + +

{grant.title}

+
+ + + First you'll need to register the build in your  + + BuidlGuidl profile + +  and then submit the URL of your BG build in this form. BG Grants team will review it to complete the + grant. + +
+ + setBuildUrl(e.target.value)} + placeholder="https://app.buidlguidl.com/build/..." + className="placeholder: pl-[14px] mt-4 w-full p-1 rounded-lg" + /> + +
+
+ ); +}; diff --git a/packages/nextjs/app/my-grants/page.tsx b/packages/nextjs/app/my-grants/page.tsx index ddc3e8c..5d65176 100644 --- a/packages/nextjs/app/my-grants/page.tsx +++ b/packages/nextjs/app/my-grants/page.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; +import { SubmitModal } from "./_components/SubmitModal"; import { NextPage } from "next"; import useSWR from "swr"; import { useAccount } from "wagmi"; @@ -14,11 +16,18 @@ const badgeBgColor = { [PROPOSAL_STATUS.REJECTED]: "bg-error", }; -// ToDo. Action (v1.0 = submit grant after completion) const MyGrants: NextPage = () => { const { address } = useAccount(); const { data: builderGrants, isLoading } = useSWR(address ? `/api/builders/${address}/grants` : null); + const [modalIsOpen, setModalIsOpen] = useState(false); + const [currentGrant, setCurrentGrant] = useState(null); + + const openModal = (grant: GrantData) => { + setCurrentGrant(grant); + setModalIsOpen(true); + }; + return (

My grants

@@ -32,8 +41,17 @@ const MyGrants: NextPage = () => {

{grant.description}

{grant.status}

+ {grant.status === PROPOSAL_STATUS.APPROVED && ( + + )}
))} + + {modalIsOpen && currentGrant !== null && ( + setModalIsOpen(false)} /> + )}
); }; diff --git a/packages/nextjs/services/database/grants.ts b/packages/nextjs/services/database/grants.ts index 7950453..73dacfa 100644 --- a/packages/nextjs/services/database/grants.ts +++ b/packages/nextjs/services/database/grants.ts @@ -5,8 +5,8 @@ import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants"; const firestoreDB = getFirestoreConnector(); const grantsCollection = firestoreDB.collection("grants"); -// const getGrantsDoc = (id: string) => grantsCollection.doc(id); -// const getGrantSnapshotById = (id: string) => getGrantsDoc(id).get(); +const getGrantsDoc = (id: string) => grantsCollection.doc(id); +const getGrantSnapshotById = (id: string) => getGrantsDoc(id).get(); export const createGrant = async (grantData: Omit) => { try { @@ -148,3 +148,29 @@ export const getGrantsStats = async () => { throw error; } }; + +export const getGrantById = async (grantId: string) => { + try { + const grantSnapshot = await getGrantSnapshotById(grantId); + if (!grantSnapshot.exists) { + return null; + } + return { id: grantSnapshot.id, ...grantSnapshot.data() } as GrantData; + } catch (error) { + console.error("Error getting grant by id:", error); + throw error; + } +}; + +export const submitGrantBuild = async (grantId: string, link: string) => { + const status = PROPOSAL_STATUS.SUBMITTED; + const grantActionTimeStamp = new Date().getTime(); + const grantActionTimeStampKey = (status + "At") as `${typeof status}At`; + + try { + await getGrantsDoc(grantId).update({ status, link, [grantActionTimeStampKey]: grantActionTimeStamp }); + } catch (error) { + console.error("Error updating the grant status:", error); + throw error; + } +}; diff --git a/packages/nextjs/utils/eip712.ts b/packages/nextjs/utils/eip712.ts index fdc67ab..319ecee 100644 --- a/packages/nextjs/utils/eip712.ts +++ b/packages/nextjs/utils/eip712.ts @@ -19,3 +19,11 @@ export const EIP_712_TYPES__REVIEW_GRANT = { { name: "action", type: "string" }, ], } as const; + +export const EIP_712_TYPES__SUBMIT_GRANT = { + Message: [ + { name: "grantId", type: "string" }, + { name: "action", type: "string" }, + { name: "link", type: "string" }, + ], +} as const;