Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/features/preOpinions/components/PreOpinionDetail.tsx
Original file line number Diff line number Diff line change
@@ -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
* <PreOpinionDetail member={selectedMember} topics={topics} />
* ```
*/
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 (
<div className="flex flex-col gap-xlarge flex-1 mb-[100px]">
{/* 회원 정보 섹션 */}
{memberInfo && (
<div className="flex justify-between items-center">
<div className="flex gap-base items-center">
<Avatar variant={ROLE_TO_AVATAR_VARIANT[memberInfo.role]}>
<AvatarImage src={memberInfo.profileImage} alt={memberInfo.nickname} />
<AvatarFallback>{memberInfo.nickname[0]}</AvatarFallback>
</Avatar>
<h4 className="typo-heading3 text-black">{memberInfo.nickname} 님의 의견</h4>
</div>
{isMyOpinion && <TextButton onClick={() => handleDelete()}>내 의견 삭제하기</TextButton>}
</div>
)}
{/* 책 평가 섹션 */}
{bookReview && (
<section className="flex flex-col gap-small">
{/* 별점 */}
<div>
<p className="typo-body4 text-grey-600 mb-tiny">별점</p>
<div className="flex gap-small items-center">
<StarRate rating={bookReview.rating} size={36} />
<span className="typo-subtitle5 text-black">{bookReview.rating.toFixed(1)}</span>
</div>
</div>

{/* 책 키워드 */}
{bookReview.keywordInfo.filter((k) => k.type === 'BOOK').length > 0 && (
<div>
<p className="typo-body4 text-grey-600 mb-tiny">책 키워드</p>
<div className="flex gap-tiny flex-wrap">
{bookReview.keywordInfo
.filter((k) => k.type === 'BOOK')
.map((keyword) => (
<Chip key={keyword.id} variant="success">
{keyword.name}
</Chip>
))}
</div>
</div>
)}

{/* 감상 키워드 */}
{bookReview.keywordInfo.filter((k) => k.type === 'IMPRESSION').length > 0 && (
<div>
<p className="typo-body4 text-grey-600 mb-tiny">감상 키워드</p>
<div className="flex gap-tiny flex-wrap">
{bookReview.keywordInfo
.filter((k) => k.type === 'IMPRESSION')
.map((keyword) => (
<Chip key={keyword.id} className="bg-blue-100 text-blue-200">
{keyword.name}
</Chip>
))}
</div>
</div>
)}
</section>
)}

{/* 주제별 의견 섹션 */}
{topicOpinions.length > 0 && (
<section className="flex flex-col gap-[32px]">
{topics.map((topic) => {
const opinion = topicOpinions.find((o) => o.topicId === topic.topicId)
if (!opinion) return null

return (
<div key={topic.topicId} className="flex flex-col">
<div className="flex flex-col gap-small">
<div className="flex gap-xsmall items-center">
<h4 className="typo-subtitle3 text-black">
주제 {topic.confirmOrder}. {topic.title}
</h4>
<Badge>{topic.topicTypeLabel}</Badge>
</div>
<p className="typo-body4 text-grey-600">{topic.description}</p>
</div>
{opinion.content && (
<p className="typo-body1 text-black mt-base">{opinion.content}</p>
)}
</div>
)
})}
</section>
)}
</div>
)
}

export { PreOpinionDetail }
54 changes: 54 additions & 0 deletions src/features/preOpinions/components/PreOpinionMemberList.tsx
Original file line number Diff line number Diff line change
@@ -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
* <PreOpinionMemberList
* members={members}
* selectedMemberId={1}
* onSelectMember={(id) => setSelectedMemberId(id)}
* />
* ```
*/
function PreOpinionMemberList({
members,
selectedMemberId,
onSelectMember,
}: PreOpinionMemberListProps) {
return (
<div className="flex flex-col gap-xsmall">
{members.map((member) => (
<UserChip
key={member.memberInfo.userId}
name={member.memberInfo.nickname}
imageUrl={member.memberInfo.profileImage}
variant={ROLE_TO_AVATAR_VARIANT[member.memberInfo.role]}
selected={selectedMemberId === member.memberInfo.userId}
disabled={!member.isSubmitted}
onClick={() => {
if (member.isSubmitted) {
onSelectMember(member.memberInfo.userId)
}
}}
/>
))}
</div>
)
}

export { PreOpinionMemberList }
2 changes: 2 additions & 0 deletions src/features/preOpinions/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './PreOpinionDetail'
export * from './PreOpinionMemberList'
3 changes: 3 additions & 0 deletions src/features/preOpinions/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './preOpinionQueryKeys'
export * from './useDeleteMyPreOpinionAnswer'
export * from './usePreOpinionAnswers'
21 changes: 21 additions & 0 deletions src/features/preOpinions/hooks/preOpinionQueryKeys.ts
Original file line number Diff line number Diff line change
@@ -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,
}
32 changes: 32 additions & 0 deletions src/features/preOpinions/hooks/useDeleteMyPreOpinionAnswer.ts
Original file line number Diff line number Diff line change
@@ -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<void, ApiError>({
mutationFn: () => deleteMyPreOpinionAnswer(params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: preOpinionQueryKeys.answers() })
},
})
}
38 changes: 38 additions & 0 deletions src/features/preOpinions/hooks/usePreOpinionAnswers.ts
Original file line number Diff line number Diff line change
@@ -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<PreOpinionAnswersData, ApiError>({
queryKey: preOpinionQueryKeys.answerList({ gatheringId, meetingId }),
queryFn: () => getPreOpinionAnswers({ gatheringId, meetingId }),
enabled: isValidParams,
gcTime: 10 * 60 * 1000,
})
}
13 changes: 13 additions & 0 deletions src/features/preOpinions/index.ts
Original file line number Diff line number Diff line change
@@ -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'
59 changes: 59 additions & 0 deletions src/features/preOpinions/preOpinions.api.ts
Original file line number Diff line number Diff line change
@@ -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<PreOpinionAnswersData> => {
const { gatheringId, meetingId } = params

if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 500))
return getMockPreOpinionAnswers()
}

return api.get<PreOpinionAnswersData>(PRE_OPINIONS_ENDPOINTS.ANSWERS(gatheringId, meetingId))
}

/**
* 내 사전 의견 삭제
*
* @description
* 현재 로그인한 사용자의 사전 의견을 삭제합니다.
*
* @param params - 삭제 파라미터
* @param params.gatheringId - 모임 식별자
* @param params.meetingId - 약속 식별자
*/
export const deleteMyPreOpinionAnswer = async (
params: DeleteMyPreOpinionAnswerParams
): Promise<void> => {
const { gatheringId, meetingId } = params
return api.delete(PRE_OPINIONS_ENDPOINTS.DELETE_MY_ANSWER(gatheringId, meetingId))
}
8 changes: 8 additions & 0 deletions src/features/preOpinions/preOpinions.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { MemberRole } from './preOpinions.types'

/** API MemberRole → Avatar variant 매핑 */
export const ROLE_TO_AVATAR_VARIANT: Record<MemberRole, 'leader' | 'host' | 'member'> = {
GATHERING_LEADER: 'leader',
MEETING_LEADER: 'host',
MEMBER: 'member',
}
11 changes: 11 additions & 0 deletions src/features/preOpinions/preOpinions.endpoints.ts
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading