Skip to content

Commit 79bcd6d

Browse files
authored
feat: welcome to organizations modal after creation (calcom#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
1 parent c6daa61 commit 79bcd6d

8 files changed

Lines changed: 262 additions & 11 deletions

File tree

apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useRouter } from "next/navigation";
22
import { useState } from "react";
33

4+
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
45
import { useFlagMap } from "@calcom/features/flags/context/provider";
56
import { CreationSource } from "@calcom/prisma/enums";
67
import { trpc } from "@calcom/trpc/react";
@@ -25,14 +26,8 @@ export const useSubmitOnboarding = () => {
2526
setError(null);
2627

2728
try {
28-
const {
29-
selectedPlan,
30-
organizationDetails,
31-
organizationBrand,
32-
teams,
33-
inviteRole,
34-
resetOnboarding,
35-
} = store;
29+
const { selectedPlan, organizationDetails, organizationBrand, teams, inviteRole, resetOnboarding } =
30+
store;
3631

3732
if (selectedPlan !== "organization") {
3833
throw new Error("Only organization plan is currently supported");
@@ -83,7 +78,8 @@ export const useSubmitOnboarding = () => {
8378
// No checkout URL means billing is disabled (self-hosted flow)
8479
// Organization has already been created by the backend
8580
showToast("Organization created successfully!", "success");
86-
// TODO: after this redirect we need to hard refresh the page to see org
81+
// Set flag to show welcome modal after personal onboarding redirect
82+
setShowNewOrgModalFlag();
8783
skipToPersonal(resetOnboarding);
8884
} catch (err) {
8985
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";

apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useState } from "react";
55
import { useForm } from "react-hook-form";
66
import { z } from "zod";
77

8+
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
89
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
910
import { useLocale } from "@calcom/lib/hooks/useLocale";
1011
import type { RouterOutputs } from "@calcom/trpc";
@@ -52,7 +53,9 @@ const useOrgCreation = () => {
5253
await utils.viewer.organizations.listCurrent.invalidate();
5354
await session.update();
5455
reset();
55-
window.location.href = `${window.location.origin}/settings/organizations/profile`;
56+
// Set flag to show welcome modal (using both query param and sessionStorage for reliability)
57+
setShowNewOrgModalFlag();
58+
window.location.href = `${window.location.origin}/settings/organizations/profile?newOrganizationModal=true`;
5659
} else {
5760
// Unexpected state
5861
setServerErrorMessage("Unexpected response from server");

apps/web/modules/settings/organizations/new/_components/PaymentStatusView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const PaymentStatusView = () => {
2828
if (organization) {
2929
setOrganizationCreated(true);
3030
// Organization is created, redirect to next step
31-
router.push(`/settings/organizations`);
31+
router.push(`/settings/organizations?newOrganizationModal=true`);
3232
}
3333
}, [organization, router, useOnboardingStore]);
3434

apps/web/public/static/locales/en/common.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2527,6 +2527,15 @@
25272527
"resolve": "Resolve",
25282528
"no_organization_slug": "There was an error creating teams for this organization. Missing URL slug.",
25292529
"copy_link_org": "Copy link to organization",
2530+
"welcome_to_organizations": "Welcome to Organizations!",
2531+
"organizations_welcome_description": "We're excited to connect your bookers with your team members. With your Teams's plan you get:",
2532+
"1_parent_team_unlimited_subteams": "1 parent team and unlimited sub-teams",
2533+
"organization_workflows": "Organization workflows",
2534+
"custom_subdomain": "Yourcompany.cal.com subdomain",
2535+
"instant_meetings": "Cal.com instant meetings",
2536+
"collective_round_robin_events": "Collective & Round-robin events",
2537+
"routing_forms": "Routing forms",
2538+
"team_workflows": "Team workflows",
25302539
"404_the_org": "The organization",
25312540
"404_the_team": "The team",
25322541
"404_claim_entity_org": "Claim your subdomain for your organization",
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"use client";
2+
3+
import { motion } from "framer-motion";
4+
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
import { Button } from "@calcom/ui/components/button";
7+
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
8+
import { Icon, type IconName } from "@calcom/ui/components/icon";
9+
import { Logo } from "@calcom/ui/components/logo";
10+
11+
import { useWelcomeModal } from "../hooks/useWelcomeModal";
12+
13+
const features = [
14+
"1_parent_team_unlimited_subteams",
15+
"organization_workflows",
16+
"custom_subdomain",
17+
"instant_meetings",
18+
"collective_round_robin_events",
19+
"routing_forms",
20+
"team_workflows",
21+
];
22+
23+
export function WelcomeToOrganizationsModal() {
24+
const { t } = useLocale();
25+
const { isOpen, closeModal } = useWelcomeModal();
26+
27+
const SMALL = { outer: 32, icon: 16 };
28+
const LARGE = { outer: 48, icon: 24 };
29+
const RINGS = [60, 95, 130]; // Ring radii in px
30+
const RING_STROKE = 1;
31+
32+
return (
33+
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
34+
<DialogContent size="default" className="!p-0">
35+
<div className="flex flex-col gap-4 p-6">
36+
<div className="flex flex-col items-center gap-1">
37+
<Logo className="h-10 w-auto" />
38+
</div>
39+
40+
{/* Team illustration with rings */}
41+
<div
42+
className="relative mx-auto"
43+
style={{
44+
width: 320,
45+
height: 220,
46+
maskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
47+
WebkitMaskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
48+
}}>
49+
{/* Center origin */}
50+
<div className="absolute left-1/2 top-1/2" style={{ transform: "translate(-50%, -50%)" }}>
51+
{/* Rings */}
52+
{RINGS.map((r, i) => (
53+
<div
54+
key={i}
55+
className="pointer-events-none absolute rounded-full border"
56+
style={{
57+
width: 2 * r,
58+
height: 2 * r,
59+
left: `calc(50% - ${r}px)`,
60+
top: `calc(50% - ${r}px)`,
61+
borderWidth: RING_STROKE,
62+
borderColor: "var(--cal-border-subtle)",
63+
}}
64+
/>
65+
))}
66+
67+
{/* Central users icon */}
68+
<div
69+
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
70+
style={{
71+
width: LARGE.outer,
72+
height: LARGE.outer,
73+
left: "50%",
74+
top: "50%",
75+
transform: "translate(-50%, -50%)",
76+
}}>
77+
<Icon
78+
name="users"
79+
className="text-emphasis opacity-70"
80+
style={{ width: LARGE.icon, height: LARGE.icon }}
81+
/>
82+
</div>
83+
84+
{/* Surrounding user icons */}
85+
{(
86+
[
87+
{ initialDeg: 30, duration: 20, icon: "user" },
88+
{ initialDeg: 190, duration: 25, icon: "user" },
89+
{ initialDeg: 320, duration: 15, icon: "user" },
90+
{ initialDeg: 280, duration: 20, icon: "building" },
91+
] as Array<{ initialDeg: number; duration: number; icon: IconName }>
92+
).map(({ initialDeg, duration, icon }, index) => {
93+
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)
94+
const steps = 60;
95+
const xKeyframes = [];
96+
const yKeyframes = [];
97+
for (let i = 0; i <= steps; i++) {
98+
const angle = initialDeg + (360 * i) / steps;
99+
xKeyframes.push(r * Math.cos((angle * Math.PI) / 180));
100+
yKeyframes.push(r * Math.sin((angle * Math.PI) / 180));
101+
}
102+
103+
return (
104+
<motion.div
105+
key={index}
106+
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
107+
style={{
108+
left: "50%",
109+
top: "50%",
110+
width: SMALL.outer,
111+
height: SMALL.outer,
112+
}}
113+
animate={{
114+
x: xKeyframes,
115+
y: yKeyframes,
116+
}}
117+
transition={{
118+
duration,
119+
repeat: Infinity,
120+
ease: "linear",
121+
}}
122+
transformTemplate={({ x, y }) =>
123+
// Lock the icon to the center of the ring and only translate on x and y
124+
`translate(-50%, -50%) translateX(${x}) translateY(${y})`
125+
}>
126+
<Icon
127+
name={icon}
128+
className="text-default"
129+
style={{ width: SMALL.icon, height: SMALL.icon }}
130+
/>
131+
</motion.div>
132+
);
133+
})}
134+
</div>
135+
</div>
136+
137+
<div className="mb-2 flex flex-col gap-2 text-center">
138+
<h2 className="font-cal text-emphasis text-2xl leading-none">{t("welcome_to_organizations")}</h2>
139+
<p className="text-default text-sm leading-normal">{t("organizations_welcome_description")}</p>
140+
</div>
141+
142+
<div className="mb-2 flex flex-col gap-3">
143+
{features.map((feature) => (
144+
<div key={feature} className="flex items-start gap-2">
145+
<Icon name="check" className="text-muted mt-0.5 h-4 w-4 flex-shrink-0" />
146+
<span className="text-default text-sm font-medium leading-tight">{t(feature)}</span>
147+
</div>
148+
))}
149+
</div>
150+
</div>
151+
152+
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
153+
<Button
154+
color="minimal"
155+
href="https://cal.com/docs/organizations"
156+
target="_blank"
157+
EndIcon="external-link"
158+
className="pointer-events-none opacity-0">
159+
{t("learn_more")}
160+
</Button>
161+
<Button color="primary" onClick={closeModal}>
162+
{t("continue")}
163+
</Button>
164+
</div>
165+
</DialogContent>
166+
</Dialog>
167+
);
168+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { parseAsBoolean, useQueryState } from "nuqs";
2+
import { useEffect, useState } from "react";
3+
4+
const STORAGE_KEY = "showNewOrgModal";
5+
6+
export function useWelcomeModal() {
7+
const [newOrganizationModal, setNewOrganizationModal] = useQueryState(
8+
"newOrganizationModal",
9+
parseAsBoolean.withDefault(false)
10+
);
11+
12+
const [isOpen, setIsOpen] = useState(false);
13+
14+
useEffect(() => {
15+
// Check query param first
16+
if (newOrganizationModal) {
17+
setIsOpen(true);
18+
return;
19+
}
20+
21+
// Check sessionStorage as fallback (for cases where we redirect through personal onboarding)
22+
if (typeof window !== "undefined" && sessionStorage.getItem(STORAGE_KEY) === "true") {
23+
setIsOpen(true);
24+
}
25+
}, [newOrganizationModal]);
26+
27+
const closeModal = () => {
28+
setIsOpen(false);
29+
// Remove the query param from URL
30+
setNewOrganizationModal(null);
31+
// Also clear sessionStorage
32+
if (typeof window !== "undefined") {
33+
sessionStorage.removeItem(STORAGE_KEY);
34+
}
35+
};
36+
37+
return {
38+
isOpen,
39+
closeModal,
40+
};
41+
}
42+
43+
/**
44+
* Helper function to set the flag that triggers the welcome modal.
45+
* Use this before redirecting to ensure the modal shows after navigation.
46+
*/
47+
export function setShowNewOrgModalFlag() {
48+
if (typeof window !== "undefined") {
49+
sessionStorage.setItem(STORAGE_KEY, "true");
50+
}
51+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"use client";
2+
3+
import { WelcomeToOrganizationsModal } from "@calcom/features/ee/organizations/components/WelcomeToOrganizationsModal";
4+
5+
/**
6+
* Container for all query-param driven modals that should appear globally across the app.
7+
* This keeps the Shell component clean and provides a centralized place for dynamic modals.
8+
*
9+
* We can probably also use this for thinks like the T&C and Privacy Policy modals. That @marketing are discussing
10+
*
11+
* To add a new modal:
12+
* 1. Create the modal component with its own useQueryState hook
13+
* 2. Import and add it here
14+
*/
15+
export function DynamicModals() {
16+
return (
17+
<>
18+
<WelcomeToOrganizationsModal />
19+
{/* Add more query-param driven modals here */}
20+
</>
21+
);
22+
}

packages/features/shell/Shell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
1818
import { SkeletonText } from "@calcom/ui/components/skeleton";
1919

2020
import { CalAiBanner } from "./CalAiBanner";
21+
import { DynamicModals } from "./DynamicModals";
2122
import { SideBarContainer } from "./SideBar";
2223
import { TopNavContainer } from "./TopNav";
2324
import { BannerContainer } from "./banners/LayoutBanner";
@@ -38,6 +39,7 @@ const Layout = (props: LayoutProps) => {
3839
</div>
3940

4041
<TimezoneChangeDialog />
42+
<DynamicModals />
4143

4244
<div className="flex min-h-screen flex-col">
4345
{banners && !props.isPlatformUser && <BannerContainer banners={banners} />}

0 commit comments

Comments
 (0)