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

use swr for fetching and mutations #24

Merged
merged 10 commits into from
Feb 20, 2024
96 changes: 96 additions & 0 deletions packages/nextjs/app/admin/_components/GrantReview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
import { useAccount, useSignTypedData } from "wagmi";
import { GrantData } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";
import { notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

type ReqBody = {
signer: string;
signature: `0x${string}`;
action: ProposalStatusType;
};

export const GrantReview = ({ grant }: { grant: GrantData }) => {
const { address } = useAccount();
const { signTypedDataAsync, isLoading: isSigningMessage } = useSignTypedData();
const { trigger: postReviewGrant, isMutating: isPostingNewGrant } = useSWRMutation(
`/api/grants/${grant.id}/review`,
postMutationFetcher<ReqBody>,
);
const { mutate } = useSWRConfig();
const isLoading = isSigningMessage || isPostingNewGrant;

const handleReviewGrant = async (grant: GrantData, action: ProposalStatusType) => {
if (!address) {
notification.error("Please connect your wallet");
return;
}

let signature;
try {
signature = await signTypedDataAsync({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grant.id,
action: action,
},
});
} catch (e) {
console.error("Error signing message", e);
notification.error("Error signing message");
return;
}

let notificationId;
try {
notificationId = notification.loading("Submiting reivew");
await postReviewGrant({ signer: address, signature, action });
await mutate("/api/grants/review");
notification.remove(notificationId);
notification.success(`Grant reviewed: ${action}`);
} catch (error) {
notification.error("Error reviewing grant");
} finally {
if (notificationId) notification.remove(notificationId);
}
};

if (grant.status !== PROPOSAL_STATUS.PROPOSED && grant.status !== PROPOSAL_STATUS.SUBMITTED) return null;

const acceptStatus = grant.status === PROPOSAL_STATUS.PROPOSED ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.COMPLETED;
const acceptLabel = grant.status === PROPOSAL_STATUS.PROPOSED ? "Approve" : "Complete";
return (
<div className="border p-4 my-4">
<h3 className="font-bold">
{grant.title}
<span className="text-sm text-gray-500 ml-2">({grant.id})</span>
</h3>
<p>{grant.description}</p>
<div className="mt-4">
<button
className={`bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded ${
isLoading ? "opacity-50" : ""
}`}
onClick={() => handleReviewGrant(grant, acceptStatus)}
disabled={isLoading}
>
{acceptLabel}
</button>
<button
className={`bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded ml-4 ${
isLoading ? "opacity-50" : ""
}`}
onClick={() => handleReviewGrant(grant, PROPOSAL_STATUS.REJECTED)}
disabled={isLoading}
>
Reject
</button>
</div>
</div>
);
};
113 changes: 17 additions & 96 deletions packages/nextjs/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,119 +1,40 @@
"use client";

import { useEffect, useState } from "react";
import { useAccount, useSignTypedData } from "wagmi";
import { GrantReview } from "./_components/GrantReview";
import useSWR from "swr";
import { GrantData } from "~~/services/database/schema";
import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712";
import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants";
import { PROPOSAL_STATUS } from "~~/utils/grants";
import { notification } from "~~/utils/scaffold-eth";

const GrantReview = ({
grant,
reviewGrant,
}: {
grant: GrantData;
reviewGrant: (grant: GrantData, action: ProposalStatusType) => void;
}) => {
if (grant.status !== PROPOSAL_STATUS.PROPOSED && grant.status !== PROPOSAL_STATUS.SUBMITTED) return null;

const acceptStatus = grant.status === PROPOSAL_STATUS.PROPOSED ? PROPOSAL_STATUS.APPROVED : PROPOSAL_STATUS.COMPLETED;
const acceptLabel = grant.status === PROPOSAL_STATUS.PROPOSED ? "Approve" : "Complete";
return (
<div className="border p-4 my-4">
<h3 className="font-bold">
{grant.title}
<span className="text-sm text-gray-500 ml-2">({grant.id})</span>
</h3>
<p>{grant.description}</p>
<div className="mt-4">
<button
className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
onClick={() => reviewGrant(grant, acceptStatus)}
>
{acceptLabel}
</button>
<button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded ml-4"
onClick={() => reviewGrant(grant, PROPOSAL_STATUS.REJECTED)}
>
Reject
</button>
</div>
</div>
);
};

// ToDo. "Protect" with address header or PROTECT with signing the read.
// ToDo. Loading states (initial, actions, etc)
// ToDo. Refresh list after action
const AdminPage = () => {
const { address } = useAccount();
const [grants, setGrants] = useState<GrantData[]>([]);
const { signTypedDataAsync } = useSignTypedData();

useEffect(() => {
const getGrants = async () => {
try {
const response = await fetch("/api/grants/review");
const grants: GrantData[] = (await response.json()).data;
setGrants(grants);
} catch (error) {
notification.error("Error getting grants for review");
}
};

getGrants();
}, []);

const reviewGrant = async (grant: GrantData, action: ProposalStatusType) => {
let signature;
try {
signature = await signTypedDataAsync({
domain: EIP_712_DOMAIN,
types: EIP_712_TYPES__REVIEW_GRANT,
primaryType: "Message",
message: {
grantId: grant.id,
action: action,
},
});
} catch (e) {
console.error("Error signing message", e);
notification.error("Error signing message");
return;
}

try {
await fetch(`/api/grants/${grant.id}/review`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ signature, signer: address, action }),
});
notification.success(`Grant reviewed: ${action}`);
} catch (error) {
notification.error("Error reviewing grant");
}
};
// TODO: Move the response type to a shared location
const { data, isLoading } = useSWR<{ data: GrantData[] }>("/api/grants/review", {
onError: error => {
console.error("Error fetching grants", error);
notification.error("Error getting grants data");
},
});
const grants = data?.data;

const completedGrants = grants?.filter(grant => grant.status === PROPOSAL_STATUS.SUBMITTED);
const newGrants = grants.filter(grant => grant.status === PROPOSAL_STATUS.PROPOSED);
const newGrants = grants?.filter(grant => grant.status === PROPOSAL_STATUS.PROPOSED);

return (
<div className="container mx-auto max-w-screen-md mt-12">
<h1 className="text-4xl font-bold">Admin page</h1>
{isLoading && <span className="loading loading-spinner "></span>}
{grants && (
<>
<h2 className="font-bold mt-8">Proposals submitted as completed:</h2>
{completedGrants?.length === 0 && <p>No completed grants</p>}
{completedGrants.map(grant => (
<GrantReview key={grant.id} grant={grant} reviewGrant={reviewGrant} />
{completedGrants?.map(grant => (
<GrantReview key={grant.id} grant={grant} />
))}
<h2 className="font-bold mt-8">New grant proposal:</h2>
{newGrants?.length === 0 && <p>No new grants</p>}
{newGrants.map(grant => (
<GrantReview key={grant.id} grant={grant} reviewGrant={reviewGrant} />
{newGrants?.map(grant => (
<GrantReview key={grant.id} grant={grant} />
))}
</>
)}
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/app/api/builders/[builderAddress]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export async function GET(_request: Request, { params }: { params: { builderAddr
return NextResponse.json(builderData);
} catch (error) {
return NextResponse.json(
{ message: "Internal Server Error" },
{ error: "Internal Server Error" },
{
status: 500,
},
Expand Down
34 changes: 19 additions & 15 deletions packages/nextjs/app/apply/_component/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,28 @@

import { useRouter } from "next/navigation";
import SubmitButton from "./SubmitButton";
import useSWRMutation from "swr/mutation";
import { useAccount, useSignTypedData } from "wagmi";
import { EIP_712_DOMAIN, EIP_712_TYPES__APPLY_FOR_GRANT } from "~~/utils/eip712";
import { notification } from "~~/utils/scaffold-eth";
import { postMutationFetcher } from "~~/utils/swr";

// TODO: move to a shared location
type ReqBody = {
title?: string;
description?: string;
askAmount?: string;
signature?: `0x${string}`;
signer?: string;
};

const selectOptions = [0.1, 0.25, 0.5, 1];

const Form = () => {
const { address: connectedAddress } = useAccount();
const { signTypedDataAsync } = useSignTypedData();
const router = useRouter();
const { trigger: postNewGrant } = useSWRMutation("/api/grants/new", postMutationFetcher<ReqBody>);

const clientFormAction = async (formData: FormData) => {
if (!connectedAddress) {
Expand All @@ -20,9 +32,9 @@ const Form = () => {
}

try {
const title = formData.get("title");
const description = formData.get("description");
const askAmount = formData.get("askAmount");
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const askAmount = formData.get("askAmount") as string;
if (!title || !description || !askAmount) {
notification.error("Please fill all the fields");
return;
Expand All @@ -33,21 +45,13 @@ const Form = () => {
types: EIP_712_TYPES__APPLY_FOR_GRANT,
primaryType: "Message",
message: {
title: title as string,
description: description as string,
askAmount: askAmount as string,
title: title,
description: description,
askAmount: askAmount,
},
});

const res = await fetch("/api/grants/new", {
method: "POST",
body: JSON.stringify({ title, description, askAmount, signature, signer: connectedAddress }),
});

if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Error submitting grant proposal");
}
await postNewGrant({ title, description, askAmount, signature, signer: connectedAddress });

notification.success("Proposal submitted successfully!");
router.push("/");
Expand Down
1 change: 0 additions & 1 deletion packages/nextjs/app/apply/_component/SubmitButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { useBGBuilderData } from "~~/hooks/useBGBuilderData";
const SubmitButton = () => {
const { pending } = useFormStatus();
const { isConnected, address: connectedAddress } = useAccount();
// ToDo. We could use builderData from global state
const { isBuilderPresent, isLoading: isFetchingBuilderData } = useBGBuilderData(connectedAddress);
const isSubmitDisabled = !isConnected || isFetchingBuilderData || pending || !isBuilderPresent;

Expand Down
6 changes: 4 additions & 2 deletions packages/nextjs/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { RainbowKitCustomConnectButton } from "./scaffold-eth";
import { useAccount } from "wagmi";
import { LockClosedIcon } from "@heroicons/react/24/outline";
import { useGlobalState } from "~~/services/store/store";
import { useBGBuilderData } from "~~/hooks/useBGBuilderData";

type HeaderMenuLink = {
label: string;
Expand All @@ -26,7 +27,8 @@ export const menuLinks: HeaderMenuLink[] = [

export const HeaderMenuLinks = () => {
const pathname = usePathname();
const builderData = useGlobalState(state => state.builderData);
const { address: connectedAddress } = useAccount();
const { data: builderData } = useBGBuilderData(connectedAddress);

return (
<>
Expand Down
22 changes: 6 additions & 16 deletions packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,17 @@ import { useEffect, useState } from "react";
import { RainbowKitProvider, darkTheme, lightTheme } from "@rainbow-me/rainbowkit";
import { useTheme } from "next-themes";
import { Toaster } from "react-hot-toast";
import { WagmiConfig, useAccount } from "wagmi";
import { SWRConfig } from "swr";
import { WagmiConfig } from "wagmi";
import { Footer } from "~~/components/Footer";
import { Header } from "~~/components/Header";
import { BlockieAvatar } from "~~/components/scaffold-eth";
import { ProgressBar } from "~~/components/scaffold-eth/ProgressBar";
import { useBGBuilderData } from "~~/hooks/useBGBuilderData";
import { useGlobalState } from "~~/services/store/store";
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
import { appChains } from "~~/services/web3/wagmiConnectors";
import { fetcher } from "~~/utils/swr";

const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
const { address } = useAccount();
const { data } = useBGBuilderData(address);

const setBuilderData = useGlobalState(state => state.setBuilderData);

useEffect(() => {
if (data?.id) {
setBuilderData(data);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setBuilderData, data?.id]);
carletex marked this conversation as resolved.
Show resolved Hide resolved

return (
<>
<div className="flex flex-col min-h-screen font-spaceGrotesk">
Expand Down Expand Up @@ -56,7 +44,9 @@ export const ScaffoldEthAppWithProviders = ({ children }: { children: React.Reac
avatar={BlockieAvatar}
theme={mounted ? (isDarkMode ? darkTheme() : lightTheme()) : lightTheme()}
>
<ScaffoldEthApp>{children}</ScaffoldEthApp>
<SWRConfig value={{ fetcher: fetcher, revalidateOnFocus: false }}>
<ScaffoldEthApp>{children}</ScaffoldEthApp>
</SWRConfig>
</RainbowKitProvider>
</WagmiConfig>
);
Expand Down
Loading
Loading