diff --git a/package-lock.json b/package-lock.json index 43996b60..1367f590 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@tanstack/react-query": "^5.90.7", "@tanstack/react-query-devtools": "^5.90.2", "clsx": "^2.1.1", + "cookie": "^1.0.2", "date-fns": "^4.1.0", "mock-socket": "^9.3.1", "next": "15.5.3", @@ -6745,6 +6746,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/core-js-compat": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", diff --git a/package.json b/package.json index cf7a98f9..33db738b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@tanstack/react-query": "^5.90.7", "@tanstack/react-query-devtools": "^5.90.2", "clsx": "^2.1.1", + "cookie": "^1.0.2", "date-fns": "^4.1.0", "mock-socket": "^9.3.1", "next": "15.5.3", diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..891a5498 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,58 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import * as cookie from "cookie"; + +const BASE_URL = process.env.NEXT_PUBLIC_API_URL; + +export async function POST(req: Request) { + const { email, password } = await req.json(); + + const backendRes = await fetch(`${BASE_URL}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (!backendRes.ok) { + const errorData = await backendRes.json(); + return NextResponse.json(errorData, { status: backendRes.status }); + } + + const { accessToken } = await backendRes.json(); + + const cookieHandler = await cookies(); + + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + path: "/", + }; + + cookieHandler.set({ + name: "accessToken", + value: accessToken, + ...cookieOptions, + maxAge: 60 * 60 * 2, + }); + + const setCookieHeaders = backendRes.headers.getSetCookie?.() ?? []; + + for (const c of setCookieHeaders) { + const parsed = cookie.parse(c); + + if (parsed.refreshToken) { + const maxAge = parsed["Max-Age"] + ? parseInt(parsed["Max-Age"], 10) + : 60 * 60 * 24 * 30; + + cookieHandler.set({ + name: "refreshToken", + value: parsed.refreshToken, + ...cookieOptions, + maxAge, + }); + } + } + + return NextResponse.json({ message: "Login successful", accessToken }); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 00000000..ee27af9a --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + const res = NextResponse.json({ message: "Logged out successfully" }); + + res.cookies.delete("accessToken"); + res.cookies.delete("refreshToken"); + + return res; +} diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts new file mode 100644 index 00000000..b21bdd5d --- /dev/null +++ b/src/app/api/auth/refresh/route.ts @@ -0,0 +1,70 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import * as cookie from "cookie"; + +const BASE_URL = process.env.NEXT_PUBLIC_API_URL; + +export async function POST() { + const cookieHandler = await cookies(); + const refreshToken = cookieHandler.get("refreshToken")?.value; + + if (!refreshToken) { + return NextResponse.json( + { message: "No refresh token provided" }, + { status: 401 }, + ); + } + + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + path: "/", + }; + + const backendRes = await fetch(`${BASE_URL}/auth/refresh`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: `refreshToken=${refreshToken}`, + }, + }); + + if (!backendRes.ok) { + return NextResponse.json( + { message: "Invalid refresh token" }, + { status: 401 }, + ); + } + + const { accessToken } = await backendRes.json(); + + const res = NextResponse.json({ accessToken }); + + cookieHandler.set({ + name: "accessToken", + value: accessToken, + ...cookieOptions, + maxAge: 60 * 60 * 2, + }); + + const setCookieHeaders = backendRes.headers.getSetCookie?.() ?? []; + + for (const c of setCookieHeaders) { + const parsed = cookie.parse(c); + + if (parsed.refreshToken) { + const maxAge = parsed["Max-Age"] + ? parseInt(parsed["Max-Age"], 10) + : 60 * 60 * 24 * 30; + + cookieHandler.set({ + name: "refreshToken", + value: parsed.refreshToken, + ...cookieOptions, + maxAge, + }); + } + } + + return res; +} diff --git a/src/app/detail/[postingId]/page.tsx b/src/app/detail/[postingId]/page.tsx index 402cc2d8..c49906e5 100644 --- a/src/app/detail/[postingId]/page.tsx +++ b/src/app/detail/[postingId]/page.tsx @@ -3,8 +3,10 @@ import { dehydrate, HydrationBoundary, } from "@tanstack/react-query"; -import { getPostDetail } from "@/entities/post/api/getPostDetail"; +import { getPostDetail } from "@/entities/post/api/getPostDetail.server"; +import { getUser } from "@/entities/user/api/getUser.server"; import PostDetailPageClient from "@/widgets/postDetail/ui/DetailPage.client"; +import type { PostDetail } from "@/entities/post/model/types/post"; export default async function Page({ params, @@ -18,9 +20,17 @@ export default async function Page({ await queryClient.prefetchQuery({ queryKey: ["postDetail", id], queryFn: () => getPostDetail(id), - staleTime: Infinity, }); + const post = queryClient.getQueryData(["postDetail", id]); + + if (post && post.sellerId) { + await queryClient.prefetchQuery({ + queryKey: ["seller", post.sellerId], + queryFn: () => getUser(post.sellerId), + }); + } + return ( diff --git a/src/app/my/page.tsx b/src/app/my/page.tsx index 3cadea3f..a8f55534 100644 --- a/src/app/my/page.tsx +++ b/src/app/my/page.tsx @@ -1,138 +1,31 @@ -"use client"; +import { + HydrationBoundary, + QueryClient, + dehydrate, +} from "@tanstack/react-query"; -import { useState, useCallback } from "react"; -import { useQuery } from "@tanstack/react-query"; -import Profile, { ProfileProps } from "@/entities/user/ui/card/Profile"; -import ProfileSkeleton from "@/entities/user/ui/card/ProfileSkeleton"; -import PostCard from "@/entities/post/ui/card/PostCard"; -import { PostCreateButton } from "@/features/createPost/ui/PostCreateButton/PostCreateButton"; -import Tab from "@/widgets/mypage/ui/Tab.tsx/Tab"; -import { useModalStore } from "@/shared/model/modal.store"; -import { usePostCreateModal } from "@/features/createPost/lib/usePostCreateModal"; -import { getMyPosts, getMyProfile } from "@/entities/user/api/mypage"; -import type { Post } from "@/entities/post/model/types/post"; -import { PostStatus } from "@/entities/post/model/types/post"; +import { getMyPosts } from "@/entities/user/api/getMyPosts.server"; +import { getMyProfile } from "@/entities/user/api/getMyProfile.server"; +import MyPageClient from "@/widgets/mypage/ui/Client/MyPage.client"; -const options = [ - { label: "판매중 상품", value: "selling" }, - { label: "판매완료 상품", value: "sold" }, - { label: "구매한 상품", value: "purchased" }, - { label: "관심 상품", value: "favorite" }, -]; +const DEFAULT_TAB = "selling"; -const emptyMessageMap: Record = { - selling: "등록되어 있는 상품이 없습니다.", - sold: "판매 완료된 상품이 없습니다.", - purchased: "구매 완료된 상품이 없습니다.", - favorite: "즐겨찾기한 상품이 없습니다.", -}; -interface PostListItem { - postingId: number; - sellerId: number; - title: string; - price: number; - content: string; - category: string; - createdAt: string; - likeCount: number; - chatCount: number; - viewCount: number; - thumbnail: string; - status: PostStatus; -} - -export default function Page() { - const [selectedTab, setSelectedTab] = useState(options[0].value); - const { openModal, closeModal } = useModalStore(); +export default async function Page() { + const queryClient = new QueryClient(); - const { - data: userProfile, - isLoading: profileLoading, - refetch: refetchUserProfile, - } = useQuery({ + await queryClient.prefetchQuery({ queryKey: ["userProfile"], queryFn: getMyProfile, }); - const { - data: postsData, - isLoading: postsLoading, - refetch: refetchPosts, - } = useQuery<{ data: Post[] }>({ - queryKey: ["myPosts", selectedTab], - queryFn: () => getMyPosts(selectedTab), + await queryClient.prefetchQuery({ + queryKey: ["myPosts", DEFAULT_TAB], + queryFn: () => getMyPosts(DEFAULT_TAB), }); - const { openPostCreateModal } = usePostCreateModal({ - onSuccess: async () => { - await refetchPosts(); - }, - }); - - const handleEditProfile = useCallback(() => { - if (!userProfile) return; - - openModal("editProfile", { - ...userProfile, - onClose: () => closeModal(), - onSave: async () => { - closeModal(); - setTimeout(() => { - openModal("normal", { - message: "프로필이 성공적으로 수정되었습니다.", - buttonText: "확인", - onClick: () => { - closeModal(); - }, - }); - }, 100); - await refetchUserProfile(); - }, - onError: () => { - closeModal(); - openModal("normal", { - message: "프로필 수정에 실패했습니다.", - buttonText: "확인", - onClick: () => { - closeModal(); - }, - }); - }, - }); - }, [userProfile, openModal, closeModal, refetchUserProfile]); - - const posts = postsData?.data || []; - return ( -
- {profileLoading || !userProfile ? ( - - ) : ( - - )} - -
- - - {postsLoading ? null : posts.length === 0 ? ( -

- {emptyMessageMap[selectedTab]} -

- ) : ( -
    - {posts.map((post) => ( -
  • - -
  • - ))} -
- )} - -
-
+ + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 8d873706..d91cdc64 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,7 @@ import { dehydrate, HydrationBoundary, } from "@tanstack/react-query"; -import { getPosts } from "@/entities/post/api/getPosts"; +import { getPosts } from "@/entities/post/api/getPosts.server"; import HomePageClient from "@/widgets/main/ui/Client/HomePage.client"; export default async function Page({ diff --git a/src/entities/post/api/getPostDetail.server.ts b/src/entities/post/api/getPostDetail.server.ts new file mode 100644 index 00000000..2b1130ab --- /dev/null +++ b/src/entities/post/api/getPostDetail.server.ts @@ -0,0 +1,10 @@ +import { serverFetch } from "@/shared/api/fetcher.server"; +import type { PostDetail } from "../model/types/post"; + +export async function getPostDetail(postingId: number): Promise { + if (!postingId) throw new Error("Invalid postingId"); + + return serverFetch(`/api/postings/${postingId}`, { + method: "GET", + }); +} diff --git a/src/entities/post/api/getPosts.server.ts b/src/entities/post/api/getPosts.server.ts new file mode 100644 index 00000000..2eecd2cf --- /dev/null +++ b/src/entities/post/api/getPosts.server.ts @@ -0,0 +1,30 @@ +import { serverFetch } from "@/shared/api/fetcher.server"; +import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api"; +import type { Post } from "@/entities/post/model/types/post"; + +export async function getPosts({ + category, + keyword, + sort, + page, +}: { + category: string; + keyword?: string; + sort?: string; + page?: number; +}) { + const query = new URLSearchParams({ + page: String(page), + size: String(POST_PAGE_SIZE), + }); + + if (category !== "전체") query.append("category", category); + if (keyword) query.append("keyword", keyword); + if (sort) query.append("sort", sort); + + const { data } = await serverFetch<{ data: Post[] }>( + `/api/postings?${query.toString()}`, + { method: "GET" }, + ); + return data; +} diff --git a/src/entities/post/api/getSellerPosts.ts b/src/entities/post/api/getSellerPosts.ts index 5799c7a7..a3aeb155 100644 --- a/src/entities/post/api/getSellerPosts.ts +++ b/src/entities/post/api/getSellerPosts.ts @@ -6,12 +6,14 @@ interface GetSellerPostsParams { userId: number; page?: number; size?: number; + excludeId?: number; } export async function getSellerPosts({ userId, page = 1, size = POST_PAGE_SIZE, + excludeId, }: GetSellerPostsParams): Promise<{ data: Post[] }> { if (!userId) throw new Error("Invalid userId"); @@ -20,6 +22,10 @@ export async function getSellerPosts({ size: String(size), }); + if (excludeId) { + query.append("postingId", String(excludeId)); + } + return apiFetch<{ data: Post[] }>( `/api/postings/user/${userId}?${query.toString()}`, { method: "GET" }, diff --git a/src/entities/user/api/getMyPosts.server.ts b/src/entities/user/api/getMyPosts.server.ts new file mode 100644 index 00000000..feea4ef3 --- /dev/null +++ b/src/entities/user/api/getMyPosts.server.ts @@ -0,0 +1,11 @@ +import { serverFetch } from "@/shared/api/fetcher.server"; +import type { Post } from "@/entities/post/model/types/post"; + +export async function getMyPosts(status: string) { + return await serverFetch<{ data: Post[] }>( + `/api/postings/my?status=${status}`, + { + method: "GET", + }, + ); +} diff --git a/src/entities/user/api/getMyProfile.server.ts b/src/entities/user/api/getMyProfile.server.ts new file mode 100644 index 00000000..e7d6ed1f --- /dev/null +++ b/src/entities/user/api/getMyProfile.server.ts @@ -0,0 +1,8 @@ +import { serverFetch } from "@/shared/api/fetcher.server"; +import type { ProfileProps } from "@/entities/user/ui/card/Profile"; + +export async function getMyProfile() { + return await serverFetch("/api/users/me", { + method: "GET", + }); +} diff --git a/src/entities/user/api/getUser.server.ts b/src/entities/user/api/getUser.server.ts new file mode 100644 index 00000000..959bf637 --- /dev/null +++ b/src/entities/user/api/getUser.server.ts @@ -0,0 +1,10 @@ +import { serverFetch } from "@/shared/api/fetcher.server"; +import type { User } from "../model/types/user"; + +export async function getUser(userId: number): Promise { + if (!userId) throw new Error("Invalid userId"); + + return serverFetch(`/api/users/${userId}`, { + method: "GET", + }); +} diff --git a/src/entities/user/ui/card/Profile.tsx b/src/entities/user/ui/card/Profile.tsx index 150e6102..212e657c 100644 --- a/src/entities/user/ui/card/Profile.tsx +++ b/src/entities/user/ui/card/Profile.tsx @@ -4,11 +4,12 @@ import { useRouter } from "next/navigation"; import Image from "next/image"; import { useAuthStore } from "@/features/auth/model/auth.store"; +import { useModalStore } from "@/shared/model/modal.store"; import Button from "@/shared/ui/Button/Button"; import DefaultProfileImage from "./assets/profile.jpg"; -import { apiFetch } from "@/shared/api/fetcher"; export interface ProfileProps { + userId: number; nickname: string; introduction?: string; imageUrl?: string; @@ -29,6 +30,7 @@ const Profile = ({ }: ProfileProps) => { const router = useRouter(); const { logout } = useAuthStore(); + const { openModal, closeModal } = useModalStore(); return (
@@ -80,16 +82,28 @@ const Profile = ({ +