Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Submission form #11

Merged
merged 7 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading