diff --git a/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx
new file mode 100644
index 0000000000..29ec2170dc
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx
@@ -0,0 +1,36 @@
+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 { OnboardingView } from "~/onboarding/getting-started/onboarding-view";
+
+export const generateMetadata = async () => {
+ return await _generateMetadata(
+ (t) => `${APP_NAME} - ${t("getting_started")}`,
+ () => "",
+ true,
+ undefined,
+ "/onboarding/getting-started"
+ );
+};
+
+const ServerPage = async () => {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user?.id) {
+ return redirect("/auth/login");
+ }
+
+ // Hello {username} || there. Not sure how to do this nicely with i18n just yet
+ const userName = session.user.name || "there";
+ const userEmail = session.user.email || "";
+
+ return ;
+};
+
+export default ServerPage;
diff --git a/apps/web/app/(use-page-wrapper)/onboarding/layout.tsx b/apps/web/app/(use-page-wrapper)/onboarding/layout.tsx
new file mode 100644
index 0000000000..b5231af770
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/onboarding/layout.tsx
@@ -0,0 +1,15 @@
+import { redirect } from "next/navigation";
+
+import { FeaturesRepository } from "@calcom/features/flags/features.repository";
+import { prisma } from "@calcom/prisma";
+
+export default async function OnboardingLayout({ children }: { children: React.ReactNode }) {
+ const featuresRepository = new FeaturesRepository(prisma);
+ const isOnboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
+
+ if (!isOnboardingV3Enabled) {
+ redirect("/getting-started");
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/brand/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/brand/page.tsx
new file mode 100644
index 0000000000..c7f9d7d2ab
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/brand/page.tsx
@@ -0,0 +1,35 @@
+import { createRouterCaller } from "app/_trpc/context";
+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 { OrganizationBrandView } from "~/onboarding/organization/brand/organization-brand-view";
+
+export const generateMetadata = async () => {
+ return await _generateMetadata(
+ (t) => `${APP_NAME} - ${t("organization_brand")}`,
+ () => "",
+ true,
+ undefined,
+ "/onboarding/organization/brand"
+ );
+};
+
+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)/onboarding/organization/details/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/details/page.tsx
new file mode 100644
index 0000000000..fd48f95f86
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/details/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 { OrganizationDetailsView } from "~/onboarding/organization/details/organization-details-view";
+
+export const generateMetadata = async () => {
+ return await _generateMetadata(
+ (t) => `${APP_NAME} - ${t("organization_details")}`,
+ () => "",
+ true,
+ undefined,
+ "/onboarding/organization/details"
+ );
+};
+
+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)/onboarding/organization/invite/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/invite/page.tsx
new file mode 100644
index 0000000000..9fc8511a47
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/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 { OrganizationInviteView } from "~/onboarding/organization/invite/organization-invite-view";
+
+export const generateMetadata = async () => {
+ return await _generateMetadata(
+ (t) => `${APP_NAME} - ${t("invite_teammates")}`,
+ () => "",
+ true,
+ undefined,
+ "/onboarding/organization/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)/onboarding/organization/teams/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/teams/page.tsx
new file mode 100644
index 0000000000..703a06dda7
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/teams/page.tsx
@@ -0,0 +1,35 @@
+import { createRouterCaller } from "app/_trpc/context";
+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 { OrganizationTeamsView } from "~/onboarding/organization/teams/organization-teams-view";
+
+export const generateMetadata = async () => {
+ return await _generateMetadata(
+ (t) => `${APP_NAME} - ${t("organization_teams")}`,
+ () => "",
+ true,
+ undefined,
+ "/onboarding/organization/teams"
+ );
+};
+
+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/lib/signup/getServerSideProps.tsx b/apps/web/lib/signup/getServerSideProps.tsx
index 3111101f53..617c48fb5a 100644
--- a/apps/web/lib/signup/getServerSideProps.tsx
+++ b/apps/web/lib/signup/getServerSideProps.tsx
@@ -30,6 +30,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
"email-verification"
);
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
+ const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
const token = z.string().optional().parse(ctx.query.token);
const redirectUrlData = z
@@ -62,6 +63,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
isSAMLLoginEnabled,
prepopulateFormValues: undefined,
emailVerificationEnabled,
+ onboardingV3Enabled,
};
if ((process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" && !token) || signupDisabled) {
diff --git a/apps/web/modules/onboarding/components/onboarding-continuation-prompt.tsx b/apps/web/modules/onboarding/components/onboarding-continuation-prompt.tsx
new file mode 100644
index 0000000000..fc505ee612
--- /dev/null
+++ b/apps/web/modules/onboarding/components/onboarding-continuation-prompt.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+
+import { Button } from "@calcom/ui/components/button";
+import { Icon } from "@calcom/ui/components/icon";
+
+import { useOnboardingStore } from "../store/onboarding-store";
+
+export const OnboardingContinuationPrompt = () => {
+ const router = useRouter();
+ const { organizationDetails, resetOnboarding } = useOnboardingStore();
+ const [isVisible, setIsVisible] = useState(false);
+
+ useEffect(() => {
+ // Check if there's existing organization data
+ const hasExistingData = organizationDetails.name && organizationDetails.link;
+ setIsVisible(!!hasExistingData);
+ }, [organizationDetails]);
+
+ if (!isVisible) {
+ return null;
+ }
+
+ const handleContinue = () => {
+ // Navigate to the next step in the flow (brand page)
+ router.push("/onboarding/organization/brand");
+ };
+
+ const handleStartOver = () => {
+ resetOnboarding();
+ setIsVisible(false);
+ };
+
+ return (
+
+
+
+
+
+
Continue onboarding?
+
+ Would you like to carry on with onboarding for{" "}
+ {organizationDetails.name} or start over?
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx
new file mode 100644
index 0000000000..02d6acad40
--- /dev/null
+++ b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
+import { Badge } from "@calcom/ui/components/badge";
+import { Button } from "@calcom/ui/components/button";
+import { Logo } from "@calcom/ui/components/logo";
+import { RadioAreaGroup } from "@calcom/ui/components/radio";
+
+import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt";
+import { useOnboardingStore, type PlanType } from "../store/onboarding-store";
+
+type OnboardingViewProps = {
+ userName: string;
+ userEmail: string;
+};
+
+export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => {
+ const router = useRouter();
+ const { selectedPlan, setSelectedPlan } = useOnboardingStore();
+
+ const handleContinue = () => {
+ if (selectedPlan === "organization") {
+ router.push("/onboarding/organization/details");
+ }
+ // TODO: Handle other plan types
+ };
+
+ const allPlans = [
+ {
+ id: "personal" as PlanType,
+ title: "For personal use",
+ badge: "Free",
+ description: "Good for individuals who are just starting out and simply want the essentials.",
+ },
+ {
+ id: "team" as PlanType,
+ title: "With my team",
+ badge: "$15 p/mo p/person",
+ description:
+ "Highly recommended for small teams who seek to upgrade their time and perform better as a unit.",
+ },
+ {
+ id: "organization" as PlanType,
+ title: "For my organization",
+ badge: "$37 p/mo p/person",
+ description: "Robust scheduling for larger teams looking to have more control, privacy, and security.",
+ },
+ ];
+
+ // Only show organization plan for company emails
+ const plans = allPlans.filter((plan) => {
+ if (plan.id === "organization") {
+ return isCompanyEmail(userEmail);
+ }
+ return true;
+ });
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ {/* Progress dots - centered */}
+
+
+
+
+
+ {/* Main content */}
+
+
+ {/* Card */}
+
+
+ {/* Card Header */}
+
+
+
Hey {userName}
+
+ To personalize your experience, what do you plan to use Cal.com for?
+
+
+
+
+ {/* Plan options */}
+
setSelectedPlan(value as PlanType)}
+ className="flex w-full flex-col gap-1 rounded-[10px]">
+ {plans.map((plan) => (
+
+
+
+
{plan.title}
+
+ {plan.badge}
+
+
+
+ {plan.badge}
+
+
+ {plan.description}
+
+
+
+ ))}
+
+
+ {/* Footer */}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts b/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts
new file mode 100644
index 0000000000..5f6c8b701c
--- /dev/null
+++ b/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts
@@ -0,0 +1,99 @@
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+import { CreationSource } from "@calcom/prisma/enums";
+import { trpc } from "@calcom/trpc/react";
+import { showToast } from "@calcom/ui/components/toast";
+
+import type { OnboardingState } from "../store/onboarding-store";
+
+export const useSubmitOnboarding = () => {
+ const router = useRouter();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const intentToCreateOrg = trpc.viewer.organizations.intentToCreateOrg.useMutation();
+
+ const submitOnboarding = async (store: OnboardingState, userEmail: string) => {
+ setIsSubmitting(true);
+ setError(null);
+
+ try {
+ const {
+ selectedPlan,
+ organizationDetails,
+ organizationBrand,
+ teams,
+ invites,
+ inviteRole,
+ resetOnboarding,
+ } = 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
+ name: team.name,
+ isBeingMigrated: false,
+ slug: null,
+ }));
+
+ // Prepare invites data
+ const invitedMembersData = invites
+ .filter((invite) => invite.email.trim().length > 0)
+ .map((invite) => ({
+ email: invite.email,
+ teamName: invite.team,
+ teamId: -1,
+ role: inviteRole,
+ }));
+
+ const result = await intentToCreateOrg.mutateAsync({
+ name: organizationDetails.name,
+ slug: organizationDetails.link,
+ bio: organizationDetails.bio || null,
+ logo: organizationBrand.logo,
+ brandColor: organizationBrand.color,
+ bannerUrl: organizationBrand.banner,
+ orgOwnerEmail: userEmail,
+ seats: null,
+ pricePerSeat: null,
+ isPlatform: false,
+ creationSource: CreationSource.WEBAPP,
+ teams: teamsData,
+ invitedMembers: invitedMembersData,
+ });
+
+ // If there's a checkout URL, redirect to Stripe (billing enabled flow)
+ if (result.checkoutUrl) {
+ window.location.href = result.checkoutUrl;
+ return;
+ }
+
+ // No checkout URL means billing is disabled (self-hosted flow)
+ // Organization has already been created by the backend
+ showToast("Organization created successfully!", "success");
+ // TODO: after this redirect we need to hard refresh the page to see org
+ resetOnboarding();
+ router.push("/getting-started");
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
+ setError(errorMessage);
+ showToast(errorMessage, "error");
+ console.error("Organization creation error:", err);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return {
+ submitOnboarding,
+ isSubmitting,
+ error,
+ };
+};
diff --git a/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx
new file mode 100644
index 0000000000..708f6b36bf
--- /dev/null
+++ b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import * as Popover from "@radix-ui/react-popover";
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import { HexColorPicker } from "react-colorful";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Button } from "@calcom/ui/components/button";
+import { Logo } from "@calcom/ui/components/logo";
+
+import { useOnboardingStore } from "../../store/onboarding-store";
+
+type OrganizationBrandViewProps = {
+ userEmail: string;
+};
+
+const BrandColorPicker = ({
+ value,
+ onChange,
+ t,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ t: (key: string) => string;
+}) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
{
+ const newValue = e.target.value.startsWith("#") ? e.target.value : `#${e.target.value}`;
+ onChange(newValue);
+ }}
+ className="text-emphasis grow border-none bg-transparent text-sm font-medium leading-4 outline-none"
+ maxLength={6}
+ />
+
+ );
+};
+
+export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) => {
+ const router = useRouter();
+ const { t } = useLocale();
+ const { organizationBrand, setOrganizationBrand, organizationDetails } = useOnboardingStore();
+
+ const [brandColor, setBrandColor] = useState("#000000");
+ const [_logoFile, setLogoFile] = useState(null);
+ const [_bannerFile, setBannerFile] = useState(null);
+ const [logoPreview, setLogoPreview] = useState(null);
+ const [bannerPreview, setBannerPreview] = useState(null);
+
+ // Load from store on mount
+ useEffect(() => {
+ setBrandColor(organizationBrand.color);
+ setLogoPreview(organizationBrand.logo);
+ setBannerPreview(organizationBrand.banner);
+ }, [organizationBrand]);
+
+ const handleLogoChange = (file: File | null) => {
+ setLogoFile(file);
+ if (file) {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ const base64 = reader.result as string;
+ setLogoPreview(base64);
+ setOrganizationBrand({ logo: base64 });
+ };
+ reader.readAsDataURL(file);
+ } else {
+ setLogoPreview(null);
+ setOrganizationBrand({ logo: null });
+ }
+ };
+
+ const handleBannerChange = (file: File | null) => {
+ setBannerFile(file);
+ if (file) {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ const base64 = reader.result as string;
+ setBannerPreview(base64);
+ setOrganizationBrand({ banner: base64 });
+ };
+ reader.readAsDataURL(file);
+ } else {
+ setBannerPreview(null);
+ setOrganizationBrand({ banner: null });
+ }
+ };
+
+ const handleContinue = () => {
+ // Save brand color to store
+ setOrganizationBrand({ color: brandColor });
+ router.push("/onboarding/organization/teams");
+ };
+
+ const handleSkip = () => {
+ // Skip brand setup and go to teams
+ router.push("/onboarding/organization/teams");
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ {/* Progress dots - centered */}
+
+
+
+
+
+ {/* Main content */}
+
+
+ {/* Card */}
+
+
+ {/* Card Header */}
+
+
+
{t("onboarding_org_brand_title")}
+
+ {t("onboarding_org_brand_subtitle")}
+
+
+
+
+ {/* Form */}
+
+
+
+
+ {/* Left side - Form */}
+
+ {/* Brand Color */}
+
+
{t("brand_color")}
+
+
+ {t("onboarding_primary_color_label")}
+
+
{
+ setBrandColor(value);
+ setOrganizationBrand({ color: value });
+ }}
+ t={t}
+ />
+
+
+
+ {/* Logo Upload */}
+
+
{t("logo")}
+
+
+ {logoPreview && (
+

+ )}
+
+
+
+ handleLogoChange(e.target.files?.[0] || null)}
+ />
+
+
+
+ {t("onboarding_logo_size_hint")}
+
+
+
+ {/* Banner Upload */}
+
+
{t("onboarding_banner_label")}
+
+
+ {bannerPreview && (
+

+ )}
+
+
+
+ handleBannerChange(e.target.files?.[0] || null)}
+ />
+
+
+
+ {t("onboarding_banner_size_hint")}
+
+
+
+
+ {/* Right side - Preview */}
+
+
+
{t("preview")}
+
+ {/* Banner preview */}
+
+ {bannerPreview && (
+

+ )}
+
+
+ {/* Content */}
+
+
+
+ {/* Logo preview */}
+
+ {logoPreview && (
+

+ )}
+
+
+ {organizationDetails.name || t("onboarding_preview_nameless")}
+
+
+
+
+
+ {t("onboarding_preview_example_title")}
+
+
+ {t("onboarding_preview_example_description")}
+
+
+
+
+
+ {[134, 104, 84, 104].map((width, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/modules/onboarding/organization/details/action/check-slug-availability.ts b/apps/web/modules/onboarding/organization/details/action/check-slug-availability.ts
new file mode 100644
index 0000000000..c1591a1abb
--- /dev/null
+++ b/apps/web/modules/onboarding/organization/details/action/check-slug-availability.ts
@@ -0,0 +1,46 @@
+"use server";
+
+import { cookies, headers } from "next/headers";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
+import { prisma } from "@calcom/prisma";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
+
+export async function checkSlugAvailability(slug: string): Promise<{
+ available: boolean;
+ message?: string;
+}> {
+ if (!slug || slug.trim() === "") {
+ return { available: false, message: "Slug is required" };
+ }
+
+ // Check if slug is reserved
+ if (RESERVED_SUBDOMAINS.includes(slug)) {
+ return { available: false, message: "This slug is reserved" };
+ }
+
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+ if (!session?.user?.id) {
+ return { available: false, message: "Unauthorized" };
+ }
+
+ // Check if slug already exists
+ const existingOrg = await prisma.team.findFirst({
+ where: {
+ slug,
+ parentId: null,
+ },
+ select: {
+ id: true,
+ isOrganization: true,
+ },
+ });
+
+ if (existingOrg) {
+ return { available: false, message: "This slug is already taken" };
+ }
+
+ return { available: true };
+}
diff --git a/apps/web/modules/onboarding/organization/details/organization-details-view.tsx b/apps/web/modules/onboarding/organization/details/organization-details-view.tsx
new file mode 100644
index 0000000000..be4e22b223
--- /dev/null
+++ b/apps/web/modules/onboarding/organization/details/organization-details-view.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Button } from "@calcom/ui/components/button";
+import { Label, TextField, TextArea } from "@calcom/ui/components/form";
+import { Logo } from "@calcom/ui/components/logo";
+
+import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
+import { useOnboardingStore } from "../../store/onboarding-store";
+import { ValidatedOrganizationSlug } from "./validated-organization-slug";
+
+type OrganizationDetailsViewProps = {
+ userEmail: string;
+};
+
+// Helper function to slugify organization name
+const slugify = (text: string): string => {
+ return text
+ .toLowerCase()
+ .trim()
+ .replace(/[^\w\s-]/g, "") // Remove special characters
+ .replace(/[\s_-]+/g, "-") // Replace spaces, underscores with single dash
+ .replace(/^-+|-+$/g, ""); // Remove leading/trailing dashes
+};
+
+export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewProps) => {
+ const router = useRouter();
+ const { t } = useLocale();
+ const { organizationDetails, setOrganizationDetails } = useOnboardingStore();
+
+ const [organizationName, setOrganizationName] = useState("");
+ const [organizationLink, setOrganizationLink] = useState("");
+ const [organizationBio, setOrganizationBio] = useState("");
+ const [isSlugValid, setIsSlugValid] = useState(false);
+ const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
+
+ // Load from store on mount
+ useEffect(() => {
+ setOrganizationName(organizationDetails.name);
+ setOrganizationLink(organizationDetails.link);
+ setOrganizationBio(organizationDetails.bio);
+ // If there's a pre-existing link, consider it manually set
+ if (organizationDetails.link) {
+ setIsSlugManuallyEdited(true);
+ }
+ }, [organizationDetails]);
+
+ // Auto-populate slug from organization name (unless manually edited)
+ useEffect(() => {
+ if (!isSlugManuallyEdited && organizationName) {
+ const slugifiedName = slugify(organizationName);
+ setOrganizationLink(slugifiedName);
+ }
+ }, [organizationName, isSlugManuallyEdited]);
+
+ const handleSlugChange = (value: string) => {
+ setOrganizationLink(value);
+ setIsSlugManuallyEdited(true);
+ };
+
+ const handleContinue = () => {
+ if (!isSlugValid) {
+ return;
+ }
+
+ // Save to store
+ setOrganizationDetails({
+ name: organizationName,
+ link: organizationLink,
+ bio: organizationBio,
+ });
+ router.push("/onboarding/organization/brand");
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ {/* Progress dots - centered */}
+
+
+
+
+
+ {/* Main content */}
+
+
+ {/* Card */}
+
+
+ {/* Card Header */}
+
+
+
+ {t("onboarding_org_details_title")}
+
+
+ {t("onboarding_org_details_subtitle")}
+
+
+
+
+ {/* Form */}
+
+
+
+
+
+ {/* Organization Name */}
+
+
+ setOrganizationName(e.target.value)}
+ placeholder={t("organization_name")}
+ className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
+ />
+
+
+ {/* Organization Link */}
+
+
+ {/* Organization Bio */}
+
+
+
+
+
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/modules/onboarding/organization/details/validated-organization-slug.tsx b/apps/web/modules/onboarding/organization/details/validated-organization-slug.tsx
new file mode 100644
index 0000000000..1f88492c16
--- /dev/null
+++ b/apps/web/modules/onboarding/organization/details/validated-organization-slug.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState, useTransition } from "react";
+
+import classNames from "@calcom/ui/classNames";
+import { Label, TextField } from "@calcom/ui/components/form";
+import { Icon } from "@calcom/ui/components/icon";
+
+import { checkSlugAvailability } from "./action/check-slug-availability";
+
+type ValidationState = "idle" | "checking" | "available" | "taken";
+
+type ValidatedOrganizationSlugProps = {
+ value: string;
+ onChange: (value: string) => void;
+ onValidationChange?: (isValid: boolean) => void;
+};
+
+export function ValidatedOrganizationSlug({
+ value,
+ onChange,
+ onValidationChange,
+}: ValidatedOrganizationSlugProps) {
+ const [validationState, setValidationState] = useState("idle");
+ const [errorMessage, setErrorMessage] = useState("");
+ const [_isPending, startTransition] = useTransition();
+ const timeoutRef = useRef();
+
+ const validateSlug = useCallback(
+ (slug: string) => {
+ if (!slug || slug.trim() === "") {
+ setValidationState("idle");
+ setErrorMessage("");
+ onValidationChange?.(false);
+ return;
+ }
+
+ setValidationState("checking");
+
+ // Call server action
+ checkSlugAvailability(slug).then((result) => {
+ startTransition(() => {
+ if (result.available) {
+ setValidationState("available");
+ setErrorMessage("");
+ onValidationChange?.(true);
+ } else {
+ setValidationState("taken");
+ setErrorMessage(result.message || "This slug is not available");
+ onValidationChange?.(false);
+ }
+ });
+ });
+ },
+ [onValidationChange]
+ );
+
+ useEffect(() => {
+ // Clear previous timeout
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+
+ // Set new timeout
+ timeoutRef.current = setTimeout(() => {
+ validateSlug(value);
+ }, 500);
+
+ // Cleanup on unmount
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, [value, validateSlug]);
+
+ return (
+
+
+
onChange(e.target.value)}
+ placeholder="acme"
+ addOnSuffix={
+
+ {validationState === "checking" && (
+
+ )}
+ .cal.com
+
+ }
+ className={classNames(validationState === "taken" ? "border-error" : "")}
+ />
+ {validationState === "taken" && {errorMessage}
}
+
+ );
+}
diff --git a/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx b/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx
new file mode 100644
index 0000000000..fcae801e67
--- /dev/null
+++ b/apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx
@@ -0,0 +1,292 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useRouter } from "next/navigation";
+import React from "react";
+import { useForm, useFieldArray } from "react-hook-form";
+import { z } from "zod";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Button } from "@calcom/ui/components/button";
+import { Form, Label, TextField, Select, ToggleGroup } from "@calcom/ui/components/form";
+import { Icon } from "@calcom/ui/components/icon";
+import { Logo } from "@calcom/ui/components/logo";
+
+import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
+import { useOnboardingStore } from "../../store/onboarding-store";
+
+type OrganizationInviteViewProps = {
+ userEmail: string;
+};
+
+type FormValues = {
+ invites: {
+ email: string;
+ team: string;
+ role: "MEMBER" | "ADMIN";
+ }[];
+};
+
+export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProps) => {
+ const router = useRouter();
+ const { t } = useLocale();
+ // We are using email mode for now until we implement the other invite methods, csv, workspace - invite link
+ const isEmailMode = true;
+
+ const store = useOnboardingStore();
+ const usersEmailDomain = userEmail.split("@")[1];
+ const { invites: storedInvites, inviteRole, setInvites, setInviteRole } = store;
+ const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
+
+ const formSchema = z.object({
+ invites: z.array(
+ z.object({
+ email: z.string().email(t("invalid_email_address")),
+ team: z.string().min(1, t("onboarding_team_required")),
+ role: z.enum(["MEMBER", "ADMIN"]),
+ })
+ ),
+ });
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ invites: storedInvites.length > 0 ? storedInvites : [{ email: "", team: "", role: inviteRole }],
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "invites",
+ });
+
+ const handleSubmitOnboarding = async (invitesData: FormValues["invites"]) => {
+ // Save invites to store before submitting
+ setInvites(invitesData);
+ await submitOnboarding(store, userEmail);
+ };
+
+ const handleContinue = async (data?: FormValues) => {
+ if (data) {
+ await handleSubmitOnboarding(data.invites);
+ } else {
+ await handleSubmitOnboarding([]);
+ }
+ };
+
+ const handleSkip = async () => {
+ // Complete onboarding without invites
+ await handleSubmitOnboarding([]);
+ };
+
+ const handleInviteViaEmail = () => {
+ router.push("/onboarding/organization/invite?mode=email");
+ };
+
+ const hasValidInvites = fields.some((_, index) => {
+ const email = form.watch(`invites.${index}.email`);
+ const team = form.watch(`invites.${index}.team`);
+ return email && email.trim().length > 0 && team && team.trim().length > 0;
+ });
+
+ // Get teams from store, filter out empty teams
+ const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
+ const teams =
+ filteredTeams.length > 0
+ ? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
+ : [];
+
+ return (
+
+ {/* Header */}
+
+
+
+ {/* Progress dots - centered */}
+
+
+
+
+
+ {/* Main content */}
+
+
+ {/* Card */}
+
+
+ {/* Card Header */}
+
+
+
+ {t("onboarding_org_invite_title")}
+
+
+ {isEmailMode
+ ? t("onboarding_org_invite_subtitle_email")
+ : t("onboarding_org_invite_subtitle_full")}
+
+
+
+
+ {/* Content */}
+
+
+ {!isEmailMode ? (
+ // Coming soon
+ // Initial invite options view
+
+
+
+
+
+
{t("onboarding_or_divider")}
+
+
+
+
+
+
+
+
+
+ {/* Role selector */}
+
+
+ {t("onboarding_invite_all_as")}
+ value && setInviteRole(value as "MEMBER" | "ADMIN")}
+ options={[
+ { value: "ADMIN", label: t("onboarding_admins") },
+ { value: "MEMBER", label: t("members") },
+ ]}
+ />
+
+
{t("onboarding_modify_roles_later")}
+
+
+ ) : (
+ // Email invite form
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+ {/* Skip button */}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx b/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx
new file mode 100644
index 0000000000..b4641060d9
--- /dev/null
+++ b/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useRouter } from "next/navigation";
+import { useForm, useFieldArray } from "react-hook-form";
+import { z } from "zod";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Button } from "@calcom/ui/components/button";
+import { Form, TextField } from "@calcom/ui/components/form";
+import { Icon } from "@calcom/ui/components/icon";
+import { Logo } from "@calcom/ui/components/logo";
+
+import { useOnboardingStore } from "../../store/onboarding-store";
+
+type OrganizationTeamsViewProps = {
+ userEmail: string;
+};
+
+type FormValues = {
+ teams: { name: string }[];
+};
+
+export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
+ const router = useRouter();
+ const { t } = useLocale();
+ const { teams: storedTeams, setTeams } = useOnboardingStore();
+
+ const formSchema = z.object({
+ teams: z.array(
+ z.object({
+ name: z.string().min(1, t("team_name_required")),
+ })
+ ),
+ });
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ teams: storedTeams.length > 0 ? storedTeams : [{ name: "" }],
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "teams",
+ });
+
+ const handleContinue = (data: FormValues) => {
+ // Save teams to store
+ setTeams(data.teams);
+ router.push("/onboarding/organization/invite");
+ };
+
+ const handleSkip = () => {
+ // Skip teams and go to invite
+ router.push("/onboarding/organization/invite");
+ };
+
+ const hasValidTeams = fields.some((_, index) => {
+ const teamName = form.watch(`teams.${index}.name`);
+ return teamName && teamName.trim().length > 0;
+ });
+
+ return (
+
+ {/* Header */}
+
+
+
+ {/* Progress dots - centered */}
+
+
+
+
+
+ {/* Main content */}
+
+
+ {/* Card */}
+
+
+ {/* Card Header */}
+
+
+
{t("onboarding_org_teams_title")}
+
+ {t("onboarding_org_teams_subtitle")}
+
+
+
+
+ {/* Form */}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/modules/onboarding/store/onboarding-store.ts b/apps/web/modules/onboarding/store/onboarding-store.ts
new file mode 100644
index 0000000000..9da8083b68
--- /dev/null
+++ b/apps/web/modules/onboarding/store/onboarding-store.ts
@@ -0,0 +1,108 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+export type PlanType = "personal" | "team" | "organization";
+export type InviteRole = "MEMBER" | "ADMIN";
+
+export interface OrganizationDetails {
+ name: string;
+ link: string;
+ bio: string;
+}
+
+export interface OrganizationBrand {
+ color: string;
+ logo: string | null; // base64 or URL
+ banner: string | null; // base64 or URL
+}
+
+export interface Team {
+ name: string;
+}
+
+export interface Invite {
+ email: string;
+ team: string;
+ role: InviteRole;
+}
+
+export interface OnboardingState {
+ selectedPlan: PlanType | null;
+
+ organizationDetails: OrganizationDetails;
+
+ organizationBrand: OrganizationBrand;
+
+ teams: Team[];
+
+ invites: Invite[];
+ inviteRole: InviteRole;
+
+ // Actions
+ setSelectedPlan: (plan: PlanType) => void;
+ setOrganizationDetails: (details: Partial) => void;
+ setOrganizationBrand: (brand: Partial) => void;
+ setTeams: (teams: Team[]) => void;
+ setInvites: (invites: Invite[]) => void;
+ setInviteRole: (role: InviteRole) => void;
+
+ // Reset
+ resetOnboarding: () => void;
+}
+
+const initialState = {
+ selectedPlan: "personal" as PlanType,
+ organizationDetails: {
+ name: "",
+ link: "",
+ bio: "",
+ },
+ organizationBrand: {
+ color: "#000000",
+ logo: null,
+ banner: null,
+ },
+ teams: [],
+ invites: [],
+ inviteRole: "MEMBER" as InviteRole,
+};
+
+export const useOnboardingStore = create()(
+ persist(
+ (set) => ({
+ ...initialState,
+
+ setSelectedPlan: (plan) => set({ selectedPlan: plan }),
+
+ setOrganizationDetails: (details) =>
+ set((state) => ({
+ organizationDetails: { ...state.organizationDetails, ...details },
+ })),
+
+ setOrganizationBrand: (brand) =>
+ set((state) => ({
+ organizationBrand: { ...state.organizationBrand, ...brand },
+ })),
+
+ setTeams: (teams) => set({ teams }),
+
+ setInvites: (invites) => set({ invites }),
+
+ setInviteRole: (role) => set({ inviteRole: role }),
+
+ resetOnboarding: () => set(initialState),
+ }),
+ {
+ name: "cal-onboarding-storage", // localStorage key
+ // Optional: Only persist certain fields
+ partialize: (state) => ({
+ selectedPlan: state.selectedPlan,
+ organizationDetails: state.organizationDetails,
+ organizationBrand: state.organizationBrand,
+ teams: state.teams,
+ invites: state.invites,
+ inviteRole: state.inviteRole,
+ }),
+ }
+ )
+);
diff --git a/apps/web/modules/signup-view.tsx b/apps/web/modules/signup-view.tsx
index 487f1581cb..dc903b4930 100644
--- a/apps/web/modules/signup-view.tsx
+++ b/apps/web/modules/signup-view.tsx
@@ -184,6 +184,7 @@ export default function Signup({
orgAutoAcceptEmail,
redirectUrl,
emailVerificationEnabled,
+ onboardingV3Enabled,
}: SignupProps) {
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
const [isSamlSignup, setIsSamlSignup] = useState(false);
@@ -262,7 +263,8 @@ export default function Signup({
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
- const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : "getting-started";
+ const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started";
+ const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath;
const gettingStartedWithPlatform = "settings/platform/new";
const constructCallBackIfUrlPresent = () => {
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 3394d68f57..5e7121d98e 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -3846,5 +3846,35 @@
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
"what_would_you_like_to_block": "What would you like to block?",
"domain_name": "Domain Name",
+ "onboarding_org_details_title": "Add your Organization's details",
+ "onboarding_org_details_subtitle": "Customize your booking pages with your Organization's name and bio",
+ "onboarding_org_bio_label": "Organization bio",
+ "onboarding_org_bio_placeholder": "Acme helps thousands of companies expand globally with unmatched speed and flexibility.",
+ "onboarding_org_teams_title": "Add your Organization's teams",
+ "onboarding_org_teams_subtitle": "Start scheduling together by adding your teams",
+ "onboarding_org_brand_title": "Add your Organization's brand",
+ "onboarding_org_brand_subtitle": "Customize your booking pages with your logo and a custom banner",
+ "onboarding_primary_color_label": "Primary color",
+ "onboarding_banner_label": "Banner",
+ "onboarding_logo_size_hint": "Recommended size 64x64px (max 10mb)",
+ "onboarding_banner_size_hint": "Recommended size 1500x150px (max 10mb)",
+ "onboarding_preview_nameless": "Nameless",
+ "onboarding_preview_example_title": "Enterprise sales",
+ "onboarding_preview_example_description": "Jump on a call with one of our sales reps to learn more",
+ "onboarding_logo_preview_alt": "Logo preview",
+ "onboarding_banner_preview_alt": "Banner preview",
+ "onboarding_pick_color_aria": "Pick color",
+ "onboarding_org_invite_title": "Invite your teammates",
+ "onboarding_org_invite_subtitle_email": "Enter your teammates email address and set their team to add them to your Organization.",
+ "onboarding_org_invite_subtitle_full": "Connect your Google Workspace, invite via email, upload a CSV file or copy the invite link and share it with your teammates to add them to your Organization.",
+ "onboarding_connect_google_workspace": "Connect Google Workspace",
+ "onboarding_upload_csv": "Upload CSV file",
+ "onboarding_copy_invite_link": "Copy invite link",
+ "onboarding_invite_all_as": "Invite all as",
+ "onboarding_admins": "Admins",
+ "onboarding_modify_roles_later": "You can modify roles later",
+ "onboarding_skip_for_now": "Skip for now",
+ "onboarding_or_divider": "or",
+ "onboarding_team_required": "Team is required",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
diff --git a/packages/features/ee/organizations/lib/server/orgCreationUtils.ts b/packages/features/ee/organizations/lib/server/orgCreationUtils.ts
index 9aead5b715..5e17c3e00c 100644
--- a/packages/features/ee/organizations/lib/server/orgCreationUtils.ts
+++ b/packages/features/ee/organizations/lib/server/orgCreationUtils.ts
@@ -191,14 +191,9 @@ export async function assertCanCreateOrg({
"email-verification"
);
- const verifiedUser =
- orgOwner.completedOnboarding && (emailVerificationEnabled ? !!orgOwner.emailVerified : true);
+ const verifiedUser = emailVerificationEnabled ? !!orgOwner.emailVerified : true;
if (!verifiedUser) {
- log.warn(
- "you_need_to_complete_user_onboarding_before_creating_an_organization",
- safeStringify({ userId: orgOwner.id })
- );
- throw new OrgCreationError("you_need_to_complete_user_onboarding_before_creating_an_organization");
+ throw new OrgCreationError("you_need_to_verify_your_email_before_creating_an_organization");
}
if (isNotACompanyEmail(orgOwner.email) && !isPlatform) {
diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts
index 493cdc9c0b..1879a9c539 100644
--- a/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts
+++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts
@@ -810,5 +810,59 @@ describe("BillingEnabledOrgOnboardingService", () => {
})
);
});
+
+ it("should transfer branding data (logo, brandColor, bannerUrl) from onboarding to organization", async () => {
+ vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
+
+ const onboardingWithBranding = await createTestOnboarding({
+ logo: "https://example.com/logo.png",
+ bio: "Test organization bio",
+ brandColor: "#FF5733",
+ bannerUrl: "https://example.com/banner.jpg",
+ });
+
+ await createTestUser({
+ email: onboardingWithBranding.orgOwnerEmail,
+ onboardingCompleted: true,
+ emailVerified: new Date(),
+ });
+
+ const { organization } = await service.createOrganization(onboardingWithBranding, {
+ subscriptionId: "sub_123",
+ subscriptionItemId: "si_123",
+ });
+
+ expect(organization.logoUrl).toBe("https://example.com/logo.png");
+ expect(organization.bio).toBe("Test organization bio");
+ expect(organization.brandColor).toBe("#FF5733");
+ expect(organization.bannerUrl).toBe("https://example.com/banner.jpg");
+ });
+
+ it("should handle null branding data correctly", async () => {
+ vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
+
+ const onboardingWithNullBranding = await createTestOnboarding({
+ logo: null,
+ bio: null,
+ brandColor: null,
+ bannerUrl: null,
+ });
+
+ await createTestUser({
+ email: onboardingWithNullBranding.orgOwnerEmail,
+ onboardingCompleted: true,
+ emailVerified: new Date(),
+ });
+
+ const { organization } = await service.createOrganization(onboardingWithNullBranding, {
+ subscriptionId: "sub_123",
+ subscriptionItemId: "si_123",
+ });
+
+ expect(organization.logoUrl).toBeNull();
+ expect(organization.bio).toBeNull();
+ expect(organization.brandColor).toBeNull();
+ expect(organization.bannerUrl).toBeNull();
+ });
});
});
diff --git a/packages/features/ee/organizations/lib/utils.ts b/packages/features/ee/organizations/lib/utils.ts
index 5f2d778a43..d131233a71 100644
--- a/packages/features/ee/organizations/lib/utils.ts
+++ b/packages/features/ee/organizations/lib/utils.ts
@@ -1,8 +1,8 @@
export function extractDomainFromEmail(email: string) {
let out = "";
try {
- const match = email.match(/^(?:.*?:\/\/)?.*?(?[\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
- out = (match && match.groups?.root) ?? "";
+ const match = email.match(/^(?:.*?:\/\/)?.*?([\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
+ out = (match && match[1]) ?? "";
} catch (ignore) {}
return out.split(".")[0];
}
diff --git a/packages/features/ee/organizations/repositories/OrganizationRepository.test.ts b/packages/features/ee/organizations/repositories/OrganizationRepository.test.ts
index a30b679110..4310b40abe 100644
--- a/packages/features/ee/organizations/repositories/OrganizationRepository.test.ts
+++ b/packages/features/ee/organizations/repositories/OrganizationRepository.test.ts
@@ -150,3 +150,64 @@ describe("Organization.getVerifiedOrganizationByAutoAcceptEmailDomain", () => {
expect(result).toEqual(null);
});
});
+
+describe("Organization.create", () => {
+ it("should create organization with branding data (logoUrl, brandColor, bannerUrl)", async () => {
+ const orgData = {
+ name: "Test Organization",
+ slug: "test-org",
+ isOrganizationConfigured: true,
+ isOrganizationAdminReviewed: true,
+ autoAcceptEmail: "test.com",
+ seats: 10,
+ pricePerSeat: 15,
+ isPlatform: false,
+ billingPeriod: "MONTHLY" as const,
+ logoUrl: "https://example.com/logo.png",
+ bio: "Test organization bio",
+ brandColor: "#FF5733",
+ bannerUrl: "https://example.com/banner.jpg",
+ };
+
+ const organization = await OrganizationRepository.create(orgData);
+
+ expect(organization).toMatchObject({
+ name: "Test Organization",
+ slug: "test-org",
+ isOrganization: true,
+ logoUrl: "https://example.com/logo.png",
+ bio: "Test organization bio",
+ brandColor: "#FF5733",
+ bannerUrl: "https://example.com/banner.jpg",
+ });
+ });
+
+ it("should create organization with null branding data", async () => {
+ const orgData = {
+ name: "Test Organization",
+ slug: "test-org-2",
+ isOrganizationConfigured: true,
+ isOrganizationAdminReviewed: true,
+ autoAcceptEmail: "test.com",
+ seats: null,
+ pricePerSeat: null,
+ isPlatform: false,
+ logoUrl: null,
+ bio: null,
+ brandColor: null,
+ bannerUrl: null,
+ };
+
+ const organization = await OrganizationRepository.create(orgData);
+
+ expect(organization).toMatchObject({
+ name: "Test Organization",
+ slug: "test-org-2",
+ isOrganization: true,
+ logoUrl: null,
+ bio: null,
+ brandColor: null,
+ bannerUrl: null,
+ });
+ });
+});
diff --git a/packages/features/ee/organizations/repositories/OrganizationRepository.ts b/packages/features/ee/organizations/repositories/OrganizationRepository.ts
index ed038ba484..ebe32f414a 100644
--- a/packages/features/ee/organizations/repositories/OrganizationRepository.ts
+++ b/packages/features/ee/organizations/repositories/OrganizationRepository.ts
@@ -36,6 +36,8 @@ export class OrganizationRepository {
billingPeriod?: "MONTHLY" | "ANNUALLY";
logoUrl: string | null;
bio: string | null;
+ brandColor: string | null;
+ bannerUrl: string | null;
requestedSlug?: string | null;
};
owner: {
@@ -84,6 +86,8 @@ export class OrganizationRepository {
isPlatform: boolean;
logoUrl: string | null;
bio: string | null;
+ brandColor: string | null;
+ bannerUrl: string | null;
};
owner: {
email: string;
@@ -133,6 +137,8 @@ export class OrganizationRepository {
isPlatform: boolean;
logoUrl: string | null;
bio: string | null;
+ brandColor: string | null;
+ bannerUrl: string | null;
requestedSlug?: string | null;
}) {
return await prisma.team.create({
@@ -140,9 +146,10 @@ export class OrganizationRepository {
name: orgData.name,
isOrganization: true,
slug: orgData.slug,
- // This is huge and causes issues, we need to have the logic to convert logo to logoUrl and then use that url ehre.
- // logoUrl: orgData.logoUrl,
+ logoUrl: orgData.logoUrl,
bio: orgData.bio,
+ brandColor: orgData.brandColor,
+ bannerUrl: orgData.bannerUrl,
organizationSettings: {
create: {
isAdminReviewed: orgData.isOrganizationAdminReviewed,
diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts
index 6d848e27e8..f9e3b38edb 100644
--- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts
@@ -183,6 +183,8 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
logoUrl: null,
bio: null,
paymentSubscriptionId: null,
+ brandColor: null,
+ bannerUrl: null,
};
// Create a new user and invite them as the owner of the organization