diff --git a/apps/web/app/(use-page-wrapper)/settings/teams/new/invite/email/page.tsx b/apps/web/app/(use-page-wrapper)/settings/teams/new/invite/email/page.tsx
new file mode 100644
index 0000000000..c23bf65896
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/settings/teams/new/invite/email/page.tsx
@@ -0,0 +1,34 @@
+import { _generateMetadata } from "app/_utils";
+import { cookies, headers } from "next/headers";
+import { redirect } from "next/navigation";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { APP_NAME } from "@calcom/lib/constants";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
+import { TeamInviteEmailView } from "~/settings/teams/new/invite/email/team-invite-email-view";
+
+export const generateMetadata = async () => {
+ return await _generateMetadata(
+ (t) => `${APP_NAME} - ${t("invite")}`,
+ () => "",
+ true,
+ undefined,
+ "/settings/teams/new/invite/email"
+ );
+};
+
+const ServerPage = async () => {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user?.id) {
+ return redirect("/auth/login");
+ }
+
+ const userEmail = session.user.email || "";
+
+ return ;
+};
+
+export default ServerPage;
diff --git a/apps/web/app/(use-page-wrapper)/settings/teams/new/invite/page.tsx b/apps/web/app/(use-page-wrapper)/settings/teams/new/invite/page.tsx
new file mode 100644
index 0000000000..2d71aa5e93
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/settings/teams/new/invite/page.tsx
@@ -0,0 +1,34 @@
+import { _generateMetadata } from "app/_utils";
+import { cookies, headers } from "next/headers";
+import { redirect } from "next/navigation";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { APP_NAME } from "@calcom/lib/constants";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
+import { TeamInviteView } from "~/settings/teams/new/invite/team-invite-view";
+
+export const generateMetadata = async () => {
+ return await _generateMetadata(
+ (t) => `${APP_NAME} - ${t("invite")}`,
+ () => "",
+ true,
+ undefined,
+ "/settings/teams/new/invite"
+ );
+};
+
+const ServerPage = async () => {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user?.id) {
+ return redirect("/auth/login");
+ }
+
+ const userEmail = session.user.email || "";
+
+ return ;
+};
+
+export default ServerPage;
diff --git a/apps/web/app/(use-page-wrapper)/settings/teams/new/page.tsx b/apps/web/app/(use-page-wrapper)/settings/teams/new/page.tsx
index a37b72d979..83a6019ab8 100644
--- a/apps/web/app/(use-page-wrapper)/settings/teams/new/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/teams/new/page.tsx
@@ -1,6 +1,12 @@
import { _generateMetadata } from "app/_utils";
+import { cookies, headers } from "next/headers";
+import { redirect } from "next/navigation";
-import CreateNewTeamView, { LayoutWrapper } from "~/settings/teams/new/create-new-team-view";
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
+import { CreateNewTeamView, LayoutWrapper } from "~/settings/teams/new/create-new-team-view";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -12,9 +18,17 @@ export const generateMetadata = async () =>
);
const ServerPage = async () => {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user?.id) {
+ return redirect("/auth/login");
+ }
+
+ const userEmail = session.user.email || "";
+
return (
-
+
);
};
diff --git a/apps/web/modules/onboarding/components/onboarding-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-browser-view.tsx
index 237936849d..605bb347a0 100644
--- a/apps/web/modules/onboarding/components/onboarding-browser-view.tsx
+++ b/apps/web/modules/onboarding/components/onboarding-browser-view.tsx
@@ -134,12 +134,14 @@ export const OnboardingBrowserView = ({
{/* Profile Header */}
-
+ {avatar && (
+
+ )}
{name || t("your_name")}
diff --git a/apps/web/modules/onboarding/hooks/useCreateTeam.ts b/apps/web/modules/onboarding/hooks/useCreateTeam.ts
index a65971c254..a1ff7f5b8b 100644
--- a/apps/web/modules/onboarding/hooks/useCreateTeam.ts
+++ b/apps/web/modules/onboarding/hooks/useCreateTeam.ts
@@ -1,15 +1,18 @@
+import { useFlagMap } from "@calcom/features/flags/context/provider";
+import { CreationSource, MembershipRole } from "@calcom/prisma/enums";
+import { trpc } from "@calcom/trpc/react";
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() {
+type UseCreateTeamOptions = {
+ redirectBasePath?: string;
+ skipRedirectAfterInvite?: boolean;
+};
+
+export function useCreateTeam(options: UseCreateTeamOptions = {}) {
+ const { redirectBasePath = "/onboarding/teams", skipRedirectAfterInvite = false } = options;
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const flags = useFlagMap();
@@ -28,7 +31,7 @@ export function useCreateTeam() {
// 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");
+ router.push(`${redirectBasePath}/details`);
setIsSubmitting(false);
return;
}
@@ -51,7 +54,7 @@ export function useCreateTeam() {
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}`);
+ router.push(`${redirectBasePath}/invite/email?teamId=${result.team.id}`);
}
} catch (error) {
console.error("Failed to create team:", error);
@@ -81,14 +84,17 @@ export function useCreateTeam() {
// 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);
+ 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
+ );
// Send invites for each role group
await Promise.all(
@@ -103,11 +109,13 @@ export function useCreateTeam() {
)
);
- // Redirect to personal settings after successful invite
- const gettingStartedPath = flags["onboarding-v3"]
- ? "/onboarding/personal/settings?fromTeamOnboarding=true"
- : "/getting-started";
- router.replace(gettingStartedPath);
+ // Redirect to personal settings after successful invite (unless caller handles its own redirect)
+ if (!skipRedirectAfterInvite) {
+ const gettingStartedPath = flags["onboarding-v3"]
+ ? "/onboarding/personal/settings?fromTeamOnboarding=true"
+ : "/getting-started";
+ router.replace(gettingStartedPath);
+ }
} catch (error) {
console.error("Failed to invite members:", error);
// Extract error message from TRPC error
diff --git a/apps/web/modules/onboarding/teams/details/action/check-team-slug-availability.ts b/apps/web/modules/onboarding/teams/details/action/check-team-slug-availability.ts
index 1bc7623e1c..239c4a55d3 100644
--- a/apps/web/modules/onboarding/teams/details/action/check-team-slug-availability.ts
+++ b/apps/web/modules/onboarding/teams/details/action/check-team-slug-availability.ts
@@ -26,11 +26,13 @@ export async function checkTeamSlugAvailability(slug: string): Promise<{
return { available: false, message: "Unauthorized" };
}
- // Check if slug already exists (teams have parentId, organizations don't)
+ const organizationId = session.user.profile?.organizationId ?? null;
+
+ // Check if slug already exists within the same parent context
const existingTeam = await prisma.team.findFirst({
where: {
slug,
- parentId: null,
+ parentId: organizationId,
},
select: {
id: true,
@@ -41,5 +43,22 @@ export async function checkTeamSlugAvailability(slug: string): Promise<{
return { available: false, message: "This slug is already taken" };
}
+ // For org child teams, also check if slug conflicts with a user's username in the org
+ if (organizationId) {
+ const userWithSlug = await prisma.profile.findFirst({
+ where: {
+ organizationId,
+ username: slug,
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ if (userWithSlug) {
+ return { available: false, message: "This slug is already taken by a user in your organization" };
+ }
+ }
+
return { available: true };
}
diff --git a/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx b/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx
index d6f1b917bb..4dc493623f 100644
--- a/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx
+++ b/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx
@@ -2,7 +2,8 @@
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
-import { WEBAPP_URL } from "@calcom/lib/constants";
+import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
+import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import classNames from "@calcom/ui/classNames";
@@ -21,6 +22,7 @@ type ValidatedTeamSlugProps = {
export function ValidatedTeamSlug({ value, onChange, onValidationChange }: ValidatedTeamSlugProps) {
const { t } = useLocale();
+ const orgBranding = useOrgBranding();
const [validationState, setValidationState] = useState("idle");
const [errorMessage, setErrorMessage] = useState("");
const [_isPending, startTransition] = useTransition();
@@ -83,7 +85,9 @@ export function ValidatedTeamSlug({ value, onChange, onValidationChange }: Valid
}
};
- const urlPrefix = `${WEBAPP_URL}/team/`;
+ const urlPrefix = orgBranding
+ ? `${orgBranding.fullDomain.replace("https://", "").replace("http://", "")}/`
+ : `${subdomainSuffix()}/team/`;
return (
diff --git a/apps/web/modules/settings/teams/new/create-new-team-view.tsx b/apps/web/modules/settings/teams/new/create-new-team-view.tsx
index 0b84fcf2a5..d320d521fe 100644
--- a/apps/web/modules/settings/teams/new/create-new-team-view.tsx
+++ b/apps/web/modules/settings/teams/new/create-new-team-view.tsx
@@ -1,58 +1,210 @@
"use client";
import { useRouter } from "next/navigation";
-import React from "react";
-import { z } from "zod";
+import posthog from "posthog-js";
+import React, { useEffect, useRef, useState, type FormEvent } from "react";
-import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
-import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
-import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
-import type { RouterOutputs } from "@calcom/trpc/react";
-import { WizardLayout } from "@calcom/ui/components/layout";
-import { CreateANewTeamForm } from "@calcom/web/modules/ee/teams/components/CreateANewTeamForm";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import slugify from "@calcom/lib/slugify";
+import { Avatar } from "@calcom/ui/components/avatar";
+import { Button } from "@calcom/ui/components/button";
+import { Label, TextField, TextArea } from "@calcom/ui/components/form";
+import { ImageUploader } from "@calcom/ui/components/image-uploader";
+import { showToast } from "@calcom/ui/components/toast";
-const querySchema = z.object({
- returnTo: z.string().optional(),
- slug: z.string().optional(),
-});
+import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
+import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
+import { OnboardingBrowserView } from "~/onboarding/components/onboarding-browser-view";
+import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
+import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
+import { ValidatedTeamSlug } from "~/onboarding/teams/details/validated-team-slug";
-const CreateNewTeamPage = () => {
- const params = useParamsWithFallback();
- const parsedQuery = querySchema.safeParse(params);
+type CreateNewTeamViewProps = {
+ userEmail: string;
+};
+
+export const CreateNewTeamView = ({ userEmail }: CreateNewTeamViewProps) => {
const router = useRouter();
+ const { t } = useLocale();
+ const store = useOnboardingStore();
+ const { teamDetails, teamBrand, setTeamDetails, setTeamBrand, resetOnboardingPreservingPlan } = store;
+ const { createTeam, isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
- const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
- const flag = isTeamBillingEnabledClient
- ? {
- submitLabel: "checkout",
- }
- : {
- submitLabel: "continue",
- };
+ const logoRef = useRef
(null);
+ const [teamName, setTeamName] = useState("");
+ const [teamSlug, setTeamSlug] = useState("");
+ const [teamBio, setTeamBio] = useState("");
+ const [teamLogo, setTeamLogo] = useState("");
+ const [isSlugValid, setIsSlugValid] = useState(false);
+ const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
- const returnToParam =
- (parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/teams") || "/teams";
+ // Reset onboarding store when entering the team creation flow from settings
+ useEffect(() => {
+ resetOnboardingPreservingPlan();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
- const onSuccess = (data: RouterOutputs["viewer"]["teams"]["create"]) => {
- // telemetry.event(flag.telemetryEvent);
- router.push(data.url);
+ useEffect(() => {
+ setTeamName(teamDetails.name);
+ setTeamSlug(teamDetails.slug);
+ setTeamBio(teamDetails.bio);
+ setTeamLogo(teamBrand.logo || "");
+ if (teamDetails.slug) {
+ setIsSlugManuallyEdited(true);
+ }
+ }, [teamDetails, teamBrand]);
+
+ useEffect(() => {
+ if (!isSlugManuallyEdited && teamName) {
+ const slugifiedName = slugify(teamName);
+ setTeamSlug(slugifiedName);
+ }
+ }, [teamName, isSlugManuallyEdited]);
+
+ const handleSlugChange = (value: string) => {
+ setTeamSlug(value);
+ setIsSlugManuallyEdited(true);
+ };
+
+ const handleLogoChange = (newLogo: string) => {
+ if (logoRef.current) {
+ logoRef.current.value = newLogo;
+ }
+ setTeamLogo(newLogo);
+ };
+
+ const handleContinue = async (e?: FormEvent) => {
+ e?.preventDefault();
+
+ if (!isSlugValid) {
+ return;
+ }
+
+ posthog.capture("settings_team_details_continue_clicked", {
+ has_logo: !!teamLogo,
+ has_bio: !!teamBio,
+ });
+
+ setTeamDetails({
+ name: teamName,
+ slug: teamSlug,
+ bio: teamBio,
+ });
+
+ setTeamBrand({
+ logo: teamLogo || null,
+ });
+
+ try {
+ await createTeam();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : t("something_went_wrong");
+ showToast(message, "error");
+ }
+ };
+
+ const handleCancel = () => {
+ posthog.capture("settings_team_details_cancel_clicked");
+ resetOnboardingPreservingPlan();
+ router.push("/teams");
};
return (
- router.push(returnToParam)}
- submitLabel={flag.submitLabel}
- onSuccess={onSuccess}
- />
- );
-};
-export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
- return (
-
- {children}
-
+
+ {/* Left column - Main content */}
+
+
+ }>
+
+ {/* Team Profile Picture */}
+
+
+
+
{t("onboarding_logo_size_hint")}
+
+
+ {/* Team Name */}
+
+
+ setTeamName(e.target.value)}
+ placeholder="Acme Inc."
+ className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
+ />
+
+
+ {/* Team Slug */}
+
+
+ {/* Team Bio */}
+
+
+
+
+
+
+
+
+ {/* Right column - Browser view */}
+
+
);
};
-export default CreateNewTeamPage;
+export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
+ return <>{children}>;
+};
+
+export default CreateNewTeamView;
diff --git a/apps/web/modules/settings/teams/new/invite/csv-upload-modal.tsx b/apps/web/modules/settings/teams/new/invite/csv-upload-modal.tsx
new file mode 100644
index 0000000000..9b124f22cc
--- /dev/null
+++ b/apps/web/modules/settings/teams/new/invite/csv-upload-modal.tsx
@@ -0,0 +1,263 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import React, { useRef, useState } from "react";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Button } from "@calcom/ui/components/button";
+import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
+import { Icon } from "@calcom/ui/components/icon";
+import { Logo } from "@calcom/ui/components/logo";
+import { showToast } from "@calcom/ui/components/toast";
+
+import { useOnboardingStore, type Invite } from "~/onboarding/store/onboarding-store";
+
+/**
+ * Parse a CSV line handling quoted fields that may contain commas.
+ * Example: '"Name, Inc",admin' -> ['Name, Inc', 'admin']
+ */
+function parseCSVLine(line: string): string[] {
+ const result: string[] = [];
+ let current = "";
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+
+ if (char === '"') {
+ inQuotes = !inQuotes;
+ } else if (char === "," && !inQuotes) {
+ result.push(current.trim());
+ current = "";
+ } else {
+ current += char;
+ }
+ }
+
+ result.push(current.trim());
+ return result;
+}
+
+type CSVUploadModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+export const CSVUploadModal = ({ isOpen, onClose }: CSVUploadModalProps) => {
+ const { t } = useLocale();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const store = useOnboardingStore();
+ const { setTeamInvites, teamDetails, teamId } = store;
+ const fileInputRef = useRef(null);
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [isUploading, setIsUploading] = useState(false);
+
+ const handleFileSelect = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (file) {
+ if (!file.name.endsWith(".csv")) {
+ showToast(t("please_upload_csv_file"), "error");
+ return;
+ }
+ setSelectedFile(file);
+ }
+ };
+
+ const handleDownloadTemplate = () => {
+ const headers = ["email", "role"];
+ const exampleRow = ["example@email.com", "MEMBER"];
+ const csvContent = [headers.join(","), exampleRow.join(",")].join("\n");
+
+ const blob = new Blob([csvContent], { type: "text/csv" });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "team_invite_template.csv";
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+
+ showToast(t("template_downloaded"), "success");
+ };
+
+ const handleUpload = async () => {
+ if (!selectedFile) {
+ showToast(t("please_select_file"), "error");
+ return;
+ }
+
+ setIsUploading(true);
+
+ try {
+ const text = await selectedFile.text();
+ const lines = text.split("\n").filter((line) => line.trim());
+
+ if (lines.length < 2) {
+ showToast(t("csv_file_empty"), "error");
+ setIsUploading(false);
+ return;
+ }
+
+ const headers = parseCSVLine(lines[0]).map((h) => h.toLowerCase());
+ const emailIndex = headers.indexOf("email");
+ const roleIndex = headers.indexOf("role");
+
+ if (emailIndex === -1) {
+ showToast(t("csv_missing_email_column"), "error");
+ setIsUploading(false);
+ return;
+ }
+
+ const parsedInvites = lines
+ .slice(1)
+ .map((line) => {
+ const values = parseCSVLine(line);
+ return {
+ email: values[emailIndex]?.trim(),
+ role: (roleIndex !== -1 ? values[roleIndex]?.trim().toUpperCase() : "MEMBER") as
+ | "MEMBER"
+ | "ADMIN",
+ };
+ })
+ .filter((invite) => invite.email && invite.email.length > 0);
+
+ if (parsedInvites.length === 0) {
+ showToast(t("csv_file_empty"), "error");
+ setIsUploading(false);
+ return;
+ }
+
+ const invites: Invite[] = parsedInvites.map((invite) => ({
+ email: invite.email,
+ team: teamDetails.name,
+ role: invite.role === "ADMIN" ? "ADMIN" : "MEMBER",
+ }));
+
+ setTeamInvites(invites);
+
+ showToast(t("csv_uploaded_successfully", { count: invites.length }), "success");
+ onClose();
+
+ // Navigate to email page to display the invites - use settings path
+ const teamIdParam = searchParams?.get("teamId") || teamId;
+ router.push(`/settings/teams/new/invite/email?teamId=${teamIdParam}`);
+ } catch (error) {
+ console.error("Error uploading CSV:", error);
+ showToast(t("error_uploading_csv"), "error");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const handleClose = () => {
+ setSelectedFile(null);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ onClose();
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/modules/settings/teams/new/invite/email/team-invite-email-view.tsx b/apps/web/modules/settings/teams/new/invite/email/team-invite-email-view.tsx
new file mode 100644
index 0000000000..0119a9fd75
--- /dev/null
+++ b/apps/web/modules/settings/teams/new/invite/email/team-invite-email-view.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc/react";
+import { Button } from "@calcom/ui/components/button";
+import { Form } from "@calcom/ui/components/form";
+import { showToast } from "@calcom/ui/components/toast";
+import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useRouter, useSearchParams } from "next/navigation";
+import React, { useEffect } from "react";
+import { useFieldArray, useForm } from "react-hook-form";
+import { z } from "zod";
+import { EmailInviteForm } from "~/onboarding/components/EmailInviteForm";
+import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
+import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
+import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
+import { RoleSelector } from "~/onboarding/components/RoleSelector";
+import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
+import { type InviteRole, useOnboardingStore } from "~/onboarding/store/onboarding-store";
+
+type TeamInviteEmailViewProps = {
+ userEmail: string;
+};
+
+type FormValues = {
+ invites: {
+ email: string;
+ role: InviteRole;
+ }[];
+};
+
+export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { t, i18n } = useLocale();
+ const utils = trpc.useUtils();
+
+ const store = useOnboardingStore();
+ const { teamInvites, setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } =
+ store;
+ const [inviteRole, setInviteRole] = React.useState("MEMBER");
+ const { inviteMembers, isSubmitting } = useCreateTeam({
+ redirectBasePath: "/settings/teams/new",
+ skipRedirectAfterInvite: true,
+ });
+
+ // Read teamId from query params and store it (from payment callback or redirect)
+ useEffect(() => {
+ const teamIdParam = searchParams?.get("teamId");
+ if (teamIdParam && !teamId) {
+ const parsedTeamId = parseInt(teamIdParam, 10);
+ if (!isNaN(parsedTeamId)) {
+ setTeamId(parsedTeamId);
+ }
+ }
+ }, [searchParams, setTeamId, teamId]);
+
+ const formSchema = z.object({
+ invites: z.array(
+ z.object({
+ email: z.union([z.literal(""), z.string().email(t("invalid_email_address"))]),
+ role: z.enum(["MEMBER", "ADMIN"]),
+ })
+ ),
+ });
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ invites:
+ teamInvites.length > 0
+ ? teamInvites.map((inv) => ({ email: inv.email, role: inv.role }))
+ : [{ email: "", role: inviteRole }],
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "invites",
+ });
+
+ const handleContinue = async (data: FormValues) => {
+ const teamIdParam = searchParams?.get("teamId");
+ const parsedTeamId = !teamId ? parseInt(teamIdParam || "", 10) : teamId;
+ if (!parsedTeamId) {
+ console.log("Team ID is missing. Please go back and create your team first.");
+ 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,
+ role: invite.role,
+ team: teamDetails.name,
+ }));
+
+ setTeamInvites(invitesWithTeam);
+
+ // 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
+ );
+ // Invalidate both server-side cache and client-side tRPC cache
+ await revalidateTeamsList();
+ await utils.viewer.teams.list.invalidate();
+ resetOnboardingPreservingPlan();
+ router.replace("/teams");
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : t("something_went_wrong") || "Something went wrong";
+ showToast(errorMessage, "error");
+ }
+ } else {
+ // No invites, skip to teams page
+ await revalidateTeamsList();
+ await utils.viewer.teams.list.invalidate();
+ resetOnboardingPreservingPlan();
+ router.replace("/teams");
+ }
+ };
+
+ const handleSkip = async () => {
+ setTeamInvites([]);
+ await revalidateTeamsList();
+ await utils.viewer.teams.list.invalidate();
+ resetOnboardingPreservingPlan();
+ router.replace("/teams");
+ };
+
+ // Watch form values to pass to browser view for real-time updates
+ const watchedInvites = form.watch("invites");
+
+ const hasValidInvites = watchedInvites.some((invite) => {
+ return invite.email && invite.email.trim().length > 0;
+ });
+
+ return (
+
+ {/* Left column - Main content */}
+
+ }>
+
+
+
+
+ {
+ setInviteRole(value);
+ fields.forEach((_, index) => {
+ form.setValue(`invites.${index}.role`, value);
+ });
+ }}
+ showInfoBadge
+ />
+
+
+
+
+
+
+ {/* Right column - Browser view */}
+
+
+ );
+};
diff --git a/apps/web/modules/settings/teams/new/invite/team-invite-view.tsx b/apps/web/modules/settings/teams/new/invite/team-invite-view.tsx
new file mode 100644
index 0000000000..e35af048b0
--- /dev/null
+++ b/apps/web/modules/settings/teams/new/invite/team-invite-view.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import posthog from "posthog-js";
+import React, { useEffect } from "react";
+
+import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Button } from "@calcom/ui/components/button";
+
+import { InviteOptions } from "~/onboarding/components/InviteOptions";
+import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
+import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
+import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
+import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
+import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
+
+import { CSVUploadModal } from "./csv-upload-modal";
+
+type TeamInviteViewProps = {
+ userEmail: string;
+};
+
+export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { t } = useLocale();
+ const flags = useFlags();
+
+ const store = useOnboardingStore();
+ const { setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } = store;
+ const { isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
+ 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 parsedTeamId = parseInt(teamIdParam, 10);
+ if (!isNaN(parsedTeamId)) {
+ setTeamId(parsedTeamId);
+ }
+ }
+ }, [searchParams, setTeamId]);
+
+ const googleWorkspaceEnabled = flags["google-workspace-directory"];
+
+ const handleGoogleWorkspaceConnect = () => {
+ // TODO: Implement Google Workspace connection
+ console.log("Connect Google Workspace");
+ };
+
+ const handleInviteViaEmail = () => {
+ router.push(`/settings/teams/new/invite/email?teamId=${teamId}`);
+ };
+
+ const handleUploadCSV = () => {
+ setIsCSVModalOpen(true);
+ };
+
+ const handleCopyInviteLink = () => {
+ // Disabled for now as per requirements
+ console.log("Copy invite link - disabled");
+ };
+
+ const handleSkip = async () => {
+ posthog.capture("settings_team_invite_skip_clicked");
+ setTeamInvites([]);
+ resetOnboardingPreservingPlan();
+ // Redirect to teams page after skipping
+ router.push("/teams");
+ };
+
+ const handleBack = () => {
+ posthog.capture("settings_team_invite_back_clicked");
+ router.push("/settings/teams/new");
+ };
+
+ return (
+ <>
+
+ {/* Left column - Main content */}
+
+
+
+
+
+
+
+ }>
+
+
+
+
+ {/* Right column - Browser view */}
+
+
+
+ {/* CSV Upload Modal */}
+
setIsCSVModalOpen(false)} />
+ >
+ );
+};
diff --git a/apps/web/playwright/auth/auth-index.e2e.ts b/apps/web/playwright/auth/auth-index.e2e.ts
index 8665919d81..31a54371c9 100644
--- a/apps/web/playwright/auth/auth-index.e2e.ts
+++ b/apps/web/playwright/auth/auth-index.e2e.ts
@@ -26,16 +26,18 @@ test.describe("Can signup from a team invite", async () => {
await page.goto("/settings/teams/new");
await page.waitForLoadState("networkidle");
- // Create a new team
- await page.locator('input[name="name"]').fill(teamName);
- await page.locator('input[name="slug"]').fill(teamName);
+ // Create a new team (new onboarding-v3 style flow)
+ await page.locator('[data-testid="team-name-input"]').fill(teamName);
await page.locator('button[type="submit"]').click();
- // Add new member to team
- await page.click('[data-testid="new-member-button"]');
- await page.fill('input[id="inviteUser"]', testUser.email);
+ // Wait for the invite email page
+ await page.waitForURL(/\/settings\/teams\/new\/invite\/email.*$/i);
+ await page.waitForLoadState("networkidle");
+
+ // Add new member to team via email invite form
+ await page.locator('input[type="email"]').first().fill(testUser.email);
const submitPromise = page.waitForResponse("/api/trpc/teams/inviteMember?batch=1");
- await page.getByTestId("invite-new-member-button").click();
+ await page.locator('button[type="submit"]').click();
const response = await submitPromise;
expect(response.status()).toBe(200);
diff --git a/apps/web/playwright/organization/team-management.e2e.ts b/apps/web/playwright/organization/team-management.e2e.ts
index 9b79891272..1edda7dbdc 100644
--- a/apps/web/playwright/organization/team-management.e2e.ts
+++ b/apps/web/playwright/organization/team-management.e2e.ts
@@ -29,51 +29,30 @@ test.describe("Teams", () => {
// Click text=Create Team
await page.locator("text=Create a new Team").click();
await page.waitForLoadState("networkidle");
- // Fill input[name="name"]
- await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
+ // Fill team name input (new onboarding-v3 style flow)
+ await page.locator('[data-testid="team-name-input"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
- await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
- await page.waitForSelector('[data-testid="pending-member-list"]');
- await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
+ // Wait for the invite email page (new flow)
+ await expect(page).toHaveURL(/\/settings\/teams\/new\/invite\/email.*$/i);
+ await page.waitForLoadState("networkidle");
});
- await test.step("Can add members", async () => {
- await page.getByTestId("new-member-button").click();
- await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
- await page.getByTestId("invite-new-member-button").click();
- await expect(page.getByTestId("pending-member-item").filter({ hasText: inviteeEmail })).toBeVisible();
-
- // locator.count() does not await for the expected number of elements
- // https://github.com/microsoft/playwright/issues/14278
- // using toHaveCount() is more reliable
- await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
+ await test.step("Can add members via email invite", async () => {
+ // Add member via email invite form
+ await page.locator('input[type="email"]').first().fill(inviteeEmail);
+ await page.click("[type=submit]");
+ await page.waitForLoadState("networkidle");
+ // Wait for redirect to teams page
+ await expect(page).toHaveURL(/\/teams$/i);
});
- await test.step("Can remove members", async () => {
- await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
- const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
- await lastRemoveMemberButton.click();
- await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
-
- // Cleanup here since this user is created without our fixtures.
- await prisma.user.delete({ where: { email: inviteeEmail } });
- });
-
- await test.step("Can finish team invitation", async () => {
- await page.getByTestId("publish-button").click();
- await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/event-type$/i);
- });
-
- await test.step("Can finish team creation", async () => {
- const locator = page.locator('div:has(input[value="ROUND_ROBIN"]) > button');
- await expect(locator).toBeVisible();
- await locator.click();
- await page.fill("[name=title]", "roundRobin");
- await page.getByTestId("finish-button").click();
+ await test.step("Can navigate to team settings", async () => {
+ // Click on the team to go to settings
+ await page.locator(`text=${user.username}'s Team`).click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
});
@@ -82,6 +61,13 @@ test.describe("Teams", () => {
await page.getByTestId("dialog-confirmation").click();
await page.waitForURL("/teams");
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
+
+ // Cleanup the invited user since they were created without our fixtures
+ const invitedUser = await prisma.user.findUnique({ where: { email: inviteeEmail } });
+ // eslint-disable-next-line playwright/no-conditional-in-test
+ if (invitedUser) {
+ await prisma.user.delete({ where: { email: inviteeEmail } });
+ }
});
});
});
diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts
index 376fe4ba78..17a439447d 100644
--- a/apps/web/playwright/teams.e2e.ts
+++ b/apps/web/playwright/teams.e2e.ts
@@ -139,11 +139,11 @@ test.describe("Teams - NonOrg", () => {
const uniqueName = "test-unique-team-name";
- // Go directly to the create team page
+ // Go directly to the create team page (new onboarding-v3 style flow)
await page.goto("/settings/teams/new");
await page.waitForLoadState("networkidle");
- // Fill input[name="name"]
- await page.locator('input[name="name"]').fill(uniqueName);
+ // Fill team name input
+ await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
await page.click("[type=submit]");
// cleanup
@@ -163,22 +163,22 @@ test.describe("Teams - NonOrg", () => {
// Click text=Create Team
await page.locator("text=Create Team").click();
await page.waitForLoadState("networkidle");
- // Fill input[name="name"]
- await page.locator('input[name="name"]').fill(uniqueName);
- // Click text=Continue
+ // Fill team name input (new onboarding-v3 style flow)
+ await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
+ // Click Continue
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
- await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
- // Wait for the page to fully load and the publish button to be visible
+ // Wait for the invite email page
+ await expect(page).toHaveURL(/\/settings\/teams\/new\/invite\/email.*$/i);
await page.waitForLoadState("networkidle");
- const publishButton = page.locator("[data-testid=publish-button]");
- await publishButton.waitFor({ state: "visible", timeout: 10000 });
- await publishButton.click();
- await page.waitForURL(/\/settings\/teams\/(\d+)\/event-type*$/i);
- await page.locator("[data-testid=handle-later-button]").click();
- await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
+ // Skip the invite step
+ const skipButton = page.locator("[data-testid=skip-invite-button]");
+ await skipButton.waitFor({ state: "visible", timeout: 10000 });
+ await skipButton.click();
+ // Wait for redirect to teams page
+ await page.waitForURL(/\/teams$/i);
});
await test.step("Can access user and team with same slug", async () => {