chore: Update personal and team onboarding flows (remove profile/video steps, add settings, improve team invites) (#24947)

## What does this PR do?

- Redesigns and streamlines the onboarding flow for personal, team, and organization accounts
- Consolidates shared components and improves code organization
- Adds browser preview for better user experience during onboarding
- Simplifies the personal onboarding flow by removing the video integration step
- Enhances team onboarding with CSV upload functionality

## Visual Demo (For contributors especially)

#### Image Demo:
- The PR adds a new browser preview component that shows users how their profile/team will look during onboarding
- Redesigned UI with a more consistent layout across all onboarding steps
- Improved mobile responsiveness with better component organization

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

1. Test the complete onboarding flow for personal accounts:
   - Start at `/onboarding/getting-started`
   - Proceed through personal details and calendar setup
   - Verify the flow completes successfully

2. Test the team onboarding flow:
   - Start at `/onboarding/getting-started` and select team
   - Complete team details
   - Test the invite options including CSV upload
   - Verify team creation works correctly

3. Test the organization onboarding flow:
   - Start at `/onboarding/getting-started` and select organization
   - Complete organization details and branding
   - Test member invitations
   - Verify organization creation works correctly

4. Verify browser preview functionality:
   - Check that the preview updates in real-time as you enter information
   - Confirm it displays correctly on different screen sizes

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
This commit is contained in:
sean-brydon
2025-11-07 08:54:55 +00:00
committed by GitHub
parent 52e27bab53
commit 02b1393ff4
24 changed files with 1312 additions and 1264 deletions
@@ -26,11 +26,9 @@ const ServerPage = async () => {
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} />;
return <OnboardingView userEmail={userEmail} />;
};
export default ServerPage;
@@ -7,7 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { PersonalProfileView } from "~/onboarding/personal/profile/personal-profile-view";
import { PersonalSettingsView } from "~/onboarding/personal/settings/personal-settings-view";
export const generateMetadata = async () => {
return await _generateMetadata(
@@ -28,7 +28,7 @@ const ServerPage = async () => {
const userEmail = session.user.email || "";
return <PersonalProfileView userEmail={userEmail} />;
return <PersonalSettingsView userEmail={userEmail} />;
};
export default ServerPage;
@@ -1,34 +0,0 @@
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 { PersonalVideoView } from "~/onboarding/personal/video/personal-video-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("connect_video")}`,
() => "",
true,
undefined,
"/onboarding/personal/video"
);
};
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 <PersonalVideoView userEmail={userEmail} />;
};
export default ServerPage;
@@ -1,34 +0,0 @@
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 { TeamBrandView } from "~/onboarding/teams/brand/team-brand-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("team_brand")}`,
() => "",
true,
undefined,
"/onboarding/teams/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 <TeamBrandView userEmail={userEmail} />;
};
export default ServerPage;
@@ -0,0 +1,92 @@
"use client";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, type IconName } from "@calcom/ui/components/icon";
export const OnboardingCalendarBrowserView = () => {
const { t } = useLocale();
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
const calendarIntegrations: Array<{
name: string;
description: string;
icon: IconName;
}> = [
{
name: t("google_calendar"),
description: t("onboarding_calendar_browser_view_google_description"),
icon: "calendar",
},
{
name: t("outlook_calendar"),
description: t("onboarding_calendar_browser_view_outlook_description"),
icon: "mail",
},
{
name: t("apple_calendar"),
description: t("onboarding_calendar_browser_view_apple_description"),
icon: "calendar-days",
},
];
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}/settings/calendars</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">
{/* Header */}
<div className="border-subtle flex flex-col gap-4 border-b p-4">
<div className="flex flex-col items-start gap-2">
<h2 className="text-emphasis text-xl font-semibold leading-tight">
{t("connect_your_calendar")}
</h2>
<p className="text-subtle text-sm leading-normal">
{t("onboarding_calendar_browser_view_subtitle")}
</p>
</div>
</div>
{/* Calendar Integrations List */}
<div className="flex flex-col overflow-y-auto">
{calendarIntegrations.map((integration, index) => (
<div key={integration.name} className="opacity-30">
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center justify-between gap-3 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="bg-muted flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
<Icon name={integration.icon} className="text-emphasis h-5 w-5" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-default text-sm font-semibold leading-none">{integration.name}</h3>
<p className="text-subtle text-sm font-medium leading-tight">
{integration.description}
</p>
</div>
</div>
<div className="bg-emphasis flex h-6 items-center justify-center rounded-md px-2">
<span className="text-emphasis text-xs font-medium leading-none">{t("connected")}</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};
@@ -1,6 +1,8 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -10,20 +12,43 @@ import { Button } from "@calcom/ui/components/button";
import { type IconName } from "@calcom/ui/components/icon";
import { RadioAreaGroup } from "@calcom/ui/components/radio";
import { OnboardingCard } from "../components/OnboardingCard";
import { OnboardingLayout } from "../components/OnboardingLayout";
import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt";
import { PlanIcon } from "../components/plan-icon";
import { useOnboardingStore, type PlanType } from "../store/onboarding-store";
type OnboardingViewProps = {
userName: string;
userEmail: string;
};
export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => {
export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { selectedPlan, setSelectedPlan } = useOnboardingStore();
const previousPlanRef = useRef<PlanType | null>(null);
// Plan order mapping for determining direction
const planOrder: Record<PlanType, number> = {
personal: 0,
team: 1,
organization: 2,
};
// Calculate animation direction synchronously
const getDirection = (): "up" | "down" => {
if (!selectedPlan || !previousPlanRef.current) return "down";
const previousOrder = planOrder[previousPlanRef.current];
const currentOrder = planOrder[selectedPlan];
return currentOrder > previousOrder ? "down" : "up";
};
const direction = getDirection();
// Update previous plan ref after render
useEffect(() => {
previousPlanRef.current = selectedPlan;
}, [selectedPlan]);
const handleContinue = () => {
if (selectedPlan === "organization") {
@@ -37,7 +62,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
const planIconByType: Record<PlanType, IconName> = {
personal: "user",
team: "users",
team: "user",
organization: "users",
};
@@ -56,7 +81,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
badge: t("onboarding_plan_team_badge"),
description: t("onboarding_plan_team_description"),
icon: planIconByType.team,
variant: "single" as const,
variant: "team" as const,
},
{
id: "organization" as PlanType,
@@ -76,26 +101,26 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
return true;
});
const selectedPlanData = plans.find((plan) => plan.id === selectedPlan);
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={1}>
<div className="flex w-full flex-col gap-6">
{/* Left column - Main content */}
<OnboardingCard
title="Select plan"
subtitle={t("onboarding_welcome_question")}
footer={
<div className="flex w-full justify-end gap-2">
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
</div>
}>
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="bg-muted border-muted relative flex min-h-0 w-full flex-col overflow-hidden 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_welcome_message", { userName })}
</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("onboarding_welcome_question")}
</p>
</div>
</div>
{/* Plan options */}
<RadioAreaGroup.Group
value={selectedPlan ?? undefined}
@@ -114,46 +139,45 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
"pr-12 [&>button]:left-auto [&>button]:right-6 [&>button]:mt-0 [&>button]:transform"
)}
classNames={{
container: "flex w-full items-center gap-4 p-4 pl-5 pr-12 md:p-5 md:pr-14",
container: "flex w-full items-center gap-3 p-5 pr-12",
}}>
<div className="flex w-full items-center gap-4">
<PlanIcon icon={plan.icon} variant={plan.variant} />
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-emphasis text-base font-semibold leading-5">{plan.title}</p>
<Badge
variant="gray"
size="md"
className="hidden h-4 rounded-md px-1 py-1 md:flex md:items-center">
<span className="text-emphasis text-xs font-medium leading-3">
{plan.badge}
</span>
</Badge>
</div>
<div className="flex w-full flex-col gap-1">
<div className="flex flex-wrap items-center gap-1">
<p className="text-emphasis text-sm font-semibold leading-4">{plan.title}</p>
<Badge
variant="gray"
size="md"
className="h-4 w-fit rounded-md px-1 py-1 md:hidden">
className="hidden h-4 rounded-md px-1 py-1 md:flex md:items-center">
<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>
<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-medium leading-[1.25]">
{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}>
{t("continue")}
</Button>
</div>
</div>
</div>
</OnboardingCard>
{/* Right column - Icon display */}
<div className="bg-muted border-subtle hidden h-full w-full rounded-l-2xl border-b border-l border-t xl:flex xl:items-center xl:justify-center">
<AnimatePresence mode="wait">
{selectedPlanData && (
<PlanIcon
key={selectedPlan}
icon={selectedPlanData.icon}
variant={selectedPlanData.variant}
animationDirection={direction}
/>
)}
</AnimatePresence>
</div>
</OnboardingLayout>
</>
@@ -0,0 +1,80 @@
import { useRouter } from "next/navigation";
import { setShowWelcomeToCalcomModalFlag } from "@calcom/features/shell/hooks/useWelcomeToCalcomModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
const DEFAULT_EVENT_TYPES = [
{
title: "15min_meeting",
slug: "15min",
length: 15,
},
{
title: "30min_meeting",
slug: "30min",
length: 30,
},
{
title: "secret_meeting",
slug: "secret",
length: 15,
hidden: true,
},
];
export const useSubmitPersonalOnboarding = () => {
const router = useRouter();
const { t } = useLocale();
const telemetry = useTelemetry();
const utils = trpc.useUtils();
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation();
const mutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async () => {
try {
// Create default event types if user has none
if (eventTypes?.length === 0) {
await Promise.all(
DEFAULT_EVENT_TYPES.map(async (event) => {
return createEventType.mutateAsync({
title: t(event.title),
slug: event.slug,
length: event.length,
hidden: event.hidden,
});
})
);
}
} catch (error) {
console.error(error);
}
await utils.viewer.me.get.refetch();
// Set flag to show welcome modal after redirect
setShowWelcomeToCalcomModalFlag();
router.push("/event-types?welcomeToCalcomModal=true");
},
onError: (error) => {
showToast(t("something_went_wrong"), "error");
console.error(error);
},
});
const submitPersonalOnboarding = () => {
telemetry.event(telemetryEventTypes.onboardingFinished);
mutation.mutate({
completedOnboarding: true,
});
};
return {
submitPersonalOnboarding,
isSubmitting: mutation.isPending,
};
};
@@ -8,8 +8,9 @@ import { HexColorPicker } from "react-colorful";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationBrandViewProps = {
@@ -118,6 +119,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
return (
<OnboardingLayout userEmail={userEmail} currentStep={2}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_brand_title")}
subtitle={t("onboarding_org_brand_subtitle")}
@@ -185,7 +187,9 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_logo_size_hint")}
</p>
</div>
{/* Banner Upload */}
@@ -225,69 +229,14 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
</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>
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingBrowserView />
</OnboardingLayout>
);
};
@@ -7,8 +7,9 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { useOnboardingStore } from "../../store/onboarding-store";
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
@@ -77,6 +78,7 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
return (
<OnboardingLayout userEmail={userEmail} currentStep={2}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_details_title")}
subtitle={t("onboarding_org_details_subtitle")}
@@ -134,6 +136,9 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
</div>
</div>
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingBrowserView />
</OnboardingLayout>
);
};
@@ -12,6 +12,7 @@ import { Form, Label, TextField, Select, ToggleGroup } from "@calcom/ui/componen
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
import { useOnboardingStore } from "../../store/onboarding-store";
@@ -117,135 +118,55 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
{/* 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 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>
</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">
{/* 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">
{/* 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>
<Button color="secondary" className="w-full justify-center" StartIcon="mail">
{t("onboarding_connect_google_workspace")}
</Button>
{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>
))}
<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>
{/* Add button */}
<div className="flex items-center gap-2">
<Button
type="button"
color="secondary"
size="sm"
StartIcon="plus"
className="mt-2 w-fit"
onClick={() => append({ email: "", team: "", role: "MEMBER" })}>
{t("add")}
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>
@@ -257,42 +178,128 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
value={inviteRole}
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
options={[
{ value: "MEMBER", label: t("members") },
{ 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>
</Form>
)}
) : (
// 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>
</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>
{/* 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>
{/* 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>
{/* Right column - Browser view */}
<OnboardingBrowserView />
</div>
</div>
</div>
@@ -1,15 +1,14 @@
"use client";
import { useRouter } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { OnboardingCalendarBrowserView } from "../../components/onboarding-calendar-browser-view";
import { useSubmitPersonalOnboarding } from "../../hooks/useSubmitPersonalOnboarding";
import { InstallableAppCard } from "../_components/InstallableAppCard";
import { OnboardingCard } from "../_components/OnboardingCard";
import { OnboardingLayout } from "../_components/OnboardingLayout";
import { SkipButton } from "../_components/SkipButton";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { useAppInstallation } from "../_components/useAppInstallation";
type PersonalCalendarViewProps = {
@@ -17,9 +16,9 @@ type PersonalCalendarViewProps = {
};
export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
const { submitPersonalOnboarding, isSubmitting } = useSubmitPersonalOnboarding();
const queryIntegrations = trpc.viewer.apps.integrations.useQuery({
variant: "calendar",
@@ -29,23 +28,38 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
});
const handleContinue = () => {
router.push("/onboarding/personal/video");
submitPersonalOnboarding();
};
const handleSkip = () => {
router.push("/onboarding/personal/video");
submitPersonalOnboarding();
};
return (
<OnboardingLayout userEmail={userEmail} currentStep={3}>
<OnboardingLayout userEmail={userEmail} currentStep={2}>
{/* Left column - Main content */}
<OnboardingCard
title={t("connect_your_calendar")}
subtitle={t("connect_calendar_to_prevent_conflicts")}
isLoading={queryIntegrations.isPending}
footer={
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
<div className="flex w-full items-center justify-end gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={queryIntegrations.isPending || isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={isSubmitting}
disabled={queryIntegrations.isPending || isSubmitting}>
{t("continue")}
</Button>
</div>
}>
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
{queryIntegrations.data?.items?.map((app) => (
@@ -60,7 +74,8 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
</div>
</OnboardingCard>
<SkipButton onClick={handleSkip} />
{/* Right column - Browser view */}
<OnboardingCalendarBrowserView />
</OnboardingLayout>
);
};
@@ -1,182 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import { UserAvatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Editor } from "@calcom/ui/components/editor";
import { Label } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { showToast } from "@calcom/ui/components/toast";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
import { OnboardingCard } from "../_components/OnboardingCard";
import { OnboardingLayout } from "../_components/OnboardingLayout";
import { useOnboardingStore } from "../../store/onboarding-store";
type PersonalProfileViewProps = {
userEmail: string;
};
type FormData = {
bio: string;
};
export const PersonalProfileView = ({ userEmail }: PersonalProfileViewProps) => {
const { data: user } = trpc.viewer.me.get.useQuery();
const router = useRouter();
const { t } = useLocale();
const { personalDetails, setPersonalDetails } = useOnboardingStore();
const avatarRef = useRef<HTMLInputElement>(null);
const [imageSrc, setImageSrc] = useState<string>("");
const [firstRender, setFirstRender] = useState(true);
// Update imageSrc when user loads
useEffect(() => {
if (user) {
setImageSrc(personalDetails.avatar || user.avatar || "");
}
}, [user, personalDetails.avatar]);
const { setValue, handleSubmit, getValues } = useForm<FormData>({
defaultValues: { bio: personalDetails.bio || user?.bio || "" },
});
const utils = trpc.useUtils();
// Avatar mutation
const avatarMutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async (data) => {
showToast(t("your_user_profile_updated_successfully"), "success");
setImageSrc(data.avatarUrl ?? "");
setPersonalDetails({ avatar: data.avatarUrl ?? null });
},
onError: () => {
showToast(t("problem_saving_user_profile"), "error");
},
});
// Profile mutation
const mutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async () => {
await utils.viewer.me.invalidate();
},
});
const onSubmit = handleSubmit(async (data: { bio: string }) => {
const { bio } = data;
// Save to store
setPersonalDetails({
bio,
});
// Save to backend
await mutation.mutateAsync({
bio,
});
router.push("/onboarding/personal/calendar");
});
async function updateProfileHandler(newAvatar: string) {
avatarMutation.mutate({
avatarUrl: newAvatar,
});
}
if (!user) {
return null;
}
return (
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingCard
title={t("complete_your_profile")}
subtitle={t("personal_profile_subtitle")}
footer={
<Button
color="primary"
className="rounded-[10px]"
onClick={onSubmit}
loading={mutation.isPending}
disabled={mutation.isPending}>
{t("continue")}
</Button>
}>
{/* 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">
<form onSubmit={onSubmit} className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-6 rounded-xl">
{/* Avatar */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("profile_photo")}</Label>
<div className="flex flex-row items-center justify-start gap-4 rtl:justify-end">
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
<input
ref={avatarRef}
type="hidden"
name="avatar"
id="avatar"
defaultValue={imageSrc}
/>
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("add_profile_photo")}
handleAvatarChange={(newAvatar) => {
if (avatarRef.current) {
avatarRef.current.value = newAvatar;
}
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
const ev2 = new Event("input", { bubbles: true });
avatarRef.current?.dispatchEvent(ev2);
updateProfileHandler(newAvatar);
}}
imageSrc={imageSrc}
/>
</div>
</div>
{/* Username */}
<div className="flex w-full flex-col gap-1.5">
<UsernameAvailabilityField />
</div>
{/* Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-1 text-sm font-medium leading-4">{t("about")}</Label>
<Editor
getText={() => md.render(getValues("bio") || user?.bio || "")}
setText={(value: string) => setValue("bio", turndown(value))}
excludedToolbarItems={["blockType"]}
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
<p className="text-subtle text-xs font-normal leading-3">
{t("few_sentences_about_yourself")}
</p>
</div>
</div>
</form>
</div>
</div>
</div>
</OnboardingCard>
</OnboardingLayout>
);
};
@@ -2,23 +2,26 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useEffect, useRef, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { UserAvatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField } from "@calcom/ui/components/form";
import { Label, TextArea, TextField } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { showToast } from "@calcom/ui/components/toast";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
import { useOnboardingStore } from "../../store/onboarding-store";
import { OnboardingCard } from "../_components/OnboardingCard";
import { OnboardingLayout } from "../_components/OnboardingLayout";
type PersonalSettingsViewProps = {
userEmail: string;
@@ -28,17 +31,17 @@ type PersonalSettingsViewProps = {
export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { data: user } = trpc.viewer.me.get.useQuery();
const { personalDetails, setPersonalDetails } = useOnboardingStore();
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
const [name, setName] = useState("");
const avatarRef = useRef<HTMLInputElement>(null);
const [imageSrc, setImageSrc] = useState<string>("");
useEffect(() => {
setName(personalDetails.name || userName || "");
if (personalDetails.timezone) {
setSelectedTimeZone(personalDetails.timezone);
if (user) {
setImageSrc(personalDetails.avatar || user.avatar || "");
}
}, [personalDetails, userName, setSelectedTimeZone]);
}, [personalDetails.avatar, user]);
const formSchema = z.object({
name: z
@@ -47,98 +50,169 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
}),
bio: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: personalDetails.name || userName || "",
bio: personalDetails.bio || "",
},
});
const utils = trpc.useUtils();
// Avatar mutation
const avatarMutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async (data) => {
showToast(t("your_user_profile_updated_successfully"), "success");
setImageSrc(data.avatarUrl ?? "");
setPersonalDetails({ avatar: data.avatarUrl ?? null });
},
onError: () => {
showToast(t("problem_saving_user_profile"), "error");
},
});
// Profile mutation
const mutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async () => {
await utils.viewer.me.invalidate();
},
});
async function updateProfileHandler(newAvatar: string) {
avatarMutation.mutate({
avatarUrl: newAvatar,
});
}
const handleContinue = form.handleSubmit(async (data) => {
// Save to store
setPersonalDetails({
name: data.name,
timezone: selectedTimeZone,
bio: data.bio || "",
});
// Save to backend
await mutation.mutateAsync({
name: data.name,
timeZone: selectedTimeZone,
bio: data.bio || "",
});
router.push("/onboarding/personal/profile");
router.push("/onboarding/personal/calendar");
});
if (!user) {
return null;
}
return (
<>
<OnboardingContinuationPrompt />
<OnboardingLayout userEmail={userEmail} currentStep={1}>
{/* Left column - Main content */}
<OnboardingCard
title={t("welcome_to_cal_com")}
subtitle={t("personal_settings_subtitle")}
title={t("add_your_details")}
subtitle={t("personal_details_subtitle")}
footer={
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={mutation.isPending}
disabled={mutation.isPending || !form.formState.isValid}>
{t("continue")}
</Button>
}>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start">
<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">
{/* Name */}
<div className="flex w-full flex-col gap-1.5">
<TextField
label={t("full_name")}
{...form.register("name")}
value={name}
onChange={(e) => {
setName(e.target.value);
form.setValue("name", e.target.value);
}}
placeholder="John Doe"
/>
{form.formState.errors.name && (
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
)}
</div>
{/* Timezone */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">
{t("timezone")}
</Label>
<TimezoneSelect
value={selectedTimeZone}
onChange={({ value }) => setSelectedTimeZone(value)}
/>
<p className="text-subtle text-xs font-normal leading-3">
{t("current_time")}{" "}
{dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
</p>
</div>
</div>
</div>
</div>
<div className="flex w-full items-center justify-end gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/getting-started")}>
{t("back")}
</Button>
<Button
type="submit"
form="personal-settings-form"
color="primary"
className="rounded-[10px]"
loading={mutation.isPending}
disabled={mutation.isPending || !form.formState.isValid}>
{t("continue")}
</Button>
</div>
</div>
}>
<FormProvider {...form}>
<form
id="personal-settings-form"
onSubmit={handleContinue}
className="flex w-full flex-col gap-6 px-5">
{/* 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>
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
{user && (
<div className="relative shrink-0">
<UserAvatar size="lg" user={user} previewSrc={imageSrc} />
</div>
)}
<input ref={avatarRef} type="hidden" name="avatar" id="avatar" defaultValue={imageSrc} />
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload")}
handleAvatarChange={(newAvatar) => {
if (avatarRef.current) {
avatarRef.current.value = newAvatar;
}
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
const ev2 = new Event("input", { bubbles: true });
avatarRef.current?.dispatchEvent(ev2);
updateProfileHandler(newAvatar);
}}
imageSrc={imageSrc}
/>
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</div>
{/* Name */}
<div className="flex w-full flex-col gap-1.5">
<TextField label={t("your_name")} {...form.register("name")} placeholder="John Doe" />
{form.formState.errors.name && (
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
)}
</div>
{/* Username */}
<div className="flex w-full flex-col gap-1.5">
<UsernameAvailabilityField
onSuccessMutation={async () => {
// Refetch user to get updated username and save to store
const updatedUser = await utils.viewer.me.get.fetch();
if (updatedUser?.username) {
setPersonalDetails({ username: updatedUser.username });
}
}}
/>
</div>
{/* Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("bio")}</Label>
<TextArea {...form.register("bio")} className="min-h-[108px]" />
{form.formState.errors.bio && (
<p className="text-error text-sm">{form.formState.errors.bio.message}</p>
)}
</div>
</form>
</FormProvider>
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingBrowserView
avatar={imageSrc || personalDetails.avatar || user.avatar}
name={form.watch("name") || personalDetails.name || user.name || undefined}
bio={form.watch("bio") || personalDetails.bio || undefined}
username={personalDetails.username || user.username || undefined}
/>
</OnboardingLayout>
</>
);
@@ -1,162 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
import { InstallableAppCard } from "../_components/InstallableAppCard";
import { OnboardingCard } from "../_components/OnboardingCard";
import { OnboardingLayout } from "../_components/OnboardingLayout";
import { SkipButton } from "../_components/SkipButton";
import { useAppInstallation } from "../_components/useAppInstallation";
type PersonalVideoViewProps = {
userEmail: string;
};
const DEFAULT_EVENT_TYPES = [
{
title: "15min_meeting",
slug: "15min",
length: 15,
},
{
title: "30min_meeting",
slug: "30min",
length: 30,
},
{
title: "secret_meeting",
slug: "secret",
length: 15,
hidden: true,
},
];
export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => {
const { data: user } = trpc.viewer.me.get.useQuery();
const router = useRouter();
const { t } = useLocale();
const telemetry = useTelemetry();
const utils = trpc.useUtils();
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
variant: "conferencing",
onlyInstalled: false,
sortByMostPopular: true,
sortByInstalledFirst: true,
});
const setDefaultConferencingApp = trpc.viewer.apps.setDefaultConferencingApp.useMutation({
onSuccess: async () => {
await utils.viewer.me.invalidate();
},
onError: (error) => {
showToast(t("something_went_wrong"), "error");
console.error(error);
},
});
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation();
const mutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async () => {
try {
// Create default event types if user has none
if (eventTypes?.length === 0) {
await Promise.all(
DEFAULT_EVENT_TYPES.map(async (event) => {
return createEventType.mutateAsync({
title: t(event.title),
slug: event.slug,
length: event.length,
hidden: event.hidden,
});
})
);
}
} catch (error) {
console.error(error);
}
await utils.viewer.me.get.refetch();
router.push("/event-types");
},
});
const handleContinue = async () => {
telemetry.event(telemetryEventTypes.onboardingFinished);
mutation.mutate({
completedOnboarding: true,
});
};
const handleSkip = async () => {
telemetry.event(telemetryEventTypes.onboardingFinished);
mutation.mutate({
completedOnboarding: true,
});
};
if (!user) {
return null;
}
// Check if user has a default conferencing app
const result = userMetadata.safeParse(user.metadata);
const metadata = result?.success ? result.data : null;
const hasDefaultConferencingApp = !!metadata?.defaultConferencingApp?.appSlug;
return (
<OnboardingLayout userEmail={userEmail} currentStep={4}>
<OnboardingCard
title={t("connect_video_app")}
subtitle={t("video_app_connection_subtitle")}
isLoading={isPending}
footer={
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={mutation.isPending}
disabled={mutation.isPending}>
{t("finish_and_start")}
</Button>
}>
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
{queryConnectedVideoApps?.items
.filter((app) => app.slug !== "daily-video")
.map((app) => {
const shouldAutoSetDefault =
!hasDefaultConferencingApp && app.appData?.location?.linkType === "dynamic";
return (
<InstallableAppCard
key={app.slug}
app={app}
isInstalling={installingAppSlug === app.slug}
onInstallClick={setInstallingAppSlug}
installOptions={createInstallHandlers(app.slug, (appSlug) => {
// Auto-set as default if it's the first connected video app
if (shouldAutoSetDefault) {
setDefaultConferencingApp.mutate({ slug: appSlug });
}
})}
/>
);
})}
</div>
</OnboardingCard>
<SkipButton onClick={handleSkip} disabled={mutation.isPending} />
</OnboardingLayout>
);
};
@@ -31,6 +31,7 @@ export interface Invite {
export interface TeamDetails {
name: string;
slug: string;
bio: string;
}
export interface TeamBrand {
@@ -104,6 +105,7 @@ const initialState = {
teamDetails: {
name: "",
slug: "",
bio: "",
},
teamBrand: {
color: "#000000",
@@ -1,258 +0,0 @@
"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 TeamBrandViewProps = {
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 TeamBrandView = ({ userEmail }: TeamBrandViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { teamBrand, setTeamBrand, teamDetails } = useOnboardingStore();
const [brandColor, setBrandColor] = useState("#000000");
const [_logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
useEffect(() => {
setBrandColor(teamBrand.color);
setLogoPreview(teamBrand.logo);
}, [teamBrand]);
const handleLogoChange = (file: File | null) => {
setLogoFile(file);
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
setLogoPreview(base64);
setTeamBrand({ logo: base64 });
};
reader.readAsDataURL(file);
} else {
setLogoPreview(null);
setTeamBrand({ logo: null });
}
};
const handleContinue = () => {
setTeamBrand({ color: brandColor });
router.push("/onboarding/teams/invite");
};
const handleSkip = () => {
router.push("/onboarding/teams/invite");
};
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="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("customize_team_brand")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">{t("team_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);
setTeamBrand({ 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>
</div>
{/* Right side - Preview */}
<div className="bg-muted border-muted flex hidden h-[260px] 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-4">
{/* 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 ">
{teamDetails.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>
);
};
@@ -1,14 +1,18 @@
"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 slugify from "@calcom/lib/slugify";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField } from "@calcom/ui/components/form";
import { Logo } from "@calcom/ui/components/logo";
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
import { useOnboardingStore } from "../../store/onboarding-store";
import { ValidatedTeamSlug } from "./validated-team-slug";
@@ -20,20 +24,25 @@ type TeamDetailsViewProps = {
export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { teamDetails, setTeamDetails } = useOnboardingStore();
const { teamDetails, teamBrand, setTeamDetails, setTeamBrand } = useOnboardingStore();
const logoRef = useRef<HTMLInputElement>(null);
const [teamName, setTeamName] = useState("");
const [teamSlug, setTeamSlug] = useState("");
const [teamBio, setTeamBio] = useState("");
const [teamLogo, setTeamLogo] = useState<string>("");
const [isSlugValid, setIsSlugValid] = useState(false);
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
useEffect(() => {
setTeamName(teamDetails.name);
setTeamSlug(teamDetails.slug);
setTeamBio(teamDetails.bio);
setTeamLogo(teamBrand.logo || "");
if (teamDetails.slug) {
setIsSlugManuallyEdited(true);
}
}, [teamDetails]);
}, [teamDetails, teamBrand]);
useEffect(() => {
if (!isSlugManuallyEdited && teamName) {
@@ -47,6 +56,13 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
setIsSlugManuallyEdited(true);
};
const handleLogoChange = (newLogo: string) => {
if (logoRef.current) {
logoRef.current.value = newLogo;
}
setTeamLogo(newLogo);
};
const handleContinue = () => {
if (!isSlugValid) {
return;
@@ -55,89 +71,101 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
setTeamDetails({
name: teamName,
slug: teamSlug,
bio: teamBio,
});
router.push("/onboarding/teams/brand");
setTeamBrand({
logo: teamLogo || null,
});
router.push("/onboarding/teams/invite");
};
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
<>
<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 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("create_your_team")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("team_onboarding_details_subtitle")}
</p>
<OnboardingLayout userEmail={userEmail} currentStep={2}>
{/* Left column - Main content */}
<OnboardingCard
title={t("create_your_team")}
subtitle={t("team_onboarding_details_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={() => router.push("/onboarding/getting-started")}>
{t("back")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !teamName || !teamSlug}>
{t("continue")}
</Button>
</div>
}>
<div className="flex w-full flex-col gap-4 px-5">
{/* 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>
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
<div className="relative shrink-0">
<Avatar
size="lg"
imageSrc={teamLogo || undefined}
alt={teamName || "Team"}
className="border-2 border-white"
/>
</div>
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
<ImageUploader
target="avatar"
id="team-logo-upload"
buttonMsg={t("upload")}
handleAvatarChange={handleLogoChange}
imageSrc={teamLogo}
/>
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</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">
{/* Team Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_name")}</Label>
<TextField
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="Acme Inc."
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
{/* Team Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_name")}</Label>
<TextField
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="Acme Inc."
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
{/* Team Slug */}
<ValidatedTeamSlug
value={teamSlug}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
</div>
</div>
</div>
</div>
</div>
{/* Team Slug */}
<ValidatedTeamSlug
value={teamSlug}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
{/* 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 || !teamName || !teamSlug}>
{t("continue")}
</Button>
</div>
{/* Team Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_bio")}</Label>
<TextArea
value={teamBio}
onChange={(e) => setTeamBio(e.target.value)}
placeholder={t("team_bio_placeholder")}
className="border-default min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
rows={3}
/>
</div>
</div>
</div>
</div>
</div>
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingBrowserView teamSlug={teamSlug} name={teamName} bio={teamBio} avatar={teamLogo || null} />
</OnboardingLayout>
</>
);
};
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import classNames from "@calcom/ui/classNames";
import { Label, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
@@ -73,6 +74,15 @@ export function ValidatedTeamSlug({ value, onChange, onValidationChange }: Valid
};
}, [value, validateSlug]);
const handleBlur = () => {
if (value) {
const slugified = slugify(value);
if (slugified !== value) {
onChange(slugified);
}
}
};
const urlPrefix = `${WEBAPP_URL}/team/`;
return (
@@ -81,6 +91,7 @@ export function ValidatedTeamSlug({ value, onChange, onValidationChange }: Valid
<TextField
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={handleBlur}
placeholder="acme"
addOnLeading={urlPrefix}
addOnSuffix={
@@ -0,0 +1,245 @@
"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 CSVUploadModalProps = {
isOpen: boolean;
onClose: () => void;
};
export const CSVUploadModal = ({ isOpen, onClose }: CSVUploadModalProps) => {
const { t } = useLocale();
const router = useRouter();
const store = useOnboardingStore();
const { setTeamInvites, teamDetails } = 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) {
// Validate file type
if (!file.name.endsWith(".csv")) {
showToast(t("please_upload_csv_file"), "error");
return;
}
setSelectedFile(file);
}
};
const handleDownloadTemplate = () => {
// Create a CSV template with headers
const headers = ["email", "role"];
const exampleRow = ["example@email.com", "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 = "team_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 {
// Read and parse the CSV file
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;
}
// Parse CSV (simple implementation - may need enhancement for complex CSVs)
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const emailIndex = headers.indexOf("email");
const roleIndex = headers.indexOf("role");
if (emailIndex === -1) {
showToast(t("csv_missing_email_column"), "error");
setIsUploading(false);
return;
}
// Filter out empty emails and validate
const parsedInvites = lines
.slice(1)
.map((line) => {
const values = line.split(",").map((v) => v.trim());
return {
email: values[emailIndex]?.trim(),
role: (roleIndex !== -1 ? values[roleIndex]?.trim().toUpperCase() : "MEMBER") as
| "MEMBER"
| "ADMIN",
};
})
.filter((invite) => invite.email && invite.email.length > 0);
if (parsedInvites.length === 0) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
// Convert to Invite format with team name
const invites: Invite[] = parsedInvites.map((invite) => ({
email: invite.email,
team: teamDetails.name,
role: invite.role === "ADMIN" ? "ADMIN" : "MEMBER",
}));
// Save invites to store
setTeamInvites(invites);
showToast(t("csv_uploaded_successfully", { count: invites.length }), "success");
onClose();
// Navigate to email page to display the invites
router.push("/onboarding/teams/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>
{/* File upload illustration */}
<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">
{/* Download template */}
<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>
{/* File upload */}
<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,188 @@
"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 { 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 { OnboardingCard } from "../../../components/OnboardingCard";
import { OnboardingLayout } from "../../../components/OnboardingLayout";
import { OnboardingInviteBrowserView } from "../../../components/onboarding-invite-browser-view";
import { useCreateTeam } from "../../../hooks/useCreateTeam";
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
type TeamInviteEmailViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
role: InviteRole;
}[];
};
export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => {
const router = useRouter();
const { t } = useLocale();
const store = useOnboardingStore();
const { teamInvites, setTeamInvites, teamDetails } = store;
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
const { createTeam, isSubmitting } = useCreateTeam();
const formSchema = z.object({
invites: z.array(
z.object({
email: z.string().email(t("invalid_email_address")),
role: z.enum(["MEMBER", "ADMIN"]),
})
),
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
invites:
teamInvites.length > 0
? teamInvites.map((inv) => ({ email: inv.email, role: inv.role }))
: [{ email: "", role: inviteRole }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invites",
});
const handleContinue = async (data: FormValues) => {
const invitesWithTeam = data.invites.map((invite) => ({
email: invite.email,
team: teamDetails.name,
role: invite.role,
}));
setTeamInvites(invitesWithTeam);
// Create the team (will handle checkout redirect if needed)
await createTeam(store);
};
const handleBack = () => {
router.push("/onboarding/teams/invite");
};
const hasValidInvites = fields.some((_, index) => {
const email = form.watch(`invites.${index}.email`);
return email && email.trim().length > 0;
});
return (
<OnboardingLayout userEmail={userEmail} currentStep={3}>
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
<OnboardingCard
title={t("invite_via_email")}
subtitle={t("team_invite_subtitle")}
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 w-full flex-col gap-4 px-5">
<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>
<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>
</div>
</Form>
</div>
</OnboardingCard>
</div>
{/* Right column - Browser view */}
<OnboardingInviteBrowserView teamName={teamDetails.name} />
</OnboardingLayout>
);
};
@@ -1,221 +1,168 @@
"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, ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingInviteBrowserView } from "../../components/onboarding-invite-browser-view";
import { useCreateTeam } from "../../hooks/useCreateTeam";
import { useOnboardingStore, type InviteRole } from "../../store/onboarding-store";
import { useOnboardingStore } from "../../store/onboarding-store";
import { CSVUploadModal } from "./csv-upload-modal";
type TeamInviteViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
role: InviteRole;
}[];
};
export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
const router = useRouter();
const { t } = useLocale();
const flags = useFlags();
const store = useOnboardingStore();
const { teamInvites, setTeamInvites, teamDetails } = store;
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
const { setTeamInvites, teamDetails } = store;
const { createTeam, isSubmitting } = useCreateTeam();
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
const formSchema = z.object({
invites: z.array(
z.object({
email: z.string().email(t("invalid_email_address")),
role: z.enum(["MEMBER", "ADMIN"]),
})
),
});
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
invites:
teamInvites.length > 0
? teamInvites.map((inv) => ({ email: inv.email, role: inv.role }))
: [{ email: "", role: inviteRole }],
},
});
const handleGoogleWorkspaceConnect = () => {
// TODO: Implement Google Workspace connection
console.log("Connect Google Workspace");
};
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invites",
});
const handleInviteViaEmail = () => {
router.push("/onboarding/teams/invite/email");
};
const handleContinue = async (data: FormValues) => {
const invitesWithTeam = data.invites.map((invite) => ({
email: invite.email,
team: teamDetails.name,
role: invite.role,
}));
const handleUploadCSV = () => {
setIsCSVModalOpen(true);
};
setTeamInvites(invitesWithTeam);
// Create the team (will handle checkout redirect if needed)
await createTeam(store);
const handleCopyInviteLink = () => {
// Disabled for now as per requirements
console.log("Copy invite link - disabled");
};
const handleSkip = async () => {
setTeamInvites([]);
// Create the team without invites (will handle checkout redirect if needed)
await createTeam(store);
};
const hasValidInvites = fields.some((_, index) => {
const email = form.watch(`invites.${index}.email`);
return email && email.trim().length > 0;
});
const handleInvite = async () => {
// For now, just proceed to create the team
// The actual invites will be handled in the email page
await createTeam(store);
};
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("invite_team_members")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">{t("team_invite_subtitle")}</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">
<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>
{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>
))}
{/* 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") },
]}
/>
</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">
<>
<OnboardingLayout userEmail={userEmail} currentStep={3}>
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
<OnboardingCard
title={t("invite")}
subtitle={t("onboarding_invite_subtitle")}
footer={
<div className="flex w-full items-center justify-between gap-4">
<Button
type="submit"
color="primary"
color="minimal"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
onClick={() => router.push("/onboarding/teams/details")}
disabled={isSubmitting}>
{t("back")}
</Button>
<div className="flex items-center gap-2">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
<Button
color="primary"
className="rounded-[10px]"
onClick={handleInvite}
disabled={isSubmitting}
loading={isSubmitting}>
{t("invite")}
</Button>
</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>
</div>
{/* Skip button */}
<div className="flex w-full justify-center">
<button
onClick={handleSkip}
disabled={isSubmitting}
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
{t("ill_do_this_later")}
</button>
</div>
</OnboardingCard>
</div>
</div>
</div>
{/* Right column - Browser view */}
<OnboardingInviteBrowserView teamName={teamDetails.name} />
</OnboardingLayout>
{/* CSV Upload Modal */}
<CSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
</>
);
};
@@ -0,0 +1,51 @@
import { parseAsBoolean, useQueryState } from "nuqs";
import { useEffect, useState } from "react";
const STORAGE_KEY = "showWelcomeToCalcomModal";
export function useWelcomeToCalcomModal() {
const [welcomeToCalcomModal, setWelcomeToCalcomModal] = useQueryState(
"welcomeToCalcomModal",
parseAsBoolean.withDefault(false)
);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Check query param first
if (welcomeToCalcomModal) {
setIsOpen(true);
return;
}
// Check sessionStorage as fallback (for cases where we redirect through personal onboarding)
if (typeof window !== "undefined" && sessionStorage.getItem(STORAGE_KEY) === "true") {
setIsOpen(true);
}
}, [welcomeToCalcomModal]);
const closeModal = () => {
setIsOpen(false);
// Remove the query param from URL
setWelcomeToCalcomModal(null);
// Also clear sessionStorage
if (typeof window !== "undefined") {
sessionStorage.removeItem(STORAGE_KEY);
}
};
return {
isOpen,
closeModal,
};
}
/**
* Helper function to set the flag that triggers the welcome modal.
* Use this before redirecting to ensure the modal shows after navigation.
*/
export function setShowWelcomeToCalcomModalFlag() {
if (typeof window !== "undefined") {
sessionStorage.setItem(STORAGE_KEY, "true");
}
}
@@ -51,7 +51,7 @@ const generateCheckoutSession = async ({
export const createHandler = async ({ ctx, input }: CreateOptions) => {
const { user } = ctx;
const { slug, name, isOnboarding } = input;
const { slug, name, bio, isOnboarding } = input;
const isOrgChildTeam = !!user.profile?.organizationId;
// For orgs we want to create teams under the org
@@ -99,6 +99,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
data: {
slug,
name,
bio: bio || null,
members: {
create: {
userId: ctx.user.id,
@@ -10,6 +10,7 @@ export const ZCreateInputSchema = z.object({
.optional()
.nullable()
.transform((v) => v || null),
bio: z.string().optional(),
isOnboarding: z.boolean().optional(),
});