From 19641ffc05acd968af05f914cc41ddad2d5a8983 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:45:42 +0000 Subject: [PATCH] feat: organization v3 redesign onboarding (#24967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What does this PR do? - Redesigns the organization onboarding flow by merging the brand and details pages - Improves the organization details page with a scrollable interface and visual previews - Adds a new organization-specific browser preview component ## Visual Demo (For contributors especially) #### Image Demo: - The PR replaces the separate brand page with an integrated details page that includes logo and banner uploads - The new organization browser view shows a preview of the organization profile with the selected branding ## Mandatory Tasks (DO NOT REMOVE) - [ ] I have self-reviewed the code. - [ ] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox. - [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? - Go through the organization onboarding flow - Test uploading logos and banners - Verify that the organization browser preview updates in real-time with the form inputs - Confirm that the form validation works correctly for organization name and slug - Check that the scrollable interface works properly with fade effects at top and bottom ## Checklist - I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code follows the style guidelines of this project - I have commented my code, particularly in hard-to-understand areas - I have checked if my changes generate no new warnings --- ## Summary by cubic Split organization brand from the details step and added live previews for organizations and teams. Revamped org/team invites with reusable components, a dedicated email-invite page, and a CSV upload modal. - **New Features** - Separate Brand step with logo, banner, and color; instant org preview via OnboardingOrganizationBrowserView. - Teams browser preview added; invites include email substep (/onboarding/organization/invite/email, /onboarding/teams/invite/email), CSV upload (template + parsing), and Google Workspace (behind flag). - Shared components (EmailInviteForm, InviteOptions, RoleSelector) used across org and team invites. - **Refactors** - Updated org flow: Details → Brand → Teams → Invites; OnboardingLayout now supports dynamic step counts (org=4, team=3, personal=2). - UI polish (OnboardingCard header padding) and org-specific previews now replace generic views across details/brand/invites/teams; ensured org welcome modal takes precedence over personal. Written for commit d9b55c0b5505aa0d4ca1c4298a513bcd90606915. Summary will update automatically on new commits. --- .../onboarding/organization/brand/page.tsx | 2 +- .../organization/invite/email/page.tsx | 35 ++ .../onboarding/teams/invite/email/page.tsx | 35 ++ .../onboarding/components/EmailInviteForm.tsx | 123 +++++++ .../onboarding/components/InviteOptions.tsx | 104 ++++++ .../onboarding/components/OnboardingCard.tsx | 3 +- .../components/OnboardingLayout.tsx | 43 ++- .../onboarding/components/RoleSelector.tsx | 38 ++ .../components/onboarding-browser-view.tsx | 8 +- .../onboarding-organization-browser-view.tsx | 147 ++++++++ .../onboarding-teams-browser-view.tsx | 136 +++++++ .../getting-started/onboarding-view.tsx | 2 +- .../hooks/useSubmitPersonalOnboarding.ts | 19 +- .../brand/organization-brand-view.tsx | 251 ++++++------- .../details/organization-details-view.tsx | 102 +++--- .../organization/invite/csv-upload-modal.tsx | 239 ++++++++++++ .../email/organization-invite-email-view.tsx | 189 ++++++++++ .../invite/organization-invite-view.tsx | 340 ++++-------------- .../teams/organization-teams-view.tsx | 46 ++- .../calendar/personal-calendar-view.tsx | 2 +- .../settings/personal-settings-view.tsx | 4 +- .../teams/details/team-details-view.tsx | 4 +- .../invite/email/team-invite-email-view.tsx | 91 ++--- .../teams/invite/team-invite-view.tsx | 68 +--- apps/web/public/static/locales/en/common.json | 2 +- .../shell/hooks/useWelcomeToCalcomModal.ts | 8 + 26 files changed, 1401 insertions(+), 640 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/organization/invite/email/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/teams/invite/email/page.tsx create mode 100644 apps/web/modules/onboarding/components/EmailInviteForm.tsx create mode 100644 apps/web/modules/onboarding/components/InviteOptions.tsx create mode 100644 apps/web/modules/onboarding/components/RoleSelector.tsx create mode 100644 apps/web/modules/onboarding/components/onboarding-organization-browser-view.tsx create mode 100644 apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx create mode 100644 apps/web/modules/onboarding/organization/invite/csv-upload-modal.tsx create mode 100644 apps/web/modules/onboarding/organization/invite/email/organization-invite-email-view.tsx 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 index c7f9d7d2ab..8b55623816 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/organization/brand/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/brand/page.tsx @@ -1,4 +1,3 @@ -import { createRouterCaller } from "app/_trpc/context"; import { _generateMetadata } from "app/_utils"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -33,3 +32,4 @@ const ServerPage = async () => { }; export default ServerPage; + diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/invite/email/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/invite/email/page.tsx new file mode 100644 index 0000000000..c9fafe6006 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/invite/email/page.tsx @@ -0,0 +1,35 @@ +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 { OrganizationInviteEmailView } from "~/onboarding/organization/invite/email/organization-invite-email-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => `${APP_NAME} - ${t("invite_teammates")}`, + () => "", + true, + undefined, + "/onboarding/organization/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)/onboarding/teams/invite/email/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/email/page.tsx new file mode 100644 index 0000000000..10bb4d25eb --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/email/page.tsx @@ -0,0 +1,35 @@ +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 "~/onboarding/teams/invite/email/team-invite-email-view"; + +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => `${APP_NAME} - ${t("invite_teammates")}`, + () => "", + true, + undefined, + "/onboarding/teams/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/modules/onboarding/components/EmailInviteForm.tsx b/apps/web/modules/onboarding/components/EmailInviteForm.tsx new file mode 100644 index 0000000000..a1f3b47631 --- /dev/null +++ b/apps/web/modules/onboarding/components/EmailInviteForm.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useFormContext } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import { Label, TextField, Select } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; + +import type { InviteRole } from "../store/onboarding-store"; + +type BaseInviteFormData = { + email: string; + role: InviteRole; +}; + +type InviteFormDataWithOptionalTeam = BaseInviteFormData & { + team?: string; +}; + +type InviteFormDataWithRequiredTeam = BaseInviteFormData & { + team: string; +}; + +type InviteFormData = InviteFormDataWithOptionalTeam | InviteFormDataWithRequiredTeam; + +type EmailInviteFormProps = { + fields: Array<{ id: string }>; + append: (value: InviteFormData) => void; + remove: (index: number) => void; + defaultRole: InviteRole; + showTeamSelect?: boolean; + teams?: Array<{ value: string; label: string }>; + emailPlaceholder?: string; +}; + +export function EmailInviteForm({ + fields, + append, + remove, + defaultRole, + showTeamSelect = false, + teams = [], + emailPlaceholder, +}: EmailInviteFormProps) { + const { t } = useLocale(); + const { register, watch, setValue } = useFormContext(); + + return ( +
+
+ {showTeamSelect ? ( +
+ + +
+ ) : ( + + )} + +
+ {fields.map((field, index) => ( +
+
+ + {showTeamSelect && ( + { - 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 { organizationDetails, organizationBrand, setOrganizationBrand } = 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); + const [brandColor, setBrandColor] = useState("#000000"); // Load from store on mount useEffect(() => { - setBrandColor(organizationBrand.color); setLogoPreview(organizationBrand.logo); setBannerPreview(organizationBrand.banner); + setBrandColor(organizationBrand.color); }, [organizationBrand]); const handleLogoChange = (file: File | null) => { @@ -106,129 +66,116 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) } }; - const handleContinue = () => { - // Save brand color to store - setOrganizationBrand({ color: brandColor }); - router.push("/onboarding/organization/teams"); + const handleColorChange = (color: string) => { + setBrandColor(color); + setOrganizationBrand({ color }); }; - const handleSkip = () => { - // Skip brand setup and go to teams + const handleContinue = () => { + // Save to store (already saved on change, but ensure it's persisted) + setOrganizationBrand({ + logo: logoPreview, + banner: bannerPreview, + color: brandColor, + }); router.push("/onboarding/organization/teams"); }; return ( - + {/* Left column - Main content */} - - +
}> {/* Form */} -
-
-
-
- {/* Left side - Form */} -
- {/* Brand Color */} -
-

{t("brand_color")}

-
-

- {t("onboarding_primary_color_label")} -

- { - setBrandColor(value); - setOrganizationBrand({ color: value }); - }} - t={t} +
+ {/* Scrollable content container */} +
+
+ {/* Banner Upload */} +
+

{t("onboarding_banner_label")}

+
+
+ {bannerPreview && ( + {t("onboarding_banner_preview_alt")} -
+ )}
- - {/* 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")} -

+
+ + handleBannerChange(e.target.files?.[0] || null)} + />
+

+ {t("onboarding_banner_size_hint")} +

+
+ {/* Logo Upload */} +
+

{t("logo")}

+
+
+ {logoPreview && ( + {t("onboarding_logo_preview_alt")} + )} +
+
+ + handleLogoChange(e.target.files?.[0] || null)} + /> +
+
+

{t("onboarding_logo_size_hint")}

+
+ {/* Brand Color */} +
+ +
@@ -236,7 +183,13 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) {/* Right column - Browser view */} - + ); }; diff --git a/apps/web/modules/onboarding/organization/details/organization-details-view.tsx b/apps/web/modules/onboarding/organization/details/organization-details-view.tsx index 10542bfc8d..7ab0868fe9 100644 --- a/apps/web/modules/onboarding/organization/details/organization-details-view.tsx +++ b/apps/web/modules/onboarding/organization/details/organization-details-view.tsx @@ -1,15 +1,15 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, 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 { OnboardingBrowserView } from "../../components/onboarding-browser-view"; import { OnboardingCard } from "../../components/OnboardingCard"; import { OnboardingLayout } from "../../components/OnboardingLayout"; +import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view"; import { useOnboardingStore } from "../../store/onboarding-store"; import { ValidatedOrganizationSlug } from "./validated-organization-slug"; @@ -77,60 +77,60 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr }; return ( - + {/* Left column - Main content */} - {t("continue")} - +
+ +
}> {/* 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" - /> -
+
+ {/* Scrollable content container */} +
+ {/* Top fade overlay */} - {/* Organization Link */} - +
+ {/* Organization Name */} +
+ + setOrganizationName(e.target.value)} + placeholder={t("organization_name")} + /> +
- {/* Organization Bio */} -
- -