From 6f381d768c52006ecd3eab285733b0fe4f8e1cdb Mon Sep 17 00:00:00 2001 From: MananTank Date: Fri, 14 Feb 2025 01:41:11 +0000 Subject: [PATCH] [TOOL-3378] Dashboard: Add Contract to Project UI updates (#6233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR-Codex overview This PR focuses on refactoring and improving the structure of the dashboard application by removing obsolete files, enhancing component functionality, and updating UI elements for better user experience. ### Detailed summary - Deleted several unused files related to contract management. - Updated `className` properties for better styling consistency. - Added `accountAddress` prop to various components for enhanced functionality. - Introduced new hooks and functions for managing project contracts. - Improved error handling in contract import functionality. - Enhanced UI components for better responsiveness and clarity. > The following files were skipped due to too many changes: `apps/dashboard/src/components/contract-components/tables/cells.tsx`, `apps/dashboard/src/components/contract-components/contract-deploy-form/add-to-project-card.tsx`, `apps/dashboard/src/components/smart-wallets/AccountFactories/index.tsx`, `apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx`, `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx`, `apps/dashboard/src/components/contract-components/tables/contract-table.tsx` > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../blocks/DangerSettingCard.stories.tsx | 2 +- .../src/@/components/ui/UnderlineLink.tsx | 16 + apps/dashboard/src/@/lib/DashboardRouter.tsx | 2 +- .../hooks/useDashboardContractMetadata.tsx | 1 + .../@3rdweb-sdk/react/hooks/useRegistry.ts | 149 ------- .../_layout/contract-page-layout.client.tsx | 5 +- .../_layout/contract-page-layout.tsx | 9 +- .../_layout/primary-dashboard-button.tsx | 270 +++++++++---- .../[chain_id]/[contractAddress]/layout.tsx | 40 ++ .../profile/[addressOrEns]/ProfileUI.tsx | 28 -- .../components/uri-based-deploy.tsx | 39 +- .../app/account/components/AccountHeader.tsx | 2 + .../components/AccountHeaderUI.stories.tsx | 13 +- .../account/components/AccountHeaderUI.tsx | 5 +- .../contracts/DeployedContractsPageHeader.tsx | 56 +-- .../_components/DeployedContractsPage.tsx | 45 ++- .../_components/DeployedContractsTable.tsx | 17 - .../GetStartedWithContractsDeploy.tsx | 18 +- .../_components/getProjectContracts.ts | 38 ++ .../getSortedDeployedContracts.tsx | 30 +- .../src/app/account/contracts/layout.tsx | 26 -- .../src/app/account/contracts/page.tsx | 13 - .../published/PublishedContractsPage.tsx | 81 ---- .../app/account/contracts/published/page.tsx | 13 - apps/dashboard/src/app/account/layout.tsx | 22 +- .../Header/SecondaryNav/SecondaryNav.tsx | 4 +- .../SecondaryNav/account-button.client.tsx | 18 +- .../app/components/MobileBurgerMenuButton.tsx | 19 +- apps/dashboard/src/app/layout.tsx | 8 +- .../app/team/[team_slug]/(team)/layout.tsx | 22 +- .../[team_slug]/(team)/~/contracts/layout.tsx | 28 -- .../[team_slug]/(team)/~/contracts/page.tsx | 18 - .../(team)/~/contracts/published/page.tsx | 16 - .../overview/components/engine-overview.tsx | 7 +- .../account-abstraction/factories/page.tsx | 145 ++++++- .../[project_slug]/contracts/layout.tsx | 28 -- .../[project_slug]/contracts/page.tsx | 34 +- .../contracts/published/page.tsx | 16 - .../[project_slug]/hooks/project-contracts.ts | 64 +++ .../[team_slug]/[project_slug]/layout.tsx | 7 +- .../TeamHeader/TeamHeaderUI.stories.tsx | 18 +- .../components/TeamHeader/TeamHeaderUI.tsx | 5 +- .../team-header-logged-in.client.tsx | 2 + .../components/TeamHeader/team-header.tsx | 16 +- .../add-to-project-card.stories.tsx | 100 +++++ .../add-to-project-card.tsx | 219 ++++++++++ .../contract-deploy-form/common.tsx | 20 +- .../contract-deploy-form/custom-contract.tsx | 103 ++--- .../deploy-context-modal.tsx | 26 +- .../import-contract/modal.tsx | 99 +++-- .../publisher/publisher-header.tsx | 2 +- .../contract-components/tables/cells.tsx | 211 +++++----- .../tables/contract-table.stories.tsx | 123 ++++++ .../tables/contract-table.tsx | 339 ++++++++++++++++ .../tables/deployed-contracts.tsx | 380 ------------------ .../components/contract-components/utils.ts | 47 --- .../components/notices/AnnouncementBanner.tsx | 34 +- .../selects/NetworkSelectDropdown.tsx | 6 +- .../AccountFactories/account-cell.tsx | 39 +- .../AccountFactories/factory-contracts.tsx | 101 ++--- .../smart-wallets/AccountFactories/index.tsx | 206 +++------- apps/dashboard/src/constants/contracts.ts | 10 - apps/dashboard/src/contract-ui/types/types.ts | 4 - .../common/read/getAllMultichainRegistry.ts | 79 ---- 64 files changed, 1883 insertions(+), 1680 deletions(-) create mode 100644 apps/dashboard/src/@/components/ui/UnderlineLink.tsx delete mode 100644 apps/dashboard/src/@3rdweb-sdk/react/hooks/useRegistry.ts delete mode 100644 apps/dashboard/src/app/account/contracts/_components/DeployedContractsTable.tsx create mode 100644 apps/dashboard/src/app/account/contracts/_components/getProjectContracts.ts delete mode 100644 apps/dashboard/src/app/account/contracts/layout.tsx delete mode 100644 apps/dashboard/src/app/account/contracts/page.tsx delete mode 100644 apps/dashboard/src/app/account/contracts/published/PublishedContractsPage.tsx delete mode 100644 apps/dashboard/src/app/account/contracts/published/page.tsx delete mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/contracts/layout.tsx delete mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/contracts/page.tsx delete mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/contracts/published/page.tsx delete mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/layout.tsx delete mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/published/page.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/hooks/project-contracts.ts create mode 100644 apps/dashboard/src/components/contract-components/contract-deploy-form/add-to-project-card.stories.tsx create mode 100644 apps/dashboard/src/components/contract-components/contract-deploy-form/add-to-project-card.tsx create mode 100644 apps/dashboard/src/components/contract-components/tables/contract-table.stories.tsx create mode 100644 apps/dashboard/src/components/contract-components/tables/contract-table.tsx delete mode 100644 apps/dashboard/src/components/contract-components/tables/deployed-contracts.tsx delete mode 100644 apps/dashboard/src/components/contract-components/utils.ts delete mode 100644 apps/dashboard/src/contract-ui/types/types.ts delete mode 100644 apps/dashboard/src/dashboard-extensions/common/read/getAllMultichainRegistry.ts diff --git a/apps/dashboard/src/@/components/blocks/DangerSettingCard.stories.tsx b/apps/dashboard/src/@/components/blocks/DangerSettingCard.stories.tsx index 5db6a5eb906..8498dfb2bb9 100644 --- a/apps/dashboard/src/@/components/blocks/DangerSettingCard.stories.tsx +++ b/apps/dashboard/src/@/components/blocks/DangerSettingCard.stories.tsx @@ -28,7 +28,7 @@ export const Mobile: Story = { function Story() { return ( -
+
; + +export function UnderlineLink(props: LinkProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/@/lib/DashboardRouter.tsx b/apps/dashboard/src/@/lib/DashboardRouter.tsx index ac1fc432666..971a02def37 100644 --- a/apps/dashboard/src/@/lib/DashboardRouter.tsx +++ b/apps/dashboard/src/@/lib/DashboardRouter.tsx @@ -117,7 +117,7 @@ function DashboardRouterTopProgressBarInner() { const width = isLoading ? progress : 100; return ( fetchDashboardContractMetadata(contract), + refetchOnWindowFocus: false, }); } diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useRegistry.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useRegistry.ts deleted file mode 100644 index c1b3f7636bd..00000000000 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useRegistry.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { addContractToMultiChainRegistry } from "components/contract-components/utils"; -import { MULTICHAIN_REGISTRY_CONTRACT } from "constants/contracts"; -import { - DASHBOARD_ENGINE_RELAYER_URL, - DASHBOARD_FORWARDER_ADDRESS, -} from "constants/misc"; -import type { BasicContract } from "contract-ui/types/types"; -import { getAllMultichainRegistry } from "dashboard-extensions/common/read/getAllMultichainRegistry"; -import { useAllChainsData } from "hooks/chains/allChains"; -import { useMemo } from "react"; -import { sendAndConfirmTransaction } from "thirdweb"; -import { remove } from "thirdweb/extensions/thirdweb"; -import { useActiveAccount } from "thirdweb/react"; -import invariant from "tiny-invariant"; - -export function useMultiChainRegContractList(walletAddress?: string) { - return useQuery({ - queryKey: ["dashboard-registry", walletAddress, "multichain-contract-list"], - queryFn: async () => { - invariant(walletAddress, "walletAddress is required"); - const contracts = await getAllMultichainRegistry({ - contract: MULTICHAIN_REGISTRY_CONTRACT, - address: walletAddress, - }); - - return contracts; - }, - - enabled: !!walletAddress, - }); -} - -interface Options { - onlyMainnet?: boolean; -} - -export const useAllContractList = ( - walletAddress: string | undefined, - { onlyMainnet }: Options = { onlyMainnet: false }, -) => { - const multiChainQuery = useMultiChainRegContractList(walletAddress); - - // TODO - instead of using ALL chains, fetch only the ones used here - const { idToChain } = useAllChainsData(); - const contractList = useMemo(() => { - const data = multiChainQuery.data || []; - - const mainnets: BasicContract[] = []; - const testnets: BasicContract[] = []; - - // biome-ignore lint/complexity/noForEach: FIXME - data.forEach((net) => { - const chn = idToChain.get(net.chainId); - if (chn && chn.status !== "deprecated") { - if (chn.testnet) { - testnets.push(net); - } else { - mainnets.push(net); - } - } - }); - - mainnets.sort((a, b) => a.chainId - b.chainId); - - if (onlyMainnet) { - return mainnets; - } - - testnets.sort((a, b) => a.chainId - b.chainId); - return mainnets.concat(testnets); - }, [multiChainQuery.data, onlyMainnet, idToChain]); - - return { - ...multiChainQuery, - data: contractList, - }; -}; - -type RemoveContractParams = { - contractAddress: string; - chainId: number; -}; - -export function useRemoveContractMutation() { - const queryClient = useQueryClient(); - const account = useActiveAccount(); - return useMutation({ - mutationFn: async (data: RemoveContractParams) => { - invariant(data.chainId, "chainId not provided"); - invariant(account, "No wallet connected"); - const { contractAddress, chainId } = data; - const transaction = remove({ - contract: MULTICHAIN_REGISTRY_CONTRACT, - deployer: account.address, - deployment: contractAddress, - chainId: BigInt(chainId), - }); - return await sendAndConfirmTransaction({ - transaction, - account, - gasless: { - experimentalChainlessSupport: true, - provider: "engine", - relayerUrl: DASHBOARD_ENGINE_RELAYER_URL, - relayerForwarderAddress: DASHBOARD_FORWARDER_ADDRESS, - }, - }); - }, - - onSettled: () => { - return queryClient.invalidateQueries({ - queryKey: ["dashboard-registry"], - }); - }, - }); -} - -type AddContractParams = { - contractAddress: string; - chainId: number; -}; - -export function useAddContractMutation() { - const account = useActiveAccount(); - - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (data: AddContractParams) => { - invariant(account, "cannot add a contract without an address"); - - return await addContractToMultiChainRegistry( - { - address: data.contractAddress, - chainId: data.chainId, - }, - account, - 300000n, - ); - }, - - onSettled: () => { - return queryClient.invalidateQueries({ - queryKey: ["dashboard-registry"], - }); - }, - }); -} diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/contract-page-layout.client.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/contract-page-layout.client.tsx index 23f5d0226b7..41b70324b3d 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/contract-page-layout.client.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/contract-page-layout.client.tsx @@ -1,8 +1,9 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import type { ThirdwebContract } from "thirdweb"; +import type { ThirdwebClient, ThirdwebContract } from "thirdweb"; import type { ChainMetadata } from "thirdweb/chains"; +import type { MinimalTeamsAndProjects } from "../../../../../../components/contract-components/contract-deploy-form/add-to-project-card"; import { ErrorPage, LoadingPage } from "../_components/page-skeletons"; import { useContractPageMetadata } from "../_hooks/useContractPageMetadata"; import { getContractPageSidebarLinks } from "../_utils/getContractPageSidebarLinks"; @@ -13,6 +14,8 @@ export function ContractPageLayoutClient(props: { chainMetadata: ChainMetadata; contract: ThirdwebContract; children: React.ReactNode; + teamsAndProjects: MinimalTeamsAndProjects | undefined; + client: ThirdwebClient; }) { const metadataQuery = useContractPageMetadata(props.contract); const headerMetadataQuery = useQuery({ diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/contract-page-layout.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/contract-page-layout.tsx index 10565dcf70c..ef2a5b942f8 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/contract-page-layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/contract-page-layout.tsx @@ -3,8 +3,9 @@ import type { SidebarLink } from "@/components/blocks/Sidebar"; import { SidebarLayout } from "@/components/blocks/SidebarLayout"; import type { DashboardContractMetadata } from "@3rdweb-sdk/react/hooks/useDashboardContractMetadata"; import { DeprecatedAlert } from "components/shared/DeprecatedAlert"; -import type { ThirdwebContract } from "thirdweb"; +import type { ThirdwebClient, ThirdwebContract } from "thirdweb"; import type { ChainMetadata } from "thirdweb/chains"; +import type { MinimalTeamsAndProjects } from "../../../../../../components/contract-components/contract-deploy-form/add-to-project-card"; import { ContractMetadata } from "./contract-metadata"; import { PrimaryDashboardButton } from "./primary-dashboard-button"; @@ -14,6 +15,8 @@ export function ContractPageLayout(props: { children: React.ReactNode; sidebarLinks: SidebarLink[]; dashboardContractMetadata: DashboardContractMetadata | undefined; + client: ThirdwebClient; + teamsAndProjects: MinimalTeamsAndProjects | undefined; externalLinks: | { name: string; @@ -27,6 +30,8 @@ export function ContractPageLayout(props: { sidebarLinks, dashboardContractMetadata, externalLinks, + teamsAndProjects, + client, } = props; return ( @@ -49,6 +54,8 @@ export function ContractPageLayout(props: { chainSlug: chainMetadata.slug, contractAddress: contract.address, }} + teamsAndProjects={teamsAndProjects} + client={client} />
diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx index fd8cbcec2f4..b83588d0dcf 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/primary-dashboard-button.tsx @@ -1,19 +1,31 @@ "use client"; import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import type { EVMContractInfo } from "@3rdweb-sdk/react"; import { - useAddContractMutation, - useAllContractList, -} from "@3rdweb-sdk/react/hooks/useRegistry"; + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import type { EVMContractInfo } from "@3rdweb-sdk/react"; import { useTrack } from "hooks/analytics/useTrack"; -import { useTxNotifications } from "hooks/useTxNotifications"; import { CodeIcon, PlusIcon } from "lucide-react"; +import { CircleAlertIcon, ExternalLinkIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import type { Chain } from "thirdweb"; -import { useActiveAccount } from "thirdweb/react"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { Chain, ThirdwebClient } from "thirdweb"; +import { + AddToProjectSelector, + type MinimalTeamsAndProjects, +} from "../../../../../../components/contract-components/contract-deploy-form/add-to-project-card"; +import { useAddContractToProject } from "../../../../../team/[team_slug]/[project_slug]/hooks/project-contracts"; const TRACKING_CATEGORY = "add_to_dashboard_upsell"; @@ -22,6 +34,8 @@ type AddToDashboardCardProps = { chain: Chain; contractInfo: EVMContractInfo; hideCodePageLink?: boolean; + teamsAndProjects: MinimalTeamsAndProjects | undefined; + client: ThirdwebClient; }; export const PrimaryDashboardButton: React.FC = ({ @@ -29,40 +43,13 @@ export const PrimaryDashboardButton: React.FC = ({ chain, contractInfo, hideCodePageLink, + teamsAndProjects, + client, }) => { - const addContract = useAddContractMutation(); - const trackEvent = useTrack(); - const walletAddress = useActiveAccount()?.address; const pathname = usePathname(); - const registry = useAllContractList(walletAddress); - const { onSuccess: onAddSuccess, onError: onAddError } = useTxNotifications( - "Successfully imported", - "Failed to import", - ); - - const isInRegistry = - registry.isFetched && - registry.data?.find( - (c) => - contractAddress && - // compare address... - c.address.toLowerCase() === contractAddress.toLowerCase() && - // ... and chainId - c.chainId === chain.id, - ) && - registry.isSuccess; - - if ( - !walletAddress || - !contractAddress || - !chain || - pathname?.includes("payments") - ) { - return null; - } - - if (isInRegistry) { + // if user is not logged in + if (!teamsAndProjects) { if (hideCodePageLink) { return null; } @@ -84,54 +71,163 @@ export const PrimaryDashboardButton: React.FC = ({ } return ( - + ); }; + +function AddToProjectButton(props: { + contractAddress: string; + teamsAndProjects: MinimalTeamsAndProjects; + chainId: string; + client: ThirdwebClient; +}) { + return ( + + + + + + + + Add To Project + + Add the contract in a project's contract list on dashboard + + + + + + ); +} + +function AddToProjectModalContent(props: { + teamsAndProjects: MinimalTeamsAndProjects; + chainId: string; + contractAddress: string; + client: ThirdwebClient; +}) { + const trackEvent = useTrack(); + const addContractToProject = useAddContractToProject(); + + const [importSelection, setImportSelection] = useState({ + team: props.teamsAndProjects[0]?.team, + project: props.teamsAndProjects[0]?.projects[0], + }); + + const selectedTeam = props.teamsAndProjects.find( + (t) => t.team.id === importSelection.team?.id, + ); + + function handleImport(params: { + teamId: string; + projectId: string; + }) { + trackEvent({ + category: TRACKING_CATEGORY, + action: "add-to-dashboard", + label: "attempt", + contractAddress: props.contractAddress, + }); + addContractToProject.mutate( + { + contractAddress: props.contractAddress, + teamId: params.teamId, + projectId: params.projectId, + chainId: props.chainId, + }, + { + onSuccess: () => { + toast.success("Contract added to the project successfully"); + trackEvent({ + category: TRACKING_CATEGORY, + action: "add-to-dashboard", + label: "success", + contractAddress: props.contractAddress, + }); + }, + onError: (err) => { + if (err.message.includes("PROJECT_CONTRACT_ALREADY_EXISTS")) { + toast.error("Contract is already added to the project"); + } else { + toast.error("Failed to import contract"); + } + trackEvent({ + category: TRACKING_CATEGORY, + action: "add-to-dashboard", + label: "error", + contractAddress: props.contractAddress, + error: err, + }); + }, + }, + ); + } + + const isImportEnabled = !!importSelection.project && !!importSelection.team; + + return ( +
+
+ + + {/* No projects alert */} + {selectedTeam?.projects.length === 0 && ( + + + Selected team has no projects + + + Create Project + + + + )} +
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx index 97c9ee710b3..60a7edc66ca 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx @@ -1,8 +1,12 @@ +import { getProjects } from "@/api/projects"; +import { getTeams } from "@/api/team"; +import { getThirdwebClient } from "@/constants/thirdweb.server"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { localhost } from "thirdweb/chains"; import { getContractMetadata } from "thirdweb/extensions/common"; import { isAddress, isContractDeployed } from "thirdweb/utils"; +import type { MinimalTeamsAndProjects } from "../../../../../components/contract-components/contract-deploy-form/add-to-project-card"; import { resolveFunctionSelectors } from "../../../../../lib/selectors"; import { shortenIfAddress } from "../../../../../utils/usedapp-external"; import { ConfigureCustomChain } from "./_layout/ConfigureCustomChain"; @@ -38,11 +42,16 @@ export default async function Layout(props: { notFound(); } + const client = getThirdwebClient(); + const teamsAndProjects = await getTeamsAndProjectsIfLoggedIn(); + if (contract.chain.id === localhost.id) { return ( {props.children} @@ -73,12 +82,43 @@ export default async function Layout(props: { sidebarLinks={sidebarLinks} dashboardContractMetadata={contractMetadata} externalLinks={externalLinks} + teamsAndProjects={teamsAndProjects} + client={client} > {props.children} ); } +async function getTeamsAndProjectsIfLoggedIn() { + try { + const teams = await getTeams(); + + if (!teams) { + return undefined; + } + + const teamsAndProjects: MinimalTeamsAndProjects = await Promise.all( + teams.map(async (team) => ({ + team: { + id: team.id, + name: team.name, + slug: team.slug, + image: team.image, + }, + projects: (await getProjects(team.slug)).map((x) => ({ + id: x.id, + name: x.name, + })), + })), + ); + + return teamsAndProjects; + } catch { + return undefined; + } +} + export async function generateMetadata(props: { params: Promise<{ contractAddress: string; diff --git a/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/ProfileUI.tsx b/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/ProfileUI.tsx index e76688122de..ae14e98da8b 100644 --- a/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/ProfileUI.tsx +++ b/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/ProfileUI.tsx @@ -1,8 +1,6 @@ import { Spinner } from "@/components/ui/Spinner/Spinner"; import { fetchPublishedContracts } from "components/contract-components/fetchPublishedContracts"; -import { DeployedContracts } from "components/contract-components/tables/deployed-contracts"; import { Suspense } from "react"; -import { getSortedDeployedContracts } from "../../../account/contracts/_components/getSortedDeployedContracts"; import { ProfileHeader } from "./components/profile-header"; import { PublishedContracts } from "./components/published-contracts"; @@ -32,36 +30,10 @@ export function ProfileUI(props: {
- -
-

- Deployed contracts -

- -

- List of contracts deployed across all Mainnets -

- -
- }> - - -
); } -async function AsyncDeployedContracts(props: { - profileAddress: string; -}) { - const contracts = await getSortedDeployedContracts({ - address: props.profileAddress, - onlyMainnet: true, - }); - - return ; -} - async function AsyncPublishedContracts(props: { publisherAddress: string; publisherEnsName: string | undefined; diff --git a/apps/dashboard/src/app/(dashboard)/published-contract/components/uri-based-deploy.tsx b/apps/dashboard/src/app/(dashboard)/published-contract/components/uri-based-deploy.tsx index e68f5e1bdb1..66401b1fbe1 100644 --- a/apps/dashboard/src/app/(dashboard)/published-contract/components/uri-based-deploy.tsx +++ b/apps/dashboard/src/app/(dashboard)/published-contract/components/uri-based-deploy.tsx @@ -1,10 +1,10 @@ +import { getProjects } from "@/api/projects"; +import { getTeams } from "@/api/team"; import { ChakraProviderSetup } from "@/components/ChakraProviderSetup"; -import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie"; import { CustomContractForm } from "components/contract-components/contract-deploy-form/custom-contract"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; -import { getAddress } from "thirdweb"; import type { FetchDeployMetadataResult } from "thirdweb/contract"; +import { getAuthToken } from "../../../api/lib/getAuthToken"; +import { loginRedirect } from "../../../login/loginRedirect"; type DeployFormForUriProps = { contractMetadata: FetchDeployMetadataResult | null; @@ -19,24 +19,35 @@ export async function DeployFormForUri(props: DeployFormForUriProps) { return
Could not fetch metadata
; } - const cookieStore = await cookies(); - const address = cookieStore.get(COOKIE_ACTIVE_ACCOUNT)?.value; - if (!address) { - redirect(`/login?next=${encodeURIComponent(pathname)}`); - } - const authCookieName = COOKIE_PREFIX_TOKEN + getAddress(address); - const token = cookieStore.get(authCookieName)?.value; - if (!token) { - redirect(`/login?next=${encodeURIComponent(pathname)}`); + const [authToken, teams] = await Promise.all([getAuthToken(), getTeams()]); + + if (!teams || !authToken) { + loginRedirect(pathname); } + const teamsAndProjects = await Promise.all( + teams.map(async (team) => ({ + team: { + id: team.id, + name: team.name, + slug: team.slug, + image: team.image, + }, + projects: (await getProjects(team.slug)).map((x) => ({ + id: x.id, + name: x.name, + })), + })), + ); + // TODO: remove the `ChakraProviderSetup` wrapper once the form is updated to no longer use chakra return ( m !== null)} - jwt={token} + jwt={authToken} + teamsAndProjects={teamsAndProjects} /> ); diff --git a/apps/dashboard/src/app/account/components/AccountHeader.tsx b/apps/dashboard/src/app/account/components/AccountHeader.tsx index 2cca88ebb65..e596a59901d 100644 --- a/apps/dashboard/src/app/account/components/AccountHeader.tsx +++ b/apps/dashboard/src/app/account/components/AccountHeader.tsx @@ -19,6 +19,7 @@ import { export function AccountHeader(props: { teamsAndProjects: Array<{ team: Team; projects: Project[] }>; account: Account; + accountAddress: string; }) { const router = useDashboardRouter(); const [createProjectDialogState, setCreateProjectDialogState] = useState< @@ -52,6 +53,7 @@ export function AccountHeader(props: { }), account: props.account, client, + accountAddress: props.accountAddress, }; return ( diff --git a/apps/dashboard/src/app/account/components/AccountHeaderUI.stories.tsx b/apps/dashboard/src/app/account/components/AccountHeaderUI.stories.tsx index 1c7f22950f4..4c68ad4535d 100644 --- a/apps/dashboard/src/app/account/components/AccountHeaderUI.stories.tsx +++ b/apps/dashboard/src/app/account/components/AccountHeaderUI.stories.tsx @@ -38,6 +38,7 @@ export const Mobile: Story = { }; const client = getThirdwebClient(); +const accountAddressStub = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37"; function Variants(props: { type: "mobile" | "desktop"; @@ -52,6 +53,7 @@ function Variants(props: { {}} + accountAddress={accountAddressStub} connectButton={} createProject={() => {}} account={{ @@ -61,17 +63,6 @@ function Variants(props: { client={client} /> - - - {}} - connectButton={} - createProject={() => {}} - account={undefined} - client={client} - /> -
); diff --git a/apps/dashboard/src/app/account/components/AccountHeaderUI.tsx b/apps/dashboard/src/app/account/components/AccountHeaderUI.tsx index bf1d93da093..354af2a37a0 100644 --- a/apps/dashboard/src/app/account/components/AccountHeaderUI.tsx +++ b/apps/dashboard/src/app/account/components/AccountHeaderUI.tsx @@ -17,8 +17,9 @@ export type AccountHeaderCompProps = { connectButton: React.ReactNode; teamsAndProjects: Array<{ team: Team; projects: Project[] }>; createProject: (team: Team) => void; - account: Pick | undefined; + account: Pick; client: ThirdwebClient; + accountAddress: string; }; export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) { @@ -68,6 +69,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) { logout={props.logout} connectButton={props.connectButton} client={props.client} + accountAddress={props.accountAddress} /> ); @@ -114,6 +116,7 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) { email={props.account?.email} logout={props.logout} connectButton={props.connectButton} + accountAddress={props.accountAddress} /> ); diff --git a/apps/dashboard/src/app/account/contracts/DeployedContractsPageHeader.tsx b/apps/dashboard/src/app/account/contracts/DeployedContractsPageHeader.tsx index 35109203de5..78ee44718bd 100644 --- a/apps/dashboard/src/app/account/contracts/DeployedContractsPageHeader.tsx +++ b/apps/dashboard/src/app/account/contracts/DeployedContractsPageHeader.tsx @@ -6,42 +6,44 @@ import { DownloadIcon, PlusIcon } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; -export function DeployedContractsPageHeader() { +export function DeployedContractsPageHeader(props: { + teamId: string; + projectId: string; +}) { const [importModalOpen, setImportModalOpen] = useState(false); return ( -
+
{ setImportModalOpen(false); }} + teamId={props.teamId} + projectId={props.projectId} /> -
-
-

- Deployed contracts -

-

- The list of contracts that you have deployed or imported with - thirdweb across all networks -

-
-
- - + +
+
+
+

Contracts

+
+
+ + +
diff --git a/apps/dashboard/src/app/account/contracts/_components/DeployedContractsPage.tsx b/apps/dashboard/src/app/account/contracts/_components/DeployedContractsPage.tsx index 20b1827cd57..4ba073ad0c3 100644 --- a/apps/dashboard/src/app/account/contracts/_components/DeployedContractsPage.tsx +++ b/apps/dashboard/src/app/account/contracts/_components/DeployedContractsPage.tsx @@ -1,39 +1,60 @@ import { Spinner } from "@/components/ui/Spinner/Spinner"; import { ClientOnly } from "components/ClientOnly/ClientOnly"; import { Suspense } from "react"; +import { ContractTable } from "../../../../components/contract-components/tables/contract-table"; import { DeployedContractsPageHeader } from "../DeployedContractsPageHeader"; -import { DeployedContractsTable } from "./DeployedContractsTable"; import { GetStartedWithContractsDeploy } from "./GetStartedWithContractsDeploy"; import { getSortedDeployedContracts } from "./getSortedDeployedContracts"; export function DeployedContractsPage(props: { - address: string; + teamId: string; + projectId: string; + authToken: string; }) { return ( -
- -
- }> - - +
+ +
+
+ }> + + +
); } async function DeployedContractsPageAsync(props: { - address: string; + teamId: string; + projectId: string; + authToken: string; }) { const deployedContracts = await getSortedDeployedContracts({ - address: props.address, + teamId: props.teamId, + projectId: props.projectId, + authToken: props.authToken, }); if (deployedContracts.length === 0) { - return ; + return ( + + ); } return ( }> - + ); } diff --git a/apps/dashboard/src/app/account/contracts/_components/DeployedContractsTable.tsx b/apps/dashboard/src/app/account/contracts/_components/DeployedContractsTable.tsx deleted file mode 100644 index 4bd6d3a2ac0..00000000000 --- a/apps/dashboard/src/app/account/contracts/_components/DeployedContractsTable.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; -import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { DeployedContracts } from "components/contract-components/tables/deployed-contracts"; -import type { BasicContract } from "contract-ui/types/types"; - -export function DeployedContractsTable(props: { - contracts: BasicContract[]; -}) { - const router = useDashboardRouter(); - return ( - - ); -} diff --git a/apps/dashboard/src/app/account/contracts/_components/GetStartedWithContractsDeploy.tsx b/apps/dashboard/src/app/account/contracts/_components/GetStartedWithContractsDeploy.tsx index 3a056b7d2b4..66dc62d0a69 100644 --- a/apps/dashboard/src/app/account/contracts/_components/GetStartedWithContractsDeploy.tsx +++ b/apps/dashboard/src/app/account/contracts/_components/GetStartedWithContractsDeploy.tsx @@ -6,18 +6,23 @@ import { StepsCard } from "components/dashboard/StepsCard"; import { useTrack } from "hooks/analytics/useTrack"; import { useMemo, useState } from "react"; -export function GetStartedWithContractsDeploy() { +export function GetStartedWithContractsDeploy(props: { + teamId: string; + projectId: string; +}) { const steps = useMemo( () => [ { title: "Build, deploy or import a contract", description: "Choose between deploying your own contract or import an existing one.", - children: , + children: ( + + ), completed: false, // because we only show this component if the user does not have any contracts }, ], - [], + [props.teamId, props.projectId], ); return ( @@ -38,7 +43,10 @@ type ContentItem = { type TabId = "explore" | "import" | "build" | "deploy"; -const DeployOptions = () => { +const DeployOptions = (props: { + teamId: string; + projectId: string; +}) => { const [showImportModal, setShowImportModal] = useState(false); const router = useDashboardRouter(); const trackEvent = useTrack(); @@ -83,6 +91,8 @@ const DeployOptions = () => { onClose={() => { setShowImportModal(false); }} + teamId={props.teamId} + projectId={props.projectId} /> c.chainId))); @@ -22,11 +26,13 @@ export async function getSortedDeployedContracts(params: { .map((c) => c.value) .filter((c) => c !== null); - const mainnetContracts: BasicContract[] = []; - const testnetContracts: BasicContract[] = []; + const mainnetContracts: ProjectContract[] = []; + const testnetContracts: ProjectContract[] = []; for (const contract of contracts) { - const chain = chains.find((chain) => contract.chainId === chain.chainId); + const chain = chains.find( + (chain) => contract.chainId === chain.chainId.toString(), + ); if (chain && chain.status !== "deprecated") { if (chain.testnet) { testnetContracts.push(contract); @@ -36,13 +42,13 @@ export async function getSortedDeployedContracts(params: { } } - mainnetContracts.sort((a, b) => a.chainId - b.chainId); + mainnetContracts.sort((a, b) => Number(a.chainId) - Number(b.chainId)); if (params.onlyMainnet) { return mainnetContracts; } - testnetContracts.sort((a, b) => a.chainId - b.chainId); + testnetContracts.sort((a, b) => Number(a.chainId) - Number(b.chainId)); return [...mainnetContracts, ...testnetContracts]; } diff --git a/apps/dashboard/src/app/account/contracts/layout.tsx b/apps/dashboard/src/app/account/contracts/layout.tsx deleted file mode 100644 index fc483138d38..00000000000 --- a/apps/dashboard/src/app/account/contracts/layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { SidebarLayout } from "@/components/blocks/SidebarLayout"; - -export default function Layout(props: { - children: React.ReactNode; -}) { - const layoutPath = "/account/contracts"; - - return ( - - {props.children} - - ); -} diff --git a/apps/dashboard/src/app/account/contracts/page.tsx b/apps/dashboard/src/app/account/contracts/page.tsx deleted file mode 100644 index 2132b151126..00000000000 --- a/apps/dashboard/src/app/account/contracts/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { redirect } from "next/navigation"; -import { getAuthTokenWalletAddress } from "../../api/lib/getAuthToken"; -import { DeployedContractsPage } from "./_components/DeployedContractsPage"; - -export default async function Page() { - const accountAddress = await getAuthTokenWalletAddress(); - - if (!accountAddress) { - return redirect(`/login?next=${encodeURIComponent("/account/contracts")}`); - } - - return ; -} diff --git a/apps/dashboard/src/app/account/contracts/published/PublishedContractsPage.tsx b/apps/dashboard/src/app/account/contracts/published/PublishedContractsPage.tsx deleted file mode 100644 index b41c3cb4c68..00000000000 --- a/apps/dashboard/src/app/account/contracts/published/PublishedContractsPage.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage"; -import { Button } from "@/components/ui/button"; -import { PlusIcon } from "lucide-react"; -import Link from "next/link"; -import { Suspense } from "react"; -import { PublishedContracts } from "../../../(dashboard)/profile/[addressOrEns]/components/published-contracts"; -import { resolveAddressAndEns } from "../../../(dashboard)/profile/[addressOrEns]/resolveAddressAndEns"; -import { fetchPublishedContracts } from "../../../../components/contract-components/fetchPublishedContracts"; - -export async function PublishedContractsPage(props: { - publisherAddress: string; -}) { - const resolvedInfo = await resolveAddressAndEns(props.publisherAddress); - - return ( -
-
-
-

- Published contracts -

- -

- The list of contracts published to thirdweb across all networks.{" "} - - Learn more about publishing contracts - -

-
- - -
- -
- - }> - - -
- ); -} - -async function AsyncPublishedContractsTable(props: { - publisherAddress: string; - publisherEnsName: string | undefined; -}) { - const publishedContracts = await fetchPublishedContracts( - props.publisherAddress, - ); - - if (publishedContracts.length === 0) { - return ( -
- No published contracts found -
- ); - } - - return ( - - ); -} diff --git a/apps/dashboard/src/app/account/contracts/published/page.tsx b/apps/dashboard/src/app/account/contracts/published/page.tsx deleted file mode 100644 index 5f2a78b9865..00000000000 --- a/apps/dashboard/src/app/account/contracts/published/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken"; -import { loginRedirect } from "../../../login/loginRedirect"; -import { PublishedContractsPage } from "./PublishedContractsPage"; - -export default async function Page() { - const accountAddress = await getAuthTokenWalletAddress(); - - if (!accountAddress) { - loginRedirect("/account/contracts"); - } - - return ; -} diff --git a/apps/dashboard/src/app/account/layout.tsx b/apps/dashboard/src/app/account/layout.tsx index 7f297e4821e..99fa94126fd 100644 --- a/apps/dashboard/src/app/account/layout.tsx +++ b/apps/dashboard/src/app/account/layout.tsx @@ -4,6 +4,7 @@ import { AppFooter } from "@/components/blocks/app-footer"; import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import type React from "react"; import { TabPathLinks } from "../../@/components/ui/tabs"; +import { getAuthTokenWalletAddress } from "../api/lib/getAuthToken"; import { TWAutoConnect } from "../components/autoconnect"; import { loginRedirect } from "../login/loginRedirect"; import { AccountHeader } from "./components/AccountHeader"; @@ -12,17 +13,24 @@ import { getValidAccount } from "./settings/getAccount"; export default async function AccountLayout(props: { children: React.ReactNode; }) { - const teams = await getTeams(); - const account = await getValidAccount("/account"); + const [teams, account, accountAddress] = await Promise.all([ + getTeams(), + getValidAccount("/account"), + getAuthTokenWalletAddress(), + ]); - if (!teams) { + if (!teams || !accountAddress) { loginRedirect("/account"); } return (
- + {props.children}
@@ -34,6 +42,7 @@ export default async function AccountLayout(props: { async function HeaderAndNav(props: { teams: Team[]; twAccount: Account; + accountAddress: string; }) { const teamsAndProjects = await Promise.all( props.teams.map(async (team) => ({ @@ -47,6 +56,7 @@ async function HeaderAndNav(props: { | undefined; + account: Pick; logout: () => void; connectButton: React.ReactNode; client: ThirdwebClient; + accountAddress: string; }) { return (
@@ -19,6 +20,7 @@ export function SecondaryNav(props: { connectButton={props.connectButton} account={props.account} client={props.client} + accountAddress={props.accountAddress} />
); diff --git a/apps/dashboard/src/app/components/Header/SecondaryNav/account-button.client.tsx b/apps/dashboard/src/app/components/Header/SecondaryNav/account-button.client.tsx index dcfde81ebb8..17f84aa8487 100644 --- a/apps/dashboard/src/app/components/Header/SecondaryNav/account-button.client.tsx +++ b/apps/dashboard/src/app/components/Header/SecondaryNav/account-button.client.tsx @@ -14,14 +14,17 @@ import { useTheme } from "next-themes"; import Link from "next/link"; import { useState } from "react"; import type { ThirdwebClient } from "thirdweb"; +import { useEns } from "../../../../components/contract-components/hooks"; export function AccountButton(props: { logout: () => void; connectButton: React.ReactNode; - account?: Pick; + account: Pick | undefined; client: ThirdwebClient; + accountAddress: string; }) { const { setTheme, theme } = useTheme(); + const ensQuery = useEns(props.accountAddress); const [isOpen, setIsOpen] = useState(false); return ( @@ -81,6 +84,19 @@ export function AccountButton(props: { My Account + + +
+ + }> + + +
+ ); +} + +async function AsyncYourFactories(props: { + teamId: string; + projectId: string; + authToken: string; +}) { + const deployedContracts = await getSortedDeployedContracts({ + teamId: props.teamId, + projectId: props.projectId, + authToken: props.authToken, + }); + + const client = getThirdwebClient(); + + const factories = ( + await Promise.all( + deployedContracts.map(async (c) => { + const contract = getContract({ + // eslint-disable-next-line no-restricted-syntax + chain: defineChain(Number(c.chainId)), + address: c.contractAddress, + client, + }); + const m = await getCompilerMetadata(contract); + return m.name.indexOf("AccountFactory") > -1 ? c : null; + }), + ) + ).filter((f) => f !== null); + + return ( + }> + + + ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/layout.tsx deleted file mode 100644 index 8a05a282fa8..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/layout.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { SidebarLayout } from "@/components/blocks/SidebarLayout"; - -export default async function Layout(props: { - children?: React.ReactNode; - params: Promise<{ team_slug: string; project_slug: string }>; -}) { - const params = await props.params; - const layoutPath = `/team/${params.team_slug}/${params.project_slug}/contracts`; - - return ( - - {props.children} - - ); -} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/page.tsx index 121e6529797..b6f1c60c24c 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/page.tsx @@ -1,16 +1,38 @@ +import { getProject } from "@/api/projects"; +import { getTeamBySlug } from "@/api/team"; +import { redirect } from "next/navigation"; import { DeployedContractsPage } from "../../../../account/contracts/_components/DeployedContractsPage"; -import { getAuthTokenWalletAddress } from "../../../../api/lib/getAuthToken"; +import { getAuthToken } from "../../../../api/lib/getAuthToken"; import { loginRedirect } from "../../../../login/loginRedirect"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { - const accountAddress = await getAuthTokenWalletAddress(); + const params = await props.params; - if (!accountAddress) { - const { team_slug, project_slug } = await props.params; - loginRedirect(`/team/${team_slug}/${project_slug}/contracts`); + const [authToken, team, project] = await Promise.all([ + getAuthToken(), + getTeamBySlug(params.team_slug), + getProject(params.team_slug, params.project_slug), + ]); + + if (!authToken) { + loginRedirect(`/team/${params.team_slug}/${params.project_slug}/contracts`); + } + + if (!team) { + redirect("/team"); + } + + if (!project) { + redirect(`/team/${params.team_slug}`); } - return ; + return ( + + ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/published/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/published/page.tsx deleted file mode 100644 index 3b8c77fa925..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/contracts/published/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { PublishedContractsPage } from "../../../../../account/contracts/published/PublishedContractsPage"; -import { getAuthTokenWalletAddress } from "../../../../../api/lib/getAuthToken"; -import { loginRedirect } from "../../../../../login/loginRedirect"; - -export default async function Page(props: { - params: Promise<{ team_slug: string; project_slug: string }>; -}) { - const accountAddress = await getAuthTokenWalletAddress(); - const params = await props.params; - - if (!accountAddress) { - loginRedirect(`/team/${params.team_slug}/${params.project_slug}/contracts`); - } - - return ; -} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/hooks/project-contracts.ts b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/hooks/project-contracts.ts new file mode 100644 index 00000000000..752aaea8ac5 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/hooks/project-contracts.ts @@ -0,0 +1,64 @@ +"use client"; + +import { apiServerProxy } from "@/actions/proxies"; +import { useMutation } from "@tanstack/react-query"; + +export function useAddContractToProject() { + return useMutation({ + mutationFn: async (params: { + teamId: string; + projectId: string; + contractAddress: string; + chainId: string; + }) => { + const res = await apiServerProxy({ + pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/contracts`, + method: "POST", + body: JSON.stringify({ + contractAddress: params.contractAddress, + chainId: params.chainId, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + console.error(res.error); + throw new Error(res.error); + } + + return res.data as { + result: { + id: string; + projectId: string; + contractAddress: string; + chainId: string; + createdAt: string; + updatedAt: string; + }; + }; + }, + }); +} + +export async function removeContractFromProject(params: { + teamId: string; + projectId: string; + contractId: string; +}) { + const res = await apiServerProxy({ + pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/contracts/${params.contractId}`, + method: "DELETE", + }); + + if (!res.ok) { + throw new Error(res.error); + } + + return res.data as { + result: { + success: boolean; + }; + }; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx index d4bfa3170e2..af6f6ac8027 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx @@ -2,6 +2,7 @@ import { getProjects } from "@/api/projects"; import { getTeams } from "@/api/team"; import { notFound, redirect } from "next/navigation"; import { getValidAccount } from "../../../account/settings/getAccount"; +import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken"; import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client"; import { ProjectTabs } from "./tabs"; @@ -11,12 +12,13 @@ export default async function TeamLayout(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { const params = await props.params; - const [teams, account] = await Promise.all([ + const [accountAddress, teams, account] = await Promise.all([ + getAuthTokenWalletAddress(), getTeams(), getValidAccount(`/team/${params.team_slug}/${params.project_slug}`), ]); - if (!teams) { + if (!teams || !accountAddress) { redirect("/login"); } @@ -53,6 +55,7 @@ export default async function TeamLayout(props: { currentTeam={team} teamsAndProjects={teamsAndProjects} account={account} + accountAddress={accountAddress} /> {}} connectButton={} @@ -69,24 +72,12 @@ function Variants(props: { /> - - {}} - connectButton={} - createProject={() => {}} - client={client} - /> - - ; currentProject: Project | undefined; className?: string; - account: Pick | undefined; + account: Pick; logout: () => void; connectButton: React.ReactNode; createProject: (team: Team) => void; client: ThirdwebClient; + accountAddress: string; }; export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) { @@ -101,6 +102,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) { logout={props.logout} connectButton={props.connectButton} client={props.client} + accountAddress={props.accountAddress} /> ); @@ -180,6 +182,7 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) { email={props.account?.email} logout={props.logout} connectButton={props.connectButton} + accountAddress={props.accountAddress} /> ); diff --git a/apps/dashboard/src/app/team/components/TeamHeader/team-header-logged-in.client.tsx b/apps/dashboard/src/app/team/components/TeamHeader/team-header-logged-in.client.tsx index de0be56d1c3..4ffef4b7de2 100644 --- a/apps/dashboard/src/app/team/components/TeamHeader/team-header-logged-in.client.tsx +++ b/apps/dashboard/src/app/team/components/TeamHeader/team-header-logged-in.client.tsx @@ -21,6 +21,7 @@ export function TeamHeaderLoggedIn(props: { teamsAndProjects: Array<{ team: Team; projects: Project[] }>; currentProject: Project | undefined; account: Pick; + accountAddress: string; }) { const [createProjectDialogState, setCreateProjectDialogState] = useState< { team: Team; isOpen: true } | { isOpen: false } @@ -56,6 +57,7 @@ export function TeamHeaderLoggedIn(props: { }); }, client: getThirdwebClient(), + accountAddress: props.accountAddress, }; return ( diff --git a/apps/dashboard/src/app/team/components/TeamHeader/team-header.tsx b/apps/dashboard/src/app/team/components/TeamHeader/team-header.tsx index b662b528da4..a56cb9b9166 100644 --- a/apps/dashboard/src/app/team/components/TeamHeader/team-header.tsx +++ b/apps/dashboard/src/app/team/components/TeamHeader/team-header.tsx @@ -1,19 +1,18 @@ import { getProjects } from "@/api/projects"; import { getTeams } from "@/api/team"; import { getRawAccount } from "../../../account/settings/getAccount"; +import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken"; import { TeamHeaderLoggedOut } from "./TeamHeaderLoggedOut"; import { TeamHeaderLoggedIn } from "./team-header-logged-in.client"; export async function TeamHeader() { - const account = await getRawAccount(); + const [account, teams, accountAddress] = await Promise.all([ + getRawAccount(), + getTeams(), + getAuthTokenWalletAddress(), + ]); - if (!account) { - return ; - } - - const teams = await getTeams(); - - if (!teams) { + if (!account || !accountAddress || !teams) { return ; } @@ -35,6 +34,7 @@ export async function TeamHeader() { teamsAndProjects={teamsAndProjects} currentProject={undefined} account={account} + accountAddress={accountAddress} /> ); } diff --git a/apps/dashboard/src/components/contract-components/contract-deploy-form/add-to-project-card.stories.tsx b/apps/dashboard/src/components/contract-components/contract-deploy-form/add-to-project-card.stories.tsx new file mode 100644 index 00000000000..ad08d7a1e2b --- /dev/null +++ b/apps/dashboard/src/components/contract-components/contract-deploy-form/add-to-project-card.stories.tsx @@ -0,0 +1,100 @@ +import { getThirdwebClient } from "@/constants/thirdweb.server"; +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { BadgeContainer, mobileViewport } from "../../../stories/utils"; +import { + AddToProjectCardUI, + type MinimalProject, + type MinimalTeam, + type MinimalTeamsAndProjects, + type TeamAndProjectSelection, +} from "./add-to-project-card"; + +const meta = { + title: "DeployContract/AddToProject", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +function teamsAndProjectsStub(teamCount: number, projectCount: number) { + const teamsAndProjects: MinimalTeamsAndProjects = []; + + // generate random names and ids + for (let i = 0; i < teamCount; i++) { + const team: MinimalTeam = { + id: `team_${i + 1}`, + name: `Team ${i + 1}`, + image: `https://picsum.photos/200?random=${i}`, + slug: `team-${i + 1}`, + }; + + const projects: MinimalProject[] = []; + for (let j = 0; j < projectCount; j++) { + projects.push({ + id: `project_${i + 1}_${j + 1}`, + name: `Project ${i + 1}_${j + 1}`, + }); + } + + teamsAndProjects.push({ team, projects }); + } + + return teamsAndProjects; +} + +function Story() { + return ( +
+ + + + +
+ ); +} + +function Variant(props: { + label: string; + teamCount: number; + projectCount: number; +}) { + const [teamsAndProjects] = useState(() => + teamsAndProjectsStub(props.teamCount, props.projectCount), + ); + const [isEnabled, setIsEnabled] = useState(true); + const [selection, setSelection] = useState({ + team: teamsAndProjects[0]?.team, + project: teamsAndProjects[0]?.projects[0], + }); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/components/contract-components/contract-deploy-form/add-to-project-card.tsx b/apps/dashboard/src/components/contract-components/contract-deploy-form/add-to-project-card.tsx new file mode 100644 index 00000000000..aab2b9134bf --- /dev/null +++ b/apps/dashboard/src/components/contract-components/contract-deploy-form/add-to-project-card.tsx @@ -0,0 +1,219 @@ +"use client"; + +import type { Project } from "@/api/projects"; +import type { Team } from "@/api/team"; +import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar"; +import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { CircleAlertIcon, ExternalLinkIcon } from "lucide-react"; +import type { ThirdwebClient } from "thirdweb"; +import { Fieldset } from "./common"; + +export type MinimalTeam = Pick; +export type MinimalProject = Pick; // TODO: add image when project has image + +export type TeamAndProjectSelection = { + team: MinimalTeam | undefined; + project: MinimalProject | undefined; +}; + +export type MinimalTeamsAndProjects = Array<{ + team: MinimalTeam; + projects: MinimalProject[]; +}>; + +type TeamAndProjectSelectorProps = { + teamsAndProjects: MinimalTeamsAndProjects; + selection: TeamAndProjectSelection; + enabled: boolean; + onSelectionChange: (selection: TeamAndProjectSelection) => void; + client: ThirdwebClient; + onSetEnabled: (enabled: boolean) => void; +}; + +export function AddToProjectCardUI(props: TeamAndProjectSelectorProps) { + const selectedTeam = props.teamsAndProjects.find( + (t) => t.team.id === props.selection.team?.id, + ); + + const isProjectSelected = props.selection.project && props.enabled; + + return ( +
+ { + props.onSetEnabled(!!checked); + }} + /> +
+ } + > +
+ {/* Team & Project selectors */} + {props.enabled && ( + + )} + + {/* No projects alert */} + {selectedTeam?.projects.length === 0 && ( + + + Selected team has no projects + + + Create Project + + + + )} + + {/* No project selected alert */} + {!isProjectSelected && ( + + + No Project Selected + + Deployed contract will not be saved in thirdweb dashboard + + + )} +
+ + ); +} + +function SlashSeparator() { + return
; +} + +export function AddToProjectSelector(props: { + selection: TeamAndProjectSelection; + onSelectionChange: (selection: TeamAndProjectSelection) => void; + teamsAndProjects: MinimalTeamsAndProjects; + client: ThirdwebClient; +}) { + const selectedTeam = props.teamsAndProjects.find( + (t) => t.team.id === props.selection.team?.id, + ); + + return ( +
+ {/* Team */} +
+ + +
+ + {/* Slash */} +
+ +
+ + {/* Project */} +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/components/contract-components/contract-deploy-form/common.tsx b/apps/dashboard/src/components/contract-components/contract-deploy-form/common.tsx index 47b6487473a..b6c83181a72 100644 --- a/apps/dashboard/src/components/contract-components/contract-deploy-form/common.tsx +++ b/apps/dashboard/src/components/contract-components/contract-deploy-form/common.tsx @@ -1,13 +1,29 @@ +import { cn } from "@/lib/utils"; + export function Fieldset(props: { legend: string; + description?: React.ReactNode; + headerChildren?: React.ReactNode; children: React.ReactNode; + headerClassName?: string; }) { return ( -
-
+
+
{props.legend} + {props.description && ( +

+ {props.description} +

+ )} + {props.headerChildren}
{props.children}
diff --git a/apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx b/apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx index c2569ed9ef8..5fdf251b826 100644 --- a/apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx +++ b/apps/dashboard/src/components/contract-components/contract-deploy-form/custom-contract.tsx @@ -9,7 +9,6 @@ import { Alert, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; import { ToolTipLabel } from "@/components/ui/tooltip"; -import { TrackedLinkTW } from "@/components/ui/tracked-link"; import { useThirdwebClient } from "@/constants/thirdweb.client"; import { Flex, FormControl } from "@chakra-ui/react"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -27,7 +26,7 @@ import { InfoIcon, } from "lucide-react"; import Link from "next/link"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { FormProvider, type UseFormReturn, useForm } from "react-hook-form"; import { ZERO_ADDRESS, getContract } from "thirdweb"; import type { FetchDeployMetadataResult } from "thirdweb/contract"; @@ -40,8 +39,12 @@ import { useActiveAccount, useActiveWalletChain } from "thirdweb/react"; import { upload } from "thirdweb/storage"; import { isZkSyncChain } from "thirdweb/utils"; import { FormHelperText, FormLabel, Text } from "tw-components"; +import { useAddContractToProject } from "../../../app/team/[team_slug]/[project_slug]/hooks/project-contracts"; import { useCustomFactoryAbi, useFunctionParamsFromABI } from "../hooks"; -import { addContractToMultiChainRegistry } from "../utils"; +import { + AddToProjectCardUI, + type MinimalTeamsAndProjects, +} from "./add-to-project-card"; import { Fieldset } from "./common"; import { ContractMetadataFieldset } from "./contract-metadata-fieldset"; import { @@ -66,10 +69,10 @@ interface CustomContractFormProps { metadata: FetchDeployMetadataResult; jwt: string; modules?: FetchDeployMetadataResult[]; + teamsAndProjects: MinimalTeamsAndProjects; } type CustomContractDeploymentFormData = { - addToDashboard: boolean; deployDeterministic: boolean; saltForCreate2: string; signerAsSalt: boolean; @@ -137,9 +140,16 @@ export const CustomContractForm: React.FC = ({ metadata, modules, jwt, + teamsAndProjects, }) => { const thirdwebClient = useThirdwebClient(jwt); + const [isImportEnabled, setIsImportEnabled] = useState(true); + const [importSelection, setImportSelection] = useState({ + team: teamsAndProjects[0]?.team, + project: teamsAndProjects[0]?.projects[0], + }); + const activeAccount = useActiveAccount(); const walletChain = useActiveWalletChain(); const { onError } = useTxNotifications( @@ -243,7 +253,6 @@ export const CustomContractForm: React.FC = ({ const transformedQueryData = useMemo( () => ({ - addToDashboard: true, deployDeterministic: isAccountFactory, saltForCreate2: "", signerAsSalt: true, @@ -521,6 +530,8 @@ export const CustomContractForm: React.FC = ({ const shouldShowDeterministicDeployWarning = constructorParams.length > 0 && form.watch("deployDeterministic"); + const addContractToProjectMutation = useAddContractToProject(); + return ( <> @@ -537,22 +548,12 @@ export const CustomContractForm: React.FC = ({ } // open the status modal - let steps: DeployModalStep[] = [ + const steps: DeployModalStep[] = [ { type: "deploy", signatureCount: deployTransactions.data?.length || 1, }, ]; - // if the add to dashboard is checked add that step - if (formData.addToDashboard) { - steps = [ - ...steps, - { - type: "import", - signatureCount: 1, - }, - ]; - } const publisherAnalyticsData = metadata.publisher ? { @@ -594,32 +595,24 @@ export const CustomContractForm: React.FC = ({ metadataUri: metadata.metadataUri, }); deployStatusModal.nextStep(); - // if add to dashboard is checked, add the contract to the dashboard - if (formData.addToDashboard) { - // add the contract to the dashboard - await addContractToMultiChainRegistry( - { - address: contractAddr, - chainId: walletChain.id, - }, - activeAccount, - 300000n, - ); - trackEvent({ - category: "custom-contract", - action: "add-to-dashboard", - label: "success", - ...publisherAnalyticsData, - contractAddress: contractAddr, - chainId: walletChain.id, - metadataUri: metadata.metadataUri, - }); - deployStatusModal.nextStep(); - } - deployStatusModal.setViewContractLink( `/${walletChain.id}/${contractAddr}`, ); + + // if the contract should be added to a project + if ( + importSelection.team && + importSelection.project && + isImportEnabled + ) { + // no await - do it in the background + addContractToProjectMutation.mutateAsync({ + chainId: walletChain.id.toString(), + contractAddress: contractAddr, + projectId: importSelection.project.id, + teamId: importSelection.team.id, + }); + } } catch (e) { onError(e); console.error("failed to deploy contract", e); @@ -841,6 +834,15 @@ export const CustomContractForm: React.FC = ({ )} + +
{/* Chain */} @@ -944,29 +946,6 @@ export const CustomContractForm: React.FC = ({ )} - {/* Import Enable/Disable */} - - - form.setValue("addToDashboard", !!checked) - } - /> - - Import so I can find it in the list of{" "} - - my contracts - - - - {/* Deploy */}
-
@@ -128,7 +128,7 @@ function RenderDeployModalStep(props: DeployModalStepProps) { const { isActive, hasCompleted } = props; const { title, description } = getStepInfo(props.step); return ( -
+
{isActive ? ( - + ) : hasCompleted ? ( ) : ( @@ -185,21 +185,5 @@ function getStepInfo(step: DeployModalStep): TitleAndDesc { ), }; } - - case "import": { - return { - title: "Adding to dashboard", - description: ( - <> - {step.signatureCount > 0 - ? "Your wallet will prompt you to sign the transaction. " - : ""} - - This step is gasless. - - - ), - }; - } } } diff --git a/apps/dashboard/src/components/contract-components/import-contract/modal.tsx b/apps/dashboard/src/components/contract-components/import-contract/modal.tsx index aa419a912ea..dc7959767aa 100644 --- a/apps/dashboard/src/components/contract-components/import-contract/modal.tsx +++ b/apps/dashboard/src/components/contract-components/import-contract/modal.tsx @@ -21,23 +21,22 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { - useAddContractMutation, - useAllContractList, -} from "@3rdweb-sdk/react/hooks/useRegistry"; import { zodResolver } from "@hookform/resolvers/zod"; import { useChainSlug } from "hooks/chains/chainSlug"; -import { PlusIcon } from "lucide-react"; -import { useState } from "react"; +import { ExternalLinkIcon, PlusIcon } from "lucide-react"; +import Link from "next/link"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { getAddress, isAddress } from "thirdweb"; -import { useActiveAccount, useActiveWalletChain } from "thirdweb/react"; +import { useActiveWalletChain } from "thirdweb/react"; import { z } from "zod"; +import { useAddContractToProject } from "../../../app/team/[team_slug]/[project_slug]/hooks/project-contracts"; type ImportModalProps = { isOpen: boolean; onClose: () => void; + teamId: string; + projectId: string; }; export const ImportModal: React.FC = (props) => { @@ -64,7 +63,7 @@ export const ImportModal: React.FC = (props) => { - + ); @@ -86,10 +85,12 @@ const importFormSchema = z.object({ chainId: z.coerce.number(), }); -function ImportForm() { +function ImportForm(props: { + teamId: string; + projectId: string; +}) { const router = useDashboardRouter(); const activeChainId = useActiveWalletChain()?.id; - const [isRedirecting, setIsRedirecting] = useState(false); const form = useForm({ resolver: zodResolver(importFormSchema), @@ -99,15 +100,7 @@ function ImportForm() { }, }); const chainSlug = useChainSlug(form.watch("chainId")); - const addToDashboard = useAddContractMutation(); - const address = useActiveAccount()?.address; - const registry = useAllContractList(address); - - const showLoading = - form.formState.isSubmitting || - addToDashboard.isPending || - addToDashboard.isSuccess || - isRedirecting; + const addContractToProject = useAddContractToProject(); return (
@@ -148,35 +141,25 @@ function ImportForm() { return; } - const isInRegistry = - registry.isFetched && - registry.data?.find( - (c) => - contractAddress && - // compare address... - c.address.toLowerCase() === contractAddress.toLowerCase() && - // ... and chainId - c.chainId === chainId, - ) && - registry.isSuccess; - - if (isInRegistry) { - router.push(`/${chainSlug}/${contractAddress}`); - setIsRedirecting(true); - return; - } - - addToDashboard.mutate( + addContractToProject.mutate( { contractAddress, - chainId, + chainId: chainId.toString(), + teamId: props.teamId, + projectId: props.projectId, }, { onSuccess: () => { - router.push(`/${chainSlug}/${contractAddress}`); + router.refresh(); + toast.success("Contract imported successfully"); }, onError: (err) => { console.error(err); + if (err.message.includes("PROJECT_CONTRACT_ALREADY_EXISTS")) { + toast.error("Contract is already added to the project"); + } else { + toast.error("Failed to import contract"); + } }, }, ); @@ -213,19 +196,29 @@ function ImportForm() {
- + {addContractToProject.isSuccess && + addContractToProject.data?.result ? ( + + ) : ( + + )}
diff --git a/apps/dashboard/src/components/contract-components/publisher/publisher-header.tsx b/apps/dashboard/src/components/contract-components/publisher/publisher-header.tsx index ce775564c3d..a05460d7416 100644 --- a/apps/dashboard/src/components/contract-components/publisher/publisher-header.tsx +++ b/apps/dashboard/src/components/contract-components/publisher/publisher-header.tsx @@ -41,7 +41,7 @@ export const PublisherHeader: React.FC = ({ wallet }) => { fallbackComponent={ } - loadingComponent={} + loadingComponent={} className="size-10 rounded-full border border-border border-solid object-cover" /> diff --git a/apps/dashboard/src/components/contract-components/tables/cells.tsx b/apps/dashboard/src/components/contract-components/tables/cells.tsx index ea0a637f487..41dac59fa77 100644 --- a/apps/dashboard/src/components/contract-components/tables/cells.tsx +++ b/apps/dashboard/src/components/contract-components/tables/cells.tsx @@ -5,7 +5,6 @@ import { SkeletonContainer } from "@/components/ui/skeleton"; import { useThirdwebClient } from "@/constants/thirdweb.client"; import { cn } from "@/lib/utils"; import { useDashboardContractMetadata } from "@3rdweb-sdk/react/hooks/useDashboardContractMetadata"; -import type { BasicContract } from "contract-ui/types/types"; import { useChainSlug } from "hooks/chains/chainSlug"; import { useV5DashboardChain } from "lib/v5-adapter"; import Link from "next/link"; @@ -15,123 +14,111 @@ import { shortenIfAddress } from "utils/usedapp-external"; import { THIRDWEB_DEPLOYER_ADDRESS } from "../../../constants/addresses"; import { usePublishedContractsFromDeploy } from "../hooks"; -interface AsyncContractNameCellProps { - cell: BasicContract; +export const ContractNameCell = memo(function ContractNameCell(props: { + chainId: string; + contractAddress: string; linkOverlay?: boolean; -} - -// The row components for the contract table, in the page - -export const AsyncContractNameCell = memo( - ({ cell, linkOverlay }: AsyncContractNameCellProps) => { - const chainSlug = useChainSlug(cell.chainId); - const chain = useV5DashboardChain(cell.chainId); - const client = useThirdwebClient(); - - const contract = getContract({ - client, - address: cell.address, - chain, +}) { + const chainSlug = useChainSlug(Number(props.chainId)); + const chain = useV5DashboardChain(Number(props.chainId)); + const client = useThirdwebClient(); + + const contract = getContract({ + client, + address: props.contractAddress, + chain, + }); + + const contractMetadata = useDashboardContractMetadata(contract); + + return ( + { + return ( + + {v || shortenIfAddress(props.contractAddress)} + + ); + }} + /> + ); +}); + +export const ContractTypeCell = memo(function ContractTypeCell(props: { + chainId: string; + contractAddress: string; +}) { + const client = useThirdwebClient(); + const chain = useV5DashboardChain(Number(props.chainId)); + const contract = getContract({ + client, + address: props.contractAddress, + chain, + }); + + const publishedContractsFromDeployQuery = + usePublishedContractsFromDeploy(contract); + + const publishedContractsFromDeployOriginal = + publishedContractsFromDeployQuery.data || []; + + const publishedContractsFromDeploySorted = [ + ...publishedContractsFromDeployOriginal, + ] + // latest first + .reverse() + // prioritize showing the publisher === thirdweb + .sort((a, b) => { + const aIsTWPublisher = a.publisher === THIRDWEB_DEPLOYER_ADDRESS; + const bIsTWPublisher = b.publisher === THIRDWEB_DEPLOYER_ADDRESS; + if (aIsTWPublisher && !bIsTWPublisher) { + return -1; + } + if (!aIsTWPublisher && bIsTWPublisher) { + return 1; + } + return 0; }); - const contractMetadata = useDashboardContractMetadata(contract); + const contractMetadata = useDashboardContractMetadata(contract); - return ( - { - return ( - - {v || shortenIfAddress(cell.address)} - - ); - }} - /> - ); - }, -); - -AsyncContractNameCell.displayName = "AsyncContractNameCell"; - -interface AsyncContractTypeCellProps { - cell: BasicContract; -} - -export const AsyncContractTypeCell = memo( - ({ cell }: AsyncContractTypeCellProps) => { - const client = useThirdwebClient(); - const chain = useV5DashboardChain(cell.chainId); - const contract = getContract({ - client, - address: cell.address, - chain, - }); - - const publishedContractsFromDeployQuery = - usePublishedContractsFromDeploy(contract); - - const publishedContractsFromDeployOriginal = - publishedContractsFromDeployQuery.data || []; - - const publishedContractsFromDeploySorted = [ - ...publishedContractsFromDeployOriginal, - ] - // latest first - .reverse() - // prioritize showing the publisher === thirdweb - .sort((a, b) => { - const aIsTWPublisher = a.publisher === THIRDWEB_DEPLOYER_ADDRESS; - const bIsTWPublisher = b.publisher === THIRDWEB_DEPLOYER_ADDRESS; - if (aIsTWPublisher && !bIsTWPublisher) { - return -1; - } - if (!aIsTWPublisher && bIsTWPublisher) { - return 1; - } - return 0; - }); - - const contractMetadata = useDashboardContractMetadata(contract); - - const contractType = - publishedContractsFromDeploySorted[0]?.displayName || - publishedContractsFromDeploySorted[0]?.name; - - return ( - { - if (v === contractType) { - return ( - - {v} - - ); - } + const contractType = + publishedContractsFromDeploySorted[0]?.displayName || + publishedContractsFromDeploySorted[0]?.name; + return ( + { + if (v === contractType) { return ( - - {v || "Custom"} + + {v} ); - }} - /> - ); - }, -); + } -AsyncContractTypeCell.displayName = "AsyncContractTypeCell"; + return ( + + {v || "Custom"} + + ); + }} + /> + ); +}); diff --git a/apps/dashboard/src/components/contract-components/tables/contract-table.stories.tsx b/apps/dashboard/src/components/contract-components/tables/contract-table.stories.tsx new file mode 100644 index 00000000000..197c865052d --- /dev/null +++ b/apps/dashboard/src/components/contract-components/tables/contract-table.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ThirdwebProvider } from "thirdweb/react"; +import type { ProjectContract } from "../../../app/account/contracts/_components/getProjectContracts"; +import { BadgeContainer, mobileViewport } from "../../../stories/utils"; +import { ContractTableUI } from "./contract-table"; + +const meta = { + title: "Contracts/ContractTable", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +const popularPolygonNFTs: ProjectContract[] = [ + projectContractStub("137", "0x83a5564378839eef0721bc68a0fbeb92e2de73d2"), + projectContractStub("137", "0x9e8ea82e76262e957d4cc24e04857a34b0d8f062"), + projectContractStub("137", "0xc93c53de60d1a28df01e41f5bc04619039d2ef4f"), + projectContractStub("137", "0x4d544035500d7ac1b42329c70eb58e77f8249f0f"), + projectContractStub("137", "0x77bd275ff2b3dc007475aac9ce7f408f5a800188"), +]; + +const EthereumPopularNFTs: ProjectContract[] = [ + projectContractStub("1", "0xed5af388653567af2f388e6224dc7c4b3241c544"), + projectContractStub("1", "0x5af0d9827e0c53e4799bb226655a1de152a425a5"), + projectContractStub("1", "0xbd3531da5cf5857e7cfaa92426877b022e612cf8"), + projectContractStub("1", "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d"), + projectContractStub("1", "0x9830b32f7210f0857a859c2a86387e4d1bb760b8"), +]; + +const basePopularTokens: ProjectContract[] = [ + projectContractStub("8453", "0xC73e76Aa9F14C1837CDB49bd028E8Ff5a0a71dAD"), + projectContractStub("8453", "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c"), + projectContractStub("8453", "0x12418783e860997eb99e8aCf682DF952F721cF62"), + projectContractStub("8453", "0xdca716b7360b76383e8f7b82aefcbe632fc381af"), + projectContractStub("8453", "0x2C8C89C442436CC6C0a77943E09c8Daf49Da3161"), +]; + +function Story() { + const removeContractStub = async (contractId: string) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("Removed contract", contractId); + }; + + return ( + +
+ + + + + + + + + + + + + + + + + + + +
+
+ ); +} + +function projectContractStub( + chainId: string, + contractAddress: string, +): ProjectContract { + return { + chainId, + contractAddress, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + id: `${chainId}-${contractAddress}`, + }; +} diff --git a/apps/dashboard/src/components/contract-components/tables/contract-table.tsx b/apps/dashboard/src/components/contract-components/tables/contract-table.tsx new file mode 100644 index 00000000000..e3de6027ed4 --- /dev/null +++ b/apps/dashboard/src/components/contract-components/tables/contract-table.tsx @@ -0,0 +1,339 @@ +"use client"; + +import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useMutation } from "@tanstack/react-query"; +import { ChainIcon } from "components/icons/ChainIcon"; +import { NetworkSelectDropdown } from "components/selects/NetworkSelectDropdown"; +import { EllipsisVerticalIcon, XIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import React from "react"; +import { toast } from "sonner"; +import { PaginationButtons } from "../../../@/components/pagination-buttons"; +import { cn } from "../../../@/lib/utils"; +import type { ProjectContract } from "../../../app/account/contracts/_components/getProjectContracts"; +import { removeContractFromProject } from "../../../app/team/[team_slug]/[project_slug]/hooks/project-contracts"; +import { useAllChainsData } from "../../../hooks/chains/allChains"; +import { ContractNameCell, ContractTypeCell } from "./cells"; + +type ContractTableFilters = { + chainId: number | undefined; +}; + +export function ContractTable(props: { + contracts: ProjectContract[]; + pageSize: number; + teamId: string; + projectId: string; +}) { + return ( + { + await removeContractFromProject({ + teamId: props.teamId, + projectId: props.projectId, + contractId, + }); + }} + /> + ); +} + +export function ContractTableUI(props: { + contracts: ProjectContract[]; + pageSize: number; + removeContractFromProject: (contractId: string) => Promise; +}) { + // instantly update the table without waiting for router refresh by adding deleted contract ids to the state + const [deletedContractIds, setDeletedContractIds] = useState([]); + + const contracts = useMemo(() => { + return props.contracts.filter( + (contract) => !deletedContractIds.includes(contract.id), + ); + }, [props.contracts, deletedContractIds]); + + const uniqueChainIds = useMemo(() => { + const set = new Set(); + for (const contract of contracts) { + set.add(Number(contract.chainId)); + } + return [...set]; + }, [contracts]); + + const [page, setPage] = useState(1); + const [filters, _setFilters] = useState({ + chainId: undefined, + }); + + const setFilters = useCallback((filters: ContractTableFilters) => { + setPage(1); + _setFilters(filters); + }, []); + + const filteredContracts = useMemo(() => { + if (filters.chainId) { + return contracts.filter( + (contract) => Number(contract.chainId) === filters.chainId, + ); + } + return contracts; + }, [contracts, filters.chainId]); + + const totalCount = filteredContracts.length; + const showPagination = totalCount > props.pageSize; + const totalPages = Math.ceil(totalCount / props.pageSize); + + const paginatedContracts = filteredContracts.slice( + (page - 1) * props.pageSize, + page * props.pageSize, + ); + + // when user deletes the last contract for a chain and that chain is set as filter - remove chain filter + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (filters.chainId && filteredContracts.length === 0) { + setFilters({ + ...filters, + chainId: undefined, + }); + } + }, [filters, filteredContracts, setFilters]); + + return ( +
+ + + + + Name + Type + + { + setFilters({ + ...filters, + chainId: chainId ? Number(chainId) : undefined, + }); + }} + /> + + Contract Address + Actions + + + + + {paginatedContracts.map((contract) => { + return ( + + + + + + + + + + + + + + + + + + + { + setDeletedContractIds((v) => { + return [...v, contract.id]; + }); + }} + /> + + + ); + })} + +
+ + {contracts.length === 0 && ( +
+ No Contracts +
+ )} +
+ + {showPagination && ( +
+ +
+ )} +
+ ); +} + +const NetworkFilterCell = React.memo(function NetworkFilterCell({ + chainId: selectedChain, + setChainId: setSelectedChain, + chainIds, +}: { + chainId: string | undefined; + setChainId: (chainId: string | undefined) => void; + chainIds: number[]; +}) { + if (chainIds.length < 2) { + return <> NETWORK ; + } + + return ( + setSelectedChain(chain)} + selectedChain={selectedChain} + /> + ); +}); + +const ChainNameCell = React.memo(function ChainNameCell(props: { + chainId: string; +}) { + const { idToChain } = useAllChainsData(); + const data = idToChain.get(Number(props.chainId)); + const cleanedChainName = + data?.name?.replace("Mainnet", "").trim() || + `Unknown Network (#${props.chainId})`; + return ( +
+ + { + return

{v}

; + }} + /> + + {data?.testnet && ( + + Testnet + + )} +
+ ); +}); + +const ContractActionsCell = React.memo(function ContractActionsCell(props: { + contractId: string; + removeContractFromProject: (contractId: string) => Promise; + onContractRemoved: () => void; +}) { + return ( + + + + + + e.stopPropagation()}> + + + + ); +}); + +function RemoveContractButton(props: { + removeContractFromProject: (contractId: string) => Promise; + onContractRemoved?: () => void; + contractId: string; +}) { + const removeMuation = useMutation({ + mutationFn: props.removeContractFromProject, + }); + + return ( + + ); +} diff --git a/apps/dashboard/src/components/contract-components/tables/deployed-contracts.tsx b/apps/dashboard/src/components/contract-components/tables/deployed-contracts.tsx deleted file mode 100644 index 74aba64d490..00000000000 --- a/apps/dashboard/src/components/contract-components/tables/deployed-contracts.tsx +++ /dev/null @@ -1,380 +0,0 @@ -"use client"; - -import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { SkeletonContainer } from "@/components/ui/skeleton"; -import { - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - type useAllContractList, - useRemoveContractMutation, -} from "@3rdweb-sdk/react/hooks/useRegistry"; -import { ChainIcon } from "components/icons/ChainIcon"; -import { NetworkSelectDropdown } from "components/selects/NetworkSelectDropdown"; -import type { BasicContract } from "contract-ui/types/types"; -import { EllipsisVerticalIcon, XIcon } from "lucide-react"; -import { memo, useEffect, useMemo, useState } from "react"; -import { - type Column, - type ColumnInstance, - type Row, - useFilters, - usePagination, - useTable, -} from "react-table"; -import { useAllChainsData } from "../../../hooks/chains/allChains"; -import { AsyncContractNameCell, AsyncContractTypeCell } from "./cells"; -import { ShowMoreButton } from "./show-more-button"; - -interface DeployedContractsProps { - contractList: ReturnType["data"]; - limit?: number; - onContractRemoved?: () => void; -} - -export const DeployedContracts: React.FC = ({ - contractList, - limit = 10, - onContractRemoved, -}) => { - const chainIdsWithDeployments = useMemo(() => { - const set = new Set(); - // biome-ignore lint/complexity/noForEach: FIXME - contractList.forEach((contract) => { - set.add(contract.chainId); - }); - return [...set]; - }, [contractList]); - - return ( -
- - - {contractList.length === 0 && ( -
- No contracts found -
- )} -
- ); -}; - -type RemoveFromDashboardButtonProps = { - chainId: number; - contractAddress: string; - onContractRemoved?: () => void; -}; - -const RemoveFromDashboardButton: React.FC = ({ - chainId, - contractAddress, - onContractRemoved, -}) => { - const mutation = useRemoveContractMutation(); - - return ( - - ); -}; - -type SelectNetworkFilterProps = { - column: ColumnInstance; - chainIdsWithDeployments: number[]; -}; - -// This is a custom filter UI for selecting from a list of chains that the user deployed to -function SelectNetworkFilter({ - column: { setFilter, filterValue }, - chainIdsWithDeployments, -}: SelectNetworkFilterProps) { - if (chainIdsWithDeployments.length < 2) { - return <> NETWORK ; - } - return ( - { - setFilter(selectedChain); - }} - selectedChain={filterValue as undefined | string} - /> - ); -} - -interface ContractTableProps { - combinedList: BasicContract[]; - limit: number; - chainIdsWithDeployments: number[]; - onContractRemoved?: () => void; -} - -const ContractTable: React.FC = ({ - combinedList, - limit, - chainIdsWithDeployments, - onContractRemoved, -}) => { - const { idToChain } = useAllChainsData(); - - const columns: Column<(typeof combinedList)[number]>[] = useMemo( - () => [ - { - Header: "Name", - accessor: (row) => row.address, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - Cell: (cell: any) => { - return ; - }, - }, - { - Header: "Type", - accessor: (row) => row.address, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - Cell: (cell: any) => , - }, - { - // No header, show filter instead - Header: () => null, - id: "Network", - accessor: (row) => row.chainId, - Filter: (props) => ( - - ), - filter: "equals", - // biome-ignore lint/suspicious/noExplicitAny: FIXME - Cell: (cell: any) => { - const data = idToChain.get(cell.row.original.chainId); - const cleanedChainName = - data?.name?.replace("Mainnet", "").trim() || - `Unknown Network (#${cell.row.original.chainId})`; - return ( -
- - { - return

{v}

; - }} - /> - - {data?.testnet && ( - - Testnet - - )} -
- ); - }, - }, - { - Header: "Contract Address", - accessor: (row) => row.address, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - Cell: (cell: any) => { - return ( - - ); - }, - }, - { - id: "actions", - // biome-ignore lint/suspicious/noExplicitAny: FIXME - Cell: (cell: any) => { - return ( - - - - - - e.stopPropagation()} - className="bg-background" - > - - - - ); - }, - }, - ], - [chainIdsWithDeployments, idToChain, onContractRemoved], - ); - - const defaultColumn = useMemo( - () => ({ - Filter: "", - }), - [], - ); - - const { - getTableProps, - getTableBodyProps, - headerGroups, - prepareRow, - page, - canNextPage, - setPageSize, - state: { pageSize }, - } = useTable( - { - columns, - data: combinedList, - defaultColumn, - }, - // these will be removed with the @tanstack/react-table v8 version - // eslint-disable-next-line react-compiler/react-compiler - useFilters, - // eslint-disable-next-line react-compiler/react-compiler - usePagination, - ); - - // the ShowMoreButton component callback sets this state variable - const [numRowsOnPage, setNumRowsOnPage] = useState(limit); - // when the state variable is updated, update the page size - - // FIXME: re-work tables and pagination with @tanstack/table@latest - which (I believe) does not need this workaround anymore - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - setPageSize(numRowsOnPage); - }, [numRowsOnPage, setPageSize]); - - return ( - - - - {headerGroups.map((headerGroup, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: FIXME - - {headerGroup.headers.map((column, i) => ( - -

- {column.render("Header")} - - {column.canFilter ? column.render("Filter") : null} - -

-
- ))} -
- ))} -
- - - {page.map((row) => { - prepareRow(row); - return ( - - ); - })} - -
- {canNextPage && ( - - )} -
- ); -}; - -const ContractTableRow = memo(({ row }: { row: Row }) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { key, ...rowProps } = row.getRowProps(); - - return ( - - {row.cells.map((cell, cellIndex) => { - return ( - - {cell.render("Cell")} - - ); - })} - - ); -}); - -ContractTableRow.displayName = "ContractTableRow"; diff --git a/apps/dashboard/src/components/contract-components/utils.ts b/apps/dashboard/src/components/contract-components/utils.ts deleted file mode 100644 index a4fbcd7bcaf..00000000000 --- a/apps/dashboard/src/components/contract-components/utils.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { MULTICHAIN_REGISTRY_CONTRACT } from "constants/contracts"; -import { - DASHBOARD_ENGINE_RELAYER_URL, - DASHBOARD_FORWARDER_ADDRESS, -} from "constants/misc"; -import { prepareContractCall, sendAndConfirmTransaction } from "thirdweb"; -import type { Account } from "thirdweb/wallets"; - -type ContractInput = { - address: string; - chainId: number; -}; - -type AddContractInput = ContractInput & { - metadataURI?: string; -}; - -export async function addContractToMultiChainRegistry( - contractData: AddContractInput, - account: Account, - gasOverride?: bigint, -) { - const transaction = prepareContractCall({ - contract: MULTICHAIN_REGISTRY_CONTRACT, - method: "function add(address, address, uint256, string)", - params: [ - account.address, - contractData.address, - BigInt(contractData.chainId), - contractData.metadataURI || "", - ], - }); - - await sendAndConfirmTransaction({ - transaction: { - ...transaction, - gas: gasOverride || transaction.gas, - }, - account, - gasless: { - experimentalChainlessSupport: true, - provider: "engine", - relayerUrl: DASHBOARD_ENGINE_RELAYER_URL, - relayerForwarderAddress: DASHBOARD_FORWARDER_ADDRESS, - }, - }); -} diff --git a/apps/dashboard/src/components/notices/AnnouncementBanner.tsx b/apps/dashboard/src/components/notices/AnnouncementBanner.tsx index 47b7ae08de8..8bedc8dbf56 100644 --- a/apps/dashboard/src/components/notices/AnnouncementBanner.tsx +++ b/apps/dashboard/src/components/notices/AnnouncementBanner.tsx @@ -1,9 +1,8 @@ "use client"; import { Button } from "@/components/ui/button"; import { TrackedLinkTW } from "@/components/ui/tracked-link"; -import { isAfter } from "date-fns"; import { useLocalStorage } from "hooks/useLocalStorage"; -import { ChevronRightIcon, XIcon } from "lucide-react"; +import { CircleAlertIcon, XIcon } from "lucide-react"; import { useSelectedLayoutSegment } from "next/navigation"; export function AnnouncementBanner(props: { @@ -25,24 +24,18 @@ export function AnnouncementBanner(props: { } return ( -
+
- + + {props.label} - @@ -58,19 +51,12 @@ export function AnnouncementBanner(props: { ); } -export function UnlimitedWalletsBanner() { - // hide banner after 31st December 2024 - const shouldHideBanner = isAfter(new Date(), new Date("31 Dec 2024")); - - if (shouldHideBanner) { - return null; - } - +export function OrganizeContractsToProjectsBanner() { return ( ); } diff --git a/apps/dashboard/src/components/selects/NetworkSelectDropdown.tsx b/apps/dashboard/src/components/selects/NetworkSelectDropdown.tsx index 55c59002a28..aa7e3bce4f0 100644 --- a/apps/dashboard/src/components/selects/NetworkSelectDropdown.tsx +++ b/apps/dashboard/src/components/selects/NetworkSelectDropdown.tsx @@ -61,13 +61,17 @@ export const NetworkSelectDropdown: React.FC = ({ - +
All Networks
+ {chains.map((chain) => (
diff --git a/apps/dashboard/src/components/smart-wallets/AccountFactories/account-cell.tsx b/apps/dashboard/src/components/smart-wallets/AccountFactories/account-cell.tsx index 6ae897e4c2e..19f3c36562b 100644 --- a/apps/dashboard/src/components/smart-wallets/AccountFactories/account-cell.tsx +++ b/apps/dashboard/src/components/smart-wallets/AccountFactories/account-cell.tsx @@ -2,17 +2,12 @@ import { SkeletonContainer } from "@/components/ui/skeleton"; import { useThirdwebClient } from "@/constants/thirdweb.client"; -import type { BasicContract } from "contract-ui/types/types"; import { memo } from "react"; import { getContract } from "thirdweb"; import { getAllAccounts } from "thirdweb/extensions/erc4337"; import { useReadContract } from "thirdweb/react"; import { useV5DashboardChain } from "../../../lib/v5-adapter"; -interface AsyncFactoryAccountCellProps { - cell: BasicContract; -} - function useAccountCount(address: string, chainId: number) { const chain = useV5DashboardChain(chainId); const client = useThirdwebClient(); @@ -30,19 +25,21 @@ function useAccountCount(address: string, chainId: number) { }); } -export const AsyncFactoryAccountCell = memo( - ({ cell }: AsyncFactoryAccountCellProps) => { - const accountsQuery = useAccountCount(cell.address, cell.chainId); - return ( - { - return {v}; - }} - /> - ); - }, -); - -AsyncFactoryAccountCell.displayName = "AsyncFactoryAccountCell"; +export const FactoryAccountCell = memo(function FactoryAccountCell(props: { + chainId: string; + contractAddress: string; +}) { + const accountsQuery = useAccountCount( + props.contractAddress, + Number(props.chainId), + ); + return ( + { + return {v}; + }} + /> + ); +}); diff --git a/apps/dashboard/src/components/smart-wallets/AccountFactories/factory-contracts.tsx b/apps/dashboard/src/components/smart-wallets/AccountFactories/factory-contracts.tsx index 2436d5bf06f..2a060c3dd61 100644 --- a/apps/dashboard/src/components/smart-wallets/AccountFactories/factory-contracts.tsx +++ b/apps/dashboard/src/components/smart-wallets/AccountFactories/factory-contracts.tsx @@ -2,53 +2,28 @@ import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; import { SkeletonContainer } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { useQuery } from "@tanstack/react-query"; -import { createColumnHelper } from "@tanstack/react-table"; -import { AsyncContractNameCell } from "components/contract-components/tables/cells"; -import { TWTable } from "components/shared/TWTable"; -import { AsyncFactoryAccountCell } from "components/smart-wallets/AccountFactories/account-cell"; -import type { BasicContract } from "contract-ui/types/types"; +import { ContractNameCell } from "components/contract-components/tables/cells"; +import { FactoryAccountCell } from "components/smart-wallets/AccountFactories/account-cell"; import { useV5DashboardChain } from "lib/v5-adapter"; import { getChainMetadata } from "thirdweb/chains"; +import type { ProjectContract } from "../../../app/account/contracts/_components/getProjectContracts"; interface FactoryContractsProps { - contracts: BasicContract[]; + contracts: ProjectContract[]; isPending: boolean; isFetched: boolean; } -const columnHelper = createColumnHelper(); - -const columns = [ - columnHelper.accessor((row) => row.address, { - header: "Name", - cell: (cell) => , - }), - columnHelper.accessor("chainId", { - header: "Network", - cell: (cell) => { - return ; - }, - }), - columnHelper.accessor("address", { - header: "Contract address", - cell: (cell) => ( - - ), - }), - columnHelper.accessor((row) => row, { - header: "Accounts", - cell: (cell) => { - return ; - }, - }), -]; - function NetworkName(props: { id: number }) { const chain = useV5DashboardChain(props.id); const chainQuery = useQuery({ @@ -70,16 +45,48 @@ function NetworkName(props: { id: number }) { export const FactoryContracts: React.FC = ({ contracts, - isPending, - isFetched, }) => { return ( - + + + + + Name + Network + Contract address + Accounts + + + + {contracts.map((contract) => ( + + + + + + + + + + + + + + + ))} + +
+
); }; diff --git a/apps/dashboard/src/components/smart-wallets/AccountFactories/index.tsx b/apps/dashboard/src/components/smart-wallets/AccountFactories/index.tsx index c54a34a713a..62d18148969 100644 --- a/apps/dashboard/src/components/smart-wallets/AccountFactories/index.tsx +++ b/apps/dashboard/src/components/smart-wallets/AccountFactories/index.tsx @@ -1,170 +1,82 @@ "use client"; import { CopyAddressButton } from "@/components/ui/CopyAddressButton"; -import { Button } from "@/components/ui/button"; -import { TrackedLinkTW } from "@/components/ui/tracked-link"; -import { useThirdwebClient } from "@/constants/thirdweb.client"; -import { useMultiChainRegContractList } from "@3rdweb-sdk/react/hooks/useRegistry"; -import { useQuery } from "@tanstack/react-query"; -import { createColumnHelper } from "@tanstack/react-table"; -import { PlusIcon } from "lucide-react"; -import { defineChain, getContract } from "thirdweb"; -import { getCompilerMetadata } from "thirdweb/contract"; -import { useActiveAccount } from "thirdweb/react"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { DEFAULT_ACCOUNT_FACTORY_V0_6, DEFAULT_ACCOUNT_FACTORY_V0_7, } from "thirdweb/wallets/smart"; -import { TWTable } from "../../shared/TWTable"; -import { FactoryContracts } from "./factory-contracts"; - -function useFactories() { - const address = useActiveAccount()?.address; - const client = useThirdwebClient(); - const contractListQuery = useMultiChainRegContractList(address); +import { UnderlineLink } from "../../../@/components/ui/UnderlineLink"; - return useQuery({ - queryKey: [ - "dashboard-registry", - address, - "multichain-contract-list", - "factories", - ], - queryFn: async () => { - const factories = await Promise.all( - (contractListQuery.data || []).map(async (c) => { - const contract = getContract({ - // eslint-disable-next-line no-restricted-syntax - chain: defineChain(c.chainId), - address: c.address, - client, - }); - const m = await getCompilerMetadata(contract); - return m.name.indexOf("AccountFactory") > -1 ? c : null; - }), - ); - - return factories.filter((f) => f !== null); +export function DefaultFactoriesSection() { + const data = [ + { + name: "AccountFactory (v0.6)", + address: DEFAULT_ACCOUNT_FACTORY_V0_6, + entrypointVersion: "0.6", }, - enabled: !!address && !!contractListQuery.data, - }); -} - -interface AccountFactoriesProps { - trackingCategory: string; -} + { + name: "AccountFactory (v0.7)", + address: DEFAULT_ACCOUNT_FACTORY_V0_7, + entrypointVersion: "0.7", + }, + ]; -export const AccountFactories: React.FC = ({ - trackingCategory, -}) => { - const factories = useFactories(); return (
- {/* Default factories */} -
-

Default Account Factories

+
+

+ Default Account Factories +

Ready to use account factories that are pre-deployed on each chain.{" "} - - Learn how to use these in your apps. - + Learn how to use these in your apps +

- - - {/* Your factories */} -
-
-

Your Account Factories

-

- Deploy your own account factories to create smart wallets.{" "} - - Learn more. - -

-
- - -
- + + + + + Name + Network + Contract address + Entrypoint Version + + + + {data.map((row) => ( + + {row.name} + All networks + + + + {row.entrypointVersion} + + ))} + +
+
); -}; - -type DefaultFactory = { - name: string; - address: string; - entrypointVersion: string; -}; - -const columnHelper = createColumnHelper(); - -const columns = [ - columnHelper.accessor((row) => row.name, { - header: "Name", - cell: (cell) => cell.row.original.name, - }), - columnHelper.accessor((row) => row.name, { - header: "Network", - cell: () => "All networks", - }), - columnHelper.accessor("address", { - header: "Contract address", - cell: (cell) => ( - - ), - }), - columnHelper.accessor((row) => row, { - header: "Entrypoint Version", - cell: (cell) => { - return cell.row.original.entrypointVersion; - }, - }), -]; +} diff --git a/apps/dashboard/src/constants/contracts.ts b/apps/dashboard/src/constants/contracts.ts index a16a5b39da9..bcc0c54b0d6 100644 --- a/apps/dashboard/src/constants/contracts.ts +++ b/apps/dashboard/src/constants/contracts.ts @@ -1,7 +1,3 @@ -import { getThirdwebClient } from "@/constants/thirdweb.server"; -import { getContract } from "thirdweb"; -import { polygon } from "thirdweb/chains"; - /** * Putting this here solely for the purpose of getting rid of sdk v4 */ @@ -20,9 +16,3 @@ export type ContractType = | "token-drop" | "token" | "vote"; - -export const MULTICHAIN_REGISTRY_CONTRACT = getContract({ - chain: polygon, - client: getThirdwebClient(), - address: "0xcdAD8FA86e18538aC207872E8ff3536501431B73", -}); diff --git a/apps/dashboard/src/contract-ui/types/types.ts b/apps/dashboard/src/contract-ui/types/types.ts deleted file mode 100644 index 4126d676a83..00000000000 --- a/apps/dashboard/src/contract-ui/types/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type BasicContract = { - chainId: number; - address: string; -}; diff --git a/apps/dashboard/src/dashboard-extensions/common/read/getAllMultichainRegistry.ts b/apps/dashboard/src/dashboard-extensions/common/read/getAllMultichainRegistry.ts deleted file mode 100644 index 36cd678e23f..00000000000 --- a/apps/dashboard/src/dashboard-extensions/common/read/getAllMultichainRegistry.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { type BaseTransactionOptions, isAddress, readContract } from "thirdweb"; -import { isAddressZero } from "utils/zeroAddress"; - -const GetAllABI = { - type: "function", - name: "getAll", - inputs: [ - { - type: "address", - name: "_deployer", - internalType: "address", - }, - ], - outputs: [ - { - type: "tuple[]", - name: "allDeployments", - components: [ - { - type: "address", - name: "deploymentAddress", - internalType: "address", - }, - { - type: "uint256", - name: "chainId", - internalType: "uint256", - }, - { - type: "string", - name: "metadataURI", - internalType: "string", - }, - ], - internalType: "struct ITWMultichainRegistry.Deployment[]", - }, - ], - stateMutability: "view", -} as const; - -type GetAllMultichainRegistryParams = { - address: string; -}; - -/** - * Retrieves the contract addresses for the given wallet address. - * @param options The transaction options. - * @returns A promise that resolves to the list of contract addresses. - * @extension - * @example - * ```ts - * const getAll = await getAllMultichainRegistry({ address }); - * ``` - */ -export async function getAllMultichainRegistry( - options: BaseTransactionOptions, -) { - const contracts = await readContract({ - ...options, - method: GetAllABI, - params: [options.address], - }); - - const contractsFiltered = [ - ...contracts.filter( - ({ deploymentAddress, chainId }) => - isAddress(deploymentAddress) && - !isAddressZero(deploymentAddress.toLowerCase()) && - chainId, - ), - ].reverse(); - - return contractsFiltered.map((contractFiltered) => { - return { - address: contractFiltered.deploymentAddress, - chainId: Number(contractFiltered.chainId), - }; - }); -}