diff --git a/package-lock.json b/package-lock.json index 4cb775c2..5a03c221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "1-weekly-mission", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.0.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -16,11 +17,13 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", + "react-hook-form": "^7.56.3", "react-icons": "5.3.0", "react-router-dom": "^7.2.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", - "web-vitals": "^4.2.4" + "web-vitals": "^4.2.4", + "zod": "^3.24.4" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", @@ -2396,6 +2399,18 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz", + "integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", @@ -3374,6 +3389,12 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -14900,6 +14921,22 @@ "react": ">=16.3.0" } }, + "node_modules/react-hook-form": { + "version": "7.56.3", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.3.tgz", + "integrity": "sha512-IK18V6GVbab4TAo1/cz3kqajxbDPGofdF0w7VHdCo0Nt8PrPlOZcuuDq9YYIV1BtjcX78x0XsldbQRQnQXWXmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-icons": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", @@ -18132,6 +18169,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 9d95a658..fcd97dfe 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@hookform/resolvers": "^5.0.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -11,11 +12,13 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", + "react-hook-form": "^7.56.3", "react-icons": "5.3.0", "react-router-dom": "^7.2.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", - "web-vitals": "^4.2.4" + "web-vitals": "^4.2.4", + "zod": "^3.24.4" }, "scripts": { "start": "react-scripts start", diff --git a/src/api/client/interceptors.ts b/src/api/client/interceptors.ts index 1b6ff212..807e6b5d 100644 --- a/src/api/client/interceptors.ts +++ b/src/api/client/interceptors.ts @@ -3,7 +3,7 @@ import { InternalAxiosRequestConfig, AxiosHeaders } from 'axios'; export const requestInterceptor = ( config: InternalAxiosRequestConfig ): InternalAxiosRequestConfig => { - const tokenString = localStorage.getItem('token'); + const tokenString = localStorage.getItem('access_token'); let token = ''; if (tokenString) { diff --git a/src/api/services/auth.services.ts b/src/api/services/auth.services.ts new file mode 100644 index 00000000..704f541d --- /dev/null +++ b/src/api/services/auth.services.ts @@ -0,0 +1,59 @@ +import { SignInFormData } from '../../pages/LoginPage'; +import { SignUpFormData } from '../../pages/SignUpPage'; +import { User } from '../../types/types'; +import requestor from '../client/requestor'; + +interface AuthResponse { + accessToken: string; + refreshToken: string; + user: User; +} + +class AuthService { + async signUp(data: SignUpFormData): Promise { + try { + const response = await requestor.post('/auth/signUp', data); + return response.data; + } catch (error: any) { + console.error('회원가입 실패:', error); + if (error.response && error.response.data) { + throw error.response.data; + } + throw error; + } + } + + async login(data: SignInFormData) { + try { + const response = await requestor.post('/auth/signIn', data); + localStorage.setItem( + 'access_token', + JSON.stringify({ token: response.data.accessToken }) + ); + localStorage.setItem( + 'refresh_token', + JSON.stringify({ token: response.data.refreshToken }) + ); + localStorage.setItem('user', JSON.stringify(response.data.user)); + + return response.data; + } catch (error: any) { + console.error('로그인 실패:', error); + if (error.response && error.response.data) { + throw error.response.data; + } + throw error; + } + } + + logout() { + // 로컬 스토리지에서 인증 관련 데이터 제거 + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user'); + } +} + +const authService = new AuthService(); + +export default authService; diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx index 04ccc31d..421dbacf 100644 --- a/src/components/common/Button.tsx +++ b/src/components/common/Button.tsx @@ -1,13 +1,14 @@ import './Button.css'; -import { MouseEvent, ReactNode } from 'react'; +import { ButtonHTMLAttributes, MouseEvent, PropsWithChildren } from 'react'; -interface ButtonProps { - children?: ReactNode; - onClick: (e: MouseEvent) => void; - disabled?: boolean; -} +interface ButtonProps extends ButtonHTMLAttributes {} -function Button({ children, onClick, disabled, ...rest }: ButtonProps) { +function Button({ + children, + onClick, + disabled, + ...rest +}: PropsWithChildren) { return ( diff --git a/src/pages/ProductItemPage.tsx b/src/pages/ProductItemPage.tsx index 54d71e03..52cbdbe8 100644 --- a/src/pages/ProductItemPage.tsx +++ b/src/pages/ProductItemPage.tsx @@ -22,7 +22,7 @@ function ProductItemPage() { return (
- + {!loading && (
diff --git a/src/pages/ProductListPage.tsx b/src/pages/ProductListPage.tsx index aac552d1..f6d79556 100644 --- a/src/pages/ProductListPage.tsx +++ b/src/pages/ProductListPage.tsx @@ -70,7 +70,7 @@ function ProductListPage() { return (
- +

베스트 상품

diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx index 6f0ccb08..64011799 100644 --- a/src/pages/SignUpPage.tsx +++ b/src/pages/SignUpPage.tsx @@ -1,121 +1,92 @@ import './SignUpPage.css'; -import { ChangeEvent, useEffect, useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; import LogoImg from '../assets/images/logo.png'; import HidePasswordIcon from '../assets/icons/eye-slash.svg'; import ShowPasswordIcon from '../assets/icons/eye.svg'; -import Input from '../components/common/Input'; import GoogleLogo from '../assets/social/google.png'; import KakaoLogo from '../assets/social/kakao.png'; +import Input from '../components/common/Input'; +import authService from '../api/services/auth.services'; -interface SignUpData { - email: string; - username: string; - password: string; - passwordConfirm: string; -} +// Zod로 폼 검증 스키마 정의 +const signUpSchema = z + .object({ + email: z + .string() + .nonempty('이메일을 입력해주세요.') + .email('잘못된 이메일 형식입니다.'), + nickname: z.string().nonempty('닉네임을 입력해주세요.'), + password: z + .string() + .nonempty('비밀번호를 입력해주세요.') + .min(8, '비밀번호를 8자 이상 입력해주세요.'), + passwordConfirmation: z.string().nonempty('비밀번호를 입력해주세요.'), + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: '비밀번호가 일치하지 않습니다.', + path: ['passwordConfirmation'], + }); + +// 타입 정의 +export type SignUpFormData = z.infer; function SignUpPage() { - const [inputData, setInputData] = useState({ - email: '', - username: '', - password: '', - passwordConfirm: '', + const { + register, + handleSubmit, + setError, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(signUpSchema), + mode: 'onChange', }); - const [showPassword, setShowPassword] = useState(false); - const [showPasswordConfirm, setShowPasswordConfirm] = - useState(false); - const passwordInputRef = useRef(null); - const passwordConfirmInputRef = useRef(null); - const [emailError, setEmailError] = useState(); - const [usernameError, setUsernameError] = useState(); - const [passwordError, setPasswordError] = useState(); - const [passwordConfirmError, setPasswordConfirmError] = useState< - string | null - >(); - const [isButtonEnabled, setIsButtonEnabled] = useState(false); + const navigate = useNavigate(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); const togglePassword = () => { setShowPassword((prev) => !prev); }; + const togglePasswordConfirm = () => { setShowPasswordConfirm((prev) => !prev); }; - const handleChange = (e: ChangeEvent) => { - const { id, value } = e.target; - setInputData((prevData) => ({ - ...prevData, - [id]: value, - })); - }; - - const handleEmailBlur = () => { - if (!inputData.email) { - setEmailError('이메일을 입력해주세요.'); - } else if (!isValidEmail(inputData.email)) { - setEmailError('잘못된 이메일 형식입니다.'); - } else { - setEmailError(null); - } - }; - - const handleUserNameBlur = () => { - if (!inputData.username) { - setUsernameError('닉네임을 입력해주세요.'); - } else { - setUsernameError(null); - } - }; - - const handlePasswordBlur = () => { - if (!inputData.password) { - setPasswordError('비밀번호를 입력해주세요.'); - } else if (inputData.password.trim().length < 8) { - setPasswordError('비밀번호를 8자 이상 입력해주세요.'); - } else { - setPasswordError(null); - } - }; - - const handlePasswordConfirmBlur = () => { - if (!inputData.passwordConfirm) { - setPasswordConfirmError('비밀번호를 입력해주세요.'); - } else if (inputData.password.trim() !== inputData.passwordConfirm.trim()) { - setPasswordConfirmError('비밀번호가 일치하지 않습니다.'); - } else { - setPasswordConfirmError(null); + const onSubmit = async (formData: SignUpFormData) => { + setIsSubmitting(true); + console.log('회원가입 데이터:', formData); + // 회원가입 + try { + const response = await authService.signUp(formData); + navigate('/login'); + } catch (error: any) { + if (error.details.email) { + setError('email', { + type: 'manual', + message: error.message, + }); + } else { + setError('nickname', { + type: 'manual', + message: error.message, + }); + } + } finally { + setIsSubmitting(false); } }; - // 이메일 유효성 검사 - const isValidEmail = (email: string): boolean => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); - }; - useEffect(() => { - if ( - inputData.email && - inputData.password && - inputData.passwordConfirm && - inputData.username && - !emailError && - !usernameError && - !passwordError && - !passwordConfirmError - ) { - setIsButtonEnabled(true); - } else { - setIsButtonEnabled(false); + const accessToken = localStorage.getItem('access_token'); + if (accessToken) { + navigate('/'); // 메인 페이지로 리다이렉트 } - }, [ - inputData, - emailError, - passwordError, - usernameError, - passwordConfirmError, - ]); + }, [navigate]); return (
@@ -130,43 +101,38 @@ function SignUpPage() { /> 판다마켓 - +
- {emailError &&

{emailError}

}
+
- {usernameError &&

{usernameError}

}
+
- {passwordError &&

{passwordError}

} {!showPassword ? ( )}
+
- {passwordConfirmError && ( -

{passwordConfirmError}

- )} {!showPasswordConfirm ? ( )}
+ diff --git a/src/types/types.ts b/src/types/types.ts index a21daf2d..2eac97cf 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -70,3 +70,10 @@ export interface CommentsResponse { nextCursor: number; }; } + +// User +export interface User { + id: number; + email: string; + image: string; +}