From e9ea47630f61b2cc34afad4f1eab502e0cb39579 Mon Sep 17 00:00:00 2001 From: jnsdls Date: Fri, 22 Nov 2024 07:44:14 +0000 Subject: [PATCH] [Dashboard] Feature: Integrate ecosystem creation with Stripe billing (#5488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### TL;DR Refactored ecosystem creation flow to use server actions and removed the confirmation dialog. ### What changed? - Created a new server action `createEcosystem` to handle ecosystem creation - Extracted `BASE_URL` constant for reusability - Removed the confirmation dialog from the creation form - Simplified form submission to directly create ecosystems - Added more specific error handling with user-friendly toast messages - Changed component props to use `teamSlug` instead of `ecosystemLayoutPath` ### How to test? 1. Navigate to the ecosystem creation page 2. Fill out the ecosystem form with a name and logo 3. Select permission type (PARTNER_WHITELIST or ANYONE) 4. Submit the form 5. Verify redirect to Stripe billing portal 6. Verify appropriate error messages display for various error scenarios ### Why make this change? To streamline the ecosystem creation process by implementing server-side actions and providing better error handling. This change improves the user experience by removing the extra confirmation step while maintaining secure server-side processing of ecosystem creation. --- ## PR-Codex overview This PR focuses on refactoring the `EcosystemCreatePage` and related components to streamline the creation of ecosystems, updating function signatures, and improving the handling of the creation process. ### Detailed summary - Removed `ecosystemLayoutPath` prop from `EcosystemCreatePage`. - Added `teamSlug` prop to `EcosystemCreatePage` and `CreateEcosystemForm`. - Updated `createEcosystem` function to handle authentication and API calls. - Enhanced error handling in the `CreateEcosystemForm`. - Removed unused imports and state management related to confirmation dialogs. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- apps/dashboard/src/@/constants/env.ts | 14 +-- .../ecosystem/create/EcosystemCreatePage.tsx | 8 +- .../create/actions/create-ecosystem.ts | 65 ++++++++++++++ .../client/create-ecosystem-form.client.tsx | 82 ++++++++---------- .../create/hooks/use-create-ecosystem.ts | 86 ------------------- .../(team)/~/ecosystem/create/page.tsx | 6 +- 6 files changed, 110 insertions(+), 151 deletions(-) create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts delete mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/hooks/use-create-ecosystem.ts diff --git a/apps/dashboard/src/@/constants/env.ts b/apps/dashboard/src/@/constants/env.ts index faf219c8263..70b75962a4e 100644 --- a/apps/dashboard/src/@/constants/env.ts +++ b/apps/dashboard/src/@/constants/env.ts @@ -34,14 +34,14 @@ export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN; // Comma-separated list of chain IDs to disable faucet for. export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS; +export const BASE_URL = isProd + ? "https://thirdweb.com" + : (process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}` + : "http://localhost:3000") || "https://thirdweb-dev.com"; + export function getAbsoluteUrlFromPath(path: string) { - const url = new URL( - isProd - ? "https://thirdweb.com" - : (process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL - ? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}` - : "http://localhost:3000") || "https://thirdweb-dev.com", - ); + const url = new URL(BASE_URL); url.pathname = path; return url; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/EcosystemCreatePage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/EcosystemCreatePage.tsx index 91b89e0249e..ba3fcdfbadb 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/EcosystemCreatePage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/EcosystemCreatePage.tsx @@ -1,9 +1,7 @@ import { CreateEcosystemForm } from "./components/client/create-ecosystem-form.client"; import { EcosystemWalletPricingCard } from "./components/pricing-card"; -export function EcosystemCreatePage(props: { - ecosystemLayoutPath: string; -}) { +export async function EcosystemCreatePage(props: { teamSlug: string }) { return (
@@ -19,9 +17,7 @@ export function EcosystemCreatePage(props: {
- +
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts new file mode 100644 index 00000000000..e1ffebe2c9e --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/actions/create-ecosystem.ts @@ -0,0 +1,65 @@ +"use server"; +import "server-only"; +import { API_SERVER_URL, BASE_URL } from "@/constants/env"; +import { getThirdwebClient } from "@/constants/thirdweb.server"; +import { redirect } from "next/navigation"; +import { upload } from "thirdweb/storage"; +import { getAuthToken } from "../../../../../../../api/lib/getAuthToken"; + +export async function createEcosystem(options: { + teamSlug: string; + name: string; + logo: File; + permission: "PARTNER_WHITELIST" | "ANYONE"; +}) { + const token = await getAuthToken(); + if (!token) { + return { + status: 401, + }; + } + + const { teamSlug, logo, ...data } = options; + + const imageUrl = await upload({ + client: getThirdwebClient(token), + files: [logo], + }); + + const res = await fetch( + `${API_SERVER_URL}/v1/teams/${teamSlug}/checkout/create-link`, + { + method: "POST", + body: JSON.stringify({ + baseUrl: BASE_URL, + sku: "product:ecosystem_wallets", + metadata: { + ...data, + imageUrl, + // not something we pass in today during creation, but required to be there + authOptions: [], + }, + }), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }, + ); + if (!res.ok) { + return { + status: res.status, + }; + } + + const json = await res.json(); + + if (!json.result) { + return { + status: 500, + }; + } + + // redirect to the stripe billing portal + redirect(json.result); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/components/client/create-ecosystem-form.client.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/components/client/create-ecosystem-form.client.tsx index 26182754aa7..65a5fb4a19a 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/components/client/create-ecosystem-form.client.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/components/client/create-ecosystem-form.client.tsx @@ -1,5 +1,4 @@ "use client"; -import { ConfirmationDialog } from "@/components/ui/ConfirmationDialog"; import { Button } from "@/components/ui/button"; import { Form, @@ -14,16 +13,13 @@ import { import { ImageUpload } from "@/components/ui/image-upload"; import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group"; -import { useDashboardRouter } from "@/lib/DashboardRouter"; import { zodResolver } from "@hookform/resolvers/zod"; import { Loader2 } from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import invariant from "tiny-invariant"; import { z } from "zod"; -import { useCreateEcosystem } from "../../hooks/use-create-ecosystem"; +import { createEcosystem } from "../../actions/create-ecosystem"; const formSchema = z.object({ name: z @@ -40,15 +36,7 @@ const formSchema = z.object({ permission: z.union([z.literal("PARTNER_WHITELIST"), z.literal("ANYONE")]), }); -export function CreateEcosystemForm(props: { - ecosystemLayoutPath: string; -}) { - // When set, the confirmation modal is open the this contains the form data to be submitted - const [formDataToBeConfirmed, setFormDataToBeConfirmed] = useState< - z.infer | undefined - >(); - - const router = useDashboardRouter(); +export function CreateEcosystemForm(props: { teamSlug: string }) { const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -56,25 +44,39 @@ export function CreateEcosystemForm(props: { }, }); - const { mutateAsync: createEcosystem, isPending } = useCreateEcosystem({ - onError: (error) => { - const message = - error instanceof Error ? error.message : "Failed to create ecosystem"; - toast.error(message); - }, - onSuccess: (slug: string) => { - form.reset(); - router.push(`${props.ecosystemLayoutPath}/${slug}`); - }, - }); - return ( <>
- setFormDataToBeConfirmed(values), - )} + onSubmit={form.handleSubmit(async (values) => { + const res = await createEcosystem({ + teamSlug: props.teamSlug, + ...values, + }); + switch (res.status) { + case 401: { + toast.error("Please login to create an ecosystem"); + break; + } + case 403: + { + toast.error( + "You are not authorized to create an ecosystem, please ask your team owner to create it.", + ); + } + break; + case 409: { + toast.error("An ecosystem with that name already exists."); + break; + } + // any other status code treat as a random failure + default: { + toast.error( + "Failed to create ecosystem, please try again later.", + ); + } + } + })} className="flex flex-col items-stretch gap-8" >
@@ -166,29 +168,15 @@ export function CreateEcosystemForm(props: { type="submit" variant="primary" className="w-full" - disabled={isPending} + disabled={form.formState.isSubmitting} > - {isPending && } + {form.formState.isSubmitting && ( + + )} Create - - !open ? setFormDataToBeConfirmed(undefined) : null - } - title={`Are you sure you want to create ecosystem ${form.getValues().name}?`} - description="Your account will be charged $250 per month." - onSubmit={() => { - invariant(formDataToBeConfirmed, "Form data not found"); - createEcosystem({ - name: formDataToBeConfirmed.name, - logo: formDataToBeConfirmed.logo, - permission: formDataToBeConfirmed.permission, - }); - }} - /> ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/hooks/use-create-ecosystem.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/hooks/use-create-ecosystem.ts deleted file mode 100644 index 90c8df76f7c..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/hooks/use-create-ecosystem.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useThirdwebClient } from "@/constants/thirdweb.client"; -import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser"; -import { - type UseMutationOptions, - useMutation, - useQueryClient, -} from "@tanstack/react-query"; -import { THIRDWEB_API_HOST } from "constants/urls"; -import { upload } from "thirdweb/storage"; - -type CreateEcosystemParams = { - name: string; - logo: File; - permission: "PARTNER_WHITELIST" | "ANYONE"; -}; - -export function useCreateEcosystem( - options?: Omit< - UseMutationOptions, - "mutationFn" - >, -) { - const { onSuccess, ...queryOptions } = options ?? {}; - const { isLoggedIn } = useLoggedInUser(); - const queryClient = useQueryClient(); - - const client = useThirdwebClient(); - - return useMutation({ - // Returns the created ecosystem slug - mutationFn: async (params: CreateEcosystemParams): Promise => { - if (!isLoggedIn) { - throw new Error("Please login to create an ecosystem"); - } - - const imageUri = await upload({ - client, - files: [params.logo], - }); - - const res = await fetch( - `${THIRDWEB_API_HOST}/v1/ecosystem-wallet/add-cloud-hosted`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: params.name, - imageUrl: imageUri, - permission: params.permission, - }), - }, - ); - - if (!res.ok) { - const body = await res.json(); - console.error(body); - if (res.status === 401) { - throw new Error( - "You're not authorized to create an ecosystem, are you logged in?", - ); - } - if (res.status === 406) { - throw new Error( - "Please setup billing on your account to create an ecosystem", - ); - } - throw new Error( - body.message ?? body?.error?.message ?? "Failed to create ecosystem", - ); - } - - const data = await res.json(); - - return data.result.slug; - }, - onSuccess: async (id, variables, context) => { - if (onSuccess) { - await onSuccess(id, variables, context); - } - return queryClient.invalidateQueries({ - queryKey: ["ecosystems"], - }); - }, - ...queryOptions, - }); -} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/page.tsx index 8b20a7911ab..11fc8b6d1bc 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/create/page.tsx @@ -4,9 +4,5 @@ export default async function Page(props: { params: Promise<{ team_slug: string }>; }) { const { team_slug } = await props.params; - return ( - - ); + return ; }