From 79bcd6dc30d445a2185846d3b03d8ec2651b8102 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:11:16 +0000 Subject: [PATCH] feat: welcome to organizations modal after creation (#24823) ## What does this PR do? Adds a welcome modal for new organizations that appears after organization creation. The modal showcases key features of the Organizations plan and provides a better onboarding experience. ## Visual Demo (For contributors especially) #### Image Demo: ![CleanShot 2025-10-31 at 12.19.17.gif](https://app.graphite.dev/user-attachments/assets/4f8c3286-9400-40e6-aeb4-8a012f604c64.gif) ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code. - [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Create a new organization through either: - The onboarding flow - The settings/organizations/new page - The organization creation form 2. After successful creation and redirect, verify the welcome modal appears showing organization features. 3. Verify the modal can be closed by: - Clicking the "Continue" button - Clicking outside the modal - Pressing ESC key 4. Verify the modal doesn't reappear after being closed (query param and session storage should be cleared). ## 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 --- .../onboarding/hooks/useSubmitOnboarding.ts | 14 +- .../new/_components/OnboardMembersView.tsx | 5 +- .../new/_components/PaymentStatusView.tsx | 2 +- apps/web/public/static/locales/en/common.json | 9 + .../WelcomeToOrganizationsModal.tsx | 168 ++++++++++++++++++ .../ee/organizations/hooks/useWelcomeModal.ts | 51 ++++++ packages/features/shell/DynamicModals.tsx | 22 +++ packages/features/shell/Shell.tsx | 2 + 8 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 packages/features/ee/organizations/components/WelcomeToOrganizationsModal.tsx create mode 100644 packages/features/ee/organizations/hooks/useWelcomeModal.ts create mode 100644 packages/features/shell/DynamicModals.tsx diff --git a/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts b/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts index 0e5db227b7..d26ae08f29 100644 --- a/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts +++ b/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts @@ -1,6 +1,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; +import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal"; import { useFlagMap } from "@calcom/features/flags/context/provider"; import { CreationSource } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; @@ -25,14 +26,8 @@ export const useSubmitOnboarding = () => { setError(null); try { - const { - selectedPlan, - organizationDetails, - organizationBrand, - teams, - inviteRole, - resetOnboarding, - } = store; + const { selectedPlan, organizationDetails, organizationBrand, teams, inviteRole, resetOnboarding } = + store; if (selectedPlan !== "organization") { throw new Error("Only organization plan is currently supported"); @@ -83,7 +78,8 @@ export const useSubmitOnboarding = () => { // 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 + // Set flag to show welcome modal after personal onboarding redirect + setShowNewOrgModalFlag(); skipToPersonal(resetOnboarding); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to create organization"; diff --git a/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx b/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx index 5ec8541438..42c421d2c8 100644 --- a/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx +++ b/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal"; import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc"; @@ -52,7 +53,9 @@ const useOrgCreation = () => { await utils.viewer.organizations.listCurrent.invalidate(); await session.update(); reset(); - window.location.href = `${window.location.origin}/settings/organizations/profile`; + // Set flag to show welcome modal (using both query param and sessionStorage for reliability) + setShowNewOrgModalFlag(); + window.location.href = `${window.location.origin}/settings/organizations/profile?newOrganizationModal=true`; } else { // Unexpected state setServerErrorMessage("Unexpected response from server"); diff --git a/apps/web/modules/settings/organizations/new/_components/PaymentStatusView.tsx b/apps/web/modules/settings/organizations/new/_components/PaymentStatusView.tsx index 2cedf3f3ab..52092ae2dd 100644 --- a/apps/web/modules/settings/organizations/new/_components/PaymentStatusView.tsx +++ b/apps/web/modules/settings/organizations/new/_components/PaymentStatusView.tsx @@ -28,7 +28,7 @@ const PaymentStatusView = () => { if (organization) { setOrganizationCreated(true); // Organization is created, redirect to next step - router.push(`/settings/organizations`); + router.push(`/settings/organizations?newOrganizationModal=true`); } }, [organization, router, useOnboardingStore]); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index fc9b3fd001..6d78041b08 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2527,6 +2527,15 @@ "resolve": "Resolve", "no_organization_slug": "There was an error creating teams for this organization. Missing URL slug.", "copy_link_org": "Copy link to organization", + "welcome_to_organizations": "Welcome to Organizations!", + "organizations_welcome_description": "We're excited to connect your bookers with your team members. With your Teams's plan you get:", + "1_parent_team_unlimited_subteams": "1 parent team and unlimited sub-teams", + "organization_workflows": "Organization workflows", + "custom_subdomain": "Yourcompany.cal.com subdomain", + "instant_meetings": "Cal.com instant meetings", + "collective_round_robin_events": "Collective & Round-robin events", + "routing_forms": "Routing forms", + "team_workflows": "Team workflows", "404_the_org": "The organization", "404_the_team": "The team", "404_claim_entity_org": "Claim your subdomain for your organization", diff --git a/packages/features/ee/organizations/components/WelcomeToOrganizationsModal.tsx b/packages/features/ee/organizations/components/WelcomeToOrganizationsModal.tsx new file mode 100644 index 0000000000..d4c6e027b1 --- /dev/null +++ b/packages/features/ee/organizations/components/WelcomeToOrganizationsModal.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { motion } from "framer-motion"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent } from "@calcom/ui/components/dialog"; +import { Icon, type IconName } from "@calcom/ui/components/icon"; +import { Logo } from "@calcom/ui/components/logo"; + +import { useWelcomeModal } from "../hooks/useWelcomeModal"; + +const features = [ + "1_parent_team_unlimited_subteams", + "organization_workflows", + "custom_subdomain", + "instant_meetings", + "collective_round_robin_events", + "routing_forms", + "team_workflows", +]; + +export function WelcomeToOrganizationsModal() { + const { t } = useLocale(); + const { isOpen, closeModal } = useWelcomeModal(); + + const SMALL = { outer: 32, icon: 16 }; + const LARGE = { outer: 48, icon: 24 }; + const RINGS = [60, 95, 130]; // Ring radii in px + const RING_STROKE = 1; + + return ( + !open && closeModal()}> + +
+
+ +
+ + {/* Team illustration with rings */} +
+ {/* Center origin */} +
+ {/* Rings */} + {RINGS.map((r, i) => ( +
+ ))} + + {/* Central users icon */} +
+ +
+ + {/* Surrounding user icons */} + {( + [ + { initialDeg: 30, duration: 20, icon: "user" }, + { initialDeg: 190, duration: 25, icon: "user" }, + { initialDeg: 320, duration: 15, icon: "user" }, + { initialDeg: 280, duration: 20, icon: "building" }, + ] as Array<{ initialDeg: number; duration: number; icon: IconName }> + ).map(({ initialDeg, duration, icon }, index) => { + const r = RINGS[index % RINGS.length]; // icon orbit radius - each icon on a different ring (we have more icons than ring so we cycle through them) + const steps = 60; + const xKeyframes = []; + const yKeyframes = []; + for (let i = 0; i <= steps; i++) { + const angle = initialDeg + (360 * i) / steps; + xKeyframes.push(r * Math.cos((angle * Math.PI) / 180)); + yKeyframes.push(r * Math.sin((angle * Math.PI) / 180)); + } + + return ( + + // Lock the icon to the center of the ring and only translate on x and y + `translate(-50%, -50%) translateX(${x}) translateY(${y})` + }> + + + ); + })} +
+
+ +
+

{t("welcome_to_organizations")}

+

{t("organizations_welcome_description")}

+
+ +
+ {features.map((feature) => ( +
+ + {t(feature)} +
+ ))} +
+
+ +
+ + +
+ +
+ ); +} diff --git a/packages/features/ee/organizations/hooks/useWelcomeModal.ts b/packages/features/ee/organizations/hooks/useWelcomeModal.ts new file mode 100644 index 0000000000..4ab1ef7f4c --- /dev/null +++ b/packages/features/ee/organizations/hooks/useWelcomeModal.ts @@ -0,0 +1,51 @@ +import { parseAsBoolean, useQueryState } from "nuqs"; +import { useEffect, useState } from "react"; + +const STORAGE_KEY = "showNewOrgModal"; + +export function useWelcomeModal() { + const [newOrganizationModal, setNewOrganizationModal] = useQueryState( + "newOrganizationModal", + parseAsBoolean.withDefault(false) + ); + + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + // Check query param first + if (newOrganizationModal) { + setIsOpen(true); + return; + } + + // Check sessionStorage as fallback (for cases where we redirect through personal onboarding) + if (typeof window !== "undefined" && sessionStorage.getItem(STORAGE_KEY) === "true") { + setIsOpen(true); + } + }, [newOrganizationModal]); + + const closeModal = () => { + setIsOpen(false); + // Remove the query param from URL + setNewOrganizationModal(null); + // Also clear sessionStorage + if (typeof window !== "undefined") { + sessionStorage.removeItem(STORAGE_KEY); + } + }; + + return { + isOpen, + closeModal, + }; +} + +/** + * Helper function to set the flag that triggers the welcome modal. + * Use this before redirecting to ensure the modal shows after navigation. + */ +export function setShowNewOrgModalFlag() { + if (typeof window !== "undefined") { + sessionStorage.setItem(STORAGE_KEY, "true"); + } +} diff --git a/packages/features/shell/DynamicModals.tsx b/packages/features/shell/DynamicModals.tsx new file mode 100644 index 0000000000..a80f9c7383 --- /dev/null +++ b/packages/features/shell/DynamicModals.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { WelcomeToOrganizationsModal } from "@calcom/features/ee/organizations/components/WelcomeToOrganizationsModal"; + +/** + * Container for all query-param driven modals that should appear globally across the app. + * This keeps the Shell component clean and provides a centralized place for dynamic modals. + * + * We can probably also use this for thinks like the T&C and Privacy Policy modals. That @marketing are discussing + * + * To add a new modal: + * 1. Create the modal component with its own useQueryState hook + * 2. Import and add it here + */ +export function DynamicModals() { + return ( + <> + + {/* Add more query-param driven modals here */} + + ); +} diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 19dbe87a75..9e6eeb3d3c 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -18,6 +18,7 @@ import { ErrorBoundary } from "@calcom/ui/components/errorBoundary"; import { SkeletonText } from "@calcom/ui/components/skeleton"; import { CalAiBanner } from "./CalAiBanner"; +import { DynamicModals } from "./DynamicModals"; import { SideBarContainer } from "./SideBar"; import { TopNavContainer } from "./TopNav"; import { BannerContainer } from "./banners/LayoutBanner"; @@ -38,6 +39,7 @@ const Layout = (props: LayoutProps) => { +
{banners && !props.isPlatformUser && }