feat: Add personal v3 onboarding flow (#24681)

* WIP personal onbaording flow

* Add i18n

* Fix overflow clip blockin timezone select

* Remove duplicate label

* Move to two column approach

* Restore lock fle

* Remove duplicate key in common json

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update personal-settings-view.tsx

* Fix endpoint

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2025-10-27 13:36:46 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent 3d9650c841
commit 01346abb85
11 changed files with 903 additions and 1 deletions
@@ -0,0 +1,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { PersonalCalendarView } from "~/onboarding/personal/calendar/personal-calendar-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("connect_calendar")}`,
() => "",
true,
undefined,
"/onboarding/personal/calendar"
);
};
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 <PersonalCalendarView userEmail={userEmail} />;
};
export default ServerPage;
@@ -0,0 +1,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { PersonalProfileView } from "~/onboarding/personal/profile/personal-profile-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("personal_profile")}`,
() => "",
true,
undefined,
"/onboarding/personal/profile"
);
};
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 <PersonalProfileView userEmail={userEmail} />;
};
export default ServerPage;
@@ -0,0 +1,35 @@
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 { PersonalSettingsView } from "~/onboarding/personal/settings/personal-settings-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("personal_settings")}`,
() => "",
true,
undefined,
"/onboarding/personal/settings"
);
};
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 || "";
const userName = session.user.name || "";
return <PersonalSettingsView userEmail={userEmail} userName={userName} />;
};
export default ServerPage;
@@ -0,0 +1,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { 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;
@@ -25,7 +25,9 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
router.push("/onboarding/organization/details");
} else if (selectedPlan === "team") {
router.push("/onboarding/teams/details");
} // TODO: Handle other plan types
} else if (selectedPlan === "personal") {
router.push("/onboarding/personal/settings");
}
};
const allPlans = [
@@ -0,0 +1,124 @@
"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 { Logo } from "@calcom/ui/components/logo";
import { SkeletonText } from "@calcom/ui/components/skeleton";
type PersonalCalendarViewProps = {
userEmail: string;
};
export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => {
const router = useRouter();
const { t } = useLocale();
const queryIntegrations = trpc.viewer.apps.integrations.useQuery({
variant: "calendar",
onlyInstalled: false,
sortByMostPopular: true,
sortByInstalledFirst: true,
});
const handleContinue = () => {
router.push("/onboarding/personal/video");
};
const handleSkip = () => {
router.push("/onboarding/personal/video");
};
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-4">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("connect_your_calendar")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("connect_calendar_to_prevent_conflicts")}
</p>
</div>
</div>
{/* Content */}
<div className="flex w-full flex-col gap-4 px-5 py-5">
{queryIntegrations.isPending ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
<SkeletonText className="h-40 w-full" />
</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) => (
<div
key={app.slug}
className="border-subtle bg-default flex flex-col items-start gap-4 rounded-xl border p-5">
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
<p
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
title={app.name}>
{app.name}
</p>
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">
{app.description}
</p>
<Button
color="secondary"
href={`/apps/${app.slug}`}
className="mt-auto w-full items-center justify-center rounded-[10px]">
{t("connect")}
</Button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
</div>
</div>
</div>
{/* Skip button */}
<div className="flex w-full justify-center">
<button
onClick={handleSkip}
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4">
{t("ill_do_this_later")}
</button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,220 @@
"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 { Logo } from "@calcom/ui/components/logo";
import { showToast } from "@calcom/ui/components/toast";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
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; // or a loading spinner
}
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-6">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("complete_your_profile")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("personal_profile_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">
<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>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={onSubmit}
loading={mutation.isPending}
disabled={mutation.isPending}>
{t("continue")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,179 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { 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 { Button } from "@calcom/ui/components/button";
import { Label, TextField } from "@calcom/ui/components/form";
import { Logo } from "@calcom/ui/components/logo";
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
import { useOnboardingStore } from "../../store/onboarding-store";
type PersonalSettingsViewProps = {
userEmail: string;
userName?: string;
};
export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { personalDetails, setPersonalDetails } = useOnboardingStore();
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
const [name, setName] = useState("");
useEffect(() => {
setName(personalDetails.name || userName || "");
if (personalDetails.timezone) {
setSelectedTimeZone(personalDetails.timezone);
}
}, [personalDetails, userName, setSelectedTimeZone]);
const formSchema = z.object({
name: z
.string()
.min(1, t("name_required"))
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: personalDetails.name || userName || "",
},
});
const utils = trpc.useUtils();
const mutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async () => {
await utils.viewer.me.invalidate();
},
});
const handleContinue = form.handleSubmit(async (data) => {
// Save to store
setPersonalDetails({
name: data.name,
timezone: selectedTimeZone,
});
// Save to backend
await mutation.mutateAsync({
name: data.name,
timeZone: selectedTimeZone,
});
router.push("/onboarding/personal/profile");
});
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start 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.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis 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 ">
{/* 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("welcome_to_cal_com")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("personal_settings_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">
<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"
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
{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)}
className="rounded-[10px] text-sm"
/>
<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>
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={mutation.isPending}
disabled={mutation.isPending || !form.formState.isValid}>
{t("continue")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,203 @@
"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 { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Logo } from "@calcom/ui/components/logo";
import { SkeletonText } from "@calcom/ui/components/skeleton";
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 { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
variant: "conferencing",
onlyInstalled: false,
sortByMostPopular: true,
sortByInstalledFirst: true,
});
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation();
const utils = trpc.useUtils();
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);
// Complete onboarding
mutation.mutate({
completedOnboarding: true,
});
};
const handleSkip = async () => {
telemetry.event(telemetryEventTypes.onboardingFinished);
// Complete onboarding
mutation.mutate({
completedOnboarding: true,
});
};
if (!user) {
return null; // or a loading spinner
}
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("connect_video_app")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("video_app_connection_subtitle")}
</p>
</div>
</div>
{/* Content */}
<div className="flex w-full flex-col gap-4 px-5 py-5">
{isPending ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
<SkeletonText className="h-40 w-full" />
</div>
) : (
<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) => (
<div
key={app.slug}
className="border-subtle bg-default relative flex flex-col items-start gap-4 rounded-xl border p-5">
{app.userCredentialIds.length > 0 && (
<span className="bg-success text-success absolute right-2 top-2 rounded-md px-2 py-1 text-xs font-medium">
{t("connected")}
</span>
)}
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
<p
className="text-default max-w- line-clamp-1 break-words text-left text-sm font-medium leading-none"
title={app.name}>
{app.name}
</p>
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">
{app.description}
</p>
<Button
color="secondary"
href={`/apps/${app.slug}`}
className="mt-auto w-full items-center justify-center rounded-[10px]">
{t("connect")}
</Button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={mutation.isPending}
disabled={mutation.isPending}>
{t("finish_and_start")}
</Button>
</div>
</div>
</div>
{/* Skip button */}
<div className="flex w-full justify-center">
<button
onClick={handleSkip}
disabled={mutation.isPending}
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
{t("ill_do_this_later")}
</button>
</div>
</div>
</div>
</div>
);
};
@@ -36,6 +36,14 @@ export interface TeamBrand {
logo: string | null; // base64 or URL
}
export interface PersonalDetails {
name: string;
username: string;
timezone: string;
bio: string;
avatar: string | null;
}
export interface OnboardingState {
selectedPlan: PlanType | null;
@@ -53,6 +61,9 @@ export interface OnboardingState {
teamBrand: TeamBrand;
teamInvites: Invite[];
// Personal user state
personalDetails: PersonalDetails;
// Actions
setSelectedPlan: (plan: PlanType) => void;
setOrganizationDetails: (details: Partial<OrganizationDetails>) => void;
@@ -66,6 +77,9 @@ export interface OnboardingState {
setTeamBrand: (brand: Partial<TeamBrand>) => void;
setTeamInvites: (invites: Invite[]) => void;
// Personal actions
setPersonalDetails: (details: Partial<PersonalDetails>) => void;
// Reset
resetOnboarding: () => void;
}
@@ -94,6 +108,13 @@ const initialState = {
logo: null,
},
teamInvites: [],
personalDetails: {
name: "",
username: "",
timezone: "",
bio: "",
avatar: null,
},
};
export const useOnboardingStore = create<OnboardingState>()(
@@ -131,6 +152,11 @@ export const useOnboardingStore = create<OnboardingState>()(
setTeamInvites: (invites) => set({ teamInvites: invites }),
setPersonalDetails: (details) =>
set((state) => ({
personalDetails: { ...state.personalDetails, ...details },
})),
resetOnboarding: () => set(initialState),
}),
{
@@ -146,6 +172,7 @@ export const useOnboardingStore = create<OnboardingState>()(
teamDetails: state.teamDetails,
teamBrand: state.teamBrand,
teamInvites: state.teamInvites,
personalDetails: state.personalDetails,
}),
}
)
@@ -1729,6 +1729,16 @@
"set_availability_getting_started_subtitle_2": "Customize it later in the availability page.",
"connect_calendar_first": "Connect a calendar first",
"complete_profile": "Complete your profile",
"complete_your_profile": "Complete your profile",
"personal_profile_subtitle": "Add details to personalize your scheduling experience",
"personal_settings_subtitle": "Let's set up your personal account",
"profile_photo": "Profile photo",
"connect_calendar_to_prevent_conflicts": "Connect your calendar to prevent double bookings and conflicts",
"connect_video_app": "Connect your video app",
"video_app_connection_subtitle": "Connect your preferred video conferencing app",
"welcome_to_cal_com": "Welcome to Cal.com",
"name_required": "Name is required",
"connected": "Connected",
"connect_calendars_from_app_store": "You can add more calendars from the app store",
"connect_conference_apps": "Connect conference apps",
"connect_calendar_apps": "Connect calendar apps",