Skip to content

Commit

Permalink
Submission form (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
damianmarti authored Aug 2, 2024
1 parent 3fe95d4 commit d8e63b2
Show file tree
Hide file tree
Showing 13 changed files with 296 additions and 3 deletions.
56 changes: 55 additions & 1 deletion packages/nextjs/app/api/submissions/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { getAllSubmissions } from "~~/services/database/repositories/submissions";
import { recoverTypedDataAddress } from "viem";
import { createBuilder, getBuilderById } from "~~/services/database/repositories/builders";
import { createSubmission, getAllSubmissions } from "~~/services/database/repositories/submissions";
import { SubmissionInsert } from "~~/services/database/repositories/submissions";
import { EIP_712_DOMAIN, EIP_712_TYPES__SUBMISSION } from "~~/utils/eip712";

export async function GET() {
try {
Expand All @@ -10,3 +14,53 @@ export async function GET() {
return NextResponse.json({ error: "Error fetching submissions" }, { status: 500 });
}
}

export type CreateNewSubmissionBody = SubmissionInsert & { signature: `0x${string}`; signer: string };

export async function POST(req: Request) {
try {
const { title, description, linkToRepository, signature, signer } = (await req.json()) as CreateNewSubmissionBody;

if (
!title ||
!description ||
!linkToRepository ||
!signature ||
!signer ||
description.length > 750 ||
title.length > 75
) {
return NextResponse.json({ error: "Invalid form details submitted" }, { status: 400 });
}

const recoveredAddress = await recoverTypedDataAddress({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__SUBMISSION,
primaryType: "Message",
message: { title, description, linkToRepository },
signature: signature,
});

if (recoveredAddress !== signer) {
return NextResponse.json({ error: "Recovered address did not match signer" }, { status: 401 });
}

const builder = await getBuilderById(signer);

if (!builder) {
await createBuilder({ id: signer, role: "user" });
}

const submission = await createSubmission({
title: title,
description: description,
linkToRepository: linkToRepository,
builder: signer,
});

return NextResponse.json({ submission }, { status: 201 });
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Error processing form" }, { status: 500 });
}
}
116 changes: 116 additions & 0 deletions packages/nextjs/app/submit/_component/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"use client";

import React, { useState } from "react";
import { useRouter } from "next/navigation";
import SubmitButton from "./SubmitButton";
import { useMutation } from "@tanstack/react-query";
import { useAccount, useSignTypedData } from "wagmi";
import { CreateNewSubmissionBody } from "~~/app/api/submissions/route";
import { EIP_712_DOMAIN, EIP_712_TYPES__SUBMISSION } from "~~/utils/eip712";
import { postMutationFetcher } from "~~/utils/react-query";
import { notification } from "~~/utils/scaffold-eth";

const MAX_DESCRIPTION_LENGTH = 750;

const Form = () => {
const { address: connectedAddress } = useAccount();
const [descriptionLength, setDescriptionLength] = useState(0);
const { signTypedDataAsync } = useSignTypedData();
const router = useRouter();
const { mutateAsync: postNewSubmission } = useMutation({
mutationFn: (newSubmission: CreateNewSubmissionBody) =>
postMutationFetcher("/api/submissions", { body: newSubmission }),
});

const clientFormAction = async (formData: FormData) => {
if (!connectedAddress) {
notification.error("Please connect your wallet");
return;
}

try {
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const linkToRepository = formData.get("linkToRepository") as string;
if (!title || !description || !linkToRepository) {
notification.error("Please fill all the fields");
return;
}

const signature = await signTypedDataAsync({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__SUBMISSION,
primaryType: "Message",
message: {
title: title,
description: description,
linkToRepository: linkToRepository,
},
});

await postNewSubmission({ title, description, linkToRepository, signature, signer: connectedAddress });

notification.success("Extension 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 rounded-xl max-w-[95%] w-[500px] bg-secondary shadow-lg mb-12">
<form action={clientFormAction} className="card-body space-y-3">
<h2 className="card-title self-center text-3xl !mb-0">Submit Extension</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-xl 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="Extension title"
name="title"
autoComplete="off"
type="text"
maxLength={75}
/>
</div>
</div>
<div className="space-y-2">
<p className="m-0 text-xl ml-2">Description</p>
<div className="flex flex-col border-2 border-base-300 bg-base-200 rounded-xl 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 md:h-52 rounded-none"
placeholder="Extension description"
name="description"
autoComplete="off"
maxLength={MAX_DESCRIPTION_LENGTH}
onChange={e => setDescriptionLength(e.target.value.length)}
/>
<p className="my-1">
{descriptionLength} / {MAX_DESCRIPTION_LENGTH}
</p>
</div>
</div>
<div className="space-y-2">
<p className="m-0 text-xl ml-2">Repository URL</p>
<div className="flex border-2 border-base-300 bg-base-200 rounded-xl 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="https://"
name="linkToRepository"
autoComplete="off"
type="text"
maxLength={75}
/>
</div>
</div>
<SubmitButton />
</form>
</div>
);
};

export default Form;
28 changes: 28 additions & 0 deletions packages/nextjs/app/submit/_component/SubmitButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { useFormStatus } from "react-dom";
import { useAccount } from "wagmi";
import { RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";

// To use useFormStatus we need to make sure button is child of form
const SubmitButton = () => {
const { pending } = useFormStatus();
const { isConnected } = useAccount();

return (
<div
className={`flex ${!isConnected && "tooltip tooltip-bottom"}`}
data-tip={`${!isConnected ? "Please connect your wallet" : ""}`}
>
{isConnected ? (
<button className="btn btn-primary w-full" disabled={pending} aria-disabled={pending}>
Submit
</button>
) : (
<RainbowKitCustomConnectButton fullWidth={true} />
)}
</div>
);
};

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

const Submit: NextPage = () => {
return (
<div className="flex bg-base-100 items-center flex-col flex-grow text-center pt-10 md:pt-4 px-6">
<h1 className="text-3xl sm:text-4xl font-bold mb-4">Submit Extension</h1>
<p className="text-md mb-0 max-w-xl">Submit your SE-2 extension.</p>
<Form />
</div>
);
};

export default Submit;
4 changes: 4 additions & 0 deletions packages/nextjs/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export const menuLinks: HeaderMenuLink[] = [
label: "Home",
href: "/",
},
{
label: "Submit",
href: "/submit",
},
{
label: "Debug Contracts",
href: "/debug",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
/**
* Custom Wagmi Connect Button (watch balance + custom design)
*/
export const RainbowKitCustomConnectButton = () => {
export const RainbowKitCustomConnectButton = ({ fullWidth }: { fullWidth?: boolean }) => {
const networkColor = useNetworkColor();
const { targetNetwork } = useTargetNetwork();

Expand All @@ -31,7 +31,11 @@ export const RainbowKitCustomConnectButton = () => {
{(() => {
if (!connected) {
return (
<button className="btn btn-primary btn-sm" onClick={openConnectModal} type="button">
<button
className={`btn btn-primary btn-sm${fullWidth ? " w-full" : ""}`}
onClick={openConnectModal}
type="button"
>
Connect Wallet
</button>
);
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@types/pg": "^8",
"@types/react": "^18.0.21",
"@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "~5.40.0",
"abitype": "1.0.5",
"autoprefixer": "~10.4.12",
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/services/database/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const submissions = pgTable("submissions", {
id: serial("id").primaryKey(),
title: varchar("name", { length: 256 }),
description: text("description"),
linkToRepository: varchar("link_to_repository", { length: 256 }),
submissionTimestamp: timestamp("submission_timestamp").default(sql`now()`),
builder: varchar("builder_id", { length: 256 }).references(() => builders.id),
});
Expand Down
17 changes: 17 additions & 0 deletions packages/nextjs/services/database/repositories/builders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { InferInsertModel, eq } from "drizzle-orm";
import { db } from "~~/services/database/config/postgresClient";
import { builders } from "~~/services/database/config/schema";

export type BuilderInsert = InferInsertModel<typeof builders>;

export async function getAllBuilders() {
return await db.select().from(builders);
}

export async function getBuilderById(id: string) {
return await db.query.builders.findFirst({ where: eq(builders.id, id) });
}

export async function createBuilder(builder: BuilderInsert) {
return await db.insert(builders).values(builder);
}
2 changes: 2 additions & 0 deletions packages/nextjs/services/database/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ async function seed() {
{
title: "First submission",
description: "This is the first submission",
linkToRepository: "https://github.com/BuidlGuidl/grants.buidlguidl.com",
builder: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
},
{
title: "Second submission",
description: "This is the second submission",
linkToRepository: "https://github.com/BuidlGuidl/extensions-hackathon",
builder: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
},
])
Expand Down
12 changes: 12 additions & 0 deletions packages/nextjs/utils/eip712.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const EIP_712_DOMAIN = {
name: "Scaffold-ETH 2 Extensions Hackathon",
version: "1",
} as const;

export const EIP_712_TYPES__SUBMISSION = {
Message: [
{ name: "title", type: "string" },
{ name: "description", type: "string" },
{ name: "linkToRepository", type: "string" },
],
} as const;
30 changes: 30 additions & 0 deletions packages/nextjs/utils/react-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export const fetcher = async <T = Record<any, any>>(...args: Parameters<typeof fetch>) => {
const res = await fetch(...args);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Error fetching data");
}
return data as T;
};

export const makeMutationFetcher =
<T = Record<any, any>>(method: "POST" | "PUT" | "PATCH" | "DELETE") =>
async (url: string, { body }: { body: T }) => {
const res = await fetch(url, {
method: method,
body: JSON.stringify(body),
});

const data = await res.json();

if (!res.ok) {
throw new Error(data.error || `Error ${method.toLowerCase()}ing data`);
}
return data;
};

export const postMutationFetcher = <T = Record<any, any>>(url: string, arg: { body: T }) =>
makeMutationFetcher<T>("POST")(url, arg);

export const patchMutationFetcher = <T = Record<any, any>>(url: string, arg: { body: T }) =>
makeMutationFetcher<T>("PATCH")(url, arg);
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2629,6 +2629,7 @@ __metadata:
"@types/pg": ^8
"@types/react": ^18.0.21
"@types/react-copy-to-clipboard": ^5.0.4
"@types/react-dom": ^18.2.18
"@typescript-eslint/eslint-plugin": ~5.40.0
"@uniswap/sdk-core": ~4.0.1
"@uniswap/v2-sdk": ~3.0.1
Expand Down Expand Up @@ -3336,6 +3337,15 @@ __metadata:
languageName: node
linkType: hard

"@types/react-dom@npm:^18.2.18":
version: 18.3.0
resolution: "@types/react-dom@npm:18.3.0"
dependencies:
"@types/react": "*"
checksum: a0cd9b1b815a6abd2a367a9eabdd8df8dd8f13f95897b2f9e1359ea3ac6619f957c1432ece004af7d95e2a7caddbba19faa045f831f32d6263483fc5404a7596
languageName: node
linkType: hard

"@types/react@npm:*, @types/react@npm:^18.0.21":
version: 18.3.3
resolution: "@types/react@npm:18.3.3"
Expand Down

0 comments on commit d8e63b2

Please sign in to comment.