From 8e0cdf3671b3d978b5fdc6be9ff78af2abb10dd7 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 23 Oct 2025 20:01:54 +0100 Subject: [PATCH] feat: Onboarding V3 (#24299) * feat: redirect to new onboarding flow * Getting started * Brand details * Preview organization brands * Orgs team pages * Invite team steps * Move to global zustand store * Few darkmdoe fixes * Wip onboarding + stripe flow * Default plan state Server Action for gettting slug satus of org * Remove onboardingId * Confirmation prompt * Update old onboarding flow handlers to handle new fields * update onboarding hook * Filter out organization section for none -company emails * Match placeholders to users domain * Drop migration * Wip new onboarding intent * WIP flow for self-hosted. Same service call just split logic * WIP * Add TODO * Use onboarding user type instead of trpc session * WIP * WIP * pass role and team name from onboarding to save in schema * Add test to ensure role + name + team are persisted into onboarding table * migrate roles to enum values * Update ENUM * Fix type error * Redirect if flag is disabled * Revert packages * Revert all packages/* changes to original branch point * Layout fixes + design * Add slugify * Support saving brand,logo and banner * Cleanup * iMobile fixes * More mobile and darkmdoe fixes * Add I18n * Fix lock file * Fix types * Fix types errors * Copy changes --- .../onboarding/getting-started/page.tsx | 36 ++ .../(use-page-wrapper)/onboarding/layout.tsx | 15 + .../onboarding/organization/brand/page.tsx | 35 ++ .../onboarding/organization/details/page.tsx | 34 ++ .../onboarding/organization/invite/page.tsx | 34 ++ .../onboarding/organization/teams/page.tsx | 35 ++ apps/web/lib/signup/getServerSideProps.tsx | 2 + .../onboarding-continuation-prompt.tsx | 64 ++++ .../getting-started/onboarding-view.tsx | 138 ++++++++ .../onboarding/hooks/useSubmitOnboarding.ts | 99 ++++++ .../brand/organization-brand-view.tsx | 328 ++++++++++++++++++ .../details/action/check-slug-availability.ts | 46 +++ .../details/organization-details-view.tsx | 176 ++++++++++ .../details/validated-organization-slug.tsx | 97 ++++++ .../invite/organization-invite-view.tsx | 292 ++++++++++++++++ .../teams/organization-teams-view.tsx | 169 +++++++++ .../onboarding/store/onboarding-store.ts | 108 ++++++ apps/web/modules/signup-view.tsx | 4 +- apps/web/public/static/locales/en/common.json | 30 ++ .../lib/server/orgCreationUtils.ts | 9 +- ...BillingEnabledOrgOnboardingService.test.ts | 54 +++ .../features/ee/organizations/lib/utils.ts | 4 +- .../OrganizationRepository.test.ts | 61 ++++ .../repositories/OrganizationRepository.ts | 11 +- .../viewer/organizations/create.handler.ts | 2 + 25 files changed, 1871 insertions(+), 12 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/layout.tsx create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/organization/brand/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/organization/details/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/organization/invite/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/organization/teams/page.tsx create mode 100644 apps/web/modules/onboarding/components/onboarding-continuation-prompt.tsx create mode 100644 apps/web/modules/onboarding/getting-started/onboarding-view.tsx create mode 100644 apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts create mode 100644 apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx create mode 100644 apps/web/modules/onboarding/organization/details/action/check-slug-availability.ts create mode 100644 apps/web/modules/onboarding/organization/details/organization-details-view.tsx create mode 100644 apps/web/modules/onboarding/organization/details/validated-organization-slug.tsx create mode 100644 apps/web/modules/onboarding/organization/invite/organization-invite-view.tsx create mode 100644 apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx create mode 100644 apps/web/modules/onboarding/store/onboarding-store.ts 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 */} +
+
+
+
+
+
+ +
+

{userEmail}

+
+
+ + {/* 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 ( +
+ + +
+ ); +}; + +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 */} +
+
+
+
+
+
+ +
+

{userEmail}

+
+
+ + {/* 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 && ( + {t("onboarding_logo_preview_alt")} + )} +
+
+ + handleLogoChange(e.target.files?.[0] || null)} + /> +
+
+

+ {t("onboarding_logo_size_hint")} +

+
+ + {/* Banner Upload */} +
+

{t("onboarding_banner_label")}

+
+
+ {bannerPreview && ( + {t("onboarding_banner_preview_alt")} + )} +
+
+ + handleBannerChange(e.target.files?.[0] || null)} + /> +
+
+

+ {t("onboarding_banner_size_hint")} +

+
+
+ + {/* Right side - Preview */} +
+
+

{t("preview")}

+
+ {/* Banner preview */} +
+ {bannerPreview && ( + {t("onboarding_banner_preview_alt")} + )} +
+ + {/* Content */} +
+
+
+ {/* Logo preview */} +
+ {logoPreview && ( + {t("onboarding_logo_preview_alt")} + )} +
+

+ {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 */} +
+
+
+
+
+
+ +
+

{userEmail}

+
+
+ + {/* 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 */} +
+ +