feat: organization v3 redesign onboarding (#24967)

## What does this PR do?

- Redesigns the organization onboarding flow by merging the brand and details pages
- Improves the organization details page with a scrollable interface and visual previews
- Adds a new organization-specific browser preview component

## Visual Demo (For contributors especially)

#### Image Demo:
- The PR replaces the separate brand page with an integrated details page that includes logo and banner uploads
- The new organization browser view shows a preview of the organization profile with the selected branding

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code.
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

- Go through the organization onboarding flow
- Test uploading logos and banners
- Verify that the organization browser preview updates in real-time with the form inputs
- Confirm that the form validation works correctly for organization name and slug
- Check that the scrollable interface works properly with fade effects at top and bottom

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings

















































<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Split organization brand from the details step and added live previews for organizations and teams. Revamped org/team invites with reusable components, a dedicated email-invite page, and a CSV upload modal.

- **New Features**
  - Separate Brand step with logo, banner, and color; instant org preview via OnboardingOrganizationBrowserView.
  - Teams browser preview added; invites include email substep (/onboarding/organization/invite/email, /onboarding/teams/invite/email), CSV upload (template + parsing), and Google Workspace (behind flag).
  - Shared components (EmailInviteForm, InviteOptions, RoleSelector) used across org and team invites.

- **Refactors**
  - Updated org flow: Details → Brand → Teams → Invites; OnboardingLayout now supports dynamic step counts (org=4, team=3, personal=2).
  - UI polish (OnboardingCard header padding) and org-specific previews now replace generic views across details/brand/invites/teams; ensured org welcome modal takes precedence over personal.

<sup>Written for commit d9b55c0b5505aa0d4ca1c4298a513bcd90606915. Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
sean-brydon
2025-11-13 10:45:42 +00:00
committed by GitHub
parent ea80e8c8d4
commit 19641ffc05
26 changed files with 1401 additions and 640 deletions
@@ -1,4 +1,3 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
@@ -33,3 +32,4 @@ const ServerPage = async () => {
};
export default ServerPage;
@@ -0,0 +1,35 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { OrganizationInviteEmailView } from "~/onboarding/organization/invite/email/organization-invite-email-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
() => "",
true,
undefined,
"/onboarding/organization/invite/email"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <OrganizationInviteEmailView userEmail={userEmail} />;
};
export default ServerPage;
@@ -0,0 +1,35 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamInviteEmailView } from "~/onboarding/teams/invite/email/team-invite-email-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
() => "",
true,
undefined,
"/onboarding/teams/invite/email"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <TeamInviteEmailView userEmail={userEmail} />;
};
export default ServerPage;
@@ -0,0 +1,123 @@
"use client";
import { useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField, Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import type { InviteRole } from "../store/onboarding-store";
type BaseInviteFormData = {
email: string;
role: InviteRole;
};
type InviteFormDataWithOptionalTeam = BaseInviteFormData & {
team?: string;
};
type InviteFormDataWithRequiredTeam = BaseInviteFormData & {
team: string;
};
type InviteFormData = InviteFormDataWithOptionalTeam | InviteFormDataWithRequiredTeam;
type EmailInviteFormProps = {
fields: Array<{ id: string }>;
append: (value: InviteFormData) => void;
remove: (index: number) => void;
defaultRole: InviteRole;
showTeamSelect?: boolean;
teams?: Array<{ value: string; label: string }>;
emailPlaceholder?: string;
};
export function EmailInviteForm({
fields,
append,
remove,
defaultRole,
showTeamSelect = false,
teams = [],
emailPlaceholder,
}: EmailInviteFormProps) {
const { t } = useLocale();
const { register, watch, setValue } = useFormContext();
return (
<div className="flex w-full flex-col gap-4">
<div className="flex flex-col gap-2">
{showTeamSelect ? (
<div className="grid grid-cols-2">
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.email">
{t("email")}
</Label>
<Label className="text-emphasis mb-0 text-sm font-medium" htmlFor="invites.0.team">
{t("team")}
</Label>
</div>
) : (
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
)}
<div
className={
showTeamSelect ? "flex flex-col gap-2" : "scroll-bar flex max-h-72 flex-col gap-1 overflow-y-auto"
}>
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-0.5 p-0.5">
<div className={showTeamSelect ? "grid flex-1 items-start gap-2 md:grid-cols-2" : "flex-1"}>
<TextField
labelSrOnly
{...register(`invites.${index}.email`)}
placeholder={emailPlaceholder || `rick@cal.com`}
type="email"
size="sm"
/>
{showTeamSelect && (
<Select
size="sm"
options={teams}
value={teams.find((t) => t.value === watch(`invites.${index}.team`))}
onChange={(option) => {
if (option) {
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>
))}
</div>
<Button
type="button"
color="secondary"
size="sm"
StartIcon="plus"
className={showTeamSelect ? "mt-2 w-fit" : "w-fit"}
onClick={() =>
append(
showTeamSelect ? { email: "", team: "", role: defaultRole } : { email: "", role: defaultRole }
)
}>
{t("add")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useFlags } from "@calcom/features/flags/hooks";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
const GoogleIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_4178_176214)">
<path
d="M8.31875 15.36C4.26 15.36 0.9575 12.0588 0.9575 8.00001C0.9575 3.94126 4.26 0.640015 8.31875 0.640015C10.1575 0.640015 11.9175 1.32126 13.2763 2.55876L13.5238 2.78501L11.0963 5.21251L10.8713 5.02001C10.1588 4.41001 9.2525 4.07376 8.31875 4.07376C6.15375 4.07376 4.39125 5.83501 4.39125 8.00001C4.39125 10.165 6.15375 11.9263 8.31875 11.9263C9.88 11.9263 11.1138 11.1288 11.695 9.77001H7.99875V6.45626L15.215 6.46626L15.2688 6.72001C15.645 8.50626 15.3438 11.1338 13.8188 13.0138C12.5563 14.57 10.7063 15.36 8.31875 15.36Z"
fill="#6B7280"
/>
</g>
<defs>
<clipPath id="clip0_4178_176214">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
type InviteOptionsProps = {
onInviteViaEmail: () => void;
onUploadCSV?: () => void;
onCopyInviteLink?: () => void;
onConnectGoogleWorkspace?: () => void;
isSubmitting?: boolean;
};
export const InviteOptions = ({
onInviteViaEmail,
onUploadCSV,
onCopyInviteLink,
onConnectGoogleWorkspace,
isSubmitting = false,
}: InviteOptionsProps) => {
const { t } = useLocale();
const flags = useFlags();
const googleWorkspaceEnabled = flags["google-workspace-directory"];
return (
<div className="flex h-full w-full flex-1 flex-col gap-6 ">
{googleWorkspaceEnabled && onConnectGoogleWorkspace && (
<>
<Button
color="secondary"
className="h-8 w-full rounded-[10px]"
CustomStartIcon={<GoogleIcon />}
onClick={onConnectGoogleWorkspace}
disabled={isSubmitting}>
{t("connect_google_workspace")}
</Button>
<div className="flex w-full items-center gap-2">
<div className="border-subtle h-px flex-1 border-t" />
<span className="text-subtle text-sm font-medium">{t("or")}</span>
<div className="border-subtle h-px flex-1 border-t" />
</div>
</>
)}
<div className="flex w-full flex-1 flex-col gap-4">
<Button
color="primary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={onInviteViaEmail}
disabled={isSubmitting}>
<div className="flex items-center gap-1">
<Icon name="mail" className="h-4 w-4" />
<span>{t("invite_via_email")}</span>
</div>
</Button>
{onUploadCSV && (
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={onUploadCSV}
disabled={isSubmitting}>
<div className="flex items-center gap-1">
<Icon name="upload" className="h-4 w-4" />
<span>{t("upload_csv_file")}</span>
</div>
</Button>
)}
{onCopyInviteLink && (
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={onCopyInviteLink}
disabled>
<div className="flex items-center gap-1">
<Icon name="link" className="h-4 w-4" />
<span>{t("copy_invite_link")}</span>
</div>
</Button>
)}
</div>
</div>
);
};
@@ -22,7 +22,7 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
</div>
{/* Content */}
<div className="flex min-h-0 w-full flex-1 flex-col gap-4">
<div className="flex h-full min-h-0 w-full flex-1 flex-col gap-4">
{isLoading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
@@ -38,4 +38,3 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
</div>
);
};
@@ -1,5 +1,6 @@
"use client";
import classNames from "classnames";
import { signOut } from "next-auth/react";
import { Children, type ReactNode } from "react";
@@ -9,11 +10,12 @@ import { Logo } from "@calcom/ui/components/logo";
type OnboardingLayoutProps = {
userEmail: string;
currentStep: 1 | 2 | 3;
currentStep: number;
totalSteps: number;
children: ReactNode;
};
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
export const OnboardingLayout = ({ userEmail, currentStep, totalSteps, children }: OnboardingLayoutProps) => {
const { t } = useLocale();
const handleSignOut = () => {
@@ -42,17 +44,31 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
{/* Footer with progress dots and sign out */}
<div className="flex w-full flex-col items-center justify-center gap-4 px-10 py-8">
<div className="flex items-center gap-2">
{[1, 2, 3].map((step) => (
<div key={step} className="relative flex h-2 w-2 shrink-0 items-center justify-center">
<div
className={`absolute inset-0 rounded-full ${
step <= currentStep ? "bg-emphasis" : "bg-muted"
}`}
/>
{step <= currentStep && <div className="bg-emphasis absolute h-1 w-1 rounded-full" />}
</div>
))}
<div className="flex items-center gap-3">
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((step) => {
const isCurrentStep = step === currentStep;
const isCompleted = step < currentStep;
return (
<div key={step} className="relative flex shrink-0 items-center justify-center">
<div
className={classNames("absolute rounded-full transition-all", {
"h-2 w-2": !isCurrentStep,
"h-3 w-3": isCurrentStep,
"bg-emphasis": isCompleted || isCurrentStep,
"bg-muted": !isCompleted && !isCurrentStep,
})}
/>
{(isCompleted || isCurrentStep) && (
<div
className={classNames("bg-emphasis absolute rounded-full", {
"h-1 w-1": !isCurrentStep,
"h-1.5 w-1.5": isCurrentStep,
})}
/>
)}
</div>
);
})}
</div>
<Button onClick={handleSignOut} color="minimal" className="text-subtle h-7">
{t("sign_out")}
@@ -61,4 +77,3 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
</div>
);
};
@@ -0,0 +1,38 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
import { InfoBadge } from "@calcom/ui/components/badge";
import type { InviteRole } from "../store/onboarding-store";
type RoleSelectorProps = {
value: InviteRole;
onValueChange: (value: InviteRole) => void;
showInfoBadge?: boolean;
};
export const RoleSelector = ({ value, onValueChange, showInfoBadge = false }: RoleSelectorProps) => {
const { t } = useLocale();
return (
<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={value}
onValueChange={(val) => val && onValueChange(val as InviteRole)}
options={[
{ value: "MEMBER", label: t("members") },
{ value: "ADMIN", label: t("onboarding_admins") },
]}
/>
{showInfoBadge && <InfoBadge content={t("onboarding_modify_roles_later")} />}
</div>
{!showInfoBadge && (
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
)}
</div>
);
};
@@ -1,5 +1,7 @@
"use client";
import classNames from "classnames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -95,7 +97,11 @@ export const OnboardingBrowserView = ({
<h2 className="text-emphasis text-xl font-semibold leading-tight">
{name || t("your_name")}
</h2>
<p className="text-default text-sm leading-normal">
<p
className={classNames("text-sm leading-normal", {
"text-default": bio,
"text-subtle italic": !bio,
})}>
{bio || t("onboarding_browser_view_default_bio")}
</p>
</div>
@@ -0,0 +1,147 @@
"use client";
import classNames from "classnames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Icon, type IconName } from "@calcom/ui/components/icon";
type OnboardingOrganizationBrowserViewProps = {
avatar?: string | null;
name?: string;
bio?: string;
slug?: string;
bannerUrl?: string | null;
};
export const OnboardingOrganizationBrowserView = ({
avatar,
name,
bio,
slug,
bannerUrl,
}: OnboardingOrganizationBrowserViewProps) => {
const { t } = useLocale();
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
const displayUrl = `${webappUrl}/${slug || ""}`;
const events: Array<{
title: string;
description: string;
duration: number;
icon: IconName;
}> = [
{
title: t("onboarding_browser_view_demo"),
description: t("onboarding_browser_view_demo_description"),
duration: 15,
icon: "bell",
},
{
title: t("onboarding_browser_view_quick_meeting"),
description: t("onboarding_browser_view_quick_meeting_description"),
duration: 15,
icon: "bell",
},
{
title: t("onboarding_browser_view_longer_meeting"),
description: t("onboarding_browser_view_longer_meeting_description"),
duration: 30,
icon: "clock",
},
{
title: t("in_person_meeting"),
description: t("onboarding_browser_view_in_person_description"),
duration: 120,
icon: "map-pin",
},
];
return (
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
{/* Browser header */}
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
</div>
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
<Icon name="lock" className="text-subtle h-4 w-4" />
<p className="text-default text-sm font-medium leading-tight">{displayUrl}</p>
</div>
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
</div>
{/* Content */}
<div className="bg-muted h-full pl-11 pt-11">
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
{/* Organization Profile Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-40 w-full overflow-hidden rounded-t-xl border-b">
{bannerUrl ? (
<img src={bannerUrl} alt={name || ""} className="h-full w-full object-cover" />
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
{/* Organization Avatar - Overlaying the banner */}
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={name || ""}
className="border-4 border-white"
/>
</div>
</div>
{/* Organization Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">
{name || t("organization_name")}
</h2>
<p
className={classNames("text-sm leading-normal", {
"text-default": bio,
"text-subtle italic": !bio,
})}>
{bio || t("onboarding_browser_view_default_bio")}
</p>
</div>
</div>
{/* Events List */}
<div className="flex flex-col overflow-y-hidden">
{events.map((event, index) => (
<div key={event.title} className="border opacity-30 first:border-t-0">
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1">
<h3 className="text-default text-sm font-semibold leading-none">{event.title}</h3>
<div className="bg-emphasis flex h-4 items-center justify-center gap-1 rounded-md px-1">
<Icon name={event.icon} className="text-emphasis h-3 w-3" />
<span className="text-emphasis text-xs font-medium leading-none">
{event.duration} {t("minute_timeUnit")}
</span>
</div>
</div>
<p className="text-subtle text-sm font-medium leading-tight">{event.description}</p>
</div>
<Button color="secondary" size="sm" EndIcon="arrow-right">
{t("book_now")}
</Button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,136 @@
"use client";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
import { Icon } from "@calcom/ui/components/icon";
type OnboardingTeamsBrowserViewProps = {
teams: Array<{ name: string }>;
organizationLogo?: string | null;
organizationName?: string;
organizationBanner?: string | null;
};
export const OnboardingTeamsBrowserView = ({
teams,
organizationLogo,
organizationName,
organizationBanner,
}: OnboardingTeamsBrowserViewProps) => {
const { t } = useLocale();
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
// Show placeholder if no teams or all teams are empty
const hasValidTeams = teams.some((team) => team.name && team.name.trim().length > 0);
return (
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
{/* Browser header */}
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
</div>
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
<Icon name="lock" className="text-subtle h-4 w-4" />
<p className="text-default text-sm font-medium leading-tight">{webappUrl}/teams</p>
</div>
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
</div>
{/* Content */}
<div className="bg-muted h-full pl-11 pt-11">
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
{/* Teams Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-40 w-full overflow-hidden rounded-t-xl border-b">
{organizationBanner ? (
<img
src={organizationBanner}
alt={organizationName || ""}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
{/* Organization Logo - Overlaying the banner */}
{organizationLogo && (
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={organizationLogo}
alt={organizationName || ""}
className="border-4 border-white"
/>
</div>
)}
</div>
{/* Teams Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("teams")}</h2>
<p className="text-subtle text-sm leading-normal">
{hasValidTeams
? t("onboarding_teams_browser_view_subtitle")
: t("onboarding_teams_browser_view_placeholder_subtitle")}
</p>
</div>
</div>
{/* Teams List */}
<div className="flex flex-col overflow-y-auto">
{hasValidTeams ? (
teams
.filter((team) => team.name && team.name.trim().length > 0)
.map((team, index) => (
<div key={index} className="">
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center gap-3 px-5 py-4">
<Avatar
size="md"
alt={team.name}
className="border-2 border-white"
fallback={
<div className="bg-emphasis flex h-full w-full items-center justify-center text-white">
<span className="text-sm font-semibold">{team.name.charAt(0).toUpperCase()}</span>
</div>
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-subtle text-sm font-semibold leading-none">{team.name}</h3>
<p className="text-muted text-xs font-medium leading-tight">
{t("onboarding_teams_browser_view_team_description")}
</p>
</div>
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center gap-3 px-5 py-12">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
<Icon name="users" className="text-subtle h-8 w-8" />
</div>
<div className="flex flex-col gap-1 text-center">
<p className="text-default text-sm font-semibold leading-tight">
{t("onboarding_teams_browser_view_no_teams_title")}
</p>
<p className="text-subtle text-xs font-medium leading-tight">
{t("onboarding_teams_browser_view_no_teams_description")}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -106,7 +106,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={1}>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={1}>
{/* Left column - Main content */}
<OnboardingCard
title="Select plan"
@@ -4,9 +4,12 @@ import { setShowWelcomeToCalcomModalFlag } from "@calcom/features/shell/hooks/us
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import { sessionStorage } from "@calcom/lib/webstorage";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
const ORG_MODAL_STORAGE_KEY = "showNewOrgModal";
const DEFAULT_EVENT_TYPES = [
{
title: "15min_meeting",
@@ -56,9 +59,19 @@ export const useSubmitPersonalOnboarding = () => {
}
await utils.viewer.me.get.refetch();
// Set flag to show welcome modal after redirect
setShowWelcomeToCalcomModalFlag();
router.push("/event-types?welcomeToCalcomModal=true");
// Check if org modal flag is set - if so, don't show personal modal
// Organization onboarding takes precedence
const hasOrgModalFlag = sessionStorage.getItem(ORG_MODAL_STORAGE_KEY) === "true";
if (!hasOrgModalFlag) {
// Only set personal modal flag if org modal flag is not set
setShowWelcomeToCalcomModalFlag();
router.push("/event-types?welcomeToCalcomModal=true");
} else {
// Org modal flag exists - redirect to event-types without personal modal query param
// The org modal will show via sessionStorage check
router.push("/event-types?newOrganizationModal=true");
}
},
onError: (error) => {
showToast(t("something_went_wrong"), "error");
@@ -1,77 +1,37 @@
"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 { useEffect, useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { ColorPicker, Label } from "@calcom/ui/components/form";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
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 { organizationDetails, organizationBrand, setOrganizationBrand } = 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);
const [brandColor, setBrandColor] = useState("#000000");
// Load from store on mount
useEffect(() => {
setBrandColor(organizationBrand.color);
setLogoPreview(organizationBrand.logo);
setBannerPreview(organizationBrand.banner);
setBrandColor(organizationBrand.color);
}, [organizationBrand]);
const handleLogoChange = (file: File | null) => {
@@ -106,129 +66,116 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
}
};
const handleContinue = () => {
// Save brand color to store
setOrganizationBrand({ color: brandColor });
router.push("/onboarding/organization/teams");
const handleColorChange = (color: string) => {
setBrandColor(color);
setOrganizationBrand({ color });
};
const handleSkip = () => {
// Skip brand setup and go to teams
const handleContinue = () => {
// Save to store (already saved on change, but ensure it's persisted)
setOrganizationBrand({
logo: logoPreview,
banner: bannerPreview,
color: brandColor,
});
router.push("/onboarding/organization/teams");
};
return (
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={4}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_brand_title")}
subtitle={t("onboarding_org_brand_subtitle")}
footer={
<>
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("ill_do_this_later")}
<div className="flex w-full items-center justify-between gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/organization/details")}>
{t("back")}
</Button>
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
</>
</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 className="relative flex">
{/* Scrollable content container */}
<div className="relative h-full w-full gap-6">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* 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>
{/* 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 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>
{/* 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>
{/* Brand Color */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("light_brand_color")}
</Label>
<ColorPicker defaultValue={brandColor} onChange={handleColorChange} />
</div>
</div>
</div>
@@ -236,7 +183,13 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingBrowserView />
<OnboardingOrganizationBrowserView
avatar={logoPreview}
name={organizationDetails.name}
bio={organizationDetails.bio}
slug={organizationDetails.link}
bannerUrl={bannerPreview}
/>
</OnboardingLayout>
);
};
@@ -1,15 +1,15 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useOnboardingStore } from "../../store/onboarding-store";
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
@@ -77,60 +77,60 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
};
return (
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={4}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_details_title")}
subtitle={t("onboarding_org_details_subtitle")}
footer={
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !organizationName || !organizationLink}>
{t("continue")}
</Button>
<div className="flex w-full items-center justify-end gap-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !organizationName || !organizationLink}>
{t("continue")}
</Button>
</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>
<div className="relative flex">
{/* Scrollable content container */}
<div className="relative h-full w-full gap-6 px-2 py-2">
{/* Top fade overlay */}
{/* Organization Link */}
<ValidatedOrganizationSlug
value={organizationLink}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
<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")}
/>
</div>
{/* 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>
{/* 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>
@@ -138,7 +138,13 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingBrowserView />
<OnboardingOrganizationBrowserView
avatar={null}
name={organizationName}
bio={organizationBio}
slug={organizationLink}
bannerUrl={null}
/>
</OnboardingLayout>
);
};
@@ -0,0 +1,239 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { showToast } from "@calcom/ui/components/toast";
import { useOnboardingStore, type Invite } from "../../store/onboarding-store";
type OrganizationCSVUploadModalProps = {
isOpen: boolean;
onClose: () => void;
};
export const OrganizationCSVUploadModal = ({ isOpen, onClose }: OrganizationCSVUploadModalProps) => {
const { t } = useLocale();
const router = useRouter();
const store = useOnboardingStore();
const { setInvites, teams } = store;
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.name.endsWith(".csv")) {
showToast(t("please_upload_csv_file"), "error");
return;
}
setSelectedFile(file);
}
};
const handleDownloadTemplate = () => {
const headers = ["email", "team", "role"];
const exampleRow = ["example@email.com", "team-name", "MEMBER"];
const csvContent = [headers.join(","), exampleRow.join(",")].join("\n");
const blob = new Blob([csvContent], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "organization_invite_template.csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showToast(t("template_downloaded"), "success");
};
const handleUpload = async () => {
if (!selectedFile) {
showToast(t("please_select_file"), "error");
return;
}
setIsUploading(true);
try {
const text = await selectedFile.text();
const lines = text.split("\n").filter((line) => line.trim());
if (lines.length < 2) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const emailIndex = headers.indexOf("email");
const teamIndex = headers.indexOf("team");
const roleIndex = headers.indexOf("role");
if (emailIndex === -1) {
showToast(t("csv_missing_email_column"), "error");
setIsUploading(false);
return;
}
const filteredTeams = teams.filter((team) => team.name && team.name.trim().length > 0);
const teamNames = filteredTeams.map((team) => team.name.toLowerCase());
const parsedInvites = lines
.slice(1)
.map((line) => {
const values = line.split(",").map((v) => v.trim());
const email = values[emailIndex]?.trim();
const team = teamIndex !== -1 ? values[teamIndex]?.trim() : "";
const role =
roleIndex !== -1 ? (values[roleIndex]?.trim().toUpperCase() as "MEMBER" | "ADMIN") : "MEMBER";
if (!email || email.length === 0) {
return null;
}
if (teamIndex !== -1 && team && !teamNames.includes(team.toLowerCase())) {
return null;
}
return {
email,
team: team || (filteredTeams.length > 0 ? filteredTeams[0].name.toLowerCase() : ""),
role: role === "ADMIN" ? "ADMIN" : "MEMBER",
};
})
.filter((invite): invite is Invite => invite !== null);
if (parsedInvites.length === 0) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
setInvites(parsedInvites);
showToast(t("csv_uploaded_successfully", { count: parsedInvites.length }), "success");
onClose();
router.push("/onboarding/organization/invite/email");
} catch (error) {
console.error("Error uploading CSV:", error);
showToast(t("error_uploading_csv"), "error");
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<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>
<div className="relative mx-auto flex items-center justify-center" style={{ width: 320, height: 180 }}>
<div
className="from-default to-muted border-subtle flex items-center justify-center rounded-2xl border bg-gradient-to-br p-8 shadow-sm"
style={{ width: 200, height: 150 }}>
{selectedFile ? (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon name="file-text" className="text-emphasis" style={{ width: 32, height: 32 }} />
</div>
<div className="flex flex-col items-center gap-1">
<p className="text-emphasis text-sm font-medium">{selectedFile.name}</p>
<p className="text-subtle text-xs">{(selectedFile.size / 1024).toFixed(2)} KB</p>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon name="upload" className="text-emphasis opacity-70" style={{ width: 32, height: 32 }} />
</div>
<p className="text-subtle text-center text-sm">{t("upload_csv_subtitle")}</p>
</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("upload_csv_file")}</h2>
<p className="text-default text-sm leading-normal">{t("upload_csv_description")}</p>
</div>
<div className="mb-2 flex flex-col gap-3">
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
<Icon name="download" className="text-emphasis" style={{ width: 16, height: 16 }} />
</div>
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("need_template")}</p>
<p className="text-subtle text-xs">{t("download_csv_template_description")}</p>
</div>
<Button color="secondary" StartIcon="download" size="sm" onClick={handleDownloadTemplate}>
{t("download_template")}
</Button>
</div>
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
<Icon name="upload" className="text-emphasis" style={{ width: 16, height: 16 }} />
</div>
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("upload_your_file")}</p>
<p className="text-subtle text-xs">
{selectedFile ? selectedFile.name : t("upload_csv_description")}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
/>
<Button
color="secondary"
StartIcon={selectedFile ? "file-text" : "upload"}
size="sm"
onClick={() => fileInputRef.current?.click()}>
{t("choose_file")}
</Button>
</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" onClick={handleClose} disabled={isUploading}>
{t("cancel")}
</Button>
<Button
color="primary"
onClick={handleUpload}
disabled={!selectedFile || isUploading}
loading={isUploading}>
{t("upload")}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,189 @@
"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 { useFlags } from "@calcom/features/flags/hooks";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Form } from "@calcom/ui/components/form";
import { EmailInviteForm } from "../../../components/EmailInviteForm";
import { InviteOptions } from "../../../components/InviteOptions";
import { OnboardingCard } from "../../../components/OnboardingCard";
import { OnboardingLayout } from "../../../components/OnboardingLayout";
import { RoleSelector } from "../../../components/RoleSelector";
import { OnboardingOrganizationBrowserView } from "../../../components/onboarding-organization-browser-view";
import { useSubmitOnboarding } from "../../../hooks/useSubmitOnboarding";
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
import { OrganizationCSVUploadModal } from "../csv-upload-modal";
type OrganizationInviteEmailViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
team: string;
role: "MEMBER" | "ADMIN";
}[];
};
export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEmailViewProps) => {
const router = useRouter();
const { t } = useLocale();
const flags = useFlags();
const store = useOnboardingStore();
const usersEmailDomain = userEmail.split("@")[1];
const {
invites: storedInvites,
inviteRole,
setInvites,
setInviteRole,
organizationDetails,
organizationBrand,
} = store;
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
const googleWorkspaceEnabled = flags["google-workspace-directory"];
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: appendField,
remove,
} = useFieldArray({
control: form.control,
name: "invites",
});
// Wrapper to ensure team is always provided when appending
const append = (value: { email: string; team?: string; role: InviteRole }) => {
appendField({
email: value.email,
team: value.team || "",
role: value.role,
});
};
const handleContinue = async (data: FormValues) => {
setInvites(data.invites);
await submitOnboarding(store, userEmail, data.invites);
};
const handleBack = () => {
router.push("/onboarding/organization/invite");
};
const handleGoogleWorkspaceConnect = () => {
console.log("Connect Google Workspace");
};
const handleUploadCSV = () => {
setIsCSVModalOpen(true);
};
const handleCopyInviteLink = () => {
console.log("Copy invite link - disabled");
};
const handleInviteViaEmail = () => {
router.push("/onboarding/organization/invite/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;
});
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 (
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
<div className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("onboarding_org_invite_title")}
subtitle={t("onboarding_org_invite_subtitle_email")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<Button color="minimal" className="rounded-[10px]" onClick={handleBack} disabled={isSubmitting}>
{t("back")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
</div>
}>
<div className="flex h-full w-full flex-col gap-4">
<Form form={form} handleSubmit={handleContinue} className="h-full w-full">
<div className="flex h-full w-full flex-col gap-4">
<EmailInviteForm
fields={fields}
append={append}
remove={remove}
defaultRole={inviteRole}
showTeamSelect
teams={teams}
emailPlaceholder={`dave@${usersEmailDomain}`}
/>
<RoleSelector
value={inviteRole}
showInfoBadge={true}
onValueChange={(value) => {
setInviteRole(value);
fields.forEach((_, index) => {
form.setValue(`invites.${index}.role`, value);
});
}}
/>
</div>
</Form>
</div>
</OnboardingCard>
</div>
<OnboardingOrganizationBrowserView
avatar={organizationBrand.logo}
name={organizationDetails.name}
bio={organizationDetails.bio}
slug={organizationDetails.link}
bannerUrl={organizationBrand.banner}
/>
<OrganizationCSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
</OnboardingLayout>
);
};
@@ -1,307 +1,105 @@
"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 { useFlags } from "@calcom/features/flags/hooks";
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 { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { InviteOptions } from "../../components/InviteOptions";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
import { useOnboardingStore } from "../../store/onboarding-store";
import { OrganizationCSVUploadModal } from "./csv-upload-modal";
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 flags = useFlags();
const store = useOnboardingStore();
const usersEmailDomain = userEmail.split("@")[1];
const { invites: storedInvites, inviteRole, setInvites, setInviteRole } = store;
const { setInvites, organizationDetails, organizationBrand } = store;
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
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 googleWorkspaceEnabled = flags["google-workspace-directory"];
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, invitesData);
};
const handleContinue = async (data?: FormValues) => {
if (data) {
await handleSubmitOnboarding(data.invites);
} else {
await handleSubmitOnboarding([]);
}
};
const handleSkip = async () => {
// Complete onboarding without invites
await handleSubmitOnboarding([]);
const handleGoogleWorkspaceConnect = () => {
console.log("Connect Google Workspace");
};
const handleInviteViaEmail = () => {
router.push("/onboarding/organization/invite?mode=email");
router.push("/onboarding/organization/invite/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;
});
const handleUploadCSV = () => {
setIsCSVModalOpen(true);
};
// 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 }))
: [];
const handleCopyInviteLink = () => {
console.log("Copy invite link - disabled");
};
const handleSkip = async () => {
setInvites([]);
await submitOnboarding(store, userEmail, []);
};
const handleInvite = async () => {
await submitOnboarding(store, userEmail, []);
};
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="grid w-full max-w-[1200px] grid-cols-1 gap-6 lg:grid-cols-[1fr_auto]">
{/* Left column - Main content */}
<div className="flex w-full 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">
<Label
className="text-emphasis mb-0 text-sm font-medium"
htmlFor="invites.0.email">
{t("email")}
</Label>
<Label
className="text-emphasis mb-0 text-sm font-medium"
htmlFor="invites.0.team">
{t("team")}
</Label>
</div>
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-0.5">
<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="mt-2 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>
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
<div className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("onboarding_org_invite_title")}
subtitle={t("onboarding_org_invite_subtitle_full")}
footer={
<div className="flex w-full items-center justify-between gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/organization/teams")}
disabled={isSubmitting}>
{t("back")}
</Button>
<div className="flex items-center gap-2">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}>
{t("ill_do_this_later")}
</Button>
</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>
{/* Right column - Browser view */}
<OnboardingBrowserView />
</div>
}>
<InviteOptions
onInviteViaEmail={handleInviteViaEmail}
onUploadCSV={handleUploadCSV}
onCopyInviteLink={handleCopyInviteLink}
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
isSubmitting={isSubmitting}
/>
</OnboardingCard>
</div>
</div>
<OnboardingOrganizationBrowserView
avatar={organizationBrand.logo}
name={organizationDetails.name}
bio={organizationDetails.bio}
slug={organizationDetails.link}
bannerUrl={organizationBrand.banner}
/>
<OrganizationCSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
</OnboardingLayout>
);
};
@@ -10,9 +10,9 @@ import { Button } from "@calcom/ui/components/button";
import { Form, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingTeamsBrowserView } from "../../components/onboarding-teams-browser-view";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationTeamsViewProps = {
@@ -26,7 +26,7 @@ type FormValues = {
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { teams: storedTeams, setTeams } = useOnboardingStore();
const { teams: storedTeams, setTeams, organizationBrand, organizationDetails } = useOnboardingStore();
const formSchema = z.object({
teams: z.array(
@@ -65,29 +65,38 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
});
return (
<OnboardingLayout userEmail={userEmail} currentStep={3}>
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={4}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_teams_title")}
subtitle={t("onboarding_org_teams_subtitle")}
footer={
<>
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("onboarding_skip_for_now")}
</Button>
<div className="flex w-full items-center justify-between gap-4">
<Button
type="submit"
form="teams-form"
color="primary"
type="button"
color="minimal"
className="rounded-[10px]"
disabled={!hasValidTeams}>
{t("continue")}
onClick={() => router.push("/onboarding/organization/brand")}>
{t("back")}
</Button>
</>
<div className="flex items-center gap-2">
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
form="teams-form"
color="primary"
className="rounded-[10px]"
disabled={!hasValidTeams}>
{t("continue")}
</Button>
</div>
</div>
}>
<Form id="teams-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="w-full ">
<div className="flex w-full flex-col gap-8 ">
<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>
@@ -136,7 +145,12 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingBrowserView />
<OnboardingTeamsBrowserView
teams={form.watch("teams")}
organizationLogo={organizationBrand.logo}
organizationName={organizationDetails.name}
organizationBanner={organizationBrand.banner}
/>
</OnboardingLayout>
);
};
@@ -36,7 +36,7 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
};
return (
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={2}>
{/* Left column - Main content */}
<OnboardingCard
title={t("connect_your_calendar")}
@@ -111,7 +111,7 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={1}>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={2}>
{/* Left column - Main content */}
<OnboardingCard
title={t("add_your_details")}
@@ -139,7 +139,7 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
<form
id="personal-settings-form"
onSubmit={handleContinue}
className="flex w-full flex-col gap-6 px-5">
className="flex w-full flex-col gap-6 px-1">
{/* Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("profile_picture")}</Label>
@@ -84,7 +84,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
{/* Left column - Main content */}
<OnboardingCard
title={t("create_your_team")}
@@ -106,7 +106,7 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
</Button>
</div>
}>
<div className="flex w-full flex-col gap-4 px-5">
<div className="flex w-full flex-col gap-4">
{/* Team Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
@@ -7,13 +7,13 @@ import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { InfoBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Form, Label, TextField, ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Form } from "@calcom/ui/components/form";
import { EmailInviteForm } from "../../../components/EmailInviteForm";
import { OnboardingCard } from "../../../components/OnboardingCard";
import { OnboardingLayout } from "../../../components/OnboardingLayout";
import { RoleSelector } from "../../../components/RoleSelector";
import { OnboardingInviteBrowserView } from "../../../components/onboarding-invite-browser-view";
import { useCreateTeam } from "../../../hooks/useCreateTeam";
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
@@ -85,7 +85,7 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
});
return (
<OnboardingLayout userEmail={userEmail} currentStep={3}>
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
<OnboardingCard
@@ -107,74 +107,27 @@ export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) =>
</Button>
</div>
}>
<div className="flex w-full flex-col gap-4 px-5">
<div className="flex w-full flex-col gap-4 px-1">
<Form form={form} handleSubmit={handleContinue} className="w-full">
<div className="flex w-full flex-col gap-4">
{/* Email inputs */}
<div className="flex flex-col gap-2">
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
<EmailInviteForm
fields={fields}
append={append}
remove={remove}
defaultRole={inviteRole}
emailPlaceholder="rick@cal.com"
/>
<div className="scroll-bar flex max-h-72 flex-col gap-2 overflow-y-auto">
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-0.5">
<div className="flex-1">
<TextField
labelSrOnly
{...form.register(`invites.${index}.email`)}
placeholder={`rick@cal.com`}
type="email"
size="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({ email: "", role: inviteRole })}>
{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) => {
if (value) {
setInviteRole(value as InviteRole);
// Update all invites with the new role
fields.forEach((_, index) => {
form.setValue(`invites.${index}.role`, value as InviteRole);
});
}
}}
options={[
{ value: "MEMBER", label: t("members") },
{ value: "ADMIN", label: t("onboarding_admins") },
]}
/>
<InfoBadge content={t("onboarding_modify_roles_later")} />
</div>
</div>
<RoleSelector
value={inviteRole}
onValueChange={(value) => {
setInviteRole(value);
fields.forEach((_, index) => {
form.setValue(`invites.${index}.role`, value);
});
}}
showInfoBadge
/>
</div>
</Form>
</div>
@@ -6,8 +6,8 @@ import React from "react";
import { useFlags } from "@calcom/features/flags/hooks";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import { InviteOptions } from "../../components/InviteOptions";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingInviteBrowserView } from "../../components/onboarding-invite-browser-view";
@@ -63,7 +63,7 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
return (
<>
<OnboardingLayout userEmail={userEmail} currentStep={3}>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
<OnboardingCard
@@ -97,63 +97,13 @@ export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
</div>
</div>
}>
<div className="flex w-full flex-col gap-6 px-5">
{/* Google Workspace Connect - Only show if feature flag is enabled */}
{googleWorkspaceEnabled && (
<>
<Button
color="secondary"
className="h-8 w-full rounded-[10px]"
onClick={handleGoogleWorkspaceConnect}
disabled={isSubmitting}>
{t("connect_google_workspace")}
</Button>
{/* Divider with "or" */}
<div className="flex w-full items-center gap-2">
<div className="border-subtle h-px flex-1 border-t" />
<span className="text-subtle text-sm font-medium">{t("or")}</span>
<div className="border-subtle h-px flex-1 border-t" />
</div>
</>
)}
{/* Invite options */}
<div className="flex w-full flex-col gap-4">
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={handleInviteViaEmail}
disabled={isSubmitting}>
<div className="flex items-center gap-1">
<Icon name="mail" className="h-4 w-4" />
<span>{t("invite_via_email")}</span>
</div>
</Button>
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={handleUploadCSV}
disabled={isSubmitting}>
<div className="flex items-center gap-1">
<Icon name="upload" className="h-4 w-4" />
<span>{t("upload_csv_file")}</span>
</div>
</Button>
<Button
color="secondary"
className="h-8 w-full justify-center rounded-[10px]"
onClick={handleCopyInviteLink}
disabled>
<div className="flex items-center gap-1">
<Icon name="link" className="h-4 w-4" />
<span>{t("copy_invite_link")}</span>
</div>
</Button>
</div>
</div>
<InviteOptions
onInviteViaEmail={handleInviteViaEmail}
onUploadCSV={handleUploadCSV}
onCopyInviteLink={handleCopyInviteLink}
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
isSubmitting={isSubmitting}
/>
</OnboardingCard>
</div>
@@ -3998,7 +3998,7 @@
"onboarding_browser_view_in_person_description": "Meet in person",
"onboarding_browser_view_ask_question": "Ask a question",
"onboarding_browser_view_ask_question_description": "Ask a question",
"onboarding_browser_view_default_bio": "Find a time that suits you",
"onboarding_browser_view_default_bio": "Add your bio here...",
"book_now": "Book now",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { sessionStorage } from "@calcom/lib/webstorage";
const STORAGE_KEY = "showWelcomeToCalcomModal";
const ORG_MODAL_STORAGE_KEY = "showNewOrgModal";
export function useWelcomeToCalcomModal() {
const [welcomeToCalcomModal, setWelcomeToCalcomModal] = useQueryState(
@@ -14,6 +15,13 @@ export function useWelcomeToCalcomModal() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Don't show personal modal if org modal flag is set (org modal takes precedence)
const hasOrgModalFlag = sessionStorage.getItem(ORG_MODAL_STORAGE_KEY) === "true";
if (hasOrgModalFlag) {
setIsOpen(false);
return;
}
if (welcomeToCalcomModal) {
setIsOpen(true);
return;