diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx
new file mode 100644
index 0000000000..5b93b7feb8
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx
@@ -0,0 +1,38 @@
+import { cookies, headers } from "next/headers";
+import { redirect } from "next/navigation";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
+import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
+import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
+import { prisma } from "@calcom/prisma";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
+import { OrganizationMigrateMembersView } from "~/onboarding/organization/migrate-members/organization-migrate-members-view";
+
+export default async function MigrateMembersPage() {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user?.id) {
+ return redirect("/auth/login");
+ }
+
+ const userEmail = session.user.email || "";
+ const userId = session.user.id;
+
+ const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
+
+ if (!isCompanyEmail(userEmail)) {
+ return redirect(gettingStartedPath);
+ }
+
+ const userRepository = new UserRepository(prisma);
+ const isMemberOfOrganization = await userRepository.findIfAMemberOfSomeOrganization({ user: { id: userId } });
+
+ if (isMemberOfOrganization) {
+ return redirect(gettingStartedPath);
+ }
+
+ return ;
+}
diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx
new file mode 100644
index 0000000000..df801856b7
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx
@@ -0,0 +1,61 @@
+import { cookies, headers } from "next/headers";
+import { redirect } from "next/navigation";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
+import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
+import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
+import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
+import { prisma } from "@calcom/prisma";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
+import { OrganizationMigrateTeamsView } from "~/onboarding/organization/migrate-teams/organization-migrate-teams-view";
+
+type PageProps = {
+ searchParams: Promise<{ migrate?: string }>;
+};
+
+export default async function MigrateTeamsPage({ searchParams }: PageProps) {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user?.id) {
+ return redirect("/auth/login");
+ }
+
+ const userEmail = session.user.email || "";
+ const userId = session.user.id;
+
+ const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
+
+ if (!isCompanyEmail(userEmail)) {
+ return redirect(gettingStartedPath);
+ }
+
+ const userRepository = new UserRepository(prisma);
+ const { organizations } = await userRepository.findOrganizations({ userId });
+
+ if (organizations.length > 0) {
+ return redirect(gettingStartedPath);
+ }
+
+ // Check for migrate query param
+ const params = await searchParams;
+ const migrateParam = params?.migrate;
+
+ // If no migrate param, redirect to teams step
+ if (migrateParam !== "true") {
+ return redirect("/onboarding/organization/teams");
+ }
+
+ // Check if user has teams to migrate
+ const teamRepository = new TeamRepository(prisma);
+ const ownedTeams = await teamRepository.findOwnedTeamsByUserId({ userId });
+
+ // If no teams, redirect to teams step
+ if (ownedTeams.length === 0) {
+ return redirect("/onboarding/organization/teams");
+ }
+
+ return ;
+}
diff --git a/apps/web/app/api/organizations/payment-redirect/route.ts b/apps/web/app/api/organizations/payment-redirect/route.ts
index 884cee42ce..d49bd32f65 100644
--- a/apps/web/app/api/organizations/payment-redirect/route.ts
+++ b/apps/web/app/api/organizations/payment-redirect/route.ts
@@ -5,9 +5,11 @@ import { z } from "zod";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
+import { OrganizationOnboardingRepository } from "@calcom/features/organizations/repositories/OrganizationOnboardingRepository";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { prisma } from "@calcom/prisma";
+import { orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
const querySchema = z.object({
session_id: z.string().min(1),
@@ -43,6 +45,19 @@ async function getHandler(req: NextRequest) {
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
if (isOnboardingV3Enabled && organizationOnboardingId) {
+ // Check if this is a migration flow (user has already completed onboarding)
+ const onboarding = await OrganizationOnboardingRepository.findById(organizationOnboardingId);
+ const hasMigratedTeams =
+ onboarding?.teams &&
+ orgOnboardingTeamsSchema.parse(onboarding.teams).some((team) => team.isBeingMigrated);
+
+ if (hasMigratedTeams) {
+ // Migration flow - user already completed onboarding, redirect to event-types
+ const redirectUrl = new URL("/event-types?newOrganizationModal=true", WEBAPP_URL).toString();
+ return NextResponse.redirect(redirectUrl);
+ }
+
+ // Regular flow - redirect to personal onboarding
params.append("fromTeamOnboarding", "true");
const redirectUrl = new URL(
`/onboarding/personal/settings?${params.toString()}`,
diff --git a/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx
index 79e4dc5d84..d90ade065f 100644
--- a/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx
+++ b/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx
@@ -6,6 +6,8 @@ import { usePathname } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
+import { Badge } from "@calcom/ui/components/badge";
+import classNames from "@calcom/ui/classNames";
import { useOnboardingStore, type Invite } from "../store/onboarding-store";
@@ -26,6 +28,7 @@ type DisplayItem = {
email: string;
team: string;
isReal?: boolean;
+ isMigrated?: boolean;
};
export const OnboardingInviteBrowserView = ({
@@ -35,8 +38,16 @@ export const OnboardingInviteBrowserView = ({
}: OnboardingInviteBrowserViewProps) => {
const pathname = usePathname();
const { data: user } = trpc.viewer.me.get.useQuery();
- const { teamBrand, teamInvites, invites, teamDetails, organizationBrand, organizationDetails } =
- useOnboardingStore();
+ const {
+ teamBrand,
+ teamInvites,
+ invites,
+ teamDetails,
+ organizationBrand,
+ organizationDetails,
+ migratedMembers,
+ teams,
+ } = useOnboardingStore();
const { t } = useLocale();
// Animation variants for entry and exit
@@ -83,6 +94,32 @@ export const OnboardingInviteBrowserView = ({
// Filter out empty invites (where email is empty or just whitespace)
const validInvites = actualInvites.filter((invite) => invite.email && invite.email.trim().length > 0);
+ // Add migrated members if using organization invites
+ const migratedInvites: Invite[] = [];
+ if (useOrganizationInvites && migratedMembers.length > 0) {
+ migratedInvites.push(
+ ...migratedMembers.map((member) => {
+ // Find team name from teamId
+ const team = teams.find((t) => t.id === member.teamId);
+ return {
+ email: member.email,
+ team: team?.name || "",
+ role: "MEMBER" as const,
+ };
+ })
+ );
+ }
+
+ // Combine form invites with migrated members, avoiding duplicates
+ const allInvites = [...validInvites];
+ const existingEmails = new Set(validInvites.map((inv) => inv.email.toLowerCase()));
+ migratedInvites.forEach((migratedInvite) => {
+ if (!existingEmails.has(migratedInvite.email.toLowerCase())) {
+ allInvites.push(migratedInvite);
+ existingEmails.add(migratedInvite.email.toLowerCase());
+ }
+ });
+
// Create empty state items
const emptyStateItem = {
name: t("team_member"),
@@ -95,14 +132,19 @@ export const OnboardingInviteBrowserView = ({
const displayItems: DisplayItem[] = [];
const maxItems = 9;
- // Add actual invites first
- for (let i = 0; i < validInvites.length && i < maxItems; i++) {
- const invite = validInvites[i];
+ // Create a set of migrated member emails for quick lookup
+ const migratedEmails = new Set(migratedMembers.map((member) => member.email.toLowerCase()));
+
+ // Add all invites (form invites + migrated members)
+ for (let i = 0; i < allInvites.length && i < maxItems; i++) {
+ const invite = allInvites[i];
+ const isMigrated = migratedEmails.has(invite.email.toLowerCase());
displayItems.push({
name: invite.email.split("@")[0] || t("team_member"),
email: invite.email,
team: invite.team || t("team"),
isReal: true,
+ isMigrated,
});
}
@@ -134,30 +176,65 @@ export const OnboardingInviteBrowserView = ({
ease: "backOut",
}}>
-
- {/* Banner Image */}
- {organizationBrand.banner && (
-
- {/* eslint-disable-next-line @next/next/no-img-element */}
-

-
- )}
+ {useOrganizationInvites || organizationBrand.banner || avatar ? (
+
+ {/* Banner Image */}
+ {useOrganizationInvites && (
+
+ {organizationBrand.banner ? (
+ /* eslint-disable-next-line @next/next/no-img-element */
+

+ ) : (
+
+ )}
+
+ )}
+ {!useOrganizationInvites && organizationBrand.banner && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ )}
- {/* Organization Avatar - Overlaying the banner */}
- {organizationBrand.banner && avatar && (
-
- )}
-
+ {/* Organization Avatar - Overlaying the banner */}
+ {useOrganizationInvites && avatar && (
+
+ )}
+ {!useOrganizationInvites && organizationBrand.banner && avatar && (
+
+ )}
+
+ ) : null}
{/* Organization Info */}
-
- {!organizationBrand.banner && avatar && (
+
+ {!useOrganizationInvites && !organizationBrand.banner && avatar && (
- {item.team && (
-
- {item.team}
-
- )}
+
+ {item.team && !item.isMigrated && (
+
+ {item.team}
+
+ )}
+ {item.isMigrated && (
+
+ {t("migrating")}
+
+ )}
+
))}
diff --git a/apps/web/modules/onboarding/components/onboarding-migrate-members-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-migrate-members-browser-view.tsx
new file mode 100644
index 0000000000..c1327ca419
--- /dev/null
+++ b/apps/web/modules/onboarding/components/onboarding-migrate-members-browser-view.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { usePathname } from "next/navigation";
+
+import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Avatar } from "@calcom/ui/components/avatar";
+import { Badge } from "@calcom/ui/components/badge";
+import { Icon } from "@calcom/ui/components/icon";
+
+type MigratedMember = {
+ email: string;
+ name?: string | null;
+ avatarUrl?: string | null;
+ teamId: number;
+ teamName?: string;
+};
+
+type OnboardingMigrateMembersBrowserViewProps = {
+ members: MigratedMember[];
+ organizationLogo?: string | null;
+ organizationName?: string;
+ organizationBanner?: string | null;
+ slug?: string;
+};
+
+export const OnboardingMigrateMembersBrowserView = ({
+ members,
+ organizationLogo,
+ organizationName,
+ organizationBanner,
+ slug,
+}: OnboardingMigrateMembersBrowserViewProps) => {
+ const pathname = usePathname();
+ const { t } = useLocale();
+ const displayUrl = slug ? `${slug}.${subdomainSuffix()}` : subdomainSuffix();
+
+ const containerVariants = {
+ initial: {
+ opacity: 0,
+ y: -20,
+ },
+ animate: {
+ opacity: 1,
+ y: 0,
+ },
+ exit: {
+ opacity: 0,
+ y: 20,
+ },
+ };
+
+ const hasMembers = members.length > 0;
+
+ return (
+
+ {/* Browser header */}
+
+ {/* Navigation buttons */}
+
+
+
+
+
+
+
+
{displayUrl}/members
+
+
+
+
+ {/* Content */}
+
+
+
+ {/* Members Header with Banner */}
+
+
+ {/* Banner Image */}
+
+ {organizationBanner ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ )}
+
+
+ {/* Organization Logo - Overlaying the banner */}
+ {organizationLogo && (
+
+ )}
+
+
+ {/* Members Info */}
+
+
{t("members")}
+ {hasMembers && (
+
+ {members.length === 1
+ ? t("migrating_members_count", { count: members.length })
+ : t("migrating_members_count_plural", { count: members.length })}
+
+ )}
+
+
+
+ {/* Members List */}
+
+ {hasMembers ? (
+ <>
+ {members.map((member, index) => (
+
+ {index > 0 &&
}
+
+
+
+ {(member.name || member.email).charAt(0).toUpperCase()}
+
+
+ }
+ />
+
+
+
+ {member.name || member.email}
+
+
+ {t("migrating")}
+
+
+
{member.email}
+
+
+
+
+ ))}
+ >
+ ) : (
+
+
+
+
+
+
+ {t("no_members_to_migrate")}
+
+
+ {t("members_will_be_added_to_organization")}
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
diff --git a/apps/web/modules/onboarding/components/onboarding-migrate-teams-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-migrate-teams-browser-view.tsx
new file mode 100644
index 0000000000..5fbe4f84dd
--- /dev/null
+++ b/apps/web/modules/onboarding/components/onboarding-migrate-teams-browser-view.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { usePathname } from "next/navigation";
+
+import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Avatar } from "@calcom/ui/components/avatar";
+import { Badge } from "@calcom/ui/components/badge";
+import { Icon } from "@calcom/ui/components/icon";
+
+type OnboardingMigrateTeamsBrowserViewProps = {
+ teams: Array<{ id: number; name: string; slug: string | null; isSelected?: boolean }>;
+ organizationLogo?: string | null;
+ organizationName?: string;
+ organizationBanner?: string | null;
+ slug?: string;
+};
+
+export const OnboardingMigrateTeamsBrowserView = ({
+ teams,
+ organizationLogo,
+ organizationName,
+ organizationBanner,
+ slug,
+}: OnboardingMigrateTeamsBrowserViewProps) => {
+ const pathname = usePathname();
+ const { t } = useLocale();
+ const displayUrl = slug ? `${slug}.${subdomainSuffix()}` : subdomainSuffix();
+
+ const selectedTeams = teams.filter((team) => team.isSelected);
+ const hasValidTeams = teams.length > 0;
+
+ const containerVariants = {
+ initial: {
+ opacity: 0,
+ y: -20,
+ },
+ animate: {
+ opacity: 1,
+ y: 0,
+ },
+ exit: {
+ opacity: 0,
+ y: 20,
+ },
+ };
+
+ return (
+
+ {/* Browser header */}
+
+ {/* Navigation buttons */}
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+
+
+ {/* Teams Header with Banner */}
+
+
+ {/* Banner Image */}
+
+ {organizationBanner ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ )}
+
+
+ {/* Organization Logo - Overlaying the banner */}
+ {organizationLogo && (
+
+ )}
+
+
+ {/* Teams Info */}
+
+
{t("teams")}
+ {selectedTeams.length > 0 && (
+
+ {selectedTeams.length === 1
+ ? t("migrating_teams_count", { count: selectedTeams.length })
+ : t("migrating_teams_count_plural", { count: selectedTeams.length })}
+
+ )}
+
+
+
+ {/* Teams List */}
+
+ {hasValidTeams ? (
+ <>
+ {teams.map((team, index) => (
+
+ {index > 0 &&
}
+
+
+ {team.name.charAt(0)}
+
+ }
+ />
+
+
+
+ {team.name}
+
+ {team.isSelected && (
+
+ {t("migrating")}
+
+ )}
+
+
+ {team.slug && slug
+ ? `${slug}.${subdomainSuffix()}/${team.slug}`
+ : team.slug
+ ? `${team.slug}.${subdomainSuffix()}`
+ : t("team_slug_pending")}
+
+
+ {team.isSelected ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+ >
+ ) : (
+
+
+
+
+
+
+ {t("no_teams_to_migrate")}
+
+
+ {t("create_teams_in_next_step")}
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
diff --git a/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx
index f18307873a..4a2c19743a 100644
--- a/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx
+++ b/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx
@@ -6,10 +6,11 @@ import { usePathname } from "next/navigation";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
+import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
type OnboardingTeamsBrowserViewProps = {
- teams: Array<{ name: string }>;
+ teams: Array<{ name: string; slug?: string | null; isMigrated?: boolean }>;
organizationLogo?: string | null;
organizationName?: string;
organizationBanner?: string | null;
@@ -135,12 +136,29 @@ export const OnboardingTeamsBrowserView = ({
}
/>
-
{team.name}
+
+
+ {team.name}
+
+ {team.isMigrated && (
+
+ {t("migrating")}
+
+ )}
+
- {t("onboarding_teams_browser_view_team_description")}
+ {team.slug && slug
+ ? `${slug}.${subdomainSuffix()}/${team.slug}`
+ : team.slug
+ ? `${team.slug}.${subdomainSuffix()}`
+ : t("onboarding_teams_browser_view_team_description")}
-
+ {team.isMigrated ? (
+
+ ) : (
+
+ )}
))
diff --git a/apps/web/modules/onboarding/hooks/__tests__/useSubmitOnboarding.test.ts b/apps/web/modules/onboarding/hooks/__tests__/useSubmitOnboarding.test.ts
new file mode 100644
index 0000000000..eb04c92610
--- /dev/null
+++ b/apps/web/modules/onboarding/hooks/__tests__/useSubmitOnboarding.test.ts
@@ -0,0 +1,276 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { CreationSource } from "@calcom/prisma/enums";
+
+import type { OnboardingState } from "../../store/onboarding-store";
+import { useSubmitOnboarding } from "../useSubmitOnboarding";
+
+vi.mock("react", async () => {
+ const actual = await vi.importActual("react");
+ return {
+ ...actual,
+ useState: vi.fn((initial: any) => [initial, vi.fn()]),
+ };
+});
+
+vi.mock("@calcom/features/flags/context/provider", () => ({
+ useFlagMap: vi.fn(() => ({})),
+}));
+
+vi.mock("@calcom/features/ee/organizations/hooks/useWelcomeModal", () => ({
+ setShowNewOrgModalFlag: vi.fn(),
+}));
+
+vi.mock("@calcom/ui/components/toast", () => ({
+ showToast: vi.fn(),
+}));
+
+vi.mock("@calcom/trpc/react", () => ({
+ trpc: {
+ viewer: {
+ organizations: {
+ intentToCreateOrg: {
+ useMutation: vi.fn(),
+ },
+ },
+ },
+ },
+}));
+
+describe("useSubmitOnboarding", () => {
+ let mockMutateAsync: ReturnType;
+ let mockResetOnboarding: ReturnType;
+ let mockUseMutation: ReturnType;
+
+ beforeEach(async () => {
+ mockMutateAsync = vi.fn();
+ mockResetOnboarding = vi.fn();
+ mockUseMutation = vi.fn(() => ({
+ mutateAsync: mockMutateAsync,
+ }));
+
+ // Mock window for skipToPersonal
+ global.window = {
+ location: {
+ href: "",
+ },
+ } as any;
+
+ const { trpc } = await import("@calcom/trpc/react");
+ vi.mocked(trpc.viewer.organizations.intentToCreateOrg.useMutation).mockImplementation(
+ mockUseMutation as any
+ );
+ });
+
+ it("should submit teams with migration data correctly", async () => {
+ const hook = useSubmitOnboarding();
+ const { submitOnboarding } = hook;
+
+ const store = {
+ selectedPlan: "organization",
+ organizationDetails: {
+ name: "Test Org",
+ link: "test-org",
+ bio: "Test bio",
+ },
+ organizationBrand: {
+ color: "#000000",
+ logo: "logo.png",
+ banner: "banner.png",
+ },
+ teams: [
+ { id: 1, name: "Existing Team", slug: "existing-team", isBeingMigrated: true },
+ { id: -1, name: "New Team", slug: null, isBeingMigrated: false },
+ ],
+ invites: [{ email: "invite@example.com", team: "New Team", role: "MEMBER" }],
+ inviteRole: "MEMBER",
+ migratedMembers: [{ email: "migrated@example.com", teamId: 1, role: "MEMBER" as const }],
+ resetOnboarding: mockResetOnboarding,
+ } as unknown as OnboardingState;
+
+ mockMutateAsync.mockResolvedValue({
+ checkoutUrl: "https://stripe.com/checkout",
+ });
+
+ await submitOnboarding(store, "user@example.com", store.invites);
+
+ expect(mockMutateAsync).toHaveBeenCalledWith({
+ name: "Test Org",
+ slug: "test-org",
+ bio: "Test bio",
+ logo: "logo.png",
+ brandColor: "#000000",
+ bannerUrl: "banner.png",
+ orgOwnerEmail: "user@example.com",
+ seats: null,
+ pricePerSeat: null,
+ isPlatform: false,
+ creationSource: CreationSource.WEBAPP,
+ teams: [
+ { id: 1, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
+ { id: -1, name: "New Team", isBeingMigrated: false, slug: null },
+ ],
+ invitedMembers: [
+ { email: "invite@example.com", teamName: "New Team", teamId: -1, role: "MEMBER" },
+ { email: "migrated@example.com", teamId: 1, role: "MEMBER" },
+ ],
+ });
+ });
+
+ it("should use teamId for invites to migrated teams", async () => {
+ const hook = useSubmitOnboarding();
+ const { submitOnboarding } = hook;
+
+ const store = {
+ selectedPlan: "organization",
+ organizationDetails: {
+ name: "Test Org",
+ link: "test-org",
+ bio: "Test bio",
+ },
+ organizationBrand: {
+ color: "#000000",
+ logo: "logo.png",
+ banner: "banner.png",
+ },
+ teams: [
+ { id: 1, name: "Existing Team", slug: "existing-team", isBeingMigrated: true },
+ { id: 2, name: "Another Existing Team", slug: "another-existing-team", isBeingMigrated: true },
+ { id: -1, name: "New Team", slug: null, isBeingMigrated: false },
+ ],
+ invites: [
+ { email: "invite1@example.com", team: "Existing Team", role: "MEMBER" },
+ { email: "invite2@example.com", team: "New Team", role: "ADMIN" },
+ { email: "invite3@example.com", team: "another existing team", role: "MEMBER" },
+ ],
+ inviteRole: "MEMBER",
+ migratedMembers: [],
+ resetOnboarding: mockResetOnboarding,
+ } as unknown as OnboardingState;
+
+ mockMutateAsync.mockResolvedValue({
+ checkoutUrl: "https://stripe.com/checkout",
+ });
+
+ await submitOnboarding(store, "user@example.com", store.invites);
+
+ expect(mockMutateAsync).toHaveBeenCalledWith({
+ name: "Test Org",
+ slug: "test-org",
+ bio: "Test bio",
+ logo: "logo.png",
+ brandColor: "#000000",
+ bannerUrl: "banner.png",
+ orgOwnerEmail: "user@example.com",
+ seats: null,
+ pricePerSeat: null,
+ isPlatform: false,
+ creationSource: CreationSource.WEBAPP,
+ teams: [
+ { id: 1, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
+ { id: 2, name: "Another Existing Team", isBeingMigrated: true, slug: "another-existing-team" },
+ { id: -1, name: "New Team", isBeingMigrated: false, slug: null },
+ ],
+ invitedMembers: [
+ // Migrated team invite should use teamId
+ { email: "invite1@example.com", teamId: 1, teamName: undefined, role: "MEMBER" },
+ // New team invite should use teamName (note: implementation uses inviteRole from store, not invite.role)
+ { email: "invite2@example.com", teamName: "New Team", teamId: -1, role: "MEMBER" },
+ // Migrated team invite with different case should still use teamId
+ { email: "invite3@example.com", teamId: 2, teamName: undefined, role: "MEMBER" },
+ ],
+ });
+ });
+
+ it("should handle invites without teams correctly", async () => {
+ const hook = useSubmitOnboarding();
+ const { submitOnboarding } = hook;
+
+ const store = {
+ selectedPlan: "organization",
+ organizationDetails: {
+ name: "Test Org",
+ link: "test-org",
+ bio: "Test bio",
+ },
+ organizationBrand: {
+ color: "#000000",
+ logo: "logo.png",
+ banner: "banner.png",
+ },
+ teams: [{ id: 1, name: "Existing Team", slug: "existing-team", isBeingMigrated: true }],
+ invites: [
+ { email: "invite1@example.com", team: "", role: "MEMBER" },
+ { email: "invite2@example.com", team: " ", role: "ADMIN" },
+ ],
+ inviteRole: "MEMBER",
+ migratedMembers: [],
+ resetOnboarding: mockResetOnboarding,
+ } as unknown as OnboardingState;
+
+ mockMutateAsync.mockResolvedValue({
+ checkoutUrl: "https://stripe.com/checkout",
+ });
+
+ await submitOnboarding(store, "user@example.com", store.invites);
+
+ expect(mockMutateAsync).toHaveBeenCalledWith({
+ name: "Test Org",
+ slug: "test-org",
+ bio: "Test bio",
+ logo: "logo.png",
+ brandColor: "#000000",
+ bannerUrl: "banner.png",
+ orgOwnerEmail: "user@example.com",
+ seats: null,
+ pricePerSeat: null,
+ isPlatform: false,
+ creationSource: CreationSource.WEBAPP,
+ teams: [{ id: 1, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" }],
+ invitedMembers: [
+ // Invites without teams should have neither teamId nor teamName (note: implementation uses inviteRole from store)
+ { email: "invite1@example.com", teamId: undefined, teamName: undefined, role: "MEMBER" },
+ { email: "invite2@example.com", teamId: undefined, teamName: undefined, role: "MEMBER" },
+ ],
+ });
+ });
+
+ it("should handle teams with null slugs correctly", async () => {
+ const hook = useSubmitOnboarding();
+ const { submitOnboarding } = hook;
+
+ const store = {
+ selectedPlan: "organization",
+ organizationDetails: {
+ name: "Test Org",
+ link: "test-org",
+ bio: "",
+ },
+ organizationBrand: {
+ color: "#000000",
+ logo: null,
+ banner: null,
+ },
+ teams: [{ id: -1, name: "New Team", slug: null, isBeingMigrated: false }],
+ invites: [],
+ inviteRole: "MEMBER",
+ migratedMembers: [],
+ resetOnboarding: mockResetOnboarding,
+ } as unknown as OnboardingState;
+
+ mockMutateAsync.mockResolvedValue({
+ checkoutUrl: null,
+ organizationId: 123,
+ });
+
+ await submitOnboarding(store, "user@example.com", []);
+
+ expect(mockMutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ teams: [{ id: -1, name: "New Team", isBeingMigrated: false, slug: null }],
+ invitedMembers: [],
+ })
+ );
+ });
+});
diff --git a/apps/web/modules/onboarding/hooks/useMigrationFlow.ts b/apps/web/modules/onboarding/hooks/useMigrationFlow.ts
new file mode 100644
index 0000000000..92a79f4053
--- /dev/null
+++ b/apps/web/modules/onboarding/hooks/useMigrationFlow.ts
@@ -0,0 +1,24 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { trpc } from "@calcom/trpc/react";
+
+export const useMigrationFlow = () => {
+ const searchParams = useSearchParams();
+ const migrateParam = searchParams?.get("migrate");
+ const isMigrationFlow = migrateParam === "true";
+
+ const { data: teams, isLoading } = trpc.viewer.teams.listOwnedTeams.useQuery(undefined, {
+ enabled: isMigrationFlow, // Only fetch if migration flow
+ });
+
+ const hasTeams = (teams?.length ?? 0) > 0;
+
+ return {
+ isMigrationFlow,
+ hasTeams,
+ teams: teams ?? [],
+ isLoading,
+ };
+};
+
diff --git a/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts b/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts
index 68f1c30efc..0c172b18d8 100644
--- a/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts
+++ b/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts
@@ -24,32 +24,65 @@ export const useSubmitOnboarding = () => {
setError(null);
try {
- const { selectedPlan, organizationDetails, organizationBrand, teams, inviteRole, resetOnboarding } =
- store;
+ const {
+ selectedPlan,
+ organizationDetails,
+ organizationBrand,
+ teams,
+ inviteRole,
+ resetOnboarding,
+ migratedMembers,
+ } = store;
if (selectedPlan !== "organization") {
throw new Error("Only organization plan is currently supported");
}
- // Prepare teams data
const teamsData = teams
.filter((team) => team.name.trim().length > 0)
.map((team) => ({
- id: -1, // New team
+ id: team.id,
name: team.name,
- isBeingMigrated: false,
- slug: null,
+ isBeingMigrated: team.isBeingMigrated,
+ slug: team.slug,
}));
- // Prepare invites data
const invitedMembersData = invitesToSubmit
.filter((invite) => invite.email.trim().length > 0)
- .map((invite) => ({
- email: invite.email,
- teamName: invite.team,
- teamId: -1,
- role: inviteRole,
- }));
+ .map((invite) => {
+ // If invite has a team name, try to find the team ID (for migrated teams)
+ let teamId: number | undefined = undefined;
+ let teamName: string | undefined = undefined;
+
+ if (invite.team && invite.team.trim().length > 0) {
+ const matchingTeam = teams.find(
+ (team) => team.name.toLowerCase() === invite.team.toLowerCase()
+ );
+ if (matchingTeam?.isBeingMigrated && matchingTeam.id !== -1) {
+ // Use team ID for migrated teams
+ teamId = matchingTeam.id;
+ } else {
+ // Use team name for new teams (will be matched after creation)
+ teamName = invite.team;
+ teamId = -1;
+ }
+ }
+
+ return {
+ email: invite.email,
+ teamName,
+ teamId,
+ role: inviteRole,
+ };
+ });
+
+ const migratedMembersData = migratedMembers.map((member) => ({
+ email: member.email,
+ teamId: member.teamId,
+ role: member.role,
+ }));
+
+ const allInvitedMembers = [...invitedMembersData, ...migratedMembersData];
const result = await intentToCreateOrg.mutateAsync({
name: organizationDetails.name,
@@ -64,7 +97,7 @@ export const useSubmitOnboarding = () => {
isPlatform: false,
creationSource: CreationSource.WEBAPP,
teams: teamsData,
- invitedMembers: invitedMembersData,
+ invitedMembers: allInvitedMembers,
});
// If there's a checkout URL, redirect to Stripe (billing enabled flow)
@@ -76,9 +109,19 @@ export const useSubmitOnboarding = () => {
// No checkout URL means billing is disabled (self-hosted flow)
// Organization has already been created by the backend
showToast("Organization created successfully!", "success");
- // Set flag to show welcome modal after personal onboarding redirect
+ // Set flag to show welcome modal after redirect
setShowNewOrgModalFlag();
- skipToPersonal(resetOnboarding);
+
+ // Check if this is a migration flow (user has already completed onboarding)
+ const hasMigratedTeams = teams.some((team) => team.isBeingMigrated);
+ if (hasMigratedTeams) {
+ // Migration flow - user already completed onboarding, redirect to event-types
+ resetOnboarding();
+ window.location.href = "/event-types?newOrganizationModal=true";
+ } else {
+ // Regular flow - redirect to personal onboarding
+ skipToPersonal(resetOnboarding);
+ }
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
setError(errorMessage);
diff --git a/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx
index 3b32381750..c3352258d7 100644
--- a/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx
+++ b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useRef, useState } from "react";
@@ -11,6 +11,7 @@ import { ColorPicker, Label } from "@calcom/ui/components/form";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
+import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationBrandViewProps = {
@@ -19,8 +20,10 @@ type OrganizationBrandViewProps = {
export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) => {
const router = useRouter();
+ const searchParams = useSearchParams();
const { t } = useLocale();
const { organizationDetails, organizationBrand, setOrganizationBrand } = useOnboardingStore();
+ const { isMigrationFlow, hasTeams } = useMigrationFlow();
const [_logoFile, setLogoFile] = useState(null);
const [_bannerFile, setBannerFile] = useState(null);
@@ -72,6 +75,17 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
setOrganizationBrand({ color });
};
+ const getNextStep = () => {
+ const migrateParam = searchParams?.get("migrate");
+ const queryString = migrateParam ? `?migrate=${migrateParam}` : "";
+
+ // If migration flow and has teams, go to migrate-teams, otherwise go to teams
+ if (isMigrationFlow && hasTeams) {
+ return `/onboarding/organization/migrate-teams${queryString}`;
+ }
+ return `/onboarding/organization/teams${queryString}`;
+ };
+
const handleContinue = () => {
posthog.capture("onboarding_organization_brand_continue_clicked", {
has_logo: !!logoPreview,
@@ -84,7 +98,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
banner: bannerPreview,
color: brandColor,
});
- router.push("/onboarding/organization/teams");
+ router.push(getNextStep());
};
const handleSkip = () => {
@@ -93,8 +107,10 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
router.push("/onboarding/organization/teams");
};
+ const totalSteps = isMigrationFlow && hasTeams ? 6 : 4;
+
return (
-
+
{/* Left column - Main content */}
{
export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewProps) => {
const router = useRouter();
+ const searchParams = useSearchParams();
const { t } = useLocale();
- const { organizationDetails, setOrganizationDetails } = useOnboardingStore();
+ const { organizationDetails, setOrganizationDetails, selectedPlan, setSelectedPlan } = useOnboardingStore();
+ const { isMigrationFlow, hasTeams } = useMigrationFlow();
const [organizationName, setOrganizationName] = useState("");
const [organizationLink, setOrganizationLink] = useState("");
@@ -39,6 +42,13 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
const [isSlugValid, setIsSlugValid] = useState(false);
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
+ // Ensure selectedPlan is set to "organization" when entering organization onboarding
+ useEffect(() => {
+ if (selectedPlan !== "organization") {
+ setSelectedPlan("organization");
+ }
+ }, [selectedPlan, setSelectedPlan]);
+
// Load from store on mount
useEffect(() => {
setOrganizationName(organizationDetails.name);
@@ -78,11 +88,15 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
link: organizationLink,
bio: organizationBio,
});
- router.push("/onboarding/organization/brand");
+ const migrateParam = searchParams?.get("migrate");
+ const nextUrl = `/onboarding/organization/brand${migrateParam ? `?migrate=${migrateParam}` : ""}`;
+ router.push(nextUrl);
};
+ const totalSteps = isMigrationFlow && hasTeams ? 6 : 4;
+
return (
-
+
{/* Left column - Main content */}
{
const router = useRouter();
+ const searchParams = useSearchParams();
const { t } = useLocale();
const flags = useFlags();
+ const { teams } = useOnboardingStore();
+ const migratedTeams = teams.filter((team) => team.isBeingMigrated);
+ const isMigrationFlow = searchParams?.get("migrate") === "true";
const store = useOnboardingStore();
const usersEmailDomain = userEmail.split("@")[1];
@@ -54,11 +58,11 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
- const teams =
+ const teamOptions =
filteredTeams.length > 0
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
: [];
- const hasTeams = teams.length > 0;
+ const hasTeams = teamOptions.length > 0;
// Conditional form schema - team is optional when there are no teams
const formSchema = z.object({
@@ -102,7 +106,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
};
const handleBack = () => {
- router.push("/onboarding/organization/teams");
+ router.back();
};
const handleSkip = async () => {
@@ -136,8 +140,12 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
return email && email.trim().length > 0 && team && team.trim().length > 0;
});
+ // Calculate total steps dynamically
+ const totalSteps = isMigrationFlow && migratedTeams.length > 0 ? 6 : 4;
+ const currentStep = isMigrationFlow && migratedTeams.length > 0 ? 6 : 4;
+
return (
-
+
diff --git a/apps/web/modules/onboarding/organization/migrate-members/organization-migrate-members-view.tsx b/apps/web/modules/onboarding/organization/migrate-members/organization-migrate-members-view.tsx
new file mode 100644
index 0000000000..21fbb2791d
--- /dev/null
+++ b/apps/web/modules/onboarding/organization/migrate-members/organization-migrate-members-view.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import type { RouterOutputs } from "@calcom/trpc/react";
+import { trpc } from "@calcom/trpc/react";
+import { Avatar } from "@calcom/ui/components/avatar";
+import { Badge } from "@calcom/ui/components/badge";
+import { Button } from "@calcom/ui/components/button";
+import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
+import { Tooltip } from "@calcom/ui/components/tooltip";
+
+import { OnboardingCard } from "../../components/OnboardingCard";
+import { OnboardingLayout } from "../../components/OnboardingLayout";
+import { OnboardingMigrateMembersBrowserView } from "../../components/onboarding-migrate-members-browser-view";
+import { useMigrationFlow } from "../../hooks/useMigrationFlow";
+import { useOnboardingStore } from "../../store/onboarding-store";
+
+type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
+
+type OrganizationMigrateMembersViewProps = {
+ userEmail: string;
+};
+
+export const OrganizationMigrateMembersView = ({ userEmail }: OrganizationMigrateMembersViewProps) => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { t } = useLocale();
+ const { isLoading: isMigrationFlowLoading } = useMigrationFlow();
+ const { teams, organizationBrand, organizationDetails, setMigratedMembers } = useOnboardingStore();
+
+ const teamsToMigrate = teams.filter((team) => team.isBeingMigrated && team.id > 0);
+ const teamIds = teamsToMigrate.map((team) => team.id);
+
+ const results = trpc.useQueries((t) => teamIds.map((teamId) => t.viewer.teams.listMembers({ teamId })));
+
+ const isLoading = results.some((result) => result.isLoading);
+
+ const uniqueMembers = results
+ .reduce
((acc, result) => {
+ if (result.data?.members) {
+ return [...acc, ...result.data.members];
+ }
+ return acc;
+ }, [])
+ .filter((member, index, self) => index === self.findIndex((m) => m.email === member.email));
+
+ const handleContinue = () => {
+ const migratedMembersData = uniqueMembers.map((member) => ({
+ email: member.email,
+ name: member.name || undefined,
+ teamId: member.teamId,
+ role: member.role === "OWNER" ? ("ADMIN" as const) : member.role,
+ }));
+
+ setMigratedMembers(migratedMembersData);
+
+ const migrateParam = searchParams?.get("migrate");
+ const nextUrl = `/onboarding/organization/invite/email${migrateParam ? `?migrate=${migrateParam}` : ""}`;
+ router.push(nextUrl);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ if (teamsToMigrate.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ }>
+
+ {uniqueMembers.length > 0 ? (
+
+ {uniqueMembers.map((member) => (
+ -
+
+
+
+
+
+ {member.name || member.email}
+
+
+ {t("migrating_from_team")}
+
+
+
+ ))}
+
+ ) : (
+
{t("no_members_to_migrate")}
+ )}
+
+
+
+ {/* Right column - Browser view */}
+ ({
+ email: member.email,
+ name: member.name || null,
+ avatarUrl: member.avatarUrl || null,
+ teamId: member.teamId,
+ }))}
+ organizationLogo={organizationBrand.logo}
+ organizationName={organizationDetails.name}
+ organizationBanner={organizationBrand.banner}
+ slug={organizationDetails.link}
+ />
+
+ );
+};
diff --git a/apps/web/modules/onboarding/organization/migrate-teams/organization-migrate-teams-view.tsx b/apps/web/modules/onboarding/organization/migrate-teams/organization-migrate-teams-view.tsx
new file mode 100644
index 0000000000..ed1ff646a5
--- /dev/null
+++ b/apps/web/modules/onboarding/organization/migrate-teams/organization-migrate-teams-view.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { z } from "zod";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import slugify from "@calcom/lib/slugify";
+import { trpc } from "@calcom/trpc/react";
+import { Button } from "@calcom/ui/components/button";
+import { CheckboxField, TextField } from "@calcom/ui/components/form";
+import { Form } from "@calcom/ui/components/form";
+
+import { OnboardingCard } from "../../components/OnboardingCard";
+import { OnboardingLayout } from "../../components/OnboardingLayout";
+import { OnboardingMigrateTeamsBrowserView } from "../../components/onboarding-migrate-teams-browser-view";
+import { useMigrationFlow } from "../../hooks/useMigrationFlow";
+import { useOnboardingStore } from "../../store/onboarding-store";
+import { getSuggestedSlug } from "./utils";
+
+type OrganizationMigrateTeamsViewProps = {
+ userEmail: string;
+};
+
+const schema = z.object({
+ moveTeams: z.array(
+ z.object({
+ id: z.number(),
+ shouldMove: z.boolean(),
+ newSlug: z.string().optional(),
+ })
+ ),
+});
+
+type FormValues = z.infer;
+
+export const OrganizationMigrateTeamsView = ({ userEmail }: OrganizationMigrateTeamsViewProps) => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { t } = useLocale();
+ const { isMigrationFlow, hasTeams, teams, isLoading } = useMigrationFlow();
+ const { organizationDetails, organizationBrand, teams: teamsFromStore, setTeams } = useOnboardingStore();
+ const orgSlug = organizationDetails.link;
+
+ const teamsToMigrateFromStore = teamsFromStore.filter((team) => team.isBeingMigrated);
+
+ const form = useForm({
+ defaultValues: {
+ moveTeams: [],
+ },
+ resolver: async (data) => {
+ try {
+ const validatedData = await schema.parseAsync(data);
+ return { values: validatedData, errors: {} };
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return {
+ values: {
+ moveTeams: [],
+ },
+ errors: error.formErrors.fieldErrors,
+ };
+ }
+ return { values: {}, errors: { moveTeams: { message: "Error validating input" } } };
+ }
+ },
+ });
+
+ const { register, control, watch, getValues, setValue, reset } = form;
+ const moveTeams = watch("moveTeams");
+
+ useEffect(() => {
+ if (teams.length === 0) return;
+
+ const currentMoveTeams = getValues("moveTeams");
+ if (currentMoveTeams.length === 0 || currentMoveTeams.length !== teams.length) {
+ reset({
+ moveTeams: teams.map((team) => {
+ const teamToMigrateInStore = teamsToMigrateFromStore.find((t) => t.id === team.id);
+ const slugConflictsWithOrg = team.slug === orgSlug;
+ return {
+ id: team.id,
+ shouldMove: slugConflictsWithOrg || !!teamToMigrateInStore,
+ newSlug:
+ teamToMigrateInStore?.slug ?? getSuggestedSlug({ teamSlug: team.slug, orgSlug }) ?? undefined,
+ };
+ }),
+ });
+ }
+ }, [teams, reset, teamsToMigrateFromStore, orgSlug, getValues]);
+
+ const handleFormSubmit = () => {
+ const moveTeamsData = getValues("moveTeams");
+
+ const teamsBeingMoved = moveTeamsData
+ .filter((team) => team.shouldMove)
+ .map((team) => {
+ const originalTeam = teams.find((t) => t.id === team.id);
+ if (!originalTeam) {
+ throw new Error(`Team with id ${team.id} not found`);
+ }
+ // Use newSlug if provided and not empty, otherwise fall back to original team slug
+ // This matches the settings flow behavior - backend will use original slug if newSlug is null
+ const slug = team.newSlug?.trim() || originalTeam.slug || null;
+ return {
+ id: team.id,
+ isBeingMigrated: true,
+ slug,
+ name: originalTeam.name,
+ };
+ });
+
+ const existingNewTeams = teamsFromStore.filter((team) => !team.isBeingMigrated);
+
+ setTeams([...teamsBeingMoved, ...existingNewTeams]);
+
+ const migrateParam = searchParams?.get("migrate");
+ const nextUrl = `/onboarding/organization/teams${migrateParam ? `?migrate=${migrateParam}` : ""}`;
+ router.push(nextUrl);
+ };
+
+ const handleSkip = () => {
+ const migrateParam = searchParams?.get("migrate");
+ const nextUrl = `/onboarding/organization/teams${migrateParam ? `?migrate=${migrateParam}` : ""}`;
+ router.push(nextUrl);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!isMigrationFlow || !hasTeams) {
+ return null;
+ }
+
+ const hasSelectedTeams = moveTeams.some((team) => team.shouldMove);
+
+ const browserViewTeams = teams.map((team) => {
+ const moveTeam = moveTeams.find((mt) => mt.id === team.id);
+ return {
+ id: team.id,
+ name: team.name,
+ slug: moveTeam?.newSlug || team.slug,
+ isSelected: moveTeam?.shouldMove || false,
+ };
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ }>
+
+
+
+ {/* Right column - Browser view */}
+
+
+ );
+};
diff --git a/apps/web/modules/onboarding/organization/migrate-teams/utils.ts b/apps/web/modules/onboarding/organization/migrate-teams/utils.ts
new file mode 100644
index 0000000000..a3f1e1c6a1
--- /dev/null
+++ b/apps/web/modules/onboarding/organization/migrate-teams/utils.ts
@@ -0,0 +1,15 @@
+export const getSuggestedSlug = ({
+ teamSlug,
+ orgSlug,
+}: {
+ teamSlug: string | null;
+ orgSlug: string | null;
+}) => {
+ // If there is no orgSlug, we can't suggest a slug
+ if (!teamSlug || !orgSlug) {
+ return teamSlug;
+ }
+
+ return teamSlug.replace(`${orgSlug}-`, "").replace(`-${orgSlug}`, "");
+};
+
diff --git a/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx b/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx
index 267ad36aa6..445a4ecab0 100644
--- a/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx
+++ b/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx
@@ -1,12 +1,14 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import slugify from "@calcom/lib/slugify";
+import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Form, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
@@ -14,6 +16,7 @@ import { Icon } from "@calcom/ui/components/icon";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingTeamsBrowserView } from "../../components/onboarding-teams-browser-view";
+import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationTeamsViewProps = {
@@ -26,8 +29,14 @@ type FormValues = {
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
const router = useRouter();
+ const searchParams = useSearchParams();
const { t } = useLocale();
const { teams: storedTeams, setTeams, organizationBrand, organizationDetails } = useOnboardingStore();
+ const { isMigrationFlow } = useMigrationFlow();
+
+ // Filter out migrated teams - only show new teams
+ const newTeams = storedTeams.filter((team) => !team.isBeingMigrated);
+ const migratedTeams = storedTeams.filter((team) => team.isBeingMigrated);
const formSchema = z.object({
teams: z.array(
@@ -40,7 +49,7 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
- teams: storedTeams.length > 0 ? storedTeams : [{ name: "" }],
+ teams: newTeams.length > 0 ? newTeams.map((team) => ({ name: team.name })) : [{ name: "" }],
},
});
@@ -49,20 +58,42 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
name: "teams",
});
+ const getNextStep = () => {
+ const migrateParam = searchParams?.get("migrate");
+ const queryString = migrateParam ? `?migrate=${migrateParam}` : "";
+
+ // If migration flow and teams were migrated, go to migrate-members
+ if (isMigrationFlow && migratedTeams.length > 0) {
+ return `/onboarding/organization/migrate-members${queryString}`;
+ }
+ return `/onboarding/organization/invite/email${queryString}`;
+ };
+
const handleContinue = (data: FormValues) => {
- const validTeams = data.teams.filter((team) => team.name.trim().length > 0);
+ // Convert form data to Team structure and combine with migrated teams
+ const newTeamsData = data.teams
+ .filter((team) => team.name && team.name.trim().length > 0)
+ .map((team) => ({
+ id: -1,
+ name: team.name,
+ slug: null,
+ isBeingMigrated: false,
+ }));
+
posthog.capture("onboarding_organization_teams_continue_clicked", {
- team_count: validTeams.length,
+ team_count: newTeamsData.length,
});
- // Save teams to store
- setTeams(data.teams);
- router.push("/onboarding/organization/invite/email");
+
+ // Combine migrated teams with new teams
+ setTeams([...migratedTeams, ...newTeamsData]);
+ router.push(getNextStep());
};
const handleSkip = () => {
posthog.capture("onboarding_organization_teams_skip_clicked");
- // Skip teams and go to invite
- router.push("/onboarding/organization/invite/email");
+ // Skip teams - keep migrated teams if any, otherwise empty
+ setTeams(migratedTeams);
+ router.push(getNextStep());
};
const hasValidTeams = fields.some((_, index) => {
@@ -70,8 +101,12 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
return teamName && teamName.trim().length > 0;
});
+ // Calculate total steps dynamically
+ const totalSteps = isMigrationFlow && migratedTeams.length > 0 ? 6 : 4;
+ const currentStep = isMigrationFlow && migratedTeams.length > 0 ? 4 : 3;
+
return (
-
+
{/* Left column - Main content */}
{
posthog.capture("onboarding_organization_teams_back_clicked");
- router.push("/onboarding/organization/brand");
+ router.back();
}}>
{t("back")}
@@ -105,7 +140,36 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
}>