diff --git a/public/reportCancle.svg b/public/reportCancle.svg
new file mode 100644
index 00000000..b958560e
--- /dev/null
+++ b/public/reportCancle.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx
index b30937e5..7e67a1d2 100644
--- a/src/app/(main)/page.tsx
+++ b/src/app/(main)/page.tsx
@@ -121,7 +121,6 @@ export default function HomePage() {
onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })}
hideSubscribeButton={story.writtenByMe}
onClick={() => router.push(`/stories/${story.bookStoryId}`)}
- onLikeClick={() => toggleLike(story.bookStoryId)}
/>
))}
@@ -178,7 +177,6 @@ export default function HomePage() {
onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })}
hideSubscribeButton={story.writtenByMe}
onClick={() => router.push(`/stories/${story.bookStoryId}`)}
- onLikeClick={() => toggleLike(story.bookStoryId)}
/>
))}
@@ -237,7 +235,6 @@ export default function HomePage() {
onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })}
hideSubscribeButton={story.writtenByMe}
onClick={() => router.push(`/stories/${story.bookStoryId}`)}
- onLikeClick={() => toggleLike(story.bookStoryId)}
/>
))}
diff --git a/src/app/(main)/profile/[nickname]/page.tsx b/src/app/(main)/profile/[nickname]/page.tsx
index df4119d4..884d20f1 100644
--- a/src/app/(main)/profile/[nickname]/page.tsx
+++ b/src/app/(main)/profile/[nickname]/page.tsx
@@ -19,7 +19,7 @@ export default async function OtherUserProfilePage({ params }: PageProps) {
-
+
);
diff --git a/src/app/(main)/stories/page.tsx b/src/app/(main)/stories/page.tsx
index 9294033b..6a1c09ca 100644
--- a/src/app/(main)/stories/page.tsx
+++ b/src/app/(main)/stories/page.tsx
@@ -112,6 +112,7 @@ export default function StoriesPage() {
isFollowing={story.authorInfo.following}
onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })}
hideSubscribeButton={story.writtenByMe}
+ onProfileClick={() => router.push(`/profile/${story.authorInfo.nickname}`)}
onClick={() => handleCardClick(story.bookStoryId)}
onLikeClick={() => toggleLike(story.bookStoryId)}
/>
@@ -149,6 +150,7 @@ export default function StoriesPage() {
isFollowing={story.authorInfo.following}
onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })}
hideSubscribeButton={story.writtenByMe}
+ onProfileClick={() => router.push(`/profile/${story.authorInfo.nickname}`)}
onClick={() => handleCardClick(story.bookStoryId)}
onLikeClick={() => toggleLike(story.bookStoryId)}
/>
diff --git a/src/components/base-ui/BookStory/bookstory_card.tsx b/src/components/base-ui/BookStory/bookstory_card.tsx
index 7473a93f..a7ac4dad 100644
--- a/src/components/base-ui/BookStory/bookstory_card.tsx
+++ b/src/components/base-ui/BookStory/bookstory_card.tsx
@@ -21,6 +21,7 @@ type Props = {
isFollowing?: boolean;
hideSubscribeButton?: boolean;
onClick?: () => void;
+ onProfileClick?: () => void;
};
export default function BookStoryCard({
@@ -41,6 +42,7 @@ export default function BookStoryCard({
isFollowing = false,
hideSubscribeButton = false,
onClick,
+ onProfileClick,
}: Props) {
const heartIcon = likedByMe ? "/red_heart.svg" : "/gray_heart.svg";
@@ -54,7 +56,15 @@ export default function BookStoryCard({
md:w-[336px] md:h-[380px]"
>
{/* 1. 상단 프로필 (모바일 숨김 / 데스크탑 노출) */}
-
+
{
+ if (onProfileClick) {
+ e.stopPropagation();
+ onProfileClick();
+ }
+ }}
+ >
void;
hideSubscribeButton?: boolean;
+ onProfileClick?: () => void;
};
export default function BookStoryCardLarge({
@@ -41,6 +42,7 @@ export default function BookStoryCardLarge({
isFollowing = false,
onClick,
hideSubscribeButton = false,
+ onProfileClick,
}: Props) {
const heartIcon = likedByMe ? "/red_heart.svg" : "/gray_heart.svg";
@@ -51,7 +53,15 @@ export default function BookStoryCardLarge({
w-[336px] h-[380px]"
>
{/* 상단 프로필 */}
-
+
{
+ if (onProfileClick) {
+ e.stopPropagation();
+ onProfileClick();
+ }
+ }}
+ >
(null);
const heartIcon = likedByMe ? "/red_heart.svg" : "/gray_heart.svg";
+ const { mutate: reportMember } = useReportMemberMutation();
// 바깥 클릭 시 메뉴 닫기
useEffect(() => {
@@ -84,6 +89,19 @@ export default function BookstoryDetail({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
+ const handleReportSubmit = (type: string, content: string) => {
+ let mappedType: ReportType = "GENERAL";
+ if (type === "책 이야기") mappedType = "BOOK_STORY";
+ if (type === "책이야기(댓글)") mappedType = "COMMENT";
+ if (type === "책모임 내부") mappedType = "CLUB_MEETING";
+
+ reportMember({
+ reportedMemberNickname: authorNickname,
+ reportType: mappedType,
+ content,
+ });
+ };
+
return (
{subscribeText}
@@ -147,7 +165,7 @@ export default function BookstoryDetail({
+ {/* Report Modal */}
+
setIsReportModalOpen(false)}
+ onSubmit={handleReportSubmit}
+ defaultReportType="책 이야기"
+ />
);
}
diff --git a/src/components/base-ui/Comment/comment_section.tsx b/src/components/base-ui/Comment/comment_section.tsx
index b1e6a418..de52f29f 100644
--- a/src/components/base-ui/Comment/comment_section.tsx
+++ b/src/components/base-ui/Comment/comment_section.tsx
@@ -11,6 +11,9 @@ import {
} from "@/hooks/mutations/useStoryMutations";
import { toast } from "react-hot-toast";
import ConfirmModal from "@/components/common/ConfirmModal";
+import ReportModal from "@/components/common/ReportModal";
+import { useReportMemberMutation } from "@/hooks/mutations/useMemberMutations";
+import { ReportType } from "@/types/member";
// 어떤 글의 댓글인지 구분
type CommentSectionProps = {
@@ -28,6 +31,7 @@ export default function CommentSection({
const createCommentMutation = useCreateCommentMutation(storyId);
const updateCommentMutation = useUpdateCommentMutation(storyId);
const deleteCommentMutation = useDeleteCommentMutation(storyId);
+ const { mutate: reportMember } = useReportMemberMutation();
// API 데이터를 UI용 Comment 형식으로 변환 및 계층 구조화
const mapApiToUiComments = (apiComments: CommentInfo[]): Comment[] => {
@@ -73,6 +77,9 @@ export default function CommentSection({
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [commentToDelete, setCommentToDelete] = useState(null);
+ const [isReportModalOpen, setIsReportModalOpen] = useState(false);
+ const [reportTargetNickname, setReportTargetNickname] = useState("");
+
// 데이터가 변경되면 상태 업데이트
useEffect(() => {
setComments(mapApiToUiComments(initialComments));
@@ -144,9 +151,42 @@ export default function CommentSection({
};
const handleReportComment = (id: number) => {
- // TODO: 댓글 신고 API 연동
- console.log("댓글 신고:", id);
- toast.success("신고가 접수되었습니다.");
+ // 찾기: 최상단 댓글과 대댓글 모두 탐색
+ let targetComment: Comment | undefined;
+
+ for (const c of comments) {
+ if (c.id === id) {
+ targetComment = c;
+ break;
+ }
+ if (c.replies) {
+ const found = c.replies.find(r => r.id === id);
+ if (found) {
+ targetComment = found;
+ break;
+ }
+ }
+ }
+
+ if (targetComment) {
+ setReportTargetNickname(targetComment.authorName);
+ setIsReportModalOpen(true);
+ }
+ };
+
+ const handleReportSubmit = (type: string, content: string) => {
+ let mappedType: ReportType = "GENERAL";
+ if (type === "책 이야기") mappedType = "BOOK_STORY";
+ if (type === "책이야기(댓글)") mappedType = "COMMENT";
+ if (type === "책모임 내부") mappedType = "CLUB_MEETING";
+
+ if (reportTargetNickname) {
+ reportMember({
+ reportedMemberNickname: reportTargetNickname,
+ reportType: mappedType,
+ content,
+ });
+ }
};
return (
@@ -168,6 +208,17 @@ export default function CommentSection({
setCommentToDelete(null);
}}
/>
+
+ {/* Report Modal */}
+ {
+ setIsReportModalOpen(false);
+ setReportTargetNickname("");
+ }}
+ onSubmit={handleReportSubmit}
+ defaultReportType="책이야기(댓글)"
+ />
>
);
}
diff --git a/src/components/base-ui/Profile/BookStoryList.tsx b/src/components/base-ui/Profile/BookStoryList.tsx
index d39220a1..9b407e85 100644
--- a/src/components/base-ui/Profile/BookStoryList.tsx
+++ b/src/components/base-ui/Profile/BookStoryList.tsx
@@ -1,87 +1,100 @@
"use client";
+import { useEffect } from "react";
import { useRouter } from "next/navigation";
import BookStoryCard from "@/components/base-ui/BookStory/bookstory_card";
+import { useOtherMemberInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries";
+import { useInView } from "react-intersection-observer";
+import { useToggleStoryLikeMutation } from "@/hooks/mutations/useStoryMutations";
+import { useToggleFollowMutation } from "@/hooks/mutations/useMemberMutations";
-const MOCK_STORIES = [
- {
- id: 1,
- authorName: "hy",
- createdAt: new Date().toISOString(),
- viewCount: 128,
- title: "한밤의 도서관에서 발견한 기적",
- content: "조용한 도서관 구석에서...",
- likeCount: 42,
- commentCount: 12,
- },
- {
- id: 2,
- authorName: "hy",
- createdAt: new Date().toISOString(),
- viewCount: 350,
- title: "이기적 유전자, 다시 읽기",
- content: "대학 시절 읽었던...",
- likeCount: 85,
- commentCount: 24,
- },
- {
- id: 3,
- authorName: "hy",
- createdAt: new Date().toISOString(),
- viewCount: 95,
- title: "여행의 이유를 찾아서",
- content: "김영하 작가님의...",
- likeCount: 30,
- commentCount: 5,
- },
- {
- id: 4,
- authorName: "hy",
- createdAt: new Date().toISOString(),
- viewCount: 420,
- title: "돈의 심리학: 부의 비밀",
- content: "부자가 되는 것보다...",
- likeCount: 150,
- commentCount: 45,
- },
- {
- id: 5,
- authorName: "hy",
- createdAt: new Date().toISOString(),
- viewCount: 210,
- title: "불편한 편의점의 따뜻한 위로",
- content: "제목과는 달리...",
- likeCount: 67,
- commentCount: 18,
- },
- {
- id: 6,
- authorName: "hy",
- createdAt: new Date().toISOString(),
- viewCount: 88,
- title: "코스모스, 우주를 향한 항해",
- content: "칼 세이건의 코스모스는...",
- likeCount: 55,
- commentCount: 9,
- },
-];
-
-export default function BookStoryList() {
+export default function BookStoryList({ nickname }: { nickname: string }) {
const router = useRouter();
+ const { mutate: toggleLike } = useToggleStoryLikeMutation();
+ const { mutate: toggleFollow } = useToggleFollowMutation();
+
+ const decodedNickname = decodeURIComponent(nickname);
+
+ const {
+ data,
+ isLoading,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isError
+ } = useOtherMemberInfiniteStoriesQuery(decodedNickname);
+
+ const { ref, inView } = useInView({ threshold: 0 });
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+ 책 이야기를 불러오는 중 오류가 발생했습니다.
+
+ );
+ }
+
+ const stories = data?.pages.flatMap((page) => page.basicInfoList) || [];
+
+ if (stories.length === 0) {
+ return (
+
+ 작성된 책 이야기가 없습니다.
+
+ );
+ }
+
return (
-
- {MOCK_STORIES.map(({ id, ...storyData }) => (
+
+ {stories.map((story) => (
router.push(`/stories/${id}`)}
+ key={story.bookStoryId}
+ id={story.bookStoryId}
+ authorName={story.authorInfo.nickname}
+ profileImgSrc={story.authorInfo.profileImageUrl}
+ createdAt={story.createdAt}
+ viewCount={story.viewCount}
+ title={story.bookStoryTitle}
+ content={story.description}
+ likeCount={story.likes}
+ commentCount={story.commentCount}
+ likedByMe={story.likedByMe}
+ coverImgSrc={story.bookInfo.imgUrl}
+ subscribeText={story.authorInfo.following ? "구독 중" : "구독"}
+ isFollowing={story.authorInfo.following}
+ onSubscribeClick={() => toggleFollow({ nickname: story.authorInfo.nickname, isFollowing: story.authorInfo.following })}
+ hideSubscribeButton={story.writtenByMe}
+ onClick={() => router.push(`/stories/${story.bookStoryId}`)}
+ onLikeClick={() => toggleLike(story.bookStoryId)}
/>
))}
+
+ {/* 무한 스크롤 타겟 */}
+ {hasNextPage && (
+
+ {isFetchingNextPage ? (
+
+ ) : (
+
+ )}
+
+ )}
);
}
diff --git a/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx b/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx
index b2a483fb..d18db73d 100644
--- a/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx
+++ b/src/components/base-ui/Profile/OtherUser/OtherUserProfileTabs.tsx
@@ -8,11 +8,11 @@ import BookStoryList from "@/components/base-ui/Profile/BookStoryList";
const TABS = ["책 이야기", "서재", "모임"] as const;
type Tab = (typeof TABS)[number];
-export default function OtherUserProfileTabs() {
+export default function OtherUserProfileTabs({ nickname }: { nickname: string }) {
const [activeTab, setActiveTab] = useState
("책 이야기");
const TAB_CONTENT: Record = {
- "책 이야기": ,
+ "책 이야기": ,
서재: ,
모임: ,
};
@@ -28,10 +28,9 @@ export default function OtherUserProfileTabs() {
onClick={() => setActiveTab(tab)}
className={`flex-1 py-[10px] text-center transition-colors
body_1_2 t:subhead_3
- ${
- activeTab === tab
- ? "border-b-2 border-primary-3 text-primary-3 -mb-[2px]"
- : "text-Gray-3 border-b-2 border-transparent"
+ ${activeTab === tab
+ ? "border-b-2 border-primary-3 text-primary-3 -mb-[2px]"
+ : "text-Gray-3 border-b-2 border-transparent"
}`}
>
{tab}
diff --git a/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx b/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
index fffe001d..f53cd7ce 100644
--- a/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
+++ b/src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
@@ -1,14 +1,22 @@
"use client";
import Image from "next/image";
+import { useOtherProfileQuery } from "@/hooks/queries/useMemberQueries";
+
+import { useToggleFollowMutation, useReportMemberMutation } from "@/hooks/mutations/useMemberMutations";
+import { ReportType } from "@/types/member";
+import { useState } from "react";
+import ReportModal from "@/components/common/ReportModal";
// [보조 컴포넌트] 액션 버튼 (구독하기 / 신고하기)
function ActionButton({
variant,
label,
+ onClick,
}: {
- variant: "primary" | "secondary";
+ variant: "primary" | "secondary" | "following";
label: string;
+ onClick?: () => void;
}) {
const baseStyles =
"flex items-center justify-center rounded-[8px] transition-colors";
@@ -17,6 +25,9 @@ function ActionButton({
primary:
"bg-primary-1 text-White font-semibold t:font-medium w-[220px] h-[32px] t:w-[486px] t:h-[48px] d:w-[532px]",
+ following:
+ "bg-[var(--Subbrown_4)] text-primary-3 font-semibold t:font-medium w-[220px] h-[32px] t:w-[486px] t:h-[48px] d:w-[532px]",
+
secondary:
"bg-White border border-Subbrown-3 text-Gray-4 font-medium hover:bg-gray-50 w-[100px] h-[32px] t:w-[178px] t:h-[48px]",
};
@@ -24,7 +35,7 @@ function ActionButton({
const textStyles = "body_1_2 t:subhead_4_1";
return (
-
+
{label}
);
@@ -43,6 +54,45 @@ function StatItem({ label, count }: { label: string; count: number }) {
}
export default function ProfileUserInfo({ nickname }: { nickname: string }) {
+ const decodedNickname = decodeURIComponent(nickname);
+ const { data: profile, isLoading } = useOtherProfileQuery(decodedNickname);
+ const { mutate: toggleFollow } = useToggleFollowMutation();
+ const { mutate: reportMember } = useReportMemberMutation();
+ const [isReportModalOpen, setIsReportModalOpen] = useState(false);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!profile) {
+ return (
+
+ 프로필 정보를 불러올 수 없습니다.
+
+ );
+ }
+
+ const handleToggleFollow = () => {
+ toggleFollow({ nickname: decodedNickname, isFollowing: profile.following });
+ };
+
+ const handleReportSubmit = (type: string, content: string) => {
+ let mappedType: ReportType = "GENERAL";
+ if (type === "책 이야기") mappedType = "BOOK_STORY";
+ if (type === "책이야기(댓글)") mappedType = "COMMENT";
+ if (type === "책모임 내부") mappedType = "CLUB_MEETING";
+
+ reportMember({
+ reportedMemberNickname: decodedNickname,
+ reportType: mappedType,
+ content,
+ });
+ };
+
return (
@@ -78,29 +128,39 @@ export default function ProfileUserInfo({ nickname }: { nickname: string }) {
{/* 닉네임 & 통계 */}
{/* 닉네임 */}
-
{nickname}
+
{profile.nickname}
{/* 통계 그룹 */}
-
-
+
+
{/* 소개글 */}
- 이제 다양한 책을 함께 읽고 서로의 생각을 나누는 특별한 시간을
- 시작해보세요. 한 권의 책이 주는 작은 울림이 일상에 큰 변화를
- 가져올지도 모릅니다.
+ {profile.description || "이 사용자는 소개를 작성하지 않았습니다."}
{/* 2. 하단 버튼 그룹 */}
-
-
+
+
setIsReportModalOpen(true)} />
+
+ {/* Report Modal */}
+ setIsReportModalOpen(false)}
+ onSubmit={handleReportSubmit}
+ defaultReportType="일반"
+ />
);
}
diff --git a/src/components/common/ReportModal.tsx b/src/components/common/ReportModal.tsx
new file mode 100644
index 00000000..64d8a232
--- /dev/null
+++ b/src/components/common/ReportModal.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Image from "next/image";
+
+type ReportType = "일반" | "책 이야기" | "책이야기(댓글)" | "책모임 내부" | null;
+
+type ReportModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (type: string, content: string) => void;
+ defaultReportType?: ReportType;
+};
+
+export default function ReportModal({ isOpen, onClose, onSubmit, defaultReportType = null }: ReportModalProps) {
+ const [reportType, setReportType] = useState
(defaultReportType);
+ const [reportContent, setReportContent] = useState("");
+
+ const reportTypes: ReportType[] = ["일반", "책 이야기", "책이야기(댓글)", "책모임 내부"];
+ const isSubmitEnabled = reportType !== null && reportContent.trim().length > 0;
+
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && isOpen) {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.body.style.overflow = "hidden";
+ document.addEventListener("keydown", handleEscape);
+ setReportType(defaultReportType);
+ } else {
+ document.body.style.overflow = "";
+ // Reset state when closed
+ setReportType(defaultReportType);
+ setReportContent("");
+ }
+
+ return () => {
+ document.body.style.overflow = "";
+ document.removeEventListener("keydown", handleEscape);
+ };
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ const handleContentChange = (e: React.ChangeEvent) => {
+ if (e.target.value.length <= 400) {
+ setReportContent(e.target.value);
+ }
+ };
+
+ return (
+
+ {/* Modal Container */}
+
e.stopPropagation()}
+ >
+ {/* Header: Title and Close button */}
+
+
신고하기
+
+
+
+
+
+ {/* Body: Report Type and Content */}
+
+
+ {/* Report Type Section */}
+
+
종류
+
+ {reportTypes.map((type) => {
+ const isSelected = reportType === type;
+ return (
+ setReportType(type)}
+ className={`flex w-[144px] h-[45px] p-[10px] justify-center items-center gap-[10px] rounded-[8px] border transition-colors ${isSelected
+ ? "border-primary-1 bg-primary-1 text-White"
+ : "border-Gray-2 bg-Gray-1 text-Gray-3"
+ }`}
+ >
+ {type}
+
+ );
+ })}
+
+
+
+ {/* Report Content Section */}
+
+ 내용
+
+
+
+ {/* Submit CTA Section */}
+
+ {
+ if (reportType && isSubmitEnabled) {
+ onSubmit(reportType, reportContent);
+ onClose();
+ }
+ }}
+ className={`flex h-[56px] p-[20px] justify-center items-center gap-[10px] self-stretch rounded-[8px] transition-colors pb-[20px] ${isSubmitEnabled
+ ? "bg-primary-1 text-White hover:bg-primary-1/90 cursor-pointer"
+ : "bg-Gray-1 text-Gray-3 cursor-not-allowed"
+ }`}
+ >
+
+ 신고 등록
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/mutations/useMemberMutations.ts b/src/hooks/mutations/useMemberMutations.ts
index 18468b7a..5db6c0fc 100644
--- a/src/hooks/mutations/useMemberMutations.ts
+++ b/src/hooks/mutations/useMemberMutations.ts
@@ -58,10 +58,11 @@ export const useUpdateProfileMutation = () => {
});
};
-import { UpdatePasswordRequest, RecommendResponse } from "@/types/member";
+import { UpdatePasswordRequest, RecommendResponse, ReportMemberRequest } from "@/types/member";
import { BookStoryListResponse } from "@/types/story";
import { storyKeys } from "@/hooks/queries/useStoryQueries";
import { memberKeys } from "@/hooks/queries/useMemberQueries";
+import { OtherProfileResponse } from "@/types/member";
import { toast } from "react-hot-toast";
import { InfiniteData } from "@tanstack/react-query";
@@ -143,11 +144,13 @@ export const useToggleFollowMutation = () => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: storyKeys.all });
await queryClient.cancelQueries({ queryKey: memberKeys.recommended() });
+ await queryClient.cancelQueries({ queryKey: memberKeys.otherProfile(nickname) });
// Snapshot previous values
const previousRecommendations = queryClient.getQueryData(memberKeys.recommended());
const previousInfiniteStories = queryClient.getQueryData(storyKeys.infiniteList());
const previousStories = queryClient.getQueryData(storyKeys.list());
+ const previousOtherProfile = queryClient.getQueryData(memberKeys.otherProfile(nickname));
// 1. Optimistically update recommendations
if (previousRecommendations) {
@@ -170,9 +173,17 @@ export const useToggleFollowMutation = () => {
);
}
- return { previousRecommendations, previousInfiniteStories, previousStories };
+ // 4. Optimistically update other profile
+ if (previousOtherProfile) {
+ queryClient.setQueryData(memberKeys.otherProfile(nickname), (old) => {
+ if (!old) return old;
+ return { ...old, following: !isFollowing };
+ });
+ }
+
+ return { previousRecommendations, previousInfiniteStories, previousStories, previousOtherProfile };
},
- onError: (error: any, _variables, context) => {
+ onError: (error: any, variables, context) => {
console.error("Failed to toggle follow:", error);
toast.error("팔로우 상태 업데이트에 실패했습니다.");
@@ -186,11 +197,31 @@ export const useToggleFollowMutation = () => {
if (context?.previousStories) {
queryClient.setQueryData(storyKeys.list(), context.previousStories);
}
+ if (context?.previousOtherProfile) {
+ queryClient.setQueryData(memberKeys.otherProfile(variables.nickname), context.previousOtherProfile);
+ }
},
- onSettled: () => {
+ onSettled: (_data, _error, variables) => {
// Refetch to sync with server
queryClient.invalidateQueries({ queryKey: storyKeys.all });
queryClient.invalidateQueries({ queryKey: memberKeys.recommended() });
+ queryClient.invalidateQueries({ queryKey: memberKeys.otherProfile(variables.nickname) });
+ },
+ });
+};
+
+export const useReportMemberMutation = () => {
+ return useMutation({
+ mutationFn: async (payload: ReportMemberRequest) => {
+ await memberService.reportMember(payload);
+ },
+ onSuccess: () => {
+ toast.success("신고가 완료되었습니다.");
+ },
+ onError: (error: any) => {
+ console.error("Failed to report member:", error);
+ const errorMessage = error.response?.data?.message || error.message || "신고에 실패했습니다.";
+ toast.error(errorMessage);
},
});
};
diff --git a/src/hooks/mutations/useStoryMutations.ts b/src/hooks/mutations/useStoryMutations.ts
index 7a90dd6f..8e4e9d5e 100644
--- a/src/hooks/mutations/useStoryMutations.ts
+++ b/src/hooks/mutations/useStoryMutations.ts
@@ -104,6 +104,9 @@ export const useCreateCommentMutation = (bookStoryId: number) => {
queryClient.setQueryData>(storyKeys.myList(), (old) =>
updateCommentCountInInfiniteList(old, bookStoryId, 1)
);
+ queryClient.setQueriesData>({ queryKey: [...storyKeys.all, "otherMember"] }, (old) =>
+ updateCommentCountInInfiniteList(old, bookStoryId, 1)
+ );
queryClient.setQueryData(storyKeys.list(), (old) =>
updateCommentCountInStoryList(old, bookStoryId, 1)
);
@@ -135,6 +138,9 @@ export const useDeleteCommentMutation = (bookStoryId: number) => {
queryClient.setQueryData>(storyKeys.myList(), (old) =>
updateCommentCountInInfiniteList(old, bookStoryId, -1)
);
+ queryClient.setQueriesData>({ queryKey: [...storyKeys.all, "otherMember"] }, (old) =>
+ updateCommentCountInInfiniteList(old, bookStoryId, -1)
+ );
queryClient.setQueryData(storyKeys.list(), (old) =>
updateCommentCountInStoryList(old, bookStoryId, -1)
);
@@ -167,6 +173,7 @@ export const useToggleStoryLikeMutation = () => {
const previousMyStories = queryClient.getQueryData(storyKeys.myList());
const previousStories = queryClient.getQueryData(storyKeys.list());
const previousStoryDetail = queryClient.getQueryData(storyKeys.detail(bookStoryId));
+ const previousOtherMemberStories = queryClient.getQueriesData({ queryKey: [...storyKeys.all, "otherMember"] });
// Optimistically update the infinite list
if (previousInfiniteStories) {
@@ -182,6 +189,11 @@ export const useToggleStoryLikeMutation = () => {
);
}
+ // Optimistically update other member stories
+ queryClient.setQueriesData>({ queryKey: [...storyKeys.all, "otherMember"] }, (old) =>
+ updateLikeInInfiniteList(old, bookStoryId)
+ );
+
// Optimistically update the regular list (if used)
if (previousStories) {
queryClient.setQueryData(storyKeys.list(), (old) =>
@@ -207,6 +219,7 @@ export const useToggleStoryLikeMutation = () => {
previousMyStories,
previousStories,
previousStoryDetail,
+ previousOtherMemberStories,
};
},
onError: (err, bookStoryId, context) => {
@@ -225,11 +238,17 @@ export const useToggleStoryLikeMutation = () => {
if (context?.previousStoryDetail) {
queryClient.setQueryData(storyKeys.detail(bookStoryId), context.previousStoryDetail);
}
+ if (context?.previousOtherMemberStories) {
+ context.previousOtherMemberStories.forEach(([queryKey, oldData]) => {
+ queryClient.setQueryData(queryKey, oldData);
+ });
+ }
},
onSettled: (data, err, bookStoryId) => {
// Invalidate queries to ensure sync with server
queryClient.invalidateQueries({ queryKey: storyKeys.infiniteList() });
queryClient.invalidateQueries({ queryKey: storyKeys.myList() });
+ queryClient.invalidateQueries({ queryKey: [...storyKeys.all, "otherMember"] });
queryClient.invalidateQueries({ queryKey: storyKeys.list() });
queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) });
},
diff --git a/src/hooks/queries/useMemberQueries.ts b/src/hooks/queries/useMemberQueries.ts
index 660db0cf..e661a2dd 100644
--- a/src/hooks/queries/useMemberQueries.ts
+++ b/src/hooks/queries/useMemberQueries.ts
@@ -5,6 +5,7 @@ export const memberKeys = {
all: ["members"] as const,
recommended: () => [...memberKeys.all, "recommended"] as const,
profile: () => [...memberKeys.all, "profile"] as const,
+ otherProfile: (nickname: string) => [...memberKeys.all, "profile", nickname] as const,
};
export const useRecommendedMembersQuery = (enabled: boolean = true) => {
@@ -22,3 +23,11 @@ export const useProfileQuery = (enabled: boolean = true) => {
enabled,
});
};
+
+export const useOtherProfileQuery = (nickname: string, enabled: boolean = true) => {
+ return useQuery({
+ queryKey: memberKeys.otherProfile(nickname),
+ queryFn: () => memberService.getOtherProfile(nickname),
+ enabled: enabled && !!nickname,
+ });
+};
diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts
index e68cbebb..2bfc3ae8 100644
--- a/src/hooks/queries/useStoryQueries.ts
+++ b/src/hooks/queries/useStoryQueries.ts
@@ -7,6 +7,7 @@ export const storyKeys = {
list: () => [...storyKeys.all, "list"] as const,
infiniteList: () => [...storyKeys.all, "infiniteList"] as const,
myList: () => [...storyKeys.all, "myList"] as const,
+ otherMember: (nickname: string) => [...storyKeys.all, "otherMember", nickname] as const,
detail: (id: number) => [...storyKeys.all, "detail", id] as const,
};
@@ -48,3 +49,16 @@ export const useMyInfiniteStoriesQuery = () => {
},
});
};
+
+export const useOtherMemberInfiniteStoriesQuery = (nickname: string) => {
+ return useInfiniteQuery({
+ queryKey: storyKeys.otherMember(nickname),
+ queryFn: ({ pageParam }) => storyService.getOtherMemberStories(nickname, pageParam ?? undefined),
+ initialPageParam: null as number | null,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage || !lastPage.hasNext) return undefined;
+ return lastPage.nextCursor;
+ },
+ enabled: !!nickname,
+ });
+};
diff --git a/src/lib/api/endpoints/bookstory.ts b/src/lib/api/endpoints/bookstory.ts
index 9645944c..95b2404d 100644
--- a/src/lib/api/endpoints/bookstory.ts
+++ b/src/lib/api/endpoints/bookstory.ts
@@ -3,5 +3,6 @@ import { API_BASE_URL } from "./base";
export const STORY_ENDPOINTS = {
LIST: `${API_BASE_URL}/book-stories`,
ME: `${API_BASE_URL}/book-stories/me`,
+ 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 987c404f..9f77af7f 100644
--- a/src/lib/api/endpoints/member.ts
+++ b/src/lib/api/endpoints/member.ts
@@ -5,5 +5,7 @@ export const MEMBER_ENDPOINTS = {
RECOMMEND: `${API_BASE_URL}/members/me/recommend`,
UPDATE_PROFILE: `${API_BASE_URL}/members/me`,
UPDATE_PASSWORD: `${API_BASE_URL}/members/me/update-password`,
+ GET_OTHER_PROFILE: (nickname: string) => `${API_BASE_URL}/members/${encodeURIComponent(nickname)}`,
FOLLOW: (nickname: string) => `${API_BASE_URL}/members/${encodeURIComponent(nickname)}/following`,
+ REPORT: `${API_BASE_URL}/members/report`,
};
diff --git a/src/services/memberService.ts b/src/services/memberService.ts
index 03b24a92..5cebbe52 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 } from "@/types/member";
+import { RecommendResponse, UpdateProfileRequest, UpdatePasswordRequest, ProfileResponse, OtherProfileResponse, ReportMemberRequest } from "@/types/member";
import { ApiResponse } from "@/types/auth";
export const memberService = {
@@ -34,6 +34,12 @@ export const memberService = {
);
return response.result!;
},
+ getOtherProfile: async (nickname: string): Promise => {
+ const response = await apiClient.get>(
+ MEMBER_ENDPOINTS.GET_OTHER_PROFILE(nickname)
+ );
+ return response.result!;
+ },
followMember: async (nickname: string): Promise => {
const response = await apiClient.post>(
MEMBER_ENDPOINTS.FOLLOW(nickname)
@@ -50,4 +56,13 @@ export const memberService = {
throw new Error(response.message || "Failed to unfollow member");
}
},
+ reportMember: async (data: ReportMemberRequest): Promise => {
+ const response = await apiClient.post>(
+ MEMBER_ENDPOINTS.REPORT,
+ data
+ );
+ if (!response.isSuccess) {
+ throw new Error(response.message || "Failed to report member");
+ }
+ },
};
diff --git a/src/services/storyService.ts b/src/services/storyService.ts
index 8457ade5..cfdc9b45 100644
--- a/src/services/storyService.ts
+++ b/src/services/storyService.ts
@@ -22,6 +22,15 @@ export const storyService = {
);
return response.result!;
},
+ getOtherMemberStories: async (nickname: string, cursorId?: number): Promise => {
+ const response = await apiClient.get>(
+ STORY_ENDPOINTS.OTHER_MEMBER(nickname),
+ {
+ params: { cursorId },
+ }
+ );
+ return response.result!;
+ },
getStoryById: async (id: number): Promise => {
const response = await apiClient.get>(
`${STORY_ENDPOINTS.LIST}/${id}`
diff --git a/src/types/member.ts b/src/types/member.ts
index b5a9e413..0b119a90 100644
--- a/src/types/member.ts
+++ b/src/types/member.ts
@@ -26,3 +26,19 @@ export interface ProfileResponse {
profileImageUrl: string;
categories: string[];
}
+
+export interface OtherProfileResponse {
+ nickname: string;
+ description: string;
+ profileImageUrl: string;
+ following: boolean;
+ categories: string[];
+}
+
+export type ReportType = "GENERAL" | "CLUB_MEETING" | "BOOK_STORY" | "COMMENT";
+
+export interface ReportMemberRequest {
+ reportedMemberNickname: string;
+ reportType: ReportType;
+ content: string;
+}