Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,21 @@ export const generateMetadata = async () => {
);
};

const ServerPage = async () => {
const ServerPage = async ({ searchParams }: { searchParams: Promise<{ teamId?: string }> }) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });

if (!session?.user?.id) {
return redirect("/auth/login");
}

const params = await searchParams;
const teamId = params?.teamId;

if (session.user.role !== "ADMIN") {
return redirect("/onboarding/teams/invite/email");
const redirectUrl = teamId
? `/onboarding/teams/invite/email?teamId=${teamId}`
: "/onboarding/teams/invite/email";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will try to pull from context if this part of the flow happens (it never should)

return redirect(redirectUrl);
}

const userEmail = session.user.email || "";
Expand Down
6 changes: 4 additions & 2 deletions apps/web/app/api/teams/create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down
79 changes: 73 additions & 6 deletions apps/web/modules/onboarding/hooks/useCreateTeam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
});
Expand All @@ -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);
Expand All @@ -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,
};
}
6 changes: 6 additions & 0 deletions apps/web/modules/onboarding/store/onboarding-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface OnboardingState {
teamDetails: TeamDetails;
teamBrand: TeamBrand;
teamInvites: Invite[];
teamId: number | null;

// Personal user state
personalDetails: PersonalDetails;
Expand All @@ -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;
Expand Down Expand Up @@ -112,6 +114,7 @@ const initialState = {
logo: null,
},
teamInvites: [],
teamId: null,
personalDetails: {
name: "",
username: "",
Expand Down Expand Up @@ -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 },
Expand All @@ -177,6 +182,7 @@ export const useOnboardingStore = create<OnboardingState>()(
teamDetails: state.teamDetails,
teamBrand: state.teamBrand,
teamInvites: state.teamInvites,
teamId: state.teamId,
personalDetails: state.personalDetails,
}),
}
Expand Down
13 changes: 8 additions & 5 deletions apps/web/modules/onboarding/teams/details/team-details-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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("");
Expand Down Expand Up @@ -62,7 +65,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
setTeamLogo(newLogo);
};

const handleContinue = () => {
const handleContinue = async () => {
if (!isSlugValid) {
return;
}
Expand All @@ -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 (
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(
Expand All @@ -63,16 +76,43 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
});

const handleContinue = async (data: FormValues) => {
if (!teamId) {
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,
}));

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);

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 handleBack = () => {
Expand All @@ -81,8 +121,9 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>

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) => {
Expand All @@ -101,10 +142,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"
Expand Down
18 changes: 15 additions & 3 deletions apps/web/modules/onboarding/teams/invite/team-invite-view.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = () => {
Expand Down
Loading