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
This commit is contained in:
sean-brydon
2025-10-23 20:01:54 +01:00
committed by GitHub
parent fcd9480f9f
commit 8e0cdf3671
25 changed files with 1871 additions and 12 deletions
@@ -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 <OnboardingView userName={userName} userEmail={userEmail} />;
};
export default ServerPage;
@@ -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}</>;
}
@@ -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 <OrganizationBrandView userEmail={userEmail} />;
};
export default ServerPage;
@@ -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 <OrganizationDetailsView userEmail={userEmail} />;
};
export default ServerPage;
@@ -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 <OrganizationInviteView userEmail={userEmail} />;
};
export default ServerPage;
@@ -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 <OrganizationTeamsView userEmail={userEmail} />;
};
export default ServerPage;
@@ -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) {
@@ -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 (
<div className="animate-fade-in fixed bottom-4 right-4 z-50">
<div className="bg-default border-muted shadow-default relative w-[360px] rounded-lg border p-4">
<button
onClick={() => setIsVisible(false)}
className="text-muted hover:text-emphasis absolute right-2 top-2 rounded-md p-1 transition-colors">
<Icon name="x" className="h-4 w-4" />
</button>
<div className="mb-3 pr-6">
<h3 className="text-emphasis mb-1 text-base font-semibold">Continue onboarding?</h3>
<p className="text-subtle text-sm">
Would you like to carry on with onboarding for{" "}
<span className="text-emphasis font-medium">{organizationDetails.name}</span> or start over?
</p>
</div>
<div className="ml-auto flex gap-2">
<Button onClick={handleStartOver} color="secondary">
Start over
</Button>
<Button onClick={handleContinue} color="primary">
Continue
</Button>
</div>
</div>
</div>
);
};
@@ -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 (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip">
<OnboardingContinuationPrompt />
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis font-mediumu text-sm leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-6">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">Hey {userName}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
To personalize your experience, what do you plan to use Cal.com for?
</p>
</div>
</div>
{/* Plan options */}
<RadioAreaGroup.Group
value={selectedPlan ?? undefined}
onValueChange={(value) => setSelectedPlan(value as PlanType)}
className="flex w-full flex-col gap-1 rounded-[10px]">
{plans.map((plan) => (
<RadioAreaGroup.Item
key={plan.id}
value={plan.id}
className={
selectedPlan === plan.id ? "border-emphasis bg-default" : "bg-default border-subtle"
}>
<div className="flex w-full flex-col gap-2">
<div className="flex items-start gap-2">
<p className="text-emphasis text-base font-semibold leading-4">{plan.title}</p>
<Badge variant="gray" size="md" className="hidden h-4 rounded-md px-1 py-1 md:block">
<span className="text-emphasis text-xs font-medium leading-3">{plan.badge}</span>
</Badge>
</div>
<Badge variant="gray" size="md" className="h-4 w-fit rounded-md px-1 py-1 md:hidden">
<span className="text-emphasis text-xs font-medium leading-3">{plan.badge}</span>
</Badge>
<p className="text-subtle max-w-full text-sm font-normal leading-tight">
{plan.description}
</p>
</div>
</RadioAreaGroup.Item>
))}
</RadioAreaGroup.Group>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
Continue
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -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<string | null>(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,
};
};
@@ -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 (
<div className="border-default bg-default flex h-7 w-32 items-center gap-2 rounded-lg border px-2 py-1.5">
<Popover.Root>
<Popover.Trigger asChild>
<button
className="h-4 w-4 shrink-0 rounded-full border border-gray-200"
style={{ backgroundColor: value }}
aria-label={t("onboarding_pick_color_aria")}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content align="start" sideOffset={5}>
<HexColorPicker color={value} onChange={onChange} className="!h-32 !w-32" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<input
type="text"
value={value.replace("#", "")}
onChange={(e) => {
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}
/>
</div>
);
};
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<File | null>(null);
const [_bannerFile, setBannerFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [bannerPreview, setBannerPreview] = useState<string | null>(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 (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="relative flex w-full max-w-[600px] flex-col gap-6">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("onboarding_org_brand_title")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("onboarding_org_brand_subtitle")}
</p>
</div>
</div>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-4">
{/* Left side - Form */}
<div className="flex w-full flex-col gap-6">
{/* Brand Color */}
<div className="flex w-full flex-col gap-6">
<p className="text-emphasis text-sm font-medium leading-4">{t("brand_color")}</p>
<div className="flex w-full items-center gap-2">
<p className="text-subtle w-[98px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-4">
{t("onboarding_primary_color_label")}
</p>
<BrandColorPicker
value={brandColor}
onChange={(value) => {
setBrandColor(value);
setOrganizationBrand({ color: value });
}}
t={t}
/>
</div>
</div>
{/* Logo Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
<div className="flex items-center gap-2">
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
{logoPreview && (
<img
src={logoPreview}
alt={t("onboarding_logo_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
onClick={() => document.getElementById("logo-upload")?.click()}>
{t("upload")}
</Button>
<input
id="logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_logo_size_hint")}
</p>
</div>
{/* Banner Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">{t("onboarding_banner_label")}</p>
<div className="flex w-full flex-col gap-2">
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
{bannerPreview && (
<img
src={bannerPreview}
alt={t("onboarding_banner_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
className="w-fit"
onClick={() => document.getElementById("banner-upload")?.click()}>
{t("upload")}
</Button>
<input
id="banner-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_banner_size_hint")}
</p>
</div>
</div>
{/* Right side - Preview */}
<div className="bg-muted border-muted flex hidden h-[328px] w-full grow overflow-hidden rounded-[10px] border p-5 md:block">
<div className="flex flex-col gap-2.5">
<p className="text-subtle text-sm font-medium leading-4">{t("preview")}</p>
<div className="border-subtle bg-default relative flex w-[110%] flex-col gap-2.5 rounded-md border px-5 pb-5 pt-[74px]">
{/* Banner preview */}
<div className="bg-muted border-muted absolute left-1 top-1 h-[92px] w-[272px] overflow-hidden rounded-[4px] border">
{bannerPreview && (
<img
src={bannerPreview}
alt={t("onboarding_banner_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
{/* Content */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-1">
<div className="flex flex-col gap-3">
{/* Logo preview */}
<div className="bg-muted z-20 h-9 w-9 shrink-0 overflow-hidden rounded-md border-2 border-[var(--cal-bg)]">
{logoPreview && (
<img
src={logoPreview}
alt={t("onboarding_logo_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
<p className="text-subtle text-sm font-medium capitalize leading-4 ">
{organizationDetails.name || t("onboarding_preview_nameless")}
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<p className="font-cal text-xl leading-5 tracking-[0.2px]">
{t("onboarding_preview_example_title")}
</p>
<p className="text-subtle text-sm font-medium leading-5">
{t("onboarding_preview_example_description")}
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-1">
{[134, 104, 84, 104].map((width, i) => (
<div key={i} className="flex items-center gap-2 p-1">
<div className="bg-subtle h-5 w-5 shrink-0 rounded-full" />
<div
className="bg-subtle h-2.5 rounded-full"
style={{ width: `${width}px` }}
/>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("ill_do_this_later")}
</Button>
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -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 };
}
@@ -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 (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-6">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">
{t("onboarding_org_details_title")}
</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("onboarding_org_details_subtitle")}
</p>
</div>
</div>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* Organization Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("organization_name")}
</Label>
<TextField
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder={t("organization_name")}
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
{/* Organization Link */}
<ValidatedOrganizationSlug
value={organizationLink}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
{/* Organization Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("onboarding_org_bio_label")}
</Label>
<TextArea
value={organizationBio}
onChange={(e) => setOrganizationBio(e.target.value)}
placeholder={t("onboarding_org_bio_placeholder")}
rows={4}
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
/>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !organizationName || !organizationLink}>
{t("continue")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,97 @@
"use client";
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import classNames from "@calcom/ui/classNames";
import { Label, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { checkSlugAvailability } from "./action/check-slug-availability";
type ValidationState = "idle" | "checking" | "available" | "taken";
type ValidatedOrganizationSlugProps = {
value: string;
onChange: (value: string) => void;
onValidationChange?: (isValid: boolean) => void;
};
export function ValidatedOrganizationSlug({
value,
onChange,
onValidationChange,
}: ValidatedOrganizationSlugProps) {
const [validationState, setValidationState] = useState<ValidationState>("idle");
const [errorMessage, setErrorMessage] = useState<string>("");
const [_isPending, startTransition] = useTransition();
const timeoutRef = useRef<NodeJS.Timeout>();
const validateSlug = useCallback(
(slug: string) => {
if (!slug || slug.trim() === "") {
setValidationState("idle");
setErrorMessage("");
onValidationChange?.(false);
return;
}
setValidationState("checking");
// Call server action
checkSlugAvailability(slug).then((result) => {
startTransition(() => {
if (result.available) {
setValidationState("available");
setErrorMessage("");
onValidationChange?.(true);
} else {
setValidationState("taken");
setErrorMessage(result.message || "This slug is not available");
onValidationChange?.(false);
}
});
});
},
[onValidationChange]
);
useEffect(() => {
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
validateSlug(value);
}, 500);
// Cleanup on unmount
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [value, validateSlug]);
return (
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">Organization link</Label>
<TextField
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="acme"
addOnSuffix={
<div className="flex items-center gap-2">
{validationState === "checking" && (
<Icon name="loader" className="text-subtle h-3 w-3 animate-spin" />
)}
<span className="text-subtle text-sm">.cal.com</span>
</div>
}
className={classNames(validationState === "taken" ? "border-error" : "")}
/>
{validationState === "taken" && <p className="text-error text-sm">{errorMessage}</p>}
</div>
);
}
@@ -0,0 +1,292 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import React from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Form, Label, TextField, Select, ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationInviteViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
team: string;
role: "MEMBER" | "ADMIN";
}[];
};
export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProps) => {
const router = useRouter();
const { t } = useLocale();
// We are using email mode for now until we implement the other invite methods, csv, workspace - invite link
const isEmailMode = true;
const store = useOnboardingStore();
const usersEmailDomain = userEmail.split("@")[1];
const { invites: storedInvites, inviteRole, setInvites, setInviteRole } = store;
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
const formSchema = z.object({
invites: z.array(
z.object({
email: z.string().email(t("invalid_email_address")),
team: z.string().min(1, t("onboarding_team_required")),
role: z.enum(["MEMBER", "ADMIN"]),
})
),
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
invites: storedInvites.length > 0 ? storedInvites : [{ email: "", team: "", role: inviteRole }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invites",
});
const handleSubmitOnboarding = async (invitesData: FormValues["invites"]) => {
// Save invites to store before submitting
setInvites(invitesData);
await submitOnboarding(store, userEmail);
};
const handleContinue = async (data?: FormValues) => {
if (data) {
await handleSubmitOnboarding(data.invites);
} else {
await handleSubmitOnboarding([]);
}
};
const handleSkip = async () => {
// Complete onboarding without invites
await handleSubmitOnboarding([]);
};
const handleInviteViaEmail = () => {
router.push("/onboarding/organization/invite?mode=email");
};
const hasValidInvites = fields.some((_, index) => {
const email = form.watch(`invites.${index}.email`);
const team = form.watch(`invites.${index}.team`);
return email && email.trim().length > 0 && team && team.trim().length > 0;
});
// Get teams from store, filter out empty teams
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
const teams =
filteredTeams.length > 0
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
: [];
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-4">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">
{t("onboarding_org_invite_title")}
</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{isEmailMode
? t("onboarding_org_invite_subtitle_email")
: t("onboarding_org_invite_subtitle_full")}
</p>
</div>
</div>
{/* Content */}
<div className="bg-default border-subtle w-full rounded-md border">
<div className="flex w-full flex-col gap-8 px-5 py-5">
{!isEmailMode ? (
// Coming soon
// Initial invite options view
<div className="flex w-full flex-col gap-4">
<Button color="secondary" className="w-full justify-center" StartIcon="mail">
{t("onboarding_connect_google_workspace")}
</Button>
<div className="flex items-center gap-2">
<div className="bg-subtle h-px flex-1" />
<span className="text-subtle text-xs">{t("onboarding_or_divider")}</span>
<div className="bg-subtle h-px flex-1" />
</div>
<div className="flex items-center gap-2">
<Button
color="secondary"
className="flex-1 justify-center"
onClick={handleInviteViaEmail}>
{t("invite_via_email")}
</Button>
<Button color="secondary" className="flex-1 justify-center" StartIcon="upload">
{t("onboarding_upload_csv")}
</Button>
<Button color="secondary" className="flex-1 justify-center" StartIcon="link">
{t("onboarding_copy_invite_link")}
</Button>
</div>
{/* Role selector */}
<div className="flex items-center justify-between">
<div className="hidden items-center gap-2 md:flex">
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
<ToggleGroup
value={inviteRole}
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
options={[
{ value: "ADMIN", label: t("onboarding_admins") },
{ value: "MEMBER", label: t("members") },
]}
/>
</div>
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
</div>
</div>
) : (
// Email invite form
<Form form={form} handleSubmit={handleContinue} className="w-full">
<div className="flex w-full flex-col gap-4">
{/* Email and Team inputs */}
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2 gap-2">
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
<Label className="text-emphasis text-sm font-medium">{t("team")}</Label>
</div>
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-0.5 pt-2 md:pt-0">
<div className="grid flex-1 items-start gap-2 md:grid-cols-2 ">
<TextField
labelSrOnly
{...form.register(`invites.${index}.email`)}
placeholder={`dave@${usersEmailDomain}`}
type="email"
size="sm"
/>
<Select
size="sm"
options={teams}
value={teams.find((t) => t.value === form.watch(`invites.${index}.team`))}
onChange={(option) => {
if (option) {
form.setValue(`invites.${index}.team`, option.value);
}
}}
placeholder={t("select_team")}
/>
</div>
<Button
type="button"
color="minimal"
variant="icon"
size="sm"
className="h-7 w-7"
disabled={fields.length === 1}
onClick={() => remove(index)}>
<Icon name="x" className="h-4 w-4" />
</Button>
</div>
))}
{/* Add button */}
<Button
type="button"
color="secondary"
size="sm"
StartIcon="plus"
className="w-fit"
onClick={() => append({ email: "", team: "", role: "MEMBER" })}>
{t("add")}
</Button>
</div>
{/* Role selector */}
<div className="flex items-center justify-between">
<div className="hidden items-center gap-2 md:flex">
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
<ToggleGroup
value={inviteRole}
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
options={[
{ value: "MEMBER", label: t("members") },
{ value: "ADMIN", label: t("onboarding_admins") },
]}
/>
</div>
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
</div>
</div>
</Form>
)}
</div>
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
type={isEmailMode ? "submit" : "button"}
color="primary"
className="rounded-[10px]"
disabled={(isEmailMode && !hasValidInvites) || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
</div>
</div>
</div>
{/* Skip button */}
<div className="flex w-full justify-center">
<button
onClick={handleSkip}
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4">
{t("ill_do_this_later")}
</button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,169 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Form, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationTeamsViewProps = {
userEmail: string;
};
type FormValues = {
teams: { name: string }[];
};
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { teams: storedTeams, setTeams } = useOnboardingStore();
const formSchema = z.object({
teams: z.array(
z.object({
name: z.string().min(1, t("team_name_required")),
})
),
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
teams: storedTeams.length > 0 ? storedTeams : [{ name: "" }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "teams",
});
const handleContinue = (data: FormValues) => {
// Save teams to store
setTeams(data.teams);
router.push("/onboarding/organization/invite");
};
const handleSkip = () => {
// Skip teams and go to invite
router.push("/onboarding/organization/invite");
};
const hasValidTeams = fields.some((_, index) => {
const teamName = form.watch(`teams.${index}.name`);
return teamName && teamName.trim().length > 0;
});
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-4">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("onboarding_org_teams_title")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("onboarding_org_teams_subtitle")}
</p>
</div>
</div>
{/* Form */}
<Form form={form} handleSubmit={handleContinue} className="w-full">
<div className="bg-default border-subtle w-full rounded-md border">
<div className="flex w-full flex-col gap-8 px-5 py-5">
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
</div>
{/* Team fields */}
<div className="flex flex-col gap-2">
{fields.map((field, index) => (
<div key={field.id} className="flex w-full items-end gap-0.5">
<div className="flex-1">
<TextField
labelSrOnly
{...form.register(`teams.${index}.name`)}
placeholder={t("team")}
className="h-7 w-full rounded-[10px] text-sm"
/>
</div>
<Button
type="button"
color="minimal"
variant="icon"
size="sm"
className="h-7 w-7"
disabled={fields.length === 1}
onClick={() => remove(index)}>
<Icon name="x" className="h-4 w-4" />
</Button>
</div>
))}
</div>
{/* Add button */}
<Button
type="button"
color="secondary"
size="sm"
StartIcon="plus"
className="w-fit"
onClick={() => append({ name: "" })}>
{t("add")}
</Button>
</div>
</div>
</div>
{/* Footer */}
<div className="relative flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
type="button"
color="minimal"
className="absolute left-[368px] rounded-[10px]"
onClick={handleSkip}>
{t("onboarding_skip_for_now")}
</Button>
<Button type="submit" color="primary" className="rounded-[10px]" disabled={!hasValidTeams}>
{t("continue")}
</Button>
</div>
</Form>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,108 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type PlanType = "personal" | "team" | "organization";
export type InviteRole = "MEMBER" | "ADMIN";
export interface OrganizationDetails {
name: string;
link: string;
bio: string;
}
export interface OrganizationBrand {
color: string;
logo: string | null; // base64 or URL
banner: string | null; // base64 or URL
}
export interface Team {
name: string;
}
export interface Invite {
email: string;
team: string;
role: InviteRole;
}
export interface OnboardingState {
selectedPlan: PlanType | null;
organizationDetails: OrganizationDetails;
organizationBrand: OrganizationBrand;
teams: Team[];
invites: Invite[];
inviteRole: InviteRole;
// Actions
setSelectedPlan: (plan: PlanType) => void;
setOrganizationDetails: (details: Partial<OrganizationDetails>) => void;
setOrganizationBrand: (brand: Partial<OrganizationBrand>) => void;
setTeams: (teams: Team[]) => void;
setInvites: (invites: Invite[]) => void;
setInviteRole: (role: InviteRole) => void;
// Reset
resetOnboarding: () => void;
}
const initialState = {
selectedPlan: "personal" as PlanType,
organizationDetails: {
name: "",
link: "",
bio: "",
},
organizationBrand: {
color: "#000000",
logo: null,
banner: null,
},
teams: [],
invites: [],
inviteRole: "MEMBER" as InviteRole,
};
export const useOnboardingStore = create<OnboardingState>()(
persist(
(set) => ({
...initialState,
setSelectedPlan: (plan) => set({ selectedPlan: plan }),
setOrganizationDetails: (details) =>
set((state) => ({
organizationDetails: { ...state.organizationDetails, ...details },
})),
setOrganizationBrand: (brand) =>
set((state) => ({
organizationBrand: { ...state.organizationBrand, ...brand },
})),
setTeams: (teams) => set({ teams }),
setInvites: (invites) => set({ invites }),
setInviteRole: (role) => set({ inviteRole: role }),
resetOnboarding: () => set(initialState),
}),
{
name: "cal-onboarding-storage", // localStorage key
// Optional: Only persist certain fields
partialize: (state) => ({
selectedPlan: state.selectedPlan,
organizationDetails: state.organizationDetails,
organizationBrand: state.organizationBrand,
teams: state.teams,
invites: state.invites,
inviteRole: state.inviteRole,
}),
}
)
);
+3 -1
View File
@@ -184,6 +184,7 @@ export default function Signup({
orgAutoAcceptEmail,
redirectUrl,
emailVerificationEnabled,
onboardingV3Enabled,
}: SignupProps) {
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
const [isSamlSignup, setIsSamlSignup] = useState(false);
@@ -262,7 +263,8 @@ export default function Signup({
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : "getting-started";
const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started";
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath;
const gettingStartedWithPlatform = "settings/platform/new";
const constructCallBackIfUrlPresent = () => {
@@ -3846,5 +3846,35 @@
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
"what_would_you_like_to_block": "What would you like to block?",
"domain_name": "Domain Name",
"onboarding_org_details_title": "Add your Organization's details",
"onboarding_org_details_subtitle": "Customize your booking pages with your Organization's name and bio",
"onboarding_org_bio_label": "Organization bio",
"onboarding_org_bio_placeholder": "Acme helps thousands of companies expand globally with unmatched speed and flexibility.",
"onboarding_org_teams_title": "Add your Organization's teams",
"onboarding_org_teams_subtitle": "Start scheduling together by adding your teams",
"onboarding_org_brand_title": "Add your Organization's brand",
"onboarding_org_brand_subtitle": "Customize your booking pages with your logo and a custom banner",
"onboarding_primary_color_label": "Primary color",
"onboarding_banner_label": "Banner",
"onboarding_logo_size_hint": "Recommended size 64x64px (max 10mb)",
"onboarding_banner_size_hint": "Recommended size 1500x150px (max 10mb)",
"onboarding_preview_nameless": "Nameless",
"onboarding_preview_example_title": "Enterprise sales",
"onboarding_preview_example_description": "Jump on a call with one of our sales reps to learn more",
"onboarding_logo_preview_alt": "Logo preview",
"onboarding_banner_preview_alt": "Banner preview",
"onboarding_pick_color_aria": "Pick color",
"onboarding_org_invite_title": "Invite your teammates",
"onboarding_org_invite_subtitle_email": "Enter your teammates email address and set their team to add them to your Organization.",
"onboarding_org_invite_subtitle_full": "Connect your Google Workspace, invite via email, upload a CSV file or copy the invite link and share it with your teammates to add them to your Organization.",
"onboarding_connect_google_workspace": "Connect Google Workspace",
"onboarding_upload_csv": "Upload CSV file",
"onboarding_copy_invite_link": "Copy invite link",
"onboarding_invite_all_as": "Invite all as",
"onboarding_admins": "Admins",
"onboarding_modify_roles_later": "You can modify roles later",
"onboarding_skip_for_now": "Skip for now",
"onboarding_or_divider": "or",
"onboarding_team_required": "Team is required",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -191,14 +191,9 @@ export async function assertCanCreateOrg({
"email-verification"
);
const verifiedUser =
orgOwner.completedOnboarding && (emailVerificationEnabled ? !!orgOwner.emailVerified : true);
const verifiedUser = emailVerificationEnabled ? !!orgOwner.emailVerified : true;
if (!verifiedUser) {
log.warn(
"you_need_to_complete_user_onboarding_before_creating_an_organization",
safeStringify({ userId: orgOwner.id })
);
throw new OrgCreationError("you_need_to_complete_user_onboarding_before_creating_an_organization");
throw new OrgCreationError("you_need_to_verify_your_email_before_creating_an_organization");
}
if (isNotACompanyEmail(orgOwner.email) && !isPlatform) {
@@ -810,5 +810,59 @@ describe("BillingEnabledOrgOnboardingService", () => {
})
);
});
it("should transfer branding data (logo, brandColor, bannerUrl) from onboarding to organization", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const onboardingWithBranding = await createTestOnboarding({
logo: "https://example.com/logo.png",
bio: "Test organization bio",
brandColor: "#FF5733",
bannerUrl: "https://example.com/banner.jpg",
});
await createTestUser({
email: onboardingWithBranding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
const { organization } = await service.createOrganization(onboardingWithBranding, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
expect(organization.logoUrl).toBe("https://example.com/logo.png");
expect(organization.bio).toBe("Test organization bio");
expect(organization.brandColor).toBe("#FF5733");
expect(organization.bannerUrl).toBe("https://example.com/banner.jpg");
});
it("should handle null branding data correctly", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
const onboardingWithNullBranding = await createTestOnboarding({
logo: null,
bio: null,
brandColor: null,
bannerUrl: null,
});
await createTestUser({
email: onboardingWithNullBranding.orgOwnerEmail,
onboardingCompleted: true,
emailVerified: new Date(),
});
const { organization } = await service.createOrganization(onboardingWithNullBranding, {
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
});
expect(organization.logoUrl).toBeNull();
expect(organization.bio).toBeNull();
expect(organization.brandColor).toBeNull();
expect(organization.bannerUrl).toBeNull();
});
});
});
@@ -1,8 +1,8 @@
export function extractDomainFromEmail(email: string) {
let out = "";
try {
const match = email.match(/^(?:.*?:\/\/)?.*?(?<root>[\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
out = (match && match.groups?.root) ?? "";
const match = email.match(/^(?:.*?:\/\/)?.*?([\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
out = (match && match[1]) ?? "";
} catch (ignore) {}
return out.split(".")[0];
}
@@ -150,3 +150,64 @@ describe("Organization.getVerifiedOrganizationByAutoAcceptEmailDomain", () => {
expect(result).toEqual(null);
});
});
describe("Organization.create", () => {
it("should create organization with branding data (logoUrl, brandColor, bannerUrl)", async () => {
const orgData = {
name: "Test Organization",
slug: "test-org",
isOrganizationConfigured: true,
isOrganizationAdminReviewed: true,
autoAcceptEmail: "test.com",
seats: 10,
pricePerSeat: 15,
isPlatform: false,
billingPeriod: "MONTHLY" as const,
logoUrl: "https://example.com/logo.png",
bio: "Test organization bio",
brandColor: "#FF5733",
bannerUrl: "https://example.com/banner.jpg",
};
const organization = await OrganizationRepository.create(orgData);
expect(organization).toMatchObject({
name: "Test Organization",
slug: "test-org",
isOrganization: true,
logoUrl: "https://example.com/logo.png",
bio: "Test organization bio",
brandColor: "#FF5733",
bannerUrl: "https://example.com/banner.jpg",
});
});
it("should create organization with null branding data", async () => {
const orgData = {
name: "Test Organization",
slug: "test-org-2",
isOrganizationConfigured: true,
isOrganizationAdminReviewed: true,
autoAcceptEmail: "test.com",
seats: null,
pricePerSeat: null,
isPlatform: false,
logoUrl: null,
bio: null,
brandColor: null,
bannerUrl: null,
};
const organization = await OrganizationRepository.create(orgData);
expect(organization).toMatchObject({
name: "Test Organization",
slug: "test-org-2",
isOrganization: true,
logoUrl: null,
bio: null,
brandColor: null,
bannerUrl: null,
});
});
});
@@ -36,6 +36,8 @@ export class OrganizationRepository {
billingPeriod?: "MONTHLY" | "ANNUALLY";
logoUrl: string | null;
bio: string | null;
brandColor: string | null;
bannerUrl: string | null;
requestedSlug?: string | null;
};
owner: {
@@ -84,6 +86,8 @@ export class OrganizationRepository {
isPlatform: boolean;
logoUrl: string | null;
bio: string | null;
brandColor: string | null;
bannerUrl: string | null;
};
owner: {
email: string;
@@ -133,6 +137,8 @@ export class OrganizationRepository {
isPlatform: boolean;
logoUrl: string | null;
bio: string | null;
brandColor: string | null;
bannerUrl: string | null;
requestedSlug?: string | null;
}) {
return await prisma.team.create({
@@ -140,9 +146,10 @@ export class OrganizationRepository {
name: orgData.name,
isOrganization: true,
slug: orgData.slug,
// This is huge and causes issues, we need to have the logic to convert logo to logoUrl and then use that url ehre.
// logoUrl: orgData.logoUrl,
logoUrl: orgData.logoUrl,
bio: orgData.bio,
brandColor: orgData.brandColor,
bannerUrl: orgData.bannerUrl,
organizationSettings: {
create: {
isAdminReviewed: orgData.isOrganizationAdminReviewed,
@@ -183,6 +183,8 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
logoUrl: null,
bio: null,
paymentSubscriptionId: null,
brandColor: null,
bannerUrl: null,
};
// Create a new user and invite them as the owner of the organization