Files
calendar/apps/web/modules/onboarding/hooks/useCreateTeam.ts
T
sean-brydonandGitHub d03f45dd0f 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
2025-11-25 09:14:59 +00:00

131 lines
4.2 KiB
TypeScript

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);
try {
const { teamDetails, teamBrand } = store;
// Validate team details - if empty, redirect back to team details step
if (!teamDetails.name || !teamDetails.name.trim() || !teamDetails.slug || !teamDetails.slug.trim()) {
router.push("/onboarding/teams/details");
setIsSubmitting(false);
return;
}
// Create the team
const result = await createTeamMutation.mutateAsync({
name: teamDetails.name,
slug: teamDetails.slug,
bio: teamDetails.bio,
logo: teamBrand.logo,
isOnboarding: true,
});
// If there's a checkout URL, redirect to Stripe payment
if (result.url && !result.team) {
window.location.href = result.url;
return;
}
if (result.team) {
// 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);
throw error;
} finally {
setIsSubmitting(false);
}
};
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 || inviteMemberMutation.error,
};
}