diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2e2b109..90be25e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,6 +32,7 @@ jobs: env: VITE_API_URL: ${{ secrets.VITE_API_URL }} VITE_APP_URL: ${{ secrets.VITE_APP_URL }} + VITE_KAKAO_MAP_KEY: ${{ secrets.VITE_KAKAO_MAP_KEY }} - name: Deploy to EC2 uses: appleboy/scp-action@v0.1.7 diff --git a/src/features/topics/components/ConfirmedTopicList.tsx b/src/features/topics/components/ConfirmedTopicList.tsx new file mode 100644 index 0000000..72504d1 --- /dev/null +++ b/src/features/topics/components/ConfirmedTopicList.tsx @@ -0,0 +1,56 @@ +import { useInfiniteScroll } from '@/shared/hooks/useInfiniteScroll' + +import type { ConfirmedTopicItem } from '../topics.types' +import EmptyTopicList from './EmptyTopicList' +import TopicCard from './TopicCard' +import TopicListSkeleton from './TopicListSkeleton' + +type ConfirmedTopicListProps = { + topics: ConfirmedTopicItem[] + hasNextPage: boolean + isFetchingNextPage: boolean + onLoadMore: () => void + pageSize?: number +} + +export default function ConfirmedTopicList({ + topics, + hasNextPage, + isFetchingNextPage, + onLoadMore, + pageSize = 5, +}: ConfirmedTopicListProps) { + // 무한 스크롤: IntersectionObserver로 다음 페이지 로드 + const observerRef = useInfiniteScroll(onLoadMore, { + hasNextPage, + isFetchingNextPage, + }) + + if (topics.length === 0) { + return + } + + return ( +
+ + {/* 무한 스크롤 로딩 상태 */} + {isFetchingNextPage && } + + {/* 무한 스크롤 트리거 */} + {hasNextPage && !isFetchingNextPage &&
} +
+ ) +} diff --git a/src/features/topics/components/DefaultTopicCard.tsx b/src/features/topics/components/DefaultTopicCard.tsx new file mode 100644 index 0000000..7b26dee --- /dev/null +++ b/src/features/topics/components/DefaultTopicCard.tsx @@ -0,0 +1,18 @@ +import { Badge, Card } from '@/shared/ui' + +export default function DefaultTopicCard() { + return ( + +
+

기본주제

+ + 자유형 + +
+

자유롭게 이야기해봅시다.

+
+

제안 : 도크도크

+
+
+ ) +} diff --git a/src/features/topics/components/EmptyTopicList.tsx b/src/features/topics/components/EmptyTopicList.tsx new file mode 100644 index 0000000..9600457 --- /dev/null +++ b/src/features/topics/components/EmptyTopicList.tsx @@ -0,0 +1,9 @@ +import { Card } from '@/shared/ui' + +export default function EmptyTopicList() { + return ( + +

아직 확정된 주제가 없어요

+
+ ) +} diff --git a/src/features/topics/components/ProposedTopicList.tsx b/src/features/topics/components/ProposedTopicList.tsx new file mode 100644 index 0000000..ae7112f --- /dev/null +++ b/src/features/topics/components/ProposedTopicList.tsx @@ -0,0 +1,67 @@ +import { useInfiniteScroll } from '@/shared/hooks/useInfiniteScroll' + +import type { ProposedTopicItem } from '../topics.types' +import DefaultTopicCard from './DefaultTopicCard' +import TopicCard from './TopicCard' +import TopicListSkeleton from './TopicListSkeleton' + +type ProposedTopicListProps = { + topics: ProposedTopicItem[] + hasNextPage: boolean + isFetchingNextPage: boolean + onLoadMore: () => void + pageSize?: number + gatheringId: number + meetingId: number +} + +export default function ProposedTopicList({ + topics, + hasNextPage, + isFetchingNextPage, + onLoadMore, + pageSize = 5, + gatheringId, + meetingId, +}: ProposedTopicListProps) { + // 무한 스크롤: IntersectionObserver로 다음 페이지 로드 + const observerRef = useInfiniteScroll(onLoadMore, { + hasNextPage, + isFetchingNextPage, + }) + + return ( +
+ {/* 기본 주제는 항상 표시 */} + + + {/* 제안된 주제 목록 */} + {topics.length > 0 && ( +
    + {topics.map((topic) => ( +
  • + +
  • + ))} +
+ )} + + {/* 무한 스크롤 로딩 상태 */} + {isFetchingNextPage && } + + {/* 무한 스크롤 트리거 */} + {hasNextPage && !isFetchingNextPage &&
} +
+ ) +} diff --git a/src/features/topics/components/TopicCard.tsx b/src/features/topics/components/TopicCard.tsx new file mode 100644 index 0000000..0e2cd0d --- /dev/null +++ b/src/features/topics/components/TopicCard.tsx @@ -0,0 +1,111 @@ +import { Badge, Card, LikeButton, TextButton } from '@/shared/ui' +import { useGlobalModalStore } from '@/store' + +import { useDeleteTopic, useLikeTopic } from '../hooks' + +type TopicCardProps = { + title: string + topicTypeLabel: string + description: string + createdByNickname: string + likeCount: number + isLiked?: boolean + isLikeDisabled?: boolean + canDelete?: boolean + gatheringId?: number + meetingId?: number + topicId?: number +} + +export default function TopicCard({ + title, + topicTypeLabel, + description, + createdByNickname, + likeCount, + isLiked = false, + isLikeDisabled = false, + canDelete = false, + gatheringId, + meetingId, + topicId, +}: TopicCardProps) { + const { openConfirm, openAlert, openError } = useGlobalModalStore() + const deleteMutation = useDeleteTopic() + const likeMutation = useLikeTopic() + + const handleDelete = async () => { + if (!gatheringId || !meetingId || !topicId) { + openError('삭제 실패', '주제 삭제에 필요한 정보가 없습니다.') + return + } + + const confirmed = await openConfirm('주제 삭제', '정말 이 주제를 삭제하시겠습니까?', { + confirmText: '삭제', + variant: 'danger', + }) + + if (!confirmed) { + return + } + + deleteMutation.mutate( + { gatheringId, meetingId, topicId }, + // TODO: 토스트 컴포넌트로 교체 예정 + { + onSuccess: () => { + openAlert('삭제 완료', '주제가 삭제되었습니다.') + }, + onError: (error) => { + openError('삭제 실패', error.userMessage) + }, + } + ) + } + + const handleLike = () => { + if (!gatheringId || !meetingId || !topicId) { + return + } + + if (likeMutation.isPending) return + + likeMutation.mutate( + { gatheringId, meetingId, topicId }, + { + onError: (error) => { + // TODO: 토스트 컴포넌트로 교체 예정 + alert(`좋아요 처리 중 오류가 발생했습니다: ${error.userMessage}`) + }, + } + ) + } + return ( + +
+
+

{title}

+ + {topicTypeLabel} + +
+ {canDelete && ( + + 삭제하기 + + )} +
+

{description}

+
+

제안 : {createdByNickname}

+ +
+
+ ) +} diff --git a/src/features/topics/components/TopicHeader.tsx b/src/features/topics/components/TopicHeader.tsx new file mode 100644 index 0000000..329d88d --- /dev/null +++ b/src/features/topics/components/TopicHeader.tsx @@ -0,0 +1,103 @@ +import { format } from 'date-fns' +import { Check } from 'lucide-react' + +import { Button } from '@/shared/ui' + +type ProposedHeaderProps = { + activeTab: 'PROPOSED' + actions: { canConfirm: boolean; canSuggest: boolean } + confirmedTopic: boolean + confirmedTopicDate: string | null +} + +type ConfirmedHeaderProps = { + activeTab: 'CONFIRMED' + actions: { canViewPreOpinions: boolean; canWritePreOpinions: boolean } + confirmedTopic: boolean + confirmedTopicDate: string | null +} + +type TopicHeaderProps = ProposedHeaderProps | ConfirmedHeaderProps + +export default function TopicHeader(props: TopicHeaderProps) { + return ( + <> + {/* 제안탭 */} + {props.activeTab === 'PROPOSED' && ( +
+
+ {props.confirmedTopic && props.confirmedTopicDate ? ( + // 주제 확정됨 + <> +

+ 주제 제안이 마감되었어요. 확정된 주제를 확인해보세요! +

+

+ {format(props.confirmedTopicDate, 'yyyy.MM.dd HH:mm')} 마감 +

+ + ) : ( + // 주제 제안 중 + <> +

+ 약속에서 나누고 싶은 주제를 제안해보세요 +

+

+ 주제를 미리 정하면 우리 모임이 훨씬 풍성하고 즐거워질 거예요 +

+ + )} +
+ +
+ {props.actions.canConfirm && ( + + )} + + {props.actions.canSuggest && } +
+
+ )} + + {/* 제안탭 */} + + {/* 확정탭 */} + {props.activeTab === 'CONFIRMED' && ( +
+
+ {props.confirmedTopic ? ( + // 주제 확정됨 + <> +

+ 주제가 확정되었어요! +

+

+ 나의 생각을 미리 정리해서 공유하면 다른 멤버들의 의견도 바로 확인할 수 있어요 +

+ + ) : ( + // 주제 제안 중 + <> +

약속장이 주제를 선정하고 있어요

+

+ 주제가 확정되면 사전 의견을 남길 수 있는 창이 열려요 +

+ + )} +
+ +
+ + + +
+
+ )} + {/* 확정탭 */} + + ) +} diff --git a/src/features/topics/components/TopicListSkeleton.tsx b/src/features/topics/components/TopicListSkeleton.tsx new file mode 100644 index 0000000..a27c2d9 --- /dev/null +++ b/src/features/topics/components/TopicListSkeleton.tsx @@ -0,0 +1,27 @@ +import { Card } from '@/shared/ui' + +type TopicListSkeletonProps = { + count?: number +} + +export default function TopicListSkeleton({ count = 5 }: TopicListSkeletonProps) { + return ( + <> + {[...Array(count).keys()].map((i) => ( + +
+
+
+
+
+
+
+
+
+
+
+ + ))} + + ) +} diff --git a/src/features/topics/components/index.ts b/src/features/topics/components/index.ts new file mode 100644 index 0000000..5825bdb --- /dev/null +++ b/src/features/topics/components/index.ts @@ -0,0 +1,7 @@ +export { default as ConfirmedTopicList } from './ConfirmedTopicList' +export { default as DefaultTopicCard } from './DefaultTopicCard' +export { default as EmptyTopicList } from './EmptyTopicList' +export { default as ProposedTopicList } from './ProposedTopicList' +export { default as TopicCard } from './TopicCard' +export { default as TopicHeader } from './TopicHeader' +export { default as TopicListSkeleton } from './TopicListSkeleton' diff --git a/src/features/topics/hooks/index.ts b/src/features/topics/hooks/index.ts new file mode 100644 index 0000000..78bb37a --- /dev/null +++ b/src/features/topics/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './topicQueryKeys' +export * from './useConfirmedTopics' +export * from './useDeleteTopic' +export * from './useLikeTopic' +export * from './useProposedTopics' diff --git a/src/features/topics/hooks/topicQueryKeys.ts b/src/features/topics/hooks/topicQueryKeys.ts new file mode 100644 index 0000000..9693991 --- /dev/null +++ b/src/features/topics/hooks/topicQueryKeys.ts @@ -0,0 +1,28 @@ +/** + * @file topicQueryKeys.ts + * @description 주제 관련 Query Key Factory + */ + +import type { GetConfirmedTopicsParams, GetProposedTopicsParams } from '../topics.types' + +/** + * Query Key Factory + * + * @description + * 주제 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수 + */ +export const topicQueryKeys = { + all: ['topics'] as const, + + // 제안된 주제 관련 + proposed: () => [...topicQueryKeys.all, 'proposed'] as const, + proposedLists: () => [...topicQueryKeys.proposed(), 'list'] as const, + proposedList: (params: GetProposedTopicsParams) => + [...topicQueryKeys.proposedLists(), params] as const, + + // 확정된 주제 관련 + confirmed: () => [...topicQueryKeys.all, 'confirmed'] as const, + confirmedLists: () => [...topicQueryKeys.confirmed(), 'list'] as const, + confirmedList: (params: GetConfirmedTopicsParams) => + [...topicQueryKeys.confirmedLists(), params] as const, +} diff --git a/src/features/topics/hooks/useConfirmedTopics.ts b/src/features/topics/hooks/useConfirmedTopics.ts new file mode 100644 index 0000000..7b3ad8a --- /dev/null +++ b/src/features/topics/hooks/useConfirmedTopics.ts @@ -0,0 +1,67 @@ +/** + * @file useConfirmedTopics.ts + * @description 확정된 주제 조회 훅 (무한 스크롤) + */ + +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { getConfirmedTopics } from '../topics.api' +import type { + ConfirmedTopicCursor, + GetConfirmedTopicsParams, + GetConfirmedTopicsResponse, +} from '../topics.types' +import { topicQueryKeys } from './topicQueryKeys' + +/** + * 확정된 주제 조회 훅 (무한 스크롤) + * + * @description + * TanStack Query의 useInfiniteQuery를 사용하여 약속의 확정된 주제 목록을 무한 스크롤로 조회합니다. + * 확정 순서 기준 오름차순으로 정렬되며, 커서 기반 페이지네이션을 지원합니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.pageSize - 페이지 크기 (기본값: 5) + * + * @returns TanStack Query 무한 스크롤 결과 객체 + */ +export const useConfirmedTopics = ( + params: Omit +) => { + const { gatheringId, meetingId, pageSize } = params + const isValidParams = + !Number.isNaN(gatheringId) && gatheringId > 0 && !Number.isNaN(meetingId) && meetingId > 0 + + return useInfiniteQuery< + GetConfirmedTopicsResponse, + ApiError, + InfiniteData, + ReturnType, + ConfirmedTopicCursor | null + >({ + queryKey: topicQueryKeys.confirmedList({ gatheringId, meetingId, pageSize }), + queryFn: ({ pageParam }: { pageParam: ConfirmedTopicCursor | null }) => + getConfirmedTopics({ + gatheringId, + meetingId, + pageSize, + // 첫 페이지: 커서 없이 요청 (pageParam = null), 다음 페이지: nextCursor 사용 + cursorConfirmOrder: pageParam?.confirmOrder, + cursorTopicId: pageParam?.topicId, + }), + // gatheringId와 meetingId가 유효할 때만 쿼리 실행 + enabled: isValidParams, + // 초기 페이지 파라미터 (첫 페이지는 커서 파라미터 없이 요청) + initialPageParam: null, + // 다음 페이지 파라미터 가져오기 + getNextPageParam: (lastPage) => { + return lastPage.hasNext ? lastPage.nextCursor : null + }, + // 캐시 데이터 10분간 유지 + gcTime: 10 * 60 * 1000, + }) +} diff --git a/src/features/topics/hooks/useDeleteTopic.ts b/src/features/topics/hooks/useDeleteTopic.ts new file mode 100644 index 0000000..6043df0 --- /dev/null +++ b/src/features/topics/hooks/useDeleteTopic.ts @@ -0,0 +1,52 @@ +/** + * @file useDeleteTopic.ts + * @description 주제 삭제 mutation 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError } from '@/api/errors' + +import { deleteTopic } from '../topics.api' +import type { DeleteTopicParams } from '../topics.types' +import { topicQueryKeys } from './topicQueryKeys' + +/** + * 주제 삭제 mutation 훅 + * + * @description + * 주제를 삭제하고 관련 쿼리 캐시를 무효화합니다. + * - 제안된 주제 리스트 캐시 무효화 + * + * @example + * ```tsx + * const deleteMutation = useDeleteTopic() + * deleteMutation.mutate( + * { gatheringId: 1, meetingId: 2, topicId: 3 }, + * { + * onSuccess: () => { + * console.log('주제가 삭제되었습니다.') + * }, + * onError: (error) => { + * console.error('삭제 실패:', error.userMessage) + * }, + * } + * ) + * ``` + */ +export const useDeleteTopic = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (params: DeleteTopicParams) => deleteTopic(params), + onSuccess: (_, variables) => { + // 제안된 주제 무효화 + queryClient.invalidateQueries({ + queryKey: topicQueryKeys.proposedList({ + gatheringId: variables.gatheringId, + meetingId: variables.meetingId, + }), + }) + }, + }) +} diff --git a/src/features/topics/hooks/useLikeTopic.ts b/src/features/topics/hooks/useLikeTopic.ts new file mode 100644 index 0000000..27e9801 --- /dev/null +++ b/src/features/topics/hooks/useLikeTopic.ts @@ -0,0 +1,111 @@ +/** + * @file useLikeTopic.ts + * @description 주제 좋아요 토글 mutation 훅 (낙관적 업데이트) + */ + +import type { InfiniteData } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError } from '@/api/errors' + +import { likeTopicToggle } from '../topics.api' +import type { GetProposedTopicsResponse, LikeTopicParams, LikeTopicResponse } from '../topics.types' +import { topicQueryKeys } from './topicQueryKeys' + +/** + * 주제 좋아요 토글 mutation 훅 (낙관적 업데이트) + * + * @description + * 주제 좋아요를 토글하고 낙관적 업데이트를 적용합니다. + * - onMutate: 즉시 UI 업데이트 (isLiked 토글, likeCount 증감) + * - onError: 실패 시 이전 상태로 롤백 + * - onSettled: 서버 데이터와 동기화 + * + * @example + * ```tsx + * const likeMutation = useLikeTopic() + * likeMutation.mutate({ + * gatheringId: 1, + * meetingId: 2, + * topicId: 3, + * }) + * ``` + */ + +type LikeTopicContext = { + previousQueries: Array<{ + queryKey: readonly unknown[] + data: InfiniteData + }> +} + +export const useLikeTopic = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (params: LikeTopicParams) => likeTopicToggle(params), + + // 낙관적 업데이트: 즉시 UI 업데이트 + onMutate: async (variables) => { + const { topicId } = variables + + // Partial matching을 위한 base 쿼리 키 (pageSize와 무관하게 모든 쿼리 매칭) + const baseQueryKey = topicQueryKeys.proposedLists() + + // 진행 중인 모든 관련 쿼리 취소 (낙관적 업데이트 덮어쓰기 방지) + await queryClient.cancelQueries({ queryKey: baseQueryKey }) + + // 모든 매칭되는 쿼리의 이전 데이터 스냅샷 저장 + const previousQueries = queryClient + .getQueriesData>({ queryKey: baseQueryKey }) + .map(([queryKey, data]) => ({ queryKey, data: data! })) + .filter((query) => query.data !== undefined) + + // 모든 관련 캐시에 낙관적 업데이트 적용 + previousQueries.forEach(({ queryKey }) => { + queryClient.setQueryData>(queryKey, (old) => { + if (!old) return old + + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((topic) => { + if (topic.topicId === topicId) { + const newIsLiked = !topic.isLiked + return { + ...topic, + isLiked: newIsLiked, + likeCount: newIsLiked ? topic.likeCount + 1 : topic.likeCount - 1, + } + } + return topic + }), + })), + } + }) + }) + + // 롤백을 위한 이전 데이터 반환 + return { previousQueries } + }, + + // 에러 발생 시 롤백 + onError: (_error, _variables, context) => { + if (context?.previousQueries) { + // 저장된 모든 쿼리를 이전 상태로 복원 + context.previousQueries.forEach(({ queryKey, data }) => { + queryClient.setQueryData(queryKey, data) + }) + } + }, + + // 성공/실패 여부와 상관없이 서버 데이터와 동기화 + onSettled: () => { + // 모든 제안된 주제 목록 쿼리 무효화 + queryClient.invalidateQueries({ + queryKey: topicQueryKeys.proposedLists(), + }) + }, + }) +} diff --git a/src/features/topics/hooks/useProposedTopics.ts b/src/features/topics/hooks/useProposedTopics.ts new file mode 100644 index 0000000..60a8d2a --- /dev/null +++ b/src/features/topics/hooks/useProposedTopics.ts @@ -0,0 +1,67 @@ +/** + * @file useProposedTopics.ts + * @description 제안된 주제 조회 훅 (무한 스크롤) + */ + +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { getProposedTopics } from '../topics.api' +import type { + GetProposedTopicsParams, + GetProposedTopicsResponse, + ProposedTopicCursor, +} from '../topics.types' +import { topicQueryKeys } from './topicQueryKeys' + +/** + * 제안된 주제 조회 훅 (무한 스크롤) + * + * @description + * TanStack Query의 useInfiniteQuery를 사용하여 약속에 제안된 주제 목록을 무한 스크롤로 조회합니다. + * 좋아요 수 기준 내림차순으로 정렬되며, 커서 기반 페이지네이션을 지원합니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.pageSize - 페이지 크기 (기본값: 5) + * + * @returns TanStack Query 무한 스크롤 결과 객체 + */ +export const useProposedTopics = ( + params: Omit +) => { + const { gatheringId, meetingId, pageSize } = params + const isValidParams = + !Number.isNaN(gatheringId) && gatheringId > 0 && !Number.isNaN(meetingId) && meetingId > 0 + + return useInfiniteQuery< + GetProposedTopicsResponse, + ApiError, + InfiniteData, + ReturnType, + ProposedTopicCursor | null + >({ + queryKey: topicQueryKeys.proposedList({ gatheringId, meetingId, pageSize }), + queryFn: ({ pageParam }: { pageParam: ProposedTopicCursor | null }) => + getProposedTopics({ + gatheringId, + meetingId, + pageSize, + // 첫 페이지: 커서 없이 요청 (pageParam = null), 다음 페이지: nextCursor 사용 + cursorLikeCount: pageParam?.likeCount, + cursorTopicId: pageParam?.topicId, + }), + // gatheringId와 meetingId가 유효할 때만 쿼리 실행 + enabled: isValidParams, + // 초기 페이지 파라미터 (첫 페이지는 커서 파라미터 없이 요청) + initialPageParam: null, + // 다음 페이지 파라미터 가져오기 + getNextPageParam: (lastPage) => { + return lastPage.hasNext ? lastPage.nextCursor : null + }, + // 캐시 데이터 10분간 유지 + gcTime: 10 * 60 * 1000, + }) +} diff --git a/src/features/topics/index.ts b/src/features/topics/index.ts new file mode 100644 index 0000000..4661f4e --- /dev/null +++ b/src/features/topics/index.ts @@ -0,0 +1,16 @@ +// Components +export * from './components' + +// Hooks +export * from './hooks' + +// // Utils +// export * from './lib' + +// API +export * from './topics.api' +export * from './topics.endpoints' +export * from './topics.mock' + +// Types +export * from './topics.types' diff --git a/src/features/topics/topics.api.ts b/src/features/topics/topics.api.ts new file mode 100644 index 0000000..7f831a4 --- /dev/null +++ b/src/features/topics/topics.api.ts @@ -0,0 +1,178 @@ +/** + * @file topics.api.ts + * @description Topic API 요청 함수 + */ + +import { api } from '@/api/client' +import { PAGE_SIZES } from '@/shared/constants' + +import { TOPICS_ENDPOINTS } from './topics.endpoints' +import { getMockConfirmedTopics, getMockProposedTopics } from './topics.mock' +import type { + DeleteTopicParams, + GetConfirmedTopicsParams, + GetConfirmedTopicsResponse, + GetProposedTopicsParams, + GetProposedTopicsResponse, + LikeTopicParams, + LikeTopicResponse, +} from './topics.types' + +/** + * 목데이터 사용 여부 + * @description 로그인 기능 개발 전까지 true로 설정하여 목데이터 사용 + * TODO: 로그인 기능 완료 후 false로 변경하여 실제 API 호출 + */ +const USE_MOCK_DATA = true + +/** + * 제안된 주제 조회 + * + * @description + * 약속에 제안된 주제 목록을 커서 기반 페이지네이션으로 조회합니다. + * 좋아요 수 기준 내림차순으로 정렬됩니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.pageSize - 페이지 크기 (기본값: 5) + * @param params.cursorLikeCount - 커서: 이전 페이지 마지막 항목의 좋아요 수 + * @param params.cursorTopicId - 커서: 이전 페이지 마지막 항목의 주제 ID + * + * @returns 제안된 주제 목록 및 액션 정보 + */ +export const getProposedTopics = async ( + params: GetProposedTopicsParams +): Promise => { + const { + gatheringId, + meetingId, + pageSize = PAGE_SIZES.TOPICS, + cursorLikeCount, + cursorTopicId, + } = params + + // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 + // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 + if (USE_MOCK_DATA) { + // 실제 API 호출을 시뮬레이션하기 위한 지연 + await new Promise((resolve) => setTimeout(resolve, 500)) + return getMockProposedTopics(pageSize, cursorLikeCount, cursorTopicId) + } + + // 실제 API 호출 (로그인 완료 후 사용) + return api.get(TOPICS_ENDPOINTS.PROPOSED(gatheringId, meetingId), { + params: { + pageSize, + cursorLikeCount, + cursorTopicId, + }, + }) +} + +/** + * 확정된 주제 조회 + * + * @description + * 약속의 확정된 주제 목록을 커서 기반 페이지네이션으로 조회합니다. + * 확정 순서 기준 오름차순으로 정렬됩니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.pageSize - 페이지 크기 (기본값: 5) + * @param params.cursorConfirmOrder - 커서: 이전 페이지 마지막 항목의 확정 순서 + * @param params.cursorTopicId - 커서: 이전 페이지 마지막 항목의 주제 ID + * + * @returns 확정된 주제 목록 및 액션 정보 + */ +export const getConfirmedTopics = async ( + params: GetConfirmedTopicsParams +): Promise => { + const { + gatheringId, + meetingId, + pageSize = PAGE_SIZES.TOPICS, + cursorConfirmOrder, + cursorTopicId, + } = params + + // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 + // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 + if (USE_MOCK_DATA) { + // 실제 API 호출을 시뮬레이션하기 위한 지연 + await new Promise((resolve) => setTimeout(resolve, 500)) + return getMockConfirmedTopics(pageSize, cursorConfirmOrder, cursorTopicId) + } + + // 실제 API 호출 (로그인 완료 후 사용) + return api.get(TOPICS_ENDPOINTS.CONFIRMED(gatheringId, meetingId), { + params: { + pageSize, + cursorConfirmOrder, + cursorTopicId, + }, + }) +} + +/** + * 주제 삭제 + * + * @description + * 주제를 삭제합니다. 삭제 권한이 있는 경우에만 삭제가 가능합니다. + * + * @param params - 삭제 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.topicId - 주제 식별자 + * + * @returns void (성공 시 응답 데이터 없음) + */ +export const deleteTopic = async (params: DeleteTopicParams): Promise => { + const { gatheringId, meetingId, topicId } = params + + // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 + // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 + if (USE_MOCK_DATA) { + // 실제 API 호출을 시뮬레이션하기 위한 지연 + await new Promise((resolve) => setTimeout(resolve, 500)) + return + } + + // 실제 API 호출 (로그인 완료 후 사용) + return api.delete(TOPICS_ENDPOINTS.DELETE(gatheringId, meetingId, topicId)) +} + +/** + * 주제 좋아요 토글 + * + * @description + * 주제 좋아요를 토글합니다. 좋아요 상태이면 취소하고, 아니면 좋아요를 추가합니다. + * + * @param params - 좋아요 토글 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.topicId - 주제 식별자 + * + * @returns 좋아요 토글 결과 (주제 ID, 좋아요 상태, 새로운 좋아요 수) + */ +export const likeTopicToggle = async (params: LikeTopicParams): Promise => { + const { gatheringId, meetingId, topicId } = params + + // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 + // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 + if (USE_MOCK_DATA) { + // 실제 API 호출을 시뮬레이션하기 위한 지연 + await new Promise((resolve) => setTimeout(resolve, 300)) + // 목 응답 (랜덤하게 좋아요/취소) + const liked = Math.random() > 0.5 + return { + topicId, + liked, + newCount: liked ? Math.floor(Math.random() * 10) + 1 : Math.floor(Math.random() * 5), + } + } + + // 실제 API 호출 (로그인 완료 후 사용) + return api.post(TOPICS_ENDPOINTS.LIKE_TOGGLE(gatheringId, meetingId, topicId)) +} diff --git a/src/features/topics/topics.endpoints.ts b/src/features/topics/topics.endpoints.ts new file mode 100644 index 0000000..f9a8140 --- /dev/null +++ b/src/features/topics/topics.endpoints.ts @@ -0,0 +1,19 @@ +import { API_PATHS } from '@/api' + +export const TOPICS_ENDPOINTS = { + // 제안된 주제 조회 (GET /api/gatherings/{gatheringId}/meetings/{meetingId}/topics) + PROPOSED: (gatheringId: number, meetingId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics`, + + // 확정된 주제 조회 (GET /api/gatherings/{gatheringId}/meetings/{meetingId}/confirm-topics) + CONFIRMED: (gatheringId: number, meetingId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/confirm-topics`, + + // 주제 삭제 (DELETE /api/gatherings/{gatheringId}/meetings/{meetingId}/topics/{topicId}) + DELETE: (gatheringId: number, meetingId: number, topicId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/${topicId}`, + + // 주제 좋아요 토글 (POST /api/gatherings/{gatheringId}/meetings/{meetingId}/topics/{topicId}/likes) + LIKE_TOGGLE: (gatheringId: number, meetingId: number, topicId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/${topicId}/likes`, +} as const diff --git a/src/features/topics/topics.mock.ts b/src/features/topics/topics.mock.ts new file mode 100644 index 0000000..69feca6 --- /dev/null +++ b/src/features/topics/topics.mock.ts @@ -0,0 +1,491 @@ +/** + * @file topics.mock.ts + * @description Topic API 목데이터 + */ + +import type { + ConfirmedTopicItem, + GetConfirmedTopicsResponse, + GetProposedTopicsResponse, + ProposedTopicItem, +} from './topics.types' + +/** + * 제안된 주제 목데이터 + */ +const mockProposedTopics: ProposedTopicItem[] = [ + { + topicId: 1, + meetingId: 1, + title: '이 책의 핵심 메시지는 무엇인가?', + description: '저자가 전달하고자 하는 핵심 메시지에 대해 토론합니다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + topicStatus: 'PROPOSED', + likeCount: 25, + isLiked: true, + canDelete: true, + createdByInfo: { + userId: 1, + nickname: '독서왕', + }, + }, + { + topicId: 2, + meetingId: 1, + title: '주인공의 심리 변화 과정', + description: '주인공이 겪는 내면의 변화를 분석해봅시다.', + topicType: 'CHARACTER_ANALYSIS', + topicTypeLabel: '인물 분석형', + topicStatus: 'PROPOSED', + likeCount: 20, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 2, + nickname: '문학소녀', + }, + }, + { + topicId: 3, + meetingId: 1, + title: '책을 읽고 느낀 감정 공유', + description: '이 책을 읽으며 느낀 솔직한 감정들을 나눠요.', + topicType: 'EMOTION', + topicTypeLabel: '감정 공유형', + topicStatus: 'PROPOSED', + likeCount: 18, + isLiked: true, + canDelete: false, + createdByInfo: { + userId: 3, + nickname: '감성독서', + }, + }, + { + topicId: 4, + meetingId: 1, + title: '작가의 문체와 서술 방식', + description: '이 작품만의 독특한 문체와 서술 기법을 살펴봅니다.', + topicType: 'STRUCTURE', + topicTypeLabel: '구조 분석형', + topicStatus: 'PROPOSED', + likeCount: 15, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 4, + nickname: '분석가', + }, + }, + { + topicId: 5, + meetingId: 1, + title: '비슷한 경험이 있었나요?', + description: '책 속 상황과 유사한 개인적 경험을 공유해봐요.', + topicType: 'EXPERIENCE', + topicTypeLabel: '경험 연결형', + topicStatus: 'PROPOSED', + likeCount: 13, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 5, + nickname: '공감왕', + }, + }, + { + topicId: 6, + meetingId: 1, + title: '이 책이 주는 교훈', + description: '책을 통해 얻은 삶의 교훈을 이야기해봅시다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + topicStatus: 'PROPOSED', + likeCount: 12, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 6, + nickname: '철학자', + }, + }, + { + topicId: 7, + meetingId: 1, + title: '현대 사회와의 연결고리', + description: '작품 속 이야기가 현대 사회와 어떻게 연결되는지 논의합니다.', + topicType: 'COMPARISON', + topicTypeLabel: '비교 분석형', + topicStatus: 'PROPOSED', + likeCount: 10, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 7, + nickname: '사회학도', + }, + }, + { + topicId: 8, + meetingId: 1, + title: '인상 깊었던 장면', + description: '가장 인상 깊었던 장면과 그 이유를 공유해요.', + topicType: 'EMOTION', + topicTypeLabel: '감정 공유형', + topicStatus: 'PROPOSED', + likeCount: 9, + isLiked: true, + canDelete: false, + createdByInfo: { + userId: 8, + nickname: '열정독서', + }, + }, + { + topicId: 9, + meetingId: 1, + title: '작가가 전달하려던 메시지', + description: '작가의 의도와 숨겨진 메시지를 파헤쳐봅시다.', + topicType: 'IN_DEPTH', + topicTypeLabel: '심층 분석형', + topicStatus: 'PROPOSED', + likeCount: 8, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 9, + nickname: '심층분석가', + }, + }, + { + topicId: 10, + meetingId: 1, + title: '다른 작품과의 비교', + description: '비슷한 주제의 다른 작품들과 비교해봅시다.', + topicType: 'COMPARISON', + topicTypeLabel: '비교 분석형', + topicStatus: 'PROPOSED', + likeCount: 7, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 10, + nickname: '비교문학', + }, + }, + { + topicId: 11, + meetingId: 1, + title: '등장인물들의 관계', + description: '등장인물들 간의 복잡한 관계를 분석합니다.', + topicType: 'CHARACTER_ANALYSIS', + topicTypeLabel: '인물 분석형', + topicStatus: 'PROPOSED', + likeCount: 6, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 11, + nickname: '관계분석', + }, + }, + { + topicId: 12, + meetingId: 1, + title: '책의 배경과 시대상', + description: '작품의 배경이 되는 시대와 사회상을 이해해봅시다.', + topicType: 'STRUCTURE', + topicTypeLabel: '구조 분석형', + topicStatus: 'PROPOSED', + likeCount: 5, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 12, + nickname: '역사탐구', + }, + }, + { + topicId: 13, + meetingId: 1, + title: '이 책으로 만든 창작물', + description: '책에서 영감을 받아 짧은 글이나 그림을 만들어봐요.', + topicType: 'CREATIVE', + topicTypeLabel: '창작형', + topicStatus: 'PROPOSED', + likeCount: 4, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 13, + nickname: '창작러', + }, + }, + { + topicId: 14, + meetingId: 1, + title: '나라면 어떻게 했을까?', + description: '주인공의 입장이 되어 나의 선택을 상상해봅시다.', + topicType: 'EXPERIENCE', + topicTypeLabel: '경험 연결형', + topicStatus: 'PROPOSED', + likeCount: 3, + isLiked: true, + canDelete: false, + createdByInfo: { + userId: 14, + nickname: '상상력', + }, + }, + { + topicId: 15, + meetingId: 1, + title: '책의 결말에 대한 생각', + description: '책의 결말이 적절했는지, 다른 결말은 없었을지 토론합니다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + topicStatus: 'PROPOSED', + likeCount: 2, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 15, + nickname: '결말탐구', + }, + }, +] + +/** + * 확정된 주제 목데이터 + */ +const mockConfirmedTopics: ConfirmedTopicItem[] = [ + { + topicId: 20, + title: '데미안에서 자기 자신이란?', + description: '주인공이 찾아가는 자아의 의미를 탐구합니다.', + topicType: 'IN_DEPTH', + topicTypeLabel: '심층 분석형', + likeCount: 5, + confirmOrder: 1, + createdByInfo: { + userId: 1, + nickname: '독서왕', + }, + }, + { + topicId: 21, + title: '새와 알의 상징', + description: '작품 속 핵심 상징인 새와 알이 의미하는 바를 논의합니다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + likeCount: 5, + confirmOrder: 2, + createdByInfo: { + userId: 2, + nickname: '문학소녀', + }, + }, + { + topicId: 22, + title: '싱클레어의 성장 과정', + description: '주인공 싱클레어의 정신적 성장을 단계별로 분석합니다.', + topicType: 'CHARACTER_ANALYSIS', + topicTypeLabel: '인물 분석형', + likeCount: 5, + confirmOrder: 3, + createdByInfo: { + userId: 3, + nickname: '감성독서', + }, + }, + { + topicId: 23, + title: '빛과 어둠의 이중성', + description: '작품 속에서 빛과 어둠이 상징하는 의미를 탐구합니다.', + topicType: 'IN_DEPTH', + topicTypeLabel: '심층 분석형', + likeCount: 5, + confirmOrder: 4, + createdByInfo: { + userId: 4, + nickname: '분석가', + }, + }, + { + topicId: 24, + title: '데미안이라는 인물의 의미', + description: '데미안이 싱클레어에게 미친 영향을 분석합니다.', + topicTypeLabel: '인물 분석형', + likeCount: 5, + topicType: 'CHARACTER_ANALYSIS', + confirmOrder: 5, + createdByInfo: { + userId: 5, + nickname: '공감왕', + }, + }, + { + topicId: 25, + title: '아브락사스의 상징', + description: '선과 악을 초월한 신, 아브락사스의 의미를 논의합니다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + likeCount: 2, + confirmOrder: 6, + createdByInfo: { + userId: 6, + nickname: '철학자', + }, + }, + { + topicId: 26, + title: '꿈과 현실의 경계', + description: '작품 속 꿈과 현실이 혼재하는 장면들을 분석합니다.', + topicType: 'STRUCTURE', + topicTypeLabel: '구조 분석형', + likeCount: 1, + confirmOrder: 7, + createdByInfo: { + userId: 7, + nickname: '사회학도', + }, + }, + { + topicId: 27, + title: '에바 부인의 역할', + description: '싱클레어의 정신적 어머니 역할을 한 에바 부인을 논의합니다.', + topicType: 'CHARACTER_ANALYSIS', + topicTypeLabel: '인물 분석형', + likeCount: 0, + confirmOrder: 8, + createdByInfo: { + userId: 8, + nickname: '열정독서', + }, + }, + { + topicId: 28, + title: '종교적 상징과 의미', + description: '작품에 나타난 다양한 종교적 상징들을 탐구합니다.', + topicType: 'IN_DEPTH', + topicTypeLabel: '심층 분석형', + likeCount: 5, + confirmOrder: 9, + createdByInfo: { + userId: 9, + nickname: '심층분석가', + }, + }, + { + topicId: 29, + title: '성장소설로서의 데미안', + description: '성장소설 장르로서 데미안이 가진 특징을 분석합니다.', + topicType: 'STRUCTURE', + topicTypeLabel: '구조 분석형', + likeCount: 5, + confirmOrder: 10, + createdByInfo: { + userId: 10, + nickname: '비교문학', + }, + }, +] + +/** + * 제안된 주제 목데이터 반환 함수 + * + * @description + * 실제 API 호출을 시뮬레이션하여 제안된 주제 목데이터를 커서 기반 페이지네이션 형태로 반환합니다. + */ +export const getMockProposedTopics = ( + pageSize: number = 10, + cursorLikeCount?: number, + cursorTopicId?: number +): GetProposedTopicsResponse => { + let items = [...mockProposedTopics] + + // 커서가 있으면 해당 커서 이후의 데이터만 필터링 + if (cursorLikeCount !== undefined && cursorTopicId !== undefined) { + const cursorIndex = items.findIndex( + (item) => item.likeCount === cursorLikeCount && item.topicId === cursorTopicId + ) + if (cursorIndex !== -1) { + items = items.slice(cursorIndex + 1) + } + } + + // 페이지 크기만큼 자르기 + const pageItems = items.slice(0, pageSize) + const hasNext = items.length > pageSize + + // 다음 커서 생성 + const nextCursor = + hasNext && pageItems.length > 0 + ? { + likeCount: pageItems[pageItems.length - 1].likeCount, + topicId: pageItems[pageItems.length - 1].topicId, + } + : null + + return { + items: pageItems, + pageSize, + hasNext, + nextCursor, + totalCount: cursorLikeCount === undefined ? mockProposedTopics.length : undefined, + actions: { + canConfirm: false, + canSuggest: true, + }, + } +} + +/** + * 확정된 주제 목데이터 반환 함수 + * + * @description + * 실제 API 호출을 시뮬레이션하여 확정된 주제 목데이터를 커서 기반 페이지네이션 형태로 반환합니다. + */ +export const getMockConfirmedTopics = ( + pageSize: number = 10, + cursorConfirmOrder?: number, + cursorTopicId?: number +): GetConfirmedTopicsResponse => { + let items = [...mockConfirmedTopics] + + // 커서가 있으면 해당 커서 이후의 데이터만 필터링 + if (cursorConfirmOrder !== undefined && cursorTopicId !== undefined) { + const cursorIndex = items.findIndex( + (item) => item.confirmOrder === cursorConfirmOrder && item.topicId === cursorTopicId + ) + if (cursorIndex !== -1) { + items = items.slice(cursorIndex + 1) + } + } + + // 페이지 크기만큼 자르기 + const pageItems = items.slice(0, pageSize) + const hasNext = items.length > pageSize + + // 다음 커서 생성 + const nextCursor = + hasNext && pageItems.length > 0 + ? { + confirmOrder: pageItems[pageItems.length - 1].confirmOrder, + topicId: pageItems[pageItems.length - 1].topicId, + } + : null + + return { + items: pageItems, + pageSize, + hasNext, + nextCursor, + totalCount: cursorConfirmOrder === undefined ? mockConfirmedTopics.length : undefined, + actions: { + canViewPreOpinions: true, + canWritePreOpinions: false, + }, + } +} diff --git a/src/features/topics/topics.types.ts b/src/features/topics/topics.types.ts new file mode 100644 index 0000000..68db60b --- /dev/null +++ b/src/features/topics/topics.types.ts @@ -0,0 +1,200 @@ +/** + * @file topics.types.ts + * @description Topic API 관련 타입 정의 + */ + +import type { CursorPaginatedResponse } from '@/api/types' + +/** + * 주제 상태 타입 + */ +export type TopicStatus = 'PROPOSED' | 'CONFIRMED' + +/** + * 주제 타입 + */ +export type TopicType = + | 'FREE' + | 'DISCUSSION' + | 'EMOTION' + | 'EXPERIENCE' + | 'CHARACTER_ANALYSIS' + | 'COMPARISON' + | 'STRUCTURE' + | 'IN_DEPTH' + | 'CREATIVE' + | 'CUSTOM' + +/** + * 주제 아이템 타입 (제안된 주제) + */ +export type ProposedTopicItem = { + /** 주제 ID */ + topicId: number + /** 약속 ID */ + meetingId: number + /** 주제 제목 */ + title: string + /** 주제 설명 */ + description: string + /** 주제 타입 */ + topicType: TopicType + /** 주제 타입 라벨 (한국어) */ + topicTypeLabel: string + /** 주제 상태 */ + topicStatus: TopicStatus + /** 좋아요 수 */ + likeCount: number + /** 좋아요 여부 */ + isLiked: boolean + /** 삭제 가능 여부 */ + canDelete: boolean + /** 생성자 정보 */ + createdByInfo: { + userId: number + nickname: string + } +} + +/** + * 확정된 주제 아이템 타입 + */ +export type ConfirmedTopicItem = { + /** 주제 ID */ + topicId: number + /** 주제 제목 */ + title: string + /** 주제 설명 */ + description: string + /** 주제 타입 */ + topicType: TopicType + /** 주제 타입 라벨 (한국어) */ + topicTypeLabel: string + /** 확정 순서 */ + confirmOrder: number + /** 좋아요 수 */ + likeCount: number + /** 생성자 정보 */ + createdByInfo: { + userId: number + nickname: string + } +} + +/** + * 커서 타입 (제안된 주제용) + */ +export type ProposedTopicCursor = { + likeCount: number + topicId: number +} + +/** + * 커서 타입 (확정된 주제용) + */ +export type ConfirmedTopicCursor = { + confirmOrder: number + topicId: number +} + +// CursorPaginatedResponse는 @/api/types에서 import하여 사용 + +/** + * 제안된 주제 조회 요청 파라미터 + */ +export type GetProposedTopicsParams = { + /** 모임 식별자 */ + gatheringId: number + /** 약속 식별자 */ + meetingId: number + /** 페이지 크기 (기본값: 10) */ + pageSize?: number + /** 커서: 이전 페이지 마지막 항목의 좋아요 수 */ + cursorLikeCount?: number + /** 커서: 이전 페이지 마지막 항목의 주제 ID */ + cursorTopicId?: number +} + +/** + * 확정된 주제 조회 요청 파라미터 + */ +export type GetConfirmedTopicsParams = { + /** 모임 식별자 */ + gatheringId: number + /** 약속 식별자 */ + meetingId: number + /** 페이지 크기 (기본값: 10) */ + pageSize?: number + /** 커서: 이전 페이지 마지막 항목의 확정 순서 */ + cursorConfirmOrder?: number + /** 커서: 이전 페이지 마지막 항목의 주제 ID */ + cursorTopicId?: number +} + +/** + * 제안된 주제 조회 응답 타입 + */ +export type GetProposedTopicsResponse = CursorPaginatedResponse< + ProposedTopicItem, + ProposedTopicCursor +> & { + /** 액션 권한 정보 */ + actions: { + /** 주제 확정 가능 여부 */ + canConfirm: boolean + /** 주제 제안 가능 여부 */ + canSuggest: boolean + } +} + +/** + * 확정된 주제 조회 응답 타입 + */ +export type GetConfirmedTopicsResponse = CursorPaginatedResponse< + ConfirmedTopicItem, + ConfirmedTopicCursor +> & { + /** 액션 권한 정의 */ + actions: { + /** 사전 의견 조회 가능 여부 */ + canViewPreOpinions: boolean + /** 사전 의견 작성 가능 여부 */ + canWritePreOpinions: boolean + } +} + +/** + * 주제 삭제 요청 파라미터 + */ +export type DeleteTopicParams = { + /** 모임 식별자 */ + gatheringId: number + /** 약속 식별자 */ + meetingId: number + /** 주제 식별자 */ + topicId: number +} + +/** + * 주제 좋아요 토글 요청 파라미터 + */ +export type LikeTopicParams = { + /** 모임 식별자 */ + gatheringId: number + /** 약속 식별자 */ + meetingId: number + /** 주제 식별자 */ + topicId: number +} + +/** + * 주제 좋아요 토글 응답 + */ +export type LikeTopicResponse = { + /** 주제 식별자 */ + topicId: number + /** 좋아요 상태 */ + liked: boolean + /** 새로운 좋아요 수 */ + newCount: number +} diff --git a/src/pages/Meetings/MeetingDetailPage.tsx b/src/pages/Meetings/MeetingDetailPage.tsx index 3602d8a..7686f4f 100644 --- a/src/pages/Meetings/MeetingDetailPage.tsx +++ b/src/pages/Meetings/MeetingDetailPage.tsx @@ -1,4 +1,5 @@ import { ChevronLeft } from 'lucide-react' +import { useEffect, useState } from 'react' import { useParams } from 'react-router-dom' import { @@ -7,13 +8,63 @@ import { MeetingDetailInfo, useMeetingDetail, } from '@/features/meetings' -import { TextButton } from '@/shared/ui' +import type { + GetConfirmedTopicsResponse, + GetProposedTopicsResponse, + TopicStatus, +} from '@/features/topics' +import { + ConfirmedTopicList, + ProposedTopicList, + TopicHeader, + useConfirmedTopics, + useProposedTopics, +} from '@/features/topics' +import { Tabs, TabsContent, TabsList, TabsTrigger, TextButton } from '@/shared/ui' export default function MeetingDetailPage() { - const { meetingId } = useParams<{ gatheringId: string; meetingId: string }>() + const { gatheringId, meetingId } = useParams<{ gatheringId: string; meetingId: string }>() + + const [activeTab, setActiveTab] = useState('PROPOSED') const { data: meeting, isLoading, error } = useMeetingDetail(Number(meetingId)) + // 제안된 주제 조회 (무한 스크롤) + const { + data: proposedTopicsInfiniteData, + isLoading: isProposedLoading, + error: proposedError, + fetchNextPage: fetchNextProposedPage, + hasNextPage: hasNextProposedPage, + isFetchingNextPage: isFetchingNextProposedPage, + } = useProposedTopics({ + gatheringId: Number(gatheringId), + meetingId: Number(meetingId), + }) + + // 확정된 주제 조회 (무한 스크롤) + const { + data: confirmedTopicsInfiniteData, + isLoading: isConfirmedLoading, + error: confirmedError, + fetchNextPage: fetchNextConfirmedPage, + hasNextPage: hasNextConfirmedPage, + isFetchingNextPage: isFetchingNextConfirmedPage, + } = useConfirmedTopics({ + gatheringId: Number(gatheringId), + meetingId: Number(meetingId), + }) + + // 에러 처리 + useEffect(() => { + if (proposedError) { + alert(`제안된 주제 조회 실패: ${proposedError.userMessage}`) + } + if (confirmedError) { + alert(`확정된 주제 조회 실패: ${confirmedError.userMessage}`) + } + }, [proposedError, confirmedError]) + if (error) { return (
@@ -58,10 +109,86 @@ export default function MeetingDetailPage() {
{/* 약속 로딩 적용 */} -
- {/* 주제 로딩 적용 */} +

주제

- {/* 주제 로딩 적용 */} + + setActiveTab(value as TopicStatus)} + className="gap-medium" + > + + + 제안 + + + 확정된 주제 + + + + {isProposedLoading ? ( +
+

로딩 중...

+
+ ) : proposedTopicsInfiniteData ? ( +
+ + page.items + )} + hasNextPage={hasNextProposedPage} + isFetchingNextPage={isFetchingNextProposedPage} + onLoadMore={fetchNextProposedPage} + pageSize={5} + gatheringId={Number(gatheringId)} + meetingId={Number(meetingId)} + /> +
+ ) : null} +
+ + + {isConfirmedLoading ? ( +
+

로딩 중...

+
+ ) : confirmedTopicsInfiniteData ? ( +
+ + page.items + )} + hasNextPage={hasNextConfirmedPage} + isFetchingNextPage={isFetchingNextConfirmedPage} + onLoadMore={fetchNextConfirmedPage} + pageSize={5} + /> +
+ ) : null} +
+
diff --git a/src/shared/constants/pagination.ts b/src/shared/constants/pagination.ts index 5ba2b00..d1fb3f2 100644 --- a/src/shared/constants/pagination.ts +++ b/src/shared/constants/pagination.ts @@ -20,4 +20,6 @@ export const DEFAULT_SHOW_PAGES = 5 export const PAGE_SIZES = { /** 약속 승인 관리 리스트 페이지 사이즈 */ MEETING_APPROVALS: 10, + /** 주제 목록 페이지 사이즈 */ + TOPICS: 5, } as const diff --git a/src/shared/ui/LikeButton.tsx b/src/shared/ui/LikeButton.tsx index 2710a4f..cdd5cea 100644 --- a/src/shared/ui/LikeButton.tsx +++ b/src/shared/ui/LikeButton.tsx @@ -8,6 +8,7 @@ export interface LikeButtonProps { onClick?: (liked: boolean) => void disabled?: boolean className?: string + isPending?: boolean } /** @@ -27,9 +28,12 @@ function LikeButton({ onClick, disabled = false, className, + isPending = false, }: LikeButtonProps) { + const isButtonDisabled = disabled || isPending + const handleClick = () => { - if (disabled) return + if (isButtonDisabled) return onClick?.(!isLiked) } @@ -39,14 +43,19 @@ function LikeButton({ data-slot="like-button" data-liked={isLiked} onClick={handleClick} - disabled={disabled} + disabled={isButtonDisabled} className={cn( 'inline-flex items-center gap-tiny rounded-small px-[10px] py-tiny typo-caption1 transition-colors', isLiked ? 'border border-primary-300 bg-primary-100 text-primary-300' : 'border border-grey-400 bg-white text-grey-700', - disabled && 'border-transparent', - !disabled && 'cursor-pointer', + + // 의미적 disabled (기존 유지) + disabled && 'border-transparent opacity-100', + + // 정상 + !isButtonDisabled && 'cursor-pointer', + className )} >