From 330614def6391999159e471117d7adf71a772f4a Mon Sep 17 00:00:00 2001 From: p-changki Date: Thu, 13 Nov 2025 18:16:03 +0900 Subject: [PATCH 01/10] =?UTF-8?q?:sparkles:=20feat=20:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EC=9E=87=20api=EB=A1=9C=20=EA=B5=90=EC=B2=B4=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20fetchClient=20=EC=83=9D=EC=84=B1=ED=9B=84=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85,=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 45 +++++ package.json | 2 + src/Components/ui/button/LoginBtn.jsx | 9 - .../(auth)/_components/_login/LoginInput.jsx | 67 +++++-- src/app/(auth)/_components/_signup/Signp.jsx | 133 +++++++++++++- src/app/(auth)/login/page.jsx | 2 - src/lib/services/articles.js | 38 +--- src/lib/services/auth.js | 24 +++ src/lib/services/fetchClient.js | 169 ++++++++++++++++++ src/lib/services/user.js | 26 +++ src/providers/AuthProvider.jsx | 5 + 11 files changed, 461 insertions(+), 59 deletions(-) delete mode 100644 src/Components/ui/button/LoginBtn.jsx create mode 100644 src/lib/services/auth.js create mode 100644 src/lib/services/fetchClient.js create mode 100644 src/lib/services/user.js create mode 100644 src/providers/AuthProvider.jsx diff --git a/package-lock.json b/package-lock.json index 7e70fe8d..1ab633a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "sprint8", "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.90.7", "clsx": "^2.1.1", "dayjs": "^1.11.19", "next": "16.0.1", @@ -17,6 +18,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@tanstack/eslint-plugin-query": "^5.91.2", "autoprefixer": "^10.4.21", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", @@ -1477,6 +1479,49 @@ "tailwindcss": "4.1.16" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.2.tgz", + "integrity": "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.44.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.7.tgz", + "integrity": "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.7.tgz", + "integrity": "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", diff --git a/package.json b/package.json index 2718077b..65abc780 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@tanstack/react-query": "^5.90.7", "clsx": "^2.1.1", "dayjs": "^1.11.19", "next": "16.0.1", @@ -18,6 +19,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@tanstack/eslint-plugin-query": "^5.91.2", "autoprefixer": "^10.4.21", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", diff --git a/src/Components/ui/button/LoginBtn.jsx b/src/Components/ui/button/LoginBtn.jsx deleted file mode 100644 index 53af4984..00000000 --- a/src/Components/ui/button/LoginBtn.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react"; - -export default function LoginBtn() { - return ( - - ); -} diff --git a/src/app/(auth)/_components/_login/LoginInput.jsx b/src/app/(auth)/_components/_login/LoginInput.jsx index 8edbaa1d..859ed64b 100644 --- a/src/app/(auth)/_components/_login/LoginInput.jsx +++ b/src/app/(auth)/_components/_login/LoginInput.jsx @@ -1,6 +1,8 @@ "use client"; +import { authService } from "@/lib/services/auth"; import clsx from "clsx"; import Image from "next/image"; +import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { AiOutlineEye, AiOutlineEyeInvisible } from "react-icons/ai"; @@ -10,13 +12,17 @@ export default function LoginInput() { const [emailError, setEmailError] = useState(""); const [password, setPassword] = useState(""); const [passwordError, setPasswordError] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const router = useRouter(); const handleEmail = (e) => { - setEmail(e.target.value); + const emailValue = e.target.value; + setEmail(emailValue); const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (e.target.value && !emailRegex.test(e.target.value)) { + if (emailValue && !emailRegex.test(emailValue)) { setEmailError("잘못된 이메일입니다!"); } else { setEmailError(""); @@ -24,12 +30,13 @@ export default function LoginInput() { }; const handlePassword = (e) => { - setPassword(e.target.value); + const passwordValue = e.target.value; + setPassword(passwordValue); const passwordRegEx = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/; - if (e.target.value && !passwordRegEx.test(e.target.value)) { + if (passwordValue && !passwordRegEx.test(passwordValue)) { setPasswordError( "비밀번호는 8자 이상, 영문·숫자·특수문자를 포함해야 합니다!" ); @@ -38,8 +45,32 @@ export default function LoginInput() { } }; + const handleSubmit = async (e) => { + e.preventDefault(); + + if (emailError || passwordError) { + setError("정보확인요망!"); + return; + } + + try { + setLoading(true); + + const result = await authService.login(email, password); + console.log("로그인 성공:", result); + alert("로그인 성공!"); + localStorage.setItem("token", result.accessToken); + localStorage.setItem("refreshToken", result.refreshToken); + router.push("/"); + } catch (error) { + setError("로그인에 실패했습니다!" || error.message); + } finally { + setLoading(false); + } + }; + return ( -
+

이메일 @@ -51,11 +82,11 @@ export default function LoginInput() { placeholder="이메일을 입력해주세요." className={clsx( "border-0 bg-gray-100 rounded-xl w-[640px] h-14 px-6 py-4", - { "outline-1 outline-red-400": emailError } + { "outline-1 outline-[#F74747]": emailError } )} /> {emailError && ( -

{emailError}

+

{emailError}

)}
@@ -70,14 +101,11 @@ export default function LoginInput() { placeholder="비밀번호를 입력해주세요" className={clsx( "border-0 bg-gray-100 w-[640px] items-center rounded-xl h-14 px-6 py-4 ", - { "outline-1 outline-red-400": passwordError } + { "outline-1 outline-[#F74747]": passwordError } )} /> - {passwordError && ( -

{passwordError}

- )}
+ {passwordError && ( +

{passwordError}

+ )} + + + {error &&

{error}

}
); } diff --git a/src/app/(auth)/_components/_signup/Signp.jsx b/src/app/(auth)/_components/_signup/Signp.jsx index 42f98960..260c3370 100644 --- a/src/app/(auth)/_components/_signup/Signp.jsx +++ b/src/app/(auth)/_components/_signup/Signp.jsx @@ -1,23 +1,123 @@ "use client"; +import { authService } from "@/lib/services/auth"; +import clsx from "clsx"; import Image from "next/image"; +import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { AiOutlineEye, AiOutlineEyeInvisible } from "react-icons/ai"; export default function SignInput() { const [showEye, setShowEye] = useState(false); + const [email, setEmail] = useState(""); + const [nickname, setNickname] = useState(""); + const [emailError, setEmailError] = useState(""); + const [password, setPassword] = useState(""); + const [passwordError, setPasswordError] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [confirmError, setConfirmError] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + //email 검증 + const handleEmail = (e) => { + const emailValue = e.target.value; + setEmail(emailValue); + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (emailValue && !emailRegex.test(emailValue)) { + setEmailError("잘못된 이메일입니다."); + } else { + setEmailError(""); + } + }; + + const handleNickName = (e) => { + const nickName = e.target.value; + setNickname(nickName); + }; + + //password 검증 + const handlePassword = (e) => { + const passwordValue = e.target.value; + setPassword(passwordValue); + + const passwordRegEx = + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/; + + if (passwordValue && !passwordRegEx.test(passwordValue)) { + setPasswordError( + "비밀번호는 8자 이상, 영문·숫자·특수문자를 포함해야 합니다!" + ); + } else { + setPasswordError(""); + } + }; + + //password 확인 + const handlePasswordConfirm = (e) => { + const confirmValue = e.target.value; + setPasswordConfirm(confirmValue); + + if (confirmValue !== password) { + setConfirmError("비밀번호가 일치하지 않습니다."); + } else { + setConfirmError(""); + } + }; + + const handleSignup = async (e) => { + e.preventDefault(); + + if (emailError || passwordError || confirmError) { + setError("입력하신 정보를 다시 확인해주세요."); + return; + } + if (!email || !password || !nickname || !passwordConfirm) { + setError("입력해주세요!"); + return; + } + try { + setError(null); + setLoading(true); + + const result = await authService.signUp( + email, + nickname, + password, + passwordConfirm + ); + console.log("회원가입 완료", result); + alert("회원가입 완료!"); + router.push("/login"); + } catch (error) { + setError(error.message || "회원가입에 실패했습니다."); + } finally { + setLoading(false); + } + }; return ( -
+

이메일

+ {emailError && ( +

{emailError}

+ )}

@@ -25,6 +125,8 @@ export default function SignInput() {

@@ -37,8 +139,13 @@ export default function SignInput() {
+ {passwordError && ( +

{passwordError}

+ )}
@@ -61,8 +171,13 @@ export default function SignInput() {
+ {confirmError && ( +

{confirmError}

+ )}
- -
+ ); } diff --git a/src/app/(auth)/login/page.jsx b/src/app/(auth)/login/page.jsx index 21e798db..8d723f5b 100644 --- a/src/app/(auth)/login/page.jsx +++ b/src/app/(auth)/login/page.jsx @@ -1,6 +1,5 @@ import React from "react"; import LoginInput from "../_components/_login/LoginInput"; -import LoginBtn from "../../../components/ui/button/LoginBtn"; import PandaLogo from "../../../components/ui/logo/PandaLogo"; import EasyLogin from "../_components/_login/EasyLogin"; @@ -10,7 +9,6 @@ export default function LoginPage() {
-
diff --git a/src/lib/services/articles.js b/src/lib/services/articles.js index 1c384f31..d98f5335 100644 --- a/src/lib/services/articles.js +++ b/src/lib/services/articles.js @@ -1,30 +1,18 @@ -const BASE_URL = process.env.NEXT_PUBLIC_API_URL; - import dayjs from "dayjs"; import "dayjs/locale/ko"; import macBook from "@/public/images/macBook.png"; import profile from "@/public/images/profile.png"; import heart from "@/public/images/heart.png"; +import { defaultFetch } from "./fetchClient"; //게시글 목록 조회 export async function getArticles() { - const res = await fetch(`${BASE_URL}/articles`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (!res.ok) { - throw new Error(`게시글 불러오기 실패 : ${res.status}`); - } - - const result = await res.json(); + const result = await defaultFetch("/articles"); - const postsDefaults = result.data.map((item) => ({ + const postsDefaults = result.list.map((item) => ({ ...item, - likes: item.likes || "9,999", - author: item.author || "익명 사용자", + likes: item.likeCount || "9,999", + author: item.writer.nickname || "익명 사용자", background: macBook, profile, heart, @@ -36,22 +24,12 @@ export async function getArticles() { //게시글 하나씩 조회 export async function getArticleById(articleId) { - const res = await fetch(`${BASE_URL}/articles/${articleId}`, { - headers: { - "Content-Type": "application/json", - }, - }); - - if (!res.ok) { - throw new Error(`"게시글 상세 조회 실패" : ${res.status}`); - } - - const item = await res.json(); + const item = await defaultFetch(`/articles/${articleId}`); return { ...item, - author: item.author || "익명 사용자", - likes: item.likes || "9,999", + author: item.likeCount || "익명 사용자", + likes: item.likeCount || "9,999", background: macBook, profile, heart, diff --git a/src/lib/services/auth.js b/src/lib/services/auth.js new file mode 100644 index 00000000..d79d1124 --- /dev/null +++ b/src/lib/services/auth.js @@ -0,0 +1,24 @@ +import { defaultFetch } from "./fetchClient"; + +export const authService = { + // 회원가입 + signUp: (email, nickname, password, passwordConfirmation) => + defaultFetch("/auth/signUp", { + method: "POST", + body: JSON.stringify({ + email, + nickname, + password, + passwordConfirmation, + }), + }), + //로그인 + login: (email, password) => + defaultFetch("/auth/signIn", { + method: "POST", + body: JSON.stringify({ + email, + password, + }), + }), +}; diff --git a/src/lib/services/fetchClient.js b/src/lib/services/fetchClient.js new file mode 100644 index 00000000..7f47525d --- /dev/null +++ b/src/lib/services/fetchClient.js @@ -0,0 +1,169 @@ +// src/lib/fetchClient.js + +/** + * 기본 fetch 클라이언트 - 인증이 필요 없는 일반 요청용 + */ + +// fetch(url, options) +// defaultFetch(url, options) + +export const defaultFetch = async (url, options = {}) => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + const defaultOptions = { + headers: { + "Content-Type": "application/json", + }, + // Next.js 기본 캐싱 활성화 + cache: "force-cache", // 'no-cache' 조건부로 쓰겠다! + }; + + const mergedOptions = { + ...defaultOptions, + ...options, + headers: { + ...defaultOptions.headers, + ...options.headers, + }, + }; + + const response = await fetch(`${baseURL}${url}`, mergedOptions); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return response.json(); +}; + +/** + * 쿠키 인증 fetch 클라이언트 - 쿠키 기반 인증이 필요한 요청용 + */ +export const cookieFetch = async (url, options = {}) => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + const defaultOptions = { + headers: { + "Content-Type": "application/json", + }, + // 쿠키 전송을 위한 설정 + credentials: "include", + // 서버 컴포넌트에서도 매번 재검증 + cache: "no-store", + }; + + const mergedOptions = { + ...defaultOptions, + ...options, + headers: { + ...defaultOptions.headers, + ...options.headers, + }, + }; + + const response = await fetch(`${baseURL}${url}`, mergedOptions); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return response.json(); +}; + +/** + * 토큰 인증 fetch 클라이언트 - 헤더에 토큰을 포함하는 요청용 + */ +export const tokenFetch = async (url, options = {}) => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + let token; + + // 클라이언트 사이드인 경우에만 localStorage 접근 + if (typeof window !== "undefined") { + token = localStorage.getItem("token"); + } + + const defaultOptions = { + headers: { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + }, + // 페이지 방문마다 재검증 (ISR 패턴) + next: { revalidate: 60 }, // 60초마다 재검증 + }; + + const mergedOptions = { + ...defaultOptions, + ...options, + headers: { + ...defaultOptions.headers, + ...options.headers, + }, + }; + + const response = await fetch(`${baseURL}${url}`, mergedOptions); + + if (response.status === 401 && typeof window !== "undefined") { + // 토큰 만료 처리 - 리프레시 토큰으로 새 토큰 요청 로직 + // 서버 컴포넌트에서는 이 로직이 실행되지 않음 + try { + const refreshToken = localStorage.getItem("refreshToken"); + const refreshResponse = await fetch(`${baseURL}/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + + if (refreshResponse.ok) { + const { token: newToken } = await refreshResponse.json(); + localStorage.setItem("token", newToken); + + // 새 토큰으로 원래 요청 재시도 + mergedOptions.headers.Authorization = `Bearer ${newToken}`; + return fetch(`${baseURL}${url}`, mergedOptions).then((res) => + res.json() + ); + } else { + // 리프레시 실패 시 로그아웃 + localStorage.removeItem("token"); + localStorage.removeItem("refreshToken"); + window.location.href = "/login"; + throw new Error("Authentication failed"); + } + } catch (error) { + throw new Error("Token refresh failed"); + } + } else if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return response.json(); +}; + +/** + * 동적 데이터 fetch 클라이언트 - SSR 없이 클라이언트에서만 사용하는 요청용 + */ +export const dynamicFetch = async (url, options = {}) => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + const defaultOptions = { + headers: { + "Content-Type": "application/json", + }, + // Next.js에게 이 요청은 캐시하지 말라고 지시 + cache: "no-store", + }; + + const mergedOptions = { + ...defaultOptions, + ...options, + headers: { + ...defaultOptions.headers, + ...options.headers, + }, + }; + + const response = await fetch(`${baseURL}${url}`, mergedOptions); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return response.json(); +}; diff --git a/src/lib/services/user.js b/src/lib/services/user.js new file mode 100644 index 00000000..b48b44ba --- /dev/null +++ b/src/lib/services/user.js @@ -0,0 +1,26 @@ +import { tokenFetch } from "./fetchClient"; + +export const userService = { + // 프로필 조회 + getMe: () => tokenFetch("/users/me"), + + //내 정보 수정 + updateMe: (data) => + tokenFetch("/users/me", { + method: "PATCH", + body: JSON.stringify(data), + }), + + //비밀번호 변경 + updatePassword: (data) => + tokenFetch("/users/me/password", { + method: "PATCH", + body: JSON.stringify(data), + }), + + //내가 등록한 상품 목록 + getMyProducts: () => tokenFetch("/users/me/products"), + + //내가 찜한 상품 목록 + getFavorites: () => tokenFetch("/users/me/favorites"), +}; diff --git a/src/providers/AuthProvider.jsx b/src/providers/AuthProvider.jsx new file mode 100644 index 00000000..dd9cfbc0 --- /dev/null +++ b/src/providers/AuthProvider.jsx @@ -0,0 +1,5 @@ +"use client"; + +export default function AuthProvider({ children }) { + return
{children}
; +} From dfa3d0351a16d7c99545bbdbfa57020f47bd1b0a Mon Sep 17 00:00:00 2001 From: p-changki Date: Thu, 13 Nov 2025 20:14:54 +0900 Subject: [PATCH 02/10] =?UTF-8?q?:sparkles:=20feat=20:=20authProvider=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=9B=84=20articles=20page=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=ED=9B=84=20=EB=A1=9C=EC=BB=AC=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EC=A7=80=20=EC=A0=80=EC=9E=A5=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Components/common/Header.jsx | 43 ++++++++-- .../(auth)/_components/_login/LoginInput.jsx | 1 + src/app/(main)/layout.jsx | 15 ++-- src/providers/AuthProvider.jsx | 81 ++++++++++++++++++- 4 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/Components/common/Header.jsx b/src/Components/common/Header.jsx index 1967c469..05115d47 100644 --- a/src/Components/common/Header.jsx +++ b/src/Components/common/Header.jsx @@ -1,8 +1,16 @@ +"use client"; import Image from "next/image"; import PandaLogo from "@/public/images/Group19.png"; import Link from "next/link"; +import { useAuth } from "@/providers/AuthProvider"; +import { useRouter } from "next/navigation"; export default function Header() { + const { user, logout, isInitialized } = useAuth(); + const router = useRouter(); + + if (!isInitialized) return null; + return (
@@ -29,12 +37,35 @@ export default function Header() {
- - 로그인 - + +
+ {user ? ( +
+ 프로필 이미지 + + {user.nickname} 님 + + +
+ ) : ( + + )} +
); } diff --git a/src/app/(auth)/_components/_login/LoginInput.jsx b/src/app/(auth)/_components/_login/LoginInput.jsx index 859ed64b..f00d4908 100644 --- a/src/app/(auth)/_components/_login/LoginInput.jsx +++ b/src/app/(auth)/_components/_login/LoginInput.jsx @@ -5,6 +5,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { AiOutlineEye, AiOutlineEyeInvisible } from "react-icons/ai"; +import EasyLogin from "./EasyLogin"; export default function LoginInput() { const [showEye, setShowEye] = useState(false); diff --git a/src/app/(main)/layout.jsx b/src/app/(main)/layout.jsx index 523850be..7ca00156 100644 --- a/src/app/(main)/layout.jsx +++ b/src/app/(main)/layout.jsx @@ -1,15 +1,20 @@ +"use client"; + import Footer from "@/components/common/Footer"; import Header from "@/components/common/Header"; +import AuthProvider from "@/providers/AuthProvider"; import React from "react"; export default function MainLayout({ children }) { return ( <> -
-
-
{children}
-
-