plane/web/components/onboarding/profile-setup.tsx
2024-04-30 12:36:21 +05:30

452 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useMemo, useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Controller, useForm } from "react-hook-form";
import { Eye, EyeOff } from "lucide-react";
// types
import { IUser, TUserProfile, TOnboardingSteps } from "@plane/types";
// ui
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { PasswordStrengthMeter } from "@/components/account";
import { UserImageUploadModal } from "@/components/core";
import { OnboardingHeader, SwitchOrDeleteAccountDropdown } from "@/components/onboarding";
// constants
import { USER_DETAILS } from "@/constants/event-tracker";
// helpers
import { getPasswordStrength } from "@/helpers/password.helper";
// hooks
import { useEventTracker, useUser, useUserProfile } from "@/hooks/store";
// services
import { AuthService } from "@/services/auth.service";
import { FileService } from "@/services/file.service";
// assets
import ProfileSetupDark from "public/onboarding/profile-setup-dark.svg";
import ProfileSetupLight from "public/onboarding/profile-setup-light.svg";
type TProfileSetupFormValues = {
first_name: string;
last_name: string;
avatar?: string | null;
password?: string;
confirm_password?: string;
use_case?: string;
};
const defaultValues: Partial<TProfileSetupFormValues> = {
first_name: "",
last_name: "",
avatar: "",
password: undefined,
confirm_password: undefined,
use_case: undefined,
};
type Props = {
user?: IUser;
totalSteps: number;
stepChange: (steps: Partial<TOnboardingSteps>) => Promise<void>;
finishOnboarding: () => Promise<void>;
};
const USE_CASES = [
"Build Products",
"Manage Feedbacks",
"Service delivery",
"Field force management",
"Code Repository Integration",
"Bug Tracking",
"Test Case Management",
"Resource allocation",
];
const fileService = new FileService();
const authService = new AuthService();
export const ProfileSetup: React.FC<Props> = observer((props) => {
const { user, totalSteps, stepChange, finishOnboarding } = props;
// states
const [isRemoving, setIsRemoving] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// hooks
const { resolvedTheme } = useTheme();
// store hooks
const { updateCurrentUser } = useUser();
const { updateUserProfile } = useUserProfile();
const { captureEvent } = useEventTracker();
// form info
const {
getValues,
handleSubmit,
control,
watch,
setValue,
formState: { errors, isSubmitting, isValid },
} = useForm<TProfileSetupFormValues>({
defaultValues: {
...defaultValues,
first_name: user?.first_name,
last_name: user?.last_name,
avatar: user?.avatar,
},
mode: "onChange",
});
const handleUserDetailUpdate = async (data: Partial<IUser>) => {
await updateCurrentUser(data);
};
const handleUserProfileUpdate = async (data: Partial<TUserProfile>) => {
await updateUserProfile(data);
};
const handleSetPassword = async (password: string) => {
const token = await authService.requestCSRFToken().then((data) => data?.csrf_token);
await authService.setPassword(token, { password });
};
const onSubmit = async (formData: TProfileSetupFormValues) => {
if (!user) return;
const userDetailsPayload: Partial<IUser> = {
first_name: formData.first_name,
last_name: formData.last_name,
avatar: formData.avatar,
};
const profileUpdatePayload: Partial<TUserProfile> = {
use_case: formData.use_case,
};
try {
await Promise.all([
handleUserDetailUpdate(userDetailsPayload),
handleUserProfileUpdate(profileUpdatePayload),
formData.password ? handleSetPassword(formData.password) : Promise.resolve(),
stepChange({ profile_complete: true }),
]).then(() => {
captureEvent(USER_DETAILS, {
state: "SUCCESS",
element: "Onboarding",
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success",
message: "Profile setup completed!",
});
if (totalSteps === 1) {
finishOnboarding();
}
});
} catch {
captureEvent(USER_DETAILS, {
state: "FAILED",
element: "Onboarding",
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: "Profile setup failed. Please try again!",
});
}
};
const handleDelete = (url: string | null | undefined) => {
if (!url) return;
setIsRemoving(true);
fileService.deleteUserFile(url).finally(() => {
setValue("avatar", "");
setIsRemoving(false);
});
};
const isPasswordAlreadySetup = !user?.is_password_autoset;
const isSignUpUsingMagicCode = user?.last_login_medium === "magic-code";
const password = watch("password");
const confirmPassword = watch("confirm_password");
const isValidPassword = (password: string, confirmPassword?: string) =>
getPasswordStrength(password) >= 3 && password === confirmPassword;
const isButtonDisabled = useMemo(
() =>
isValid &&
(isPasswordAlreadySetup
? true
: isSignUpUsingMagicCode
? !!password && isValidPassword(password, confirmPassword)
: !!password
? isValidPassword(password, confirmPassword)
: true)
? false
: true,
[isValid, isPasswordAlreadySetup, isSignUpUsingMagicCode, password, confirmPassword]
);
return (
<div className="flex h-full w-full">
<div className="w-full h-full overflow-auto px-6 py-10 sm:px-7 sm:py-14 md:px-14 lg:px-28">
<div className="flex items-center justify-between">
<OnboardingHeader currentStep={1} totalSteps={totalSteps} />
<div className="shrink-0 lg:hidden">
<SwitchOrDeleteAccountDropdown fullName={`${watch("first_name")} ${watch("last_name")}`} />
</div>
</div>
<div className="flex flex-col w-full items-center justify-center p-8 mt-6">
<div className="text-center space-y-1 py-4 mx-auto">
<h3 className="text-3xl font-bold text-onboarding-text-100">Welcome to Plane!</h3>
<p className="font-medium text-onboarding-text-400">
Lets setup your profile, tell us a bit about yourself.
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="w-full mx-auto mt-2 space-y-4 sm:w-96">
<Controller
control={control}
name="avatar"
render={({ field: { onChange, value } }) => (
<UserImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isRemoving}
handleDelete={() => handleDelete(getValues("avatar"))}
onSuccess={(url) => {
onChange(url);
setIsImageUploadModalOpen(false);
}}
value={value && value.trim() !== "" ? value : null}
/>
)}
/>
<div className="space-y-1 flex items-center justify-center">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!watch("avatar") || watch("avatar") === "" ? (
<div className="flex flex-col items-center justify-between">
<div className="relative h-14 w-14 overflow-hidden">
<div className="absolute left-0 top-0 flex items-center justify-center h-full w-full rounded-full text-white text-3xl font-medium bg-[#9747FF] uppercase">
{watch("first_name")[0] ?? "R"}
</div>
</div>
<div className="pt-1 text-sm font-medium text-custom-primary-300 hover:text-custom-primary-400">
Choose image
</div>
</div>
) : (
<div className="relative mr-3 h-16 w-16 overflow-hidden">
<img
src={watch("avatar") || undefined}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
onClick={() => setIsImageUploadModalOpen(true)}
alt={user?.display_name}
/>
</div>
)}
</button>
</div>
<div className="flex gap-4">
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="first_name">
First name
</label>
<Controller
control={control}
name="first_name"
rules={{
required: "First name is required",
maxLength: {
value: 24,
message: "First name must be within 24 characters.",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="first_name"
name="first_name"
type="text"
value={value}
autoFocus
onChange={onChange}
ref={ref}
hasError={Boolean(errors.first_name)}
placeholder="RWilbur"
className="w-full border-onboarding-border-100 focus:border-custom-primary-100"
/>
)}
/>
{errors.first_name && <span className="text-sm text-red-500">{errors.first_name.message}</span>}
</div>
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="last_name">
Last name
</label>
<Controller
control={control}
name="last_name"
rules={{
required: "Last name is required",
maxLength: {
value: 24,
message: "Last name must be within 24 characters.",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="last_name"
name="last_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.last_name)}
placeholder="Wright"
className="w-full border-onboarding-border-100 focus:border-custom-primary-100"
/>
)}
/>
{errors.last_name && <span className="text-sm text-red-500">{errors.last_name.message}</span>}
</div>
</div>
{!isPasswordAlreadySetup && (
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="password">
Set a password{" "}
{!isSignUpUsingMagicCode && <span className="text-onboarding-text-400">(optional)</span>}
</label>
<Controller
control={control}
name="password"
rules={{
required: isSignUpUsingMagicCode ? "Password is required" : false,
}}
render={({ field: { value, onChange, ref } }) => (
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword ? "text" : "password"}
name="password"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.password)}
placeholder="New password..."
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
/>
{showPassword ? (
<EyeOff
className="absolute right-3 h-4 w-4 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)}
/>
) : (
<Eye
className="absolute right-3 h-4 w-4 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)}
/>
)}
</div>
)}
/>
{isPasswordInputFocused && <PasswordStrengthMeter password={watch("password") ?? ""} />}
{errors.password && <span className="text-sm text-red-500">{errors.password.message}</span>}
</div>
)}
{!isPasswordAlreadySetup && password && getPasswordStrength(password) >= 3 && (
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">
Confirm password
</label>
<Controller
control={control}
name="confirm_password"
rules={{
validate: (value) => value === password || "Password doesn't match",
}}
render={({ field: { value, onChange, ref } }) => (
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword ? "text" : "password"}
name="confirm_password"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.password)}
placeholder="Confirm password..."
className="w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
/>
{showPassword ? (
<EyeOff
className="absolute right-3 h-4 w-4 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)}
/>
) : (
<Eye
className="absolute right-3 h-4 w-4 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)}
/>
)}
</div>
)}
/>
{errors.confirm_password && (
<span className="text-sm text-red-500">{errors.confirm_password.message}</span>
)}
</div>
)}
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="use_case">
How will you use Plane? Choose one.
</label>
<Controller
control={control}
name="use_case"
rules={{
required: "This field is required",
}}
render={({ field: { value, onChange } }) => (
<div className="flex flex-wrap gap-2 py-2 overflow-auto break-all">
{USE_CASES.map((useCase) => (
<div
key={useCase}
className={`flex-shrink-0 border-[0.5px] hover:cursor-pointer hover:bg-onboarding-background-300/30 ${
value === useCase ? "border-custom-primary-100" : "border-onboarding-border-100"
} rounded px-3 py-1.5 text-sm font-medium`}
onClick={() => onChange(useCase)}
>
{useCase}
</div>
))}
</div>
)}
/>
{errors.use_case && <span className="text-sm text-red-500">{errors.use_case.message}</span>}
</div>
<Button
variant="primary"
type="submit"
size="lg"
className="w-full"
disabled={isButtonDisabled}
loading={isSubmitting}
>
{isSubmitting ? "Updating..." : "Continue"}
</Button>
</form>
</div>
</div>
<div className="hidden lg:block relative w-2/5 h-screen overflow-hidden px-6 py-10 sm:px-7 sm:py-14 md:px-14 lg:px-28">
<SwitchOrDeleteAccountDropdown fullName={`${watch("first_name")} ${watch("last_name")}`} />
<div className="absolute inset-0 z-0">
<Image
src={resolvedTheme === "dark" ? ProfileSetupDark : ProfileSetupLight}
className="h-screen w-auto float-end object-cover"
alt="Profile setup"
/>
</div>
</div>
</div>
);
});