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:
co-authored by
cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent
3d9650c841
commit
01346abb85
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user