From f2f7d5c0fc7d7c05250b96f0f05254104a114b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Sun, 18 Feb 2024 20:38:27 +0100 Subject: [PATCH 1/6] Admin page: skeleton + read data --- packages/nextjs/app/admin/page.tsx | 43 +++++++++++++++ .../nextjs/app/api/grants/review/route.tsx | 8 +++ packages/nextjs/components/Header.tsx | 53 ++++++++++++++++++- packages/nextjs/services/database/grants.ts | 16 ++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 packages/nextjs/app/admin/page.tsx create mode 100644 packages/nextjs/app/api/grants/review/route.tsx diff --git a/packages/nextjs/app/admin/page.tsx b/packages/nextjs/app/admin/page.tsx new file mode 100644 index 0000000..c7d1efe --- /dev/null +++ b/packages/nextjs/app/admin/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { GrantData } from "~~/services/database/schema"; +import { notification } from "~~/utils/scaffold-eth"; + +// ToDo. "Protect" with address header or PROTECT with signing the read. +const AdminPage = () => { + const [grants, setGrants] = useState([]); + + 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(); + }, []); + + return ( +
+

Admin page

+ {grants && ( + <> +

All grants that need review:

+ {grants.map(grant => ( +
+

{grant.title}

+

{grant.description}

+
+ ))} + + )} +
+ ); +}; + +export default AdminPage; diff --git a/packages/nextjs/app/api/grants/review/route.tsx b/packages/nextjs/app/api/grants/review/route.tsx new file mode 100644 index 0000000..78546cd --- /dev/null +++ b/packages/nextjs/app/api/grants/review/route.tsx @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { getAllGrantsForReview } from "~~/services/database/grants"; + +export async function GET() { + const grants = await getAllGrantsForReview(); + + return NextResponse.json({ data: grants }); +} diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index e4770b1..b927560 100644 --- a/packages/nextjs/components/Header.tsx +++ b/packages/nextjs/components/Header.tsx @@ -1,7 +1,54 @@ import React from "react"; import Image from "next/image"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { RainbowKitCustomConnectButton } from "./scaffold-eth"; +import { LockClosedIcon } from "@heroicons/react/24/outline"; + +type HeaderMenuLink = { + label: string; + href: string; + icon?: React.ReactNode; +}; + +export const menuLinks: HeaderMenuLink[] = [ + { + label: "Home", + href: "/", + }, + // ToDo. Show only on admins + { + label: "Admin", + href: "/admin", + icon: , + }, +]; + +export const HeaderMenuLinks = () => { + const pathname = usePathname(); + + return ( + <> + {menuLinks.map(({ label, href, icon }) => { + const isActive = pathname === href; + return ( +
  • + + {icon} + {label} + +
  • + ); + })} + + ); +}; /** * Site header @@ -9,13 +56,17 @@ import { RainbowKitCustomConnectButton } from "./scaffold-eth"; export const Header = () => { return (
    -
    +
    SE2 logo
    +
      + +
    +
    diff --git a/packages/nextjs/services/database/grants.ts b/packages/nextjs/services/database/grants.ts index 4325c8e..cf24ab2 100644 --- a/packages/nextjs/services/database/grants.ts +++ b/packages/nextjs/services/database/grants.ts @@ -43,6 +43,22 @@ export const getAllGrants = async () => { } }; +export const getAllGrantsForReview = async () => { + try { + const grantsSnapshot = await grantsCollection + .where("status", "in", [PROPOSAL_STATUS.PROPOSED, PROPOSAL_STATUS.SUBMITTED]) + .get(); + const grants: GrantData[] = []; + grantsSnapshot.forEach(doc => { + grants.push({ id: doc.id, ...doc.data() } as GrantData); + }); + return grants; + } catch (error) { + console.error("Error getting all completed grants:", error); + throw error; + } +}; + export const getAllCompletedGrants = async () => { try { const grantsSnapshot = await grantsCollection.where("status", "==", PROPOSAL_STATUS.COMPLETED).get(); From 63ba7616cf1e83bc9df6ec0e3430038365de061b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Sun, 18 Feb 2024 21:38:15 +0100 Subject: [PATCH 2/6] Admin page actions (approve / reject) + EIP712 --- packages/nextjs/app/admin/page.tsx | 61 ++++++++++++++++++- .../app/api/grants/[grantId]/review/route.tsx | 35 +++++++++++ packages/nextjs/scaffold.config.ts | 2 +- packages/nextjs/services/database/grants.ts | 18 +++--- packages/nextjs/utils/eip712.ts | 13 ++++ packages/nextjs/utils/grants.ts | 9 +++ 6 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 packages/nextjs/app/api/grants/[grantId]/review/route.tsx create mode 100644 packages/nextjs/utils/eip712.ts create mode 100644 packages/nextjs/utils/grants.ts diff --git a/packages/nextjs/app/admin/page.tsx b/packages/nextjs/app/admin/page.tsx index c7d1efe..77e86ae 100644 --- a/packages/nextjs/app/admin/page.tsx +++ b/packages/nextjs/app/admin/page.tsx @@ -1,12 +1,20 @@ "use client"; import { useEffect, useState } from "react"; +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"; // ToDo. "Protect" with address header or PROTECT with signing the read. +// ToDo. Submitted grants +// 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 () => { @@ -22,6 +30,38 @@ const AdminPage = () => { 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"); + } + }; + return (

    Admin page

    @@ -30,8 +70,27 @@ const AdminPage = () => {

    All grants that need review:

    {grants.map(grant => (
    -

    {grant.title}

    +

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

    {grant.description}

    + {grant.status === PROPOSAL_STATUS.PROPOSED && ( +
    + + +
    + )}
    ))} diff --git a/packages/nextjs/app/api/grants/[grantId]/review/route.tsx b/packages/nextjs/app/api/grants/[grantId]/review/route.tsx new file mode 100644 index 0000000..b285075 --- /dev/null +++ b/packages/nextjs/app/api/grants/[grantId]/review/route.tsx @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { recoverTypedDataAddress } from "viem"; +import { reviewGrant } from "~~/services/database/grants"; +import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712"; + +export async function POST(req: NextRequest, { params }: { params: { grantId: string } }) { + const { grantId } = params; + const { signature, signer, action } = await req.json(); + + // Validate Signature + const recoveredAddress = await recoverTypedDataAddress({ + domain: EIP_712_DOMAIN, + types: EIP_712_TYPES__REVIEW_GRANT, + primaryType: "Message", + message: { + grantId: grantId, + action: action, + }, + signature, + }); + + if (recoveredAddress !== signer) { + console.error("Signature error", recoveredAddress, signer); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + await reviewGrant(grantId, action); + } catch (error) { + console.error("Error approving grant", error); + return NextResponse.json({ error: "Error approving grant" }, { status: 500 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/packages/nextjs/scaffold.config.ts b/packages/nextjs/scaffold.config.ts index bee782e..ca452a5 100644 --- a/packages/nextjs/scaffold.config.ts +++ b/packages/nextjs/scaffold.config.ts @@ -11,7 +11,7 @@ export type ScaffoldConfig = { const scaffoldConfig = { // The networks on which your DApp is live - targetNetworks: [chains.optimism], + targetNetworks: [chains.mainnet], // The interval at which your front-end polls the RPC servers for new data // it has no effect if you only target the local network (default is 4000) diff --git a/packages/nextjs/services/database/grants.ts b/packages/nextjs/services/database/grants.ts index cf24ab2..5c42539 100644 --- a/packages/nextjs/services/database/grants.ts +++ b/packages/nextjs/services/database/grants.ts @@ -1,13 +1,6 @@ import { getFirestoreConnector } from "./firestoreDB"; import { GrantData } from "./schema"; - -export const PROPOSAL_STATUS = { - PROPOSED: "proposed", - APPROVED: "approved", - SUBMITTED: "submitted", - COMPLETED: "completed", - REJECTED: "rejected", -} as const; +import { PROPOSAL_STATUS, ProposalStatusType } from "~~/utils/grants"; const firestoreDB = getFirestoreConnector(); const grantsCollection = firestoreDB.collection("grants"); @@ -73,6 +66,15 @@ export const getAllCompletedGrants = async () => { } }; +export const reviewGrant = async (grantId: string, action: ProposalStatusType) => { + try { + await grantsCollection.doc(grantId).update({ status: action }); + } catch (error) { + console.error("Error approving the grant:", error); + throw error; + } +}; + export const getGrantsStats = async () => { // Summation of askAmount for completed grants: total_eth_granted // Total number of completed grants : total_completed_grants diff --git a/packages/nextjs/utils/eip712.ts b/packages/nextjs/utils/eip712.ts new file mode 100644 index 0000000..d4316d6 --- /dev/null +++ b/packages/nextjs/utils/eip712.ts @@ -0,0 +1,13 @@ +export const EIP_712_DOMAIN = { + name: "BuidlGuidl Grants", + version: "1", + chainId: 1, +} as const; + +// ToDo. We could add more fields (grant title, builder, etc) +export const EIP_712_TYPES__REVIEW_GRANT = { + Message: [ + { name: "grantId", type: "string" }, + { name: "action", type: "string" }, + ], +} as const; diff --git a/packages/nextjs/utils/grants.ts b/packages/nextjs/utils/grants.ts new file mode 100644 index 0000000..758dbda --- /dev/null +++ b/packages/nextjs/utils/grants.ts @@ -0,0 +1,9 @@ +export const PROPOSAL_STATUS = { + PROPOSED: "proposed", + APPROVED: "approved", + SUBMITTED: "submitted", + COMPLETED: "completed", + REJECTED: "rejected", +} as const; + +export type ProposalStatusType = (typeof PROPOSAL_STATUS)[keyof typeof PROPOSAL_STATUS]; From 2c0b5cb6e2a987c0be97bc8b09fbe6ae6f2a04fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Sun, 18 Feb 2024 21:42:02 +0100 Subject: [PATCH 3/6] Revert back to OP --- packages/nextjs/scaffold.config.ts | 2 +- packages/nextjs/utils/eip712.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/scaffold.config.ts b/packages/nextjs/scaffold.config.ts index ca452a5..bee782e 100644 --- a/packages/nextjs/scaffold.config.ts +++ b/packages/nextjs/scaffold.config.ts @@ -11,7 +11,7 @@ export type ScaffoldConfig = { const scaffoldConfig = { // The networks on which your DApp is live - targetNetworks: [chains.mainnet], + targetNetworks: [chains.optimism], // The interval at which your front-end polls the RPC servers for new data // it has no effect if you only target the local network (default is 4000) diff --git a/packages/nextjs/utils/eip712.ts b/packages/nextjs/utils/eip712.ts index d4316d6..4a34de6 100644 --- a/packages/nextjs/utils/eip712.ts +++ b/packages/nextjs/utils/eip712.ts @@ -1,7 +1,7 @@ export const EIP_712_DOMAIN = { name: "BuidlGuidl Grants", version: "1", - chainId: 1, + chainId: 10, } as const; // ToDo. We could add more fields (grant title, builder, etc) From 8a9d0eb87b3f106ad2b2ce0d0766d4837243fd9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Mon, 19 Feb 2024 11:59:23 +0100 Subject: [PATCH 4/6] Admin page: proposed + submitted grants --- packages/nextjs/app/admin/page.tsx | 74 +++++++++++++++++++----------- 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/packages/nextjs/app/admin/page.tsx b/packages/nextjs/app/admin/page.tsx index 77e86ae..1931140 100644 --- a/packages/nextjs/app/admin/page.tsx +++ b/packages/nextjs/app/admin/page.tsx @@ -7,8 +7,43 @@ 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"; +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. Submitted grants // ToDo. Loading states (initial, actions, etc) // ToDo. Refresh list after action const AdminPage = () => { @@ -62,36 +97,23 @@ const AdminPage = () => { } }; + const completedGrants = grants?.filter(grant => grant.status === PROPOSAL_STATUS.SUBMITTED); + const newGrants = grants.filter(grant => grant.status === PROPOSAL_STATUS.PROPOSED); + return (

    Admin page

    {grants && ( <> -

    All grants that need review:

    - {grants.map(grant => ( -
    -

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

    -

    {grant.description}

    - {grant.status === PROPOSAL_STATUS.PROPOSED && ( -
    - - -
    - )} -
    +

    Proposals submitted as completed:

    + {completedGrants?.length === 0 &&

    No completed grants

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

    New grant proposal:

    + {newGrants?.length === 0 &&

    No new grants

    } + {newGrants.map(grant => ( + ))} )} From 0acf9ad0b89ffd375873212ace2d517dd66eede4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Mon, 19 Feb 2024 12:06:44 +0100 Subject: [PATCH 5/6] Only admins can review grants --- .../nextjs/app/api/grants/[grantId]/review/route.tsx | 9 +++++++++ packages/nextjs/services/database/schema.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/app/api/grants/[grantId]/review/route.tsx b/packages/nextjs/app/api/grants/[grantId]/review/route.tsx index b285075..970d56e 100644 --- a/packages/nextjs/app/api/grants/[grantId]/review/route.tsx +++ b/packages/nextjs/app/api/grants/[grantId]/review/route.tsx @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { recoverTypedDataAddress } from "viem"; import { reviewGrant } from "~~/services/database/grants"; +import { findUserByAddress } from "~~/services/database/users"; import { EIP_712_DOMAIN, EIP_712_TYPES__REVIEW_GRANT } from "~~/utils/eip712"; export async function POST(req: NextRequest, { params }: { params: { grantId: string } }) { @@ -24,6 +25,14 @@ export async function POST(req: NextRequest, { params }: { params: { grantId: st return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + // Only admins can review grants + const signerData = await findUserByAddress(signer); + + if (signerData.data?.role !== "admin") { + console.error("Unauthorized", signer); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { await reviewGrant(grantId, action); } catch (error) { diff --git a/packages/nextjs/services/database/schema.ts b/packages/nextjs/services/database/schema.ts index 5781018..b4ab8bb 100644 --- a/packages/nextjs/services/database/schema.ts +++ b/packages/nextjs/services/database/schema.ts @@ -25,7 +25,7 @@ type Graduated = { export type BuilderData = { id: string; socialLinks?: SocialLinks; - role?: string; + role?: "anonymous" | "builder" | "admin"; function?: string; creationTimestamp?: number; builds?: Build[]; From 4f26782e8daf29fcd6591766d2bd753fd533d907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Mon, 19 Feb 2024 12:31:53 +0100 Subject: [PATCH 6/6] Save connected user data to global store --- .../submit-grant/_component/SubmitButton.tsx | 1 + packages/nextjs/components/Footer.tsx | 12 +----------- packages/nextjs/components/Header.tsx | 4 +++- .../components/ScaffoldEthAppWithProviders.tsx | 17 ++++++++++------- packages/nextjs/services/store/store.ts | 5 +++++ 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/nextjs/app/submit-grant/_component/SubmitButton.tsx b/packages/nextjs/app/submit-grant/_component/SubmitButton.tsx index 4027881..bc12fe3 100644 --- a/packages/nextjs/app/submit-grant/_component/SubmitButton.tsx +++ b/packages/nextjs/app/submit-grant/_component/SubmitButton.tsx @@ -8,6 +8,7 @@ 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/Footer.tsx b/packages/nextjs/components/Footer.tsx index 7aac136..bd6a68a 100644 --- a/packages/nextjs/components/Footer.tsx +++ b/packages/nextjs/components/Footer.tsx @@ -1,19 +1,17 @@ import React from "react"; import Link from "next/link"; import { hardhat } from "viem/chains"; -import { CurrencyDollarIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { HeartIcon } from "@heroicons/react/24/outline"; import { SwitchTheme } from "~~/components/SwitchTheme"; import { BuidlGuidlLogo } from "~~/components/assets/BuidlGuidlLogo"; import { Faucet } from "~~/components/scaffold-eth"; import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork"; -import { useGlobalState } from "~~/services/store/store"; /** * Site footer */ export const Footer = () => { - const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrencyPrice); const { targetNetwork } = useTargetNetwork(); const isLocalNetwork = targetNetwork.id === hardhat.id; @@ -22,14 +20,6 @@ export const Footer = () => {
    - {nativeCurrencyPrice > 0 && ( -
    -
    - - {nativeCurrencyPrice} -
    -
    - )} {isLocalNetwork && ( <> diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index b927560..3ceb161 100644 --- a/packages/nextjs/components/Header.tsx +++ b/packages/nextjs/components/Header.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { RainbowKitCustomConnectButton } from "./scaffold-eth"; import { LockClosedIcon } from "@heroicons/react/24/outline"; +import { useGlobalState } from "~~/services/store/store"; type HeaderMenuLink = { label: string; @@ -16,7 +17,6 @@ export const menuLinks: HeaderMenuLink[] = [ label: "Home", href: "/", }, - // ToDo. Show only on admins { label: "Admin", href: "/admin", @@ -26,11 +26,13 @@ export const menuLinks: HeaderMenuLink[] = [ export const HeaderMenuLinks = () => { const pathname = usePathname(); + const builderData = useGlobalState(state => state.builderData); return ( <> {menuLinks.map(({ label, href, icon }) => { const isActive = pathname === href; + if (href === "/admin" && builderData?.role !== "admin") return null; return (
  • { - const price = useNativeCurrencyPrice(); - const setNativeCurrencyPrice = useGlobalState(state => state.setNativeCurrencyPrice); + const { address } = useAccount(); + const { data } = useBGBuilderData(address); + + const setBuilderData = useGlobalState(state => state.setBuilderData); useEffect(() => { - if (price > 0) { - setNativeCurrencyPrice(price); + if (data?.id) { + setBuilderData(data); } - }, [setNativeCurrencyPrice, price]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setBuilderData, data?.id]); return ( <> diff --git a/packages/nextjs/services/store/store.ts b/packages/nextjs/services/store/store.ts index da69755..0d61097 100644 --- a/packages/nextjs/services/store/store.ts +++ b/packages/nextjs/services/store/store.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import scaffoldConfig from "~~/scaffold.config"; +import { BuilderData } from "~~/services/database/schema"; import { ChainWithAttributes } from "~~/utils/scaffold-eth"; /** @@ -16,6 +17,8 @@ type GlobalState = { setNativeCurrencyPrice: (newNativeCurrencyPriceState: number) => void; targetNetwork: ChainWithAttributes; setTargetNetwork: (newTargetNetwork: ChainWithAttributes) => void; + builderData: BuilderData | null; + setBuilderData: (newBuilderData: BuilderData) => void; }; export const useGlobalState = create(set => ({ @@ -23,4 +26,6 @@ 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 })), }));