diff --git a/build.sh b/build.sh deleted file mode 100644 index 56907aaf..00000000 --- a/build.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -cd ../ -mkdir output -cp -R ./FE/* ./output -cp -R ./output ./FE/ \ No newline at end of file diff --git a/src/app/(main)/books/[id]/page.tsx b/src/app/(main)/books/[id]/page.tsx index c4627cfa..e402dca6 100644 --- a/src/app/(main)/books/[id]/page.tsx +++ b/src/app/(main)/books/[id]/page.tsx @@ -6,14 +6,14 @@ import SearchBookResult from "@/components/base-ui/Search/search_bookresult"; import { DUMMY_STORIES } from "@/data/dummyStories"; import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large"; import { useBookDetailQuery } from "@/hooks/queries/useBookQueries"; +import { useToggleBookLikeMutation } from "@/hooks/mutations/useBookMutations"; export default function BookDetailPage() { const params = useParams(); const router = useRouter(); const isbn = params.id as string; - const [liked, setLiked] = useState(false); - const { data: bookData, isLoading, isError } = useBookDetailQuery(isbn); + const { mutate: toggleLike } = useToggleBookLikeMutation(); // 관련된 책 이야기들 (더미 데이터에서 필터링) const relatedStories = useMemo(() => { @@ -51,8 +51,8 @@ export default function BookDetailPage() { title={bookData.title} author={bookData.author} detail={bookData.description} - liked={liked} - onLikeChange={setLiked} + liked={bookData.likedByMe || false} + onLikeChange={() => toggleLike(isbn)} onPencilClick={() => { router.push(`/stories/new?isbn=${isbn}`); }} diff --git a/src/app/(main)/news/page.tsx b/src/app/(main)/news/page.tsx index d88c84d0..4c5edfec 100644 --- a/src/app/(main)/news/page.tsx +++ b/src/app/(main)/news/page.tsx @@ -1,61 +1,52 @@ "use client"; +import { useEffect, useMemo } from "react"; import Image from "next/image"; import NewsList from "@/components/base-ui/News/news_list"; import TodayRecommendedBooks from "@/components/base-ui/News/today_recommended_books"; import FloatingFab from "@/components/base-ui/Float"; import { useRecommendedBooksQuery } from "@/hooks/queries/useBookQueries"; -import { useMemo } from "react"; - -const DUMMY_NEWS = [ - { - id: 1, - imageUrl: "/news_sample.svg", - title: "책 읽는 한강공원", - content: - "소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용", - date: "2025-10-09", - }, - { - id: 2, - imageUrl: "/news_sample.svg", - title: "책 읽는 한강공원", - content: - "소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용", - date: "2025-10-09", - }, - { - id: 3, - imageUrl: "/news_sample.svg", - title: "책 읽는 한강공원", - content: - "소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용", - date: "2025-10-09", - }, - { - id: 4, - imageUrl: "/news_sample.svg", - title: "책 읽는 한강공원", - content: - "소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용소식내용", - date: "2025-10-09", - }, -]; +import { useInfiniteNewsQuery } from "@/hooks/queries/useNewsQueries"; +import { useInView } from "react-intersection-observer"; +import { EXTERNAL_LINKS } from "@/constants/links"; +import { Book } from "@/types/book"; export default function NewsPage() { const { data: recommendedData, isLoading: isLoadingRecommended } = useRecommendedBooksQuery(); + const { + data: newsData, + isLoading: isLoadingNews, + fetchNextPage, + hasNextPage, + isFetchingNextPage + } = useInfiniteNewsQuery(); + + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); const recommendedBooks = useMemo(() => { - return (recommendedData?.detailInfoList || []).map((book) => ({ + return (recommendedData?.detailInfoList || []).map((book: Book) => ({ id: book.isbn, imgUrl: book.imgUrl, title: book.title, author: book.author, + likedByMe: book.likedByMe, })); }, [recommendedData]); + const newsList = newsData?.pages.flatMap((page) => page.basicInfoList) || []; + + const isValidSrc = (src: string) => { + return src && src !== "string" && (src.startsWith("/") || src.startsWith("http://") || src.startsWith("https://")); + }; + return ( -
+
- {/* 오늘의 추천 */} + {/* 오늘의 추천 (모바일) */} {!isLoadingRecommended && recommendedBooks.length > 0 && ( )} - {/* 뉴스 리스트 */} -
- {DUMMY_NEWS.map((news) => ( - - ))} -
+ {/* 뉴스 리스트 섹션 */} +
+

새로운 소식

+ + {isLoadingNews ? ( +
+
+
+ ) : newsList.length === 0 ? ( +
+

등록된 소식이 없습니다.

+
+ ) : ( +
+ {newsList.map((news) => ( + + ))} + + {/* 무한 스크롤 트리거 */} +
+ {isFetchingNextPage && ( +
+ )} +
+
+ )} +
+ {/* 오늘의 추천 (데스크톱) */} {!isLoadingRecommended && recommendedBooks.length > 0 && ( )} @@ -100,6 +116,7 @@ export default function NewsPage() { window.open(EXTERNAL_LINKS.INQUIRY_FORM_URL, "_blank")} />
); diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 49ec8c41..153746a5 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -12,17 +12,26 @@ import LoginModal from "@/components/base-ui/Login/LoginModal"; import BookStoryCardLarge from "@/components/base-ui/BookStory/bookstory_card_large"; import { useAuthStore } from "@/store/useAuthStore"; -import { useStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useInView } from "react-intersection-observer"; import { useRecommendedMembersQuery } from "@/hooks/queries/useMemberQueries"; import { useMyClubsQuery } from "@/hooks/queries/useClubQueries"; import { useToggleStoryLikeMutation } from "@/hooks/mutations/useStoryMutations"; import { useToggleFollowMutation } from "@/hooks/mutations/useMemberMutations"; +import { BookStory } from "@/types/story"; export default function HomePage() { const router = useRouter(); const { isLoggedIn, isLoginModalOpen, openLoginModal, closeLoginModal } = useAuthStore(); - const { data: storiesData, isLoading: isLoadingStories } = useStoriesQuery(); + const { + data: storiesData, + isLoading: isLoadingStories, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isError: isErrorStories, + } = useInfiniteStoriesQuery(); const { data: membersData, isLoading: isLoadingMembers, isError: isErrorMembers } = useRecommendedMembersQuery(isLoggedIn); const { data: myClubsData, isLoading: isLoadingClubs } = useMyClubsQuery(isLoggedIn); const { mutate: toggleLike } = useToggleStoryLikeMutation(); @@ -45,14 +54,47 @@ export default function HomePage() { }; const groups = myClubsData?.clubList || []; - - const stories = storiesData?.basicInfoList || []; - // 멤버 데이터가 없으면 빈 배열 + const stories = storiesData?.pages.flatMap((page) => page.basicInfoList) || []; const recommendedUsers = membersData?.friends || []; - - // isLoading 멤버/클럽 변수는 로그인 되어있을 때만 실제 로딩 상태를 반영해야 함 const isLoading = isLoadingStories || (isLoggedIn && isLoadingMembers) || (isLoggedIn && isLoadingClubs); + const { ref, inView } = useInView({ threshold: 0 }); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Helper for rendering story cards + const renderStory = (story: BookStory, isLarge = true) => { + const CardComponent = isLarge ? BookStoryCardLarge : BookStoryCard; + return ( +
+ handleToggleFollow(story.authorInfo.nickname, story.authorInfo.following)} + hideSubscribeButton={story.writtenByMe} + {...(isLarge && { onProfileClick: () => router.push(`/profile/${story.authorInfo.nickname}`) })} + onClick={() => router.push(`/stories/${story.bookStoryId}`)} + onLikeClick={() => handleToggleLike(story.bookStoryId)} + /> +
+ ); + }; + if (isLoading) { return (
@@ -63,42 +105,33 @@ export default function HomePage() { return (
- {isLoginModalOpen && ( - closeLoginModal()} /> - )} + {isLoginModalOpen && closeLoginModal()} />} - {/* 모바일 */} + {/* 모바일 */}
- {/* 소식 */}

소식

- {/* 독서모임 + 사용자 추천 */}

독서모임

- {/* 사용자 추천 */}
-

- 사용자 추천 -

+

사용자 추천

- {isErrorMembers && ( + {isErrorMembers ? (

추천 목록을 불러오지 못했어요.

- )} - {!isErrorMembers && recommendedUsers.length === 0 && ( + ) : recommendedUsers.length === 0 ? (

사용자 추천이 없습니다.

- )} - {!isErrorMembers && recommendedUsers.length > 0 && + ) : ( recommendedUsers.slice(0, 3).map((u) => ( handleToggleFollow(u.nickname, u.isFollowing)} /> - ))} + )) + )}
- {/* 책 이야기 카드 */}
- {stories.slice(0, 3).map((story) => ( - handleToggleFollow(story.authorInfo.nickname, story.authorInfo.following)} - hideSubscribeButton={story.writtenByMe} - onClick={() => router.push(`/stories/${story.bookStoryId}`)} - onLikeClick={() => handleToggleLike(story.bookStoryId)} - /> - ))} + {isErrorStories ? ( +
책 이야기 리스트를 불러오지 못했어요.
+ ) : stories.length === 0 ? ( +
책 이야기 리스트가 없습니다.
+ ) : ( + stories.map((s) => renderStory(s, true)) + )}
{/* 태블릿 */}
- {/* 소식 */}
-

- 소식 -

+

소식

- {/* 독서모임 + 사용자 추천 */}
-

- 독서모임 -

+

독서모임

- {/* 책 이야기 카드 */}
- {stories.slice(0, 4).map((story) => ( - handleToggleFollow(story.authorInfo.nickname, story.authorInfo.following)} - hideSubscribeButton={story.writtenByMe} - onClick={() => router.push(`/stories/${story.bookStoryId}`)} - onLikeClick={() => handleToggleLike(story.bookStoryId)} - /> - ))} + {isErrorStories ? ( +
책 이야기 리스트를 불러오지 못했어요.
+ ) : stories.length === 0 ? ( +
책 이야기 리스트가 없습니다.
+ ) : ( + stories.map((s) => renderStory(s, false)) + )}
{/* 데스크톱 */} -
- {/* 독서모임 + 사용자 추천 */} -
-

- 독서모임 -

- -
- handleToggleFollow(nickname, isFollowing)} - /> -
-
- - {/* 소식 + 책 이야기 */} -
- {/* 소식 */} -
-

- 소식 -

- +
+
+
+

독서모임

+
- - {/* 책 이야기 카드 */} -
-
- {stories.slice(0, 3).map((story) => ( - handleToggleFollow(story.authorInfo.nickname, story.authorInfo.following)} - hideSubscribeButton={story.writtenByMe} - onClick={() => router.push(`/stories/${story.bookStoryId}`)} - onLikeClick={() => handleToggleLike(story.bookStoryId)} - /> - ))} -
+
+

소식

+
+ +
+
+ {isErrorStories ? ( +
책 이야기 리스트를 불러오지 못했어요.
+ ) : stories.length === 0 ? ( +
책 이야기 리스트가 없습니다.
+ ) : ( + <> + {stories.slice(0, 4).map((s) => renderStory(s, true))} + {recommendedUsers.length > 0 && ( + handleToggleFollow(nickname, isFollowing)} + /> + )} + {stories.slice(4).map((s) => renderStory(s, true))} + + )} +
+
- {/* 비로그인 시 플로팅 로그인 하기 버튼 */} + {!isErrorStories && hasNextPage && ( +
+ {isFetchingNextPage ? ( +
+ ) : ( +
+ )} +
+ )} + {!isLoggedIn && ( )} diff --git a/src/app/(main)/profile/[nickname]/follows/page.tsx b/src/app/(main)/profile/[nickname]/follows/page.tsx new file mode 100644 index 00000000..6bab065a --- /dev/null +++ b/src/app/(main)/profile/[nickname]/follows/page.tsx @@ -0,0 +1,134 @@ +"use client"; + +import React, { useState, useEffect, Suspense } from "react"; +import { useSearchParams, useRouter, useParams } from "next/navigation"; +import Image from "next/image"; +import ProfileBreadcrumb from "@/components/base-ui/Profile/OtherUser/ProfileBreadcrumb"; +import FollowList from "@/components/base-ui/Profile/FollowList"; +import { FollowUser } from "@/components/base-ui/Profile/FollowItem"; +import { useOtherProfileQuery, useFollowerListQuery, useFollowingListQuery } from "@/hooks/queries/useMemberQueries"; +import { useToggleFollowMutation } from "@/hooks/mutations/useMemberMutations"; + +function OtherUserFollowsContent() { + const router = useRouter(); + const params = useParams(); + const nickname = params?.nickname as string; + const decodedNickname = decodeURIComponent(nickname || ""); + const searchParams = useSearchParams(); + const initialTab = (searchParams?.get("tab") as "follower" | "following") || "follower"; + const [activeTab, setActiveTab] = useState<"follower" | "following">(initialTab); + + const { data: profileData, isLoading: isProfileLoading } = useOtherProfileQuery(decodedNickname); + + const { + data: followerData, + fetchNextPage: fetchNextFollower, + hasNextPage: hasNextFollower, + isFetchingNextPage: isFetchingNextFollower, + isLoading: isFollowerLoading + } = useFollowerListQuery(decodedNickname, activeTab === "follower"); + + const { + data: followingData, + fetchNextPage: fetchNextFollowing, + hasNextPage: hasNextFollowing, + isFetchingNextPage: isFetchingNextFollowing, + isLoading: isFollowingLoading + } = useFollowingListQuery(decodedNickname, activeTab === "following"); + + const { mutate: toggleFollow } = useToggleFollowMutation(); + + // URL Query Params 갱신 + useEffect(() => { + router.replace(`?tab=${activeTab}`, { scroll: false }); + }, [activeTab, router]); + + if (isProfileLoading) { + return ( +
+
+
+ ); + } + + if (!profileData) { + return ( +
+ 사용자를 찾을 수 없습니다. +
+ ); + } + + const currentData = activeTab === "follower" ? followerData : followingData; + + // API 응답 데이터를 FollowUser 타입으로 매핑 + const users: FollowUser[] = currentData?.pages.flatMap(page => page.followList).map(userDTO => ({ + id: userDTO.nickname, + nickname: userDTO.nickname, + profileImageUrl: userDTO.profileImageUrl, + isFollowing: userDTO.following, + })) || []; + + const handleToggleFollow = (id: string | number, currentIsFollowing: boolean) => { + toggleFollow({ nickname: String(id), isFollowing: currentIsFollowing }); + }; + + const hasMore = activeTab === "follower" ? hasNextFollower : hasNextFollowing; + const isFetching = activeTab === "follower" ? isFetchingNextFollower || isFollowerLoading : isFetchingNextFollowing || isFollowingLoading; + const handleLoadMore = () => { + if (activeTab === "follower") fetchNextFollower(); + else fetchNextFollowing(); + }; + + return ( +
+ + +
+ {/* Profile Image & Nickname Area */} +
+
+ {profileData.profileImageUrl ? ( + {profileData.nickname} + ) : ( +
+ )} +
+ + {profileData.nickname} + +
+ + {/* Follow Tabs and List */} + +
+
+ ); +} + +export default function OtherUserFollowsPage() { + return ( + +
+
+ }> + + + ); +} diff --git a/src/app/(main)/profile/[nickname]/page.tsx b/src/app/(main)/profile/[nickname]/page.tsx index 884d20f1..54241563 100644 --- a/src/app/(main)/profile/[nickname]/page.tsx +++ b/src/app/(main)/profile/[nickname]/page.tsx @@ -9,10 +9,11 @@ type PageProps = { export default async function OtherUserProfilePage({ params }: PageProps) { const { nickname } = await params; + const decodedNickname = decodeURIComponent(nickname); return (
- +
diff --git a/src/app/(main)/profile/mypage/follows/page.tsx b/src/app/(main)/profile/mypage/follows/page.tsx index 978cfaba..8ef80ea3 100644 --- a/src/app/(main)/profile/mypage/follows/page.tsx +++ b/src/app/(main)/profile/mypage/follows/page.tsx @@ -7,9 +7,8 @@ import MyPageBreadcrumb from "@/components/base-ui/MyPage/MyPageBreadcrumb"; import FollowList from "@/components/base-ui/Profile/FollowList"; import { FollowUser } from "@/components/base-ui/Profile/FollowItem"; import { useAuthGuard } from "@/hooks/useAuthGuard"; -import { useProfileQuery, useFollowerListQuery, useFollowingListQuery } from "@/hooks/queries/useMemberQueries"; +import { useProfileQuery, useFollowerListQuery, useFollowingListQuery, useFollowCountQuery } from "@/hooks/queries/useMemberQueries"; import { useToggleFollowMutation } from "@/hooks/mutations/useMemberMutations"; -import { DUMMY_USER_PROFILE } from "@/constants/mocks/mypage"; function FollowsContent() { const router = useRouter(); @@ -19,6 +18,7 @@ function FollowsContent() { const { isInitialized, isLoggedIn } = useAuthGuard(); const { data: profileData } = useProfileQuery(); + const { data: followCountData } = useFollowCountQuery(); const { data: followerData, @@ -26,7 +26,7 @@ function FollowsContent() { hasNextPage: hasNextFollower, isFetchingNextPage: isFetchingNextFollower, isLoading: isFollowerLoading - } = useFollowerListQuery(activeTab === "follower"); + } = useFollowerListQuery(undefined, activeTab === "follower"); const { data: followingData, @@ -34,13 +34,13 @@ function FollowsContent() { hasNextPage: hasNextFollowing, isFetchingNextPage: isFetchingNextFollowing, isLoading: isFollowingLoading - } = useFollowingListQuery(activeTab === "following"); + } = useFollowingListQuery(undefined, activeTab === "following"); // URL Query Params 갱신 useEffect(() => { router.replace(`?tab=${activeTab}`, { scroll: false }); }, [activeTab, router]); - + const { mutate: toggleFollow } = useToggleFollowMutation(); if (!isInitialized || !isLoggedIn) { return null; } @@ -48,9 +48,8 @@ function FollowsContent() { const user = { name: profileData?.nickname || "알 수 없음", profileImage: profileData?.profileImageUrl || null, - // TODO: API에서 구독자/구독중 수 제공 시 실제 데이터로 교체해야 함 - subscribers: DUMMY_USER_PROFILE.subscribers, - following: DUMMY_USER_PROFILE.following, + subscribers: followCountData?.followerCount ?? 0, + following: followCountData?.followingCount ?? 0, }; const currentData = activeTab === "follower" ? followerData : followingData; @@ -63,8 +62,6 @@ function FollowsContent() { isFollowing: userDTO.following, })) || []; - const { mutate: toggleFollow } = useToggleFollowMutation(); - const handleToggleFollow = (id: string | number, currentIsFollowing: boolean) => { toggleFollow({ nickname: String(id), isFollowing: currentIsFollowing }); }; diff --git a/src/app/(main)/setting/profile/page.tsx b/src/app/(main)/setting/profile/page.tsx index f8ff338d..cc3e2005 100644 --- a/src/app/(main)/setting/profile/page.tsx +++ b/src/app/(main)/setting/profile/page.tsx @@ -6,7 +6,6 @@ import ProfileImageSection from "@/components/base-ui/Settings/EditProfile/Profi import SettingsDetailLayout from "@/components/base-ui/Settings/SettingsDetailLayout"; import { useState, useEffect } from "react"; import toast from "react-hot-toast"; -import { authService } from "@/services/authService"; // API call import { useUpdateProfileMutation } from "@/hooks/mutations/useMemberMutations"; export default function ProfileEditPage() { @@ -25,8 +24,6 @@ export default function ProfileEditPage() { user?.profileImageUrl || null ); - // Nickname Duplicate Check State - const [isNicknameChecked, setIsNicknameChecked] = useState(true); // default true for existing user const { mutate: updateProfile, isPending: isUpdating } = useUpdateProfileMutation(); @@ -38,8 +35,7 @@ export default function ProfileEditPage() { setName(user.nickname || ""); setSelectedCategories(user.categories || []); setPreviewImage(user.profileImageUrl || null); - // Reset check state if it matches original - setIsNicknameChecked(true); + setPreviewImage(user.profileImageUrl || null); } }, [user]); @@ -70,54 +66,7 @@ export default function ProfileEditPage() { setPreviewImage(null); // or set to default image URL if needed }; - const handleNicknameChange = (e: React.ChangeEvent) => { - const value = e.target.value; - // 회원가입과 동일한 닉네임 필터 로직: 영어 소문자 및 특수문자, 숫자만 사용 가능, 최대 20글자 - const filteredValue = value.replace(/[^a-z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/g, "").slice(0, 20); - setNickname(filteredValue); - - // 만약 원래 닉네임과 같다면 중복 확인 된 것으로 간주 - if (user && filteredValue === user.nickname) { - setIsNicknameChecked(true); - } else { - setIsNicknameChecked(false); - } - }; - - const handleCheckNickname = async () => { - if (!nickname) { - toast.error("닉네임을 입력해주세요!"); - return; - } - - // 본인 닉네임과 동일하면 체크할 필요 없음 - if (user && nickname === user.nickname) { - setIsNicknameChecked(true); - toast.success("기존 닉네임과 동일하므로 사용 가능합니다."); - return; - } - - try { - const response = await authService.checkNickname(nickname); - // Backend Spec: result: false (not duplicated/available), result: true (duplicated/taken) - if (response.isSuccess && response.result === false) { - setIsNicknameChecked(true); - toast.success("사용 가능한 닉네임입니다."); - } else { - toast.error("이미 사용 중인 닉네임입니다."); - setIsNicknameChecked(false); - } - } catch (error: any) { - toast.error(error.message || "닉네임 확인 중 오류가 발생했습니다."); - } - }; - const handleSave = () => { - if (!isNicknameChecked) { - toast.error("닉네임 중복확인을 해주세요!"); - return; - } - updateProfile({ description: intro.slice(0, 20), categories: selectedCategories, @@ -157,23 +106,16 @@ export default function ProfileEditPage() { {/* 닉네임 */}
-
-
+
+
- + 닉네임 수정은 따로 문의해주세요!
diff --git a/src/app/(main)/stories/new/page.tsx b/src/app/(main)/stories/new/page.tsx index 0545255b..59a221ad 100644 --- a/src/app/(main)/stories/new/page.tsx +++ b/src/app/(main)/stories/new/page.tsx @@ -53,14 +53,7 @@ function StoryNewContent() { } createStoryMutation.mutate({ - bookInfo: { - isbn: selectedBook.isbn, - title: selectedBook.title, - author: selectedBook.author, - imgUrl: selectedBook.imgUrl, - publisher: selectedBook.publisher, - description: selectedBook.description, - }, + isbn: selectedBook.isbn, title, description: detail, }, { diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx index d4c17c5c..d335e1a3 100644 --- a/src/app/(main)/stories/page.tsx +++ b/src/app/(main)/stories/page.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; import FloatingFab from "@/components/base-ui/Float"; import LoginModal from "@/components/base-ui/Login/LoginModal"; import { useAuthStore } from "@/store/useAuthStore"; -import { useInfiniteStoriesQuery, useFollowingInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries"; +import { useInfiniteStoriesQuery, useFollowingInfiniteStoriesQuery, useClubInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries"; import { useRecommendedMembersQuery } from "@/hooks/queries/useMemberQueries"; import { useMyClubsQuery } from "@/hooks/queries/useClubQueries"; import { useInView } from "react-intersection-observer"; @@ -56,6 +56,17 @@ export default function StoriesPage() { } = useFollowingInfiniteStoriesQuery(isLoggedIn); const isFollowingTab = selectedCategory === "following"; + const isClubTab = selectedCategory !== "all" && selectedCategory !== "following"; + const activeClubId = isClubTab ? Number(selectedCategory) : null; + + const { + data: clubStoriesData, + isLoading: isLoadingClubStories, + isError: isErrorClubStories, + fetchNextPage: fetchNextClubPage, + hasNextPage: hasNextClubPage, + isFetchingNextPage: isFetchingNextClubPage, + } = useClubInfiniteStoriesQuery(activeClubId, isLoggedIn); const { storiesData, @@ -75,6 +86,16 @@ export default function StoriesPage() { isFetchingNextPage: isFetchingNextFollowingPage, }; } + if (isClubTab) { + return { + storiesData: clubStoriesData, + isLoadingStories: isLoadingClubStories, + isErrorStories: isErrorClubStories, + fetchNextPage: fetchNextClubPage, + hasNextPage: hasNextClubPage, + isFetchingNextPage: isFetchingNextClubPage, + }; + } return { storiesData: defaultStoriesData, isLoadingStories: isLoadingDefaultStories, @@ -85,12 +106,19 @@ export default function StoriesPage() { }; }, [ isFollowingTab, + isClubTab, followingStoriesData, isLoadingFollowingStories, isErrorFollowingStories, fetchNextFollowingPage, hasNextFollowingPage, isFetchingNextFollowingPage, + clubStoriesData, + isLoadingClubStories, + isErrorClubStories, + fetchNextClubPage, + hasNextClubPage, + isFetchingNextClubPage, defaultStoriesData, isLoadingDefaultStories, isErrorDefaultStories, diff --git a/src/components/base-ui/BookStory/bookstory_card.tsx b/src/components/base-ui/BookStory/bookstory_card.tsx index 405ae499..369b9018 100644 --- a/src/components/base-ui/BookStory/bookstory_card.tsx +++ b/src/components/base-ui/BookStory/bookstory_card.tsx @@ -56,30 +56,33 @@ export default function BookStoryCard({ md:w-[336px] md:h-[380px]" > {/* 1. 상단 프로필 (모바일 숨김 / 데스크탑 노출) */} -
{ - if (onProfileClick) { - e.stopPropagation(); - onProfileClick(); - } - }} - > -
- {authorName} -
-
-

{authorName}

-

- {formatTimeAgo(createdAt)} 조회수 {viewCount} -

+
+
{ + if (onProfileClick) { + e.stopPropagation(); + onProfileClick(); + } + }} + > +
+ {authorName} +
+
+

{authorName}

+

+ {formatTimeAgo(createdAt)} 조회수 {viewCount} +

+
+
{!hideSubscribeButton && (
-
+
share 공유하기
@@ -281,7 +292,10 @@ export default function BookstoryDetail({ 좋아요 {likeCount}
-
+
share +
+ + handleReplyContentChange(comment.id, e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + handleReplySubmit(comment.parentCommentId || comment.id); + } + }} + placeholder="답글 내용을 입력해주세요" + className="flex-1 min-w-0 h-[36px] t:h-[56px] px-4 py-3 rounded-lg border border-Subbrown-4 bg-White Body_1_2 text-Gray-7 placeholder:text-Gray-3 outline-none" + autoFocus + /> + +
)} diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx index 99c3971c..702fe7c8 100644 --- a/src/components/base-ui/Comment/comment_section.tsx +++ b/src/components/base-ui/Comment/comment_section.tsx @@ -37,7 +37,8 @@ export default function CommentSection({ // API 데이터를 UI용 Comment 형식으로 변환 및 계층 구조화 const mapApiToUiComments = (apiComments: CommentInfo[]): Comment[] => { - const flatComments: Comment[] = apiComments.map((c) => ({ + // 1. 재귀적으로 맵핑 (백엔드에서 이미 nested 된 replies 배열을 줄 경우를 대비) + const mapNode = (c: CommentInfo): Comment & { parentCommentId?: number | null } => ({ id: c.commentId, authorName: c.authorInfo.nickname, profileImgSrc: isValidUrl(c.authorInfo.profileImageUrl) @@ -47,30 +48,49 @@ export default function CommentSection({ createdAt: c.createdAt, isAuthor: c.authorInfo.nickname === storyAuthorNickname, isMine: c.writtenByMe, - replies: [], - })); + replies: c.replies ? c.replies.map(r => mapNode(r)) : [], + parentCommentId: c.parentCommentId, + }); + + const mapped = apiComments.map(mapNode); const rootComments: Comment[] = []; - const commentMap = new Map(); + const commentMap = new Map(); - flatComments.forEach(c => commentMap.set(c.id, c)); + // 맵에 모든 요소 저장 + const addToMap = (comments: (Comment & { parentCommentId?: number | null })[]) => { + comments.forEach(c => { + commentMap.set(c.id, c); + if (c.replies && c.replies.length > 0) addToMap(c.replies); + }); + }; + addToMap(mapped); - apiComments.forEach((c, index) => { - const uiComment = flatComments[index]; + // flat list 로 넘어왔을 경우 수동으로 부모-자식 연결 + mapped.forEach((c) => { if (c.parentCommentId && commentMap.has(c.parentCommentId)) { - commentMap.get(c.parentCommentId)!.replies!.push(uiComment); + const parent = commentMap.get(c.parentCommentId)!; + if (!parent.replies!.find(r => r.id === c.id)) { + parent.replies!.push(c); + } } else { - rootComments.push(uiComment); + rootComments.push(c); } }); // 최상위 댓글 최신순(내림차순) 정렬 rootComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - // 대댓글은 등록순(오름차순) 유지 (일반적인 UI 패턴) - rootComments.forEach(c => { - c.replies?.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - }); + // 대댓글은 등록순(오름차순) 유지 + const sortReplies = (comments: Comment[]) => { + comments.forEach(c => { + if (c.replies && c.replies.length > 0) { + c.replies.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + sortReplies(c.replies); + } + }); + }; + sortReplies(rootComments); return rootComments; }; diff --git a/src/components/base-ui/Float.tsx b/src/components/base-ui/Float.tsx index c807fced..1aac9243 100644 --- a/src/components/base-ui/Float.tsx +++ b/src/components/base-ui/Float.tsx @@ -3,6 +3,8 @@ import Image from "next/image"; import React from "react"; +import { useSearchStore } from "@/store/useSearchStore"; + type FloatingFabProps = { iconSrc?: string; // 예: "/icons/pencil_white.svg" iconAlt?: string; // 접근성/aria용 @@ -20,6 +22,10 @@ export default function FloatingFab({ iconClassName = "", type = "button", }: FloatingFabProps) { + const { isSearchOpen } = useSearchStore(); + + if (isSearchOpen) return null; + return ( +
{errors?.password && ( {errors.password} )} @@ -122,9 +172,8 @@ export default function LoginModal({ + +
+ + + {menuOpen && ( +
+ +
+ )} +
+ + setIsConfirmModalOpen(false)} + />
); }; export default MyMeetingCard; + diff --git a/src/components/base-ui/News/news_list.tsx b/src/components/base-ui/News/news_list.tsx index 7adf1c15..f6e56dd7 100644 --- a/src/components/base-ui/News/news_list.tsx +++ b/src/components/base-ui/News/news_list.tsx @@ -82,12 +82,12 @@ export default function NewsList({ {/* 모바일: absolute right-5 bottom-2.5 */} {/* 태블릿이상: static block */}
diff --git a/src/components/base-ui/News/today_recommended_books.tsx b/src/components/base-ui/News/today_recommended_books.tsx index bb4e8769..5af62d8d 100644 --- a/src/components/base-ui/News/today_recommended_books.tsx +++ b/src/components/base-ui/News/today_recommended_books.tsx @@ -1,13 +1,17 @@ "use client"; import { useState } from "react"; +import { useRouter } from "next/navigation"; import BookCoverCard from "@/components/base-ui/Book/BookCoverCard"; +import { useAuthStore } from "@/store/useAuthStore"; +import { useToggleBookLikeMutation } from "@/hooks/mutations/useBookMutations"; type Book = { id: string | number; imgUrl: string; title: string; author: string; + likedByMe?: boolean; }; type TodayRecommendedBooksProps = { @@ -19,13 +23,16 @@ export default function TodayRecommendedBooks({ books, className = "", }: TodayRecommendedBooksProps) { - const [likedBooks, setLikedBooks] = useState>({}); + const router = useRouter(); + const { isLoggedIn, openLoginModal } = useAuthStore(); + const { mutate: toggleLike } = useToggleBookLikeMutation(); - const handleLikeChange = (bookId: string | number, liked: boolean) => { - setLikedBooks((prev) => ({ - ...prev, - [bookId]: liked, - })); + const handleLikeChange = (bookId: string | number) => { + if (!isLoggedIn) { + openLoginModal(); + return; + } + toggleLike(String(bookId)); }; // 모바일: 2개, 태블릿: 4개, 데스크탑: 전체 @@ -45,8 +52,8 @@ export default function TodayRecommendedBooks({ imgUrl={book.imgUrl} title={book.title} author={book.author} - liked={likedBooks[book.id] || false} - onLikeChange={(liked) => handleLikeChange(book.id, liked)} + liked={book.likedByMe || false} + onLikeChange={() => handleLikeChange(book.id)} responsive /> ))} @@ -60,8 +67,8 @@ export default function TodayRecommendedBooks({ imgUrl={book.imgUrl} title={book.title} author={book.author} - liked={likedBooks[book.id] || false} - onLikeChange={(liked) => handleLikeChange(book.id, liked)} + liked={book.likedByMe || false} + onLikeChange={() => handleLikeChange(book.id)} responsive /> ))} @@ -75,8 +82,9 @@ export default function TodayRecommendedBooks({ imgUrl={book.imgUrl} title={book.title} author={book.author} - liked={likedBooks[book.id] || false} - onLikeChange={(liked) => handleLikeChange(book.id, liked)} + liked={book.likedByMe || false} + onLikeChange={() => handleLikeChange(book.id)} + onCardClick={() => router.push(`/books/${book.id}`)} /> ))}
diff --git a/src/components/base-ui/Profile/LibraryList.tsx b/src/components/base-ui/Profile/LibraryList.tsx index 5e78a398..fb1e1dcf 100644 --- a/src/components/base-ui/Profile/LibraryList.tsx +++ b/src/components/base-ui/Profile/LibraryList.tsx @@ -1,27 +1,50 @@ "use client"; -import React, { useState } from "react"; -import { DUMMY_LIBRARY_BOOKS } from "@/constants/mocks/mypage"; +import React, { useEffect, useMemo } from "react"; import LibraryCard from "./items/LibraryCard"; +import { useLikedBooksInfiniteQuery } from "@/hooks/queries/useBookQueries"; +import { useInView } from "react-intersection-observer"; -const LibraryList = () => { - const [likedBooks, setLikedBooks] = useState([]); +const LibraryList = ({ nickname }: { nickname?: string }) => { + const isMyLibrary = !nickname; + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useLikedBooksInfiniteQuery(nickname); + + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const books = useMemo(() => { + return data?.pages.flatMap((page) => page.books) || []; + }, [data]); + + if (isLoading) { + return
{isMyLibrary ? "내 서재를 불러오는 중..." : "서재를 불러오는 중..."}
; + } + + if (books.length === 0) { + return
{isMyLibrary ? "내 서재가 비어있습니다." : "서재가 비어있습니다."}
; + } - const toggleLike = (id: number) => { - setLikedBooks((prev) => - prev.includes(id) ? prev.filter((bookId) => bookId !== id) : [...prev, id] - ); - }; return ( -
- {DUMMY_LIBRARY_BOOKS.map((book) => ( - - ))} +
+
+ {books.map((book) => ( + + ))} +
+
+ {isFetchingNextPage &&
} +
); }; diff --git a/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx b/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx index d18db73d..3d17dc70 100644 --- a/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx +++ b/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx @@ -13,7 +13,7 @@ export default function OtherUserProfileTabs({ nickname }: { nickname: string }) const TAB_CONTENT: Record = { "책 이야기": , - 서재: , + 서재: , 모임: , }; diff --git a/src/components/base-ui/Profile/OtherUser/ProfileBreadcrumb.tsx b/src/components/base-ui/Profile/OtherUser/ProfileBreadcrumb.tsx index 7a09ccf3..7c007f22 100644 --- a/src/components/base-ui/Profile/OtherUser/ProfileBreadcrumb.tsx +++ b/src/components/base-ui/Profile/OtherUser/ProfileBreadcrumb.tsx @@ -1,7 +1,7 @@ import React from "react"; import Image from "next/image"; -const MyPageBreadcrumb = () => { +const ProfileBreadcrumb = ({ nickname }: { nickname: string }) => { return (
@@ -16,11 +16,11 @@ const MyPageBreadcrumb = () => { className="shrink-0" /> - 다른 사람 페이지 + {nickname}님의 페이지
); }; -export default MyPageBreadcrumb; +export default ProfileBreadcrumb; diff --git a/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx b/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx index 00ee7cc6..35f60cae 100644 --- a/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx +++ b/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx @@ -42,15 +42,17 @@ function ActionButton({ ); } +import Link from "next/link"; + // [보조 컴포넌트] 통계 아이템 (구독 중 / 구독자) -function StatItem({ label, count }: { label: string; count: number }) { +function StatItem({ label, count, href }: { label: string; count: number, href: string }) { return ( -
+ {/* Label: Gray-4 */} {label} {/* Count: primary-1 */} {count} -
+ ); } @@ -138,8 +140,16 @@ export default function ProfileUserInfo({ nickname }: { nickname: string }) { {/* 통계 그룹 */}
- - + +
diff --git a/src/components/base-ui/Profile/items/LibraryCard.tsx b/src/components/base-ui/Profile/items/LibraryCard.tsx index e7890824..199d3aaf 100644 --- a/src/components/base-ui/Profile/items/LibraryCard.tsx +++ b/src/components/base-ui/Profile/items/LibraryCard.tsx @@ -1,21 +1,22 @@ "use client"; import Image from "next/image"; -import { MyPageLibraryBook } from "@/types/mypage"; +import { Book } from "@/types/book"; +import { useToggleBookLikeMutation } from "@/hooks/mutations/useBookMutations"; interface LibraryCardProps { - book: MyPageLibraryBook; - isLiked: boolean; - onToggleLike: (id: number) => void; + book: Book; } -const LibraryCard = ({ book, isLiked, onToggleLike }: LibraryCardProps) => { +const LibraryCard = ({ book }: LibraryCardProps) => { + const { mutate: toggleLike } = useToggleBookLikeMutation(); + return (
{book.title} { type="button" onClick={(e) => { e.stopPropagation(); - onToggleLike(book.id); + toggleLike(book.isbn); }} className="relative z-10 w-[20px] h-[20px] md:w-[24px] md:h-[24px]" > like page.basicInfoList) || []; + + if (isLoading) { + return ( +
+
소식을 불러오는 중...
+
+ ); + } + + if (isError || newsList.length === 0) { + return ( +
+
새로운 소식이 아직 없어요!
+
준비 중인 소식을 기다려 주세요.
+
+ ); + } + + const currentNews = newsList[index % newsList.length]; + + const isValidSrc = (src: string) => { + return src && src !== "string" && (src.startsWith("/") || src.startsWith("http://") || src.startsWith("https://")); + }; + + const imageSrc = isValidSrc(currentNews.thumbnailUrl) ? currentNews.thumbnailUrl : "/news_sample.svg"; + return ( -
- 소식 배너 +
+
router.push(`/news/${currentNews.newsId}`)} + > + {currentNews.title} + + {/* Text Overlay */} +
+
+

+ {currentNews.title} +

+

+ {currentNews.description} +

+
+
+
+
- {banners.map((_, i) => { + {newsList.slice(0, 5).map((_, i) => { const active = i === index; return ( diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 4289f908..4d3ae8c7 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -10,6 +10,8 @@ import LoginModal from "../base-ui/Login/LoginModal"; import { useHeaderTitle } from "@/contexts/HeaderTitleContext"; import { useAuthStore } from "@/store/useAuthStore"; +import { useSearchStore } from "@/store/useSearchStore"; + const NAV = [ { label: "책모 홈", href: "/" }, { label: "모임", href: "/groups" }, @@ -33,7 +35,7 @@ export default function Header() { const { customTitle } = useHeaderTitle(); const { user, isLoggedIn, isLoginModalOpen, openLoginModal, closeLoginModal } = useAuthStore(); const pageTitle = customTitle || defaultTitle; - const [isSearchOpen, setIsSearchOpen] = useState(false); + const { isSearchOpen, toggleSearch, closeSearch } = useSearchStore(); const handleNavClick = (href: string, label: string) => { if (label === "모임" && !isLoggedIn) { @@ -90,7 +92,7 @@ export default function Header() { {/*아이콘*/}
setIsSearchOpen(false)} + onClose={closeSearch} /> {isLoginModalOpen && ( closeLoginModal()} /> diff --git a/src/components/layout/SearchModal.tsx b/src/components/layout/SearchModal.tsx index 6003233c..1ce888dc 100644 --- a/src/components/layout/SearchModal.tsx +++ b/src/components/layout/SearchModal.tsx @@ -6,9 +6,12 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import BookCoverCard from "@/components/base-ui/Book/BookCoverCard"; import { useInfiniteBookSearchQuery, useRecommendedBooksQuery } from "@/hooks/queries/useBookQueries"; +import { useToggleBookLikeMutation } from "@/hooks/mutations/useBookMutations"; +import { useAuthStore } from "@/store/useAuthStore"; import { useDebounce } from "@/hooks/useDebounce"; import { useInView } from "react-intersection-observer"; import { Book } from "@/types/book"; +import { EXTERNAL_LINKS } from "@/constants/links"; type SearchModalProps = { isOpen: boolean; @@ -18,9 +21,18 @@ type SearchModalProps = { export default function SearchModal({ isOpen, onClose }: SearchModalProps) { const router = useRouter(); const [topOffset, setTopOffset] = useState(0); - const [likedBooks, setLikedBooks] = useState>({}); const [searchValue, setSearchValue] = useState(""); const debouncedSearchValue = useDebounce(searchValue, 300); + const { isLoggedIn, openLoginModal } = useAuthStore(); + const { mutate: toggleLike } = useToggleBookLikeMutation(); + + const handleLikeChange = (isbn: string) => { + if (!isLoggedIn) { + openLoginModal(); + return; + } + toggleLike(isbn); + }; const { data: searchData, @@ -216,10 +228,8 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { imgUrl={book.imgUrl} title={book.title} author={book.author} - liked={likedBooks[book.isbn] || false} - onLikeChange={(liked) => - setLikedBooks((prev) => ({ ...prev, [book.isbn]: liked })) - } + liked={book.likedByMe || false} + onLikeChange={() => handleLikeChange(book.isbn)} onCardClick={() => { router.push(`/books/${book.isbn}`); onClose(); @@ -234,11 +244,15 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
- 알라딘 랭킹 더 보러가기 -
+ + 알라딘 랭킹 더 보러가기 + +
알라딘 = {}; + +const updateLikeInBookList = (old: BookSearchResponse | undefined, isbn: string) => { + if (!old || !old.detailInfoList) return old; + return { + ...old, + detailInfoList: old.detailInfoList.map((book) => { + if (book.isbn === isbn) { + return { + ...book, + likedByMe: !book.likedByMe, + }; + } + return book; + }), + }; +}; + +const updateLikeInInfiniteSearch = (old: InfiniteData | undefined, isbn: string) => { + if (!old || !old.pages) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + detailInfoList: page.detailInfoList.map((book) => { + if (book.isbn === isbn) { + return { + ...book, + likedByMe: !book.likedByMe, + }; + } + return book; + }), + })), + }; +}; + +export const useToggleBookLikeMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (isbn: string) => { + const now = Date.now(); + const lastTime = likeThrottleMap[isbn] || 0; + + // Throttle: 500ms + if (now - lastTime < 500) { + return Promise.reject(new Error("Throttled request")); + } + likeThrottleMap[isbn] = now; + + return bookService.toggleLike(isbn); + }, + onMutate: async (isbn: string) => { + await queryClient.cancelQueries({ queryKey: bookKeys.all }); + + const previousRecommended = queryClient.getQueryData(bookKeys.recommend()); + const previousDetail = queryClient.getQueryData(bookKeys.detail(isbn)); + + // Grab all infiniteSearch queries + const previousSearches = queryClient.getQueriesData>({ + queryKey: [...bookKeys.all, "infiniteSearch"], + }); + + const previousLikedBooks = queryClient.getQueriesData>({ + queryKey: [...bookKeys.all, "likedBooks"], + }); + + if (previousRecommended) { + queryClient.setQueryData(bookKeys.recommend(), (old) => + updateLikeInBookList(old, isbn) + ); + } + + if (previousDetail) { + queryClient.setQueryData(bookKeys.detail(isbn), (old) => { + if (!old) return old; + return { + ...old, + likedByMe: !old.likedByMe, + }; + }); + } + + previousSearches.forEach(([queryKey, oldData]) => { + if (oldData) { + queryClient.setQueryData>(queryKey, (old) => + updateLikeInInfiniteSearch(old, isbn) + ); + } + }); + + // 위 로직을 좀 더 정교하게 수정: 내 서재에서만 필터링 되도록. + const handleLikedBooksUpdate = (queryKey: readonly unknown[], oldData: InfiniteData | undefined) => { + if (!oldData) return; + // likedBooks 키의 마지막 요소가 "me" 인 경우 "내 서재" 로 판단 + const isMyLibrary = queryKey.includes("me"); + + // Find the full book object from other caches to inject if it's a new like + let fullBookToInject: Book | null = null; + if (previousDetail && previousDetail.isbn === isbn) { + fullBookToInject = previousDetail; + } else if (previousRecommended) { + const found = previousRecommended.detailInfoList?.find(b => b.isbn === isbn); + if (found) fullBookToInject = found; + } + if (!fullBookToInject && previousSearches.length > 0) { + for (const [, searchData] of previousSearches) { + if (searchData && searchData.pages) { + for (const page of searchData.pages) { + const found = page.detailInfoList?.find(b => b.isbn === isbn); + if (found) { + fullBookToInject = found; + break; + } + } + } + if (fullBookToInject) break; + } + } + + queryClient.setQueryData>(queryKey, (old) => { + if (!old || !old.pages) return old; + + // If it's my library and it's a "like" action (wasn't liked before), we need to add it + let isAlreadyInLibrary = false; + for (const page of old.pages) { + if (page.books.some(b => b.isbn === isbn)) { + isAlreadyInLibrary = true; + break; + } + } + + return { + ...old, + pages: old.pages.map((page, index) => { + let updatedBooks = page.books + .map((book) => + book.isbn === isbn ? { ...book, likedByMe: !book.likedByMe } : book + ) + .filter((book) => { + if (isMyLibrary) { + // 내 서재면 좋아요 취소 시 제거 + return book.isbn !== isbn || book.likedByMe; + } + return true; // 타인의 서재면 제거하지 않음 + }); + + // Inject the new book into the very first page if it's not already in the library, + // we are actually liking it, and we are looking at My Library + if (index === 0 && isMyLibrary && !isAlreadyInLibrary && fullBookToInject) { + const isLikedAfterMutation = fullBookToInject.likedByMe; // This is the state *after* the mutation execution context (already toggled by previous logic) + if (isLikedAfterMutation) { + // Make sure to add it as liked + updatedBooks = [{ ...fullBookToInject, likedByMe: true }, ...updatedBooks]; + } + } + + return { + ...page, + books: updatedBooks + }; + }), + }; + }); + }; + + previousLikedBooks.forEach(([queryKey, oldData]) => handleLikedBooksUpdate(queryKey, oldData)); + + return { previousRecommended, previousDetail, previousSearches, previousLikedBooks }; + }, + onError: (err, isbn, context) => { + if (err.message === "Throttled request") return; + + console.error("Failed to toggle book like:", err); + toast.error("좋아요 상태 업데이트에 실패했습니다."); + + if (context?.previousRecommended) { + queryClient.setQueryData(bookKeys.recommend(), context.previousRecommended); + } + if (context?.previousDetail) { + queryClient.setQueryData(bookKeys.detail(isbn), context.previousDetail); + } + if (context?.previousSearches) { + context.previousSearches.forEach(([queryKey, oldData]) => { + queryClient.setQueryData(queryKey, oldData); + }); + } + if (context?.previousLikedBooks) { + context.previousLikedBooks.forEach(([queryKey, oldData]) => { + queryClient.setQueryData(queryKey, oldData); + }); + } + }, + onSuccess: (data, isbn) => { + // Update simple queries with the actual response from server + const { liked } = data; + + queryClient.setQueryData(bookKeys.recommend(), (old) => { + if (!old || !old.detailInfoList) return old; + return { + ...old, + detailInfoList: old.detailInfoList.map(book => + book.isbn === isbn ? { ...book, likedByMe: liked } : book + ) + }; + }); + + queryClient.setQueryData(bookKeys.detail(isbn), (old) => { + if (!old) return old; + return { ...old, likedByMe: liked }; + }); + + // Invalidate infinite queries to ensure data consistency + queryClient.invalidateQueries({ queryKey: [...bookKeys.all, "infiniteSearch"] }); + queryClient.invalidateQueries({ queryKey: [...bookKeys.all, "likedBooks"] }); + }, + }); +}; diff --git a/src/hooks/mutations/useClubMutations.ts b/src/hooks/mutations/useClubMutations.ts new file mode 100644 index 00000000..e4615e4e --- /dev/null +++ b/src/hooks/mutations/useClubMutations.ts @@ -0,0 +1,22 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { clubService } from "@/services/clubService"; +import { clubKeys } from "@/hooks/queries/useClubQueries"; +import toast from "react-hot-toast"; + +export function useLeaveClubMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (clubId: number) => clubService.leaveClub(clubId), + onSuccess: () => { + toast.success("모임에서 탈퇴했습니다."); + queryClient.invalidateQueries({ queryKey: clubKeys.myList() }); + }, + onError: (error: Error) => { + const message = error?.message || "탈퇴 처리 중 오류가 발생했습니다."; + toast.error(message); + }, + }); +} diff --git a/src/hooks/mutations/useMemberMutations.ts b/src/hooks/mutations/useMemberMutations.ts index 6fedd2b6..9690322e 100644 --- a/src/hooks/mutations/useMemberMutations.ts +++ b/src/hooks/mutations/useMemberMutations.ts @@ -62,7 +62,7 @@ import { UpdatePasswordRequest, RecommendResponse, ReportMemberRequest, FollowLi import { BookStoryListResponse } from "@/types/story"; import { storyKeys } from "@/hooks/queries/useStoryQueries"; import { memberKeys } from "@/hooks/queries/useMemberQueries"; -import { OtherProfileResponse } from "@/types/member"; +import { OtherProfileResponse, FollowCountResponse } from "@/types/member"; import { toast } from "react-hot-toast"; import { InfiniteData } from "@tanstack/react-query"; @@ -162,6 +162,7 @@ export const useToggleFollowMutation = () => { await queryClient.cancelQueries({ queryKey: memberKeys.otherProfile(nickname) }); await queryClient.cancelQueries({ queryKey: memberKeys.followers() }); await queryClient.cancelQueries({ queryKey: memberKeys.followings() }); + await queryClient.cancelQueries({ queryKey: memberKeys.followCount() }); // Snapshot previous values const previousRecommendations = queryClient.getQueryData(memberKeys.recommended()); @@ -170,6 +171,7 @@ export const useToggleFollowMutation = () => { const previousOtherProfile = queryClient.getQueryData(memberKeys.otherProfile(nickname)); const previousFollowers = queryClient.getQueryData(memberKeys.followers()); const previousFollowings = queryClient.getQueryData(memberKeys.followings()); + const previousFollowCount = queryClient.getQueryData(memberKeys.followCount()); // 1. Optimistically update recommendations if (previousRecommendations) { @@ -196,7 +198,12 @@ export const useToggleFollowMutation = () => { if (previousOtherProfile) { queryClient.setQueryData(memberKeys.otherProfile(nickname), (old) => { if (!old) return old; - return { ...old, following: !isFollowing }; + const newFollowing = !isFollowing; + return { + ...old, + following: newFollowing, + followerCount: newFollowing ? old.followerCount + 1 : Math.max(0, old.followerCount - 1) + }; }); } @@ -212,7 +219,18 @@ export const useToggleFollowMutation = () => { ); } - return { previousRecommendations, previousInfiniteStories, previousStories, previousOtherProfile, previousFollowers, previousFollowings }; + // 6. Optimistically update follow count (only "following" count since it's "me" following someone else) + if (previousFollowCount) { + queryClient.setQueryData(memberKeys.followCount(), (old) => { + if (!old) return old; + return { + ...old, + followingCount: isFollowing ? Math.max(0, old.followingCount - 1) : old.followingCount + 1 + }; + }); + } + + return { previousRecommendations, previousInfiniteStories, previousStories, previousOtherProfile, previousFollowers, previousFollowings, previousFollowCount }; }, onError: (error: any, variables, context) => { console.error("Failed to toggle follow:", error); @@ -237,6 +255,9 @@ export const useToggleFollowMutation = () => { if (context?.previousFollowings) { queryClient.setQueryData(memberKeys.followings(), context.previousFollowings); } + if (context?.previousFollowCount) { + queryClient.setQueryData(memberKeys.followCount(), context.previousFollowCount); + } }, onSettled: (_data, _error, variables) => { // Refetch to sync with server @@ -245,6 +266,7 @@ export const useToggleFollowMutation = () => { queryClient.invalidateQueries({ queryKey: memberKeys.otherProfile(variables.nickname) }); queryClient.invalidateQueries({ queryKey: memberKeys.followers() }); queryClient.invalidateQueries({ queryKey: memberKeys.followings() }); + queryClient.invalidateQueries({ queryKey: memberKeys.followCount() }); }, }); }; diff --git a/src/hooks/mutations/useStoryMutations.ts b/src/hooks/mutations/useStoryMutations.ts index 8e4e9d5e..2bb4abd4 100644 --- a/src/hooks/mutations/useStoryMutations.ts +++ b/src/hooks/mutations/useStoryMutations.ts @@ -172,7 +172,7 @@ export const useToggleStoryLikeMutation = () => { const previousInfiniteStories = queryClient.getQueryData(storyKeys.infiniteList()); const previousMyStories = queryClient.getQueryData(storyKeys.myList()); const previousStories = queryClient.getQueryData(storyKeys.list()); - const previousStoryDetail = queryClient.getQueryData(storyKeys.detail(bookStoryId)); + const previousStoryDetail = queryClient.getQueryData(storyKeys.detail(bookStoryId)); const previousOtherMemberStories = queryClient.getQueriesData({ queryKey: [...storyKeys.all, "otherMember"] }); // Optimistically update the infinite list @@ -203,13 +203,13 @@ export const useToggleStoryLikeMutation = () => { // Optimistically update the detail view if (previousStoryDetail) { + const isLikedAfterMutation = !previousStoryDetail.likedByMe; queryClient.setQueryData(storyKeys.detail(bookStoryId), (old) => { if (!old) return old; - const nextLiked = !old.likedByMe; return { ...old, - likedByMe: nextLiked, - likes: nextLiked ? old.likes + 1 : old.likes - 1, + likedByMe: isLikedAfterMutation, + likes: isLikedAfterMutation ? old.likes + 1 : old.likes - 1, }; }); } diff --git a/src/hooks/queries/useBookQueries.ts b/src/hooks/queries/useBookQueries.ts index 5acebdd7..644570d3 100644 --- a/src/hooks/queries/useBookQueries.ts +++ b/src/hooks/queries/useBookQueries.ts @@ -7,6 +7,7 @@ export const bookKeys = { infiniteSearch: (title: string) => [...bookKeys.all, "infiniteSearch", title] as const, recommend: () => [...bookKeys.all, "recommend"] as const, detail: (isbn: string) => [...bookKeys.all, "detail", isbn] as const, + likedBooks: (nickname?: string) => [...bookKeys.all, "likedBooks", nickname || "me"] as const, }; export const useBookSearchQuery = (keyword: string) => { @@ -45,3 +46,15 @@ export const useBookDetailQuery = (isbn: string) => { enabled: !!isbn, }); }; + +export const useLikedBooksInfiniteQuery = (nickname?: string) => { + return useInfiniteQuery({ + queryKey: bookKeys.likedBooks(nickname), + queryFn: ({ pageParam }) => bookService.getLikedBooks(nickname, pageParam), + initialPageParam: undefined as number | undefined, + getNextPageParam: (lastPage) => { + if (!lastPage || !lastPage.hasNext) return undefined; + return (lastPage as any).nextCursor; + }, + }); +}; diff --git a/src/hooks/queries/useMemberQueries.ts b/src/hooks/queries/useMemberQueries.ts index b6c01a23..51a3327c 100644 --- a/src/hooks/queries/useMemberQueries.ts +++ b/src/hooks/queries/useMemberQueries.ts @@ -8,6 +8,7 @@ export const memberKeys = { otherProfile: (nickname: string) => [...memberKeys.all, "profile", nickname] as const, followers: () => [...memberKeys.all, "followers"] as const, followings: () => [...memberKeys.all, "followings"] as const, + followCount: () => [...memberKeys.all, "follow-count"] as const, }; export const useRecommendedMembersQuery = (enabled: boolean = true) => { @@ -34,22 +35,30 @@ export const useOtherProfileQuery = (nickname: string, enabled: boolean = true) }); }; -export const useFollowerListQuery = (enabled: boolean = true) => { +export const useFollowerListQuery = (nickname?: string, enabled: boolean = true) => { return useInfiniteQuery({ - queryKey: memberKeys.followers(), - queryFn: ({ pageParam }) => memberService.getFollowerList(pageParam), + queryKey: nickname ? [...memberKeys.followers(), nickname] : memberKeys.followers(), + queryFn: ({ pageParam }) => memberService.getFollowerList(nickname, pageParam), initialPageParam: undefined as number | undefined, getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), enabled, }); }; -export const useFollowingListQuery = (enabled: boolean = true) => { +export const useFollowingListQuery = (nickname?: string, enabled: boolean = true) => { return useInfiniteQuery({ - queryKey: memberKeys.followings(), - queryFn: ({ pageParam }) => memberService.getFollowingList(pageParam), + queryKey: nickname ? [...memberKeys.followings(), nickname] : memberKeys.followings(), + queryFn: ({ pageParam }) => memberService.getFollowingList(nickname, pageParam), initialPageParam: undefined as number | undefined, getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), enabled, }); }; + +export const useFollowCountQuery = (enabled: boolean = true) => { + return useQuery({ + queryKey: memberKeys.followCount(), + queryFn: () => memberService.getMyFollowCount(), + enabled, + }); +}; diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts index 05be7fb0..87b9a0a9 100644 --- a/src/hooks/queries/useStoryQueries.ts +++ b/src/hooks/queries/useStoryQueries.ts @@ -8,6 +8,7 @@ export const storyKeys = { infiniteList: () => [...storyKeys.all, "infiniteList"] as const, followingList: () => [...storyKeys.all, "followingList"] as const, myList: () => [...storyKeys.all, "myList"] as const, + clubList: (clubId: number) => [...storyKeys.all, "clubList", clubId] as const, otherMember: (nickname: string) => [...storyKeys.all, "otherMember", nickname] as const, detail: (id: number) => [...storyKeys.all, "detail", id] as const, }; @@ -64,6 +65,19 @@ export const useMyInfiniteStoriesQuery = () => { }); }; +export const useClubInfiniteStoriesQuery = (clubId: number | null, enabled: boolean = true) => { + return useInfiniteQuery({ + queryKey: clubId ? storyKeys.clubList(clubId) : storyKeys.all, + queryFn: ({ pageParam }) => storyService.getClubStories(clubId!, pageParam ?? undefined), + initialPageParam: null as number | null, + getNextPageParam: (lastPage) => { + if (!lastPage || !lastPage.hasNext) return undefined; + return lastPage.nextCursor; + }, + enabled: enabled && clubId !== null, + }); +}; + export const useOtherMemberInfiniteStoriesQuery = (nickname: string) => { return useInfiniteQuery({ queryKey: storyKeys.otherMember(nickname), diff --git a/src/hooks/useOnClickOutside.ts b/src/hooks/useOnClickOutside.ts new file mode 100644 index 00000000..8823daf8 --- /dev/null +++ b/src/hooks/useOnClickOutside.ts @@ -0,0 +1,27 @@ +import { useEffect, RefObject } from "react"; + +/** + * Hook that alerts clicks outside of the passed ref + */ +export function useOnClickOutside( + ref: RefObject, + handler: (event: MouseEvent | TouchEvent) => void +) { + useEffect(() => { + const listener = (event: MouseEvent | TouchEvent) => { + // Do nothing if clicking ref's element or descendent elements + if (!ref.current || ref.current.contains(event.target as Node)) { + return; + } + handler(event); + }; + + document.addEventListener("mousedown", listener); + document.addEventListener("touchstart", listener); + + return () => { + document.removeEventListener("mousedown", listener); + document.removeEventListener("touchstart", listener); + }; + }, [ref, handler]); +} diff --git a/src/lib/api/endpoints/Clubs.ts b/src/lib/api/endpoints/Clubs.ts index 9e8c2aac..2298dd09 100644 --- a/src/lib/api/endpoints/Clubs.ts +++ b/src/lib/api/endpoints/Clubs.ts @@ -7,6 +7,7 @@ export const CLUBS = { recommendations: `${API_BASE_URL}/clubs/recommendations`, search: `${API_BASE_URL}/clubs/search`, join: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/join`, + leave: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/leave`, me: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/me`, home: (clubId: number) => `${API_BASE_URL}/clubs/${clubId}/home`, @@ -25,5 +26,5 @@ export const CLUBS = { meetingMembers: (clubId: number, meetingId: number) => `${API_BASE_URL}/clubs/${clubId}/meetings/${meetingId}/members`, - meetingTeams: (clubId: number, meetingId: number) => `${API_BASE_URL}/clubs/${clubId}/meetings/${meetingId}/teams`, + meetingTeams: (clubId: number, meetingId: number) => `${API_BASE_URL}/clubs/${clubId}/meetings/${meetingId}/teams`, } as const; \ No newline at end of file diff --git a/src/lib/api/endpoints/book.ts b/src/lib/api/endpoints/book.ts index a35809cd..965d1b61 100644 --- a/src/lib/api/endpoints/book.ts +++ b/src/lib/api/endpoints/book.ts @@ -4,4 +4,7 @@ export const BOOK_ENDPOINTS = { SEARCH: `${API_BASE_URL}/books/search`, RECOMMEND: `${API_BASE_URL}/books/recommend`, DETAIL: (isbn: string) => `${API_BASE_URL}/books/${isbn}`, + LIKE: (isbn: string) => `${API_BASE_URL}/books/${isbn}/like`, + MY_LIKES: `${API_BASE_URL}/books/me/likes`, + OTHER_LIKES: (nickname: string) => `${API_BASE_URL}/books/${encodeURIComponent(nickname)}/likes`, }; diff --git a/src/lib/api/endpoints/bookstory.ts b/src/lib/api/endpoints/bookstory.ts index 8853fc20..143033d7 100644 --- a/src/lib/api/endpoints/bookstory.ts +++ b/src/lib/api/endpoints/bookstory.ts @@ -4,6 +4,7 @@ export const STORY_ENDPOINTS = { LIST: `${API_BASE_URL}/book-stories`, FOLLOWING: `${API_BASE_URL}/book-stories/following`, ME: `${API_BASE_URL}/book-stories/me`, + CLUB: (clubId: number) => `${API_BASE_URL}/book-stories/clubs/${clubId}`, OTHER_MEMBER: (nickname: string) => `${API_BASE_URL}/book-stories/members/${encodeURIComponent(nickname)}`, LIKE: (id: number) => `${API_BASE_URL}/book-stories/${id}/like`, }; diff --git a/src/lib/api/endpoints/member.ts b/src/lib/api/endpoints/member.ts index 4e75a29b..fcb6cbb3 100644 --- a/src/lib/api/endpoints/member.ts +++ b/src/lib/api/endpoints/member.ts @@ -10,4 +10,7 @@ export const MEMBER_ENDPOINTS = { REPORT: `${API_BASE_URL}/members/report`, GET_FOLLOWERS: `${API_BASE_URL}/members/me/follower`, GET_FOLLOWINGS: `${API_BASE_URL}/members/me/following`, + GET_OTHER_FOLLOWERS: (nickname: string) => `${API_BASE_URL}/members/${encodeURIComponent(nickname)}/followers`, + GET_OTHER_FOLLOWINGS: (nickname: string) => `${API_BASE_URL}/members/${encodeURIComponent(nickname)}/followings`, + GET_FOLLOW_COUNT: `${API_BASE_URL}/members/me/follow-count`, }; diff --git a/src/middleware.ts b/src/proxy.ts similarity index 90% rename from src/middleware.ts rename to src/proxy.ts index bd5f9a29..ab6331d3 100644 --- a/src/middleware.ts +++ b/src/proxy.ts @@ -3,11 +3,11 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; // 보호된 경로 (로그인 필요) -const protectedRoutes:string[] = []; // ["/mypage"] - 개발 중 접근 허용을 위해 임시 주석 처리 +const protectedRoutes: string[] = []; // ["/mypage"] - 개발 중 접근 허용을 위해 임시 주석 처리 // 인증된 사용자가 접근할 수 없는 경로 (로그인 불필요) const authRoutes = ["/login", "/signup"]; -export function middleware(request: NextRequest) { +export function proxy(request: NextRequest) { const token = request.cookies.get("accessToken")?.value; const { pathname } = request.nextUrl; diff --git a/src/services/bookService.ts b/src/services/bookService.ts index c30481dd..52c51009 100644 --- a/src/services/bookService.ts +++ b/src/services/bookService.ts @@ -1,7 +1,7 @@ import { apiClient } from "@/lib/api/client"; import { BOOK_ENDPOINTS } from "@/lib/api/endpoints/book"; import { ApiResponse } from "@/types/auth"; -import { Book, BookSearchResponse } from "@/types/book"; +import { Book, BookSearchResponse, MyLikedBooksResponse } from "@/types/book"; export const bookService = { searchBooks: async (keyword: string, page: number = 1): Promise => { @@ -28,4 +28,22 @@ export const bookService = { ); return response.result!; }, + toggleLike: async (isbn: string): Promise<{ isbn: string; liked: boolean; likes: number }> => { + const response = await apiClient.post>( + BOOK_ENDPOINTS.LIKE(isbn) + ); + return response.result!; + }, + getLikedBooks: async (nickname?: string, cursorId?: number): Promise => { + const url = nickname ? BOOK_ENDPOINTS.OTHER_LIKES(nickname) : BOOK_ENDPOINTS.MY_LIKES; + const response = await apiClient.get>( + url, + { + params: { + cursorId + } + } + ); + return response.result!; + } }; diff --git a/src/services/clubService.ts b/src/services/clubService.ts index 90b519ad..879d9ac7 100644 --- a/src/services/clubService.ts +++ b/src/services/clubService.ts @@ -34,19 +34,19 @@ export const clubService = { }, searchClubs: async (params: ClubSearchParams) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const cleaned: any = { ...params }; - if (cleaned.cursorId == null) delete cleaned.cursorId; - if (typeof cleaned.keyword === "string" && cleaned.keyword.trim() === "") { - delete cleaned.keyword; - } - if (cleaned.inputFilter == null) delete cleaned.inputFilter; - - const res = await apiClient.get(CLUBS.search, { - params: cleaned, - }); - return res.result; -}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cleaned: any = { ...params }; + if (cleaned.cursorId == null) delete cleaned.cursorId; + if (typeof cleaned.keyword === "string" && cleaned.keyword.trim() === "") { + delete cleaned.keyword; + } + if (cleaned.inputFilter == null) delete cleaned.inputFilter; + + const res = await apiClient.get(CLUBS.search, { + params: cleaned, + }); + return res.result; + }, joinClub: async (clubId: number, body: ClubJoinRequest) => { const res = await apiClient.post( @@ -66,44 +66,49 @@ export const clubService = { return res.result; }, - getLatestNotice: async (clubId: number) => { - try { - const res = await apiClient.get(CLUBS.latestNotice(clubId)); - return res.result; - } catch (e: any) { - const msg = e?.message ?? ""; - if ( - msg.includes("공지") && (msg.includes("없") || msg.includes("존재하지")) - ) { - return null; + getLatestNotice: async (clubId: number) => { + try { + const res = await apiClient.get(CLUBS.latestNotice(clubId)); + return res.result; + } catch (e: any) { + const msg = e?.message ?? ""; + if ( + msg.includes("공지") && (msg.includes("없") || msg.includes("존재하지")) + ) { + return null; + } + throw e; } - throw e; - } -}, + }, -getNextMeeting: async (clubId: number) => { - try { - const res = await apiClient.get(CLUBS.nextMeeting(clubId)); - return res.result; - } catch (e: any) { - const msg = e?.message ?? ""; - if (msg.includes("다음 정기모임이 존재하지 않습니다")) { - return null; - } - if (msg.includes("정기모임") && (msg.includes("없") || msg.includes("존재하지"))) { - return null; + getNextMeeting: async (clubId: number) => { + try { + const res = await apiClient.get(CLUBS.nextMeeting(clubId)); + return res.result; + } catch (e: any) { + const msg = e?.message ?? ""; + if (msg.includes("다음 정기모임이 존재하지 않습니다")) { + return null; + } + if (msg.includes("정기모임") && (msg.includes("없") || msg.includes("존재하지"))) { + return null; + } + throw e; } - throw e; - } -}, -getAdminClubDetail: async (clubId: number) => { - const res = await apiClient.get(CLUBS.detail(clubId)); - return res.result; -}, -updateAdminClub: async (clubId: number, body: UpdateClubAdminRequest) => { - const res = await apiClient.put(CLUBS.update(clubId), body); - return res.result; -}, + }, + getAdminClubDetail: async (clubId: number) => { + const res = await apiClient.get(CLUBS.detail(clubId)); + return res.result; + }, + updateAdminClub: async (clubId: number, body: UpdateClubAdminRequest) => { + const res = await apiClient.put(CLUBS.update(clubId), body); + return res.result; + }, + + leaveClub: async (clubId: number) => { + const res = await apiClient.delete>(CLUBS.leave(clubId)); + return res.result; + }, }; diff --git a/src/services/memberService.ts b/src/services/memberService.ts index 2b16a5d7..aa5eb343 100644 --- a/src/services/memberService.ts +++ b/src/services/memberService.ts @@ -1,6 +1,6 @@ import { apiClient } from "@/lib/api/client"; import { MEMBER_ENDPOINTS } from "@/lib/api/endpoints/member"; -import { RecommendResponse, UpdateProfileRequest, UpdatePasswordRequest, ProfileResponse, OtherProfileResponse, ReportMemberRequest, FollowListResponse } from "@/types/member"; +import { RecommendResponse, UpdateProfileRequest, UpdatePasswordRequest, ProfileResponse, OtherProfileResponse, ReportMemberRequest, FollowListResponse, FollowCountResponse } from "@/types/member"; import { ApiResponse } from "@/types/auth"; export const memberService = { @@ -65,20 +65,26 @@ export const memberService = { throw new Error(response.message || "Failed to report member"); } }, - getFollowerList: async (cursorId?: number): Promise => { - const url = new URL(MEMBER_ENDPOINTS.GET_FOLLOWERS); + getFollowerList: async (nickname?: string, cursorId?: number): Promise => { + const url = new URL(nickname ? MEMBER_ENDPOINTS.GET_OTHER_FOLLOWERS(nickname) : MEMBER_ENDPOINTS.GET_FOLLOWERS); if (cursorId) { url.searchParams.append("cursorId", cursorId.toString()); } const response = await apiClient.get>(url.toString()); return response.result!; }, - getFollowingList: async (cursorId?: number): Promise => { - const url = new URL(MEMBER_ENDPOINTS.GET_FOLLOWINGS); + getFollowingList: async (nickname?: string, cursorId?: number): Promise => { + const url = new URL(nickname ? MEMBER_ENDPOINTS.GET_OTHER_FOLLOWINGS(nickname) : MEMBER_ENDPOINTS.GET_FOLLOWINGS); if (cursorId) { url.searchParams.append("cursorId", cursorId.toString()); } const response = await apiClient.get>(url.toString()); return response.result!; }, + getMyFollowCount: async (): Promise => { + const response = await apiClient.get>( + MEMBER_ENDPOINTS.GET_FOLLOW_COUNT + ); + return response.result!; + }, }; diff --git a/src/services/newsService.ts b/src/services/newsService.ts index 3140a3f3..055ada3e 100644 --- a/src/services/newsService.ts +++ b/src/services/newsService.ts @@ -6,7 +6,7 @@ import { ApiResponse } from "@/types/auth"; export const newsService = { getNewsList: async (cursorId?: number): Promise => { const url = new URL(NEWS_ENDPOINTS.GET_NEWS_LIST); - if (cursorId) { + if (cursorId !== undefined && cursorId !== null) { url.searchParams.append("cursorId", cursorId.toString()); } diff --git a/src/services/storyService.ts b/src/services/storyService.ts index e721e4d1..29b17695 100644 --- a/src/services/storyService.ts +++ b/src/services/storyService.ts @@ -22,6 +22,17 @@ export const storyService = { ); return response.result!; }, + // 특정 모임의 클럽 책 이야기 조회 + getClubStories: async (clubId: number, cursorId?: number): Promise => { + const response = await apiClient.get>( + STORY_ENDPOINTS.CLUB(clubId), + { + params: { cursorId } + } + ); + return response.result!; + }, + getMyStories: async (cursorId?: number): Promise => { const response = await apiClient.get>( STORY_ENDPOINTS.ME, @@ -58,9 +69,11 @@ export const storyService = { data: { content: string }, parentCommentId?: number ): Promise => { + // 백엔드가 Request Param과 Body 중 어느 곳을 기대할지 모호한 경우를 대비하여 둘 다 전달 + const requestBody = parentCommentId ? { ...data, parentCommentId } : data; const response = await apiClient.post>( `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments`, - data, + requestBody, { params: { parentCommentId } } diff --git a/src/store/useSearchStore.ts b/src/store/useSearchStore.ts new file mode 100644 index 00000000..b69150ca --- /dev/null +++ b/src/store/useSearchStore.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +interface SearchState { + isSearchOpen: boolean; + openSearch: () => void; + closeSearch: () => void; + toggleSearch: () => void; +} + +export const useSearchStore = create((set) => ({ + isSearchOpen: false, + openSearch: () => set({ isSearchOpen: true }), + closeSearch: () => set({ isSearchOpen: false }), + toggleSearch: () => set((state) => ({ isSearchOpen: !state.isSearchOpen })), +})); diff --git a/src/types/book.ts b/src/types/book.ts index 11b992f4..44d70cb0 100644 --- a/src/types/book.ts +++ b/src/types/book.ts @@ -6,10 +6,18 @@ export interface Book { publisher: string; description: string; link: string; + likedByMe?: boolean; } export interface BookSearchResponse { detailInfoList: Book[]; hasNext: boolean; currentPage: number; + nextCursor?: number; +} + +export interface MyLikedBooksResponse { + books: Book[]; + hasNext: boolean; + nextCursor?: number; } diff --git a/src/types/member.ts b/src/types/member.ts index 08423892..0aa49a58 100644 --- a/src/types/member.ts +++ b/src/types/member.ts @@ -32,7 +32,8 @@ export interface OtherProfileResponse { description: string; profileImageUrl: string; following: boolean; - categories: string[]; + followerCount: number; + followingCount: number; } export type ReportType = "GENERAL" | "CLUB_MEETING" | "BOOK_STORY" | "COMMENT"; @@ -54,3 +55,8 @@ export interface FollowListResponse { hasNext: boolean; nextCursor: number | null; } + +export interface FollowCountResponse { + followerCount: number; + followingCount: number; +} diff --git a/src/types/story.ts b/src/types/story.ts index 402d7790..7e606959 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -50,6 +50,7 @@ export interface CommentInfo { writtenByMe: boolean; deleted: boolean; parentCommentId?: number | null; + replies?: CommentInfo[]; } @@ -71,14 +72,7 @@ export interface BookStoryDetail { } export interface CreateBookStoryRequest { - bookInfo: { - isbn: string; - title: string; - author: string; - imgUrl: string; - publisher: string; - description: string; - }; + isbn: string; title: string; description: string; }