feat: Onboarding V3 (#24299)
* feat: redirect to new onboarding flow * Getting started * Brand details * Preview organization brands * Orgs team pages * Invite team steps * Move to global zustand store * Few darkmdoe fixes * Wip onboarding + stripe flow * Default plan state Server Action for gettting slug satus of org * Remove onboardingId * Confirmation prompt * Update old onboarding flow handlers to handle new fields * update onboarding hook * Filter out organization section for none -company emails * Match placeholders to users domain * Drop migration * Wip new onboarding intent * WIP flow for self-hosted. Same service call just split logic * WIP * Add TODO * Use onboarding user type instead of trpc session * WIP * WIP * pass role and team name from onboarding to save in schema * Add test to ensure role + name + team are persisted into onboarding table * migrate roles to enum values * Update ENUM * Fix type error * Redirect if flag is disabled * Revert packages * Revert all packages/* changes to original branch point * Layout fixes + design * Add slugify * Support saving brand,logo and banner * Cleanup * iMobile fixes * More mobile and darkmdoe fixes * Add I18n * Fix lock file * Fix types * Fix types errors * Copy changes
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
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 { OnboardingView } from "~/onboarding/getting-started/onboarding-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("getting_started")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/getting-started"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
// Hello {username} || there. Not sure how to do this nicely with i18n just yet
|
||||
const userName = session.user.name || "there";
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <OnboardingView userName={userName} userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export default async function OnboardingLayout({ children }: { children: React.ReactNode }) {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isOnboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
|
||||
|
||||
if (!isOnboardingV3Enabled) {
|
||||
redirect("/getting-started");
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
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 { OrganizationBrandView } from "~/onboarding/organization/brand/organization-brand-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("organization_brand")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/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 <OrganizationBrandView 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 { OrganizationDetailsView } from "~/onboarding/organization/details/organization-details-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("organization_details")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/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 <OrganizationDetailsView 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 { OrganizationInviteView } from "~/onboarding/organization/invite/organization-invite-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("invite_teammates")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/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 <OrganizationInviteView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
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 { OrganizationTeamsView } from "~/onboarding/organization/teams/organization-teams-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("organization_teams")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/organization/teams"
|
||||
);
|
||||
};
|
||||
|
||||
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 <OrganizationTeamsView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -30,6 +30,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
"email-verification"
|
||||
);
|
||||
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
||||
const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
|
||||
|
||||
const token = z.string().optional().parse(ctx.query.token);
|
||||
const redirectUrlData = z
|
||||
@@ -62,6 +63,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
isSAMLLoginEnabled,
|
||||
prepopulateFormValues: undefined,
|
||||
emailVerificationEnabled,
|
||||
onboardingV3Enabled,
|
||||
};
|
||||
|
||||
if ((process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" && !token) || signupDisabled) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useOnboardingStore } from "../store/onboarding-store";
|
||||
|
||||
export const OnboardingContinuationPrompt = () => {
|
||||
const router = useRouter();
|
||||
const { organizationDetails, resetOnboarding } = useOnboardingStore();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if there's existing organization data
|
||||
const hasExistingData = organizationDetails.name && organizationDetails.link;
|
||||
setIsVisible(!!hasExistingData);
|
||||
}, [organizationDetails]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
// Navigate to the next step in the flow (brand page)
|
||||
router.push("/onboarding/organization/brand");
|
||||
};
|
||||
|
||||
const handleStartOver = () => {
|
||||
resetOnboarding();
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in fixed bottom-4 right-4 z-50">
|
||||
<div className="bg-default border-muted shadow-default relative w-[360px] rounded-lg border p-4">
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="text-muted hover:text-emphasis absolute right-2 top-2 rounded-md p-1 transition-colors">
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="mb-3 pr-6">
|
||||
<h3 className="text-emphasis mb-1 text-base font-semibold">Continue onboarding?</h3>
|
||||
<p className="text-subtle text-sm">
|
||||
Would you like to carry on with onboarding for{" "}
|
||||
<span className="text-emphasis font-medium">{organizationDetails.name}</span> or start over?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button onClick={handleStartOver} color="secondary">
|
||||
Start over
|
||||
</Button>
|
||||
<Button onClick={handleContinue} color="primary">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { RadioAreaGroup } from "@calcom/ui/components/radio";
|
||||
|
||||
import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt";
|
||||
import { useOnboardingStore, type PlanType } from "../store/onboarding-store";
|
||||
|
||||
type OnboardingViewProps = {
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => {
|
||||
const router = useRouter();
|
||||
const { selectedPlan, setSelectedPlan } = useOnboardingStore();
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selectedPlan === "organization") {
|
||||
router.push("/onboarding/organization/details");
|
||||
}
|
||||
// TODO: Handle other plan types
|
||||
};
|
||||
|
||||
const allPlans = [
|
||||
{
|
||||
id: "personal" as PlanType,
|
||||
title: "For personal use",
|
||||
badge: "Free",
|
||||
description: "Good for individuals who are just starting out and simply want the essentials.",
|
||||
},
|
||||
{
|
||||
id: "team" as PlanType,
|
||||
title: "With my team",
|
||||
badge: "$15 p/mo p/person",
|
||||
description:
|
||||
"Highly recommended for small teams who seek to upgrade their time and perform better as a unit.",
|
||||
},
|
||||
{
|
||||
id: "organization" as PlanType,
|
||||
title: "For my organization",
|
||||
badge: "$37 p/mo p/person",
|
||||
description: "Robust scheduling for larger teams looking to have more control, privacy, and security.",
|
||||
},
|
||||
];
|
||||
|
||||
// Only show organization plan for company emails
|
||||
const plans = allPlans.filter((plan) => {
|
||||
if (plan.id === "organization") {
|
||||
return isCompanyEmail(userEmail);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip">
|
||||
<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.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 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 font-mediumu text-sm 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">Hey {userName}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
To personalize your experience, what do you plan to use Cal.com for?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan options */}
|
||||
<RadioAreaGroup.Group
|
||||
value={selectedPlan ?? undefined}
|
||||
onValueChange={(value) => setSelectedPlan(value as PlanType)}
|
||||
className="flex w-full flex-col gap-1 rounded-[10px]">
|
||||
{plans.map((plan) => (
|
||||
<RadioAreaGroup.Item
|
||||
key={plan.id}
|
||||
value={plan.id}
|
||||
className={
|
||||
selectedPlan === plan.id ? "border-emphasis bg-default" : "bg-default border-subtle"
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="text-emphasis text-base font-semibold leading-4">{plan.title}</p>
|
||||
<Badge variant="gray" size="md" className="hidden h-4 rounded-md px-1 py-1 md:block">
|
||||
<span className="text-emphasis text-xs font-medium leading-3">{plan.badge}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="gray" size="md" className="h-4 w-fit rounded-md px-1 py-1 md:hidden">
|
||||
<span className="text-emphasis text-xs font-medium leading-3">{plan.badge}</span>
|
||||
</Badge>
|
||||
<p className="text-subtle max-w-full text-sm font-normal leading-tight">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
</RadioAreaGroup.Item>
|
||||
))}
|
||||
</RadioAreaGroup.Group>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import type { OnboardingState } from "../store/onboarding-store";
|
||||
|
||||
export const useSubmitOnboarding = () => {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const intentToCreateOrg = trpc.viewer.organizations.intentToCreateOrg.useMutation();
|
||||
|
||||
const submitOnboarding = async (store: OnboardingState, userEmail: string) => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const {
|
||||
selectedPlan,
|
||||
organizationDetails,
|
||||
organizationBrand,
|
||||
teams,
|
||||
invites,
|
||||
inviteRole,
|
||||
resetOnboarding,
|
||||
} = store;
|
||||
|
||||
if (selectedPlan !== "organization") {
|
||||
throw new Error("Only organization plan is currently supported");
|
||||
}
|
||||
|
||||
// Prepare teams data
|
||||
const teamsData = teams
|
||||
.filter((team) => team.name.trim().length > 0)
|
||||
.map((team) => ({
|
||||
id: -1, // New team
|
||||
name: team.name,
|
||||
isBeingMigrated: false,
|
||||
slug: null,
|
||||
}));
|
||||
|
||||
// Prepare invites data
|
||||
const invitedMembersData = invites
|
||||
.filter((invite) => invite.email.trim().length > 0)
|
||||
.map((invite) => ({
|
||||
email: invite.email,
|
||||
teamName: invite.team,
|
||||
teamId: -1,
|
||||
role: inviteRole,
|
||||
}));
|
||||
|
||||
const result = await intentToCreateOrg.mutateAsync({
|
||||
name: organizationDetails.name,
|
||||
slug: organizationDetails.link,
|
||||
bio: organizationDetails.bio || null,
|
||||
logo: organizationBrand.logo,
|
||||
brandColor: organizationBrand.color,
|
||||
bannerUrl: organizationBrand.banner,
|
||||
orgOwnerEmail: userEmail,
|
||||
seats: null,
|
||||
pricePerSeat: null,
|
||||
isPlatform: false,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
teams: teamsData,
|
||||
invitedMembers: invitedMembersData,
|
||||
});
|
||||
|
||||
// If there's a checkout URL, redirect to Stripe (billing enabled flow)
|
||||
if (result.checkoutUrl) {
|
||||
window.location.href = result.checkoutUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// No checkout URL means billing is disabled (self-hosted flow)
|
||||
// Organization has already been created by the backend
|
||||
showToast("Organization created successfully!", "success");
|
||||
// TODO: after this redirect we need to hard refresh the page to see org
|
||||
resetOnboarding();
|
||||
router.push("/getting-started");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage, "error");
|
||||
console.error("Organization creation error:", err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
submitOnboarding,
|
||||
isSubmitting,
|
||||
error,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
"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 OrganizationBrandViewProps = {
|
||||
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 OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { organizationBrand, setOrganizationBrand, organizationDetails } = useOnboardingStore();
|
||||
|
||||
const [brandColor, setBrandColor] = useState("#000000");
|
||||
const [_logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [_bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
|
||||
// Load from store on mount
|
||||
useEffect(() => {
|
||||
setBrandColor(organizationBrand.color);
|
||||
setLogoPreview(organizationBrand.logo);
|
||||
setBannerPreview(organizationBrand.banner);
|
||||
}, [organizationBrand]);
|
||||
|
||||
const handleLogoChange = (file: File | null) => {
|
||||
setLogoFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setLogoPreview(base64);
|
||||
setOrganizationBrand({ logo: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setLogoPreview(null);
|
||||
setOrganizationBrand({ logo: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBannerChange = (file: File | null) => {
|
||||
setBannerFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setBannerPreview(base64);
|
||||
setOrganizationBrand({ banner: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setBannerPreview(null);
|
||||
setOrganizationBrand({ banner: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
// Save brand color to store
|
||||
setOrganizationBrand({ color: brandColor });
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip brand setup and go to teams
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
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.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="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("onboarding_org_brand_title")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_org_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);
|
||||
setOrganizationBrand({ 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>
|
||||
|
||||
{/* Banner Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("onboarding_banner_label")}</p>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => document.getElementById("banner-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="banner-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_banner_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Preview */}
|
||||
<div className="bg-muted border-muted flex hidden h-[328px] 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-[74px]">
|
||||
{/* Banner preview */}
|
||||
<div className="bg-muted border-muted absolute left-1 top-1 h-[92px] w-[272px] overflow-hidden rounded-[4px] border">
|
||||
{bannerPreview && (
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt={t("onboarding_banner_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 ">
|
||||
{organizationDetails.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,46 @@
|
||||
"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 checkSlugAvailability(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
|
||||
const existingOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
parentId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingOrg) {
|
||||
return { available: false, message: "This slug is already taken" };
|
||||
}
|
||||
|
||||
return { available: true };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField, TextArea } 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 { ValidatedOrganizationSlug } from "./validated-organization-slug";
|
||||
|
||||
type OrganizationDetailsViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
// Helper function to slugify organization name
|
||||
const slugify = (text: string): string => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, "") // Remove special characters
|
||||
.replace(/[\s_-]+/g, "-") // Replace spaces, underscores with single dash
|
||||
.replace(/^-+|-+$/g, ""); // Remove leading/trailing dashes
|
||||
};
|
||||
|
||||
export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { organizationDetails, setOrganizationDetails } = useOnboardingStore();
|
||||
|
||||
const [organizationName, setOrganizationName] = useState("");
|
||||
const [organizationLink, setOrganizationLink] = useState("");
|
||||
const [organizationBio, setOrganizationBio] = useState("");
|
||||
const [isSlugValid, setIsSlugValid] = useState(false);
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
|
||||
// Load from store on mount
|
||||
useEffect(() => {
|
||||
setOrganizationName(organizationDetails.name);
|
||||
setOrganizationLink(organizationDetails.link);
|
||||
setOrganizationBio(organizationDetails.bio);
|
||||
// If there's a pre-existing link, consider it manually set
|
||||
if (organizationDetails.link) {
|
||||
setIsSlugManuallyEdited(true);
|
||||
}
|
||||
}, [organizationDetails]);
|
||||
|
||||
// Auto-populate slug from organization name (unless manually edited)
|
||||
useEffect(() => {
|
||||
if (!isSlugManuallyEdited && organizationName) {
|
||||
const slugifiedName = slugify(organizationName);
|
||||
setOrganizationLink(slugifiedName);
|
||||
}
|
||||
}, [organizationName, isSlugManuallyEdited]);
|
||||
|
||||
const handleSlugChange = (value: string) => {
|
||||
setOrganizationLink(value);
|
||||
setIsSlugManuallyEdited(true);
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!isSlugValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to store
|
||||
setOrganizationDetails({
|
||||
name: organizationName,
|
||||
link: organizationLink,
|
||||
bio: organizationBio,
|
||||
});
|
||||
router.push("/onboarding/organization/brand");
|
||||
};
|
||||
|
||||
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.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("onboarding_org_details_title")}
|
||||
</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_org_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">
|
||||
{/* Organization Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("organization_name")}
|
||||
</Label>
|
||||
<TextField
|
||||
value={organizationName}
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
placeholder={t("organization_name")}
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organization Link */}
|
||||
<ValidatedOrganizationSlug
|
||||
value={organizationLink}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
|
||||
{/* Organization Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">
|
||||
{t("onboarding_org_bio_label")}
|
||||
</Label>
|
||||
<TextArea
|
||||
value={organizationBio}
|
||||
onChange={(e) => setOrganizationBio(e.target.value)}
|
||||
placeholder={t("onboarding_org_bio_placeholder")}
|
||||
rows={4}
|
||||
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
|
||||
/>
|
||||
</div>
|
||||
</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 || !organizationName || !organizationLink}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { checkSlugAvailability } from "./action/check-slug-availability";
|
||||
|
||||
type ValidationState = "idle" | "checking" | "available" | "taken";
|
||||
|
||||
type ValidatedOrganizationSlugProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onValidationChange?: (isValid: boolean) => void;
|
||||
};
|
||||
|
||||
export function ValidatedOrganizationSlug({
|
||||
value,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
}: ValidatedOrganizationSlugProps) {
|
||||
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");
|
||||
|
||||
// Call server action
|
||||
checkSlugAvailability(slug).then((result) => {
|
||||
startTransition(() => {
|
||||
if (result.available) {
|
||||
setValidationState("available");
|
||||
setErrorMessage("");
|
||||
onValidationChange?.(true);
|
||||
} else {
|
||||
setValidationState("taken");
|
||||
setErrorMessage(result.message || "This slug is not available");
|
||||
onValidationChange?.(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
[onValidationChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear previous timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
validateSlug(value);
|
||||
}, 500);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, validateSlug]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">Organization link</Label>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="acme"
|
||||
addOnSuffix={
|
||||
<div className="flex items-center gap-2">
|
||||
{validationState === "checking" && (
|
||||
<Icon name="loader" className="text-subtle h-3 w-3 animate-spin" />
|
||||
)}
|
||||
<span className="text-subtle text-sm">.cal.com</span>
|
||||
</div>
|
||||
}
|
||||
className={classNames(validationState === "taken" ? "border-error" : "")}
|
||||
/>
|
||||
{validationState === "taken" && <p className="text-error text-sm">{errorMessage}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"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, Select, ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationInviteViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
invites: {
|
||||
email: string;
|
||||
team: string;
|
||||
role: "MEMBER" | "ADMIN";
|
||||
}[];
|
||||
};
|
||||
|
||||
export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
// We are using email mode for now until we implement the other invite methods, csv, workspace - invite link
|
||||
const isEmailMode = true;
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const usersEmailDomain = userEmail.split("@")[1];
|
||||
const { invites: storedInvites, inviteRole, setInvites, setInviteRole } = store;
|
||||
const { submitOnboarding, isSubmitting } = useSubmitOnboarding();
|
||||
|
||||
const formSchema = z.object({
|
||||
invites: z.array(
|
||||
z.object({
|
||||
email: z.string().email(t("invalid_email_address")),
|
||||
team: z.string().min(1, t("onboarding_team_required")),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
invites: storedInvites.length > 0 ? storedInvites : [{ email: "", team: "", role: inviteRole }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
|
||||
const handleSubmitOnboarding = async (invitesData: FormValues["invites"]) => {
|
||||
// Save invites to store before submitting
|
||||
setInvites(invitesData);
|
||||
await submitOnboarding(store, userEmail);
|
||||
};
|
||||
|
||||
const handleContinue = async (data?: FormValues) => {
|
||||
if (data) {
|
||||
await handleSubmitOnboarding(data.invites);
|
||||
} else {
|
||||
await handleSubmitOnboarding([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
// Complete onboarding without invites
|
||||
await handleSubmitOnboarding([]);
|
||||
};
|
||||
|
||||
const handleInviteViaEmail = () => {
|
||||
router.push("/onboarding/organization/invite?mode=email");
|
||||
};
|
||||
|
||||
const hasValidInvites = fields.some((_, index) => {
|
||||
const email = form.watch(`invites.${index}.email`);
|
||||
const team = form.watch(`invites.${index}.team`);
|
||||
return email && email.trim().length > 0 && team && team.trim().length > 0;
|
||||
});
|
||||
|
||||
// Get teams from store, filter out empty teams
|
||||
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
|
||||
const teams =
|
||||
filteredTeams.length > 0
|
||||
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
|
||||
: [];
|
||||
|
||||
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("onboarding_org_invite_title")}
|
||||
</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{isEmailMode
|
||||
? t("onboarding_org_invite_subtitle_email")
|
||||
: t("onboarding_org_invite_subtitle_full")}
|
||||
</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">
|
||||
{!isEmailMode ? (
|
||||
// Coming soon
|
||||
// Initial invite options view
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Button color="secondary" className="w-full justify-center" StartIcon="mail">
|
||||
{t("onboarding_connect_google_workspace")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-subtle h-px flex-1" />
|
||||
<span className="text-subtle text-xs">{t("onboarding_or_divider")}</span>
|
||||
<div className="bg-subtle h-px flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="flex-1 justify-center"
|
||||
onClick={handleInviteViaEmail}>
|
||||
{t("invite_via_email")}
|
||||
</Button>
|
||||
<Button color="secondary" className="flex-1 justify-center" StartIcon="upload">
|
||||
{t("onboarding_upload_csv")}
|
||||
</Button>
|
||||
<Button color="secondary" className="flex-1 justify-center" StartIcon="link">
|
||||
{t("onboarding_copy_invite_link")}
|
||||
</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) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
|
||||
options={[
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Email invite form
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Email and Team inputs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
<Label className="text-emphasis text-sm font-medium">{t("team")}</Label>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5 pt-2 md:pt-0">
|
||||
<div className="grid flex-1 items-start gap-2 md:grid-cols-2 ">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`invites.${index}.email`)}
|
||||
placeholder={`dave@${usersEmailDomain}`}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
options={teams}
|
||||
value={teams.find((t) => t.value === form.watch(`invites.${index}.team`))}
|
||||
onChange={(option) => {
|
||||
if (option) {
|
||||
form.setValue(`invites.${index}.team`, option.value);
|
||||
}
|
||||
}}
|
||||
placeholder={t("select_team")}
|
||||
/>
|
||||
</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: "", team: "", role: "MEMBER" })}>
|
||||
{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) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
|
||||
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={isEmailMode ? "submit" : "button"}
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={(isEmailMode && !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}
|
||||
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4">
|
||||
{t("ill_do_this_later")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
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, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationTeamsViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
teams: { name: string }[];
|
||||
};
|
||||
|
||||
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { teams: storedTeams, setTeams } = useOnboardingStore();
|
||||
|
||||
const formSchema = z.object({
|
||||
teams: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1, t("team_name_required")),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
teams: storedTeams.length > 0 ? storedTeams : [{ name: "" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "teams",
|
||||
});
|
||||
|
||||
const handleContinue = (data: FormValues) => {
|
||||
// Save teams to store
|
||||
setTeams(data.teams);
|
||||
router.push("/onboarding/organization/invite");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Skip teams and go to invite
|
||||
router.push("/onboarding/organization/invite");
|
||||
};
|
||||
|
||||
const hasValidTeams = fields.some((_, index) => {
|
||||
const teamName = form.watch(`teams.${index}.name`);
|
||||
return teamName && teamName.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.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="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("onboarding_org_teams_title")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_org_teams_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="bg-default border-subtle w-full rounded-md border">
|
||||
<div className="flex w-full flex-col gap-8 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
|
||||
</div>
|
||||
|
||||
{/* Team fields */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex w-full items-end gap-0.5">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`teams.${index}.name`)}
|
||||
placeholder={t("team")}
|
||||
className="h-7 w-full rounded-[10px] text-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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="w-fit"
|
||||
onClick={() => append({ name: "" })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="absolute left-[368px] rounded-[10px]"
|
||||
onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button type="submit" color="primary" className="rounded-[10px]" disabled={!hasValidTeams}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type PlanType = "personal" | "team" | "organization";
|
||||
export type InviteRole = "MEMBER" | "ADMIN";
|
||||
|
||||
export interface OrganizationDetails {
|
||||
name: string;
|
||||
link: string;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
export interface OrganizationBrand {
|
||||
color: string;
|
||||
logo: string | null; // base64 or URL
|
||||
banner: string | null; // base64 or URL
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
email: string;
|
||||
team: string;
|
||||
role: InviteRole;
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
selectedPlan: PlanType | null;
|
||||
|
||||
organizationDetails: OrganizationDetails;
|
||||
|
||||
organizationBrand: OrganizationBrand;
|
||||
|
||||
teams: Team[];
|
||||
|
||||
invites: Invite[];
|
||||
inviteRole: InviteRole;
|
||||
|
||||
// Actions
|
||||
setSelectedPlan: (plan: PlanType) => void;
|
||||
setOrganizationDetails: (details: Partial<OrganizationDetails>) => void;
|
||||
setOrganizationBrand: (brand: Partial<OrganizationBrand>) => void;
|
||||
setTeams: (teams: Team[]) => void;
|
||||
setInvites: (invites: Invite[]) => void;
|
||||
setInviteRole: (role: InviteRole) => void;
|
||||
|
||||
// Reset
|
||||
resetOnboarding: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
selectedPlan: "personal" as PlanType,
|
||||
organizationDetails: {
|
||||
name: "",
|
||||
link: "",
|
||||
bio: "",
|
||||
},
|
||||
organizationBrand: {
|
||||
color: "#000000",
|
||||
logo: null,
|
||||
banner: null,
|
||||
},
|
||||
teams: [],
|
||||
invites: [],
|
||||
inviteRole: "MEMBER" as InviteRole,
|
||||
};
|
||||
|
||||
export const useOnboardingStore = create<OnboardingState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
setSelectedPlan: (plan) => set({ selectedPlan: plan }),
|
||||
|
||||
setOrganizationDetails: (details) =>
|
||||
set((state) => ({
|
||||
organizationDetails: { ...state.organizationDetails, ...details },
|
||||
})),
|
||||
|
||||
setOrganizationBrand: (brand) =>
|
||||
set((state) => ({
|
||||
organizationBrand: { ...state.organizationBrand, ...brand },
|
||||
})),
|
||||
|
||||
setTeams: (teams) => set({ teams }),
|
||||
|
||||
setInvites: (invites) => set({ invites }),
|
||||
|
||||
setInviteRole: (role) => set({ inviteRole: role }),
|
||||
|
||||
resetOnboarding: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: "cal-onboarding-storage", // localStorage key
|
||||
// Optional: Only persist certain fields
|
||||
partialize: (state) => ({
|
||||
selectedPlan: state.selectedPlan,
|
||||
organizationDetails: state.organizationDetails,
|
||||
organizationBrand: state.organizationBrand,
|
||||
teams: state.teams,
|
||||
invites: state.invites,
|
||||
inviteRole: state.inviteRole,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -184,6 +184,7 @@ export default function Signup({
|
||||
orgAutoAcceptEmail,
|
||||
redirectUrl,
|
||||
emailVerificationEnabled,
|
||||
onboardingV3Enabled,
|
||||
}: SignupProps) {
|
||||
const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username;
|
||||
const [isSamlSignup, setIsSamlSignup] = useState(false);
|
||||
@@ -262,7 +263,8 @@ export default function Signup({
|
||||
|
||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
|
||||
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : "getting-started";
|
||||
const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started";
|
||||
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath;
|
||||
const gettingStartedWithPlatform = "settings/platform/new";
|
||||
|
||||
const constructCallBackIfUrlPresent = () => {
|
||||
|
||||
@@ -3846,5 +3846,35 @@
|
||||
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
|
||||
"what_would_you_like_to_block": "What would you like to block?",
|
||||
"domain_name": "Domain Name",
|
||||
"onboarding_org_details_title": "Add your Organization's details",
|
||||
"onboarding_org_details_subtitle": "Customize your booking pages with your Organization's name and bio",
|
||||
"onboarding_org_bio_label": "Organization bio",
|
||||
"onboarding_org_bio_placeholder": "Acme helps thousands of companies expand globally with unmatched speed and flexibility.",
|
||||
"onboarding_org_teams_title": "Add your Organization's teams",
|
||||
"onboarding_org_teams_subtitle": "Start scheduling together by adding your teams",
|
||||
"onboarding_org_brand_title": "Add your Organization's brand",
|
||||
"onboarding_org_brand_subtitle": "Customize your booking pages with your logo and a custom banner",
|
||||
"onboarding_primary_color_label": "Primary color",
|
||||
"onboarding_banner_label": "Banner",
|
||||
"onboarding_logo_size_hint": "Recommended size 64x64px (max 10mb)",
|
||||
"onboarding_banner_size_hint": "Recommended size 1500x150px (max 10mb)",
|
||||
"onboarding_preview_nameless": "Nameless",
|
||||
"onboarding_preview_example_title": "Enterprise sales",
|
||||
"onboarding_preview_example_description": "Jump on a call with one of our sales reps to learn more",
|
||||
"onboarding_logo_preview_alt": "Logo preview",
|
||||
"onboarding_banner_preview_alt": "Banner preview",
|
||||
"onboarding_pick_color_aria": "Pick color",
|
||||
"onboarding_org_invite_title": "Invite your teammates",
|
||||
"onboarding_org_invite_subtitle_email": "Enter your teammates email address and set their team to add them to your Organization.",
|
||||
"onboarding_org_invite_subtitle_full": "Connect your Google Workspace, invite via email, upload a CSV file or copy the invite link and share it with your teammates to add them to your Organization.",
|
||||
"onboarding_connect_google_workspace": "Connect Google Workspace",
|
||||
"onboarding_upload_csv": "Upload CSV file",
|
||||
"onboarding_copy_invite_link": "Copy invite link",
|
||||
"onboarding_invite_all_as": "Invite all as",
|
||||
"onboarding_admins": "Admins",
|
||||
"onboarding_modify_roles_later": "You can modify roles later",
|
||||
"onboarding_skip_for_now": "Skip for now",
|
||||
"onboarding_or_divider": "or",
|
||||
"onboarding_team_required": "Team is required",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -191,14 +191,9 @@ export async function assertCanCreateOrg({
|
||||
"email-verification"
|
||||
);
|
||||
|
||||
const verifiedUser =
|
||||
orgOwner.completedOnboarding && (emailVerificationEnabled ? !!orgOwner.emailVerified : true);
|
||||
const verifiedUser = emailVerificationEnabled ? !!orgOwner.emailVerified : true;
|
||||
if (!verifiedUser) {
|
||||
log.warn(
|
||||
"you_need_to_complete_user_onboarding_before_creating_an_organization",
|
||||
safeStringify({ userId: orgOwner.id })
|
||||
);
|
||||
throw new OrgCreationError("you_need_to_complete_user_onboarding_before_creating_an_organization");
|
||||
throw new OrgCreationError("you_need_to_verify_your_email_before_creating_an_organization");
|
||||
}
|
||||
|
||||
if (isNotACompanyEmail(orgOwner.email) && !isPlatform) {
|
||||
|
||||
+54
@@ -810,5 +810,59 @@ describe("BillingEnabledOrgOnboardingService", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should transfer branding data (logo, brandColor, bannerUrl) from onboarding to organization", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const onboardingWithBranding = await createTestOnboarding({
|
||||
logo: "https://example.com/logo.png",
|
||||
bio: "Test organization bio",
|
||||
brandColor: "#FF5733",
|
||||
bannerUrl: "https://example.com/banner.jpg",
|
||||
});
|
||||
|
||||
await createTestUser({
|
||||
email: onboardingWithBranding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const { organization } = await service.createOrganization(onboardingWithBranding, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
expect(organization.logoUrl).toBe("https://example.com/logo.png");
|
||||
expect(organization.bio).toBe("Test organization bio");
|
||||
expect(organization.brandColor).toBe("#FF5733");
|
||||
expect(organization.bannerUrl).toBe("https://example.com/banner.jpg");
|
||||
});
|
||||
|
||||
it("should handle null branding data correctly", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const onboardingWithNullBranding = await createTestOnboarding({
|
||||
logo: null,
|
||||
bio: null,
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
});
|
||||
|
||||
await createTestUser({
|
||||
email: onboardingWithNullBranding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const { organization } = await service.createOrganization(onboardingWithNullBranding, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
expect(organization.logoUrl).toBeNull();
|
||||
expect(organization.bio).toBeNull();
|
||||
expect(organization.brandColor).toBeNull();
|
||||
expect(organization.bannerUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export function extractDomainFromEmail(email: string) {
|
||||
let out = "";
|
||||
try {
|
||||
const match = email.match(/^(?:.*?:\/\/)?.*?(?<root>[\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
|
||||
out = (match && match.groups?.root) ?? "";
|
||||
const match = email.match(/^(?:.*?:\/\/)?.*?([\w\-]*(?:\.\w{2,}|\.\w{2,}\.\w{2}))(?:[\/?#:]|$)/);
|
||||
out = (match && match[1]) ?? "";
|
||||
} catch (ignore) {}
|
||||
return out.split(".")[0];
|
||||
}
|
||||
|
||||
@@ -150,3 +150,64 @@ describe("Organization.getVerifiedOrganizationByAutoAcceptEmailDomain", () => {
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization.create", () => {
|
||||
it("should create organization with branding data (logoUrl, brandColor, bannerUrl)", async () => {
|
||||
const orgData = {
|
||||
name: "Test Organization",
|
||||
slug: "test-org",
|
||||
isOrganizationConfigured: true,
|
||||
isOrganizationAdminReviewed: true,
|
||||
autoAcceptEmail: "test.com",
|
||||
seats: 10,
|
||||
pricePerSeat: 15,
|
||||
isPlatform: false,
|
||||
billingPeriod: "MONTHLY" as const,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
bio: "Test organization bio",
|
||||
brandColor: "#FF5733",
|
||||
bannerUrl: "https://example.com/banner.jpg",
|
||||
};
|
||||
|
||||
const organization = await OrganizationRepository.create(orgData);
|
||||
|
||||
expect(organization).toMatchObject({
|
||||
name: "Test Organization",
|
||||
slug: "test-org",
|
||||
isOrganization: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
bio: "Test organization bio",
|
||||
brandColor: "#FF5733",
|
||||
bannerUrl: "https://example.com/banner.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create organization with null branding data", async () => {
|
||||
const orgData = {
|
||||
name: "Test Organization",
|
||||
slug: "test-org-2",
|
||||
isOrganizationConfigured: true,
|
||||
isOrganizationAdminReviewed: true,
|
||||
autoAcceptEmail: "test.com",
|
||||
seats: null,
|
||||
pricePerSeat: null,
|
||||
isPlatform: false,
|
||||
logoUrl: null,
|
||||
bio: null,
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
};
|
||||
|
||||
const organization = await OrganizationRepository.create(orgData);
|
||||
|
||||
expect(organization).toMatchObject({
|
||||
name: "Test Organization",
|
||||
slug: "test-org-2",
|
||||
isOrganization: true,
|
||||
logoUrl: null,
|
||||
bio: null,
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ export class OrganizationRepository {
|
||||
billingPeriod?: "MONTHLY" | "ANNUALLY";
|
||||
logoUrl: string | null;
|
||||
bio: string | null;
|
||||
brandColor: string | null;
|
||||
bannerUrl: string | null;
|
||||
requestedSlug?: string | null;
|
||||
};
|
||||
owner: {
|
||||
@@ -84,6 +86,8 @@ export class OrganizationRepository {
|
||||
isPlatform: boolean;
|
||||
logoUrl: string | null;
|
||||
bio: string | null;
|
||||
brandColor: string | null;
|
||||
bannerUrl: string | null;
|
||||
};
|
||||
owner: {
|
||||
email: string;
|
||||
@@ -133,6 +137,8 @@ export class OrganizationRepository {
|
||||
isPlatform: boolean;
|
||||
logoUrl: string | null;
|
||||
bio: string | null;
|
||||
brandColor: string | null;
|
||||
bannerUrl: string | null;
|
||||
requestedSlug?: string | null;
|
||||
}) {
|
||||
return await prisma.team.create({
|
||||
@@ -140,9 +146,10 @@ export class OrganizationRepository {
|
||||
name: orgData.name,
|
||||
isOrganization: true,
|
||||
slug: orgData.slug,
|
||||
// This is huge and causes issues, we need to have the logic to convert logo to logoUrl and then use that url ehre.
|
||||
// logoUrl: orgData.logoUrl,
|
||||
logoUrl: orgData.logoUrl,
|
||||
bio: orgData.bio,
|
||||
brandColor: orgData.brandColor,
|
||||
bannerUrl: orgData.bannerUrl,
|
||||
organizationSettings: {
|
||||
create: {
|
||||
isAdminReviewed: orgData.isOrganizationAdminReviewed,
|
||||
|
||||
@@ -183,6 +183,8 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
||||
logoUrl: null,
|
||||
bio: null,
|
||||
paymentSubscriptionId: null,
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
};
|
||||
|
||||
// Create a new user and invite them as the owner of the organization
|
||||
|
||||
Reference in New Issue
Block a user