diff --git a/packages/nextjs/app/admin/_components/GrantReview.tsx b/packages/nextjs/app/admin/_components/GrantReview.tsx new file mode 100644 index 0000000..e446e03 --- /dev/null +++ b/packages/nextjs/app/admin/_components/GrantReview.tsx @@ -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, + ); + 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("Submitting review"); + 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 ( +
+

+ {grant.title} + ({grant.id}) +

+

{grant.description}

+
+ + +
+
+ ); +}; diff --git a/packages/nextjs/app/admin/page.tsx b/packages/nextjs/app/admin/page.tsx index 1931140..1e47a32 100644 --- a/packages/nextjs/app/admin/page.tsx +++ b/packages/nextjs/app/admin/page.tsx @@ -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 ( -
-

- {grant.title} - ({grant.id}) -

-

{grant.description}

-
- - -
-
- ); -}; - // 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([]); - 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 (

Admin page

+ {isLoading && } {grants && ( <>

Proposals submitted as completed:

{completedGrants?.length === 0 &&

No completed grants

} - {completedGrants.map(grant => ( - + {completedGrants?.map(grant => ( + ))}

New grant proposal:

{newGrants?.length === 0 &&

No new grants

} - {newGrants.map(grant => ( - + {newGrants?.map(grant => ( + ))} )} diff --git a/packages/nextjs/app/api/builders/[builderAddress]/route.ts b/packages/nextjs/app/api/builders/[builderAddress]/route.ts index 5116b56..12f3f41 100644 --- a/packages/nextjs/app/api/builders/[builderAddress]/route.ts +++ b/packages/nextjs/app/api/builders/[builderAddress]/route.ts @@ -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, }, diff --git a/packages/nextjs/app/apply/_component/Form.tsx b/packages/nextjs/app/apply/_component/Form.tsx index 18f4ac2..07bd057 100644 --- a/packages/nextjs/app/apply/_component/Form.tsx +++ b/packages/nextjs/app/apply/_component/Form.tsx @@ -2,9 +2,20 @@ 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]; @@ -12,6 +23,7 @@ const Form = () => { const { address: connectedAddress } = useAccount(); const { signTypedDataAsync } = useSignTypedData(); const router = useRouter(); + const { trigger: postNewGrant } = useSWRMutation("/api/grants/new", postMutationFetcher); const clientFormAction = async (formData: FormData) => { if (!connectedAddress) { @@ -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; @@ -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("/"); diff --git a/packages/nextjs/app/apply/_component/SubmitButton.tsx b/packages/nextjs/app/apply/_component/SubmitButton.tsx index bc12fe3..4027881 100644 --- a/packages/nextjs/app/apply/_component/SubmitButton.tsx +++ b/packages/nextjs/app/apply/_component/SubmitButton.tsx @@ -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; diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index 3ceb161..b11e97c 100644 --- a/packages/nextjs/components/Header.tsx +++ b/packages/nextjs/components/Header.tsx @@ -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; @@ -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 ( <> diff --git a/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx b/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx index 58c900e..d9711ba 100644 --- a/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx +++ b/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx @@ -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]); - return ( <>
@@ -56,7 +44,9 @@ export const ScaffoldEthAppWithProviders = ({ children }: { children: React.Reac avatar={BlockieAvatar} theme={mounted ? (isDarkMode ? darkTheme() : lightTheme()) : lightTheme()} > - {children} + + {children} + ); diff --git a/packages/nextjs/hooks/useBGBuilderData.ts b/packages/nextjs/hooks/useBGBuilderData.ts index c3522d7..65f2c83 100644 --- a/packages/nextjs/hooks/useBGBuilderData.ts +++ b/packages/nextjs/hooks/useBGBuilderData.ts @@ -1,46 +1,15 @@ -import { useEffect, useState } from "react"; -import { BuilderData, BuilderDataResponse } from "~~/services/database/schema"; +import useSWRImmutable from "swr/immutable"; +import { 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]); + const { + data: responseData, + isLoading, + error, + } = useSWRImmutable(address ? `/api/builders/${address}` : null); + + const data = responseData?.data; + const isBuilderPresent = responseData?.exists ?? false; return { isLoading, error, data, isBuilderPresent }; }; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 77df898..3cf6b98 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -30,6 +30,7 @@ "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.0", + "swr": "^2.2.5", "use-debounce": "^8.0.4", "usehooks-ts": "^2.13.0", "viem": "1.19.9", diff --git a/packages/nextjs/services/store/store.ts b/packages/nextjs/services/store/store.ts index 0d61097..da69755 100644 --- a/packages/nextjs/services/store/store.ts +++ b/packages/nextjs/services/store/store.ts @@ -1,6 +1,5 @@ import { create } from "zustand"; import scaffoldConfig from "~~/scaffold.config"; -import { BuilderData } from "~~/services/database/schema"; import { ChainWithAttributes } from "~~/utils/scaffold-eth"; /** @@ -17,8 +16,6 @@ type GlobalState = { setNativeCurrencyPrice: (newNativeCurrencyPriceState: number) => void; targetNetwork: ChainWithAttributes; setTargetNetwork: (newTargetNetwork: ChainWithAttributes) => void; - builderData: BuilderData | null; - setBuilderData: (newBuilderData: BuilderData) => void; }; export const useGlobalState = create(set => ({ @@ -26,6 +23,4 @@ export const useGlobalState = create(set => ({ setNativeCurrencyPrice: (newValue: number): void => set(() => ({ nativeCurrencyPrice: newValue })), targetNetwork: scaffoldConfig.targetNetworks[0], setTargetNetwork: (newTargetNetwork: ChainWithAttributes) => set(() => ({ targetNetwork: newTargetNetwork })), - builderData: null, - setBuilderData: (newBuilderData: BuilderData) => set(() => ({ builderData: newBuilderData })), })); diff --git a/packages/nextjs/utils/swr.ts b/packages/nextjs/utils/swr.ts new file mode 100644 index 0000000..936c186 --- /dev/null +++ b/packages/nextjs/utils/swr.ts @@ -0,0 +1,22 @@ +export const fetcher = async (...args: Parameters) => { + const res = await fetch(...args); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Error fetching data"); + } + return data; +}; + +export const postMutationFetcher = async >(url: string, { arg }: { arg: T }) => { + const res = await fetch(url, { + method: "POST", + body: JSON.stringify(arg), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Error posting data"); + } + return data; +}; diff --git a/yarn.lock b/yarn.lock index c29da88..5c5969f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2327,6 +2327,7 @@ __metadata: react-copy-to-clipboard: ^5.1.0 react-dom: ^18.2.0 react-hot-toast: ^2.4.0 + swr: ^2.2.5 tailwindcss: ^3.3.3 type-fest: ^4.6.0 typescript: ^5.1.6 @@ -5725,7 +5726,7 @@ __metadata: languageName: node linkType: hard -"client-only@npm:0.0.1": +"client-only@npm:0.0.1, client-only@npm:^0.0.1": version: 0.0.1 resolution: "client-only@npm:0.0.1" checksum: 0c16bf660dadb90610553c1d8946a7fdfb81d624adea073b8440b7d795d5b5b08beb3c950c6a2cf16279365a3265158a236876d92bce16423c485c322d7dfaf8 @@ -14484,6 +14485,18 @@ __metadata: languageName: node linkType: hard +"swr@npm:^2.2.5": + version: 2.2.5 + resolution: "swr@npm:2.2.5" + dependencies: + client-only: ^0.0.1 + use-sync-external-store: ^1.2.0 + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + checksum: c6e6a5bd254951b22e5fd0930a95c7f79b5d0657f803c41ba1542cd6376623fb70b1895049d54ddde26da63b91951ae9d62a06772f82be28c1014d421e5b7aa9 + languageName: node + linkType: hard + "sync-request@npm:^6.0.0": version: 6.1.0 resolution: "sync-request@npm:6.1.0"