feat: onboarding v3 teams (#24573)

## What does this PR do?
This PR is stacked on https://github.com/calcom/cal.com/pull/24299 this adds the v3 flow for onboarding with teams.

    
<!-- This is an auto-generated description by cubic. -->
---

## Summary by cubic
Adds the v3 team onboarding flow with Details, Brand, and Invite steps, including slug validation, branding, and member invites. Updates routing to support the Team plan and creates teams with Stripe checkout when needed.

- **New Features**
  - Team Details: name and slug with async availability check and URL preview.
  - Team Brand: hex color picker and logo upload with live preview.
  - Team Invite: add/remove emails, invite role toggle (Member/Admin), and form validation.
  - State: adds teamDetails, teamBrand, and teamInvites to the onboarding store with actions.
  - Creation: new useCreateTeam hook; redirects to Stripe if checkout URL is returned, or to Getting Started on success.
  - Routing/Auth: protected team pages (details, brand, invite) and updates plan selection to route to /onboarding/teams.

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
sean-brydon
2025-10-27 11:00:11 +00:00
committed by GitHub
parent e5abe93940
commit 435587f043
11 changed files with 961 additions and 2 deletions
@@ -0,0 +1,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { 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,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamDetailsView } from "~/onboarding/teams/details/team-details-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("team_details")}`,
() => "",
true,
undefined,
"/onboarding/teams/details"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <TeamDetailsView userEmail={userEmail} />;
};
export default ServerPage;
@@ -0,0 +1,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamInviteView } from "~/onboarding/teams/invite/team-invite-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("team_invite")}`,
() => "",
true,
undefined,
"/onboarding/teams/invite"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <TeamInviteView userEmail={userEmail} />;
};
export default ServerPage;
@@ -23,8 +23,9 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
const handleContinue = () => {
if (selectedPlan === "organization") {
router.push("/onboarding/organization/details");
}
// TODO: Handle other plan types
} else if (selectedPlan === "team") {
router.push("/onboarding/teams/details");
} // TODO: Handle other plan types
};
const allPlans = [
@@ -0,0 +1,49 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { trpc } from "@calcom/trpc/react";
import type { OnboardingState } from "../store/onboarding-store";
export function useCreateTeam() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const createTeamMutation = trpc.viewer.teams.create.useMutation();
const createTeam = async (store: OnboardingState) => {
setIsSubmitting(true);
try {
const { teamDetails, teamBrand } = store;
// Create the team
const result = await createTeamMutation.mutateAsync({
name: teamDetails.name,
slug: teamDetails.slug,
logo: teamBrand.logo,
});
// If there's a checkout URL, redirect to Stripe payment
if (result.url && !result.team) {
window.location.href = result.url;
return;
}
if (result.team) {
router.push("/getting-started");
}
} catch (error) {
console.error("Failed to create team:", error);
throw error;
} finally {
setIsSubmitting(false);
}
};
return {
createTeam,
isSubmitting,
error: createTeamMutation.error,
};
}
@@ -26,6 +26,16 @@ export interface Invite {
role: InviteRole;
}
export interface TeamDetails {
name: string;
slug: string;
}
export interface TeamBrand {
color: string;
logo: string | null; // base64 or URL
}
export interface OnboardingState {
selectedPlan: PlanType | null;
@@ -38,6 +48,11 @@ export interface OnboardingState {
invites: Invite[];
inviteRole: InviteRole;
// Team-specific state
teamDetails: TeamDetails;
teamBrand: TeamBrand;
teamInvites: Invite[];
// Actions
setSelectedPlan: (plan: PlanType) => void;
setOrganizationDetails: (details: Partial<OrganizationDetails>) => void;
@@ -46,6 +61,11 @@ export interface OnboardingState {
setInvites: (invites: Invite[]) => void;
setInviteRole: (role: InviteRole) => void;
// Team actions
setTeamDetails: (details: Partial<TeamDetails>) => void;
setTeamBrand: (brand: Partial<TeamBrand>) => void;
setTeamInvites: (invites: Invite[]) => void;
// Reset
resetOnboarding: () => void;
}
@@ -65,6 +85,15 @@ const initialState = {
teams: [],
invites: [],
inviteRole: "MEMBER" as InviteRole,
teamDetails: {
name: "",
slug: "",
},
teamBrand: {
color: "#000000",
logo: null,
},
teamInvites: [],
};
export const useOnboardingStore = create<OnboardingState>()(
@@ -90,6 +119,18 @@ export const useOnboardingStore = create<OnboardingState>()(
setInviteRole: (role) => set({ inviteRole: role }),
setTeamDetails: (details) =>
set((state) => ({
teamDetails: { ...state.teamDetails, ...details },
})),
setTeamBrand: (brand) =>
set((state) => ({
teamBrand: { ...state.teamBrand, ...brand },
})),
setTeamInvites: (invites) => set({ teamInvites: invites }),
resetOnboarding: () => set(initialState),
}),
{
@@ -102,6 +143,9 @@ export const useOnboardingStore = create<OnboardingState>()(
teams: state.teams,
invites: state.invites,
inviteRole: state.inviteRole,
teamDetails: state.teamDetails,
teamBrand: state.teamBrand,
teamInvites: state.teamInvites,
}),
}
)
@@ -0,0 +1,258 @@
"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>
);
};
@@ -0,0 +1,45 @@
"use server";
import { cookies, headers } from "next/headers";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export async function checkTeamSlugAvailability(slug: string): Promise<{
available: boolean;
message?: string;
}> {
if (!slug || slug.trim() === "") {
return { available: false, message: "Slug is required" };
}
// Check if slug is reserved
if (RESERVED_SUBDOMAINS.includes(slug)) {
return { available: false, message: "This slug is reserved" };
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return { available: false, message: "Unauthorized" };
}
// Check if slug already exists (teams have parentId, organizations don't)
const existingTeam = await prisma.team.findFirst({
where: {
slug,
parentId: null,
},
select: {
id: true,
},
});
if (existingTeam) {
return { available: false, message: "This slug is already taken" };
}
return { available: true };
}
@@ -0,0 +1,143 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField } from "@calcom/ui/components/form";
import { Logo } from "@calcom/ui/components/logo";
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
import { useOnboardingStore } from "../../store/onboarding-store";
import { ValidatedTeamSlug } from "./validated-team-slug";
type TeamDetailsViewProps = {
userEmail: string;
};
export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { teamDetails, setTeamDetails } = useOnboardingStore();
const [teamName, setTeamName] = useState("");
const [teamSlug, setTeamSlug] = useState("");
const [isSlugValid, setIsSlugValid] = useState(false);
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
useEffect(() => {
setTeamName(teamDetails.name);
setTeamSlug(teamDetails.slug);
if (teamDetails.slug) {
setIsSlugManuallyEdited(true);
}
}, [teamDetails]);
useEffect(() => {
if (!isSlugManuallyEdited && teamName) {
const slugifiedName = slugify(teamName);
setTeamSlug(slugifiedName);
}
}, [teamName, isSlugManuallyEdited]);
const handleSlugChange = (value: string) => {
setTeamSlug(value);
setIsSlugManuallyEdited(true);
};
const handleContinue = () => {
if (!isSlugValid) {
return;
}
setTeamDetails({
name: teamName,
slug: teamSlug,
});
router.push("/onboarding/teams/brand");
};
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>
</div>
</div>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* 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>
{/* 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>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,96 @@
"use client";
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Label, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { checkTeamSlugAvailability } from "./action/check-team-slug-availability";
type ValidationState = "idle" | "checking" | "available" | "taken";
type ValidatedTeamSlugProps = {
value: string;
onChange: (value: string) => void;
onValidationChange?: (isValid: boolean) => void;
};
export function ValidatedTeamSlug({ value, onChange, onValidationChange }: ValidatedTeamSlugProps) {
const { t } = useLocale();
const [validationState, setValidationState] = useState<ValidationState>("idle");
const [errorMessage, setErrorMessage] = useState<string>("");
const [_isPending, startTransition] = useTransition();
const timeoutRef = useRef<NodeJS.Timeout>();
const validateSlug = useCallback(
(slug: string) => {
if (!slug || slug.trim() === "") {
setValidationState("idle");
setErrorMessage("");
onValidationChange?.(false);
return;
}
setValidationState("checking");
const checkAvailability = async () => {
const result = await checkTeamSlugAvailability(slug);
if (result.available) {
setValidationState("available");
setErrorMessage("");
onValidationChange?.(true);
} else {
setValidationState("taken");
setErrorMessage(result.message || "This slug is not available");
onValidationChange?.(false);
}
};
startTransition(() => {
checkAvailability();
});
},
[onValidationChange]
);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
validateSlug(value);
}, 500);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [value, validateSlug]);
const urlPrefix = `${WEBAPP_URL}/team/`;
return (
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_url")}</Label>
<TextField
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="acme"
addOnLeading={urlPrefix}
addOnSuffix={
validationState === "checking" ? (
<Icon name="loader" className="text-subtle h-3 w-3 animate-spin" />
) : undefined
}
className={classNames(validationState === "taken" ? "border-error" : "")}
/>
{validationState === "taken" && <p className="text-error text-sm">{errorMessage}</p>}
</div>
);
}
@@ -0,0 +1,221 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import React from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Form, Label, TextField, ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { useCreateTeam } from "../../hooks/useCreateTeam";
import { useOnboardingStore, type InviteRole } from "../../store/onboarding-store";
type TeamInviteViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
role: InviteRole;
}[];
};
export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
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 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;
});
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">
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}
onClick={form.handleSubmit(handleContinue)}>
{t("continue")}
</Button>
</div>
</div>
</div>
{/* Skip button */}
<div className="flex w-full justify-center">
<button
onClick={handleSkip}
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>
</div>
</div>
</div>
);
};