diff --git a/packages/nextjs/app/api/builders/[builderAddress]/route.ts b/packages/nextjs/app/api/builders/[builderAddress]/route.ts
new file mode 100644
index 0000000..5116b56
--- /dev/null
+++ b/packages/nextjs/app/api/builders/[builderAddress]/route.ts
@@ -0,0 +1,17 @@
+import { NextResponse } from "next/server";
+import { findUserByAddress } from "~~/services/database/users";
+
+export async function GET(_request: Request, { params }: { params: { builderAddress: string } }) {
+ try {
+ const builderAddress = params.builderAddress;
+ const builderData = await findUserByAddress(builderAddress);
+ return NextResponse.json(builderData);
+ } catch (error) {
+ return NextResponse.json(
+ { message: "Internal Server Error" },
+ {
+ status: 500,
+ },
+ );
+ }
+}
diff --git a/packages/nextjs/app/submit-grant/_actions/index.ts b/packages/nextjs/app/submit-grant/_actions/index.ts
new file mode 100644
index 0000000..434020a
--- /dev/null
+++ b/packages/nextjs/app/submit-grant/_actions/index.ts
@@ -0,0 +1,51 @@
+"use server";
+
+import { verifyMessage } from "viem";
+import { createGrant } from "~~/services/database/grants";
+import { findUserByAddress } from "~~/services/database/users";
+
+type SignatureAndSigner = {
+ signature?: `0x${string}`;
+ address?: string;
+};
+
+export const submitGrantAction = async ({ signature, address }: SignatureAndSigner, form: FormData) => {
+ try {
+ const formData = Object.fromEntries(form.entries());
+ if (!formData.title || !formData.description || !formData.askAmount) {
+ throw new Error("Invalid form data");
+ }
+
+ if (!signature || !address) {
+ throw new Error("Signature and address are required to submit grant");
+ }
+
+ const constructedMessage = JSON.stringify(formData);
+ const isMessageValid = await verifyMessage({ message: constructedMessage, signature, address });
+ if (!isMessageValid) {
+ throw new Error("Invalid signature");
+ }
+
+ // Verif if the builder is present
+ const builder = await findUserByAddress(address);
+ if (!builder.exists) {
+ throw new Error("Only buidlguild builders can submit for grants");
+ }
+
+ // Save the form data to the database
+ const grant = await createGrant({
+ title: formData.title as string,
+ description: formData.description as string,
+ askAmount: Number(formData.askAmount),
+ builder: address,
+ });
+
+ return grant;
+ } catch (e) {
+ if (e instanceof Error) {
+ throw e;
+ }
+
+ throw new Error("Error processing form");
+ }
+};
diff --git a/packages/nextjs/app/submit-grant/_component/Form.tsx b/packages/nextjs/app/submit-grant/_component/Form.tsx
new file mode 100644
index 0000000..6ca879f
--- /dev/null
+++ b/packages/nextjs/app/submit-grant/_component/Form.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { submitGrantAction } from "../_actions";
+import SubmitButton from "./SubmitButton";
+import { useAccount, useSignMessage } from "wagmi";
+import { notification } from "~~/utils/scaffold-eth";
+
+const selectOptions = [0.1, 0.25, 0.5, 1];
+
+const Form = () => {
+ const { signMessageAsync } = useSignMessage();
+ const { address: connectedAddress } = useAccount();
+ const router = useRouter();
+
+ const clientFormAction = async (formData: FormData) => {
+ try {
+ const formState = Object.fromEntries(formData.entries());
+ if (formState.title === "" || formState.description === "") {
+ notification.error("Title and description are required");
+ return;
+ }
+
+ const signature = await signMessageAsync({ message: JSON.stringify(formState) });
+ const signedMessageObject = {
+ signature: signature,
+ address: connectedAddress,
+ };
+
+ // server action
+ const submitGrantActionWithSignedMessage = submitGrantAction.bind(null, signedMessageObject);
+ await submitGrantActionWithSignedMessage(formData);
+
+ notification.success("Proposal submitted successfully!");
+ router.push("/");
+ } catch (error: any) {
+ if (error instanceof Error) {
+ notification.error(error.message);
+ return;
+ }
+ notification.error("Something went wrong");
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default Form;
diff --git a/packages/nextjs/app/submit-grant/_component/SubmitButton.tsx b/packages/nextjs/app/submit-grant/_component/SubmitButton.tsx
new file mode 100644
index 0000000..4027881
--- /dev/null
+++ b/packages/nextjs/app/submit-grant/_component/SubmitButton.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { useFormStatus } from "react-dom";
+import { useAccount } from "wagmi";
+import { useBGBuilderData } from "~~/hooks/useBGBuilderData";
+
+// To use useFormStatus we need to make sure button is child of form
+const SubmitButton = () => {
+ const { pending } = useFormStatus();
+ const { isConnected, address: connectedAddress } = useAccount();
+ const { isBuilderPresent, isLoading: isFetchingBuilderData } = useBGBuilderData(connectedAddress);
+ const isSubmitDisabled = !isConnected || isFetchingBuilderData || pending || !isBuilderPresent;
+
+ return (
+
+
+
+ );
+};
+
+export default SubmitButton;
diff --git a/packages/nextjs/app/submit-grant/page.tsx b/packages/nextjs/app/submit-grant/page.tsx
new file mode 100644
index 0000000..f8dd36d
--- /dev/null
+++ b/packages/nextjs/app/submit-grant/page.tsx
@@ -0,0 +1,12 @@
+import Form from "./_component/Form";
+import { NextPage } from "next";
+
+const SubmitGrant: NextPage = () => {
+ return (
+
+
+
+ );
+};
+
+export default SubmitGrant;
diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx
index f24a1de..c20dc0a 100644
--- a/packages/nextjs/components/Header.tsx
+++ b/packages/nextjs/components/Header.tsx
@@ -4,7 +4,7 @@ import React, { useCallback, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { Bars3Icon, BugAntIcon } from "@heroicons/react/24/outline";
+import { Bars3Icon, BugAntIcon, DocumentIcon } from "@heroicons/react/24/outline";
import { FaucetButton, RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";
import { useOutsideClick } from "~~/hooks/scaffold-eth";
@@ -19,6 +19,11 @@ export const menuLinks: HeaderMenuLink[] = [
label: "Home",
href: "/",
},
+ {
+ label: "Submit Grant",
+ href: "/submit-grant",
+ icon: ,
+ },
{
label: "Debug Contracts",
href: "/debug",
diff --git a/packages/nextjs/hooks/useBGBuilderData.ts b/packages/nextjs/hooks/useBGBuilderData.ts
new file mode 100644
index 0000000..c3522d7
--- /dev/null
+++ b/packages/nextjs/hooks/useBGBuilderData.ts
@@ -0,0 +1,46 @@
+import { useEffect, useState } from "react";
+import { BuilderData, BuilderDataResponse } from "~~/services/database/schema";
+
+export const useBGBuilderData = (address?: string) => {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [isBuilderPresent, setIsBuilderPresent] = useState(false);
+
+ useEffect(() => {
+ if (!address) {
+ setData(null);
+ setError(null);
+ setIsBuilderPresent(false);
+ return;
+ }
+
+ const fetchBuilderData = async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetch(`/api/builders/${address}`);
+ if (!response.ok) {
+ throw new Error("An error occurred while fetching builder data");
+ }
+ const jsonData: BuilderDataResponse = await response.json();
+ if (!jsonData.exists || !jsonData.data) {
+ setData(null);
+ setIsBuilderPresent(false);
+ return;
+ }
+
+ setData(jsonData.data);
+ setIsBuilderPresent(true);
+ } catch (err: any) {
+ setError(err);
+ setIsBuilderPresent(false);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchBuilderData();
+ }, [address]);
+
+ return { isLoading, error, data, isBuilderPresent };
+};
diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json
index 1c85591..ca3af70 100644
--- a/packages/nextjs/package.json
+++ b/packages/nextjs/package.json
@@ -41,6 +41,7 @@
"@types/nprogress": "^0",
"@types/react": "^18.0.9",
"@types/react-copy-to-clipboard": "^5.0.4",
+ "@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^5.39.0",
"autoprefixer": "^10.4.12",
"eslint": "^8.15.0",
diff --git a/packages/nextjs/services/database/grants.ts b/packages/nextjs/services/database/grants.ts
new file mode 100644
index 0000000..a83e33a
--- /dev/null
+++ b/packages/nextjs/services/database/grants.ts
@@ -0,0 +1,22 @@
+import { getFirestoreConnector } from "./firestoreDB";
+import { GrantData } from "./schema";
+
+const firestoreDB = getFirestoreConnector();
+const grantsCollection = firestoreDB.collection("grants");
+// const getGrantsDoc = (id: string) => grantsCollection.doc(id);
+// const getGrantSnapshotById = (id: string) => getGrantsDoc(id).get();
+
+export const createGrant = async (grantData: Omit) => {
+ try {
+ const timestamp = new Date().getTime();
+ const status = "pending";
+
+ const grantRef = await grantsCollection.add({ ...grantData, timestamp, status });
+ const grantSnapshot = await grantRef.get();
+
+ return { id: grantSnapshot.id, ...grantSnapshot.data() } as GrantData;
+ } catch (error) {
+ console.error("Error creating the grant:", error);
+ throw error;
+ }
+};
diff --git a/packages/nextjs/services/database/schema.ts b/packages/nextjs/services/database/schema.ts
new file mode 100644
index 0000000..b867162
--- /dev/null
+++ b/packages/nextjs/services/database/schema.ts
@@ -0,0 +1,49 @@
+type SocialLinks = {
+ twitter?: string;
+ github?: string;
+ discord?: string;
+ telegram?: string;
+ instagram?: string;
+ email?: string;
+};
+
+type Build = {
+ submittedTimestamp: number;
+ id: string;
+};
+
+type Status = {
+ text: string;
+ timestamp: number;
+};
+
+type Graduated = {
+ reason: string;
+ status: boolean;
+};
+
+export type BuilderData = {
+ id: string;
+ socialLinks?: SocialLinks;
+ role?: string;
+ function?: string;
+ creationTimestamp?: number;
+ builds?: Build[];
+ status?: Status;
+ graduated?: Graduated;
+};
+
+export type BuilderDataResponse = {
+ exists: boolean;
+ data?: BuilderData;
+};
+
+export type GrantData = {
+ id: string;
+ title: string;
+ description: string;
+ askAmount: number;
+ builder: string;
+ timestamp: number;
+ status: "pending" | "approved" | "completed" | "rejected";
+};
diff --git a/packages/nextjs/services/database/users.ts b/packages/nextjs/services/database/users.ts
new file mode 100644
index 0000000..a30753f
--- /dev/null
+++ b/packages/nextjs/services/database/users.ts
@@ -0,0 +1,21 @@
+import { getFirestoreConnector } from "./firestoreDB";
+import { BuilderDataResponse } from "./schema";
+import { DocumentData } from "firebase-admin/firestore";
+
+const firestoreDB = getFirestoreConnector();
+const getUserDoc = (id: string) => firestoreDB.collection("users").doc(id);
+const getUserSnapshotById = (id: string) => getUserDoc(id).get();
+
+export const findUserByAddress = async (builderAddress: string): Promise => {
+ try {
+ const builderSnapshot = await getUserSnapshotById(builderAddress);
+ if (!builderSnapshot.exists) {
+ return { exists: false };
+ }
+ const data = builderSnapshot.data() as DocumentData;
+ return { exists: true, data: { id: builderSnapshot.id, ...data } };
+ } catch (error) {
+ console.error("Error finding user by address:", error);
+ throw error;
+ }
+};
diff --git a/yarn.lock b/yarn.lock
index c17c5a1..c465e75 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2133,6 +2133,7 @@ __metadata:
"@types/nprogress": ^0
"@types/react": ^18.0.9
"@types/react-copy-to-clipboard": ^5.0.4
+ "@types/react-dom": ^18.2.18
"@typescript-eslint/eslint-plugin": ^5.39.0
"@uniswap/sdk-core": ^4.0.1
"@uniswap/v2-sdk": ^3.0.1
@@ -3029,6 +3030,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/react-dom@npm:^18.2.18":
+ version: 18.2.18
+ resolution: "@types/react-dom@npm:18.2.18"
+ dependencies:
+ "@types/react": "*"
+ checksum: 8e3da404c980e2b2a76da3852f812ea6d8b9d0e7f5923fbaf3bfbbbfa1d59116ff91c129de8f68e9b7668a67ae34484fe9df74d5a7518cf8591ec07a0c4dad57
+ languageName: node
+ linkType: hard
+
"@types/react@npm:*, @types/react@npm:^18.0.9":
version: 18.2.23
resolution: "@types/react@npm:18.2.23"