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:
@@ -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} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user