Skip to content

Commit d03f45d

Browse files
authored
fix: Onboarding v3 - create team then invite. (calcom#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
1 parent 2b2bf36 commit d03f45d

7 files changed

Lines changed: 166 additions & 37 deletions

File tree

apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants";
77

88
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
99

10+
import { TeamInviteEmailView } from "~/onboarding/teams/invite/email/team-invite-email-view";
1011
import { TeamInviteView } from "~/onboarding/teams/invite/team-invite-view";
1112

1213
export const generateMetadata = async () => {
@@ -26,12 +27,13 @@ const ServerPage = async () => {
2627
return redirect("/auth/login");
2728
}
2829

30+
const userEmail = session.user.email || "";
31+
32+
// If user is not ADMIN, show the email view directly instead of redirecting
2933
if (session.user.role !== "ADMIN") {
30-
return redirect("/onboarding/teams/invite/email");
34+
return <TeamInviteEmailView userEmail={userEmail} />;
3135
}
3236

33-
const userEmail = session.user.email || "";
34-
3537
return <TeamInviteView userEmail={userEmail} />;
3638
};
3739

apps/web/app/api/teams/create/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,10 @@ async function getHandler(req: NextRequest) {
113113
const isOnboarding = checkoutSessionMetadata.isOnboarding === "true";
114114

115115
if (isOnboarding) {
116-
// Redirect to event-types for onboarding flow
117-
return NextResponse.redirect(new URL("/onboarding/personal/settings", WEBAPP_URL), {
116+
// Redirect to invite flow after payment for onboarding with teamId as query param
117+
const inviteUrl = new URL("/onboarding/teams/invite", WEBAPP_URL);
118+
inviteUrl.searchParams.set("teamId", team.id.toString());
119+
return NextResponse.redirect(inviteUrl, {
118120
status: 302,
119121
});
120122
}

apps/web/modules/onboarding/hooks/useCreateTeam.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@ import { useRouter } from "next/navigation";
22
import { useState } from "react";
33

44
import { useFlagMap } from "@calcom/features/flags/context/provider";
5+
import { MembershipRole } from "@calcom/prisma/enums";
6+
import { CreationSource } from "@calcom/prisma/enums";
57
import { trpc } from "@calcom/trpc/react";
68

79
import type { OnboardingState } from "../store/onboarding-store";
10+
import { useOnboardingStore } from "../store/onboarding-store";
811

912
export function useCreateTeam() {
1013
const router = useRouter();
1114
const [isSubmitting, setIsSubmitting] = useState(false);
1215
const flags = useFlagMap();
16+
const { setTeamId, teamId } = useOnboardingStore();
1317

1418
const createTeamMutation = trpc.viewer.teams.create.useMutation();
19+
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation();
1520

1621
const createTeam = async (store: OnboardingState) => {
1722
setIsSubmitting(true);
@@ -30,6 +35,7 @@ export function useCreateTeam() {
3035
const result = await createTeamMutation.mutateAsync({
3136
name: teamDetails.name,
3237
slug: teamDetails.slug,
38+
bio: teamDetails.bio,
3339
logo: teamBrand.logo,
3440
isOnboarding: true,
3541
});
@@ -41,11 +47,9 @@ export function useCreateTeam() {
4147
}
4248

4349
if (result.team) {
44-
// Not sure we need this flag check - keeping it here for safe keeping as this is called only from v3 onboarding flow
45-
const gettingStartedPath = flags["onboarding-v3"]
46-
? "/onboarding/personal/settings"
47-
: "/getting-started";
48-
router.push(gettingStartedPath);
50+
// Store the teamId and redirect to invite flow after team creation
51+
setTeamId(result.team.id);
52+
router.push(`/onboarding/teams/invite?teamId=${result.team.id}`);
4953
}
5054
} catch (error) {
5155
console.error("Failed to create team:", error);
@@ -55,9 +59,72 @@ export function useCreateTeam() {
5559
}
5660
};
5761

62+
const inviteMembers = async (
63+
invites: Array<{ email: string; role: "MEMBER" | "ADMIN" }>,
64+
language: string
65+
) => {
66+
if (!teamId) {
67+
throw new Error("Team ID is required to invite members");
68+
}
69+
70+
setIsSubmitting(true);
71+
72+
try {
73+
// Filter and validate invites
74+
const validInvites = invites.filter((invite) => invite.email && invite.email.trim().length > 0);
75+
76+
if (validInvites.length === 0) {
77+
throw new Error("At least one valid email address is required");
78+
}
79+
80+
// Group invites by role and send separate requests for each role
81+
// This is necessary because the schema validation expects array of strings when using bulk invites
82+
const invitesByRole = validInvites.reduce((acc, invite) => {
83+
const role = invite.role === "ADMIN" ? MembershipRole.ADMIN : MembershipRole.MEMBER;
84+
if (!acc[role]) {
85+
acc[role] = [];
86+
}
87+
acc[role].push(invite.email.trim().toLowerCase());
88+
return acc;
89+
}, {} as Record<MembershipRole, string[]>);
90+
91+
// Send invites for each role group
92+
await Promise.all(
93+
Object.entries(invitesByRole).map(([role, emails]) =>
94+
inviteMemberMutation.mutateAsync({
95+
teamId,
96+
usernameOrEmail: emails, // Array of strings, not objects
97+
role: role as MembershipRole,
98+
language,
99+
creationSource: CreationSource.WEBAPP,
100+
})
101+
)
102+
);
103+
104+
// Redirect to personal settings after successful invite
105+
const gettingStartedPath = flags["onboarding-v3"]
106+
? "/onboarding/personal/settings"
107+
: "/getting-started";
108+
router.push(gettingStartedPath);
109+
} catch (error) {
110+
console.error("Failed to invite members:", error);
111+
// Extract error message from TRPC error
112+
if (error && typeof error === "object" && "message" in error) {
113+
throw new Error(error.message as string);
114+
}
115+
if (error instanceof Error) {
116+
throw error;
117+
}
118+
throw new Error("Failed to invite members. Please try again.");
119+
} finally {
120+
setIsSubmitting(false);
121+
}
122+
};
123+
58124
return {
59125
createTeam,
126+
inviteMembers,
60127
isSubmitting,
61-
error: createTeamMutation.error,
128+
error: createTeamMutation.error || inviteMemberMutation.error,
62129
};
63130
}

apps/web/modules/onboarding/store/onboarding-store.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface OnboardingState {
6363
teamDetails: TeamDetails;
6464
teamBrand: TeamBrand;
6565
teamInvites: Invite[];
66+
teamId: number | null;
6667

6768
// Personal user state
6869
personalDetails: PersonalDetails;
@@ -79,6 +80,7 @@ export interface OnboardingState {
7980
setTeamDetails: (details: Partial<TeamDetails>) => void;
8081
setTeamBrand: (brand: Partial<TeamBrand>) => void;
8182
setTeamInvites: (invites: Invite[]) => void;
83+
setTeamId: (teamId: number | null) => void;
8284

8385
// Personal actions
8486
setPersonalDetails: (details: Partial<PersonalDetails>) => void;
@@ -112,6 +114,7 @@ const initialState = {
112114
logo: null,
113115
},
114116
teamInvites: [],
117+
teamId: null,
115118
personalDetails: {
116119
name: "",
117120
username: "",
@@ -156,6 +159,8 @@ export const useOnboardingStore = create<OnboardingState>()(
156159

157160
setTeamInvites: (invites) => set({ teamInvites: invites }),
158161

162+
setTeamId: (teamId) => set({ teamId }),
163+
159164
setPersonalDetails: (details) =>
160165
set((state) => ({
161166
personalDetails: { ...state.personalDetails, ...details },
@@ -177,6 +182,7 @@ export const useOnboardingStore = create<OnboardingState>()(
177182
teamDetails: state.teamDetails,
178183
teamBrand: state.teamBrand,
179184
teamInvites: state.teamInvites,
185+
teamId: state.teamId,
180186
personalDetails: state.personalDetails,
181187
}),
182188
}

apps/web/modules/onboarding/teams/details/team-details-view.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ImageUploader } from "@calcom/ui/components/image-uploader";
1313
import { OnboardingCard } from "../../components/OnboardingCard";
1414
import { OnboardingLayout } from "../../components/OnboardingLayout";
1515
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
16+
import { useCreateTeam } from "../../hooks/useCreateTeam";
1617
import { useOnboardingStore } from "../../store/onboarding-store";
1718
import { ValidatedTeamSlug } from "./validated-team-slug";
1819

@@ -23,7 +24,9 @@ type TeamDetailsViewProps = {
2324
export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
2425
const router = useRouter();
2526
const { t } = useLocale();
26-
const { teamDetails, teamBrand, setTeamDetails, setTeamBrand } = useOnboardingStore();
27+
const store = useOnboardingStore();
28+
const { teamDetails, teamBrand, setTeamDetails, setTeamBrand } = store;
29+
const { createTeam, isSubmitting } = useCreateTeam();
2730

2831
const logoRef = useRef<HTMLInputElement>(null);
2932
const [teamName, setTeamName] = useState("");
@@ -62,7 +65,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
6265
setTeamLogo(newLogo);
6366
};
6467

65-
const handleContinue = () => {
68+
const handleContinue = async () => {
6669
if (!isSlugValid) {
6770
return;
6871
}
@@ -77,8 +80,8 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
7780
logo: teamLogo || null,
7881
});
7982

80-
// 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
81-
router.push("/onboarding/teams/invite/email");
83+
// Create the team (will handle payment redirect if needed)
84+
await createTeam(store);
8285
};
8386

8487
return (
@@ -101,7 +104,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
101104
color="primary"
102105
className="rounded-[10px]"
103106
onClick={handleContinue}
104-
disabled={!isSlugValid || !teamName || !teamSlug}>
107+
disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}>
105108
{t("continue")}
106109
</Button>
107110
</div>

apps/web/modules/onboarding/teams/invite/email/team-invite-email-view.tsx

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"use client";
22

33
import { zodResolver } from "@hookform/resolvers/zod";
4-
import { useRouter } from "next/navigation";
5-
import React from "react";
4+
import { useRouter, useSearchParams } from "next/navigation";
5+
import React, { useEffect } from "react";
66
import { useForm, useFieldArray } from "react-hook-form";
77
import { z } from "zod";
88

99
import { useLocale } from "@calcom/lib/hooks/useLocale";
1010
import { Button } from "@calcom/ui/components/button";
1111
import { Form } from "@calcom/ui/components/form";
12+
import { showToast } from "@calcom/ui/components/toast";
1213

1314
import { EmailInviteForm } from "../../../components/EmailInviteForm";
1415
import { OnboardingCard } from "../../../components/OnboardingCard";
@@ -31,12 +32,24 @@ type FormValues = {
3132

3233
export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => {
3334
const router = useRouter();
34-
const { t } = useLocale();
35+
const searchParams = useSearchParams();
36+
const { t, i18n } = useLocale();
3537

3638
const store = useOnboardingStore();
37-
const { teamInvites, setTeamInvites, teamDetails } = store;
39+
const { teamInvites, setTeamInvites, teamDetails, setTeamId, teamId } = store;
3840
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
39-
const { createTeam, isSubmitting } = useCreateTeam();
41+
const { inviteMembers, isSubmitting } = useCreateTeam();
42+
43+
// Read teamId from query params and store it (from payment callback or redirect)
44+
useEffect(() => {
45+
const teamIdParam = searchParams?.get("teamId");
46+
if (teamIdParam) {
47+
const teamId = parseInt(teamIdParam, 10);
48+
if (!isNaN(teamId)) {
49+
setTeamId(teamId);
50+
}
51+
}
52+
}, [searchParams, setTeamId]);
4053

4154
const formSchema = z.object({
4255
invites: z.array(
@@ -63,26 +76,53 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
6376
});
6477

6578
const handleContinue = async (data: FormValues) => {
79+
const teamIdParam = searchParams?.get("teamId");
80+
const parsedTeamId = !teamId ? parseInt(teamIdParam || "", 10) : teamId;
81+
if (!parsedTeamId) {
82+
showToast(
83+
t("team_id_missing") || "Team ID is missing. Please go back and create your team first.",
84+
"error"
85+
);
86+
return;
87+
}
88+
6689
const invitesWithTeam = data.invites.map((invite) => ({
6790
email: invite.email,
68-
team: teamDetails.name,
6991
role: invite.role,
92+
team: teamDetails.name,
7093
}));
7194

7295
setTeamInvites(invitesWithTeam);
7396

74-
// Create the team (will handle checkout redirect if needed)
75-
await createTeam(store);
76-
};
77-
78-
const handleBack = () => {
79-
router.push("/onboarding/teams/invite");
97+
// Filter out empty emails and invite members
98+
const validInvites = data.invites.filter((invite) => invite.email && invite.email.trim().length > 0);
99+
100+
if (validInvites.length > 0) {
101+
try {
102+
await inviteMembers(
103+
validInvites.map((invite) => ({
104+
email: invite.email,
105+
role: invite.role,
106+
})),
107+
i18n.language
108+
);
109+
} catch (error) {
110+
const errorMessage =
111+
error instanceof Error ? error.message : t("something_went_wrong") || "Something went wrong";
112+
showToast(errorMessage, "error");
113+
}
114+
} else {
115+
// No invites, skip to personal settings
116+
const gettingStartedPath = "/onboarding/personal/settings";
117+
router.push(gettingStartedPath);
118+
}
80119
};
81120

82121
const handleSkip = async () => {
83122
setTeamInvites([]);
84-
// Create the team without invites (will handle checkout redirect if needed)
85-
await createTeam(store);
123+
// Skip inviting members and go to personal settings
124+
const gettingStartedPath = "/onboarding/personal/settings";
125+
router.push(gettingStartedPath);
86126
};
87127

88128
const hasValidInvites = fields.some((_, index) => {
@@ -101,10 +141,7 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
101141
title={t("invite_via_email")}
102142
subtitle={t("team_invite_subtitle")}
103143
footer={
104-
<div className="flex w-full items-center justify-between gap-4">
105-
<Button color="minimal" className="rounded-[10px]" onClick={handleBack} disabled={isSubmitting}>
106-
{t("back")}
107-
</Button>
144+
<div className="flex w-full items-center justify-end gap-4">
108145
<div className="flex items-center gap-2">
109146
<Button
110147
color="minimal"

0 commit comments

Comments
 (0)