Files
sean-brydonGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
3adb9c2dda feat: enable username editing during personal onboarding for social sign-in users (#27495)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-02 11:38:53 +00:00

223 lines
8.1 KiB
TypeScript

"use client";
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, 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 { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
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";
type PersonalSettingsViewProps = {
userEmail: string;
userName?: string;
fromTeamOnboarding?: boolean;
};
export const PersonalSettingsView = ({
userEmail,
userName,
fromTeamOnboarding = false,
}: PersonalSettingsViewProps) => {
const router = useRouter();
const { t } = useLocale();
const { data: user } = trpc.viewer.me.get.useQuery();
const { personalDetails, setPersonalDetails } = useOnboardingStore();
const avatarRef = useRef<HTMLInputElement>(null);
const [imageSrc, setImageSrc] = useState<string>("");
useEffect(() => {
if (user) {
setImageSrc(personalDetails.avatar || user.avatar || "");
}
}, [personalDetails.avatar, user]);
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 }),
}),
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,
bio: data.bio || "",
});
// Save to backend
await mutation.mutateAsync({
name: data.name,
bio: data.bio || "",
});
router.push("/onboarding/personal/calendar");
});
if (!user) {
return null;
}
return (
<>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={2}>
{/* Left column - Main content */}
<OnboardingCard
title={t("add_your_details")}
subtitle={t("personal_details_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
{!fromTeamOnboarding && (
<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>
}>
<FormProvider {...form}>
<form
id="personal-settings-form"
onSubmit={handleContinue}
className="flex w-full flex-col gap-6 px-1">
{/* 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] max-h-[150px]" />
{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>
</>
);
};