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 */} +
+ +
+
+
+
+ ); +};