From 70a973fe3faa87315a57c831ca3f58367c19f9fb Mon Sep 17 00:00:00 2001 From: numi8462 Date: Tue, 8 Apr 2025 16:06:15 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor(mentor):=20ButtonProps=20extends?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD,=20ButtonLage=20->=20LinkButton?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/Button.tsx | 15 ++++++++------- src/components/common/ButtonMedium.tsx | 8 +++----- .../common/{ButtonLarge.css => LinkButton.css} | 2 +- .../common/{ButtonLarge.tsx => LinkButton.tsx} | 15 +++++++-------- src/pages/Homepage.tsx | 4 ++-- 5 files changed, 21 insertions(+), 23 deletions(-) rename src/components/common/{ButtonLarge.css => LinkButton.css} (95%) rename src/components/common/{ButtonLarge.tsx => LinkButton.tsx} (60%) 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 ( From cecb754419108357f801d32f4d0ce604e1462452 Mon Sep 17 00:00:00 2001 From: numi8462 Date: Thu, 15 May 2025 18:27:55 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/client/interceptors.ts | 2 +- src/api/services/auth.services.ts | 40 +++++++++++++++++++-- src/pages/SignUpPage.tsx | 58 ++++++++++++++++++++++--------- src/types/types.ts | 7 ++++ 4 files changed, 88 insertions(+), 19 deletions(-) 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 index 253ed2dd..333b2bfe 100644 --- a/src/api/services/auth.services.ts +++ b/src/api/services/auth.services.ts @@ -1,11 +1,47 @@ +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 { - signUp() {} + async signUp(data: SignUpFormData): Promise { + try { + const response = await requestor.post('/auth/signUp', data); + localStorage.setItem( + 'access_token', + JSON.stringify({ token: response.data.accessToken }) + ); + localStorage.setItem( + 'refresh_token', + JSON.stringify({ token: response.data.refreshToken }) + ); + localStorage.setItem('user_info', 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; + } + } + login() { return requestor; } - logout() {} + + logout() { + // 로컬 스토리지에서 인증 관련 데이터 제거 + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + } } const authService = new AuthService(); diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx index 98a8de0c..1469e4e8 100644 --- a/src/pages/SignUpPage.tsx +++ b/src/pages/SignUpPage.tsx @@ -1,6 +1,6 @@ import './SignUpPage.css'; import { useEffect, useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, Navigate, useNavigate } from 'react-router-dom'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -10,6 +10,7 @@ import ShowPasswordIcon from '../assets/icons/eye.svg'; 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'; // Zod로 폼 검증 스키마 정의 const signUpSchema = z @@ -18,32 +19,34 @@ const signUpSchema = z .string() .nonempty('이메일을 입력해주세요.') .email('잘못된 이메일 형식입니다.'), - username: z.string().nonempty('닉네임을 입력해주세요.'), + nickname: z.string().nonempty('닉네임을 입력해주세요.'), password: z .string() .nonempty('비밀번호를 입력해주세요.') .min(8, '비밀번호를 8자 이상 입력해주세요.'), - passwordConfirm: z.string().nonempty('비밀번호를 입력해주세요.'), + passwordConfirmation: z.string().nonempty('비밀번호를 입력해주세요.'), }) - .refine((data) => data.password === data.passwordConfirm, { + .refine((data) => data.password === data.passwordConfirmation, { message: '비밀번호가 일치하지 않습니다.', - path: ['passwordConfirm'], + path: ['passwordConfirmation'], }); // 타입 정의 -type SignUpFormData = z.infer; +export type SignUpFormData = z.infer; function SignUpPage() { const { register, handleSubmit, + setError, formState: { errors, isValid }, control, } = useForm({ resolver: zodResolver(signUpSchema), mode: 'onBlur', // 필드에서 포커스가 벗어날 때 유효성 검사 }); - + const navigate = useNavigate(); + const [isSubmitting, setIsSubmitting] = useState(false); const [showPassword, setShowPassword] = useState(false); const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); @@ -55,9 +58,28 @@ function SignUpPage() { setShowPasswordConfirm((prev) => !prev); }; - const onSubmit = (data: SignUpFormData) => { - console.log('회원가입 데이터:', data); - // 여기에 회원가입 API 호출 로직 추가 + const onSubmit = async (formData: SignUpFormData) => { + setIsSubmitting(true); + console.log('회원가입 데이터:', formData); + // 회원가입 + try { + const response = await authService.signUp(formData); + navigate('/items'); + } 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); + } }; return ( @@ -88,11 +110,11 @@ function SignUpPage() {
@@ -128,8 +150,8 @@ function SignUpPage() { id="passwordConfirm" type={showPasswordConfirm ? 'text' : 'password'} placeholder="비밀번호를 다시 한번 입력해주세요" - error={errors.passwordConfirm?.message} - {...register('passwordConfirm')} + error={errors.passwordConfirmation?.message} + {...register('passwordConfirmation')} /> {!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; +} From 1370e4b531df646534d48ee3269a084922caa328 Mon Sep 17 00:00:00 2001 From: numi8462 Date: Thu, 15 May 2025 18:45:32 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/services/auth.services.ts | 26 +++++- src/components/common/Input.tsx | 1 + src/pages/LoginPage.tsx | 132 +++++++++++++++--------------- src/pages/SignUpPage.tsx | 7 ++ 4 files changed, 95 insertions(+), 71 deletions(-) diff --git a/src/api/services/auth.services.ts b/src/api/services/auth.services.ts index 333b2bfe..e1f48fc0 100644 --- a/src/api/services/auth.services.ts +++ b/src/api/services/auth.services.ts @@ -1,3 +1,4 @@ +import { SignInFormData } from '../../pages/LoginPage'; import { SignUpFormData } from '../../pages/SignUpPage'; import { User } from '../../types/types'; import requestor from '../client/requestor'; @@ -24,7 +25,7 @@ class AuthService { return response.data; } catch (error: any) { - console.error('로그인 실패:', error); + console.error('회원가입 실패:', error); if (error.response && error.response.data) { throw error.response.data; } @@ -32,8 +33,27 @@ class AuthService { } } - login() { - return requestor; + 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_info', 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() { diff --git a/src/components/common/Input.tsx b/src/components/common/Input.tsx index 642bda0d..cbb479c6 100644 --- a/src/components/common/Input.tsx +++ b/src/components/common/Input.tsx @@ -17,6 +17,7 @@ const Input = forwardRef( onChange={onChange} onBlur={onBlur} ref={ref} + className={error ? 'error' : ''} {...rest} /> {error &&

{error}

} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 107285a1..60ab21ac 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,79 +1,81 @@ import { ChangeEvent, useEffect, useRef, useState } from 'react'; import './LoginPage.css'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; 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 { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import authService from '../api/services/auth.services'; -interface LoginData { - email: string; - password: string; -} +// Zod로 폼 검증 스키마 정의 +const signInSchema = z.object({ + email: z + .string() + .nonempty('이메일을 입력해주세요.') + .email('잘못된 이메일 형식입니다.'), + password: z + .string() + .nonempty('비밀번호를 입력해주세요.') + .min(8, '비밀번호를 8자 이상 입력해주세요.'), +}); + +// 타입 정의 +export type SignInFormData = z.infer; function LoginPage() { - const [inputData, setInputData] = useState({ - email: '', - password: '', + const { + register, + handleSubmit, + setError, + formState: { errors, isValid }, + control, + } = useForm({ + resolver: zodResolver(signInSchema), + mode: 'onBlur', // 필드에서 포커스가 벗어날 때 유효성 검사 }); - const [showPassword, setShowPassword] = useState(false); - const passwordInputRef = useRef(null); - const [emailError, setEmailError] = useState(); - const [passwordError, setPasswordError] = useState(); - const [isButtonEnabled, setIsButtonEnabled] = useState(false); + const navigate = useNavigate(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showPassword, setShowPassword] = useState(false); const togglePassword = () => { setShowPassword((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 handlePasswordBlur = () => { - if (!inputData.password) { - setPasswordError('비밀번호를 입력해주세요.'); - } else if (inputData.password.trim().length < 8) { - setPasswordError('비밀번호를 8자 이상 입력해주세요.'); - } else { - setPasswordError(null); + const onSubmit = async (formData: SignInFormData) => { + setIsSubmitting(true); + console.log('로그인 데이터:', formData); + // 로그인 + try { + const response = await authService.login(formData); + navigate('/items'); + } catch (error: any) { + if (error.details.email) { + setError('email', { + type: 'manual', + message: error.message, + }); + } else { + setError('password', { + 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 && - !emailError && - !passwordError - ) { - setIsButtonEnabled(true); - } else { - setIsButtonEnabled(false); + const accessToken = localStorage.getItem('access_token'); + if (accessToken) { + navigate('/'); // 메인 페이지로 리다이렉트 } - }, [inputData, emailError, passwordError]); + }, [navigate]); return ( <> @@ -89,32 +91,26 @@ function LoginPage() { /> 판다마켓 -
+
- {emailError &&

{emailError}

}
- {passwordError && ( -

{passwordError}

- )} {!showPassword ? ( diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx index 1469e4e8..2d82536a 100644 --- a/src/pages/SignUpPage.tsx +++ b/src/pages/SignUpPage.tsx @@ -82,6 +82,13 @@ function SignUpPage() { } }; + useEffect(() => { + const accessToken = localStorage.getItem('access_token'); + if (accessToken) { + navigate('/'); // 메인 페이지로 리다이렉트 + } + }, [navigate]); + return (
From cadf5653b1a7f620784f0a3bb3c8435dc4671b47 Mon Sep 17 00:00:00 2001 From: numi8462 Date: Thu, 15 May 2025 20:44:20 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20dropdown=20handleClickOutside?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/services/auth.services.ts | 8 +++--- src/components/common/Dropdown.css | 1 + src/components/common/Dropdown.tsx | 11 ++++++-- src/components/common/Navbar.css | 5 ++++ src/components/common/Navbar.tsx | 41 +++++++++++++++++++++++++++--- src/pages/AddItemPage.tsx | 2 +- src/pages/Homepage.tsx | 2 +- src/pages/ProductItemPage.tsx | 2 +- src/pages/ProductListPage.tsx | 2 +- 9 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/api/services/auth.services.ts b/src/api/services/auth.services.ts index e1f48fc0..721bbf05 100644 --- a/src/api/services/auth.services.ts +++ b/src/api/services/auth.services.ts @@ -21,7 +21,7 @@ class AuthService { 'refresh_token', JSON.stringify({ token: response.data.refreshToken }) ); - localStorage.setItem('user_info', JSON.stringify(response.data.user)); + localStorage.setItem('user', JSON.stringify(response.data.user)); return response.data; } catch (error: any) { @@ -44,7 +44,7 @@ class AuthService { 'refresh_token', JSON.stringify({ token: response.data.refreshToken }) ); - localStorage.setItem('user_info', JSON.stringify(response.data.user)); + localStorage.setItem('user', JSON.stringify(response.data.user)); return response.data; } catch (error: any) { @@ -58,8 +58,8 @@ class AuthService { logout() { // 로컬 스토리지에서 인증 관련 데이터 제거 - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); localStorage.removeItem('user'); } } diff --git a/src/components/common/Dropdown.css b/src/components/common/Dropdown.css index e25d0b8f..dd1dbbb9 100644 --- a/src/components/common/Dropdown.css +++ b/src/components/common/Dropdown.css @@ -13,6 +13,7 @@ font-size: 16px; font-weight: 400; color: var(--gray-500); + white-space: nowrap; } .commentDropdown li { diff --git a/src/components/common/Dropdown.tsx b/src/components/common/Dropdown.tsx index bda83a67..3d722fb9 100644 --- a/src/components/common/Dropdown.tsx +++ b/src/components/common/Dropdown.tsx @@ -10,18 +10,25 @@ interface DropdownProps { items: DropdownItem[]; isOpen: boolean; onClose: () => void; + triggerElementId?: string; } -function Dropdown({ items, isOpen, onClose }: DropdownProps) { +function Dropdown({ items, isOpen, onClose, triggerElementId }: DropdownProps) { const dropdownRef = useRef(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + + // 트리거 요소(이미지)를 클릭한 경우 무시 + if (triggerElementId && target.id === triggerElementId) { + return; + } + if ( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { - console.log('close'); onClose(); } }; diff --git a/src/components/common/Navbar.css b/src/components/common/Navbar.css index fd94436b..010ded79 100644 --- a/src/components/common/Navbar.css +++ b/src/components/common/Navbar.css @@ -64,6 +64,11 @@ a { border-radius: 8px; } +.profile { + position: relative; + display: flex; +} + @media (width <= 1199px) { .header { padding: 0 24px; diff --git a/src/components/common/Navbar.tsx b/src/components/common/Navbar.tsx index 9cb17230..c333b407 100644 --- a/src/components/common/Navbar.tsx +++ b/src/components/common/Navbar.tsx @@ -1,7 +1,10 @@ import './Navbar.css'; import logo from '../../assets/images/logo.png'; import userIcon from '../../assets/images/user.png'; -import { Link, NavLink } from 'react-router'; +import { Link, NavLink, useNavigate } from 'react-router'; +import { useState } from 'react'; +import authService from '../../api/services/auth.services'; +import Dropdown from './Dropdown'; function getLinkStyle({ isActive }: { isActive: boolean }) { return { @@ -9,7 +12,24 @@ function getLinkStyle({ isActive }: { isActive: boolean }) { }; } -function Navbar({ isLoggedIn }: { isLoggedIn: boolean }) { +function Navbar() { + const isLoggedIn = localStorage.getItem('access_token'); + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + const dropdownItems = [{ label: '로그아웃', onClick: () => handleLogout() }]; + const dropdownButton = 'dropdown-button'; + + // dropdown 열기/닫기 + const handleProfileClick = () => { + setIsOpen(!isOpen); + }; + + // 로그아웃 + const handleLogout = () => { + authService.logout(); + navigate('/'); + }; + return (