Skip to content

Commit

Permalink
Submit grants page (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
technophile-04 authored Feb 6, 2024
1 parent 8d19074 commit 2d0bea5
Show file tree
Hide file tree
Showing 12 changed files with 356 additions and 1 deletion.
17 changes: 17 additions & 0 deletions packages/nextjs/app/api/builders/[builderAddress]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
},
);
}
}
51 changes: 51 additions & 0 deletions packages/nextjs/app/submit-grant/_actions/index.ts
Original file line number Diff line number Diff line change
@@ -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");
}
};
88 changes: 88 additions & 0 deletions packages/nextjs/app/submit-grant/_component/Form.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="card card-compact w-96 bg-base-100 shadow-xl">
<form action={clientFormAction} className="card-body space-y-4">
<h2 className="card-title self-center text-3xl !mb-0">Submit Proposal</h2>
<div className="space-y-2">
<p className="m-0 text-xl ml-2">Title</p>
<div className="flex border-2 border-base-300 bg-base-200 rounded-full text-accent">
<input
className="input input-ghost focus-within:border-transparent focus:outline-none focus:bg-transparent focus:text-gray-400 h-[2.2rem] min-h-[2.2rem] px-4 border w-full font-medium placeholder:text-accent/50 text-gray-400"
placeholder="title"
name="title"
autoComplete="off"
/>
</div>
</div>
<div className="space-y-2">
<p className="m-0 text-xl ml-2">Description</p>
<div className="flex border-2 border-base-300 bg-base-200 rounded-3xl text-accent">
<textarea
className="input input-ghost focus-within:border-transparent focus:outline-none focus:bg-transparent focus:text-gray-400 px-4 pt-2 border w-full font-medium placeholder:text-accent/50 text-gray-400 h-28 rounded-none"
placeholder="description"
name="description"
autoComplete="off"
/>
</div>
</div>
<div className="space-y-2">
<p className="m-0 text-xl ml-2">Ask amount</p>
<select className="select bg-base-200 select-primary select-md select-bordered w-full" name="askAmount">
<option disabled>Select amount</option>
{selectOptions.map(option => (
<option key={option} value={option}>
{option} ETH
</option>
))}
</select>
</div>
<SubmitButton />
</form>
</div>
);
};

export default Form;
33 changes: 33 additions & 0 deletions packages/nextjs/app/submit-grant/_component/SubmitButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`flex ${(!isConnected || !isBuilderPresent) && "tooltip tooltip-bottom"}`}
data-tip={`${
!isConnected
? "Please connect your wallet"
: !isBuilderPresent
? "You should be a buidlguidl builder to submit grant"
: ""
}`}
>
<button className="btn btn-primary w-full" disabled={isSubmitDisabled} aria-disabled={isSubmitDisabled}>
{(isFetchingBuilderData || pending) && <span className="loading loading-spinner loading-md"></span>}
Submit
</button>
</div>
);
};

export default SubmitButton;
12 changes: 12 additions & 0 deletions packages/nextjs/app/submit-grant/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Form from "./_component/Form";
import { NextPage } from "next";

const SubmitGrant: NextPage = () => {
return (
<div className="flex items-center flex-col flex-grow pt-10">
<Form />
</div>
);
};

export default SubmitGrant;
7 changes: 6 additions & 1 deletion packages/nextjs/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -19,6 +19,11 @@ export const menuLinks: HeaderMenuLink[] = [
label: "Home",
href: "/",
},
{
label: "Submit Grant",
href: "/submit-grant",
icon: <DocumentIcon className="h-4 w-4" />,
},
{
label: "Debug Contracts",
href: "/debug",
Expand Down
46 changes: 46 additions & 0 deletions packages/nextjs/hooks/useBGBuilderData.ts
Original file line number Diff line number Diff line change
@@ -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<BuilderData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(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 };
};
1 change: 1 addition & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions packages/nextjs/services/database/grants.ts
Original file line number Diff line number Diff line change
@@ -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<GrantData, "id" | "timestamp" | "status">) => {
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;
}
};
49 changes: 49 additions & 0 deletions packages/nextjs/services/database/schema.ts
Original file line number Diff line number Diff line change
@@ -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";
};
21 changes: 21 additions & 0 deletions packages/nextjs/services/database/users.ts
Original file line number Diff line number Diff line change
@@ -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<BuilderDataResponse> => {
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;
}
};
Loading

0 comments on commit 2d0bea5

Please sign in to comment.