From 7c5f9327ff495f700d1237b96a8adaf251ebcd28 Mon Sep 17 00:00:00 2001 From: ycbyun Date: Fri, 19 Sep 2025 21:51:18 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20zustand=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 32 +++++++++++++++++++++++++++++++- package.json | 3 ++- 2 files changed, 33 insertions(+), 2 deletions(-) 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", From e6bf38a5199e59ba4fc21fe26613b2ff5efdb349 Mon Sep 17 00:00:00 2001 From: ycbyun Date: Fri, 19 Sep 2025 21:52:32 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20=EC=A0=84=EC=97=AD=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?Zustand=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contexts/AuthContext.tsx | 76 +++++++++++++++++++++++++++++------- src/store/authStore.ts | 38 ++++++++++++++++++ 2 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 src/store/authStore.ts diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index f0be059..091acfd 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,38 +1,84 @@ -import { createContext, type ReactNode, useContext, useState } from "react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { useNavigate } from "react-router-dom"; +import { logout as logoutApi } from "../api/authApi"; import type { User } from "../types/types"; type AuthState = { isLoggedIn: boolean; user?: User; + login: (accessToken: string, user: User) => void; + logout: () => void; wishlistCount: number; cartCount: number; - login: (user: User) => void; - logout: () => void; - // 필요하면 카운트 업데이트 함수도 추가 }; const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [user, setUser] = useState(); + const [user, setUser] = useState(); + const navigate = useNavigate(); + + // 1. localStorage에서 토큰을 읽어와 accessToken 상태의 초기값으로 설정합니다. + const [accessToken, setAccessToken] = useState( + localStorage.getItem("accessToken"), + ); + + // 2. isLoggedIn 상태는 accessToken의 존재 여부에 따라 결정됩니다. + const isLoggedIn = !!accessToken; + + // 기존 카운트 상태는 유지 const [wishlistCount, setWishlistCount] = useState(0); const [cartCount, setCartCount] = useState(0); - const login = (userData: User) => { - setIsLoggedIn(true); + // 3. 로그인 함수를 수정합니다. + // 토큰과 사용자 정보를 받아 localStorage와 상태에 모두 저장합니다. + const login = (token: string, userData: User) => { + localStorage.setItem("accessToken", token); + setAccessToken(token); setUser(userData); - // 예: 서버에서 counts 받아오기 + // TODO: 로그인 후 위시리스트/장바구니 개수 불러오는 API 호출 // setWishlistCount(fetchedWishlist); // setCartCount(fetchedCart); }; - const logout = () => { - setIsLoggedIn(false); - setUser(undefined); - setWishlistCount(0); - setCartCount(0); - }; + // 4. 로그아웃 함수를 실제로직으로 구현합니다. + const logout = useCallback(async () => { + if (!window.confirm("로그아웃하시겠습니까?")) { + return; + } + try { + // 서버에 로그아웃 요청을 보내 Refresh Token을 무효화합니다. + await logoutApi(); + } catch (error) { + console.error("서버 로그아웃 요청에 실패했습니다.", error); + } finally { + // API 성공 여부와 관계없이 클라이언트의 모든 인증 정보를 삭제합니다. + localStorage.removeItem("accessToken"); + setAccessToken(null); + setUser(undefined); + setWishlistCount(0); + setCartCount(0); + // 로그아웃 후 홈으로 이동합니다. + navigate("/"); + } + }, [navigate]); + + useEffect(() => { + const syncLoginState = () => { + setAccessToken(localStorage.getItem("accessToken")); + }; + window.addEventListener("storage", syncLoginState); + return () => { + window.removeEventListener("storage", syncLoginState); + }; + }, []); return ( 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 }); + }, +})); From 53c5c5331c9c761c0ea2b3ff9ab2553b78aad22b Mon Sep 17 00:00:00 2001 From: ycbyun Date: Fri, 19 Sep 2025 21:53:29 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9C=A0=EC=A0=80=20=EB=A9=94=EB=89=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/authApi.ts | 6 ++++++ src/components/molecules/UserNav/UserNav.tsx | 21 ++++++++++++++++++++ src/components/molecules/index.ts | 1 + 3 files changed, 28 insertions(+) create mode 100644 src/components/molecules/UserNav/UserNav.tsx 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.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"; From 2a82fefcbdd49a763d84d14d0c1715ce2409b76b Mon Sep 17 00:00:00 2001 From: ycbyun Date: Fri, 19 Sep 2025 21:57:02 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=8B=9C=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20UI=EA=B0=80=20=EC=A6=89=EC=8B=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=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/organisms/Header/Header.tsx | 36 +++++----------------- src/pages/Login/Login.tsx | 16 +++++----- 2 files changed, 15 insertions(+), 37 deletions(-) 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 ? : } +
{ 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} /> From 302f6ec1a8e36ca1a047646158af74dbfca6b2e9 Mon Sep 17 00:00:00 2001 From: ycbyun Date: Fri, 19 Sep 2025 21:57:58 +0900 Subject: [PATCH 5/6] =?UTF-8?q?style:=20UserNav=20=EB=B0=8F=20Header=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../molecules/UserNav/UserNav.module.css | 30 +++++++++++++++++++ .../organisms/Header/Header.module.css | 25 ++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/components/molecules/UserNav/UserNav.module.css 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/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; +} From bcfd3036ca970aec6c56efc182d662519b483e52 Mon Sep 17 00:00:00 2001 From: ycbyun Date: Mon, 22 Sep 2025 17:33:40 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20AuthProvider=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20Zustand=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 63 +++++++++++------------ src/contexts/AuthContext.tsx | 96 ------------------------------------ 2 files changed, 30 insertions(+), 129 deletions(-) delete mode 100644 src/contexts/AuthContext.tsx 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/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx deleted file mode 100644 index 091acfd..0000000 --- a/src/contexts/AuthContext.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useState, -} from "react"; -import { useNavigate } from "react-router-dom"; -import { logout as logoutApi } from "../api/authApi"; -import type { User } from "../types/types"; - -type AuthState = { - isLoggedIn: boolean; - user?: User; - login: (accessToken: string, user: User) => void; - logout: () => void; - wishlistCount: number; - cartCount: number; -}; - -const AuthContext = createContext(undefined); - -export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(); - const navigate = useNavigate(); - - // 1. localStorage에서 토큰을 읽어와 accessToken 상태의 초기값으로 설정합니다. - const [accessToken, setAccessToken] = useState( - localStorage.getItem("accessToken"), - ); - - // 2. isLoggedIn 상태는 accessToken의 존재 여부에 따라 결정됩니다. - const isLoggedIn = !!accessToken; - - // 기존 카운트 상태는 유지 - const [wishlistCount, setWishlistCount] = useState(0); - const [cartCount, setCartCount] = useState(0); - - // 3. 로그인 함수를 수정합니다. - // 토큰과 사용자 정보를 받아 localStorage와 상태에 모두 저장합니다. - const login = (token: string, userData: User) => { - localStorage.setItem("accessToken", token); - setAccessToken(token); - setUser(userData); - // TODO: 로그인 후 위시리스트/장바구니 개수 불러오는 API 호출 - // setWishlistCount(fetchedWishlist); - // setCartCount(fetchedCart); - }; - - // 4. 로그아웃 함수를 실제로직으로 구현합니다. - const logout = useCallback(async () => { - if (!window.confirm("로그아웃하시겠습니까?")) { - return; - } - try { - // 서버에 로그아웃 요청을 보내 Refresh Token을 무효화합니다. - await logoutApi(); - } catch (error) { - console.error("서버 로그아웃 요청에 실패했습니다.", error); - } finally { - // API 성공 여부와 관계없이 클라이언트의 모든 인증 정보를 삭제합니다. - localStorage.removeItem("accessToken"); - setAccessToken(null); - setUser(undefined); - setWishlistCount(0); - setCartCount(0); - // 로그아웃 후 홈으로 이동합니다. - navigate("/"); - } - }, [navigate]); - - useEffect(() => { - const syncLoginState = () => { - setAccessToken(localStorage.getItem("accessToken")); - }; - window.addEventListener("storage", syncLoginState); - return () => { - window.removeEventListener("storage", syncLoginState); - }; - }, []); - - return ( - - {children} - - ); -} - -export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth must be inside AuthProvider"); - return ctx; -}