diff --git a/package-lock.json b/package-lock.json index af615c0a..7720c097 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react-datepicker": "^4.25.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.12", + "react-hook-form": "^7.49.3", "react-loading-skeleton": "^3.3.1", "react-paginate": "^8.2.0", "react-router-dom": "^6.21.1", @@ -6588,6 +6589,22 @@ "react": ">=16.13.1" } }, + "node_modules/react-hook-form": { + "version": "7.49.3", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.49.3.tgz", + "integrity": "sha512-foD6r3juidAT1cOZzpmD/gOKt7fRsDhXXZ0y28+Al1CHgX+AY1qIN9VSIIItXRq1dN68QrRwl1ORFlwjBaAqeQ==", + "engines": { + "node": ">=18", + "pnpm": "8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", diff --git a/package.json b/package.json index 4b76cf3f..18b8e9e4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react-datepicker": "^4.25.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.12", + "react-hook-form": "^7.49.3", "react-loading-skeleton": "^3.3.1", "react-paginate": "^8.2.0", "react-router-dom": "^6.21.1", diff --git a/src/components/common/Auth/AuthButton/index.tsx b/src/components/Auth/AuthButton/index.tsx similarity index 89% rename from src/components/common/Auth/AuthButton/index.tsx rename to src/components/Auth/AuthButton/index.tsx index 3fbe96e8..59f0e3d3 100644 --- a/src/components/common/Auth/AuthButton/index.tsx +++ b/src/components/Auth/AuthButton/index.tsx @@ -2,12 +2,19 @@ import styled from '@emotion/styled'; import { AuthButton, AuthButtonStyleProps } from '@/types/auth'; -const AuthButton = ({ size, variant, text, buttonFunc }: AuthButton) => { +const AuthButton = ({ + size, + variant, + text, + disabled, + buttonFunc +}: AuthButton) => { return ( {text} diff --git a/src/components/common/Auth/AuthInputNormal/index.tsx b/src/components/Auth/AuthInputNormal/index.tsx similarity index 54% rename from src/components/common/Auth/AuthInputNormal/index.tsx rename to src/components/Auth/AuthInputNormal/index.tsx index c0ee44c7..f8490e93 100644 --- a/src/components/common/Auth/AuthInputNormal/index.tsx +++ b/src/components/Auth/AuthInputNormal/index.tsx @@ -1,51 +1,55 @@ -import { ChangeEvent, useState } from 'react'; import styled from '@emotion/styled'; +import { useFormContext } from 'react-hook-form'; -import { AuthInputNormal } from '@/types/auth'; +import { AuthInputNormal, AuthInputStyleProps } from '@/types/auth'; import closeIcon from '@assets/icons/ic-login-close.svg'; import checkInvalid from '@assets/icons/ic-signup-check-invalid.svg'; import checkValid from '@assets/icons/ic-signup-check-valid.svg'; +import { getInputOptions } from '@utils/index'; const AuthInputNormal = ({ type, id, placeholder, usedFor, - isInvalid + isError }: AuthInputNormal) => { - const [text, setText] = useState(''); + const { register, watch, resetField, getFieldState, formState } = + useFormContext(); + const inputValue = watch(id); + const handleReset = () => resetField(id); - const handleChange = (event: ChangeEvent) => { - setText(event.target.value); - }; - - const handleReset = (event: React.MouseEvent) => { - event.preventDefault(); - setText(''); - }; - - // TODO : react-hook-form 적용 후 유효성 검사 로직 예정 + const isInputTouched = getFieldState(id, formState).isTouched; + const isValid = isInputTouched ? !isError : false; return ( - + - {usedFor === 'login' && text.length > 0 && ( - )} - {usedFor === 'signup' && - (isInvalid ? : )} + {usedFor === 'signup' && type !== 'email' && ( + + )} ); @@ -53,18 +57,20 @@ const AuthInputNormal = ({ export default AuthInputNormal; -const Container = styled.div` +const Container = styled.div` position: relative; - width: 524px; + width: ${props => + props.$usedFor === 'signup' && props.$type === 'email' ? '358px' : '524px'}; height: 79px; display: flex; align-items: center; `; -const Input = styled.input` - width: 524px; +const Input = styled.input` + width: ${props => + props.$usedFor === 'signup' && props.$type === 'email' ? '358px' : '524px'}; height: 79px; border-radius: 16px; diff --git a/src/components/common/Auth/AuthInputPassword/index.tsx b/src/components/Auth/AuthInputPassword/index.tsx similarity index 75% rename from src/components/common/Auth/AuthInputPassword/index.tsx rename to src/components/Auth/AuthInputPassword/index.tsx index 1749c801..b0343353 100644 --- a/src/components/common/Auth/AuthInputPassword/index.tsx +++ b/src/components/Auth/AuthInputPassword/index.tsx @@ -1,5 +1,6 @@ -import { ChangeEvent, useState } from 'react'; +import { useState } from 'react'; import styled from '@emotion/styled'; +import { useFormContext } from 'react-hook-form'; import { AuthInput } from '@/types/auth'; import eyeOn from '@assets/icons/ic-login-eye-on.svg'; @@ -7,24 +8,25 @@ import eyeOff from '@assets/icons/ic-login-eye-off.svg'; import closeIcon from '@assets/icons/ic-login-close.svg'; import checkInvalid from '@assets/icons/ic-signup-check-invalid.svg'; import checkValid from '@assets/icons/ic-signup-check-valid.svg'; +import { getInputOptions } from '@utils/index'; const AuthInputPassword = ({ id, placeholder, usedFor, - isInvalid + isError }: AuthInput) => { - const [text, setText] = useState(''); - const [showPW, setShowPW] = useState(false); + const { register, watch, resetField, getFieldState, formState } = + useFormContext(); - const handleChange = (event: ChangeEvent) => { - setText(event.target.value); - }; + const inputValue = watch(id); + const handleReset = () => resetField(id); - const handleReset = (event: React.MouseEvent) => { - event.preventDefault(); - setText(''); - }; + const passwordValue = watch('user_password'); + const isInputTouched = getFieldState(id, formState).isTouched; + const isValid = isInputTouched ? !isError : false; + + const [showPW, setShowPW] = useState(false); const handleShowPW = (event: React.MouseEvent) => { event.preventDefault(); @@ -37,11 +39,10 @@ const AuthInputPassword = ({ type={showPW ? 'text' : 'password'} id={id} placeholder={placeholder} - value={text} - onChange={handleChange} + {...register(id, getInputOptions(id, passwordValue))} /> - {text.length > 0 && ( + {!!inputValue && ( )} - {usedFor === 'login' && text.length > 0 && ( + {usedFor === 'login' && !!inputValue && ( )} - {usedFor === 'signup' && - (isInvalid ? : )} + {usedFor === 'signup' && ( + + )} ); diff --git a/src/components/common/Auth/index.tsx b/src/components/Auth/index.tsx similarity index 100% rename from src/components/common/Auth/index.tsx rename to src/components/Auth/index.tsx diff --git a/src/components/Login/LoginForm/index.tsx b/src/components/Login/LoginForm/index.tsx index 02b6ceb2..eec62c84 100644 --- a/src/components/Login/LoginForm/index.tsx +++ b/src/components/Login/LoginForm/index.tsx @@ -1,12 +1,19 @@ import { useLocation, useNavigate } from 'react-router-dom'; +import { AxiosError } from 'axios'; import styled from '@emotion/styled'; +import { + FormProvider, + useForm, + SubmitHandler, + FieldValues +} from 'react-hook-form'; import { InputValidation } from '@/types/login'; import { AuthButton, AuthInputNormal, AuthInputPassword -} from '@components/common/Auth'; +} from '@components/Auth'; import { LoginData } from '@/types/auth'; import { postLogin } from 'src/api'; import { setCookies } from '@utils/lib/cookies'; @@ -15,81 +22,99 @@ const LoginForm = () => { const navigate = useNavigate(); const { state } = useLocation(); - // HACK : 추후 useState로 입력 데이터 관리할 예쩡 - const formData: LoginData = { - email: 'juhwanTest@gmail.com', - password: 'juhwanTest' - }; - - // HACK: 유효성 검사 기능 구현 후 유효성 메세지 노출 여부 결정 - const isInvalid = true; + const methods = useForm({ + mode: 'onBlur' + }); + const { + formState: { errors, isValid }, + handleSubmit + } = methods; + const isError = !!errors?.user_id || !!errors?.user_password ? true : false; const movetoSignUp = (event: React.MouseEvent) => { event.preventDefault(); navigate('/signup'); }; - const handleLoginSubmit = async ( - event: React.FormEvent - ) => { - event.preventDefault(); - const response = await postLogin(formData); - setCookies('userName', response.name, response.expires_in); - setCookies('userEmail', response.email, response.expires_in); - setCookies('accessToken', response.access_token, response.expires_in); - setCookies('refreshToken', response.refresh_token, response.expires_in); - - if (state) { - navigate(state); - } else { - navigate('/'); + const onSubmit: SubmitHandler = async data => { + const formData: LoginData = { + email: data.user_id, + password: data.user_password + }; + try { + const response = await postLogin(formData); + setCookies('userName', response.name, response.expires_in); + setCookies('userEmail', response.email, response.expires_in); + setCookies('accessToken', response.access_token, response.expires_in); + setCookies('refreshToken', response.refresh_token, response.expires_in); + + if (state) { + navigate(state); + } else { + navigate('/'); + } + } catch (error) { + if (error instanceof AxiosError) { + console.log(error); + // TODO : 에러코드에 따라 모달 표시 예정 + // error.response?.data.code; + // error.response?.data.message; + } } }; return ( -
- - - - - {isInvalid && ( - - 아이디를 입력해 주세요 - - )} - - - - -
+ +
+ + + + + {errors.user_id && ( + + {errors?.user_id?.message?.toString()} + + )} + {!errors.user_id && errors.user_password && ( + + {errors?.user_password?.message?.toString()} + + )} + + + + +
+
); }; export default LoginForm; const Inputs = styled.div` - margin-bottom: ${props => (props.$isInvalid ? '10px' : '65px')}; + margin-bottom: ${props => (props.$isValid ? '65px' : '10px')}; display: flex; flex-direction: column; @@ -101,12 +126,8 @@ const ValidationText = styled.p` color: #da1e28; font-size: 15px; - font-weight: 500; - line-height: 32px; -`; - -const ValidationBoldText = styled.span` font-weight: 700; + line-height: 32px; `; const Buttons = styled.div` @@ -114,5 +135,5 @@ const Buttons = styled.div` flex-direction: column; gap: 13px; - ${props => props.$isInvalid && 'margin-top: 23px'}; + margin-top: ${props => (props.$isValid ? 0 : '23px')}; `; diff --git a/src/components/SignUp/SignUpForm/index.tsx b/src/components/SignUp/SignUpForm/index.tsx index 35ec971b..b1f0aa2a 100644 --- a/src/components/SignUp/SignUpForm/index.tsx +++ b/src/components/SignUp/SignUpForm/index.tsx @@ -1,91 +1,111 @@ import styled from '@emotion/styled'; +import { FormProvider, useForm } from 'react-hook-form'; import { SignUpInputValidation } from '@/types/signUp'; import { AuthButton, AuthInputNormal, AuthInputPassword -} from '@components/common/Auth'; +} from '@components/Auth'; const SignUpForm = () => { - const isInvalid = true; - const isEmailValidationVisible = false; + const methods = useForm({ + mode: 'all' + }); + const { getFieldState, formState } = methods; + const { errors, isValid } = formState; + + const isEmailTouched = getFieldState('user_email', formState).isTouched; + const isEmailValid = isEmailTouched ? !errors?.user_email : false; return ( -
- - - - {isInvalid && ( - - 이름을 입력해 주세요. - - )} - - - - - + + + + - { - // TODO : 회원가입 API 요청 로직 - }} + {errors.user_name && ( + + {errors?.user_name?.message?.toString()} + + )} + + + + + + { + // TODO : 이메일 중복확인 API 요청 로직 + }} + /> + + {errors.user_email && ( + + {errors?.user_email?.message?.toString()} + + )} + {!errors.user_email && isEmailValid && ( + + 백엔드 응답에 따라 나타날 유효성 메세지 + + )} + + + + - - {isEmailValidationVisible && ( - - 사용 가능한 아이디입니다. - - )} - - - - - {isInvalid && ( - - 비밀번호 형식이 아닙니다. - - )} - - - - + {errors?.user_password?.message?.toString()} + + )} + + + + + {errors.user_password_confirm && ( + + {errors?.user_password_confirm?.message?.toString()} + + )} + + { + // TODO : 회원가입 API 요청 로직 + }} /> - {isInvalid && ( - - 비밀번호가 일치하지 않습니다. - - )} - - {}} - /> - + + ); }; @@ -113,49 +133,16 @@ const Label = styled.label` line-height: 32px; `; -const Input = styled.input` - width: 524px; - height: 79px; - - border-radius: 16px; - border: 2px solid #757676; - padding: 23px 20px; - - display: flex; - align-items: center; - - color: #1a2849; - font-size: 18px; - font-weight: 500; - line-height: 32px; - - &::placeholder { - color: #979c9e; - font-size: 18px; - font-weight: 500; - line-height: 32px; - } - - &:focus { - outline: 2px solid #1a2849; - border-color: #1a2849; - } -`; - const EmailInputWrapper = styled.div` display: flex; justify-content: space-between; `; -const EmailInput = styled(Input)` - width: 358px; -`; - const ValidationText = styled.p` margin-top: 2px; margin-left: 12px; - color: ${props => (props.$isInvalid ? '#DA1E28' : '#1a2849')}; + color: ${props => (props.$isValid ? '#1a2849' : '#DA1E28')}; font-size: 15px; font-weight: 700; line-height: 32px; diff --git a/src/types/auth.ts b/src/types/auth.ts index 49861aba..80b9c785 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,8 +1,13 @@ +export type AuthInputStyleProps = { + $usedFor: string; + $type: string; +}; + export type AuthInput = { id: string; placeholder: string; usedFor: string; - isInvalid: boolean; + isError?: boolean; }; export type AuthInputNormal = AuthInput & { @@ -21,6 +26,7 @@ export type AuthButton = { size: string; variant: string; text: string; + disabled?: boolean; buttonFunc: | LoginAPIButton | SignUpAPIButton diff --git a/src/types/login.ts b/src/types/login.ts index f529da27..78ad6373 100644 --- a/src/types/login.ts +++ b/src/types/login.ts @@ -1,5 +1,5 @@ export type LoginFormStyleProps = { - $isInvalid: boolean; + $isValid: boolean; }; -export type InputValidation = Pick; +export type InputValidation = Pick; diff --git a/src/types/signUp.ts b/src/types/signUp.ts index c07f069b..a0fd1e23 100644 --- a/src/types/signUp.ts +++ b/src/types/signUp.ts @@ -1,5 +1,5 @@ export type SignUpFormStyleProps = { - $isInvalid: boolean; + $isValid?: boolean; }; -export type SignUpInputValidation = Pick; +export type SignUpInputValidation = Pick; diff --git a/src/utils/index.ts b/src/utils/index.ts index d2a5ded9..7d76d2a6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,3 +8,4 @@ export { } from './lib/report'; export { getUpdatedDate } from './lib/calculation'; export { getStatusToLocaleString } from './lib/dashboard'; +export { getInputOptions } from './lib/auth'; diff --git a/src/utils/lib/auth.ts b/src/utils/lib/auth.ts new file mode 100644 index 00000000..15a0697a --- /dev/null +++ b/src/utils/lib/auth.ts @@ -0,0 +1,72 @@ +const nameRegex = /^[가-힣]{2,20}$/i; +const emailRegex = + /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i; +const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,20}$/g; + +export const getInputOptions = (inputName: string, password?: string) => { + switch (inputName) { + case 'user_name': + return { + required: '이름을 입력해주세요.', + minLength: { value: 2, message: '이름은 최소 2자 이상 입력해주세요.' }, + maxLength: { value: 20, message: '이름은 최대 20자까지 입력해주세요.' }, + pattern: { + value: nameRegex, + message: '2-20자의 한글로 입력해주세요.' + } + }; + case 'user_email': + return { + required: '이메일을 입력해주세요.', + pattern: { + value: emailRegex, + message: '이메일 형식에 맞게 입력해주세요.' + } + }; + case 'user_password': + return { + required: '비밀번호를 입력해주세요.', + minLength: { + value: 8, + message: '비밀번호는 최소 8자 이상 입력해주세요.' + }, + maxLength: { + value: 20, + message: '비밀번호는 최대 20자까지 입력해주세요.' + }, + pattern: { + value: passwordRegex, + message: '8~20자의 영문, 숫자, 특수문자를 모두 포함하여 입력해주세요.' + } + }; + case 'user_password_confirm': + return { + required: '비밀번호를 입력해주세요.', + minLength: { + value: 8, + message: '비밀번호는 최소 8자 이상 입력해주세요.' + }, + maxLength: { + value: 20, + message: '비밀번호는 최대 20자까지 입력해주세요.' + }, + pattern: { + value: passwordRegex, + message: '8~20자의 영문, 숫자, 특수문자를 모두 포함하여 입력해주세요.' + }, + validate: { + matchPassword: (value: string) => { + return password === value || '비밀번호가 일치하지 않습니다'; + } + } + }; + case 'user_id': + return { + required: '아이디를 입력해주세요.', + pattern: { + value: emailRegex, + message: '이메일 형식에 맞게 입력해주세요.' + } + }; + } +};