From 435587f0432661a439d3e67cd22f5adeb699046b Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:00:11 +0000 Subject: [PATCH] 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. --- ## 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. --- .../onboarding/teams/brand/page.tsx | 34 +++ .../onboarding/teams/details/page.tsx | 34 +++ .../onboarding/teams/invite/page.tsx | 34 +++ .../getting-started/onboarding-view.tsx | 5 +- .../modules/onboarding/hooks/useCreateTeam.ts | 49 ++++ .../onboarding/store/onboarding-store.ts | 44 +++ .../teams/brand/team-brand-view.tsx | 258 ++++++++++++++++++ .../action/check-team-slug-availability.ts | 45 +++ .../teams/details/team-details-view.tsx | 143 ++++++++++ .../teams/details/validated-team-slug.tsx | 96 +++++++ .../teams/invite/team-invite-view.tsx | 221 +++++++++++++++ 11 files changed, 961 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/teams/details/page.tsx create mode 100644 apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx create mode 100644 apps/web/modules/onboarding/hooks/useCreateTeam.ts create mode 100644 apps/web/modules/onboarding/teams/brand/team-brand-view.tsx create mode 100644 apps/web/modules/onboarding/teams/details/action/check-team-slug-availability.ts create mode 100644 apps/web/modules/onboarding/teams/details/team-details-view.tsx create mode 100644 apps/web/modules/onboarding/teams/details/validated-team-slug.tsx create mode 100644 apps/web/modules/onboarding/teams/invite/team-invite-view.tsx diff --git a/apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx new file mode 100644 index 0000000000..72f40aaa21 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/onboarding/teams/brand/page.tsx @@ -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 ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/teams/details/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/teams/details/page.tsx new file mode 100644 index 0000000000..8c1b2eb010 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/onboarding/teams/details/page.tsx @@ -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 ; +}; + +export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx new file mode 100644 index 0000000000..bc0a42eaf2 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/onboarding/teams/invite/page.tsx @@ -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 ; +}; + +export default ServerPage; diff --git a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx index 02d6acad40..e5f263eb05 100644 --- a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx +++ b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx @@ -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 = [ diff --git a/apps/web/modules/onboarding/hooks/useCreateTeam.ts b/apps/web/modules/onboarding/hooks/useCreateTeam.ts new file mode 100644 index 0000000000..1b89fa0062 --- /dev/null +++ b/apps/web/modules/onboarding/hooks/useCreateTeam.ts @@ -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, + }; +} diff --git a/apps/web/modules/onboarding/store/onboarding-store.ts b/apps/web/modules/onboarding/store/onboarding-store.ts index 9da8083b68..b352b1232d 100644 --- a/apps/web/modules/onboarding/store/onboarding-store.ts +++ b/apps/web/modules/onboarding/store/onboarding-store.ts @@ -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) => void; @@ -46,6 +61,11 @@ export interface OnboardingState { setInvites: (invites: Invite[]) => void; setInviteRole: (role: InviteRole) => void; + // Team actions + setTeamDetails: (details: Partial) => void; + setTeamBrand: (brand: Partial) => 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()( @@ -90,6 +119,18 @@ export const useOnboardingStore = create()( 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()( teams: state.teams, invites: state.invites, inviteRole: state.inviteRole, + teamDetails: state.teamDetails, + teamBrand: state.teamBrand, + teamInvites: state.teamInvites, }), } ) diff --git a/apps/web/modules/onboarding/teams/brand/team-brand-view.tsx b/apps/web/modules/onboarding/teams/brand/team-brand-view.tsx new file mode 100644 index 0000000000..75fb540a5f --- /dev/null +++ b/apps/web/modules/onboarding/teams/brand/team-brand-view.tsx @@ -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 ( +
+ + +
+ ); +}; + +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 */} +
+
+
+
+
+
+ +
+

{userEmail}

+
+
+ + {/* 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 && ( + {t("onboarding_logo_preview_alt")} + )} +
+
+ + handleLogoChange(e.target.files?.[0] || null)} + /> +
+
+

+ {t("onboarding_logo_size_hint")} +

+
+
+ + {/* Right side - Preview */} +
+
+

{t("preview")}

+
+ {/* Content */} +
+
+
+ {/* Logo preview */} +
+ {logoPreview && ( + {t("onboarding_logo_preview_alt")} + )} +
+

+ {teamDetails.name || t("onboarding_preview_nameless")} +

+
+
+
+

+ {t("onboarding_preview_example_title")} +

+

+ {t("onboarding_preview_example_description")} +

+
+
+
+
+ {[134, 104, 84, 104].map((width, i) => ( +
+
+
+
+ ))} +
+
+
+
+
+
+
+
+
+ + {/* Footer */} +
+ + +
+
+
+
+
+
+ ); +}; diff --git a/apps/web/modules/onboarding/teams/details/action/check-team-slug-availability.ts b/apps/web/modules/onboarding/teams/details/action/check-team-slug-availability.ts new file mode 100644 index 0000000000..1bc7623e1c --- /dev/null +++ b/apps/web/modules/onboarding/teams/details/action/check-team-slug-availability.ts @@ -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 }; +} diff --git a/apps/web/modules/onboarding/teams/details/team-details-view.tsx b/apps/web/modules/onboarding/teams/details/team-details-view.tsx new file mode 100644 index 0000000000..410b8ca86d --- /dev/null +++ b/apps/web/modules/onboarding/teams/details/team-details-view.tsx @@ -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 ( +
+ + {/* Header */} +
+ + + {/* Progress dots - centered */} +
+
+
+
+
+
+ +
+

{userEmail}

+
+
+ + {/* Main content */} +
+
+ {/* Card */} +
+
+ {/* Card Header */} +
+
+

{t("create_your_team")}

+

+ {t("team_onboarding_details_subtitle")} +

+
+
+ + {/* Form */} +
+
+
+
+
+ {/* 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 */} + +
+
+
+
+
+ + {/* Footer */} +
+ +
+
+
+
+
+
+ ); +}; diff --git a/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx b/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx new file mode 100644 index 0000000000..5d1e92097e --- /dev/null +++ b/apps/web/modules/onboarding/teams/details/validated-team-slug.tsx @@ -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("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const [_isPending, startTransition] = useTransition(); + const timeoutRef = useRef(); + + 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 ( +
+ + onChange(e.target.value)} + placeholder="acme" + addOnLeading={urlPrefix} + addOnSuffix={ + validationState === "checking" ? ( + + ) : undefined + } + className={classNames(validationState === "taken" ? "border-error" : "")} + /> + {validationState === "taken" &&

{errorMessage}

} +
+ ); +} diff --git a/apps/web/modules/onboarding/teams/invite/team-invite-view.tsx b/apps/web/modules/onboarding/teams/invite/team-invite-view.tsx new file mode 100644 index 0000000000..470811cf7d --- /dev/null +++ b/apps/web/modules/onboarding/teams/invite/team-invite-view.tsx @@ -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("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 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 ( +
+ {/* Header */} +
+ + + {/* Progress dots - centered */} +
+
+
+
+
+
+ +
+

{userEmail}

+
+
+ + {/* Main content */} +
+
+ {/* Card */} +
+
+ {/* Card Header */} +
+
+

{t("invite_team_members")}

+

{t("team_invite_subtitle")}

+
+
+ + {/* Content */} +
+
+
+
+ {/* Email inputs */} +
+ + + {fields.map((field, index) => ( +
+
+ +
+ +
+ ))} + + {/* Add button */} + +
+ + {/* 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 */} +
+ +
+
+
+ + {/* Skip button */} +
+ +
+
+
+
+ ); +};