diff --git a/apps/dashboard/src/@/api/getProjectContracts.ts b/apps/dashboard/src/@/api/getProjectContracts.ts index f33d24880ab..396bce5dbae 100644 --- a/apps/dashboard/src/@/api/getProjectContracts.ts +++ b/apps/dashboard/src/@/api/getProjectContracts.ts @@ -1,4 +1,5 @@ import "server-only"; +import { getAddress } from "thirdweb"; import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; export type ProjectContract = { @@ -43,3 +44,43 @@ export async function getProjectContracts(options: { return data.result; } + +export type PartialProject = { + id: string; + name: string; + slug: string; + image: string | null; +}; + +/** + * get a list of projects within a team that have a given contract imported + */ +export async function getContractImportedProjects(options: { + teamId: string; + authToken: string; + chainId: number; + contractAddress: string; +}) { + const url = new URL( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/projects/contracts/lookup?chainId=${options.chainId}&contractAddress=${getAddress(options.contractAddress)}`, + ); + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${options.authToken}`, + }, + }); + + if (!res.ok) { + const errorMessage = await res.text(); + console.error("Error fetching: /projects/contracts/lookup"); + console.error(errorMessage); + return []; + } + + const data = (await res.json()) as { + result: PartialProject[]; + }; + + return data.result; +} diff --git a/apps/dashboard/src/@/components/contracts/import-contract/modal.tsx b/apps/dashboard/src/@/components/contracts/import-contract/modal.tsx index c7389e7c34b..c4cbc25af04 100644 --- a/apps/dashboard/src/@/components/contracts/import-contract/modal.tsx +++ b/apps/dashboard/src/@/components/contracts/import-contract/modal.tsx @@ -37,6 +37,8 @@ type ImportModalProps = { onClose: () => void; teamId: string; projectId: string; + projectSlug: string; + teamSlug: string; client: ThirdwebClient; type: "contract" | "asset"; onSuccess?: () => void; @@ -69,7 +71,9 @@ export const ImportModal: React.FC = (props) => { client={props.client} onSuccess={props.onSuccess} projectId={props.projectId} + projectSlug={props.projectSlug} teamId={props.teamId} + teamSlug={props.teamSlug} type={props.type} /> @@ -96,6 +100,8 @@ const importFormSchema = z.object({ function ImportForm(props: { teamId: string; projectId: string; + teamSlug: string; + projectSlug: string; client: ThirdwebClient; type: "contract" | "asset"; onSuccess?: () => void; @@ -216,7 +222,7 @@ function ImportForm(props: { addContractToProject.data?.result ? ( + {explorersToShow?.map((validBlockExplorer) => ( diff --git a/apps/dashboard/src/app/(app)/account/contracts/_components/DeployViaCLIOrImportCard.tsx b/apps/dashboard/src/app/(app)/account/contracts/_components/DeployViaCLIOrImportCard.tsx index a44b7c99b32..1ca76f588a3 100644 --- a/apps/dashboard/src/app/(app)/account/contracts/_components/DeployViaCLIOrImportCard.tsx +++ b/apps/dashboard/src/app/(app)/account/contracts/_components/DeployViaCLIOrImportCard.tsx @@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button"; export function DeployViaCLIOrImportCard(props: { teamId: string; projectId: string; + projectSlug: string; + teamSlug: string; client: ThirdwebClient; }) { const [importModalOpen, setImportModalOpen] = useState(false); @@ -23,7 +25,9 @@ export function DeployViaCLIOrImportCard(props: { setImportModalOpen(false); }} projectId={props.projectId} + projectSlug={props.projectSlug} teamId={props.teamId} + teamSlug={props.teamSlug} type="contract" /> diff --git a/apps/dashboard/src/app/(app)/account/contracts/_components/DeployedContractsPage.tsx b/apps/dashboard/src/app/(app)/account/contracts/_components/DeployedContractsPage.tsx index 9134900dcac..c76df46c73e 100644 --- a/apps/dashboard/src/app/(app)/account/contracts/_components/DeployedContractsPage.tsx +++ b/apps/dashboard/src/app/(app)/account/contracts/_components/DeployedContractsPage.tsx @@ -23,7 +23,9 @@ export function DeployedContractsPage(props: { ); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/contracts/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/contracts/layout.tsx index 5f8e468652f..4b72620a41c 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/contracts/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/contracts/layout.tsx @@ -43,7 +43,9 @@ export default async function Layout(props: { diff --git a/apps/dashboard/src/app/(app)/team/~/[[...paths]]/components/team-selector.stories.tsx b/apps/dashboard/src/app/(app)/team/~/[[...paths]]/components/team-selector.stories.tsx new file mode 100644 index 00000000000..763bf54c337 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/~/[[...paths]]/components/team-selector.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { teamStub } from "@/storybook/stubs"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import { TeamSelectorCard } from "./team-selector"; + +const meta: Meta = { + component: TeamSelectorCard, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "selectors/TeamSelectorCard", +}; + +export default meta; +type Story = StoryObj; + +export const TwoTeams: Story = { + args: { + client: storybookThirdwebClient, + paths: undefined, + searchParams: "", + teams: [teamStub("1", "free"), teamStub("2", "starter")], + }, +}; + +export const FiveTeams: Story = { + args: { + client: storybookThirdwebClient, + paths: undefined, + searchParams: "", + teams: [ + teamStub("1", "free"), + teamStub("2", "starter"), + teamStub("3", "growth"), + teamStub("4", "pro"), + teamStub("5", "scale"), + ], + }, +}; + +export const WithSearchParams: Story = { + args: { + client: storybookThirdwebClient, + paths: undefined, + searchParams: "tab=overview§ion=analytics", + teams: [ + teamStub("1", "free"), + teamStub("2", "starter"), + teamStub("3", "growth"), + ], + }, +}; + +export const WithPaths: Story = { + args: { + client: storybookThirdwebClient, + paths: ["projects", "123", "settings"], + searchParams: "", + teams: [teamStub("1", "free"), teamStub("2", "starter")], + }, +}; + +export const WithPathsAndSearchParams: Story = { + args: { + client: storybookThirdwebClient, + paths: ["projects", "123", "settings"], + searchParams: "tab=overview§ion=analytics", + teams: [ + teamStub("1", "free"), + teamStub("2", "starter"), + teamStub("3", "growth"), + teamStub("4", "pro"), + ], + }, +}; diff --git a/apps/dashboard/src/app/(app)/team/~/[[...paths]]/components/team-selector.tsx b/apps/dashboard/src/app/(app)/team/~/[[...paths]]/components/team-selector.tsx new file mode 100644 index 00000000000..fa64db07b1c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/~/[[...paths]]/components/team-selector.tsx @@ -0,0 +1,73 @@ +import { ChevronRightIcon, UsersIcon } from "lucide-react"; +import Link from "next/link"; +import type { ThirdwebClient } from "thirdweb"; +import type { Team } from "@/api/team"; +import { GradientAvatar } from "@/components/blocks/avatar/gradient-avatar"; +import { TeamPlanBadge } from "@/components/blocks/TeamPlanBadge"; + +export function createTeamLink(params: { + team: Team; + paths: string[] | undefined; + searchParams: string | undefined; +}) { + const pathsSegment = params.paths?.length ? `/${params.paths.join("/")}` : ""; + const searchParamsSegment = params.searchParams + ? `?${params.searchParams}` + : ""; + return `/team/${params.team.slug}${pathsSegment}${searchParamsSegment}`; +} + +export function TeamSelectorCard(props: { + teams: Team[]; + client: ThirdwebClient; + searchParams: string; + paths: string[] | undefined; +}) { + return ( +
+
+
+ +
+

+ Select a team +

+

+ You are currently a member of multiple teams +
+ Select a team to view this page +

+
+ +
+ {props.teams.map((team) => { + return ( +
+ + + {team.name} + + + +
+ ); + })} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/~/[[...paths]]/page.tsx b/apps/dashboard/src/app/(app)/team/~/[[...paths]]/page.tsx index bb615462936..852d06aa227 100644 --- a/apps/dashboard/src/app/(app)/team/~/[[...paths]]/page.tsx +++ b/apps/dashboard/src/app/(app)/team/~/[[...paths]]/page.tsx @@ -1,14 +1,11 @@ -import { ChevronRightIcon, UsersIcon } from "lucide-react"; -import Link from "next/link"; import { notFound, redirect } from "next/navigation"; import { getAuthToken } from "@/api/auth-token"; -import { getTeams, type Team } from "@/api/team"; -import { GradientAvatar } from "@/components/blocks/avatar/gradient-avatar"; -import { TeamPlanBadge } from "@/components/blocks/TeamPlanBadge"; +import { getTeams } from "@/api/team"; import { AppFooter } from "@/components/footers/app-footer"; import { DotsBackgroundPattern } from "@/components/ui/background-patterns"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; import { TeamHeader } from "../../components/TeamHeader/team-header"; +import { createTeamLink, TeamSelectorCard } from "./components/team-selector"; export default async function Page(props: { params: Promise<{ @@ -69,54 +66,12 @@ export default async function Page(props: {
-
-
-
- -
-

- Select a team -

-

- You are currently a member of multiple teams -
- Select a team to view this page -

-
- -
- {teams.map((team) => { - return ( -
- - - {team.name} - - - -
- ); - })} -
-
+
@@ -124,11 +79,3 @@ export default async function Page(props: { ); } - -function createTeamLink(params: { - team: Team; - paths: string[] | undefined; - searchParams: string | undefined; -}) { - return `/team/${params.team.slug}/${(params.paths || [])?.join("/") || ""}${params.searchParams ? `?${params.searchParams}` : ""}`; -} diff --git a/apps/dashboard/src/app/(app)/team/~/components/TeamAndProjectSelectorCard.stories.tsx b/apps/dashboard/src/app/(app)/team/~/components/TeamAndProjectSelectorCard.stories.tsx new file mode 100644 index 00000000000..ec25e4bdb4c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/~/components/TeamAndProjectSelectorCard.stories.tsx @@ -0,0 +1,133 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { PartialProject } from "@/api/getProjectContracts"; +import { projectStub, teamStub } from "@/storybook/stubs"; +import { storybookLog, storybookThirdwebClient } from "@/storybook/utils"; +import { ProjectAndTeamSelectorCard } from "./TeamAndProjectSelectorCard"; + +const meta = { + component: ProjectAndTeamSelectorCard, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "selectors/TeamAndProjectSelectorCard", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Helper function to create PartialProject from full Project +function createPartialProject( + project: ReturnType, +): PartialProject { + return { + id: project.id, + image: project.image, + name: project.name, + slug: project.slug, + }; +} + +export const OneTeamFiveProjects: Story = { + args: { + client: storybookThirdwebClient, + description: "Select a project to continue", + onSelect: (selected) => { + console.log("Selected:", selected); + }, + teamAndProjects: [ + { + projects: [ + createPartialProject(projectStub("1", "team-1")), + createPartialProject(projectStub("2", "team-1")), + createPartialProject(projectStub("3", "team-1")), + createPartialProject(projectStub("4", "team-1")), + createPartialProject(projectStub("5", "team-1")), + ], + team: teamStub("1", "growth"), + }, + ], + }, +}; + +export const TwoTeamsTwoProjectsEach: Story = { + args: { + client: storybookThirdwebClient, + description: "Select a project to continue", + onSelect: (selected) => { + storybookLog(selected); + }, + teamAndProjects: [ + { + projects: [ + createPartialProject(projectStub("1", "team-1")), + createPartialProject(projectStub("2", "team-1")), + ], + team: teamStub("1", "free"), + }, + { + projects: [ + createPartialProject(projectStub("3", "team-2")), + createPartialProject(projectStub("4", "team-2")), + ], + team: teamStub("2", "starter"), + }, + ], + }, +}; + +export const TwoTeamsOneWithThreeProjectsOtherWithZero: Story = { + args: { + client: storybookThirdwebClient, + description: "Select a project to continue", + onSelect: (selected) => { + storybookLog(selected); + }, + teamAndProjects: [ + { + projects: [ + createPartialProject(projectStub("1", "team-1")), + createPartialProject(projectStub("2", "team-1")), + createPartialProject(projectStub("3", "team-1")), + ], + team: teamStub("1", "growth"), + }, + { + projects: [], + team: teamStub("2", "free"), // Empty projects array + }, + ], + }, +}; + +export const TwoTeamsLotsOfProjects: Story = { + args: { + client: storybookThirdwebClient, + description: "Select a project to continue", + onSelect: (selected) => { + console.log("Selected:", selected); + }, + teamAndProjects: [ + { + projects: Array.from({ length: 100 }, (_, i) => + createPartialProject(projectStub(`${i + 1}`, "team-1")), + ), + team: teamStub("1", "growth"), + }, + { + projects: Array.from({ length: 100 }, (_, i) => + createPartialProject(projectStub(`${i + 1}`, "team-2")), + ), + team: teamStub("2", "growth"), + }, + ], + }, +}; diff --git a/apps/dashboard/src/app/(app)/team/~/components/TeamAndProjectSelectorCard.tsx b/apps/dashboard/src/app/(app)/team/~/components/TeamAndProjectSelectorCard.tsx new file mode 100644 index 00000000000..466a4c7fb4b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/~/components/TeamAndProjectSelectorCard.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { BoxIcon, ChevronRightIcon } from "lucide-react"; +import type { ThirdwebClient } from "thirdweb"; +import type { PartialProject } from "@/api/getProjectContracts"; +import type { Team } from "@/api/team"; +import { GradientAvatar } from "@/components/blocks/avatar/gradient-avatar"; +import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar"; +import { TeamPlanBadge } from "@/components/blocks/TeamPlanBadge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function ProjectAndTeamSelectorCard(props: { + teamAndProjects: { + team: Team; + projects: PartialProject[]; + }[]; + client: ThirdwebClient; + description: React.ReactNode; + onSelect: (selected: { team: Team; project: PartialProject }) => void; +}) { + const teamAndProjects = props.teamAndProjects.filter( + ({ projects }) => projects.length > 0, + ); + + const showTeamHeader = teamAndProjects.length > 1; + + return ( +
+
+
+ +
+

+ Select a project +

+

{props.description}

+
+ +
+ {teamAndProjects.map(({ team, projects }) => { + // If multiple teams, show team name and then projects + return ( +
+
+ {showTeamHeader && ( +
+
+ + {team.name} + +
+
+ )} + +
+
+ {projects.map((project) => ( + + ))} +
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/~/~/contract/[chain]/[contractAddress]/components/project-selector.tsx b/apps/dashboard/src/app/(app)/team/~/~/contract/[chain]/[contractAddress]/components/project-selector.tsx new file mode 100644 index 00000000000..d3dc622d1d0 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/~/~/contract/[chain]/[contractAddress]/components/project-selector.tsx @@ -0,0 +1,80 @@ +"use client"; + +import type { ThirdwebClient } from "thirdweb"; +import type { PartialProject } from "@/api/getProjectContracts"; +import type { Team } from "@/api/team"; +import { useAddContractToProject } from "@/hooks/project-contracts"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { ProjectAndTeamSelectorCard } from "../../../../../components/TeamAndProjectSelectorCard"; + +export function SelectProjectForContract(props: { + chainSlug: string; + contractAddress: string; + client: ThirdwebClient; + teamAndProjects: { + team: Team; + projects: PartialProject[]; + }[]; +}) { + const router = useDashboardRouter(); + return ( + + This contract is imported in multiple projects +
+ Select a project to view this contract + + } + onSelect={(selection) => { + router.push( + `/team/${selection.team.slug}/${selection.project.slug}/contract/${props.chainSlug}/${props.contractAddress}`, + ); + }} + teamAndProjects={props.teamAndProjects} + /> + ); +} + +export function ImportAndSelectProjectForContract(props: { + chainSlug: string; + chainId: number; + contractAddress: string; + client: ThirdwebClient; + teamAndProjects: { + team: Team; + projects: PartialProject[]; + }[]; +}) { + const router = useDashboardRouter(); + const addToProject = useAddContractToProject(); + return ( + + This contract is not imported in any projects +
+ Select a project to import the contract and continue + + } + onSelect={(selection) => { + // do not await - send request and move to the contract page + addToProject.mutate({ + chainId: props.chainId.toString(), + contractAddress: props.contractAddress, + contractType: undefined, + deploymentType: undefined, + projectId: selection.project.id, + teamId: selection.team.id, + }); + + router.push( + `/team/${selection.team.slug}/${selection.project.slug}/contract/${props.chainSlug}/${props.contractAddress}`, + ); + }} + teamAndProjects={props.teamAndProjects} + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/~/~/contract/[chain]/[contractAddress]/page.tsx b/apps/dashboard/src/app/(app)/team/~/~/contract/[chain]/[contractAddress]/page.tsx new file mode 100644 index 00000000000..708cdbc947c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/~/~/contract/[chain]/[contractAddress]/page.tsx @@ -0,0 +1,147 @@ +import { notFound, redirect } from "next/navigation"; +import { getAddress } from "thirdweb"; +import { getAuthToken } from "@/api/auth-token"; +import { + getContractImportedProjects, + type PartialProject, +} from "@/api/getProjectContracts"; +import { getProjects } from "@/api/projects"; +import { getTeams, type Team } from "@/api/team"; +import { AppFooter } from "@/components/footers/app-footer"; +import { DotsBackgroundPattern } from "@/components/ui/background-patterns"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { fetchChain } from "@/utils/fetchChain"; +import { getValidAccount } from "../../../../../../account/settings/getAccount"; +import { loginRedirect } from "../../../../../../login/loginRedirect"; +import { TeamHeader } from "../../../../../components/TeamHeader/team-header"; +import { + ImportAndSelectProjectForContract, + SelectProjectForContract, +} from "./components/project-selector"; + +export default async function Page(props: { + params: Promise<{ + chain: string; + contractAddress: string; + }>; +}) { + const params = await props.params; + const pagePath = `/team/~/~/contract/${params.chain}/${params.contractAddress}`; + + const contractAddress = getAddress(params.contractAddress); + + const [authToken, chainMetadata, account, teams] = await Promise.all([ + getAuthToken(), + fetchChain(params.chain), + await getValidAccount(pagePath), + getTeams(), + ]); + + if (!chainMetadata) { + notFound(); + } + + if (!authToken || !account || !teams) { + loginRedirect(pagePath); + } + + // get the list of projects in each team where this contract is imported + // filter out teams with no projects + const teamAndProjectWithContracts = ( + await Promise.all( + teams.map(async (team) => { + return { + projects: await getContractImportedProjects({ + authToken, + chainId: chainMetadata.chainId, + contractAddress, + teamId: team.id, + }).catch(() => []), + team, + }; + }), + ) + ).filter((x) => x.projects.length > 0); + + const projectImports: Array<{ + team: Team; + project: PartialProject; + }> = []; + + for (const teamAndProject of teamAndProjectWithContracts) { + for (const project of teamAndProject.projects) { + projectImports.push({ + project, + team: teamAndProject.team, + }); + } + } + + // if contract imported in only one project, redirect to it directly + if (projectImports.length === 1 && projectImports[0]) { + redirect( + `/team/${projectImports[0].team.slug}/${projectImports[0].project.slug}/contract/${chainMetadata.slug}/${contractAddress}`, + ); + } + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: undefined, + }); + + // if contract imported in multiple projects + // user needs to select one + if (projectImports.length > 1) { + return ( + + + + ); + } + + // if contract not imported in any projects + // user needs to select one from all projects, import the contract in the selected project and redirect + const teamAndAllProjects = await Promise.all( + teams.map(async (team) => { + return { + projects: await getProjects(team.slug).catch(() => []), + team, + }; + }), + ); + + return ( + + + + ); +} + +function ProjectSelectionLayout(props: { children: React.ReactNode }) { + return ( +
+
+ +
+ +
+ +
+ {props.children} +
+
+ +
+ ); +}