From d80ee83da76285c412bc6102410aaa0d66ab680b Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:19:39 +0000 Subject: [PATCH] feat: redesign team creation flow to match onboarding (#27698) * Revert "Revert "feat: redesign team creation flow to match onboarding-v3 design (#26733)"" This re-applies the team creation redesign from PR #26733 which was previously reverted. Cherry-picked from 2540423ba3. Co-Authored-By: Claude Opus 4.6 * fix: check org/username slug when in org context * fix: use expect(page).toHaveURL() instead of page.waitForURL() in team e2e test Co-Authored-By: unknown <> * fix: redirect URL * fix: flag import --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../settings/teams/new/invite/email/page.tsx | 34 +++ .../settings/teams/new/invite/page.tsx | 34 +++ .../settings/teams/new/page.tsx | 18 +- .../components/onboarding-browser-view.tsx | 14 +- .../modules/onboarding/hooks/useCreateTeam.ts | 52 ++-- .../action/check-team-slug-availability.ts | 23 +- .../teams/details/validated-team-slug.tsx | 8 +- .../teams/new/create-new-team-view.tsx | 236 +++++++++++++--- .../teams/new/invite/csv-upload-modal.tsx | 263 ++++++++++++++++++ .../invite/email/team-invite-email-view.tsx | 210 ++++++++++++++ .../teams/new/invite/team-invite-view.tsx | 125 +++++++++ apps/web/playwright/auth/auth-index.e2e.ts | 16 +- .../organization/team-management.e2e.ts | 58 ++-- apps/web/playwright/teams.e2e.ts | 28 +- 14 files changed, 986 insertions(+), 133 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/settings/teams/new/invite/email/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/settings/teams/new/invite/page.tsx create mode 100644 apps/web/modules/settings/teams/new/invite/csv-upload-modal.tsx create mode 100644 apps/web/modules/settings/teams/new/invite/email/team-invite-email-view.tsx create mode 100644 apps/web/modules/settings/teams/new/invite/team-invite-view.tsx 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 */} +
+ +