diff --git a/.gitignore b/.gitignore
index f940a99..1ab3c71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@ dist
dist-ssr
*.local
+# Environment variables
+.env
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
diff --git a/package.json b/package.json
index 6312a28..a233820 100644
--- a/package.json
+++ b/package.json
@@ -13,12 +13,15 @@
"build-storybook": "storybook build"
},
"dependencies": {
+ "@heroicons/react": "^2.2.0",
"@reduxjs/toolkit": "^2.4.0",
"@tanstack/react-query": "^5.62.2",
"@types/react-redux": "^7.1.34",
"axios": "^1.7.9",
+ "jwt-decode": "^4.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-hook-form": "^7.54.1",
"react-redux": "^9.1.2",
"react-router": "^7.0.2",
"styled-components": "^6.1.13"
@@ -33,6 +36,7 @@
"@storybook/react": "^8.4.7",
"@storybook/react-vite": "^8.4.7",
"@storybook/test": "^8.4.7",
+ "@types/jwt-decode": "^3.1.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 18933c8..c0049ff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@heroicons/react':
+ specifier: ^2.2.0
+ version: 2.2.0(react@18.3.1)
'@reduxjs/toolkit':
specifier: ^2.4.0
version: 2.4.0(react-redux@9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
@@ -20,12 +23,18 @@ importers:
axios:
specifier: ^1.7.9
version: 1.7.9
+ jwt-decode:
+ specifier: ^4.0.0
+ version: 4.0.0
react:
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
+ react-hook-form:
+ specifier: ^7.54.1
+ version: 7.54.1(react@18.3.1)
react-redux:
specifier: ^9.1.2
version: 9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1)
@@ -63,6 +72,9 @@ importers:
'@storybook/test':
specifier: ^8.4.7
version: 8.4.7(storybook@8.4.7)
+ '@types/jwt-decode':
+ specifier: ^3.1.0
+ version: 3.1.0
'@types/react':
specifier: ^18.3.12
version: 18.3.14
@@ -541,6 +553,11 @@ packages:
resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@heroicons/react@2.2.0':
+ resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
+ peerDependencies:
+ react: '>= 16 || ^19.0.0-rc'
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -966,6 +983,10 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/jwt-decode@3.1.0':
+ resolution: {integrity: sha512-tthwik7TKkou3mVnBnvVuHnHElbjtdbM63pdBCbZTirCt3WAdM73Y79mOri7+ljsS99ZVwUFZHLMxJuJnv/z1w==}
+ deprecated: This is a stub types definition. jwt-decode provides its own type definitions, so you do not need this installed.
+
'@types/mdx@2.0.13':
resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
@@ -1702,6 +1723,10 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
+ jwt-decode@4.0.0:
+ resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
+ engines: {node: '>=18'}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1926,6 +1951,12 @@ packages:
peerDependencies:
react: ^18.3.1
+ react-hook-form@7.54.1:
+ resolution: {integrity: sha512-PUNzFwQeQ5oHiiTUO7GO/EJXGEtuun2Y1A59rLnZBBj+vNEOWt/3ERTiG1/zt7dVeJEM+4vDX/7XQ/qanuvPMg==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -2753,6 +2784,10 @@ snapshots:
dependencies:
levn: 0.4.1
+ '@heroicons/react@2.2.0(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@@ -3215,6 +3250,10 @@ snapshots:
'@types/json-schema@7.0.15': {}
+ '@types/jwt-decode@3.1.0':
+ dependencies:
+ jwt-decode: 4.0.0
+
'@types/mdx@2.0.13': {}
'@types/node@22.10.2':
@@ -3975,6 +4014,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
+ jwt-decode@4.0.0: {}
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -4197,6 +4238,10 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
+ react-hook-form@7.54.1(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
react-is@16.13.1: {}
react-is@17.0.2: {}
diff --git a/src/App.tsx b/src/App.tsx
index 93b98f4..8f202cc 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import Button from '@/components/atoms/Button';
import { RouterProvider, createBrowserRouter } from 'react-router';
import router from '@/routes/router';
+import { Provider } from 'react-redux'; // Redux Provider 임포트
+import { store } from '@/store/store'; // 설정된 Redux 스토어 임포트
const queryClient = new QueryClient({
defaultOptions: {
@@ -16,10 +17,11 @@ const queryClient = new QueryClient({
const App = () => {
const appRouter = createBrowserRouter(router);
return (
-
-
-
-
+
+
+
+
+
);
};
diff --git a/src/apis/auth.api.ts b/src/apis/auth.api.ts
new file mode 100644
index 0000000..8c22891
--- /dev/null
+++ b/src/apis/auth.api.ts
@@ -0,0 +1,70 @@
+import httpClient from './http.api'; // Axios 인스턴스 가져오기
+import { JoinProps } from '@/pages/JoinPage';
+import { LoginProps } from '@/pages/LoginPage';
+
+// 회원가입 요청
+export const join = async (userData: JoinProps) => {
+ try {
+ const { data, status } = await httpClient.post('/api/users/join', userData);
+
+ if (status === 200) {
+ console.log('회원가입 성공:', data);
+ return data;
+ } else if (status === 400 && data.message.includes('입력해주세요')) {
+ console.error('필수 입력값 없음:', data.message);
+ throw new Error(data.message);
+ } else if (status === 400 && data.message.includes('중복된')) {
+ console.error('중복 오류:', data.message);
+ throw new Error(data.message);
+ } else {
+ throw new Error('알 수 없는 회원가입 에러가 발생했습니다.');
+ }
+ } catch (error) {
+ console.error('회원가입 요청 중 에러 발생:', error);
+ throw new Error('회원가입 요청에 실패했습니다. 관리자에게 문의하세요.');
+ }
+};
+
+interface LoginResponse {
+ message: any;
+ success: boolean;
+ token?: string;
+}
+
+// 로그인 요청
+export const login = async (data: LoginProps): Promise => {
+ try {
+ const { data: responseData, status } = await httpClient.post(
+ '/api/users/login',
+ data
+ );
+
+ if (status === 200 && responseData.success) {
+ console.log('로그인 성공:', responseData);
+ return responseData;
+ } else if (
+ status === 400 &&
+ responseData.message.includes('입력해주세요')
+ ) {
+ console.error('필수 입력값 없음:', responseData.message);
+ throw new Error(responseData.message);
+ } else if (
+ status === 404 &&
+ responseData.message.includes('등록되지 않은 이메일')
+ ) {
+ console.error('등록되지 않은 이메일:', responseData.message);
+ throw new Error(responseData.message);
+ } else if (
+ status === 401 &&
+ responseData.message.includes('비밀번호가 틀렸습니다')
+ ) {
+ console.error('비밀번호 오류:', responseData.message);
+ throw new Error(responseData.message);
+ } else {
+ throw new Error('알 수 없는 로그인 에러가 발생했습니다.');
+ }
+ } catch (error) {
+ console.error('로그인 요청 중 에러 발생:', error);
+ throw new Error('로그인 요청에 실패했습니다. 관리자에게 문의하세요.');
+ }
+};
diff --git a/src/apis/http.api.ts b/src/apis/http.api.ts
new file mode 100644
index 0000000..67909a3
--- /dev/null
+++ b/src/apis/http.api.ts
@@ -0,0 +1,105 @@
+import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
+import { getToken, removeToken } from '@/store/slices/authSlice'; // 토큰 유틸리티 함수 import
+import { logout } from '@/store/slices/authSlice';
+import { store } from '@/store/store';
+
+const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3333';
+const DEFAULT_TIMEOUT = 30000; // 요청 제한 시간
+
+// Axios 인스턴스 생성 함수
+export const createClient = (config?: AxiosRequestConfig): AxiosInstance => {
+ const axiosInstance = axios.create({
+ baseURL: BASE_URL,
+ timeout: DEFAULT_TIMEOUT,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ withCredentials: true,
+ ...config,
+ });
+
+ // 요청 인터셉터: Authorization 헤더 동적 설정
+ axiosInstance.interceptors.request.use(
+ (config) => {
+ const accessToken = getToken();
+ if (accessToken) {
+ if (config.headers && typeof config.headers.set === 'function') {
+ // AxiosHeaders 객체 처리
+ config.headers.set('Authorization', `Bearer ${accessToken}`);
+ } else {
+ // 일반 객체 초기화
+ config.headers = {
+ ...config.headers, // 기존 헤더 유지
+ Authorization: `Bearer ${accessToken}`,
+ } as any;
+ }
+ }
+ console.log('Request Headers:', config.headers); // 디버깅용 로그
+ return config;
+ },
+ (error) => {
+ console.error('Request Error:', error);
+ return Promise.reject(error);
+ }
+ );
+
+ // 응답 인터셉터: 401 상태 처리
+ axiosInstance.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response?.status === 401) {
+ console.warn('401 Unauthorized: Logging out user...');
+ removeToken(); // 토큰 삭제
+ store.dispatch(logout()); // Redux 상태 초기화
+ window.location.href = '/login'; // 로그인 페이지로 리다이렉트
+ return;
+ }
+ return Promise.reject(error);
+ }
+ );
+
+ return axiosInstance;
+};
+
+// 기본 Axios 인스턴스 생성
+export const httpClient = createClient();
+
+// API 요청 함수
+export const authApi = {
+ join: async (data: { email: string; password: string; nickname: string }) => {
+ try {
+ const response = await httpClient.post('/api/signup', data);
+ return response.data;
+ } catch (error) {
+ console.error('Signup API Error:', error);
+ throw error;
+ }
+ },
+ login: async (data: { email: string; password: string }) => {
+ try {
+ const response = await httpClient.post('/api/login', data);
+ const { token } = response.data;
+
+ // 토큰 저장
+ localStorage.setItem('accessToken', token); // 필요 시 유틸리티 함수 사용 가능
+ return response.data;
+ } catch (error) {
+ console.error('Login API Error:', error);
+ throw error;
+ }
+ },
+ logout: async () => {
+ try {
+ await httpClient.post('/api/logout');
+ removeToken(); // 토큰 삭제
+ store.dispatch(logout()); // Redux 상태 초기화
+ window.location.href = '/login'; // 로그인 페이지로 리다이렉트
+ } catch (error) {
+ console.error('Logout API Error:', error);
+ throw error;
+ }
+ },
+};
+
+// 기본 Axios 인스턴스 내보내기
+export default httpClient;
diff --git a/src/assets/qublogo.svg b/src/assets/qublogo.svg
new file mode 100644
index 0000000..3ae79fb
--- /dev/null
+++ b/src/assets/qublogo.svg
@@ -0,0 +1,62 @@
+
diff --git a/src/components/atoms/Input.tsx b/src/components/atoms/Input.tsx
new file mode 100644
index 0000000..22af179
--- /dev/null
+++ b/src/components/atoms/Input.tsx
@@ -0,0 +1,89 @@
+import React, { ForwardedRef, useState } from 'react';
+import styled from 'styled-components';
+
+interface InputFieldProps extends React.InputHTMLAttributes {
+ label: string;
+ inputType?: 'text' | 'email' | 'password' | 'number' | 'nickname';
+}
+
+const Container = styled.div`
+ position: relative;
+ width: 100%;
+ align-items: center;
+ margin-top: 1.5rem;
+ justify-content: center;
+`;
+
+interface LabelProps {
+ isActive: boolean;
+}
+
+const StyledLabel = styled.label`
+ position: absolute;
+ left: 1rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: #727272;
+ font-family: sans-serif;
+ transition: all 0.2s ease-in-out;
+
+ ${({ isActive }) =>
+ isActive &&
+ `
+ top: 25%;
+ font-size: 0.75rem; /* text-xs */
+ color: #6b7280; /* text-gray-500 */
+ `}
+`;
+
+const StyledInput = styled.input`
+ align-items: center;
+ width: 100%;
+ height: 70px;
+ padding: 0.1rem 1rem;
+ background-color: #ffffff;
+ border-radius: 0.375rem;
+ border: 0.5px solid #000000;
+ color: #727272;
+ font-size: 1.25rem;
+
+ &:focus {
+ outline: none;
+ }
+
+ ::placeholder {
+ color: transparent;
+ }
+`;
+
+const Input = React.forwardRef(
+ (
+ { label, inputType = 'text', onChange, ...props }: InputFieldProps,
+ ref: ForwardedRef
+ ) => {
+ const [isFocused, setIsFocused] = useState(false);
+ const [hasValue, setHasValue] = useState(false);
+
+ const handleInputFocus = () => setIsFocused(true);
+ const handleInputBlur = (e: React.ChangeEvent) => {
+ setIsFocused(false);
+ setHasValue(e.target.value !== '');
+ };
+
+ return (
+
+ {label}
+
+
+ );
+ }
+);
+
+export default Input;
diff --git a/src/components/atoms/PasswordGuideLines.tsx b/src/components/atoms/PasswordGuideLines.tsx
new file mode 100644
index 0000000..f5f03be
--- /dev/null
+++ b/src/components/atoms/PasswordGuideLines.tsx
@@ -0,0 +1,87 @@
+import { useEffect, useState } from 'react';
+import { CheckCircleIcon } from '@heroicons/react/20/solid';
+import styled from 'styled-components';
+
+interface Guideline {
+ label: string;
+ isValid: boolean;
+ check: (password: string) => boolean;
+}
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ > * + * {
+ margin-top: 0.5rem;
+ }
+`;
+
+const GuidelineItem = styled.div`
+ display: flex;
+ align-items: center;
+`;
+
+const StyledCheckCircleIcon = styled(CheckCircleIcon)`
+ width: 1.25rem;
+ height: 1.25rem;
+ color: #32c040;
+`;
+
+const EmptyCircle = styled.span`
+ margin-left: 0.15rem;
+ width: 0.85rem;
+ height: 0.85rem;
+ border: 1px solid #374151;
+ border-radius: 9999px;
+ display: inline-block;
+`;
+
+const GuidelineLabel = styled.span<{ isValid: boolean }>`
+ margin-left: 0.5rem;
+ font-size: 0.875rem;
+ color: ${(props) => (props.isValid ? '#32C040' : '#6b7280')};
+`;
+
+const PasswordGuideLines = ({ password }: { password: string }) => {
+ const [guidelines, setGuidelines] = useState([
+ {
+ label: '8자 이상, 15자 이하로 설정해 주세요',
+ isValid: false,
+ check: (password: string) =>
+ password.length >= 8 && password.length <= 15,
+ },
+ {
+ label: '특수 문자를 사용해 주세요',
+ isValid: false,
+ check: (password: string) => /[!@#$%^&*(),.?":{}|<>]/.test(password),
+ },
+ {
+ label: '동일한 문자가 4번 반복되면 안돼요',
+ isValid: false,
+ check: (password: string) =>
+ !/(.)\1{3}/.test(password.replace(/\s/g, '')),
+ },
+ ]);
+
+ useEffect(() => {
+ setGuidelines((prevGuidelines) =>
+ prevGuidelines.map((guideline) => ({
+ ...guideline,
+ isValid: guideline.check(password),
+ }))
+ );
+ }, [password]);
+
+ return (
+
+ {guidelines.map(({ label, isValid }, index) => (
+
+ {isValid ? : }
+ {label}
+
+ ))}
+
+ );
+};
+
+export default PasswordGuideLines;
diff --git a/src/main.tsx b/src/main.tsx
index 74566ee..4ee7573 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,22 +3,6 @@ import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import '@/styles/reset.css';
-// if (process.env.NODE_ENV === 'development') {
-// worker.start().then(() => {
-// createRoot(document.getElementById('root')!).render(
-//
-//
-//
-// );
-// });
-// } else {
-// createRoot(document.getElementById('root')!).render(
-//
-//
-//
-// );
-// }
-
createRoot(document.getElementById('root')!).render(
diff --git a/src/pages/JoinPage.tsx b/src/pages/JoinPage.tsx
new file mode 100644
index 0000000..4cf756d
--- /dev/null
+++ b/src/pages/JoinPage.tsx
@@ -0,0 +1,228 @@
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import styled from 'styled-components';
+import { useNavigate } from 'react-router';
+import qublogo from '@/assets/qublogo.svg';
+import Input from '@/components/atoms/Input';
+import PasswordGuideLines from '@/components/atoms/PasswordGuideLines';
+import { join } from '@/apis/auth.api';
+
+export interface JoinProps {
+ email: string;
+ nickname: string;
+ password: string;
+ confirmPassword: string;
+}
+
+function JoinPage() {
+ const navigate = useNavigate();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ watch,
+ } = useForm();
+
+ const password = watch('password'); // PasswordGuideLines에서 실시간 사용
+
+ const onSubmit = async (data: JoinProps) => {
+ setIsLoading(true);
+ try {
+ await join(data); // 회원가입 요청
+ alert('회원가입이 완료되었습니다!');
+ navigate('/login'); // 로그인 페이지로 이동
+ } catch (error: any) {
+ console.error('회원가입 중 에러 발생:', error);
+ if (error.response?.data?.message) {
+ alert(error.response.data.message); // 서버에서 반환된 에러 메시지 표시
+ } else {
+ alert('회원가입에 실패했습니다. 다시 시도해주세요.');
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ navigate('/')}>
+
+
+
+
+
+
+ );
+}
+
+const Container = styled.div`
+ background-color: #ffffff;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 100vh;
+`;
+
+const InnerWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ max-width: 24rem;
+`;
+
+const LogoWrapper = styled.div`
+ margin-bottom: 2.5rem;
+ cursor: pointer;
+`;
+
+const LogoImage = styled.img`
+ width: 150px;
+ height: 80px;
+ object-fit: contain;
+`;
+
+const Form = styled.form`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const InputWrapper = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ margin-bottom: 1rem;
+`;
+
+const StyledInput = styled(Input)`
+ max-width: 350px;
+ width: 100%;
+ margin: 0 auto;
+ display: block;
+`;
+
+const PasswordGuideLinesWrapper = styled.div`
+ width: 100%;
+ text-align: left;
+ margin-left: 1rem;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+`;
+
+const SubmitButton = styled.button`
+ width: 300px;
+ height: 70px;
+ background-color: #ffffff;
+ border: 1px solid #000000;
+ border-radius: 9999px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: #000000;
+ font-size: 15px;
+ font-weight: 400;
+ cursor: pointer;
+ margin: 0 auto;
+ margin-top: 2rem;
+
+ &:hover {
+ background-color: #e5e7eb;
+ }
+
+ &:disabled {
+ background-color: #e5e7eb;
+ cursor: not-allowed;
+ }
+`;
+
+const ErrorText = styled.p`
+ color: #ef4444;
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+ text-align: left;
+ width: 100%;
+ max-width: 350px;
+`;
+
+export default JoinPage;
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..2575c59
--- /dev/null
+++ b/src/pages/LoginPage.tsx
@@ -0,0 +1,183 @@
+import styled from 'styled-components';
+import { useNavigate } from 'react-router';
+import qublogo from '@/assets/qublogo.svg';
+import Input from '@/components/atoms/Input';
+import { useForm } from 'react-hook-form';
+import { login } from '@/apis/auth.api';
+import { useState } from 'react';
+
+export interface LoginProps {
+ email: string;
+ password: string;
+}
+
+function LoginPage() {
+ const navigate = useNavigate();
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm();
+
+ const [isLoading, setIsLoading] = useState(false); // 로딩 상태 추가
+
+ const onSubmit = async (data: LoginProps) => {
+ setIsLoading(true);
+ try {
+ await login(data);
+ alert('로그인에 성공했습니다!');
+ navigate('/'); // 로그인 성공 시 메인 페이지로 이동
+ } catch (error: any) {
+ console.error('로그인 실패:', error);
+ alert(error.message || '로그인에 실패했습니다. 다시 시도해주세요.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+ navigate('/')}>
+
+
+
+
+
+
+ 아직 회원이 아니신가요?
+ navigate('/join')}>이메일 회원가입
+
+
+
+ );
+}
+
+const Container = styled.div`
+ background-color: #ffffff;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 100vh;
+`;
+
+const InnerWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ max-width: 24rem;
+`;
+
+const LogoWrapper = styled.div`
+ margin-bottom: 2.5rem;
+ cursor: pointer;
+`;
+
+const LogoImage = styled.img`
+ width: 150px;
+ height: 80px;
+ object-fit: contain;
+`;
+
+const Form = styled.form`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const InputWrapper = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ margin-bottom: 1rem;
+`;
+
+const StyledInput = styled(Input)`
+ max-width: 350px;
+ width: 100%;
+ margin: 0 auto;
+ display: block;
+`;
+
+const SubmitButton = styled.button`
+ width: 300px;
+ height: 70px;
+ background-color: #ffffff;
+ border: 1px solid #000000;
+ border-radius: 9999px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: #000000;
+ font-size: 18px;
+ font-weight: 400;
+ cursor: pointer;
+ margin: 0 auto;
+ margin-top: 2rem;
+
+ &:hover {
+ background-color: #e5e7eb;
+ }
+
+ &:disabled {
+ background-color: #e5e7eb;
+ cursor: not-allowed;
+ }
+`;
+
+const BottomSection = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 3.5rem;
+ gap: 1rem;
+`;
+
+const InfoText = styled.span`
+ color: #a7a7a7;
+ font-size: 12px;
+`;
+
+const LinkText = styled.span`
+ color: #000000;
+ font-size: 12px;
+ cursor: pointer;
+`;
+
+const ErrorText = styled.p`
+ color: #ef4444;
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+ text-align: left;
+ width: 100%;
+ max-width: 350px;
+`;
+
+export default LoginPage;
diff --git a/src/routes/router.tsx b/src/routes/router.tsx
index b3db9b9..dae66f1 100644
--- a/src/routes/router.tsx
+++ b/src/routes/router.tsx
@@ -1,10 +1,20 @@
import HomePage from '@/pages/HomePage';
+import JoinPage from '@/pages/JoinPage';
+import LoginPage from '@/pages/LoginPage';
const router = [
{
path: '/',
element: ,
},
+ {
+ path: '/login',
+ element: ,
+ },
+ {
+ path: '/join',
+ element: ,
+ },
];
export default router;
diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts
new file mode 100644
index 0000000..cad3317
--- /dev/null
+++ b/src/store/slices/authSlice.ts
@@ -0,0 +1,208 @@
+// src/store/slices/authSlice.ts
+
+import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
+import axios from 'axios';
+import { jwtDecode } from 'jwt-decode';
+
+// 사용자 정보 인터페이스
+interface User {
+ email: string;
+ nickname: string;
+ role: 'user' | 'admin';
+}
+
+// 토큰 인터페이스
+interface Tokens {
+ accessToken: string;
+}
+
+// 인증 상태 인터페이스
+export interface AuthState {
+ isLoggedIn: boolean;
+ user: User | null;
+ tokens: Tokens | null;
+ error: string | null;
+}
+
+// 로그인 요청 페이로드 인터페이스
+interface LoginPayload {
+ email: string;
+ password: string;
+}
+
+// 회원가입 요청 페이로드 인터페이스
+interface SignupPayload {
+ email: string;
+ password: string;
+ nickname: string;
+}
+
+// JWT 디코딩 인터페이스
+interface DecodedToken {
+ email: string;
+ nickname: string;
+ roles: string[];
+ exp: number;
+}
+
+// LocalStorage 관련 유틸리티 함수
+export const getToken = (): string | null =>
+ localStorage.getItem('accessToken');
+export const setToken = (token: string): void =>
+ localStorage.setItem('accessToken', token);
+export const removeToken = (): void => localStorage.removeItem('accessToken');
+
+export const getNickname = (): string | null =>
+ localStorage.getItem('nickname');
+export const setNickname = (nickname: string): void =>
+ localStorage.setItem('nickname', nickname);
+export const removeNickname = (): void => localStorage.removeItem('nickname');
+
+// 비동기 Thunk 액션 생성
+
+// 회원가입 Thunk
+export const signup = createAsyncThunk<
+ { success: boolean },
+ SignupPayload,
+ { rejectValue: string }
+>('auth/signup', async (data, thunkAPI) => {
+ try {
+ console.log('회원가입 요청 데이터:', data);
+ const response = await axios.post('/api/signup', data);
+ console.log('회원가입 성공 응답:', response.data);
+ return response.data;
+ } catch (error: any) {
+ console.error(
+ '회원가입 실패:',
+ error.response?.data?.message || error.message
+ );
+ return thunkAPI.rejectWithValue(
+ error.response?.data?.message || '회원가입 실패'
+ );
+ }
+});
+
+// 로그인 Thunk
+export const login = createAsyncThunk<
+ { user: User; tokens: Tokens },
+ LoginPayload,
+ { rejectValue: string }
+>('auth/login', async (credentials, thunkAPI) => {
+ try {
+ console.log('로그인 요청 데이터:', credentials);
+ const response = await axios.post('/api/login', credentials);
+ const { token } = response.data;
+
+ // JWT 디코딩하여 사용자 정보 추출
+ const decoded: DecodedToken = jwtDecode(token);
+ const role = decoded.roles.includes('admin') ? 'admin' : 'user';
+
+ const userData: User = {
+ email: decoded.email,
+ nickname: decoded.nickname,
+ role,
+ };
+
+ const tokensData: Tokens = {
+ accessToken: token,
+ };
+
+ // 로컬스토리지에 토큰 저장
+ setToken(tokensData.accessToken);
+ setNickname(userData.nickname);
+
+ console.log('로그인 성공:', { user: userData, tokens: tokensData });
+
+ return { user: userData, tokens: tokensData };
+ } catch (error: any) {
+ console.error(
+ '로그인 실패:',
+ error.response?.data?.message || error.message
+ );
+ return thunkAPI.rejectWithValue(
+ error.response?.data?.message || '로그인 실패'
+ );
+ }
+});
+
+// 초기 상태 설정
+const initialState: AuthState = {
+ isLoggedIn: !!getToken(),
+ user: getNickname()
+ ? {
+ email: '',
+ nickname: getNickname() || '',
+ role: 'user',
+ }
+ : null,
+ tokens: getToken()
+ ? {
+ accessToken: getToken()!,
+ }
+ : null,
+ error: null,
+};
+
+// 슬라이스 생성
+const authSlice = createSlice({
+ name: 'auth',
+ initialState,
+ reducers: {
+ // 로그아웃 액션
+ logout: (state) => {
+ console.log('로그아웃 처리');
+ state.isLoggedIn = false;
+ state.user = null;
+ state.tokens = null;
+ state.error = null;
+ removeToken();
+ removeNickname();
+ },
+ // 에러 설정 액션
+ setError: (state, action: PayloadAction) => {
+ state.error = action.payload;
+ },
+ // 에러 클리어 액션
+ clearError: (state) => {
+ state.error = null;
+ },
+ },
+ extraReducers: (builder) => {
+ // 회원가입
+ builder.addCase(signup.pending, (state) => {
+ console.log('회원가입 진행 중...');
+ state.error = null;
+ });
+ builder.addCase(signup.fulfilled, (state) => {
+ console.log('회원가입 성공');
+ state.error = null;
+ });
+ builder.addCase(signup.rejected, (state, action) => {
+ console.log('회원가입 실패:', action.payload || '알 수 없는 오류');
+ state.error = action.payload || '회원가입 실패';
+ });
+
+ // 로그인
+ builder.addCase(login.pending, (state) => {
+ console.log('로그인 진행 중...');
+ state.error = null;
+ });
+ builder.addCase(
+ login.fulfilled,
+ (state, action: PayloadAction<{ user: User; tokens: Tokens }>) => {
+ console.log('로그인 성공');
+ state.isLoggedIn = true;
+ state.user = action.payload.user;
+ state.tokens = action.payload.tokens;
+ }
+ );
+ builder.addCase(login.rejected, (state, action) => {
+ console.log('로그인 실패:', action.payload || '알 수 없는 오류');
+ state.error = action.payload || '로그인 실패';
+ });
+ },
+});
+
+// 액션 및 리듀서 내보내기
+export const { logout, setError, clearError } = authSlice.actions;
+export default authSlice.reducer;
diff --git a/src/store/store.ts b/src/store/store.ts
new file mode 100644
index 0000000..5db4517
--- /dev/null
+++ b/src/store/store.ts
@@ -0,0 +1,11 @@
+import { configureStore } from '@reduxjs/toolkit';
+import authReducer from '@/store/slices/authSlice';
+
+export const store = configureStore({
+ reducer: {
+ auth: authReducer,
+ },
+});
+
+export type RootState = ReturnType;
+export type AppDispatch = typeof store.dispatch;