From e4caab45646d6fab2283d24f790ac6578044edb0 Mon Sep 17 00:00:00 2001 From: Jonas Daniels Date: Wed, 17 Sep 2025 21:03:06 -0700 Subject: [PATCH] Add Stripe checkout for Growth plan subscription --- apps/dashboard/.env.example | 2 + apps/dashboard/package.json | 2 + .../dashboard/src/@/actions/stripe-actions.ts | 63 ++- apps/dashboard/src/@/analytics/report.ts | 39 +- apps/dashboard/src/@/constants/server-envs.ts | 21 + .../_components/create-project-form.tsx | 452 ++++++++++++++++++ .../team/[team_slug]/create-project/page.tsx | 29 ++ ...{plan-selector.tsx => plan-selector.salty} | 0 .../_components/stripe-checkout.tsx | 31 ++ .../team/[team_slug]/select-plan/page.tsx | 61 ++- apps/dashboard/src/app/(app)/providers.tsx | 7 + .../login/onboarding/onboarding-layout.tsx | 19 +- .../team-onboarding/team-onboarding.tsx | 6 +- pnpm-lock.yaml | 28 +- 14 files changed, 711 insertions(+), 49 deletions(-) create mode 100644 apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/_components/create-project-form.tsx create mode 100644 apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/page.tsx rename apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/_components/{plan-selector.tsx => plan-selector.salty} (100%) create mode 100644 apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/_components/stripe-checkout.tsx diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index a5d73272512..3820e728559 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -64,3 +64,5 @@ ANALYTICS_SERVICE_URL="" # required for billing parts of the dashboard (team -> settings -> billing / invoices) STRIPE_SECRET_KEY="" +GROWTH_PLAN_SKU="" +PAYMENT_METHOD_CONFIGURATION="" diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 1de553dde2c..aaf3e6946af 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -21,6 +21,8 @@ "@radix-ui/react-tooltip": "1.2.7", "@sentry/nextjs": "9.34.0", "@shazow/whatsabi": "0.22.2", + "@stripe/react-stripe-js": "4.0.2", + "@stripe/stripe-js": "7.9.0", "@tanstack/react-query": "5.81.5", "@tanstack/react-table": "^8.21.3", "@thirdweb-dev/api": "workspace:*", diff --git a/apps/dashboard/src/@/actions/stripe-actions.ts b/apps/dashboard/src/@/actions/stripe-actions.ts index 3030aa4a1a3..8cfcb20eb23 100644 --- a/apps/dashboard/src/@/actions/stripe-actions.ts +++ b/apps/dashboard/src/@/actions/stripe-actions.ts @@ -1,8 +1,13 @@ import "server-only"; +import { headers } from "next/headers"; import Stripe from "stripe"; import type { Team } from "@/api/team/get-team"; -import { STRIPE_SECRET_KEY } from "@/constants/server-envs"; +import { + GROWTH_PLAN_SKU, + PAYMENT_METHOD_CONFIGURATION, + STRIPE_SECRET_KEY, +} from "@/constants/server-envs"; let existingStripe: Stripe | undefined; @@ -72,3 +77,59 @@ export async function getStripeBalance(customerId: string) { // Stripe returns a positive balance for credits, so we need to divide by -100 to get the actual balance (as long as the balance is not 0) return customer.balance === 0 ? 0 : customer.balance / -100; } + +export async function fetchClientSecret(team: Team) { + "use server"; + const origin = (await headers()).get("origin"); + const stripe = getStripe(); + const customerId = team.stripeCustomerId; + + if (!customerId) { + throw new Error("No customer ID found"); + } + + // Create Checkout Sessions from body params. + const session = await stripe.checkout.sessions.create({ + ui_mode: "embedded", + line_items: [ + { + // Provide the exact Price ID (for example, price_1234) of + // the product you want to sell + price: GROWTH_PLAN_SKU, + quantity: 1, + }, + ], + mode: "subscription", + + return_url: `${origin}/get-started/team/${team.slug}/select-plan?session_id={CHECKOUT_SESSION_ID}`, + automatic_tax: { enabled: true }, + allow_promotion_codes: true, + customer: customerId, + customer_update: { + address: "auto", + }, + payment_method_collection: "always", + payment_method_configuration: PAYMENT_METHOD_CONFIGURATION, + subscription_data: { + trial_period_days: 14, + trial_settings: { + end_behavior: { + missing_payment_method: "cancel", + }, + }, + }, + }); + + if (!session.client_secret) { + throw new Error("No client secret found"); + } + + return session.client_secret; +} + +export async function getStripeSessionById(sessionId: string) { + const session = await getStripe().checkout.sessions.retrieve(sessionId, { + expand: ["line_items", "payment_intent"], + }); + return session; +} diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts index c23f768b2be..1292edaa834 100644 --- a/apps/dashboard/src/@/analytics/report.ts +++ b/apps/dashboard/src/@/analytics/report.ts @@ -81,33 +81,6 @@ export function reportOnboardingStarted() { posthog.capture("onboarding started"); } -/** - * ### Why do we need to report this event? - * - To track the number of teams that select a paid plan during onboarding - * - To know **which** plan was selected - * - * ### Who is responsible for this event? - * @jnsdls - * - */ -export function reportOnboardingPlanSelected(properties: { - plan: Team["billingPlan"]; -}) { - posthog.capture("onboarding plan selected", properties); -} - -/** - * ### Why do we need to report this event? - * - To track the number of teams that skip the plan-selection step during onboarding - * - * ### Who is responsible for this event? - * @jnsdls - * - */ -export function reportOnboardingPlanSelectionSkipped() { - posthog.capture("onboarding plan selection skipped"); -} - /** * ### Why do we need to report this event? * - To track the number of teams that invite members during onboarding @@ -161,6 +134,18 @@ export function reportOnboardingMembersUpsellPlanSelected(properties: { posthog.capture("onboarding members upsell plan selected", properties); } +/** + * ### Why do we need to report this event? + * - To track the number of teams that completed the team member step during onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportTeamMemberStepCompleted() { + posthog.capture("onboarding members completed"); +} + /** * ### Why do we need to report this event? * - To track the number of teams that completed onboarding diff --git a/apps/dashboard/src/@/constants/server-envs.ts b/apps/dashboard/src/@/constants/server-envs.ts index 991001ec199..5cb82d85015 100644 --- a/apps/dashboard/src/@/constants/server-envs.ts +++ b/apps/dashboard/src/@/constants/server-envs.ts @@ -91,3 +91,24 @@ if (REDIS_URL) { REDIS_URL, ); } + +export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || ""; + +if (GROWTH_PLAN_SKU) { + experimental_taintUniqueValue( + "Do not pass GROWTH_PLAN_SKU to the client", + process, + GROWTH_PLAN_SKU, + ); +} + +export const PAYMENT_METHOD_CONFIGURATION = + process.env.PAYMENT_METHOD_CONFIGURATION || ""; + +if (PAYMENT_METHOD_CONFIGURATION) { + experimental_taintUniqueValue( + "Do not pass PAYMENT_METHOD_CONFIGURATION to the client", + process, + PAYMENT_METHOD_CONFIGURATION, + ); +} diff --git a/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/_components/create-project-form.tsx b/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/_components/create-project-form.tsx new file mode 100644 index 00000000000..d8b8b306f3c --- /dev/null +++ b/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/create-project/_components/create-project-form.tsx @@ -0,0 +1,452 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { type ProjectService, SERVICES } from "@thirdweb-dev/service-utils"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { reportOnboardingCompleted } from "@/analytics/report"; +import type { Project } from "@/api/project/projects"; +import type { CreateProjectPrefillOptions } from "@/components/project/create-project-modal"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { Card } from "@/components/ui/card"; +import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/Spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { createProjectClient } from "@/hooks/useApi"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { projectDomainsSchema, projectNameSchema } from "@/schema/validations"; +import { toArrFromList } from "@/utils/string"; +import { createVaultAccountAndAccessToken } from "../../../../../team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; + +const ALL_PROJECT_SERVICES = SERVICES.filter( + (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", +); + +export function CreateProjectFormOnboarding(props: { + prefill?: CreateProjectPrefillOptions; + enableNebulaServiceByDefault: boolean; + teamSlug: string; + teamId: string; +}) { + const [screen, setScreen] = useState< + { id: "create" } | { id: "api-details"; project: Project; secret: string } + >({ id: "create" }); + return ( + + + {screen.id === "create" && ( + { + const res = await createProjectClient(props.teamId, params); + await createVaultAccountAndAccessToken({ + project: res.project, + projectSecretKey: res.secret, + }).catch((error) => { + console.error( + "Failed to create vault account and access token", + error, + ); + throw error; + }); + return { + project: res.project, + secret: res.secret, + }; + }} + enableNebulaServiceByDefault={props.enableNebulaServiceByDefault} + onProjectCreated={(params) => { + setScreen({ + id: "api-details", + project: params.project, + secret: params.secret, + }); + }} + prefill={props.prefill} + /> + )} + + {screen.id === "api-details" && ( + { + setScreen({ id: "create" }); + }} + project={screen.project} + secret={screen.secret} + teamSlug={props.teamSlug} + /> + )} + + + ); +} + +const createProjectFormSchema = z.object({ + domains: projectDomainsSchema, + name: projectNameSchema, +}); + +type CreateProjectFormSchema = z.infer; + +function CreateProjectForm(props: { + createProject: (param: Partial) => Promise<{ + project: Project; + secret: string; + }>; + prefill?: CreateProjectPrefillOptions; + enableNebulaServiceByDefault: boolean; + onProjectCreated: (params: { project: Project; secret: string }) => void; +}) { + const [showAlert, setShowAlert] = useState<"no-domain" | "any-domain">(); + + const createProject = useMutation({ + mutationFn: props.createProject, + }); + + const form = useForm({ + defaultValues: { + domains: props.prefill?.domains || "", + name: props.prefill?.name || "", + }, + resolver: zodResolver(createProjectFormSchema), + }); + + function handleAPICreation(values: { name: string; domains: string }) { + const servicesToEnableByDefault = props.enableNebulaServiceByDefault + ? ALL_PROJECT_SERVICES + : ALL_PROJECT_SERVICES.filter((srv) => srv.name !== "nebula"); + + const formattedValues: Partial = { + domains: toArrFromList(values.domains), + name: values.name, + // enable all services + services: servicesToEnableByDefault.map((srv) => { + if (srv.name === "storage") { + return { + actions: srv.actions.map((sa) => sa.name), + name: srv.name, + } satisfies ProjectService; + } + + if (srv.name === "pay") { + return { + actions: [], + name: "pay", + payoutAddress: null, + } satisfies ProjectService; + } + + return { + actions: [], + name: srv.name, + } satisfies ProjectService; + }), + }; + + createProject.mutate(formattedValues, { + onError: () => { + toast.error("Failed to create a project"); + }, + onSuccess: (data) => { + props.onProjectCreated(data); + toast.success("Project created successfully"); + }, + }); + } + + const handleSubmit = form.handleSubmit((values) => { + if (!values.domains) { + setShowAlert("no-domain"); + } else if (values.domains === "*") { + setShowAlert("any-domain"); + } else { + handleAPICreation({ + domains: values.domains, + name: values.name, + }); + } + }); + + if (showAlert) { + return ( + setShowAlert(undefined)} + onProceed={() => { + handleAPICreation({ + domains: form.getValues("domains"), + name: form.getValues("name"), + }); + }} + type={showAlert} + /> + ); + } + + return ( +
+ +
+
+

Create Project

+
+ +
+ ( + + Project Name + + + + + + )} + /> + +
+ + { + form.setValue("domains", checked ? "*" : "", { + shouldDirty: true, + }); + }} + /> + Allow all domains + + + ( + + Allowed Domains + +