-
-
-
-
- {/* Name */}
-
-
{
- setName(e.target.value);
- form.setValue("name", e.target.value);
- }}
- placeholder="John Doe"
- />
- {form.formState.errors.name && (
- {form.formState.errors.name.message}
- )}
-
-
- {/* Timezone */}
-
-
- {t("timezone")}
-
-
setSelectedTimeZone(value)}
- />
-
- {t("current_time")}{" "}
- {dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
-
-
-
-
-
+
+ router.push("/onboarding/getting-started")}>
+ {t("back")}
+
+
+ {t("continue")}
+
-
+ }>
+
+
+
+
+ {/* Right column - Browser view */}
+
>
);
diff --git a/apps/web/modules/onboarding/personal/video/personal-video-view.tsx b/apps/web/modules/onboarding/personal/video/personal-video-view.tsx
deleted file mode 100644
index 1fda826eb0..0000000000
--- a/apps/web/modules/onboarding/personal/video/personal-video-view.tsx
+++ /dev/null
@@ -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 (
-
-
- {t("finish_and_start")}
-
- }>
-
- {queryConnectedVideoApps?.items
- .filter((app) => app.slug !== "daily-video")
- .map((app) => {
- const shouldAutoSetDefault =
- !hasDefaultConferencingApp && app.appData?.location?.linkType === "dynamic";
-
- return (
- {
- // Auto-set as default if it's the first connected video app
- if (shouldAutoSetDefault) {
- setDefaultConferencingApp.mutate({ slug: appSlug });
- }
- })}
- />
- );
- })}
-
-
-
-
-
- );
-};
diff --git a/apps/web/modules/onboarding/store/onboarding-store.ts b/apps/web/modules/onboarding/store/onboarding-store.ts
index 2da6bb0236..52579e809f 100644
--- a/apps/web/modules/onboarding/store/onboarding-store.ts
+++ b/apps/web/modules/onboarding/store/onboarding-store.ts
@@ -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",
diff --git a/apps/web/modules/onboarding/teams/brand/team-brand-view.tsx b/apps/web/modules/onboarding/teams/brand/team-brand-view.tsx
deleted file mode 100644
index 75fb540a5f..0000000000
--- a/apps/web/modules/onboarding/teams/brand/team-brand-view.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
{
- 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}
- />
-
- );
-};
-
-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
(null);
- const [logoPreview, setLogoPreview] = useState(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 (
-
- {/* Header */}
-
-
-
- {/* Progress dots - centered */}
-
-
-
-
-
- {/* Main content */}
-
-
- {/* Card */}
-
-
- {/* Card Header */}
-
-
-
{t("customize_team_brand")}
-
{t("team_brand_subtitle")}
-
-
-
- {/* Form */}
-
-
-
-
- {/* Left side - Form */}
-
- {/* Brand Color */}
-
-
{t("brand_color")}
-
-
- {t("onboarding_primary_color_label")}
-
-
{
- setBrandColor(value);
- setTeamBrand({ color: value });
- }}
- t={t}
- />
-
-
-
- {/* Logo Upload */}
-
-
{t("logo")}
-
-
- {logoPreview && (
-
- )}
-
-
- document.getElementById("logo-upload")?.click()}>
- {t("upload")}
-
- handleLogoChange(e.target.files?.[0] || null)}
- />
-
-
-
- {t("onboarding_logo_size_hint")}
-
-
-
-
- {/* Right side - Preview */}
-
-
-
{t("preview")}
-
- {/* Content */}
-
-
-
- {/* Logo preview */}
-
- {logoPreview && (
-
- )}
-
-
- {teamDetails.name || t("onboarding_preview_nameless")}
-
-
-
-
-
- {t("onboarding_preview_example_title")}
-
-
- {t("onboarding_preview_example_description")}
-
-
-
-
-
- {[134, 104, 84, 104].map((width, i) => (
-
- ))}
-
-
-
-
-
-
-
-
-
-
- {/* Footer */}
-
-
- {t("ill_do_this_later")}
-
-
- {t("continue")}
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/web/modules/onboarding/teams/details/team-details-view.tsx b/apps/web/modules/onboarding/teams/details/team-details-view.tsx
index 410b8ca86d..ed320f485f 100644
--- a/apps/web/modules/onboarding/teams/details/team-details-view.tsx
+++ b/apps/web/modules/onboarding/teams/details/team-details-view.tsx
@@ -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(null);
const [teamName, setTeamName] = useState("");
const [teamSlug, setTeamSlug] = useState("");
+ const [teamBio, setTeamBio] = useState("");
+ const [teamLogo, setTeamLogo] = useState("");
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 (
-
+ <>
- {/* Header */}
-
-
-
- {/* Progress dots - centered */}
-
-
-
-
-
- {/* Main content */}
-
-
- {/* Card */}
-
-
- {/* Card Header */}
-
-
-
{t("create_your_team")}
-
- {t("team_onboarding_details_subtitle")}
-
+
+ {/* Left column - Main content */}
+
+ router.push("/onboarding/getting-started")}>
+ {t("back")}
+
+
+ {t("continue")}
+
+
+ }>
+
+ {/* Team Profile Picture */}
+
+
{t("team_logo")}
+
+
{t("onboarding_logo_size_hint")}
+
- {/* Form */}
-
-
-
-
-
- {/* Team Name */}
-
- {t("team_name")}
- setTeamName(e.target.value)}
- placeholder="Acme Inc."
- className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
- />
-
+ {/* Team Name */}
+
+ {t("team_name")}
+ setTeamName(e.target.value)}
+ placeholder="Acme Inc."
+ className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
+ />
+
- {/* Team Slug */}
-
-
-
-
-
-
+ {/* Team Slug */}
+
- {/* Footer */}
-
-
- {t("continue")}
-
-
+ {/* Team Bio */}
+
+ {t("team_bio")}
+ 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}
+ />
-
-
-
+
+
+ {/* Right column - Browser view */}
+
+
+ >
);
};
diff --git a/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx b/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx
index 5d1e92097e..4490b2bf86 100644
--- a/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx
+++ b/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx
@@ -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
onChange(e.target.value)}
+ onBlur={handleBlur}
placeholder="acme"
addOnLeading={urlPrefix}
addOnSuffix={
diff --git a/apps/web/modules/onboarding/teams/invite/csv-upload-modal.tsx b/apps/web/modules/onboarding/teams/invite/csv-upload-modal.tsx
new file mode 100644
index 0000000000..661a2dc98c
--- /dev/null
+++ b/apps/web/modules/onboarding/teams/invite/csv-upload-modal.tsx
@@ -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(null);
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [isUploading, setIsUploading] = useState(false);
+
+ const handleFileSelect = (event: React.ChangeEvent) => {
+ 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 (
+
+
+
+
+
+
+
+ {/* File upload illustration */}
+
+
+ {selectedFile ? (
+
+
+
+
+
+
{selectedFile.name}
+
{(selectedFile.size / 1024).toFixed(2)} KB
+
+
+ ) : (
+
+
+
+
+
{t("upload_csv_subtitle")}
+
+ )}
+
+
+
+
+
{t("upload_csv_file")}
+
{t("upload_csv_description")}
+
+
+
+ {/* Download template */}
+
+
+
+
+
+
{t("need_template")}
+
{t("download_csv_template_description")}
+
+
+ {t("download_template")}
+
+
+
+ {/* File upload */}
+
+
+
+
+
+
{t("upload_your_file")}
+
+ {selectedFile ? selectedFile.name : t("upload_csv_description")}
+
+
+
+
fileInputRef.current?.click()}>
+ {t("choose_file")}
+
+
+
+
+
+
+
+ {t("cancel")}
+
+
+ {t("upload")}
+
+
+
+
+ );
+};
diff --git a/apps/web/modules/onboarding/teams/invite/email/team-invite-email-view.tsx b/apps/web/modules/onboarding/teams/invite/email/team-invite-email-view.tsx
new file mode 100644
index 0000000000..a2f176b33d
--- /dev/null
+++ b/apps/web/modules/onboarding/teams/invite/email/team-invite-email-view.tsx
@@ -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("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({
+ 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 (
+
+ {/* Left column - Main content */}
+
+
+
+ {t("back")}
+
+
+ {t("continue")}
+
+
+ }>
+
+
+
+ {/* Email inputs */}
+
+
{t("email")}
+
+
+ {fields.map((field, index) => (
+
+
+
+
+
remove(index)}>
+
+
+
+ ))}
+
+
+ {/* Add button */}
+
append({ email: "", role: inviteRole })}>
+ {t("add")}
+
+
+
+ {/* Role selector */}
+
+
+ {t("onboarding_invite_all_as")}
+ {
+ 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") },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+ {/* Right column - Browser view */}
+
+
+ );
+};
diff --git a/apps/web/modules/onboarding/teams/invite/team-invite-view.tsx b/apps/web/modules/onboarding/teams/invite/team-invite-view.tsx
index 470811cf7d..8b049fc25b 100644
--- a/apps/web/modules/onboarding/teams/invite/team-invite-view.tsx
+++ b/apps/web/modules/onboarding/teams/invite/team-invite-view.tsx
@@ -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
("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({
- 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 (
-
- {/* Header */}
-
-
-
- {/* Progress dots - centered */}
-
-
-
-
-
- {/* Main content */}
-
-
- {/* Card */}
-
-
- {/* Card Header */}
-
-
-
{t("invite_team_members")}
-
{t("team_invite_subtitle")}
-
-
-
- {/* Content */}
-
-
-
-
- {/* Email inputs */}
-
-
{t("email")}
-
- {fields.map((field, index) => (
-
-
-
-
-
remove(index)}>
-
-
-
- ))}
-
- {/* Add button */}
-
append({ email: "", role: inviteRole })}>
- {t("add")}
-
-
-
- {/* Role selector */}
-
-
- {t("onboarding_invite_all_as")}
- {
- 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") },
- ]}
- />
-
-
{t("onboarding_modify_roles_later")}
-
-
-
-
-
-
- {/* Footer */}
-
+ <>
+
+ {/* Left column - Main content */}
+
+
- {t("continue")}
+ onClick={() => router.push("/onboarding/teams/details")}
+ disabled={isSubmitting}>
+ {t("back")}
+
+
+
+ {t("onboarding_skip_for_now")}
+
+
+ {t("invite")}
+
+
+
+ }>
+
+ {/* Google Workspace Connect - Only show if feature flag is enabled */}
+ {googleWorkspaceEnabled && (
+ <>
+
+ {t("connect_google_workspace")}
+
+
+ {/* Divider with "or" */}
+
+ >
+ )}
+
+ {/* Invite options */}
+
+
+
+
+ {t("invite_via_email")}
+
+
+
+
+
+
+ {t("upload_csv_file")}
+
+
+
+
+
+
+ {t("copy_invite_link")}
+
-
-
- {/* Skip button */}
-
-
- {t("ill_do_this_later")}
-
-
+
-
-
+
+ {/* Right column - Browser view */}
+
+
+
+ {/* CSV Upload Modal */}
+
setIsCSVModalOpen(false)} />
+ >
);
};
diff --git a/packages/features/shell/hooks/useWelcomeToCalcomModal.ts b/packages/features/shell/hooks/useWelcomeToCalcomModal.ts
new file mode 100644
index 0000000000..1a6faaf78e
--- /dev/null
+++ b/packages/features/shell/hooks/useWelcomeToCalcomModal.ts
@@ -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");
+ }
+}
diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts
index 8a0caba944..735f10e493 100644
--- a/packages/trpc/server/routers/viewer/teams/create.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts
@@ -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,
diff --git a/packages/trpc/server/routers/viewer/teams/create.schema.ts b/packages/trpc/server/routers/viewer/teams/create.schema.ts
index 0bd67da017..87937c24fc 100644
--- a/packages/trpc/server/routers/viewer/teams/create.schema.ts
+++ b/packages/trpc/server/routers/viewer/teams/create.schema.ts
@@ -10,6 +10,7 @@ export const ZCreateInputSchema = z.object({
.optional()
.nullable()
.transform((v) => v || null),
+ bio: z.string().optional(),
isOnboarding: z.boolean().optional(),
});