Skip to content

Commit 435587f

Browse files
authored
feat: onboarding v3 teams (calcom#24573)
## What does this PR do? This PR is stacked on calcom#24299 this adds the v3 flow for onboarding with teams. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds the v3 team onboarding flow with Details, Brand, and Invite steps, including slug validation, branding, and member invites. Updates routing to support the Team plan and creates teams with Stripe checkout when needed. - **New Features** - Team Details: name and slug with async availability check and URL preview. - Team Brand: hex color picker and logo upload with live preview. - Team Invite: add/remove emails, invite role toggle (Member/Admin), and form validation. - State: adds teamDetails, teamBrand, and teamInvites to the onboarding store with actions. - Creation: new useCreateTeam hook; redirects to Stripe if checkout URL is returned, or to Getting Started on success. - Routing/Auth: protected team pages (details, brand, invite) and updates plan selection to route to /onboarding/teams. <!-- End of auto-generated description by cubic. -->
1 parent e5abe93 commit 435587f

11 files changed

Lines changed: 961 additions & 2 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { _generateMetadata } from "app/_utils";
2+
import { cookies, headers } from "next/headers";
3+
import { redirect } from "next/navigation";
4+
5+
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
6+
import { APP_NAME } from "@calcom/lib/constants";
7+
8+
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
9+
10+
import { TeamBrandView } from "~/onboarding/teams/brand/team-brand-view";
11+
12+
export const generateMetadata = async () => {
13+
return await _generateMetadata(
14+
(t) => `${APP_NAME} - ${t("team_brand")}`,
15+
() => "",
16+
true,
17+
undefined,
18+
"/onboarding/teams/brand"
19+
);
20+
};
21+
22+
const ServerPage = async () => {
23+
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
24+
25+
if (!session?.user?.id) {
26+
return redirect("/auth/login");
27+
}
28+
29+
const userEmail = session.user.email || "";
30+
31+
return <TeamBrandView userEmail={userEmail} />;
32+
};
33+
34+
export default ServerPage;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { _generateMetadata } from "app/_utils";
2+
import { cookies, headers } from "next/headers";
3+
import { redirect } from "next/navigation";
4+
5+
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
6+
import { APP_NAME } from "@calcom/lib/constants";
7+
8+
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
9+
10+
import { TeamDetailsView } from "~/onboarding/teams/details/team-details-view";
11+
12+
export const generateMetadata = async () => {
13+
return await _generateMetadata(
14+
(t) => `${APP_NAME} - ${t("team_details")}`,
15+
() => "",
16+
true,
17+
undefined,
18+
"/onboarding/teams/details"
19+
);
20+
};
21+
22+
const ServerPage = async () => {
23+
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
24+
25+
if (!session?.user?.id) {
26+
return redirect("/auth/login");
27+
}
28+
29+
const userEmail = session.user.email || "";
30+
31+
return <TeamDetailsView userEmail={userEmail} />;
32+
};
33+
34+
export default ServerPage;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { _generateMetadata } from "app/_utils";
2+
import { cookies, headers } from "next/headers";
3+
import { redirect } from "next/navigation";
4+
5+
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
6+
import { APP_NAME } from "@calcom/lib/constants";
7+
8+
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
9+
10+
import { TeamInviteView } from "~/onboarding/teams/invite/team-invite-view";
11+
12+
export const generateMetadata = async () => {
13+
return await _generateMetadata(
14+
(t) => `${APP_NAME} - ${t("team_invite")}`,
15+
() => "",
16+
true,
17+
undefined,
18+
"/onboarding/teams/invite"
19+
);
20+
};
21+
22+
const ServerPage = async () => {
23+
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
24+
25+
if (!session?.user?.id) {
26+
return redirect("/auth/login");
27+
}
28+
29+
const userEmail = session.user.email || "";
30+
31+
return <TeamInviteView userEmail={userEmail} />;
32+
};
33+
34+
export default ServerPage;

apps/web/modules/onboarding/getting-started/onboarding-view.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
2323
const handleContinue = () => {
2424
if (selectedPlan === "organization") {
2525
router.push("/onboarding/organization/details");
26-
}
27-
// TODO: Handle other plan types
26+
} else if (selectedPlan === "team") {
27+
router.push("/onboarding/teams/details");
28+
} // TODO: Handle other plan types
2829
};
2930

3031
const allPlans = [
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useRouter } from "next/navigation";
2+
import { useState } from "react";
3+
4+
import { trpc } from "@calcom/trpc/react";
5+
6+
import type { OnboardingState } from "../store/onboarding-store";
7+
8+
export function useCreateTeam() {
9+
const router = useRouter();
10+
const [isSubmitting, setIsSubmitting] = useState(false);
11+
12+
const createTeamMutation = trpc.viewer.teams.create.useMutation();
13+
14+
const createTeam = async (store: OnboardingState) => {
15+
setIsSubmitting(true);
16+
17+
try {
18+
const { teamDetails, teamBrand } = store;
19+
20+
// Create the team
21+
const result = await createTeamMutation.mutateAsync({
22+
name: teamDetails.name,
23+
slug: teamDetails.slug,
24+
logo: teamBrand.logo,
25+
});
26+
27+
// If there's a checkout URL, redirect to Stripe payment
28+
if (result.url && !result.team) {
29+
window.location.href = result.url;
30+
return;
31+
}
32+
33+
if (result.team) {
34+
router.push("/getting-started");
35+
}
36+
} catch (error) {
37+
console.error("Failed to create team:", error);
38+
throw error;
39+
} finally {
40+
setIsSubmitting(false);
41+
}
42+
};
43+
44+
return {
45+
createTeam,
46+
isSubmitting,
47+
error: createTeamMutation.error,
48+
};
49+
}

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ export interface Invite {
2626
role: InviteRole;
2727
}
2828

29+
export interface TeamDetails {
30+
name: string;
31+
slug: string;
32+
}
33+
34+
export interface TeamBrand {
35+
color: string;
36+
logo: string | null; // base64 or URL
37+
}
38+
2939
export interface OnboardingState {
3040
selectedPlan: PlanType | null;
3141

@@ -38,6 +48,11 @@ export interface OnboardingState {
3848
invites: Invite[];
3949
inviteRole: InviteRole;
4050

51+
// Team-specific state
52+
teamDetails: TeamDetails;
53+
teamBrand: TeamBrand;
54+
teamInvites: Invite[];
55+
4156
// Actions
4257
setSelectedPlan: (plan: PlanType) => void;
4358
setOrganizationDetails: (details: Partial<OrganizationDetails>) => void;
@@ -46,6 +61,11 @@ export interface OnboardingState {
4661
setInvites: (invites: Invite[]) => void;
4762
setInviteRole: (role: InviteRole) => void;
4863

64+
// Team actions
65+
setTeamDetails: (details: Partial<TeamDetails>) => void;
66+
setTeamBrand: (brand: Partial<TeamBrand>) => void;
67+
setTeamInvites: (invites: Invite[]) => void;
68+
4969
// Reset
5070
resetOnboarding: () => void;
5171
}
@@ -65,6 +85,15 @@ const initialState = {
6585
teams: [],
6686
invites: [],
6787
inviteRole: "MEMBER" as InviteRole,
88+
teamDetails: {
89+
name: "",
90+
slug: "",
91+
},
92+
teamBrand: {
93+
color: "#000000",
94+
logo: null,
95+
},
96+
teamInvites: [],
6897
};
6998

7099
export const useOnboardingStore = create<OnboardingState>()(
@@ -90,6 +119,18 @@ export const useOnboardingStore = create<OnboardingState>()(
90119

91120
setInviteRole: (role) => set({ inviteRole: role }),
92121

122+
setTeamDetails: (details) =>
123+
set((state) => ({
124+
teamDetails: { ...state.teamDetails, ...details },
125+
})),
126+
127+
setTeamBrand: (brand) =>
128+
set((state) => ({
129+
teamBrand: { ...state.teamBrand, ...brand },
130+
})),
131+
132+
setTeamInvites: (invites) => set({ teamInvites: invites }),
133+
93134
resetOnboarding: () => set(initialState),
94135
}),
95136
{
@@ -102,6 +143,9 @@ export const useOnboardingStore = create<OnboardingState>()(
102143
teams: state.teams,
103144
invites: state.invites,
104145
inviteRole: state.inviteRole,
146+
teamDetails: state.teamDetails,
147+
teamBrand: state.teamBrand,
148+
teamInvites: state.teamInvites,
105149
}),
106150
}
107151
)

0 commit comments

Comments
 (0)