chore: Update personal and team onboarding flows (remove profile/video steps, add settings, improve team invites) (#24947)
## What does this PR do? - Redesigns and streamlines the onboarding flow for personal, team, and organization accounts - Consolidates shared components and improves code organization - Adds browser preview for better user experience during onboarding - Simplifies the personal onboarding flow by removing the video integration step - Enhances team onboarding with CSV upload functionality ## Visual Demo (For contributors especially) #### Image Demo: - The PR adds a new browser preview component that shows users how their profile/team will look during onboarding - Redesigned UI with a more consistent layout across all onboarding steps - Improved mobile responsiveness with better component organization ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code. - [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox. - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Test the complete onboarding flow for personal accounts: - Start at `/onboarding/getting-started` - Proceed through personal details and calendar setup - Verify the flow completes successfully 2. Test the team onboarding flow: - Start at `/onboarding/getting-started` and select team - Complete team details - Test the invite options including CSV upload - Verify team creation works correctly 3. Test the organization onboarding flow: - Start at `/onboarding/getting-started` and select organization - Complete organization details and branding - Test member invitations - Verify organization creation works correctly 4. Verify browser preview functionality: - Check that the preview updates in real-time as you enter information - Confirm it displays correctly on different screen sizes ## Checklist - I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code follows the style guidelines of this project - I have commented my code, particularly in hard-to-understand areas - I have checked if my changes generate no new warnings
This commit is contained in:
@@ -26,11 +26,9 @@ const ServerPage = async () => {
|
||||
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} />;
|
||||
return <OnboardingView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { PersonalProfileView } from "~/onboarding/personal/profile/personal-profile-view";
|
||||
import { PersonalSettingsView } from "~/onboarding/personal/settings/personal-settings-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
@@ -28,7 +28,7 @@ const ServerPage = async () => {
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <PersonalProfileView userEmail={userEmail} />;
|
||||
return <PersonalSettingsView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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 { PersonalVideoView } from "~/onboarding/personal/video/personal-video-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("connect_video")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/personal/video"
|
||||
);
|
||||
};
|
||||
|
||||
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 <PersonalVideoView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { TeamBrandView } from "~/onboarding/teams/brand/team-brand-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("team_brand")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/onboarding/teams/brand"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <TeamBrandView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
export const OnboardingCalendarBrowserView = () => {
|
||||
const { t } = useLocale();
|
||||
const webappUrl = WEBAPP_URL.replace(/^https?:\/\//, "");
|
||||
|
||||
const calendarIntegrations: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
icon: IconName;
|
||||
}> = [
|
||||
{
|
||||
name: t("google_calendar"),
|
||||
description: t("onboarding_calendar_browser_view_google_description"),
|
||||
icon: "calendar",
|
||||
},
|
||||
{
|
||||
name: t("outlook_calendar"),
|
||||
description: t("onboarding_calendar_browser_view_outlook_description"),
|
||||
icon: "mail",
|
||||
},
|
||||
{
|
||||
name: t("apple_calendar"),
|
||||
description: t("onboarding_calendar_browser_view_apple_description"),
|
||||
icon: "calendar-days",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle hidden h-full w-full flex-col rounded-l-2xl border xl:flex">
|
||||
{/* Browser header */}
|
||||
<div className="border-subtle flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b bg-white p-3">
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex shrink-0 items-center gap-4 opacity-50">
|
||||
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-muted flex w-full items-center gap-2 rounded-[32px] px-3 py-2">
|
||||
<Icon name="lock" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default text-sm font-medium leading-tight">{webappUrl}/settings/calendars</p>
|
||||
</div>
|
||||
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="bg-muted h-full pl-11 pt-11">
|
||||
<div className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-xl border">
|
||||
{/* Header */}
|
||||
<div className="border-subtle flex flex-col gap-4 border-b p-4">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h2 className="text-emphasis text-xl font-semibold leading-tight">
|
||||
{t("connect_your_calendar")}
|
||||
</h2>
|
||||
<p className="text-subtle text-sm leading-normal">
|
||||
{t("onboarding_calendar_browser_view_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Integrations List */}
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
{calendarIntegrations.map((integration, index) => (
|
||||
<div key={integration.name} className="opacity-30">
|
||||
{index > 0 && <div className="border-subtle h-px border-t" />}
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="bg-muted flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
|
||||
<Icon name={integration.icon} className="text-emphasis h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h3 className="text-default text-sm font-semibold leading-none">{integration.name}</h3>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{integration.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-emphasis flex h-6 items-center justify-center rounded-md px-2">
|
||||
<span className="text-emphasis text-xs font-medium leading-none">{t("connected")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -10,20 +12,43 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { type IconName } from "@calcom/ui/components/icon";
|
||||
import { RadioAreaGroup } from "@calcom/ui/components/radio";
|
||||
|
||||
import { OnboardingCard } from "../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../components/OnboardingLayout";
|
||||
import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt";
|
||||
import { PlanIcon } from "../components/plan-icon";
|
||||
import { useOnboardingStore, type PlanType } from "../store/onboarding-store";
|
||||
|
||||
type OnboardingViewProps = {
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) => {
|
||||
export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { selectedPlan, setSelectedPlan } = useOnboardingStore();
|
||||
const previousPlanRef = useRef<PlanType | null>(null);
|
||||
|
||||
// Plan order mapping for determining direction
|
||||
const planOrder: Record<PlanType, number> = {
|
||||
personal: 0,
|
||||
team: 1,
|
||||
organization: 2,
|
||||
};
|
||||
|
||||
// Calculate animation direction synchronously
|
||||
const getDirection = (): "up" | "down" => {
|
||||
if (!selectedPlan || !previousPlanRef.current) return "down";
|
||||
const previousOrder = planOrder[previousPlanRef.current];
|
||||
const currentOrder = planOrder[selectedPlan];
|
||||
return currentOrder > previousOrder ? "down" : "up";
|
||||
};
|
||||
|
||||
const direction = getDirection();
|
||||
|
||||
// Update previous plan ref after render
|
||||
useEffect(() => {
|
||||
previousPlanRef.current = selectedPlan;
|
||||
}, [selectedPlan]);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selectedPlan === "organization") {
|
||||
@@ -37,7 +62,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
|
||||
|
||||
const planIconByType: Record<PlanType, IconName> = {
|
||||
personal: "user",
|
||||
team: "users",
|
||||
team: "user",
|
||||
organization: "users",
|
||||
};
|
||||
|
||||
@@ -56,7 +81,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
|
||||
badge: t("onboarding_plan_team_badge"),
|
||||
description: t("onboarding_plan_team_description"),
|
||||
icon: planIconByType.team,
|
||||
variant: "single" as const,
|
||||
variant: "team" as const,
|
||||
},
|
||||
{
|
||||
id: "organization" as PlanType,
|
||||
@@ -76,26 +101,26 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
|
||||
return true;
|
||||
});
|
||||
|
||||
const selectedPlanData = plans.find((plan) => plan.id === selectedPlan);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1}>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title="Select plan"
|
||||
subtitle={t("onboarding_welcome_question")}
|
||||
footer={
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="bg-muted border-muted relative flex min-h-0 w-full flex-col overflow-hidden 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_welcome_message", { userName })}
|
||||
</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("onboarding_welcome_question")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan options */}
|
||||
<RadioAreaGroup.Group
|
||||
value={selectedPlan ?? undefined}
|
||||
@@ -114,46 +139,45 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
|
||||
"pr-12 [&>button]:left-auto [&>button]:right-6 [&>button]:mt-0 [&>button]:transform"
|
||||
)}
|
||||
classNames={{
|
||||
container: "flex w-full items-center gap-4 p-4 pl-5 pr-12 md:p-5 md:pr-14",
|
||||
container: "flex w-full items-center gap-3 p-5 pr-12",
|
||||
}}>
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<PlanIcon icon={plan.icon} variant={plan.variant} />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-emphasis text-base font-semibold leading-5">{plan.title}</p>
|
||||
<Badge
|
||||
variant="gray"
|
||||
size="md"
|
||||
className="hidden h-4 rounded-md px-1 py-1 md:flex md:items-center">
|
||||
<span className="text-emphasis text-xs font-medium leading-3">
|
||||
{plan.badge}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<p className="text-emphasis text-sm font-semibold leading-4">{plan.title}</p>
|
||||
<Badge
|
||||
variant="gray"
|
||||
size="md"
|
||||
className="h-4 w-fit rounded-md px-1 py-1 md:hidden">
|
||||
className="hidden h-4 rounded-md px-1 py-1 md:flex md:items-center">
|
||||
<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>
|
||||
<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-medium leading-[1.25]">
|
||||
{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}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Icon display */}
|
||||
<div className="bg-muted border-subtle hidden h-full w-full rounded-l-2xl border-b border-l border-t xl:flex xl:items-center xl:justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{selectedPlanData && (
|
||||
<PlanIcon
|
||||
key={selectedPlan}
|
||||
icon={selectedPlanData.icon}
|
||||
variant={selectedPlanData.variant}
|
||||
animationDirection={direction}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { setShowWelcomeToCalcomModalFlag } from "@calcom/features/shell/hooks/useWelcomeToCalcomModal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
const DEFAULT_EVENT_TYPES = [
|
||||
{
|
||||
title: "15min_meeting",
|
||||
slug: "15min",
|
||||
length: 15,
|
||||
},
|
||||
{
|
||||
title: "30min_meeting",
|
||||
slug: "30min",
|
||||
length: 30,
|
||||
},
|
||||
{
|
||||
title: "secret_meeting",
|
||||
slug: "secret",
|
||||
length: 15,
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const useSubmitPersonalOnboarding = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const telemetry = useTelemetry();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
||||
const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation();
|
||||
|
||||
const mutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
try {
|
||||
// Create default event types if user has none
|
||||
if (eventTypes?.length === 0) {
|
||||
await Promise.all(
|
||||
DEFAULT_EVENT_TYPES.map(async (event) => {
|
||||
return createEventType.mutateAsync({
|
||||
title: t(event.title),
|
||||
slug: event.slug,
|
||||
length: event.length,
|
||||
hidden: event.hidden,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
await utils.viewer.me.get.refetch();
|
||||
// Set flag to show welcome modal after redirect
|
||||
setShowWelcomeToCalcomModalFlag();
|
||||
router.push("/event-types?welcomeToCalcomModal=true");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
const submitPersonalOnboarding = () => {
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
mutation.mutate({
|
||||
completedOnboarding: true,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
submitPersonalOnboarding,
|
||||
isSubmitting: mutation.isPending,
|
||||
};
|
||||
};
|
||||
@@ -8,8 +8,9 @@ import { HexColorPicker } from "react-colorful";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationBrandViewProps = {
|
||||
@@ -118,6 +119,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_brand_title")}
|
||||
subtitle={t("onboarding_org_brand_subtitle")}
|
||||
@@ -185,7 +187,9 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_logo_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Banner Upload */}
|
||||
@@ -225,69 +229,14 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
</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>
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
|
||||
|
||||
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
|
||||
|
||||
@@ -77,6 +78,7 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_details_title")}
|
||||
subtitle={t("onboarding_org_details_subtitle")}
|
||||
@@ -134,6 +136,9 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Form, Label, TextField, Select, ToggleGroup } from "@calcom/ui/componen
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { useSubmitOnboarding } from "../../hooks/useSubmitOnboarding";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
@@ -117,135 +118,55 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
|
||||
|
||||
{/* 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 className="grid w-full max-w-[1200px] grid-cols-1 gap-6 lg:grid-cols-[1fr_auto]">
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full 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>
|
||||
</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">
|
||||
{/* 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">
|
||||
{/* Email and Team inputs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.email">
|
||||
{t("email")}
|
||||
</Label>
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.team">
|
||||
{t("team")}
|
||||
</Label>
|
||||
</div>
|
||||
<Button color="secondary" className="w-full justify-center" StartIcon="mail">
|
||||
{t("onboarding_connect_google_workspace")}
|
||||
</Button>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5">
|
||||
<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>
|
||||
))}
|
||||
<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>
|
||||
|
||||
{/* Add button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="mt-2 w-fit"
|
||||
onClick={() => append({ email: "", team: "", role: "MEMBER" })}>
|
||||
{t("add")}
|
||||
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>
|
||||
|
||||
@@ -257,42 +178,128 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => value && setInviteRole(value as "MEMBER" | "ADMIN")}
|
||||
options={[
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
{ 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>
|
||||
</Form>
|
||||
)}
|
||||
) : (
|
||||
// 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">
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.email">
|
||||
{t("email")}
|
||||
</Label>
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.team">
|
||||
{t("team")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5">
|
||||
<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="mt-2 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { OnboardingCalendarBrowserView } from "../../components/onboarding-calendar-browser-view";
|
||||
import { useSubmitPersonalOnboarding } from "../../hooks/useSubmitPersonalOnboarding";
|
||||
import { InstallableAppCard } from "../_components/InstallableAppCard";
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
import { SkipButton } from "../_components/SkipButton";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { useAppInstallation } from "../_components/useAppInstallation";
|
||||
|
||||
type PersonalCalendarViewProps = {
|
||||
@@ -17,9 +16,9 @@ type PersonalCalendarViewProps = {
|
||||
};
|
||||
|
||||
export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
|
||||
const { submitPersonalOnboarding, isSubmitting } = useSubmitPersonalOnboarding();
|
||||
|
||||
const queryIntegrations = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "calendar",
|
||||
@@ -29,23 +28,38 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
|
||||
});
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push("/onboarding/personal/video");
|
||||
submitPersonalOnboarding();
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
router.push("/onboarding/personal/video");
|
||||
submitPersonalOnboarding();
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("connect_your_calendar")}
|
||||
subtitle={t("connect_calendar_to_prevent_conflicts")}
|
||||
isLoading={queryIntegrations.isPending}
|
||||
footer={
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleSkip}
|
||||
disabled={queryIntegrations.isPending || isSubmitting}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
loading={isSubmitting}
|
||||
disabled={queryIntegrations.isPending || isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
|
||||
{queryIntegrations.data?.items?.map((app) => (
|
||||
@@ -60,7 +74,8 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
<SkipButton onClick={handleSkip} />
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingCalendarBrowserView />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { UserAvatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type PersonalProfileViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
|
||||
export const PersonalProfileView = ({ userEmail }: PersonalProfileViewProps) => {
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { personalDetails, setPersonalDetails } = useOnboardingStore();
|
||||
|
||||
const avatarRef = useRef<HTMLInputElement>(null);
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
// Update imageSrc when user loads
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setImageSrc(personalDetails.avatar || user.avatar || "");
|
||||
}
|
||||
}, [user, personalDetails.avatar]);
|
||||
|
||||
const { setValue, handleSubmit, getValues } = useForm<FormData>({
|
||||
defaultValues: { bio: personalDetails.bio || user?.bio || "" },
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Avatar mutation
|
||||
const avatarMutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
setImageSrc(data.avatarUrl ?? "");
|
||||
setPersonalDetails({ avatar: data.avatarUrl ?? null });
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("problem_saving_user_profile"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
// Profile mutation
|
||||
const mutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (data: { bio: string }) => {
|
||||
const { bio } = data;
|
||||
|
||||
// Save to store
|
||||
setPersonalDetails({
|
||||
bio,
|
||||
});
|
||||
|
||||
// Save to backend
|
||||
await mutation.mutateAsync({
|
||||
bio,
|
||||
});
|
||||
|
||||
router.push("/onboarding/personal/calendar");
|
||||
});
|
||||
|
||||
async function updateProfileHandler(newAvatar: string) {
|
||||
avatarMutation.mutate({
|
||||
avatarUrl: newAvatar,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
<OnboardingCard
|
||||
title={t("complete_your_profile")}
|
||||
subtitle={t("personal_profile_subtitle")}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={onSubmit}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
}>
|
||||
{/* 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">
|
||||
<form onSubmit={onSubmit} className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-6 rounded-xl">
|
||||
{/* Avatar */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("profile_photo")}</Label>
|
||||
<div className="flex flex-row items-center justify-start gap-4 rtl:justify-end">
|
||||
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
defaultValue={imageSrc}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("add_profile_photo")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
if (avatarRef.current) {
|
||||
avatarRef.current.value = newAvatar;
|
||||
}
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
avatarRef.current?.dispatchEvent(ev2);
|
||||
updateProfileHandler(newAvatar);
|
||||
}}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<UsernameAvailabilityField />
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-1 text-sm font-medium leading-4">{t("about")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(getValues("bio") || user?.bio || "")}
|
||||
setText={(value: string) => setValue("bio", turndown(value))}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("few_sentences_about_yourself")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -2,23 +2,26 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { UserAvatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Label, TextArea, TextField } from "@calcom/ui/components/form";
|
||||
import { ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
|
||||
type PersonalSettingsViewProps = {
|
||||
userEmail: string;
|
||||
@@ -28,17 +31,17 @@ type PersonalSettingsViewProps = {
|
||||
export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
const { personalDetails, setPersonalDetails } = useOnboardingStore();
|
||||
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const avatarRef = useRef<HTMLInputElement>(null);
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setName(personalDetails.name || userName || "");
|
||||
if (personalDetails.timezone) {
|
||||
setSelectedTimeZone(personalDetails.timezone);
|
||||
if (user) {
|
||||
setImageSrc(personalDetails.avatar || user.avatar || "");
|
||||
}
|
||||
}, [personalDetails, userName, setSelectedTimeZone]);
|
||||
}, [personalDetails.avatar, user]);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
@@ -47,98 +50,169 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
|
||||
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
|
||||
}),
|
||||
bio: z.string().optional(),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: personalDetails.name || userName || "",
|
||||
bio: personalDetails.bio || "",
|
||||
},
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Avatar mutation
|
||||
const avatarMutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
setImageSrc(data.avatarUrl ?? "");
|
||||
setPersonalDetails({ avatar: data.avatarUrl ?? null });
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("problem_saving_user_profile"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
// Profile mutation
|
||||
const mutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
async function updateProfileHandler(newAvatar: string) {
|
||||
avatarMutation.mutate({
|
||||
avatarUrl: newAvatar,
|
||||
});
|
||||
}
|
||||
|
||||
const handleContinue = form.handleSubmit(async (data) => {
|
||||
// Save to store
|
||||
setPersonalDetails({
|
||||
name: data.name,
|
||||
timezone: selectedTimeZone,
|
||||
bio: data.bio || "",
|
||||
});
|
||||
|
||||
// Save to backend
|
||||
await mutation.mutateAsync({
|
||||
name: data.name,
|
||||
timeZone: selectedTimeZone,
|
||||
bio: data.bio || "",
|
||||
});
|
||||
|
||||
router.push("/onboarding/personal/profile");
|
||||
router.push("/onboarding/personal/calendar");
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("welcome_to_cal_com")}
|
||||
subtitle={t("personal_settings_subtitle")}
|
||||
title={t("add_your_details")}
|
||||
subtitle={t("personal_details_subtitle")}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending || !form.formState.isValid}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
}>
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start">
|
||||
<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">
|
||||
{/* Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<TextField
|
||||
label={t("full_name")}
|
||||
{...form.register("name")}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
form.setValue("name", e.target.value);
|
||||
}}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">
|
||||
{t("timezone")}
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
/>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("current_time")}{" "}
|
||||
{dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/getting-started")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="personal-settings-form"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending || !form.formState.isValid}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
id="personal-settings-form"
|
||||
onSubmit={handleContinue}
|
||||
className="flex w-full flex-col gap-6 px-5">
|
||||
{/* Profile Picture */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("profile_picture")}</Label>
|
||||
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
|
||||
{user && (
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar size="lg" user={user} previewSrc={imageSrc} />
|
||||
</div>
|
||||
)}
|
||||
<input ref={avatarRef} type="hidden" name="avatar" id="avatar" defaultValue={imageSrc} />
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("upload")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
if (avatarRef.current) {
|
||||
avatarRef.current.value = newAvatar;
|
||||
}
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
avatarRef.current?.dispatchEvent(ev2);
|
||||
updateProfileHandler(newAvatar);
|
||||
}}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<TextField label={t("your_name")} {...form.register("name")} placeholder="John Doe" />
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<UsernameAvailabilityField
|
||||
onSuccessMutation={async () => {
|
||||
// Refetch user to get updated username and save to store
|
||||
const updatedUser = await utils.viewer.me.get.fetch();
|
||||
if (updatedUser?.username) {
|
||||
setPersonalDetails({ username: updatedUser.username });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("bio")}</Label>
|
||||
<TextArea {...form.register("bio")} className="min-h-[108px]" />
|
||||
{form.formState.errors.bio && (
|
||||
<p className="text-error text-sm">{form.formState.errors.bio.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView
|
||||
avatar={imageSrc || personalDetails.avatar || user.avatar}
|
||||
name={form.watch("name") || personalDetails.name || user.name || undefined}
|
||||
bio={form.watch("bio") || personalDetails.bio || undefined}
|
||||
username={personalDetails.username || user.username || undefined}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { InstallableAppCard } from "../_components/InstallableAppCard";
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
import { SkipButton } from "../_components/SkipButton";
|
||||
import { useAppInstallation } from "../_components/useAppInstallation";
|
||||
|
||||
type PersonalVideoViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
const DEFAULT_EVENT_TYPES = [
|
||||
{
|
||||
title: "15min_meeting",
|
||||
slug: "15min",
|
||||
length: 15,
|
||||
},
|
||||
{
|
||||
title: "30min_meeting",
|
||||
slug: "30min",
|
||||
length: 30,
|
||||
},
|
||||
{
|
||||
title: "secret_meeting",
|
||||
slug: "secret",
|
||||
length: 15,
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => {
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const telemetry = useTelemetry();
|
||||
const utils = trpc.useUtils();
|
||||
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
|
||||
|
||||
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: false,
|
||||
sortByMostPopular: true,
|
||||
sortByInstalledFirst: true,
|
||||
});
|
||||
|
||||
const setDefaultConferencingApp = trpc.viewer.apps.setDefaultConferencingApp.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
||||
const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation();
|
||||
|
||||
const mutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
try {
|
||||
// Create default event types if user has none
|
||||
if (eventTypes?.length === 0) {
|
||||
await Promise.all(
|
||||
DEFAULT_EVENT_TYPES.map(async (event) => {
|
||||
return createEventType.mutateAsync({
|
||||
title: t(event.title),
|
||||
slug: event.slug,
|
||||
length: event.length,
|
||||
hidden: event.hidden,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
await utils.viewer.me.get.refetch();
|
||||
router.push("/event-types");
|
||||
},
|
||||
});
|
||||
|
||||
const handleContinue = async () => {
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
mutation.mutate({
|
||||
completedOnboarding: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
mutation.mutate({
|
||||
completedOnboarding: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user has a default conferencing app
|
||||
const result = userMetadata.safeParse(user.metadata);
|
||||
const metadata = result?.success ? result.data : null;
|
||||
const hasDefaultConferencingApp = !!metadata?.defaultConferencingApp?.appSlug;
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={4}>
|
||||
<OnboardingCard
|
||||
title={t("connect_video_app")}
|
||||
subtitle={t("video_app_connection_subtitle")}
|
||||
isLoading={isPending}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending}>
|
||||
{t("finish_and_start")}
|
||||
</Button>
|
||||
}>
|
||||
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
|
||||
{queryConnectedVideoApps?.items
|
||||
.filter((app) => app.slug !== "daily-video")
|
||||
.map((app) => {
|
||||
const shouldAutoSetDefault =
|
||||
!hasDefaultConferencingApp && app.appData?.location?.linkType === "dynamic";
|
||||
|
||||
return (
|
||||
<InstallableAppCard
|
||||
key={app.slug}
|
||||
app={app}
|
||||
isInstalling={installingAppSlug === app.slug}
|
||||
onInstallClick={setInstallingAppSlug}
|
||||
installOptions={createInstallHandlers(app.slug, (appSlug) => {
|
||||
// Auto-set as default if it's the first connected video app
|
||||
if (shouldAutoSetDefault) {
|
||||
setDefaultConferencingApp.mutate({ slug: appSlug });
|
||||
}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
<SkipButton onClick={handleSkip} disabled={mutation.isPending} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,7 @@ export interface Invite {
|
||||
export interface TeamDetails {
|
||||
name: string;
|
||||
slug: string;
|
||||
bio: string;
|
||||
}
|
||||
|
||||
export interface TeamBrand {
|
||||
@@ -104,6 +105,7 @@ const initialState = {
|
||||
teamDetails: {
|
||||
name: "",
|
||||
slug: "",
|
||||
bio: "",
|
||||
},
|
||||
teamBrand: {
|
||||
color: "#000000",
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type TeamBrandViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
const BrandColorPicker = ({
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="border-default bg-default flex h-7 w-32 items-center gap-2 rounded-lg border px-2 py-1.5">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className="h-4 w-4 shrink-0 rounded-full border border-gray-200"
|
||||
style={{ backgroundColor: value }}
|
||||
aria-label={t("onboarding_pick_color_aria")}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content align="start" sideOffset={5}>
|
||||
<HexColorPicker color={value} onChange={onChange} className="!h-32 !w-32" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<input
|
||||
type="text"
|
||||
value={value.replace("#", "")}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value.startsWith("#") ? e.target.value : `#${e.target.value}`;
|
||||
onChange(newValue);
|
||||
}}
|
||||
className="text-emphasis grow border-none bg-transparent text-sm font-medium leading-4 outline-none"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TeamBrandView = ({ userEmail }: TeamBrandViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { teamBrand, setTeamBrand, teamDetails } = useOnboardingStore();
|
||||
|
||||
const [brandColor, setBrandColor] = useState("#000000");
|
||||
const [_logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBrandColor(teamBrand.color);
|
||||
setLogoPreview(teamBrand.logo);
|
||||
}, [teamBrand]);
|
||||
|
||||
const handleLogoChange = (file: File | null) => {
|
||||
setLogoFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
setLogoPreview(base64);
|
||||
setTeamBrand({ logo: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setLogoPreview(null);
|
||||
setTeamBrand({ logo: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
setTeamBrand({ color: brandColor });
|
||||
router.push("/onboarding/teams/invite");
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
router.push("/onboarding/teams/invite");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="relative flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{t("customize_team_brand")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{t("team_brand_subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-4">
|
||||
{/* Left side - Form */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Brand Color */}
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("brand_color")}</p>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<p className="text-subtle w-[98px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-4">
|
||||
{t("onboarding_primary_color_label")}
|
||||
</p>
|
||||
<BrandColorPicker
|
||||
value={brandColor}
|
||||
onChange={(value) => {
|
||||
setBrandColor(value);
|
||||
setTeamBrand({ color: value });
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById("logo-upload")?.click()}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
<input
|
||||
id="logo-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("onboarding_logo_size_hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Preview */}
|
||||
<div className="bg-muted border-muted flex hidden h-[260px] w-full grow overflow-hidden rounded-[10px] border p-5 md:block">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p className="text-subtle text-sm font-medium leading-4">{t("preview")}</p>
|
||||
<div className="border-subtle bg-default relative flex w-[110%] flex-col gap-2.5 rounded-md border px-5 pb-5 pt-4">
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 p-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Logo preview */}
|
||||
<div className="bg-muted z-20 h-9 w-9 shrink-0 overflow-hidden rounded-md border-2 border-[var(--cal-bg)]">
|
||||
{logoPreview && (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt={t("onboarding_logo_preview_alt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-subtle text-sm font-medium capitalize leading-4 ">
|
||||
{teamDetails.name || t("onboarding_preview_nameless")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="font-cal text-xl leading-5 tracking-[0.2px]">
|
||||
{t("onboarding_preview_example_title")}
|
||||
</p>
|
||||
<p className="text-subtle text-sm font-medium leading-5">
|
||||
{t("onboarding_preview_example_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{[134, 104, 84, 104].map((width, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-1">
|
||||
<div className="bg-subtle h-5 w-5 shrink-0 rounded-full" />
|
||||
<div
|
||||
className="bg-subtle h-2.5 rounded-full"
|
||||
style={{ width: `${width}px` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("ill_do_this_later")}
|
||||
</Button>
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
|
||||
import { ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingBrowserView } from "../../components/onboarding-browser-view";
|
||||
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { ValidatedTeamSlug } from "./validated-team-slug";
|
||||
@@ -20,20 +24,25 @@ type TeamDetailsViewProps = {
|
||||
export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { teamDetails, setTeamDetails } = useOnboardingStore();
|
||||
const { teamDetails, teamBrand, setTeamDetails, setTeamBrand } = useOnboardingStore();
|
||||
|
||||
const logoRef = useRef<HTMLInputElement>(null);
|
||||
const [teamName, setTeamName] = useState("");
|
||||
const [teamSlug, setTeamSlug] = useState("");
|
||||
const [teamBio, setTeamBio] = useState("");
|
||||
const [teamLogo, setTeamLogo] = useState<string>("");
|
||||
const [isSlugValid, setIsSlugValid] = useState(false);
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTeamName(teamDetails.name);
|
||||
setTeamSlug(teamDetails.slug);
|
||||
setTeamBio(teamDetails.bio);
|
||||
setTeamLogo(teamBrand.logo || "");
|
||||
if (teamDetails.slug) {
|
||||
setIsSlugManuallyEdited(true);
|
||||
}
|
||||
}, [teamDetails]);
|
||||
}, [teamDetails, teamBrand]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSlugManuallyEdited && teamName) {
|
||||
@@ -47,6 +56,13 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
setIsSlugManuallyEdited(true);
|
||||
};
|
||||
|
||||
const handleLogoChange = (newLogo: string) => {
|
||||
if (logoRef.current) {
|
||||
logoRef.current.value = newLogo;
|
||||
}
|
||||
setTeamLogo(newLogo);
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!isSlugValid) {
|
||||
return;
|
||||
@@ -55,89 +71,101 @@ export const TeamDetailsView = ({ userEmail }: TeamDetailsViewProps) => {
|
||||
setTeamDetails({
|
||||
name: teamName,
|
||||
slug: teamSlug,
|
||||
bio: teamBio,
|
||||
});
|
||||
router.push("/onboarding/teams/brand");
|
||||
|
||||
setTeamBrand({
|
||||
logo: teamLogo || null,
|
||||
});
|
||||
|
||||
router.push("/onboarding/teams/invite");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
<>
|
||||
<OnboardingContinuationPrompt />
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-6">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{t("create_your_team")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("team_onboarding_details_subtitle")}
|
||||
</p>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("create_your_team")}
|
||||
subtitle={t("team_onboarding_details_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={() => router.push("/onboarding/getting-started")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !teamName || !teamSlug}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4 px-5">
|
||||
{/* Team Profile Picture */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
|
||||
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={teamLogo || undefined}
|
||||
alt={teamName || "Team"}
|
||||
className="border-2 border-white"
|
||||
/>
|
||||
</div>
|
||||
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="team-logo-upload"
|
||||
buttonMsg={t("upload")}
|
||||
handleAvatarChange={handleLogoChange}
|
||||
imageSrc={teamLogo}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-default border-muted w-full rounded-[10px] border">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<div className="flex w-full gap-6 px-5 py-5">
|
||||
<div className="flex w-full flex-col gap-4 rounded-xl">
|
||||
{/* Team Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_name")}</Label>
|
||||
<TextField
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{/* Team Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_name")}</Label>
|
||||
<TextField
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team Slug */}
|
||||
<ValidatedTeamSlug
|
||||
value={teamSlug}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Team Slug */}
|
||||
<ValidatedTeamSlug
|
||||
value={teamSlug}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={!isSlugValid || !teamName || !teamSlug}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Team Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_bio")}</Label>
|
||||
<TextArea
|
||||
value={teamBio}
|
||||
onChange={(e) => setTeamBio(e.target.value)}
|
||||
placeholder={t("team_bio_placeholder")}
|
||||
className="border-default min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView teamSlug={teamSlug} name={teamName} bio={teamBio} avatar={teamLogo || null} />
|
||||
</OnboardingLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
@@ -73,6 +74,15 @@ export function ValidatedTeamSlug({ value, onChange, onValidationChange }: Valid
|
||||
};
|
||||
}, [value, validateSlug]);
|
||||
|
||||
const handleBlur = () => {
|
||||
if (value) {
|
||||
const slugified = slugify(value);
|
||||
if (slugified !== value) {
|
||||
onChange(slugified);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const urlPrefix = `${WEBAPP_URL}/team/`;
|
||||
|
||||
return (
|
||||
@@ -81,6 +91,7 @@ export function ValidatedTeamSlug({ value, onChange, onValidationChange }: Valid
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="acme"
|
||||
addOnLeading={urlPrefix}
|
||||
addOnSuffix={
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useOnboardingStore, type Invite } from "../../store/onboarding-store";
|
||||
|
||||
type CSVUploadModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const CSVUploadModal = ({ isOpen, onClose }: CSVUploadModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const store = useOnboardingStore();
|
||||
const { setTeamInvites, teamDetails } = store;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
showToast(t("please_upload_csv_file"), "error");
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
// Create a CSV template with headers
|
||||
const headers = ["email", "role"];
|
||||
const exampleRow = ["example@email.com", "MEMBER"];
|
||||
const csvContent = [headers.join(","), exampleRow.join(",")].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "team_invite_template.csv";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showToast(t("template_downloaded"), "success");
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
showToast(t("please_select_file"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
// Read and parse the CSV file
|
||||
const text = await selectedFile.text();
|
||||
const lines = text.split("\n").filter((line) => line.trim());
|
||||
|
||||
if (lines.length < 2) {
|
||||
showToast(t("csv_file_empty"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse CSV (simple implementation - may need enhancement for complex CSVs)
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const emailIndex = headers.indexOf("email");
|
||||
const roleIndex = headers.indexOf("role");
|
||||
|
||||
if (emailIndex === -1) {
|
||||
showToast(t("csv_missing_email_column"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out empty emails and validate
|
||||
const parsedInvites = lines
|
||||
.slice(1)
|
||||
.map((line) => {
|
||||
const values = line.split(",").map((v) => v.trim());
|
||||
return {
|
||||
email: values[emailIndex]?.trim(),
|
||||
role: (roleIndex !== -1 ? values[roleIndex]?.trim().toUpperCase() : "MEMBER") as
|
||||
| "MEMBER"
|
||||
| "ADMIN",
|
||||
};
|
||||
})
|
||||
.filter((invite) => invite.email && invite.email.length > 0);
|
||||
|
||||
if (parsedInvites.length === 0) {
|
||||
showToast(t("csv_file_empty"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to Invite format with team name
|
||||
const invites: Invite[] = parsedInvites.map((invite) => ({
|
||||
email: invite.email,
|
||||
team: teamDetails.name,
|
||||
role: invite.role === "ADMIN" ? "ADMIN" : "MEMBER",
|
||||
}));
|
||||
|
||||
// Save invites to store
|
||||
setTeamInvites(invites);
|
||||
|
||||
showToast(t("csv_uploaded_successfully", { count: invites.length }), "success");
|
||||
onClose();
|
||||
|
||||
// Navigate to email page to display the invites
|
||||
router.push("/onboarding/teams/invite/email");
|
||||
} catch (error) {
|
||||
console.error("Error uploading CSV:", error);
|
||||
showToast(t("error_uploading_csv"), "error");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent size="default" className="!p-0">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Logo className="h-10 w-auto" />
|
||||
</div>
|
||||
|
||||
{/* File upload illustration */}
|
||||
<div
|
||||
className="relative mx-auto flex items-center justify-center"
|
||||
style={{ width: 320, height: 180 }}>
|
||||
<div
|
||||
className="from-default to-muted border-subtle flex items-center justify-center rounded-2xl border bg-gradient-to-br p-8 shadow-sm"
|
||||
style={{ width: 200, height: 150 }}>
|
||||
{selectedFile ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
|
||||
<Icon name="file-text" className="text-emphasis" style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-emphasis text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-subtle text-xs">{(selectedFile.size / 1024).toFixed(2)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
|
||||
<Icon
|
||||
name="upload"
|
||||
className="text-emphasis opacity-70"
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-subtle text-center text-sm">{t("upload_csv_subtitle")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-2 text-center">
|
||||
<h2 className="font-cal text-emphasis text-2xl leading-none">{t("upload_csv_file")}</h2>
|
||||
<p className="text-default text-sm leading-normal">{t("upload_csv_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-3">
|
||||
{/* Download template */}
|
||||
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
|
||||
<Icon name="download" className="text-emphasis" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-emphasis text-sm font-medium">{t("need_template")}</p>
|
||||
<p className="text-subtle text-xs">{t("download_csv_template_description")}</p>
|
||||
</div>
|
||||
<Button color="secondary" StartIcon="download" size="sm" onClick={handleDownloadTemplate}>
|
||||
{t("download_template")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div className="bg-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
|
||||
<Icon name="upload" className="text-emphasis" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-emphasis text-sm font-medium">{t("upload_your_file")}</p>
|
||||
<p className="text-subtle text-xs">
|
||||
{selectedFile ? selectedFile.name : t("upload_csv_description")}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
StartIcon={selectedFile ? "file-text" : "upload"}
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}>
|
||||
{t("choose_file")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
|
||||
<Button color="minimal" onClick={handleClose} disabled={isUploading}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isUploading}
|
||||
loading={isUploading}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
"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 { InfoBadge } from "@calcom/ui/components/badge";
|
||||
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 { OnboardingCard } from "../../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../../components/OnboardingLayout";
|
||||
import { OnboardingInviteBrowserView } from "../../../components/onboarding-invite-browser-view";
|
||||
import { useCreateTeam } from "../../../hooks/useCreateTeam";
|
||||
import { useOnboardingStore, type InviteRole } from "../../../store/onboarding-store";
|
||||
|
||||
type TeamInviteEmailViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
invites: {
|
||||
email: string;
|
||||
role: InviteRole;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const { teamInvites, setTeamInvites, teamDetails } = store;
|
||||
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
|
||||
const { createTeam, isSubmitting } = useCreateTeam();
|
||||
|
||||
const formSchema = z.object({
|
||||
invites: z.array(
|
||||
z.object({
|
||||
email: z.string().email(t("invalid_email_address")),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
invites:
|
||||
teamInvites.length > 0
|
||||
? teamInvites.map((inv) => ({ email: inv.email, role: inv.role }))
|
||||
: [{ email: "", role: inviteRole }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
|
||||
const handleContinue = async (data: FormValues) => {
|
||||
const invitesWithTeam = data.invites.map((invite) => ({
|
||||
email: invite.email,
|
||||
team: teamDetails.name,
|
||||
role: invite.role,
|
||||
}));
|
||||
|
||||
setTeamInvites(invitesWithTeam);
|
||||
|
||||
// Create the team (will handle checkout redirect if needed)
|
||||
await createTeam(store);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/onboarding/teams/invite");
|
||||
};
|
||||
|
||||
const hasValidInvites = fields.some((_, index) => {
|
||||
const email = form.watch(`invites.${index}.email`);
|
||||
return email && email.trim().length > 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("invite_via_email")}
|
||||
subtitle={t("team_invite_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleBack} disabled={isSubmitting}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidInvites || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={form.handleSubmit(handleContinue)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4 px-5">
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Email inputs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
|
||||
<div className="scroll-bar flex max-h-72 flex-col gap-2 overflow-y-auto">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`invites.${index}.email`)}
|
||||
placeholder={`rick@cal.com`}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="w-fit"
|
||||
onClick={() => append({ email: "", role: inviteRole })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Role selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setInviteRole(value as InviteRole);
|
||||
// Update all invites with the new role
|
||||
fields.forEach((_, index) => {
|
||||
form.setValue(`invites.${index}.role`, value as InviteRole);
|
||||
});
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
]}
|
||||
/>
|
||||
<InfoBadge content={t("onboarding_modify_roles_later")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingInviteBrowserView teamName={teamDetails.name} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,221 +1,168 @@
|
||||
"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 { useFlags } from "@calcom/features/flags/hooks";
|
||||
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 { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingInviteBrowserView } from "../../components/onboarding-invite-browser-view";
|
||||
import { useCreateTeam } from "../../hooks/useCreateTeam";
|
||||
import { useOnboardingStore, type InviteRole } from "../../store/onboarding-store";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { CSVUploadModal } from "./csv-upload-modal";
|
||||
|
||||
type TeamInviteViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
invites: {
|
||||
email: string;
|
||||
role: InviteRole;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const flags = useFlags();
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const { teamInvites, setTeamInvites, teamDetails } = store;
|
||||
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
|
||||
const { setTeamInvites, teamDetails } = store;
|
||||
const { createTeam, isSubmitting } = useCreateTeam();
|
||||
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
invites: z.array(
|
||||
z.object({
|
||||
email: z.string().email(t("invalid_email_address")),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
});
|
||||
const googleWorkspaceEnabled = flags["google-workspace-directory"];
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
invites:
|
||||
teamInvites.length > 0
|
||||
? teamInvites.map((inv) => ({ email: inv.email, role: inv.role }))
|
||||
: [{ email: "", role: inviteRole }],
|
||||
},
|
||||
});
|
||||
const handleGoogleWorkspaceConnect = () => {
|
||||
// TODO: Implement Google Workspace connection
|
||||
console.log("Connect Google Workspace");
|
||||
};
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
const handleInviteViaEmail = () => {
|
||||
router.push("/onboarding/teams/invite/email");
|
||||
};
|
||||
|
||||
const handleContinue = async (data: FormValues) => {
|
||||
const invitesWithTeam = data.invites.map((invite) => ({
|
||||
email: invite.email,
|
||||
team: teamDetails.name,
|
||||
role: invite.role,
|
||||
}));
|
||||
const handleUploadCSV = () => {
|
||||
setIsCSVModalOpen(true);
|
||||
};
|
||||
|
||||
setTeamInvites(invitesWithTeam);
|
||||
|
||||
// Create the team (will handle checkout redirect if needed)
|
||||
await createTeam(store);
|
||||
const handleCopyInviteLink = () => {
|
||||
// Disabled for now as per requirements
|
||||
console.log("Copy invite link - disabled");
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
const handleInvite = async () => {
|
||||
// For now, just proceed to create the team
|
||||
// The actual invites will be handled in the email page
|
||||
await createTeam(store);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-4">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{t("invite_team_members")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{t("team_invite_subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-default border-subtle w-full rounded-md border">
|
||||
<div className="flex w-full flex-col gap-8 px-5 py-5">
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Email inputs */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
{...form.register(`invites.${index}.email`)}
|
||||
placeholder={`rick@cal.com`}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
className="h-7 w-7"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}>
|
||||
<Icon name="x" className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="w-fit"
|
||||
onClick={() => append({ email: "", role: inviteRole })}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Role selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-emphasis text-sm">{t("onboarding_invite_all_as")}</span>
|
||||
<ToggleGroup
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setInviteRole(value as InviteRole);
|
||||
// Update all invites with the new role
|
||||
fields.forEach((_, index) => {
|
||||
form.setValue(`invites.${index}.role`, value as InviteRole);
|
||||
});
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: "MEMBER", label: t("members") },
|
||||
{ value: "ADMIN", label: t("onboarding_admins") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-subtle text-sm">{t("onboarding_modify_roles_later")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("invite")}
|
||||
subtitle={t("onboarding_invite_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidInvites || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={form.handleSubmit(handleContinue)}>
|
||||
{t("continue")}
|
||||
onClick={() => router.push("/onboarding/teams/details")}
|
||||
disabled={isSubmitting}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleInvite}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}>
|
||||
{t("invite")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-6 px-5">
|
||||
{/* Google Workspace Connect - Only show if feature flag is enabled */}
|
||||
{googleWorkspaceEnabled && (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full rounded-[10px]"
|
||||
onClick={handleGoogleWorkspaceConnect}
|
||||
disabled={isSubmitting}>
|
||||
{t("connect_google_workspace")}
|
||||
</Button>
|
||||
|
||||
{/* Divider with "or" */}
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
<span className="text-subtle text-sm font-medium">{t("or")}</span>
|
||||
<div className="border-subtle h-px flex-1 border-t" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Invite options */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={handleInviteViaEmail}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="mail" className="h-4 w-4" />
|
||||
<span>{t("invite_via_email")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={handleUploadCSV}
|
||||
disabled={isSubmitting}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="upload" className="h-4 w-4" />
|
||||
<span>{t("upload_csv_file")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
className="h-8 w-full justify-center rounded-[10px]"
|
||||
onClick={handleCopyInviteLink}
|
||||
disabled>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name="link" className="h-4 w-4" />
|
||||
<span>{t("copy_invite_link")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip button */}
|
||||
<div className="flex w-full justify-center">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
|
||||
{t("ill_do_this_later")}
|
||||
</button>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingInviteBrowserView teamName={teamDetails.name} />
|
||||
</OnboardingLayout>
|
||||
|
||||
{/* CSV Upload Modal */}
|
||||
<CSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { parseAsBoolean, useQueryState } from "nuqs";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "showWelcomeToCalcomModal";
|
||||
|
||||
export function useWelcomeToCalcomModal() {
|
||||
const [welcomeToCalcomModal, setWelcomeToCalcomModal] = useQueryState(
|
||||
"welcomeToCalcomModal",
|
||||
parseAsBoolean.withDefault(false)
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check query param first
|
||||
if (welcomeToCalcomModal) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check sessionStorage as fallback (for cases where we redirect through personal onboarding)
|
||||
if (typeof window !== "undefined" && sessionStorage.getItem(STORAGE_KEY) === "true") {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [welcomeToCalcomModal]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
// Remove the query param from URL
|
||||
setWelcomeToCalcomModal(null);
|
||||
// Also clear sessionStorage
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
closeModal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to set the flag that triggers the welcome modal.
|
||||
* Use this before redirecting to ensure the modal shows after navigation.
|
||||
*/
|
||||
export function setShowWelcomeToCalcomModalFlag() {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const generateCheckoutSession = async ({
|
||||
|
||||
export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
const { user } = ctx;
|
||||
const { slug, name, isOnboarding } = input;
|
||||
const { slug, name, bio, isOnboarding } = input;
|
||||
const isOrgChildTeam = !!user.profile?.organizationId;
|
||||
|
||||
// For orgs we want to create teams under the org
|
||||
@@ -99,6 +99,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
data: {
|
||||
slug,
|
||||
name,
|
||||
bio: bio || null,
|
||||
members: {
|
||||
create: {
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ZCreateInputSchema = z.object({
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => v || null),
|
||||
bio: z.string().optional(),
|
||||
isOnboarding: z.boolean().optional(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user