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 (
+
+
+ {topics.map((topic) => (
+ -
+
+
+ ))}
+
+ {/* 무한 스크롤 로딩 상태 */}
+ {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
)}
>