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:  ## 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
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
|
||||
<DialogContent size="default" className="!p-0">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Logo className="h-10 w-auto" />
|
||||
</div>
|
||||
|
||||
{/* Team illustration with rings */}
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{
|
||||
width: 320,
|
||||
height: 220,
|
||||
maskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
|
||||
WebkitMaskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
|
||||
}}>
|
||||
{/* Center origin */}
|
||||
<div className="absolute left-1/2 top-1/2" style={{ transform: "translate(-50%, -50%)" }}>
|
||||
{/* Rings */}
|
||||
{RINGS.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="pointer-events-none absolute rounded-full border"
|
||||
style={{
|
||||
width: 2 * r,
|
||||
height: 2 * r,
|
||||
left: `calc(50% - ${r}px)`,
|
||||
top: `calc(50% - ${r}px)`,
|
||||
borderWidth: RING_STROKE,
|
||||
borderColor: "var(--cal-border-subtle)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Central users icon */}
|
||||
<div
|
||||
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
|
||||
style={{
|
||||
width: LARGE.outer,
|
||||
height: LARGE.outer,
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}>
|
||||
<Icon
|
||||
name="users"
|
||||
className="text-emphasis opacity-70"
|
||||
style={{ width: LARGE.icon, height: LARGE.icon }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: SMALL.outer,
|
||||
height: SMALL.outer,
|
||||
}}
|
||||
animate={{
|
||||
x: xKeyframes,
|
||||
y: yKeyframes,
|
||||
}}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
transformTemplate={({ x, y }) =>
|
||||
// Lock the icon to the center of the ring and only translate on x and y
|
||||
`translate(-50%, -50%) translateX(${x}) translateY(${y})`
|
||||
}>
|
||||
<Icon
|
||||
name={icon}
|
||||
className="text-default"
|
||||
style={{ width: SMALL.icon, height: SMALL.icon }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-2 text-center">
|
||||
<h2 className="font-cal text-emphasis text-2xl leading-none">{t("welcome_to_organizations")}</h2>
|
||||
<p className="text-default text-sm leading-normal">{t("organizations_welcome_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-3">
|
||||
{features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-2">
|
||||
<Icon name="check" className="text-muted mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-default text-sm font-medium leading-tight">{t(feature)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
|
||||
<Button
|
||||
color="minimal"
|
||||
href="https://cal.com/docs/organizations"
|
||||
target="_blank"
|
||||
EndIcon="external-link"
|
||||
className="pointer-events-none opacity-0">
|
||||
{t("learn_more")}
|
||||
</Button>
|
||||
<Button color="primary" onClick={closeModal}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<WelcomeToOrganizationsModal />
|
||||
{/* Add more query-param driven modals here */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
</div>
|
||||
|
||||
<TimezoneChangeDialog />
|
||||
<DynamicModals />
|
||||
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{banners && !props.isPlatformUser && <BannerContainer banners={banners} />}
|
||||
|
||||
Reference in New Issue
Block a user