From e8718b3ca3e85a8fff166f0f3a633c31bf3a70bc Mon Sep 17 00:00:00 2001 From: JeonDoGyun Date: Sun, 22 Oct 2023 23:39:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20validation=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EB=B2=84=ED=8A=BC=20Loader=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yup 라이브러리의 schema를 사용해서 validation 기능 추가했습니다. react-spinner 사용하여 API 동작 시 Loading Spinner 들어가도록 추가했습니다. --- package-lock.json | 50 +++++++++- package.json | 4 +- public/index.html | 4 + src/App.tsx | 2 +- src/pages/login/LoginInputForm.tsx | 71 +++++++------- src/pages/login/VLoginInputForm.tsx | 33 ++++--- src/pages/login/VLoginPage.tsx | 2 +- src/pages/map/MapPage.tsx | 4 +- src/pages/signUp/SignupInputForm.tsx | 129 +++++++++++++++++++------- src/pages/signUp/VSignupInputForm.tsx | 31 +++++-- 10 files changed, 236 insertions(+), 94 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7ce9dfac..6bcb3349 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,9 +22,11 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.16.0", "react-scripts": "5.0.1", + "react-spinners": "^0.13.8", "recoil": "^0.7.7", "typescript": "^4.9.5", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "yup": "^1.3.2" }, "devDependencies": { "@types/react-daum-postcode": "^1.6.1", @@ -14630,6 +14632,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -15069,6 +15076,15 @@ } } }, + "node_modules/react-spinners": { + "version": "0.13.8", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.13.8.tgz", + "integrity": "sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -16773,6 +16789,11 @@ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "node_modules/titleize": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", @@ -16817,6 +16838,11 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -18234,6 +18260,28 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.3.2.tgz", + "integrity": "sha512-6KCM971iQtJ+/KUaHdrhVr2LDkfhBtFPRnsG1P8F4q3uUVQ2RfEM9xekpha9aA4GXWJevjM10eDcPQ1FfWlmaQ==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index e197d323..9f68b9c5 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.16.0", "react-scripts": "5.0.1", + "react-spinners": "^0.13.8", "recoil": "^0.7.7", "typescript": "^4.9.5", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "yup": "^1.3.2" }, "scripts": { "start": "react-scripts start", diff --git a/public/index.html b/public/index.html index bbe3382f..06b21fc1 100644 --- a/public/index.html +++ b/public/index.html @@ -9,6 +9,10 @@ name="description" content="Web site created using create-react-app" /> + 애니모리 diff --git a/src/App.tsx b/src/App.tsx index e1d6e7b9..1e3fd2ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,7 +29,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/login/LoginInputForm.tsx b/src/pages/login/LoginInputForm.tsx index f39e020f..1967384c 100644 --- a/src/pages/login/LoginInputForm.tsx +++ b/src/pages/login/LoginInputForm.tsx @@ -1,38 +1,23 @@ import React, { useState } from 'react'; import { useRecoilState } from 'recoil'; import { useNavigate } from 'react-router-dom'; -import { shelterLoginState } from 'recoil/shelterState'; +import { ShelterLoginType, shelterLoginState } from 'recoil/shelterState'; +import * as Yup from 'yup'; import VLoginInputForm from './VLoginInputForm'; import { setCookie } from '../../commons/cookie/cookie'; const LoginInputForm = () => { const [userInfo, setUserInfo] = useRecoilState(shelterLoginState); - const [isEmailEmpty, setIsEmailEmpty] = useState(false); - const [isPasswordEmpty, setIsPasswordEmpty] = useState(false); - const [errorText, setErrorText] = useState('이메일을 입력해주세요'); - + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); const navigate = useNavigate(); - const emailValidate = (text: string) => { - const emailReg = /^[\w.-]+@[\w.-]+\.\w+$/g; // email형식 - if (!emailReg.test(text)) { - setErrorText('이메일 형식에 맞게 입력해주세요'); - setIsEmailEmpty(true); - return; - } - setErrorText(''); - setIsEmailEmpty(false); - }; - - const checkEmpty = () => { - if (!userInfo.email) { - setErrorText('이메일을 입력해주세요'); - setIsEmailEmpty(true); - } - if (!userInfo.password) { - setIsPasswordEmpty(true); - } - }; + const validationSchema = Yup.object().shape({ + email: Yup.string() + .email('이메일 형식에 맞게 입력해주세요.') + .required('이메일을 입력해주세요.'), + password: Yup.string().required('비밀번호를 입력해주세요.'), + }); const userfetch = () => { fetch(`${process.env.REACT_APP_URI}/account/login`, { @@ -52,32 +37,48 @@ const LoginInputForm = () => { const slicedToken = jwtToken.split(' ')[1]; setCookie('loginToken', slicedToken); } else { - console.log('Token이 Null'); + console.log('로그인 실패로 token이 Null'); } - return res.json(); }) + .then((data) => { if (data.success) { navigate('/'); + } else { + // 형식은 맞지만 입력된 값이 가입되지 않은 계정일 때 + alert(data.error.message); } + setIsLoading(false); }); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); // email, password 비었는지 확인 - checkEmpty(); - // email, password 보내기 - userfetch(); + validationSchema + .validate(userInfo, { abortEarly: false }) + .then(() => { + // email, password 보내기 + setIsLoading(true); + userfetch(); + setErrors({}); + }) + .catch((err) => { + const newErrors: Partial = {}; + err.inner.forEach( + (er: { path: string; message: string | undefined }) => { + newErrors[er.path as keyof ShelterLoginType] = er.message; + }, + ); + setErrors(newErrors); + }); }; const handleChange = (event: React.ChangeEvent) => { const target = event.target as HTMLInputElement; if (target.id === 'id') { setUserInfo((prev) => ({ ...prev, email: target.value })); - emailValidate(target.value); - setIsEmailEmpty(true); } else if (target.id === 'password') { setUserInfo((prev) => ({ ...prev, password: target.value })); } @@ -86,12 +87,10 @@ const LoginInputForm = () => { const LoginInputFormProps = { handleChange, handleSubmit, - isEmailEmpty, - isPasswordEmpty, - errorText, + errors, + isLoading, }; - // submit에 userInfo를 넣어주거나 button에서 보내도록 하는 것 필요!!!! return ; }; diff --git a/src/pages/login/VLoginInputForm.tsx b/src/pages/login/VLoginInputForm.tsx index 77625ea7..05876f6d 100644 --- a/src/pages/login/VLoginInputForm.tsx +++ b/src/pages/login/VLoginInputForm.tsx @@ -1,28 +1,27 @@ import InputGroup from 'commons/InputGroup'; +import { ShelterLoginType } from 'recoil/shelterState'; +import { ClipLoader } from 'react-spinners'; interface LoginInputFormProps { + errors: Partial; + isLoading: boolean; handleChange: (event: React.ChangeEvent) => void; handleSubmit: (e: React.FormEvent) => void; - isEmailEmpty: boolean; - isPasswordEmpty: boolean; - errorText: string; } -interface ErrorTextProps { - isEmpty: boolean; - text: string; +interface ValidationProps { + text?: string; } -const ErrorText = ({ isEmpty, text }: ErrorTextProps) => { - return
{isEmpty && {text}}
; +const ValidateText = ({ text }: ValidationProps) => { + return
{text}
; }; const VLoginInputForm = ({ + errors, + isLoading, handleChange, handleSubmit, - isEmailEmpty, - isPasswordEmpty, - errorText, }: LoginInputFormProps) => { return (
- + - - ); diff --git a/src/pages/login/VLoginPage.tsx b/src/pages/login/VLoginPage.tsx index dbdfee64..939b2ddf 100644 --- a/src/pages/login/VLoginPage.tsx +++ b/src/pages/login/VLoginPage.tsx @@ -17,7 +17,7 @@ const VLoginPage = ({ redirectSignupPage }: Props) => { backgroundPosition: 'center', }} > -
+
{ return (
- + {/* */} +
); }; diff --git a/src/pages/signUp/SignupInputForm.tsx b/src/pages/signUp/SignupInputForm.tsx index b0846711..a61f55f7 100644 --- a/src/pages/signUp/SignupInputForm.tsx +++ b/src/pages/signUp/SignupInputForm.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useRecoilState } from 'recoil'; -import { shelterSignupState } from 'recoil/shelterState'; +import { ShelterSignupType, shelterSignupState } from 'recoil/shelterState'; +import * as Yup from 'yup'; import VSignupInputForm from './VSignupInputForm'; export interface EmailConfirmProps { @@ -29,8 +30,43 @@ const SignupInputForm = () => { const [emailValidText, setEmailValidText] = useState(''); const [emailInValidText, setEmailInValidText] = useState(''); + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + const validationSchema = Yup.object().shape({ + email: Yup.string() + .email('이메일 형식에 맞게 입력해주세요.') + .required('이메일을 입력해주세요.'), + password: Yup.string() + .matches( + /^(?=.*[a-z])/, + '적어도 1개 이상의 영문 소문자가 포함되어야 합니다.', + ) + .matches( + /^(?=.*[A-Z])/, + '적어도 1개 이상의 영문 대문자가 포함되어야 합니다.', + ) + .matches(/^(?=.*\d)/, '적어도 1개 이상의 숫자가 포함되어야 합니다.') + .matches( + /^(?=.*[@$!%*?&])/, + '적어도 1개 이상의 특수기호가 포함되어야 합니다.', + ) + .matches( + /^[A-Za-z\d@$!%*?&]{8,20}$/, + '비밀번호는 8자에서 20자 사이여야 합니다.', + ) + .required('비밀번호를 입력해주세요.'), + passwordConfirm: Yup.string() + .required('비밀번호 확인은 필수 입력 사항입니다.') + .oneOf([Yup.ref('password')], '비밀번호가 일치하지 않습니다.'), + name: Yup.string().required('보호소 이름을 입력해주세요.'), + contact: Yup.string().required( + '보호소에 연락 가능한 연락처를 입력해주세요.', + ), + }); + const getEmailValidText = ({ validText, inValidText, @@ -88,36 +124,7 @@ const SignupInputForm = () => { }); }; - const handleChange = (event: React.ChangeEvent) => { - const target = event.target as HTMLInputElement; - switch (target.id) { - case 'email': - setShelterInfo((prev) => ({ ...prev, email: target.value })); - break; - case 'password': - setShelterInfo((prev) => ({ ...prev, password: target.value })); - break; - case 'shelter': - setShelterInfo((prev) => ({ ...prev, name: target.value })); - break; - case 'shelter-contact': - setShelterInfo((prev) => ({ ...prev, contact: target.value })); - break; - // 비밀번호 일치하지 않는 경우, 표시하기 위해 해당 부분 구현 - case 'password-confirm': - if (target.value !== shelterInfo.password) { - setPasswordConfirm(false); - } else { - setPasswordConfirm(true); - } - break; - default: - break; - } - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const userfetch = () => { // 중복 확인이 되지 않았을 때 if (!emailConfirm.checked) { alert('이메일 중복을 확인해주세요'); @@ -152,6 +159,64 @@ const SignupInputForm = () => { } }; + const handleChange = (event: React.ChangeEvent) => { + const target = event.target as HTMLInputElement; + switch (target.id) { + case 'email': + setShelterInfo((prev) => ({ ...prev, email: target.value })); + break; + case 'password': + setShelterInfo((prev) => ({ ...prev, password: target.value })); + break; + case 'shelter': + setShelterInfo((prev) => ({ ...prev, name: target.value })); + break; + case 'shelter-contact': + setShelterInfo((prev) => ({ ...prev, contact: target.value })); + break; + // 비밀번호 일치하지 않는 경우, 표시하기 위해 해당 부분 구현 + case 'password-confirm': + if (target.value !== shelterInfo.password) { + setPasswordConfirm(false); + } else { + setPasswordConfirm(true); + } + break; + default: + break; + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + validationSchema + .validate(shelterInfo, { abortEarly: false }) + .then(() => { + setIsLoading(true); + userfetch(); + setErrors({}); + }) + .catch((err) => { + const newErrors: Partial = {}; + err.inner.forEach( + (er: { + path: string; + message: + | (string & { + province: string; + city: string; + roadName: string; + detail: string; + }) + | undefined; + }) => { + newErrors[er.path as keyof ShelterSignupType] = er.message; + }, + ); + setErrors(newErrors); + }); + }; + const SignupInputFormProps = { shelterInfo, handleChange, @@ -162,6 +227,8 @@ const SignupInputForm = () => { checked, emailValidText, emailInValidText, + errors, + isLoading, }; return ; diff --git a/src/pages/signUp/VSignupInputForm.tsx b/src/pages/signUp/VSignupInputForm.tsx index 23ad9c02..28105d5b 100644 --- a/src/pages/signUp/VSignupInputForm.tsx +++ b/src/pages/signUp/VSignupInputForm.tsx @@ -1,6 +1,8 @@ import AddressInputGroup from 'pages/signUp/AddressInputGroup'; import InputGroup from 'commons/InputGroup'; import React from 'react'; +import { ClipLoader } from 'react-spinners'; +import { ShelterSignupType } from 'recoil/shelterState'; interface VSignupInputProps { handleChange: (event: React.ChangeEvent) => void; @@ -11,8 +13,18 @@ interface VSignupInputProps { passwordConfirm: boolean; emailValidText: string; emailInValidText: string; + errors: Partial; + isLoading: boolean; } +interface ValidationProps { + text?: string; +} + +const ValidateText = ({ text }: ValidationProps) => { + return
{text}
; +}; + const VSignupInputForm = ({ handleChange, handleSubmit, @@ -22,6 +34,8 @@ const VSignupInputForm = ({ passwordConfirm, emailValidText, emailInValidText, + errors, + isLoading, }: VSignupInputProps) => { return (
- {checked && isValid && ( -
{emailValidText}
- )} - {!checked && !isValid && ( -
{emailInValidText}
- )} + + + {/* 수정필요 */} {!passwordConfirm && (
비밀번호가 일치하지 않습니다.
)} @@ -78,6 +89,7 @@ const VSignupInputForm = ({ onChange={handleChange} autocomplete="off" /> + + ); From c133490c8ac3517e48b9000026f2e99e9c7a1c6c Mon Sep 17 00:00:00 2001 From: JeonDoGyun Date: Sun, 22 Oct 2023 23:44:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=A4=91=EC=9D=B8=20Map=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm build 문제로 인해 아직 완성되지 않은 Map 컴포넌트 일단 추가하겠습니다. --- src/pages/map/TestMap.tsx | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/pages/map/TestMap.tsx diff --git a/src/pages/map/TestMap.tsx b/src/pages/map/TestMap.tsx new file mode 100644 index 00000000..ed202ec3 --- /dev/null +++ b/src/pages/map/TestMap.tsx @@ -0,0 +1,42 @@ +import React, { useEffect, useState } from 'react'; + +const { kakao } = window; + +// 지도 띄우기 +const LoadMap = () => { + const [map, setMap] = useState(null); + + // useEffect []로 script 태그 내용 넣어주는게 가장 간단한 방식 + API 문서대로 하기 쉬울듯 + useEffect(() => { + const container = document.getElementById('map'); + const options = { + // 중간 위치 -> 나중에 사용자 위치로 바꿔야 됨 + center: new kakao.maps.LatLng(35.175483, 126.906988), + // 확대 수준 + level: 4, + }; + const kakaoMap = new kakao.maps.Map(container, options); + setMap(kakaoMap); + }, []); + + return
; +}; + +const TestMap = () => { + // const [data, isLoading, isSuccess] = useQuery(['shelter'], () => { + // fetch(`${process.env.REACT_APP_URI}/shelter/filter`, { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify(), + // }).then((res) => { + // return res.json(); + // }); + // }); + const currentPosition = { lat: 35.1759293, lon: 126.9149701 }; + + return ; +}; + +export default TestMap;