- 화면 구현 완료
This commit is contained in:
geonhee-min
2025-12-02 16:49:54 +09:00
parent 49ca9b9ae3
commit eec883ac32

View File

@@ -0,0 +1,415 @@
import { Card, CardContent, CardHeader, CardFooter } from '@/components/ui/card';
import { ResetPasswordSchema } from '@/data/form';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { useState, useCallback } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { PageRouting } from '@/const/PageRouting';
import { Stepper, StepperContent, StepperIndicator, StepperItem, StepperNav, StepperPanel, StepperSeparator, StepperTrigger } from '@/components/ui/stepper';
import * as z from 'zod';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
import { Validator } from '@/util/Validator';
import { Eye, EyeOff, LoaderCircleIcon, CircleCheckBigIcon } from 'lucide-react';
import { AccountNetwork } from '@/network/AccountNetwork';
import { Label } from '@/components/ui/label';
const steps = [1, 2, 3, 4];
export default function ResetPasswordPage() {
const [currentStep, setCurrentStep] = useState(4);
const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const accountNetwork = new AccountNetwork();
const resetPasswordForm = useForm<z.infer<typeof ResetPasswordSchema>>({
resolver: zodResolver(ResetPasswordSchema),
defaultValues: {
email: "",
code: "",
password: "",
passwordConfirm: ""
}
});
const { email, code, password, passwordConfirm } = resetPasswordForm.watch();
const moveToLoginPage = useCallback(() => {
navigate(PageRouting["LOGIN"].path);
}, []);
const handleClickFirstStepButton = async () => {
if (isLoading) return;
if (!email || email.trim().length === 0) {
resetPasswordForm.setError('email', {
type: 'manual',
message: '이메일을 입력해주십시오'
});
return;
}
const isEmailValid = await resetPasswordForm.trigger('email');
if (!isEmailValid) {
resetPasswordForm.setError('email', {
type: 'validate',
message: '이메일 형식이 올바르지 않습니다'
});
return;
}
setIsLoading(true);
try {
const response = await accountNetwork.sendResetPasswordCode({ email: email });
const resData = response.data;
if (!resData.success) {
resetPasswordForm.setError('email', {
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
return;
}
setCurrentStep(current => current + 1);
} catch (err) {
resetPasswordForm.setError('email', {
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
} finally {
setIsLoading(false);
}
}
const handleSecondStepOTPCompleted = async () => {
if (isLoading) return;
if (!Validator.validatePasswordFormat(code)) {
resetPasswordForm.setError('code', {
type: 'pattern',
message: '올바른 코드 형식이 아닙니다.'
});
return;
}
const data = {
email: email,
code: code
}
setIsLoading(true);
try {
const response = await accountNetwork.verifyResetPasswordCode(data);
const resData = response.data;
if (!resData.success || !resData.verified) {
resetPasswordForm.setError('code', {
type: 'value',
message: resData.error
});
return;
}
setCurrentStep(current => current + 1);
} catch (err) {
resetPasswordForm.setError('code', {
type: 'value',
message: '서버 오류로 코드 인증에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
} finally {
setIsLoading(false);
}
}
const handleThirdStepButton = async () => {
if (isLoading) return;
const passwordValid = await resetPasswordForm.trigger('password');
if (!passwordValid) return;
const passwordConfirmValid = await resetPasswordForm.trigger('passwordConfirm');
if (!passwordConfirmValid) return;
const data = {
email: email,
password: password
}
setIsLoading(true);
try {
const response = await accountNetwork.resetPassword(data);
const resData = response.data;
if (!resData.success) {
resetPasswordForm.setError('password', {
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
return;
}
setCurrentStep(current => current + 1);
} catch (err) {
resetPasswordForm.setError('password', {
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
});
} finally {
setIsLoading(false);
}
}
return (
<Stepper
value={currentStep}
onValueChange={setCurrentStep}
className="w-full h-full flex flex-col justify-center items-center"
indicators={{
loading: <LoaderCircleIcon className="size-3.5 animate-spin" />
}}
>
<Card className="w-md pl-2 pr-2">
<CardHeader>
</CardHeader>
<CardContent>
<StepperNav className="select-none">
{steps.map((step) => (
<StepperItem key={step} step={step} loading={isLoading}>
<StepperTrigger asChild>
<StepperIndicator
className={[
`transition-all duration-300`,
`bg-accent text-accent-foreground rounded-full text-xs data-[state=completed]:bg-indigo-500 data-[state=completed]:text-primary-foreground data-[state=active]:bg-indigo-300 data-[state=active]:text-primary-foreground`,
].join(' ')}
>
{step}
</StepperIndicator>
</StepperTrigger>
{
steps.length > step
&& <StepperSeparator
className="transition-all duration-300 group-data-[state=completed]/step:bg-indigo-500"
/>
}
</StepperItem>
))}
</StepperNav>
</CardContent>
<CardContent>
<StepperPanel>
<StepperContent value={1} key={1}>
<Field data-invalid={resetPasswordForm.formState.errors.email?.message ? true : false}>
<FieldLabel htmlFor="reseet-password-email"></FieldLabel>
<Controller
name="email"
control={resetPasswordForm.control}
render={({ field, fieldState }) => (
<>
<Input
{...field}
type="email"
id="reset-password-email"
aria-invalid={fieldState.invalid}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (email.length === 0) return;
handleClickFirstStepButton();
}
}}
/>
<FieldError className="font-[12px]" errors={[fieldState.error]} />
</>
)}
/>
</Field>
</StepperContent>
<StepperContent value={2} key={2}>
<Controller
name="code"
control={resetPasswordForm.control}
render={
({ field, fieldState }) => (
<div className="w-full flex flex-col justify-center gap-5">
<FieldLabel htmlFor="reset-password-code"> </FieldLabel>
<div className="flex flex-row justify-center items-center">
<InputOTP
maxLength={8}
inputMode="text"
id="reset-password-code"
onComplete={handleSecondStepOTPCompleted}
value={field.value}
onChange={(value) => field.onChange(value)}
onBlur={field.onBlur}
required
>
<InputOTPGroup
className="gap-2.5 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border"
>
{
[0, 1, 2, 3, 4, 5, 6, 7].map((idx) => (
<InputOTPSlot index={idx} />
))
}
</InputOTPGroup>
</InputOTP>
</div>
<FieldError errors={[fieldState.error]} />
</div>
)
}
/>
</StepperContent>
<StepperContent value={3} key={3}>
<div className="w-full flex flex-col gap-5 items-start">
<FieldLabel htmlFor="reset-password-password"> </FieldLabel>
<Controller
name="password"
control={resetPasswordForm.control}
render={({ field, fieldState }) => (
<>
<div className="relative w-full">
<Input
{...field}
type={ showPassword ? "text" : "password" }
id="reset-password-password"
aria-invalid={fieldState.invalid}
className="pr-10"
/>
<button
type="button"
className="absolute top-1/2 right-2 -translate-y-1/2"
onClick={() => setShowPassword(prev => !prev)}
>
{
showPassword
? <EyeOff size={14} />
: <Eye size={14} />
}
</button>
</div>
<FieldError className="text-[12px]" errors={[fieldState.error]} />
</>
)}
/>
<FieldLabel htmlFor="reset-password-password-confirm"> </FieldLabel>
<Controller
name="passwordConfirm"
control={resetPasswordForm.control}
render={({ field, fieldState}) => (
<>
<div className="w-full relative">
<Input
{...field}
type={ showPasswordConfirm ? "text" : "password" }
id="reset-password-password-confirm"
className="pr-10"
aria-invalid={fieldState.invalid}
/>
<button
type="button"
className="absolute top-1/2 right-2 -translate-y-1/2"
onClick={() => setShowPasswordConfirm(prev => !prev)}
>
{
showPasswordConfirm
? <EyeOff size={14} />
: <Eye size={14} />
}
</button>
</div>
<FieldError className="text-[12px]" errors={[fieldState.error]} />
</>
)}
/>
</div>
</StepperContent>
<StepperContent value={4} key={4}>
<div className="w-full flex flex-col justify-center items-center gap-5">
<CircleCheckBigIcon size={50} className="text-indigo-500" />
<Label className="font-extrabold text-xl"> </Label>
</div>
</StepperContent>
</StepperPanel>
</CardContent>
<CardFooter
className="w-full"
>
<StepperPanel className="w-full">
<StepperContent value={1}>
<div className="w-full flex flex-row items-center gap-5">
<Button
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
type="button"
form="form-reset-password"
disabled={
(email.trim().length < 1)
}
onClick={handleClickFirstStepButton}
>
</Button>
<Button
type="button"
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
onClick={moveToLoginPage}
>
</Button>
</div>
</StepperContent>
<StepperContent value={2} key={2}>
<div className="w-full flex flex-row justify-end items-center gap-5">
<Button
type="button"
className="bg-stone-300 text-white hover:bg-stone-400"
onClick={moveToLoginPage}
>
</Button>
</div>
</StepperContent>
<StepperContent value={3} key={3}>
<div className="w-full flex flex-row align-center gap-5">
<Button
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
type="button"
form="form-reset-password"
disabled={
(password.trim().length < 1)
&& (passwordConfirm.trim().length < 1)
}
onClick={handleThirdStepButton}
>
</Button>
<Button
type="button"
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
onClick={moveToLoginPage}
>
</Button>
</div>
</StepperContent>
<StepperContent value={4} key={4}>
<div className="w-full flex flex-row justify-end items-center gap-5">
<Button
type="button"
className="bg-stone-500 text-white hover:bg-stone-400"
onClick={moveToLoginPage}
>
</Button>
</div>
</StepperContent>
</StepperPanel>
</CardFooter>
</Card>
</Stepper>
)
}