diff --git a/package-lock.json b/package-lock.json index e453b60..ba326b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "lucide-react": "^0.525.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router-dom": "^7.7.0" + "react-router-dom": "^7.7.0", + "zustand": "^5.0.8" }, "devDependencies": { "@biomejs/biome": "^2.1.2", @@ -3669,6 +3670,35 @@ "optional": true } } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 25079a4..de51955 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "lucide-react": "^0.525.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router-dom": "^7.7.0" + "react-router-dom": "^7.7.0", + "zustand": "^5.0.8" }, "devDependencies": { "@biomejs/biome": "^2.1.2", diff --git a/src/App.tsx b/src/App.tsx index 0b8e786..1d04db1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,6 @@ import { Route, Routes } from "react-router-dom"; import styles from "./App.module.css"; import { ChatWidget } from "./components/organisms"; import { MainTemplate } from "./components/templates"; -import { AuthProvider } from "./contexts/AuthContext"; import { CategoryProvider } from "./contexts/CategoryContext"; import { ModalProvider } from "./contexts/ModalContext"; import { ToastProvider } from "./contexts/ToastContext"; @@ -55,39 +54,37 @@ export default function App() { return (
- - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - - - setIsChatOpen(!isChatOpen)} - onChange={setNewMessage} - onSend={handleSendMessage} + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> - - - - + } /> + } /> + } /> + + + setIsChatOpen(!isChatOpen)} + onChange={setNewMessage} + onSend={handleSendMessage} + /> + + +
); diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 276cc83..c259fe0 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -88,3 +88,9 @@ export async function resetPassword( const res = await api.post("/auth/password/reset", body); return { message: res.data.message }; } + +// 로그아웃 API 함수 +export async function logout(): Promise { + const res = await api.post("/auth/logout"); + return { message: res.data.message }; +} diff --git a/src/components/molecules/UserNav/UserNav.module.css b/src/components/molecules/UserNav/UserNav.module.css new file mode 100644 index 0000000..5b4d7ed --- /dev/null +++ b/src/components/molecules/UserNav/UserNav.module.css @@ -0,0 +1,30 @@ +.container { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.navLink { + text-decoration: none; + color: var(--color-text); + font-size: 0.9rem; + font-weight: 500; +} + +.navLink:hover { + text-decoration: underline; +} + +.logoutButton { + border: none; + background-color: transparent; + color: var(--color-text); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + padding: 0; +} + +.logoutButton:hover { + text-decoration: underline; +} diff --git a/src/components/molecules/UserNav/UserNav.tsx b/src/components/molecules/UserNav/UserNav.tsx new file mode 100644 index 0000000..c53df62 --- /dev/null +++ b/src/components/molecules/UserNav/UserNav.tsx @@ -0,0 +1,21 @@ +import { useAuthStore } from "../../../store/authStore"; +import { Button } from "../../atoms"; +import styles from "./UserNav.module.css"; + +export default function UserNav() { + const { logout } = useAuthStore(); + + return ( +
+ + 마이 + + + 장바구니 + + +
+ ); +} diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index 63abc1b..6af68dd 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -23,3 +23,4 @@ export { default as SearchBar } from "./SearchBar/SearchBar"; export { default as SidebarMenu } from "./SidebarMenu/SidebarMenu"; export { default as Table } from "./Table/Table"; export { default as UserMenu } from "./UserMenu/UserMenu"; +export { default as UserNav } from "./UserNav/UserNav"; diff --git a/src/components/organisms/Header/Header.module.css b/src/components/organisms/Header/Header.module.css index d68e48d..22aa145 100644 --- a/src/components/organisms/Header/Header.module.css +++ b/src/components/organisms/Header/Header.module.css @@ -18,3 +18,28 @@ align-items: center; gap: 1.5rem; } + +.navLink { + text-decoration: none; + color: var(--color-text); + font-size: 0.9rem; + font-weight: 500; +} + +.navLink:hover { + text-decoration: underline; +} + +.logoutButton { + border: none; + background-color: transparent; + color: var(--color-text); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + padding: 0; +} + +.logoutButton:hover { + text-decoration: underline; +} diff --git a/src/components/organisms/Header/Header.tsx b/src/components/organisms/Header/Header.tsx index ac82f82..d54a15e 100644 --- a/src/components/organisms/Header/Header.tsx +++ b/src/components/organisms/Header/Header.tsx @@ -1,14 +1,7 @@ -import { Heart, ShoppingCart } from "lucide-react"; import { CATEGORIES } from "../../../constants/categories"; -import { useAuth } from "../../../contexts/AuthContext"; +import { useAuthStore } from "../../../store/authStore"; import { Logo } from "../../atoms"; -import { - AuthNav, - IconButton, - NavMenu, - SearchBar, - UserMenu, -} from "../../molecules"; +import { AuthNav, NavMenu, SearchBar, UserNav } from "../../molecules"; import styles from "./Header.module.css"; type HeaderProps = { @@ -24,7 +17,8 @@ export default function Header({ selectedCategory, setSelectedCategory, }: HeaderProps) { - const { isLoggedIn, user, wishlistCount, cartCount } = useAuth(); + const { accessToken } = useAuthStore(); + const isLoggedIn = !!accessToken; return (
@@ -38,25 +32,9 @@ export default function Header({ } /> - {isLoggedIn ? ( -
- - - -
- ) : ( - - )} +
+ {isLoggedIn ? : } +
void; - logout: () => void; - // 필요하면 카운트 업데이트 함수도 추가 -}; - -const AuthContext = createContext(undefined); - -export function AuthProvider({ children }: { children: ReactNode }) { - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [user, setUser] = useState(); - const [wishlistCount, setWishlistCount] = useState(0); - const [cartCount, setCartCount] = useState(0); - - const login = (userData: User) => { - setIsLoggedIn(true); - setUser(userData); - // 예: 서버에서 counts 받아오기 - // setWishlistCount(fetchedWishlist); - // setCartCount(fetchedCart); - }; - - const logout = () => { - setIsLoggedIn(false); - setUser(undefined); - setWishlistCount(0); - setCartCount(0); - }; - - return ( - - {children} - - ); -} - -export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth must be inside AuthProvider"); - return ctx; -} diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index 8e89bef..cd09c78 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -3,10 +3,12 @@ import { Eye, EyeClosed } from "lucide-react"; import { type FormEvent, useState } from "react"; import { useNavigate } from "react-router-dom"; import { login } from "../../api/authApi"; +import { useAuthStore } from "../../store/authStore"; import styles from "./Login.module.css"; const Login = () => { const navigate = useNavigate(); + const { setAuth } = useAuthStore(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -21,14 +23,13 @@ const Login = () => { setIsLoading(true); try { - // 1. API를 호출하여 응답 데이터를 받습니다. const data = await login({ email, password }); console.log("로그인 성공:", data); - // 2. 받은 accessToken을 localStorage에 저장합니다. - localStorage.setItem("accessToken", data.accessToken); + // localStorage와 메모리 상태(zustand)를 모두 업데이트해줍니다. + setAuth(data.accessToken, data.userId); - // 3. alert 대신 메인 페이지('/')로 즉시 리다이렉트(이동)합니다. + // 메인 페이지('/')로 즉시 리다이렉트(이동)합니다. navigate("/"); } catch (err) { const error = err as AxiosError<{ code: string; message: string }>; @@ -86,7 +87,7 @@ const Login = () => { className={styles.input} placeholder="이메일 또는 아이디를 입력하세요" required - disabled={isLoading} // 로딩 중 비활성화 + disabled={isLoading} /> @@ -104,7 +105,7 @@ const Login = () => { className={`${styles.input} ${styles.passwordInput}`} placeholder="비밀번호를 입력하세요" required - disabled={isLoading} // 로딩 중 비활성화 + disabled={isLoading} /> diff --git a/src/store/authStore.ts b/src/store/authStore.ts new file mode 100644 index 0000000..e39bcd0 --- /dev/null +++ b/src/store/authStore.ts @@ -0,0 +1,38 @@ +import { create } from "zustand"; +import { logout as logoutApi } from "../api/authApi"; + +type AuthState = { + userId: string | null; + accessToken: string | null; + setAuth: (accessToken: string, userId: string) => void; + logout: () => Promise; + isInitialized: boolean; + initialize: () => void; +}; + +export const useAuthStore = create((set) => ({ + userId: null, + accessToken: localStorage.getItem("accessToken"), // 페이지 로드 시 localStorage에서 토큰을 가져와 초기화 + isInitialized: false, + setAuth: (accessToken, userId) => { + localStorage.setItem("accessToken", accessToken); + set({ accessToken, userId }); + }, + logout: async () => { + try { + // 1. 서버에 로그아웃 요청을 보내 Refresh Token을 무효화합니다. + await logoutApi(); + } catch (error) { + console.error("서버 로그아웃에 실패했습니다.", error); + } finally { + // 2. API 성공 여부와 관계없이 클라이언트의 상태를 모두 초기화합니다. + localStorage.removeItem("accessToken"); + set({ accessToken: null, userId: null }); + // 로그아웃 후 메인 페이지로 이동 + window.location.href = "/"; + } + }, + initialize: () => { + set({ isInitialized: true }); + }, +}));