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

<!-- Remove bullet points below that don't apply to you -->

- 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
This commit is contained in:
sean-brydon
2025-11-25 09:14:59 +00:00
committed by GitHub
parent 2b2bf36703
commit d03f45dd0f
7 changed files with 166 additions and 37 deletions
@@ -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 <TeamInviteEmailView userEmail={userEmail} />;
}
return <TeamInviteView userEmail={userEmail} />;
};
+4 -2
View File
@@ -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,
});
}
@@ -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<MembershipRole, string[]>);
// 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,
};
}
@@ -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<TeamDetails>) => void;
setTeamBrand: (brand: Partial<TeamBrand>) => void;
setTeamInvites: (invites: Invite[]) => void;
setTeamId: (teamId: number | null) => void;
// Personal actions
setPersonalDetails: (details: Partial<PersonalDetails>) => void;
@@ -112,6 +114,7 @@ const initialState = {
logo: null,
},
teamInvites: [],
teamId: null,
personalDetails: {
name: "",
username: "",
@@ -156,6 +159,8 @@ export const useOnboardingStore = create<OnboardingState>()(
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<OnboardingState>()(
teamDetails: state.teamDetails,
teamBrand: state.teamBrand,
teamInvites: state.teamInvites,
teamId: state.teamId,
personalDetails: state.personalDetails,
}),
}
@@ -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<HTMLInputElement>(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")}
</Button>
</div>
@@ -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<InviteRole>("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={
<div className="flex w-full items-center justify-between gap-4">
<Button color="minimal" className="rounded-[10px]" onClick={handleBack} disabled={isSubmitting}>
{t("back")}
</Button>
<div className="flex w-full items-center justify-end gap-4">
<div className="flex items-center gap-2">
<Button
color="minimal"
@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect } from "react";
import { useFlags } from "@calcom/features/flags/hooks";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -21,14 +21,26 @@ type TeamInviteViewProps = {
export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const flags = useFlags();
const store = useOnboardingStore();
const { setTeamInvites, teamDetails } = store;
const { setTeamInvites, teamDetails, setTeamId } = store;
const { createTeam, isSubmitting } = useCreateTeam();
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
// Read teamId from query params and store it (from payment callback)
useEffect(() => {
const teamIdParam = searchParams?.get("teamId");
if (teamIdParam) {
const teamId = parseInt(teamIdParam, 10);
if (!isNaN(teamId)) {
setTeamId(teamId);
}
}
}, [searchParams, setTeamId]);
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const handleGoogleWorkspaceConnect = () => {