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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user