feat: redesign team creation flow to match onboarding (#27698)

* Revert "Revert "feat: redesign team creation flow to match onboarding-v3 design (#26733)""

This re-applies the team creation redesign from PR #26733 which was
previously reverted. Cherry-picked from 2540423ba3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: check org/username slug when in org context

* fix: use expect(page).toHaveURL() instead of page.waitForURL() in team e2e test

Co-Authored-By: unknown <>

* fix: redirect URL

* fix: flag import

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2026-02-06 13:19:39 +00:00
committed by GitHub
co-authored by Claude Opus 4.6 unknown <> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 369ec831fe
commit d80ee83da7
14 changed files with 986 additions and 133 deletions
@@ -1,58 +1,210 @@
"use client";
import { useRouter } from "next/navigation";
import React from "react";
import { z } from "zod";
import posthog from "posthog-js";
import React, { useEffect, useRef, useState, type FormEvent } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { RouterOutputs } from "@calcom/trpc/react";
import { WizardLayout } from "@calcom/ui/components/layout";
import { CreateANewTeamForm } from "@calcom/web/modules/ee/teams/components/CreateANewTeamForm";
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, TextArea } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { showToast } from "@calcom/ui/components/toast";
const querySchema = z.object({
returnTo: z.string().optional(),
slug: z.string().optional(),
});
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
import { OnboardingBrowserView } from "~/onboarding/components/onboarding-browser-view";
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
import { ValidatedTeamSlug } from "~/onboarding/teams/details/validated-team-slug";
const CreateNewTeamPage = () => {
const params = useParamsWithFallback();
const parsedQuery = querySchema.safeParse(params);
type CreateNewTeamViewProps = {
userEmail: string;
};
export const CreateNewTeamView = ({ userEmail }: CreateNewTeamViewProps) => {
const router = useRouter();
const { t } = useLocale();
const store = useOnboardingStore();
const { teamDetails, teamBrand, setTeamDetails, setTeamBrand, resetOnboardingPreservingPlan } = store;
const { createTeam, isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
const flag = isTeamBillingEnabledClient
? {
submitLabel: "checkout",
}
: {
submitLabel: "continue",
};
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);
const returnToParam =
(parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/teams") || "/teams";
// Reset onboarding store when entering the team creation flow from settings
useEffect(() => {
resetOnboardingPreservingPlan();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onSuccess = (data: RouterOutputs["viewer"]["teams"]["create"]) => {
// telemetry.event(flag.telemetryEvent);
router.push(data.url);
useEffect(() => {
setTeamName(teamDetails.name);
setTeamSlug(teamDetails.slug);
setTeamBio(teamDetails.bio);
setTeamLogo(teamBrand.logo || "");
if (teamDetails.slug) {
setIsSlugManuallyEdited(true);
}
}, [teamDetails, teamBrand]);
useEffect(() => {
if (!isSlugManuallyEdited && teamName) {
const slugifiedName = slugify(teamName);
setTeamSlug(slugifiedName);
}
}, [teamName, isSlugManuallyEdited]);
const handleSlugChange = (value: string) => {
setTeamSlug(value);
setIsSlugManuallyEdited(true);
};
const handleLogoChange = (newLogo: string) => {
if (logoRef.current) {
logoRef.current.value = newLogo;
}
setTeamLogo(newLogo);
};
const handleContinue = async (e?: FormEvent) => {
e?.preventDefault();
if (!isSlugValid) {
return;
}
posthog.capture("settings_team_details_continue_clicked", {
has_logo: !!teamLogo,
has_bio: !!teamBio,
});
setTeamDetails({
name: teamName,
slug: teamSlug,
bio: teamBio,
});
setTeamBrand({
logo: teamLogo || null,
});
try {
await createTeam();
} catch (error) {
const message = error instanceof Error ? error.message : t("something_went_wrong");
showToast(message, "error");
}
};
const handleCancel = () => {
posthog.capture("settings_team_details_cancel_clicked");
resetOnboardingPreservingPlan();
router.push("/teams");
};
return (
<CreateANewTeamForm
slug={parsedQuery.success ? parsedQuery.data.slug : ""}
onCancel={() => router.push(returnToParam)}
submitLabel={flag.submitLabel}
onSuccess={onSuccess}
/>
);
};
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<WizardLayout currentStep={1} maxSteps={3}>
{children}
</WizardLayout>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex h-full w-full flex-col gap-4">
<form onSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
<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
type="button"
color="minimal"
className="rounded-[10px]"
onClick={handleCancel}
disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}>
{t("continue")}
</Button>
</div>
}>
<div className="flex h-full w-full flex-col gap-4">
{/* Team Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
<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>
{/* Team Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_name")}</Label>
<TextField
name="name"
data-testid="team-name-input"
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}
/>
{/* Team Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 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 max-h-[150px] min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
rows={3}
/>
</div>
</div>
</OnboardingCard>
</form>
</div>
{/* Right column - Browser view */}
<OnboardingBrowserView teamSlug={teamSlug} name={teamName} bio={teamBio} avatar={teamLogo || null} />
</OnboardingLayout>
);
};
export default CreateNewTeamPage;
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
export default CreateNewTeamView;
@@ -0,0 +1,263 @@
"use client";
import { useRouter, useSearchParams } 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 "~/onboarding/store/onboarding-store";
/**
* Parse a CSV line handling quoted fields that may contain commas.
* Example: '"Name, Inc",admin' -> ['Name, Inc', 'admin']
*/
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) {
result.push(current.trim());
current = "";
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
type CSVUploadModalProps = {
isOpen: boolean;
onClose: () => void;
};
export const CSVUploadModal = ({ isOpen, onClose }: CSVUploadModalProps) => {
const { t } = useLocale();
const router = useRouter();
const searchParams = useSearchParams();
const store = useOnboardingStore();
const { setTeamInvites, teamDetails, teamId } = store;
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.name.endsWith(".csv")) {
showToast(t("please_upload_csv_file"), "error");
return;
}
setSelectedFile(file);
}
};
const handleDownloadTemplate = () => {
const headers = ["email", "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 {
const text = await selectedFile.text();
const lines = text.split("\n").filter((line) => line.trim());
if (lines.length < 2) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
const headers = parseCSVLine(lines[0]).map((h) => h.toLowerCase());
const emailIndex = headers.indexOf("email");
const roleIndex = headers.indexOf("role");
if (emailIndex === -1) {
showToast(t("csv_missing_email_column"), "error");
setIsUploading(false);
return;
}
const parsedInvites = lines
.slice(1)
.map((line) => {
const values = parseCSVLine(line);
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;
}
const invites: Invite[] = parsedInvites.map((invite) => ({
email: invite.email,
team: teamDetails.name,
role: invite.role === "ADMIN" ? "ADMIN" : "MEMBER",
}));
setTeamInvites(invites);
showToast(t("csv_uploaded_successfully", { count: invites.length }), "success");
onClose();
// Navigate to email page to display the invites - use settings path
const teamIdParam = searchParams?.get("teamId") || teamId;
router.push(`/settings/teams/new/invite/email?teamId=${teamIdParam}`);
} catch (error) {
console.error("Error uploading CSV:", error);
showToast(t("error_uploading_csv"), "error");
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent size="default" className="!p-0">
<div className="flex flex-col gap-4 p-6">
<div className="flex flex-col items-center gap-1">
<Logo className="h-10 w-auto" />
</div>
<div
className="relative mx-auto flex items-center justify-center"
style={{ width: 320, height: 180 }}>
<div
className="from-default to-muted border-subtle flex items-center justify-center rounded-2xl border bg-gradient-to-br p-8 shadow-sm"
style={{ width: 200, height: 150 }}>
{selectedFile ? (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon name="file-text" className="text-emphasis" style={{ width: 32, height: 32 }} />
</div>
<div className="flex flex-col items-center gap-1">
<p className="text-emphasis text-sm font-medium">{selectedFile.name}</p>
<p className="text-subtle text-xs">{(selectedFile.size / 1024).toFixed(2)} KB</p>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon
name="upload"
className="text-emphasis opacity-70"
style={{ width: 32, height: 32 }}
/>
</div>
<p className="text-subtle text-center text-sm">{t("upload_csv_subtitle")}</p>
</div>
)}
</div>
</div>
<div className="mb-2 flex flex-col gap-2 text-center">
<h2 className="font-heading text-emphasis text-2xl leading-none">{t("upload_csv_file")}</h2>
<p className="text-default text-sm leading-normal">{t("upload_csv_description")}</p>
</div>
<div className="mb-2 flex flex-col gap-3">
<div className="bg-cal-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
<Icon name="download" className="text-emphasis" style={{ width: 16, height: 16 }} />
</div>
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("need_template")}</p>
<p className="text-subtle text-xs">{t("download_csv_template_description")}</p>
</div>
<Button color="secondary" StartIcon="download" size="sm" onClick={handleDownloadTemplate}>
{t("download_template")}
</Button>
</div>
<div className="bg-cal-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-cal-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,210 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Form } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { EmailInviteForm } from "~/onboarding/components/EmailInviteForm";
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
import { RoleSelector } from "~/onboarding/components/RoleSelector";
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
import { type InviteRole, useOnboardingStore } from "~/onboarding/store/onboarding-store";
type TeamInviteEmailViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
role: InviteRole;
}[];
};
export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t, i18n } = useLocale();
const utils = trpc.useUtils();
const store = useOnboardingStore();
const { teamInvites, setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } =
store;
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
const { inviteMembers, isSubmitting } = useCreateTeam({
redirectBasePath: "/settings/teams/new",
skipRedirectAfterInvite: true,
});
// Read teamId from query params and store it (from payment callback or redirect)
useEffect(() => {
const teamIdParam = searchParams?.get("teamId");
if (teamIdParam && !teamId) {
const parsedTeamId = parseInt(teamIdParam, 10);
if (!isNaN(parsedTeamId)) {
setTeamId(parsedTeamId);
}
}
}, [searchParams, setTeamId, teamId]);
const formSchema = z.object({
invites: z.array(
z.object({
email: z.union([z.literal(""), 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 teamIdParam = searchParams?.get("teamId");
const parsedTeamId = !teamId ? parseInt(teamIdParam || "", 10) : teamId;
if (!parsedTeamId) {
console.log("Team ID is missing. Please go back and create your team first.");
showToast(
t("team_id_missing") || "Team ID is missing. Please go back and create your team first.",
"error"
);
return;
}
const invitesWithTeam = data.invites.map((invite) => ({
email: invite.email,
role: invite.role,
team: teamDetails.name,
}));
setTeamInvites(invitesWithTeam);
// Filter out empty emails and invite members
const validInvites = data.invites.filter((invite) => invite.email && invite.email.trim().length > 0);
if (validInvites.length > 0) {
try {
await inviteMembers(
validInvites.map((invite) => ({
email: invite.email,
role: invite.role,
})),
i18n.language
);
// Invalidate both server-side cache and client-side tRPC cache
await revalidateTeamsList();
await utils.viewer.teams.list.invalidate();
resetOnboardingPreservingPlan();
router.replace("/teams");
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("something_went_wrong") || "Something went wrong";
showToast(errorMessage, "error");
}
} else {
// No invites, skip to teams page
await revalidateTeamsList();
await utils.viewer.teams.list.invalidate();
resetOnboardingPreservingPlan();
router.replace("/teams");
}
};
const handleSkip = async () => {
setTeamInvites([]);
await revalidateTeamsList();
await utils.viewer.teams.list.invalidate();
resetOnboardingPreservingPlan();
router.replace("/teams");
};
// Watch form values to pass to browser view for real-time updates
const watchedInvites = form.watch("invites");
const hasValidInvites = watchedInvites.some((invite) => {
return invite.email && invite.email.trim().length > 0;
});
return (
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex h-full w-full flex-col gap-4">
<Form form={form} handleSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("invite")}
subtitle={t("team_invite_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<div className="flex items-center gap-2">
<Button
type="button"
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}
data-testid="skip-invite-button">
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}>
{t("continue")}
</Button>
</div>
</div>
}>
<div className="flex w-full flex-col gap-4 px-1">
<div className="flex w-full flex-col gap-4">
<EmailInviteForm
fields={fields}
append={append}
remove={remove}
defaultRole={inviteRole}
emailPlaceholder="rick@cal.com"
/>
<RoleSelector
value={inviteRole}
onValueChange={(value) => {
setInviteRole(value);
fields.forEach((_, index) => {
form.setValue(`invites.${index}.role`, value);
});
}}
showInfoBadge
/>
</div>
</div>
</OnboardingCard>
</Form>
</div>
{/* Right column - Browser view */}
<OnboardingInviteBrowserView teamName={teamDetails.name} watchedInvites={watchedInvites} />
</OnboardingLayout>
);
};
@@ -0,0 +1,125 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import React, { useEffect } from "react";
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { InviteOptions } from "~/onboarding/components/InviteOptions";
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
import { CSVUploadModal } from "./csv-upload-modal";
type TeamInviteViewProps = {
userEmail: string;
};
export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const flags = useFlags();
const store = useOnboardingStore();
const { setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } = store;
const { isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
// Read teamId from query params and store it (from payment callback)
useEffect(() => {
const teamIdParam = searchParams?.get("teamId");
if (teamIdParam) {
const parsedTeamId = parseInt(teamIdParam, 10);
if (!isNaN(parsedTeamId)) {
setTeamId(parsedTeamId);
}
}
}, [searchParams, setTeamId]);
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const handleGoogleWorkspaceConnect = () => {
// TODO: Implement Google Workspace connection
console.log("Connect Google Workspace");
};
const handleInviteViaEmail = () => {
router.push(`/settings/teams/new/invite/email?teamId=${teamId}`);
};
const handleUploadCSV = () => {
setIsCSVModalOpen(true);
};
const handleCopyInviteLink = () => {
// Disabled for now as per requirements
console.log("Copy invite link - disabled");
};
const handleSkip = async () => {
posthog.capture("settings_team_invite_skip_clicked");
setTeamInvites([]);
resetOnboardingPreservingPlan();
// Redirect to teams page after skipping
router.push("/teams");
};
const handleBack = () => {
posthog.capture("settings_team_invite_back_clicked");
router.push("/settings/teams/new");
};
return (
<>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={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
color="minimal"
className="rounded-[10px]"
onClick={handleBack}
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>
</div>
</div>
}>
<InviteOptions
onInviteViaEmail={handleInviteViaEmail}
onUploadCSV={handleUploadCSV}
onCopyInviteLink={handleCopyInviteLink}
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
isSubmitting={isSubmitting}
/>
</OnboardingCard>
</div>
{/* Right column - Browser view */}
<OnboardingInviteBrowserView teamName={teamDetails.name} />
</OnboardingLayout>
{/* CSV Upload Modal */}
<CSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
</>
);
};