diff --git a/src/features/preOpinions/components/PreOpinionDetail.tsx b/src/features/preOpinions/components/PreOpinionDetail.tsx new file mode 100644 index 0000000..2339bab --- /dev/null +++ b/src/features/preOpinions/components/PreOpinionDetail.tsx @@ -0,0 +1,140 @@ +import { useAuth } from '@/features/auth' +import { StarRate } from '@/shared/components/StarRate' +import { Avatar, AvatarFallback, AvatarImage, Badge, TextButton } from '@/shared/ui' +import { Chip } from '@/shared/ui/Chip' +import { useGlobalModalStore } from '@/store' + +import { useDeleteMyPreOpinionAnswer } from '../hooks/useDeleteMyPreOpinionAnswer' +import { ROLE_TO_AVATAR_VARIANT } from '../preOpinions.constants' +import type { PreOpinionMember, PreOpinionTopic } from '../preOpinions.types' + +type PreOpinionDetailProps = { + member: PreOpinionMember + topics: PreOpinionTopic[] + gatheringId: number + meetingId: number +} + +/** + * 사전 의견 상세 (선택된 멤버의 책 평가 + 주제별 의견) + * + * @description + * 선택된 멤버의 책 평가(별점, 키워드)와 주제별 의견을 표시합니다. + * + * @example + * ```tsx + * + * ``` + */ +function PreOpinionDetail({ member, topics, gatheringId, meetingId }: PreOpinionDetailProps) { + const { data: currentUser } = useAuth() + const { openConfirm, openError } = useGlobalModalStore() + const { bookReview, topicOpinions, memberInfo } = member + const isMyOpinion = currentUser?.userId === memberInfo?.userId + const deleteMutation = useDeleteMyPreOpinionAnswer({ gatheringId, meetingId }) + + const handleDelete = async () => { + const confirmed = await openConfirm( + '내 의견 삭제하기', + '내 의견을 삭제하면 다른 멤버들의 의견을 보는 권한도 함께 사라져요.\n삭제를 진행할까요?', + { confirmText: '삭제', variant: 'danger' } + ) + if (!confirmed) return + + deleteMutation.mutate(undefined, { + onError: (error) => openError('에러', error.userMessage), + }) + } + + return ( +
+ {/* 회원 정보 섹션 */} + {memberInfo && ( +
+
+ + + {memberInfo.nickname[0]} + +

{memberInfo.nickname} 님의 의견

+
+ {isMyOpinion && handleDelete()}>내 의견 삭제하기} +
+ )} + {/* 책 평가 섹션 */} + {bookReview && ( +
+ {/* 별점 */} +
+

별점

+
+ + {bookReview.rating.toFixed(1)} +
+
+ + {/* 책 키워드 */} + {bookReview.keywordInfo.filter((k) => k.type === 'BOOK').length > 0 && ( +
+

책 키워드

+
+ {bookReview.keywordInfo + .filter((k) => k.type === 'BOOK') + .map((keyword) => ( + + {keyword.name} + + ))} +
+
+ )} + + {/* 감상 키워드 */} + {bookReview.keywordInfo.filter((k) => k.type === 'IMPRESSION').length > 0 && ( +
+

감상 키워드

+
+ {bookReview.keywordInfo + .filter((k) => k.type === 'IMPRESSION') + .map((keyword) => ( + + {keyword.name} + + ))} +
+
+ )} +
+ )} + + {/* 주제별 의견 섹션 */} + {topicOpinions.length > 0 && ( +
+ {topics.map((topic) => { + const opinion = topicOpinions.find((o) => o.topicId === topic.topicId) + if (!opinion) return null + + return ( +
+
+
+

+ 주제 {topic.confirmOrder}. {topic.title} +

+ {topic.topicTypeLabel} +
+

{topic.description}

+
+ {opinion.content && ( +

{opinion.content}

+ )} +
+ ) + })} +
+ )} +
+ ) +} + +export { PreOpinionDetail } diff --git a/src/features/preOpinions/components/PreOpinionMemberList.tsx b/src/features/preOpinions/components/PreOpinionMemberList.tsx new file mode 100644 index 0000000..26e843d --- /dev/null +++ b/src/features/preOpinions/components/PreOpinionMemberList.tsx @@ -0,0 +1,54 @@ +import { UserChip } from '@/shared/ui/UserChip' + +import { ROLE_TO_AVATAR_VARIANT } from '../preOpinions.constants' +import type { PreOpinionMember } from '../preOpinions.types' + +type PreOpinionMemberListProps = { + members: PreOpinionMember[] + selectedMemberId: number | null + onSelectMember: (memberId: number) => void +} + +/** + * 사전 의견 멤버 리스트 + * + * @description + * 사전 의견을 작성한/작성하지 않은 멤버들을 UserChip 형태로 표시합니다. + * 사전 의견을 제출하지 않은 멤버는 disabled 상태로 표시됩니다. + * + * @example + * ```tsx + * setSelectedMemberId(id)} + * /> + * ``` + */ +function PreOpinionMemberList({ + members, + selectedMemberId, + onSelectMember, +}: PreOpinionMemberListProps) { + return ( +
+ {members.map((member) => ( + { + if (member.isSubmitted) { + onSelectMember(member.memberInfo.userId) + } + }} + /> + ))} +
+ ) +} + +export { PreOpinionMemberList } diff --git a/src/features/preOpinions/components/index.ts b/src/features/preOpinions/components/index.ts new file mode 100644 index 0000000..317611c --- /dev/null +++ b/src/features/preOpinions/components/index.ts @@ -0,0 +1,2 @@ +export * from './PreOpinionDetail' +export * from './PreOpinionMemberList' diff --git a/src/features/preOpinions/hooks/index.ts b/src/features/preOpinions/hooks/index.ts new file mode 100644 index 0000000..305c6e7 --- /dev/null +++ b/src/features/preOpinions/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './preOpinionQueryKeys' +export * from './useDeleteMyPreOpinionAnswer' +export * from './usePreOpinionAnswers' diff --git a/src/features/preOpinions/hooks/preOpinionQueryKeys.ts b/src/features/preOpinions/hooks/preOpinionQueryKeys.ts new file mode 100644 index 0000000..d4d25d6 --- /dev/null +++ b/src/features/preOpinions/hooks/preOpinionQueryKeys.ts @@ -0,0 +1,21 @@ +/** + * @file preOpinionQueryKeys.ts + * @description 사전 의견 관련 Query Key Factory + */ + +import type { GetPreOpinionAnswersParams } from '../preOpinions.types' + +/** + * Query Key Factory + * + * @description + * 사전 의견 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수 + */ +export const preOpinionQueryKeys = { + all: ['preOpinions'] as const, + + // 사전 의견 목록 관련 + answers: () => [...preOpinionQueryKeys.all, 'answers'] as const, + answerList: (params: GetPreOpinionAnswersParams) => + [...preOpinionQueryKeys.answers(), params] as const, +} diff --git a/src/features/preOpinions/hooks/useDeleteMyPreOpinionAnswer.ts b/src/features/preOpinions/hooks/useDeleteMyPreOpinionAnswer.ts new file mode 100644 index 0000000..555376d --- /dev/null +++ b/src/features/preOpinions/hooks/useDeleteMyPreOpinionAnswer.ts @@ -0,0 +1,32 @@ +/** + * @file useDeleteMyPreOpinionAnswer.ts + * @description 내 사전 의견 삭제 뮤테이션 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { deleteMyPreOpinionAnswer } from '../preOpinions.api' +import type { DeleteMyPreOpinionAnswerParams } from '../preOpinions.types' +import { preOpinionQueryKeys } from './preOpinionQueryKeys' + +/** + * 내 사전 의견을 삭제하는 뮤테이션 훅 + * + * @example + * ```tsx + * const deleteMutation = useDeleteMyPreOpinionAnswer({ gatheringId, meetingId }) + * deleteMutation.mutate() + * ``` + */ +export function useDeleteMyPreOpinionAnswer(params: DeleteMyPreOpinionAnswerParams) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => deleteMyPreOpinionAnswer(params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: preOpinionQueryKeys.answers() }) + }, + }) +} diff --git a/src/features/preOpinions/hooks/usePreOpinionAnswers.ts b/src/features/preOpinions/hooks/usePreOpinionAnswers.ts new file mode 100644 index 0000000..2c0860b --- /dev/null +++ b/src/features/preOpinions/hooks/usePreOpinionAnswers.ts @@ -0,0 +1,38 @@ +/** + * @file usePreOpinionAnswers.ts + * @description 사전 의견 목록 조회 훅 + */ + +import { useQuery } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { getPreOpinionAnswers } from '../preOpinions.api' +import type { GetPreOpinionAnswersParams, PreOpinionAnswersData } from '../preOpinions.types' +import { preOpinionQueryKeys } from './preOpinionQueryKeys' + +/** + * 사전 의견 목록 조회 훅 + * + * @description + * TanStack Query를 사용하여 약속의 사전 의견 목록을 조회합니다. + * 멤버별 책 평가, 주제별 의견 등을 포함합니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * + * @returns TanStack Query 결과 객체 + */ +export const usePreOpinionAnswers = (params: GetPreOpinionAnswersParams) => { + const { gatheringId, meetingId } = params + const isValidParams = + !Number.isNaN(gatheringId) && gatheringId > 0 && !Number.isNaN(meetingId) && meetingId > 0 + + return useQuery({ + queryKey: preOpinionQueryKeys.answerList({ gatheringId, meetingId }), + queryFn: () => getPreOpinionAnswers({ gatheringId, meetingId }), + enabled: isValidParams, + gcTime: 10 * 60 * 1000, + }) +} diff --git a/src/features/preOpinions/index.ts b/src/features/preOpinions/index.ts new file mode 100644 index 0000000..e804ad9 --- /dev/null +++ b/src/features/preOpinions/index.ts @@ -0,0 +1,13 @@ +// Components +export * from './components' + +// Hooks +export * from './hooks' + +// API +export * from './preOpinions.api' +export * from './preOpinions.endpoints' +export * from './preOpinions.mock' + +// Types +export * from './preOpinions.types' diff --git a/src/features/preOpinions/preOpinions.api.ts b/src/features/preOpinions/preOpinions.api.ts new file mode 100644 index 0000000..158a104 --- /dev/null +++ b/src/features/preOpinions/preOpinions.api.ts @@ -0,0 +1,59 @@ +/** + * @file preOpinions.api.ts + * @description 사전 의견 API 요청 함수 + */ + +import { api } from '@/api/client' + +import { PRE_OPINIONS_ENDPOINTS } from './preOpinions.endpoints' +import { getMockPreOpinionAnswers } from './preOpinions.mock' +import type { + DeleteMyPreOpinionAnswerParams, + GetPreOpinionAnswersParams, + PreOpinionAnswersData, +} from './preOpinions.types' + +/** 목데이터 사용 여부 플래그 */ +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' + +/** + * 사전 의견 목록 조회 + * + * @description + * 약속의 사전 의견 목록(멤버별 책 평가 + 주제 의견)을 조회합니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * + * @returns 사전 의견 목록 데이터 (topics + members) + */ +export const getPreOpinionAnswers = async ( + params: GetPreOpinionAnswersParams +): Promise => { + const { gatheringId, meetingId } = params + + if (USE_MOCK) { + await new Promise((resolve) => setTimeout(resolve, 500)) + return getMockPreOpinionAnswers() + } + + return api.get(PRE_OPINIONS_ENDPOINTS.ANSWERS(gatheringId, meetingId)) +} + +/** + * 내 사전 의견 삭제 + * + * @description + * 현재 로그인한 사용자의 사전 의견을 삭제합니다. + * + * @param params - 삭제 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + */ +export const deleteMyPreOpinionAnswer = async ( + params: DeleteMyPreOpinionAnswerParams +): Promise => { + const { gatheringId, meetingId } = params + return api.delete(PRE_OPINIONS_ENDPOINTS.DELETE_MY_ANSWER(gatheringId, meetingId)) +} diff --git a/src/features/preOpinions/preOpinions.constants.ts b/src/features/preOpinions/preOpinions.constants.ts new file mode 100644 index 0000000..1671b60 --- /dev/null +++ b/src/features/preOpinions/preOpinions.constants.ts @@ -0,0 +1,8 @@ +import type { MemberRole } from './preOpinions.types' + +/** API MemberRole → Avatar variant 매핑 */ +export const ROLE_TO_AVATAR_VARIANT: Record = { + GATHERING_LEADER: 'leader', + MEETING_LEADER: 'host', + MEMBER: 'member', +} diff --git a/src/features/preOpinions/preOpinions.endpoints.ts b/src/features/preOpinions/preOpinions.endpoints.ts new file mode 100644 index 0000000..d4a748b --- /dev/null +++ b/src/features/preOpinions/preOpinions.endpoints.ts @@ -0,0 +1,11 @@ +import { API_PATHS } from '@/api' + +export const PRE_OPINIONS_ENDPOINTS = { + // 사전 의견 목록 조회 (GET /api/gatherings/{gatheringId}/meetings/{meetingId}/answers) + ANSWERS: (gatheringId: number, meetingId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/answers`, + + // 내 사전 의견 삭제 (DELETE /api/gatherings/{gatheringId}/meetings/{meetingId}/topics/answers/me) + DELETE_MY_ANSWER: (gatheringId: number, meetingId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/answers/me`, +} as const diff --git a/src/features/preOpinions/preOpinions.mock.ts b/src/features/preOpinions/preOpinions.mock.ts new file mode 100644 index 0000000..3b7eef8 --- /dev/null +++ b/src/features/preOpinions/preOpinions.mock.ts @@ -0,0 +1,102 @@ +/** + * @file preOpinions.mock.ts + * @description 사전 의견 API 목데이터 + */ + +import type { PreOpinionAnswersData } from './preOpinions.types' + +/** + * 사전 의견 목록 목데이터 + */ +const mockPreOpinionAnswers: PreOpinionAnswersData = { + topics: [ + { + topicId: 1, + title: '책의 주요 메시지', + description: '이 책에서 전달하고자 하는 핵심 메시지는 무엇인가요?', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + confirmOrder: 1, + }, + { + topicId: 2, + title: '가장 인상 깊었던 장면', + description: '책을 읽으며 가장 기억에 남았던 장면은 무엇인가요?', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + confirmOrder: 2, + }, + ], + members: [ + { + memberInfo: { + userId: 1, + nickname: '독서왕', + profileImage: 'https://picsum.photos/seed/user1/100/100', + role: 'GATHERING_LEADER', + }, + isSubmitted: true, + bookReview: { + rating: 4.5, + keywordInfo: [ + { id: 3, name: '성장', type: 'BOOK' }, + { id: 7, name: '여운이 남는', type: 'IMPRESSION' }, + ], + }, + topicOpinions: [ + { + topicId: 1, + content: '이 책의 핵심 메시지는 자기 성찰이라고 생각합니다.', + }, + { + topicId: 2, + content: '주인공이 선택의 기로에 서는 장면이 가장 인상 깊었습니다.', + }, + ], + }, + { + memberInfo: { + userId: 10, + nickname: '밤독서', + profileImage: 'https://picsum.photos/seed/user3/100/100', + role: 'MEETING_LEADER', + }, + isSubmitted: true, + bookReview: { + rating: 3.0, + keywordInfo: [ + { id: 5, name: '관계', type: 'BOOK' }, + { id: 7, name: '여운이 남는', type: 'IMPRESSION' }, + ], + }, + topicOpinions: [ + { + topicId: 1, + content: null, + }, + { + topicId: 2, + content: '잔잔하지만 오래 남는 장면들이 많았습니다.', + }, + ], + }, + { + memberInfo: { + userId: 2, + nickname: '페이지러버', + profileImage: 'https://picsum.photos/seed/user2/100/100', + role: 'MEMBER', + }, + isSubmitted: false, + bookReview: null, + topicOpinions: [], + }, + ], +} + +/** + * 사전 의견 목록 목데이터 반환 함수 + */ +export const getMockPreOpinionAnswers = (): PreOpinionAnswersData => { + return mockPreOpinionAnswers +} diff --git a/src/features/preOpinions/preOpinions.types.ts b/src/features/preOpinions/preOpinions.types.ts new file mode 100644 index 0000000..8e5fbd2 --- /dev/null +++ b/src/features/preOpinions/preOpinions.types.ts @@ -0,0 +1,73 @@ +/** + * @file preOpinions.types.ts + * @description 사전 의견 API 관련 타입 정의 + */ + +import type { KeywordType } from '@/features/keywords/keywords.types' +import type { TopicType } from '@/features/topics/topics.types' + +/** 멤버 역할 타입 */ +export type MemberRole = 'GATHERING_LEADER' | 'MEETING_LEADER' | 'MEMBER' + +/** 사전 의견 키워드 */ +export type PreOpinionKeyword = { + id: number + name: string + type: KeywordType +} + +/** 책 평가 정보 */ +export type BookReviewSummary = { + rating: number + keywordInfo: PreOpinionKeyword[] +} + +/** 멤버 정보 */ +export type PreOpinionMemberInfo = { + userId: number + nickname: string + profileImage: string + role: MemberRole +} + +/** 주제별 의견 */ +export type TopicOpinion = { + topicId: number + content: string | null +} + +/** 확정된 주제 항목 */ +export type PreOpinionTopic = { + topicId: number + title: string + description: string + topicType: TopicType + topicTypeLabel: string + confirmOrder: number +} + +/** 멤버별 사전 의견 */ +export type PreOpinionMember = { + memberInfo: PreOpinionMemberInfo + isSubmitted: boolean + bookReview: BookReviewSummary | null + topicOpinions: TopicOpinion[] +} + +/** 사전 의견 목록 조회 응답 데이터 */ +export type PreOpinionAnswersData = { + topics: PreOpinionTopic[] + members: PreOpinionMember[] +} + +/** 사전 의견 목록 조회 파라미터 */ +export type GetPreOpinionAnswersParams = { + gatheringId: number + meetingId: number +} + +/** 내 사전 의견 삭제 파라미터 */ +export type DeleteMyPreOpinionAnswerParams = { + gatheringId: number + meetingId: number +} diff --git a/src/pages/PreOpinions/PreOpinionListPage.tsx b/src/pages/PreOpinions/PreOpinionListPage.tsx new file mode 100644 index 0000000..9095786 --- /dev/null +++ b/src/pages/PreOpinions/PreOpinionListPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' + +import { + PreOpinionDetail, + PreOpinionMemberList, + usePreOpinionAnswers, +} from '@/features/preOpinions' +import SubPageHeader from '@/shared/components/SubPageHeader' +import { useGlobalModalStore } from '@/store' + +export default function PreOpinionListPage() { + const { gatheringId, meetingId } = useParams<{ gatheringId: string; meetingId: string }>() + const navigate = useNavigate() + const openError = useGlobalModalStore((state) => state.openError) + const sentinelRef = useRef(null) + const [isStuck, setIsStuck] = useState(false) + const [selectedMemberId, setSelectedMemberId] = useState(null) + + const { data, isLoading, error } = usePreOpinionAnswers({ + gatheringId: Number(gatheringId), + meetingId: Number(meetingId), + }) + + useEffect(() => { + if (error) { + openError('조회 불가', error.userMessage, () => navigate(-1)) + } + }, [error, openError, navigate]) + + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + + const observer = new IntersectionObserver( + ([entry]) => { + setIsStuck(!entry.isIntersecting) + }, + { threshold: 0 } + ) + + observer.observe(sentinel) + return () => observer.disconnect() + }, [isLoading]) + + // 선택된 멤버 ID: 유저가 선택한 값이 있으면 사용, 없으면 첫 번째 제출한 멤버를 기본값으로 + const activeMemberId = useMemo(() => { + if (selectedMemberId !== null) return selectedMemberId + const firstSubmitted = data?.members.find((m) => m.isSubmitted) + return firstSubmitted?.memberInfo.userId ?? null + }, [selectedMemberId, data]) + + const selectedMember = data?.members.find((m) => m.memberInfo.userId === activeMemberId) + + if (isLoading) return
로딩중...
+ + return ( + <> +
+ +

사전 의견

+
+ {/* 왼쪽: 멤버 리스트 */} + {data && ( + + )} + + {/* 오른쪽: 선택된 멤버의 의견 상세 */} + {selectedMember && data ? ( + + ) : ( +
+

멤버를 선택해주세요

+
+ )} +
+ + ) +} diff --git a/src/pages/PreOpinions/index.ts b/src/pages/PreOpinions/index.ts new file mode 100644 index 0000000..53bc8ef --- /dev/null +++ b/src/pages/PreOpinions/index.ts @@ -0,0 +1 @@ +export { default as PreOpinionListPage } from './PreOpinionListPage' diff --git a/src/pages/index.ts b/src/pages/index.ts index a3942e4..7b02f30 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -4,4 +4,5 @@ export * from './ComponentGuide' export * from './Gatherings' export * from './Home' export * from './Meetings' +export * from './PreOpinions' export * from './Records' diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8d08899..b4a4293 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -15,6 +15,7 @@ import { MeetingDetailPage, MeetingSettingPage, OnboardingPage, + PreOpinionListPage, RecordListPage, } from '@/pages' import { ROUTES } from '@/shared/constants' @@ -100,6 +101,10 @@ export const router = createBrowserRouter([ path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId`, element: , }, + { + path: ROUTES.PRE_OPINIONS(':gatheringId', ':meetingId'), + element: , + }, { path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/setting`, element: , diff --git a/src/shared/components/SubPageHeader.tsx b/src/shared/components/SubPageHeader.tsx index 369a015..3f292b2 100644 --- a/src/shared/components/SubPageHeader.tsx +++ b/src/shared/components/SubPageHeader.tsx @@ -1,6 +1,7 @@ import { ChevronLeft } from 'lucide-react' import { useNavigate } from 'react-router-dom' +import { cn } from '@/shared/lib/utils' import { TextButton } from '@/shared/ui/TextButton' export interface SubPageHeaderProps { @@ -8,6 +9,8 @@ export interface SubPageHeaderProps { label?: string /** 이동할 경로. 지정하지 않으면 navigate(-1)로 뒤로가기 */ to?: string + /** 외부에서 전달하는 추가 클래스 */ + className?: string } /** @@ -24,7 +27,7 @@ export interface SubPageHeaderProps { * * ``` */ -export default function SubPageHeader({ label = '뒤로가기', to }: SubPageHeaderProps) { +export default function SubPageHeader({ label = '뒤로가기', to, className }: SubPageHeaderProps) { const navigate = useNavigate() const handleClick = () => { @@ -38,7 +41,7 @@ export default function SubPageHeader({ label = '뒤로가기', to }: SubPageHea return (