From d03f45dd0f1b710132aeb6bf01a3a1d3eff046fc Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 25 Nov 2025 09:14:59 +0000 Subject: [PATCH] fix: Onboarding v3 - create team then invite. (#25364) ## What does this PR do? This PR changes the order of creating the team -> paying -> invite users. The old approach was create team -> invite -> pay. But we are matching the current implementation of how billing works with usage based billing ## How should this be tested? Enable onboarding-v3 flag on your instance Ensure you have stripe enabled + stripe:listen running login as onboarding@example.com, onboarding Go through the team setup flow Pay Invite Ensure uers were added to team Create a new account disable billing Test again ## Checklist - I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code doesn't follow the style guidelines of this project - I haven't commented my code, particularly in hard-to-understand areas - I haven't checked if my changes generate no new warnings --- .../onboarding/teams/invite/page.tsx | 10 ++- apps/web/app/api/teams/create/route.ts | 6 +- .../modules/onboarding/hooks/useCreateTeam.ts | 79 +++++++++++++++++-- .../onboarding/store/onboarding-store.ts | 6 ++ .../teams/details/team-details-view.tsx | 13 +-- .../invite/email/team-invite-email-view.tsx | 71 +++++++++++++---- .../teams/invite/team-invite-view.tsx | 18 ++++- 7 files changed, 166 insertions(+), 37 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx index aeba1acca1..3bde80e55e 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx @@ -7,6 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import { TeamInviteEmailView } from "~/onboarding/teams/invite/email/team-invite-email-view"; import { TeamInviteView } from "~/onboarding/teams/invite/team-invite-view"; export const generateMetadata = async () => { @@ -26,12 +27,13 @@ const ServerPage = async () => { return redirect("/auth/login"); } - if (session.user.role !== "ADMIN") { - return redirect("/onboarding/teams/invite/email"); - } - const userEmail = session.user.email || ""; + // If user is not ADMIN, show the email view directly instead of redirecting + if (session.user.role !== "ADMIN") { + return ; + } + return ; }; diff --git a/apps/web/app/api/teams/create/route.ts b/apps/web/app/api/teams/create/route.ts index 04fe9399a1..e5f5e996fa 100644 --- a/apps/web/app/api/teams/create/route.ts +++ b/apps/web/app/api/teams/create/route.ts @@ -113,8 +113,10 @@ async function getHandler(req: NextRequest) { const isOnboarding = checkoutSessionMetadata.isOnboarding === "true"; if (isOnboarding) { - // Redirect to event-types for onboarding flow - return NextResponse.redirect(new URL("/onboarding/personal/settings", WEBAPP_URL), { + // Redirect to invite flow after payment for onboarding with teamId as query param + const inviteUrl = new URL("/onboarding/teams/invite", WEBAPP_URL); + inviteUrl.searchParams.set("teamId", team.id.toString()); + return NextResponse.redirect(inviteUrl, { status: 302, }); } diff --git a/apps/web/modules/onboarding/hooks/useCreateTeam.ts b/apps/web/modules/onboarding/hooks/useCreateTeam.ts index 523ada7f5d..52fb4467ea 100644 --- a/apps/web/modules/onboarding/hooks/useCreateTeam.ts +++ b/apps/web/modules/onboarding/hooks/useCreateTeam.ts @@ -2,16 +2,21 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { useFlagMap } from "@calcom/features/flags/context/provider"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { CreationSource } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; import type { OnboardingState } from "../store/onboarding-store"; +import { useOnboardingStore } from "../store/onboarding-store"; export function useCreateTeam() { const router = useRouter(); const [isSubmitting, setIsSubmitting] = useState(false); const flags = useFlagMap(); + const { setTeamId, teamId } = useOnboardingStore(); const createTeamMutation = trpc.viewer.teams.create.useMutation(); + const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation(); const createTeam = async (store: OnboardingState) => { setIsSubmitting(true); @@ -30,6 +35,7 @@ export function useCreateTeam() { const result = await createTeamMutation.mutateAsync({ name: teamDetails.name, slug: teamDetails.slug, + bio: teamDetails.bio, logo: teamBrand.logo, isOnboarding: true, }); @@ -41,11 +47,9 @@ export function useCreateTeam() { } if (result.team) { - // Not sure we need this flag check - keeping it here for safe keeping as this is called only from v3 onboarding flow - const gettingStartedPath = flags["onboarding-v3"] - ? "/onboarding/personal/settings" - : "/getting-started"; - router.push(gettingStartedPath); + // Store the teamId and redirect to invite flow after team creation + setTeamId(result.team.id); + router.push(`/onboarding/teams/invite?teamId=${result.team.id}`); } } catch (error) { console.error("Failed to create team:", error); @@ -55,9 +59,72 @@ export function useCreateTeam() { } }; + const inviteMembers = async ( + invites: Array<{ email: string; role: "MEMBER" | "ADMIN" }>, + language: string + ) => { + if (!teamId) { + throw new Error("Team ID is required to invite members"); + } + + setIsSubmitting(true); + + try { + // Filter and validate invites + const validInvites = invites.filter((invite) => invite.email && invite.email.trim().length > 0); + + if (validInvites.length === 0) { + throw new Error("At least one valid email address is required"); + } + + // Group invites by role and send separate requests for each role + // This is necessary because the schema validation expects array of strings when using bulk invites + const invitesByRole = validInvites.reduce((acc, invite) => { + const role = invite.role === "ADMIN" ? MembershipRole.ADMIN : MembershipRole.MEMBER; + if (!acc[role]) { + acc[role] = []; + } + acc[role].push(invite.email.trim().toLowerCase()); + return acc; + }, {} as Record); + + // Send invites for each role group + await Promise.all( + Object.entries(invitesByRole).map(([role, emails]) => + inviteMemberMutation.mutateAsync({ + teamId, + usernameOrEmail: emails, // Array of strings, not objects + role: role as MembershipRole, + language, + creationSource: CreationSource.WEBAPP, + }) + ) + ); + + // Redirect to personal settings after successful invite + const gettingStartedPath = flags["onboarding-v3"] + ? "/onboarding/personal/settings" + : "/getting-started"; + router.push(gettingStartedPath); + } catch (error) { + console.error("Failed to invite members:", error); + // Extract error message from TRPC error + if (error && typeof error === "object" && "message" in error) { + throw new Error(error.message as string); + } + if (error instanceof Error) { + throw error; + } + throw new Error("Failed to invite members. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + return { createTeam, + inviteMembers, isSubmitting, - error: createTeamMutation.error, + error: createTeamMutation.error || inviteMemberMutation.error, }; } diff --git a/apps/web/modules/onboarding/store/onboarding-store.ts b/apps/web/modules/onboarding/store/onboarding-store.ts index 52579e809f..d9774e60f5 100644 --- a/apps/web/modules/onboarding/store/onboarding-store.ts +++ b/apps/web/modules/onboarding/store/onboarding-store.ts @@ -63,6 +63,7 @@ export interface OnboardingState { teamDetails: TeamDetails; teamBrand: TeamBrand; teamInvites: Invite[]; + teamId: number | null; // Personal user state personalDetails: PersonalDetails; @@ -79,6 +80,7 @@ export interface OnboardingState { setTeamDetails: (details: Partial) => void; setTeamBrand: (brand: Partial) => void; setTeamInvites: (invites: Invite[]) => void; + setTeamId: (teamId: number | null) => void; // Personal actions setPersonalDetails: (details: Partial) => void; @@ -112,6 +114,7 @@ const initialState = { logo: null, }, teamInvites: [], + teamId: null, personalDetails: { name: "", username: "", @@ -156,6 +159,8 @@ export const useOnboardingStore = create()( setTeamInvites: (invites) => set({ teamInvites: invites }), + setTeamId: (teamId) => set({ teamId }), + setPersonalDetails: (details) => set((state) => ({ personalDetails: { ...state.personalDetails, ...details }, @@ -177,6 +182,7 @@ export const useOnboardingStore = create()( teamDetails: state.teamDetails, teamBrand: state.teamBrand, teamInvites: state.teamInvites, + teamId: state.teamId, personalDetails: state.personalDetails, }), } diff --git a/apps/web/modules/onboarding/teams/details/team-details-view.tsx b/apps/web/modules/onboarding/teams/details/team-details-view.tsx index 3647758165..b083d5e7f9 100644 --- a/apps/web/modules/onboarding/teams/details/team-details-view.tsx +++ b/apps/web/modules/onboarding/teams/details/team-details-view.tsx @@ -13,6 +13,7 @@ import { ImageUploader } from "@calcom/ui/components/image-uploader"; import { OnboardingCard } from "../../components/OnboardingCard"; import { OnboardingLayout } from "../../components/OnboardingLayout"; import { OnboardingBrowserView } from "../../components/onboarding-browser-view"; +import { useCreateTeam } from "../../hooks/useCreateTeam"; import { useOnboardingStore } from "../../store/onboarding-store"; import { ValidatedTeamSlug } from "./validated-team-slug"; @@ -23,7 +24,9 @@ type TeamDetailsViewProps = { export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => { const router = useRouter(); const { t } = useLocale(); - const { teamDetails, teamBrand, setTeamDetails, setTeamBrand } = useOnboardingStore(); + const store = useOnboardingStore(); + const { teamDetails, teamBrand, setTeamDetails, setTeamBrand } = store; + const { createTeam, isSubmitting } = useCreateTeam(); const logoRef = useRef(null); const [teamName, setTeamName] = useState(""); @@ -62,7 +65,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => { setTeamLogo(newLogo); }; - const handleContinue = () => { + const handleContinue = async () => { if (!isSlugValid) { return; } @@ -77,8 +80,8 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => { logo: teamLogo || null, }); - // We will push to /invite when we have other methods of inviting users from onboarding i.e. CSV upload, Google Workspace connect, copy link etc - router.push("/onboarding/teams/invite/email"); + // Create the team (will handle payment redirect if needed) + await createTeam(store); }; return ( @@ -101,7 +104,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => { color="primary" className="rounded-[10px]" onClick={handleContinue} - disabled={!isSlugValid || !teamName || !teamSlug}> + disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}> {t("continue")} diff --git a/apps/web/modules/onboarding/teams/invite/email/team-invite-email-view.tsx b/apps/web/modules/onboarding/teams/invite/email/team-invite-email-view.tsx index 1cb9963f74..3657a02fe9 100644 --- a/apps/web/modules/onboarding/teams/invite/email/team-invite-email-view.tsx +++ b/apps/web/modules/onboarding/teams/invite/email/team-invite-email-view.tsx @@ -1,14 +1,15 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useRouter } from "next/navigation"; -import React from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import React, { useEffect } from "react"; import { useForm, useFieldArray } from "react-hook-form"; import { z } from "zod"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button } from "@calcom/ui/components/button"; import { Form } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; import { EmailInviteForm } from "../../../components/EmailInviteForm"; import { OnboardingCard } from "../../../components/OnboardingCard"; @@ -31,12 +32,24 @@ type FormValues = { export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => { const router = useRouter(); - const { t } = useLocale(); + const searchParams = useSearchParams(); + const { t, i18n } = useLocale(); const store = useOnboardingStore(); - const { teamInvites, setTeamInvites, teamDetails } = store; + const { teamInvites, setTeamInvites, teamDetails, setTeamId, teamId } = store; const [inviteRole, setInviteRole] = React.useState("MEMBER"); - const { createTeam, isSubmitting } = useCreateTeam(); + const { inviteMembers, isSubmitting } = useCreateTeam(); + + // Read teamId from query params and store it (from payment callback or redirect) + useEffect(() => { + const teamIdParam = searchParams?.get("teamId"); + if (teamIdParam) { + const teamId = parseInt(teamIdParam, 10); + if (!isNaN(teamId)) { + setTeamId(teamId); + } + } + }, [searchParams, setTeamId]); const formSchema = z.object({ invites: z.array( @@ -63,26 +76,53 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => }); const handleContinue = async (data: FormValues) => { + const teamIdParam = searchParams?.get("teamId"); + const parsedTeamId = !teamId ? parseInt(teamIdParam || "", 10) : teamId; + if (!parsedTeamId) { + showToast( + t("team_id_missing") || "Team ID is missing. Please go back and create your team first.", + "error" + ); + return; + } + const invitesWithTeam = data.invites.map((invite) => ({ email: invite.email, - team: teamDetails.name, role: invite.role, + team: teamDetails.name, })); setTeamInvites(invitesWithTeam); - // Create the team (will handle checkout redirect if needed) - await createTeam(store); - }; + // Filter out empty emails and invite members + const validInvites = data.invites.filter((invite) => invite.email && invite.email.trim().length > 0); - const handleBack = () => { - router.push("/onboarding/teams/invite"); + if (validInvites.length > 0) { + try { + await inviteMembers( + validInvites.map((invite) => ({ + email: invite.email, + role: invite.role, + })), + i18n.language + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : t("something_went_wrong") || "Something went wrong"; + showToast(errorMessage, "error"); + } + } else { + // No invites, skip to personal settings + const gettingStartedPath = "/onboarding/personal/settings"; + router.push(gettingStartedPath); + } }; const handleSkip = async () => { setTeamInvites([]); - // Create the team without invites (will handle checkout redirect if needed) - await createTeam(store); + // Skip inviting members and go to personal settings + const gettingStartedPath = "/onboarding/personal/settings"; + router.push(gettingStartedPath); }; const hasValidInvites = fields.some((_, index) => { @@ -101,10 +141,7 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => title={t("invite_via_email")} subtitle={t("team_invite_subtitle")} footer={ -
- +