+
)
}
const { items, totalPages, totalCount } = data
+ const pageSize = PAGE_SIZES.MEETING_APPROVALS
const showPagination = totalCount > pageSize
return (
{items.map((item) => (
-
+
))}
{showPagination && (
-
setCurrentPage(page)}
- />
+
)}
)
diff --git a/src/features/meetings/components/MeetingApprovalListSkeleton.tsx b/src/features/meetings/components/MeetingApprovalListSkeleton.tsx
new file mode 100644
index 0000000..9cf38d3
--- /dev/null
+++ b/src/features/meetings/components/MeetingApprovalListSkeleton.tsx
@@ -0,0 +1,25 @@
+/**
+ * @file MeetingApprovalListSkeleton.tsx
+ * @description 약속 승인 리스트 스켈레톤 컴포넌트
+ */
+
+export default function MeetingApprovalListSkeleton() {
+ const SKELETON_COUNT = 10
+
+ return (
+
+ {[...Array(SKELETON_COUNT).keys()].map((i) => (
+ -
+
+
+ ))}
+
+ )
+}
diff --git a/src/features/meetings/components/MeetingDetailButton.tsx b/src/features/meetings/components/MeetingDetailButton.tsx
index 0a43a82..5246913 100644
--- a/src/features/meetings/components/MeetingDetailButton.tsx
+++ b/src/features/meetings/components/MeetingDetailButton.tsx
@@ -1,5 +1,9 @@
+import { useNavigate } from 'react-router-dom'
+
import { useCancelJoinMeeting, useJoinMeeting } from '@/features/meetings/hooks'
import type { MeetingDetailActionStateType } from '@/features/meetings/meetings.types'
+import { ROUTES } from '@/shared/constants'
+import { showToast } from '@/shared/lib/toast'
import { Button } from '@/shared/ui'
import { useGlobalModalStore } from '@/store'
@@ -7,6 +11,7 @@ interface MeetingDetailButtonProps {
buttonLabel: string
isEnabled: boolean
type: MeetingDetailActionStateType
+ gatheringId: number
meetingId: number
}
@@ -14,8 +19,10 @@ export default function MeetingDetailButton({
buttonLabel,
isEnabled,
type,
+ gatheringId,
meetingId,
}: MeetingDetailButtonProps) {
+ const navigate = useNavigate()
const joinMutation = useJoinMeeting()
const cancelJoinMutation = useCancelJoinMeeting()
const { openError, openConfirm } = useGlobalModalStore()
@@ -25,9 +32,9 @@ export default function MeetingDetailButton({
const handleClick = async () => {
if (!isEnabled || isPending) return
- // 약속 수정 - 페이지 이동 예정 (TODO)
+ // 약속 수정
if (type === 'CAN_EDIT') {
- // 페이지 이동 로직 추가 예정
+ navigate(ROUTES.MEETING_UPDATE(gatheringId, meetingId))
return
}
@@ -38,7 +45,7 @@ export default function MeetingDetailButton({
joinMutation.mutate(meetingId, {
onSuccess: () => {
- alert('참가 신청이 완료되었습니다.')
+ showToast('참가 신청이 완료되었습니다.')
},
onError: (error) => {
openError('에러', error.userMessage)
@@ -54,7 +61,7 @@ export default function MeetingDetailButton({
cancelJoinMutation.mutate(meetingId, {
onSuccess: () => {
- alert('참가 취소가 완료되었습니다.')
+ showToast('참가 취소가 완료되었습니다.')
},
onError: (error) => {
openError('에러', error.userMessage)
@@ -74,7 +81,7 @@ export default function MeetingDetailButton({
>
{buttonLabel}
- {isEnabled && (
+ {!isEnabled && (
{type === 'EDIT_TIME_EXPIRED' && '약속 24시간 전까지만 약속 정보를 수정할 수 있어요'}
{(type === 'CANCEL_TIME_EXPIRED' || type === 'JOIN_TIME_EXPIRED') &&
diff --git a/src/features/meetings/components/MeetingDetailHeader.tsx b/src/features/meetings/components/MeetingDetailHeader.tsx
index ab2d34e..6c59a90 100644
--- a/src/features/meetings/components/MeetingDetailHeader.tsx
+++ b/src/features/meetings/components/MeetingDetailHeader.tsx
@@ -9,7 +9,10 @@ type ProgressBadge = {
text: '약속 전' | '약속 중' | '약속 후'
color: 'yellow' | 'blue' | 'red'
}
-export function MeetingDetailHeader({ children, progressStatus }: MeetingDetailHeaderProps) {
+export default function MeetingDetailHeader({
+ children,
+ progressStatus,
+}: MeetingDetailHeaderProps) {
const progressStatusLabelMap: Record = {
PRE: { text: '약속 전', color: 'yellow' },
ONGOING: { text: '약속 중', color: 'red' },
diff --git a/src/features/meetings/components/MeetingDetailInfo.tsx b/src/features/meetings/components/MeetingDetailInfo.tsx
index 797b12f..69b9bb8 100644
--- a/src/features/meetings/components/MeetingDetailInfo.tsx
+++ b/src/features/meetings/components/MeetingDetailInfo.tsx
@@ -1,7 +1,5 @@
import { MapPin } from 'lucide-react'
-import { useState } from 'react'
-import MapModal from '@/features/meetings/components/MapModal'
import {
Avatar,
AvatarFallback,
@@ -20,9 +18,7 @@ interface MeetingDetailInfoProps {
meeting: GetMeetingDetailResponse
}
-export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) {
- const [isMapModalOpen, setIsMapModalOpen] = useState(false)
-
+export default function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) {
const leader = meeting.participants.members.find((member) => member.role === 'LEADER')
const members = meeting.participants.members.filter((member) => member.role === 'MEMBER')
const displayedMembers = members.slice(0, MAX_DISPLAYED_AVATARS)
@@ -32,14 +28,19 @@ export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) {
const [startDate, endDate] = meeting.schedule.displayDate.split(' ~ ')
+ const location = meeting.location
+
return (
{/* 도서 */}
- 도서
- -
-
{meeting.book.bookName}
+ -
+
+
{meeting.book.bookName}
+
{meeting.book.authors}
+
- 장소
-
- {meeting.location && (
+ {location && (
setIsMapModalOpen(true)}
+ onClick={() => {
+ window.open(
+ `https://map.kakao.com/link/map/${location.name},${location.latitude},${location.longitude}`,
+ '_blank',
+ 'noopener,noreferrer'
+ )
+ }}
>
- {meeting.location.name}
+ {location.name}
)}
-
- {/* 지도 모달 */}
- {meeting.location && (
-
- )}
)
}
diff --git a/src/features/meetings/components/PlaceList.tsx b/src/features/meetings/components/PlaceList.tsx
index 9f99b42..6af8c68 100644
--- a/src/features/meetings/components/PlaceList.tsx
+++ b/src/features/meetings/components/PlaceList.tsx
@@ -3,51 +3,47 @@
* @description 장소 검색 결과 목록 컴포넌트
*/
-import type { KakaoPlace } from '../kakaoMap.types'
+import type { KakaoPlace } from '@/features/kakaomap'
+import { Button } from '@/shared/ui'
export type PlaceListProps = {
/** 장소 목록 */
places: KakaoPlace[]
- /** 장소 클릭 핸들러 */
+ /** li 클릭 시 지도 포커스 핸들러 */
+ onPlaceFocus: (place: KakaoPlace) => void
+ /** 선택 버튼 클릭 핸들러 */
onPlaceClick: (place: KakaoPlace) => void
- /** 장소 hover 핸들러 */
- onPlaceHover?: (place: KakaoPlace, index: number) => void
- /** 장소 hover 종료 핸들러 */
- onPlaceHoverEnd?: () => void
}
-export default function PlaceList({
- places,
- onPlaceClick,
- onPlaceHover,
- onPlaceHoverEnd,
-}: PlaceListProps) {
- if (places.length === 0) {
- return (
-
- )
- }
-
+export default function PlaceList({ places, onPlaceFocus, onPlaceClick }: PlaceListProps) {
return (
-
- {places.map((place, index) => (
-
+
+
{place.place_name}
+
{place.category_group_name}
+
+
{place.road_address_name}
+
+
+
+
+
))}
-
+
)
}
diff --git a/src/features/meetings/components/PlaceListSkeleton.tsx b/src/features/meetings/components/PlaceListSkeleton.tsx
new file mode 100644
index 0000000..f7cc107
--- /dev/null
+++ b/src/features/meetings/components/PlaceListSkeleton.tsx
@@ -0,0 +1,19 @@
+type PlaceListSkeletonProps = {
+ count?: number
+}
+
+export default function PlaceListSkeleton({ count = 5 }: PlaceListSkeletonProps) {
+ return (
+
+ {[...Array(count).keys()].map((i) => (
+ -
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/features/meetings/components/PlaceSearchModal.tsx b/src/features/meetings/components/PlaceSearchModal.tsx
index dfd54f5..0dc5d2c 100644
--- a/src/features/meetings/components/PlaceSearchModal.tsx
+++ b/src/features/meetings/components/PlaceSearchModal.tsx
@@ -1,33 +1,17 @@
/**
* @file PlaceSearchModal.tsx
- * @description 카카오 장소 검색 모달 컴포넌트
+ * @description 장소 검색 모달 컴포넌트
+ *
+ * UI는 searchState 기준으로만 화면을 분기합니다.
+ * 모든 상태 관리와 비동기 로직은 usePlaceSearch 훅에서 처리합니다.
*/
-import { Search } from 'lucide-react'
-import { useEffect, useRef } from 'react'
-
-import {
- Button,
- Input,
- Modal,
- ModalBody,
- ModalContent,
- ModalFooter,
- ModalHeader,
- ModalTitle,
-} from '@/shared/ui'
-
-import { useKakaoMap } from '../hooks/useKakaoMap'
-import { useKakaoPlaceSearch } from '../hooks/useKakaoPlaceSearch'
-import type { KakaoPlace } from '../kakaoMap.types'
-import PlaceList from './PlaceList'
-
-declare global {
- interface Window {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- kakao: any
- }
-}
+import { Map, MapMarker, ZoomControl } from '@/features/kakaomap'
+import PlaceList from '@/features/meetings/components/PlaceList'
+import PlaceListSkeleton from '@/features/meetings/components/PlaceListSkeleton'
+import { usePlaceSearch } from '@/features/meetings/hooks'
+import { cn } from '@/shared/lib/utils'
+import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle, SearchField } from '@/shared/ui'
export type PlaceSearchModalProps = {
/** 모달 열림 상태 */
@@ -48,59 +32,21 @@ export default function PlaceSearchModal({
onOpenChange,
onSelectPlace,
}: PlaceSearchModalProps) {
- // 지도 관리
- const { mapElement, isInitialized, initializeMap, renderMarkers, setCenter, cleanup } =
- useKakaoMap()
-
- // 장소 검색 관리
- const keywordRef = useRef
(null)
- const { places, search, reset } = useKakaoPlaceSearch({
- onSearchSuccess: renderMarkers,
- })
-
- // 모달 열릴 때 지도 초기화
- useEffect(() => {
- if (open && !isInitialized) {
- initializeMap()
- }
- }, [open, isInitialized, initializeMap])
-
- // 검색 실행
- const handleSearch = () => {
- const keyword = keywordRef.current?.value || ''
- search(keyword)
- }
-
- // Enter 키 처리
- const handleKeyUp = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault()
- handleSearch()
- }
- }
-
- // 장소 선택
- const handlePlaceClick = (place: KakaoPlace) => {
- setCenter(Number(place.y), Number(place.x))
-
- onSelectPlace({
- name: place.place_name,
- address: place.road_address_name || place.address_name,
- latitude: Number(place.y),
- longitude: Number(place.x),
- })
-
- onOpenChange(false)
- reset()
- cleanup()
- }
-
- // 모달 닫기
- const handleClose = () => {
- onOpenChange(false)
- reset()
- cleanup()
- }
+ const {
+ searchState,
+ errorMessage,
+ places,
+ isMapMounted,
+ isMapVisible,
+ hoveredPlaceId,
+ keywordRef,
+ setMapInstance,
+ setHoveredPlaceId,
+ handleKeyDown,
+ handlePlaceClick,
+ handlePlaceFocus,
+ handleClose,
+ } = usePlaceSearch({ open, onOpenChange, onSelectPlace })
return (
@@ -110,46 +56,68 @@ export default function PlaceSearchModal({
-
-
-
-
-
-
- {/* 지도 영역 */}
-
-
+
+
+ {/* 지도 + 리스트 영역
+ isMapMounted: 첫 검색 전 / 에러는 마운트하지 않음
+ isMapVisible: noResults일 때 Map 인스턴스를 유지한 채 CSS로만 숨김 */}
+ {isMapMounted && (
+
+
+
+
+ {searchState === 'searching' ? (
+
+ ) : (
+
+ )}
+
+
+ )}
- {/* 검색 전 안내 메시지 오버레이 */}
- {!isInitialized && (
-
-
-
-
장소를 검색하면
-
지도에 표시됩니다
-
-
- )}
+ {/* 검색 결과 없음 */}
+ {searchState === 'noResults' && (
+
+ )}
- {/* 장소 리스트 */}
-
-
+ {/* SDK 오류 또는 검색 오류 */}
+ {searchState === 'error' && (
+
+ )}
-
-
-
-
)
diff --git a/src/features/meetings/components/index.ts b/src/features/meetings/components/index.ts
index 272ec4f..68df722 100644
--- a/src/features/meetings/components/index.ts
+++ b/src/features/meetings/components/index.ts
@@ -1,8 +1,9 @@
-export { default as MapModal } from './MapModal'
export { default as MeetingApprovalItem } from './MeetingApprovalItem'
export { default as MeetingApprovalList } from './MeetingApprovalList'
+export { default as MeetingApprovalListSkeleton } from './MeetingApprovalListSkeleton'
export { default as MeetingDetailButton } from './MeetingDetailButton'
-export { MeetingDetailHeader } from './MeetingDetailHeader'
-export { MeetingDetailInfo } from './MeetingDetailInfo'
+export { default as MeetingDetailHeader } from './MeetingDetailHeader'
+export { default as MeetingDetailInfo } from './MeetingDetailInfo'
export { default as PlaceList } from './PlaceList'
+export { default as PlaceListSkeleton } from './PlaceListSkeleton'
export { default as PlaceSearchModal } from './PlaceSearchModal'
diff --git a/src/features/meetings/hooks/index.ts b/src/features/meetings/hooks/index.ts
index 68684c0..9915b1a 100644
--- a/src/features/meetings/hooks/index.ts
+++ b/src/features/meetings/hooks/index.ts
@@ -1,13 +1,15 @@
export * from './meetingQueryKeys'
+export * from './myMeetingQueryKeys'
export * from './useCancelJoinMeeting'
export * from './useConfirmMeeting'
export * from './useCreateMeeting'
export * from './useDeleteMeeting'
export * from './useJoinMeeting'
-export * from './useKakaoMap'
-export * from './useKakaoPlaceSearch'
export * from './useMeetingApprovals'
-export * from './useMeetingApprovalsCount'
export * from './useMeetingDetail'
export * from './useMeetingForm'
+export * from './useMyMeetings'
+export * from './useMyMeetingTabCounts'
+export * from './usePlaceSearch'
export * from './useRejectMeeting'
+export * from './useUpdateMeeting'
diff --git a/src/features/meetings/hooks/myMeetingQueryKeys.ts b/src/features/meetings/hooks/myMeetingQueryKeys.ts
new file mode 100644
index 0000000..cd6bfbc
--- /dev/null
+++ b/src/features/meetings/hooks/myMeetingQueryKeys.ts
@@ -0,0 +1,8 @@
+import type { MyMeetingFilter } from '@/features/meetings/meetings.types'
+
+export const myMeetingQueryKeys = {
+ all: ['myMeetings'] as const,
+ lists: () => [...myMeetingQueryKeys.all, 'list'] as const,
+ list: (filter: MyMeetingFilter) => [...myMeetingQueryKeys.lists(), filter] as const,
+ tabCounts: () => [...myMeetingQueryKeys.all, 'tabCounts'] as const,
+}
diff --git a/src/features/meetings/hooks/useCancelJoinMeeting.ts b/src/features/meetings/hooks/useCancelJoinMeeting.ts
index 3d80a28..d424b74 100644
--- a/src/features/meetings/hooks/useCancelJoinMeeting.ts
+++ b/src/features/meetings/hooks/useCancelJoinMeeting.ts
@@ -24,8 +24,7 @@ export const useCancelJoinMeeting = () => {
return useMutation
, ApiError, number>({
mutationFn: (meetingId: number) => cancelJoinMeeting(meetingId),
- onSuccess: (data, variables) => {
- void data // 사용하지 않는 파라미터
+ onSuccess: (_data, variables) => {
// 약속 상세 캐시 무효화
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.detail(variables),
diff --git a/src/features/meetings/hooks/useConfirmMeeting.ts b/src/features/meetings/hooks/useConfirmMeeting.ts
index 1fa80ac..86b0a95 100644
--- a/src/features/meetings/hooks/useConfirmMeeting.ts
+++ b/src/features/meetings/hooks/useConfirmMeeting.ts
@@ -5,8 +5,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { ApiError } from '@/api/errors'
-import type { ApiResponse } from '@/api/types'
+import type { ApiError, ApiResponse } from '@/api'
+import { gatheringQueryKeys } from '@/features/gatherings'
import { confirmMeeting, type ConfirmMeetingResponse } from '@/features/meetings'
import { meetingQueryKeys } from './meetingQueryKeys'
@@ -18,12 +18,13 @@ import { meetingQueryKeys } from './meetingQueryKeys'
* 약속을 승인하고 관련 쿼리 캐시를 무효화합니다.
* - 약속 승인 리스트 캐시 무효화
* - 약속 승인 카운트 캐시 무효화
+ * - 모임 약속 리스트 캐시 무효화
*
* @example
- * const confirmMutation = useConfirmMeeting()
+ * const confirmMutation = useConfirmMeeting(gatheringId)
* confirmMutation.mutate(meetingId)
*/
-export const useConfirmMeeting = () => {
+export const useConfirmMeeting = (gatheringId: number) => {
const queryClient = useQueryClient()
return useMutation, ApiError, number>({
@@ -33,6 +34,8 @@ export const useConfirmMeeting = () => {
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.approvals(),
})
+ // 모임 약속 리스트 캐시 무효화
+ queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.meetings(gatheringId) })
},
})
}
diff --git a/src/features/meetings/hooks/useCreateMeeting.ts b/src/features/meetings/hooks/useCreateMeeting.ts
index e56733b..6610439 100644
--- a/src/features/meetings/hooks/useCreateMeeting.ts
+++ b/src/features/meetings/hooks/useCreateMeeting.ts
@@ -20,20 +20,6 @@ import { meetingQueryKeys } from './meetingQueryKeys'
*
* @description
* 새로운 약속을 생성하고 관련 쿼리 캐시를 무효화합니다.
- * - 약속 승인 리스트 캐시 무효화
- * - 약속 승인 카운트 캐시 무효화
- *
- * @example
- * const createMutation = useCreateMeeting()
- * createMutation.mutate({
- * gatheringId: 1,
- * bookId: 1,
- * meetingName: '1월 독서 모임',
- * meetingStartDate: '2025-02-01T14:00:00',
- * meetingEndDate: '2025-02-01T16:00:00',
- * maxParticipants: 10,
- * place: '강남역 스타벅스'
- * })
*/
export const useCreateMeeting = () => {
const queryClient = useQueryClient()
@@ -41,7 +27,7 @@ export const useCreateMeeting = () => {
return useMutation, ApiError, CreateMeetingRequest>({
mutationFn: (data: CreateMeetingRequest) => createMeeting(data),
onSuccess: () => {
- // 약속 승인 관련 모든 캐시 무효화 (리스트 + 카운트)
+ // 약속 승인 관련 모든 캐시 무효화
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.approvals(),
})
diff --git a/src/features/meetings/hooks/useDeleteMeeting.ts b/src/features/meetings/hooks/useDeleteMeeting.ts
index 4f09f3e..561caee 100644
--- a/src/features/meetings/hooks/useDeleteMeeting.ts
+++ b/src/features/meetings/hooks/useDeleteMeeting.ts
@@ -7,6 +7,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ApiError } from '@/api/errors'
import type { ApiResponse } from '@/api/types'
+import { gatheringQueryKeys } from '@/features/gatherings'
import { deleteMeeting } from '@/features/meetings'
import { meetingQueryKeys } from './meetingQueryKeys'
@@ -23,7 +24,7 @@ import { meetingQueryKeys } from './meetingQueryKeys'
* const deleteMutation = useDeleteMeeting()
* deleteMutation.mutate(meetingId)
*/
-export const useDeleteMeeting = () => {
+export const useDeleteMeeting = (gatheringId: number) => {
const queryClient = useQueryClient()
return useMutation, ApiError, number>({
@@ -33,6 +34,7 @@ export const useDeleteMeeting = () => {
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.approvals(),
})
+ queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.meetings(gatheringId) })
},
})
}
diff --git a/src/features/meetings/hooks/useJoinMeeting.ts b/src/features/meetings/hooks/useJoinMeeting.ts
index 34aa980..ea63b82 100644
--- a/src/features/meetings/hooks/useJoinMeeting.ts
+++ b/src/features/meetings/hooks/useJoinMeeting.ts
@@ -24,8 +24,7 @@ export const useJoinMeeting = () => {
return useMutation, ApiError, number>({
mutationFn: (meetingId: number) => joinMeeting(meetingId),
- onSuccess: (data, variables) => {
- void data // 사용하지 않는 파라미터
+ onSuccess: (_data, variables) => {
// 약속 상세 캐시 무효화
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.detail(variables),
diff --git a/src/features/meetings/hooks/useKakaoMap.ts b/src/features/meetings/hooks/useKakaoMap.ts
deleted file mode 100644
index 9d67bee..0000000
--- a/src/features/meetings/hooks/useKakaoMap.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-/**
- * @file useKakaoMap.ts
- * @description Kakao Maps 지도 및 마커 관리 훅
- */
-
-import { useRef, useState } from 'react'
-
-import type { KakaoPlace } from '../kakaoMap.types'
-
-export type UseKakaoMapOptions = {
- /** 초기 중심 좌표 */
- initialCenter?: { lat: number; lng: number }
- /** 초기 줌 레벨 */
- initialLevel?: number
-}
-
-export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOptions = {}) {
- const [mapElement, setMapElement] = useState(null)
- const [isInitialized, setIsInitialized] = useState(false)
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const mapRef = useRef(null)
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const markersRef = useRef([])
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const infowindowRef = useRef(null)
-
- const defaultCenter = useRef(initialCenter ?? { lat: 37.566826, lng: 126.9786567 })
-
- // 마커 제거
- const clearMarkers = () => {
- markersRef.current.forEach((marker) => {
- marker.setMap(null)
- })
- markersRef.current = []
- }
-
- // 인포윈도우 닫기
- const closeInfoWindow = () => {
- infowindowRef.current?.close()
- }
-
- // HTML escape 유틸리티
- const escapeHtml = (text: string) => {
- const div = document.createElement('div')
- div.textContent = text
- return div.innerHTML
- }
-
- // 인포윈도우 열기
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const openInfoWindow = (marker: any, title: string) => {
- if (!mapRef.current || !infowindowRef.current) return
- const escapedTitle = escapeHtml(title)
- infowindowRef.current.setContent(`${escapedTitle}
`)
- infowindowRef.current.open(mapRef.current, marker)
- }
-
- // 지도 수동 초기화
- const initializeMap = () => {
- if (!mapElement) {
- console.warn('Map element not ready')
- return false
- }
-
- if (mapRef.current) {
- // 이미 초기화된 경우 relayout만 실행
- mapRef.current.relayout()
- return true
- }
-
- const kakao = window.kakao
-
- if (!kakao?.maps) {
- console.error('Kakao Maps SDK not loaded')
- return false
- }
-
- // 지도 생성
- const map = new kakao.maps.Map(mapElement, {
- center: new kakao.maps.LatLng(defaultCenter.current.lat, defaultCenter.current.lng),
- level: initialLevel,
- })
-
- mapRef.current = map
-
- infowindowRef.current = new kakao.maps.InfoWindow({ zIndex: 1 })
- setIsInitialized(true)
-
- // Portal/Modal에서 사이즈 계산 이슈 방지
- setTimeout(() => {
- map.relayout()
- map.setCenter(new kakao.maps.LatLng(defaultCenter.current.lat, defaultCenter.current.lng))
- }, 0)
-
- return true
- }
-
- // 지도 정리
- const cleanup = () => {
- clearMarkers()
- closeInfoWindow()
- mapRef.current = null
- infowindowRef.current = null
- setIsInitialized(false)
- }
-
- // 장소 목록에 대한 마커 렌더링
- const renderMarkers = (places: KakaoPlace[]) => {
- if (!mapRef.current || !window.kakao) return
-
- const kakao = window.kakao
- const map = mapRef.current
-
- clearMarkers()
- closeInfoWindow()
-
- const bounds = new kakao.maps.LatLngBounds()
-
- places.forEach((place) => {
- const position = new kakao.maps.LatLng(Number(place.y), Number(place.x))
-
- const marker = new kakao.maps.Marker({
- position,
- map,
- })
-
- // 마커 hover 이벤트
- kakao.maps.event.addListener(marker, 'mouseover', () => {
- openInfoWindow(marker, place.place_name)
- })
- kakao.maps.event.addListener(marker, 'mouseout', () => {
- closeInfoWindow()
- })
-
- markersRef.current.push(marker)
- bounds.extend(position)
- })
-
- // 마커들이 모두 보이도록 bounds 조정
- if (places.length > 0) {
- map.setBounds(bounds)
- }
- }
-
- // 특정 좌표로 지도 중심 이동
- const setCenter = (lat: number, lng: number) => {
- if (!mapRef.current || !window.kakao) return
- const kakao = window.kakao
- const position = new kakao.maps.LatLng(lat, lng)
- mapRef.current.setCenter(position)
- }
-
- return {
- mapElement: setMapElement,
- isInitialized,
- initializeMap,
- renderMarkers,
- closeInfoWindow,
- openInfoWindow,
- setCenter,
- cleanup,
- }
-}
diff --git a/src/features/meetings/hooks/useKakaoPlaceSearch.ts b/src/features/meetings/hooks/useKakaoPlaceSearch.ts
deleted file mode 100644
index e9829c8..0000000
--- a/src/features/meetings/hooks/useKakaoPlaceSearch.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @file useKakaoPlaceSearch.ts
- * @description Kakao Places API 검색 로직 훅
- */
-
-import { useRef, useState } from 'react'
-
-import type { KakaoPlace } from '../kakaoMap.types'
-
-export type UseKakaoPlaceSearchOptions = {
- /** 검색 성공 콜백 */
- onSearchSuccess?: (places: KakaoPlace[]) => void
-}
-
-export function useKakaoPlaceSearch({ onSearchSuccess }: UseKakaoPlaceSearchOptions = {}) {
- const [places, setPlaces] = useState([])
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const placesServiceRef = useRef(null)
-
- // 검색 실행
- const search = (searchKeyword: string) => {
- if (!searchKeyword.trim()) {
- return false
- }
-
- const kakao = window.kakao
- if (!kakao?.maps?.services) {
- return false
- }
-
- // Places 서비스
- if (!placesServiceRef.current) {
- placesServiceRef.current = new kakao.maps.services.Places()
- }
-
- const ps = placesServiceRef.current
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- ps.keywordSearch(searchKeyword, (data: KakaoPlace[], status: any) => {
- if (status === kakao.maps.services.Status.OK) {
- setPlaces(data)
- onSearchSuccess?.(data)
- } else if (status === kakao.maps.services.Status.ZERO_RESULT) {
- setPlaces([])
- onSearchSuccess?.([])
- } else {
- setPlaces([])
- onSearchSuccess?.([])
- alert('검색 중 오류가 발생했습니다.')
- }
- })
-
- return true
- }
-
- // 검색 상태 초기화
- const reset = () => {
- setPlaces([])
- placesServiceRef.current = null
- }
-
- return {
- places,
- search,
- reset,
- }
-}
diff --git a/src/features/meetings/hooks/useMeetingApprovals.ts b/src/features/meetings/hooks/useMeetingApprovals.ts
index 6c0ce37..7db0abd 100644
--- a/src/features/meetings/hooks/useMeetingApprovals.ts
+++ b/src/features/meetings/hooks/useMeetingApprovals.ts
@@ -40,13 +40,11 @@ import { meetingQueryKeys } from './meetingQueryKeys'
* })
*/
export const useMeetingApprovals = (params: GetMeetingApprovalsParams) => {
- const isValidGatheringId = !Number.isNaN(params.gatheringId) && params.gatheringId > 0
-
return useQuery, ApiError>({
queryKey: meetingQueryKeys.approvalList(params),
queryFn: () => getMeetingApprovals(params),
// gatheringId가 유효할 때만 쿼리 실행
- enabled: isValidGatheringId,
+ enabled: params.gatheringId > 0,
// 캐시 데이터 10분간 유지 (전역 설정 staleTime: 5분 사용)
gcTime: 10 * 60 * 1000,
})
diff --git a/src/features/meetings/hooks/useMeetingApprovalsCount.ts b/src/features/meetings/hooks/useMeetingApprovalsCount.ts
deleted file mode 100644
index 41ef067..0000000
--- a/src/features/meetings/hooks/useMeetingApprovalsCount.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * @file useMeetingApprovalsCount.ts
- * @description 약속 승인 카운트 조회 훅
- */
-
-import { useQueries } from '@tanstack/react-query'
-
-import type { PaginatedResponse } from '@/api/types'
-import { getMeetingApprovals, type MeetingApprovalItemType } from '@/features/meetings'
-
-import { meetingQueryKeys } from './meetingQueryKeys'
-
-/**
- * 약속 승인 카운트 일괄 조회 훅
- *
- * @description
- * PENDING과 CONFIRMED 상태의 카운트를 병렬로 조회합니다.
- * size=1로 요청하여 totalCount만 효율적으로 가져옵니다.
- * 두 개의 쿼리를 useQueries로 한 번에 처리하여 코드 간결성을 높입니다.
- *
- * @param gatheringId - 모임 식별자 (유효하지 않은 경우 쿼리 비활성화)
- *
- * @returns 카운트 및 로딩/에러 상태 객체
- * - pendingCount: PENDING 상태 카운트
- * - confirmedCount: CONFIRMED 상태 카운트
- * - isPendingLoading: PENDING 로딩 상태
- * - isConfirmedLoading: CONFIRMED 로딩 상태
- * - isLoading: 둘 중 하나라도 로딩 중인지 여부
- * - pendingError: PENDING 에러 객체
- * - confirmedError: CONFIRMED 에러 객체
- * - isError: 둘 중 하나라도 에러가 발생했는지 여부
- *
- * @example
- * const { pendingCount, confirmedCount, isLoading, isError } = useMeetingApprovalsCount(1)
- */
-export const useMeetingApprovalsCount = (gatheringId: number) => {
- const isValidGatheringId = !Number.isNaN(gatheringId) && gatheringId > 0
-
- const results = useQueries({
- queries: [
- {
- queryKey: meetingQueryKeys.approvalCount(gatheringId, 'PENDING'),
- queryFn: () =>
- getMeetingApprovals({
- gatheringId,
- status: 'PENDING',
- page: 0,
- size: 1,
- }),
- enabled: isValidGatheringId,
- select: (data: PaginatedResponse) => data.totalCount,
- gcTime: 10 * 60 * 1000,
- },
- {
- queryKey: meetingQueryKeys.approvalCount(gatheringId, 'CONFIRMED'),
- queryFn: () =>
- getMeetingApprovals({
- gatheringId,
- status: 'CONFIRMED',
- page: 0,
- size: 1,
- }),
- enabled: isValidGatheringId,
- select: (data: PaginatedResponse) => data.totalCount,
- gcTime: 10 * 60 * 1000,
- },
- ],
- })
-
- return {
- pendingCount: results[0].data,
- confirmedCount: results[1].data,
- isPendingLoading: results[0].isLoading,
- isConfirmedLoading: results[1].isLoading,
- isLoading: results[0].isLoading || results[1].isLoading,
- pendingError: results[0].error,
- confirmedError: results[1].error,
- isError: results[0].isError || results[1].isError,
- }
-}
diff --git a/src/features/meetings/hooks/useMeetingForm.ts b/src/features/meetings/hooks/useMeetingForm.ts
index fcc8b10..a1ec808 100644
--- a/src/features/meetings/hooks/useMeetingForm.ts
+++ b/src/features/meetings/hooks/useMeetingForm.ts
@@ -1,14 +1,19 @@
-import { useMemo, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { type SearchBookItem } from '@/features/book'
import {
combineDateAndTime,
+ extractTime,
formatScheduleRange,
generateTimeOptions,
+ type GetMeetingDetailResponse,
isStartBeforeEnd,
} from '@/features/meetings'
type UseMeetingFormParams = {
gatheringMaxCount: number
+ /** 수정 모드일 때 초기값 */
+ initialData?: GetMeetingDetailResponse | null
}
type ValidationErrors = {
@@ -18,22 +23,82 @@ type ValidationErrors = {
location?: string | null
}
-export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => {
- // 폼 상태
- const [locationName, setLocationName] = useState(null)
- const [locationAddress, setLocationAddress] = useState(null)
- const [latitude, setLatitude] = useState(null)
- const [longitude, setLongitude] = useState(null)
- const [meetingName, setMeetingName] = useState(null)
- const [bookId, setBookId] = useState(null)
- const [bookName, setBookName] = useState(null)
- const [maxParticipants, setMaxParticipants] = useState(null)
-
- // 날짜/시간 상태
- const [startDate, setStartDate] = useState(null)
- const [startTime, setStartTime] = useState(null)
- const [endDate, setEndDate] = useState(null)
- const [endTime, setEndTime] = useState(null)
+/**
+ * 폼 데이터 타입
+ */
+type FormData = {
+ locationName: string | null
+ locationAddress: string | null
+ latitude: number | null
+ longitude: number | null
+ meetingName: string | null
+ bookId: string | null
+ bookName: string | null
+ bookThumbnail: string | null
+ bookAuthors: string | null
+ bookPublisher: string | null
+ maxParticipants: string | null
+ startDate: Date | null
+ startTime: string | null
+ endDate: Date | null
+ endTime: string | null
+}
+
+/**
+ * 초기 폼 데이터 생성
+ */
+const getInitialFormData = (initialData?: GetMeetingDetailResponse | null): FormData => {
+ if (!initialData) {
+ return {
+ locationName: null,
+ locationAddress: null,
+ latitude: null,
+ longitude: null,
+ meetingName: null,
+ bookId: null,
+ bookName: null,
+ bookThumbnail: null,
+ bookAuthors: null,
+ bookPublisher: null,
+ maxParticipants: null,
+ startDate: null,
+ startTime: null,
+ endDate: null,
+ endTime: null,
+ }
+ }
+
+ return {
+ locationName: initialData.location?.name ?? null,
+ locationAddress: initialData.location?.address ?? null,
+ latitude: initialData.location?.latitude ?? null,
+ longitude: initialData.location?.longitude ?? null,
+ meetingName: initialData.meetingName ?? null,
+ bookId: initialData.book?.bookId?.toString() ?? null,
+ bookName: initialData.book?.bookName ?? null,
+ bookThumbnail: initialData.book?.thumbnail ?? null,
+ bookAuthors: initialData.book?.authors ?? null,
+ bookPublisher: initialData.book?.publisher ?? null,
+ maxParticipants: initialData.participants?.maxCount?.toString() ?? null,
+ startDate: initialData.schedule?.startDateTime
+ ? new Date(initialData.schedule.startDateTime)
+ : null,
+ startTime: initialData.schedule?.startDateTime
+ ? extractTime(initialData.schedule.startDateTime)
+ : null,
+ endDate: initialData.schedule?.endDateTime ? new Date(initialData.schedule.endDateTime) : null,
+ endTime: initialData.schedule?.endDateTime
+ ? extractTime(initialData.schedule.endDateTime)
+ : null,
+ }
+}
+
+export const useMeetingForm = ({ gatheringMaxCount, initialData }: UseMeetingFormParams) => {
+ // 수정 모드 여부
+ const isEditMode = !!initialData
+
+ // 폼 상태를 단일 객체로 관리
+ const [formData, setFormData] = useState(() => getInitialFormData(initialData))
// 유효성 검사 에러 상태
const [errors, setErrors] = useState(null)
@@ -47,27 +112,41 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => {
// 시간 옵션 메모이제이션 (렌더링마다 재생성 방지)
const timeOptions = useMemo(() => generateTimeOptions(), [])
+ // initialData가 로드되면 상태 업데이트 (수정 모드)
+ useEffect(() => {
+ if (initialData) {
+ setFormData(getInitialFormData(initialData))
+ }
+ }, [initialData])
+
const validateForm = (): boolean => {
const newError: ValidationErrors = {}
- if (!bookId || !bookName) {
+ if (
+ !isEditMode &&
+ (!formData.bookId ||
+ !formData.bookName ||
+ !formData.bookThumbnail ||
+ !formData.bookAuthors ||
+ !formData.bookPublisher)
+ ) {
newError.bookId = '* 도서를 선택해주세요.'
}
- if (!startDate || !startTime || !endDate || !endTime) {
+ if (!formData.startDate || !formData.startTime || !formData.endDate || !formData.endTime) {
newError.schedule = '* 일정을 선택해주세요.'
} else {
// 시작/종료 일시 비교 (둘 다 있을 때만)
- const startDateTime = combineDateAndTime(startDate, startTime)
- const endDateTime = combineDateAndTime(endDate, endTime)
+ const startDateTime = combineDateAndTime(formData.startDate, formData.startTime)
+ const endDateTime = combineDateAndTime(formData.endDate, formData.endTime)
if (!isStartBeforeEnd(startDateTime, endDateTime)) {
newError.schedule = '* 종료 일정은 시작 일정보다 늦어야 합니다.'
}
}
- if (maxParticipants) {
- const participants = Number(maxParticipants)
+ if (formData.maxParticipants) {
+ const participants = Number(formData.maxParticipants)
if (isNaN(participants) || participants < 1 || participants > gatheringMaxCount) {
newError.maxParticipants = `현재 모임의 전체 멤버 수는 ${gatheringMaxCount}명이에요. 최대 ${gatheringMaxCount}명까지 참가 가능해요.`
}
@@ -75,10 +154,10 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => {
// 장소 검증: 4개 필드가 모두 있거나 모두 없어야 함
const locationFields = [
- locationName !== null,
- locationAddress !== null,
- latitude !== null,
- longitude !== null,
+ formData.locationName !== null,
+ formData.locationAddress !== null,
+ formData.latitude !== null,
+ formData.longitude !== null,
]
const filledCount = locationFields.filter(Boolean).length
@@ -103,50 +182,53 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => {
return true
}
- /**
- * 에러 설정/제거 내부 함수
- */
- const setError = (field: keyof ValidationErrors, message: string | null) => {
- setErrors((prev) => {
- if (!prev) return message ? { [field]: message } : null
- const updated = { ...prev, [field]: message }
- return updated
- })
- }
-
/**
* 에러 초기화 (특정 필드 또는 전체)
- * @param field - 초기화할 필드 (undefined면 전체 초기화)
*/
const clearError = (field?: keyof ValidationErrors) => {
if (field === undefined) {
- // 전체 에러 초기화
setErrors(null)
} else {
- // 특정 필드 에러 초기화
- setError(field, null)
+ setErrors((prev) => {
+ if (!prev) return null
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { [field]: _, ...rest } = prev
+ return Object.keys(rest).length > 0 ? rest : null
+ })
+ }
+ }
+
+ /**
+ * 폼 필드 업데이트 헬퍼
+ */
+ const updateField = (
+ field: K,
+ value: FormData[K],
+ errorField?: keyof ValidationErrors
+ ) => {
+ setFormData((prev) => ({ ...prev, [field]: value }))
+ if (errorField && errors?.[errorField]) {
+ clearError(errorField)
}
}
/**
* 종료 날짜 비활성화 조건
- * 시작 날짜보다 이전 날짜는 선택 불가
*/
const getEndDateDisabled = () => {
- if (!startDate || !startTime) return undefined
- return { before: startDate }
+ if (!formData.startDate || !formData.startTime) return undefined
+ return { before: formData.startDate }
}
/**
* 종료 시간 옵션 필터링
- * 같은 날짜인 경우 시작 시간 이후만 선택 가능
*/
const getEndTimeOptions = () => {
- if (!startDate || !endDate || !startTime) return timeOptions
+ if (!formData.startDate || !formData.endDate || !formData.startTime) return timeOptions
// 같은 날짜인 경우
- if (startDate.toDateString() === endDate.toDateString()) {
- return timeOptions.filter((option) => option.value > startTime)
+ if (formData.startDate.toDateString() === formData.endDate.toDateString()) {
+ return timeOptions.filter((option) => option.value > formData.startTime!)
}
return timeOptions
@@ -154,7 +236,6 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => {
/**
* 시작 날짜 비활성화 조건
- * 오늘은 불가, 내일부터 선택 가능
*/
const getStartDateDisabled = () => {
const tomorrow = new Date()
@@ -165,77 +246,37 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => {
}
/**
- * 시작 날짜 변경 (에러 초기화 포함)
+ * 도서 정보 변경 (에러 초기화 포함)
*/
- const handleStartDateChange = (date: Date | null) => {
- setStartDate(date)
- if (errors?.schedule) {
- clearError('schedule')
- }
- }
-
- /**
- * 시작 시간 변경 (에러 초기화 포함)
- */
- const handleStartTimeChange = (time: string) => {
- setStartTime(time)
- if (errors?.schedule) {
- clearError('schedule')
- }
- }
-
- /**
- * 종료 날짜 변경 (에러 초기화 포함)
- */
- const handleEndDateChange = (date: Date | null) => {
- setEndDate(date)
- if (errors?.schedule) {
- clearError('schedule')
- }
- }
-
- /**
- * 종료 시간 변경 (에러 초기화 포함)
- */
- const handleEndTimeChange = (time: string) => {
- setEndTime(time)
- if (errors?.schedule) {
- clearError('schedule')
- }
- }
-
- /**
- * 참가 인원 변경 (에러 초기화 포함)
- */
- const handleMaxParticipantsChange = (value: string) => {
- setMaxParticipants(value)
- if (errors?.maxParticipants) {
- clearError('maxParticipants')
+ const handleBookChange = (book: Omit) => {
+ setFormData((prev) => ({
+ ...prev,
+ bookId: book.isbn,
+ bookName: book.title,
+ bookThumbnail: book.thumbnail,
+ bookAuthors: book.authors.join(', '),
+ bookPublisher: book.publisher,
+ }))
+ if (errors?.bookId) {
+ clearError('bookId')
}
}
/**
* 선택된 일정 텍스트 생성
- * 시작/종료 날짜와 시간이 모두 선택된 경우 포맷된 문자열 반환
*/
- const formattedSchedule = formatScheduleRange(startDate, startTime, endDate, endTime)
+ const formattedSchedule = formatScheduleRange(
+ formData.startDate,
+ formData.startTime,
+ formData.endDate,
+ formData.endTime
+ )
return {
+ // 모드
+ isEditMode,
// 폼 데이터
- formData: {
- meetingName,
- bookId,
- bookName,
- locationName,
- locationAddress,
- latitude,
- longitude,
- maxParticipants,
- startDate,
- startTime,
- endDate,
- endTime,
- },
+ formData,
// 시간 옵션
timeOptions,
// 유효성 검사
@@ -256,18 +297,18 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => {
},
// 상태 업데이트 핸들러
handlers: {
- setMeetingName,
- setBookId,
- setBookName,
- setMaxParticipants: handleMaxParticipantsChange,
- setStartDate: handleStartDateChange,
- setStartTime: handleStartTimeChange,
- setEndDate: handleEndDateChange,
- setEndTime: handleEndTimeChange,
- setLocationAddress,
- setLocationName,
- setLatitude,
- setLongitude,
+ setMeetingName: (value: string) => updateField('meetingName', value),
+ setBook: handleBookChange,
+ setMaxParticipants: (value: string) =>
+ updateField('maxParticipants', value, 'maxParticipants'),
+ setStartDate: (date: Date | null) => updateField('startDate', date, 'schedule'),
+ setStartTime: (time: string) => updateField('startTime', time, 'schedule'),
+ setEndDate: (date: Date | null) => updateField('endDate', date, 'schedule'),
+ setEndTime: (time: string) => updateField('endTime', time, 'schedule'),
+ setLocationAddress: (address: string | null) => updateField('locationAddress', address),
+ setLocationName: (name: string | null) => updateField('locationName', name),
+ setLatitude: (lat: number | null) => updateField('latitude', lat),
+ setLongitude: (lng: number | null) => updateField('longitude', lng),
},
}
}
diff --git a/src/features/meetings/hooks/useMyMeetingTabCounts.ts b/src/features/meetings/hooks/useMyMeetingTabCounts.ts
new file mode 100644
index 0000000..a402ee0
--- /dev/null
+++ b/src/features/meetings/hooks/useMyMeetingTabCounts.ts
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+
+import { getMyMeetingTabCounts } from '../meetings.api'
+import { myMeetingQueryKeys } from './myMeetingQueryKeys'
+
+export function useMyMeetingTabCounts() {
+ return useQuery({
+ queryKey: myMeetingQueryKeys.tabCounts(),
+ queryFn: getMyMeetingTabCounts,
+ })
+}
diff --git a/src/features/meetings/hooks/useMyMeetings.ts b/src/features/meetings/hooks/useMyMeetings.ts
new file mode 100644
index 0000000..269fe68
--- /dev/null
+++ b/src/features/meetings/hooks/useMyMeetings.ts
@@ -0,0 +1,22 @@
+import { useInfiniteQuery } from '@tanstack/react-query'
+
+import { PAGE_SIZES } from '@/shared/constants'
+
+import { getMyMeetings } from '../meetings.api'
+import type { MyMeetingFilter, MyMeetingListResponse } from '../meetings.types'
+import { myMeetingQueryKeys } from './myMeetingQueryKeys'
+
+export function useMyMeetings(filter: MyMeetingFilter) {
+ return useInfiniteQuery({
+ queryKey: myMeetingQueryKeys.list(filter),
+ queryFn: ({ pageParam }) =>
+ getMyMeetings({
+ filter,
+ startDateTime: pageParam?.startDateTime,
+ meetingId: pageParam?.meetingId,
+ size: PAGE_SIZES.MY_MEETINGS,
+ }),
+ initialPageParam: undefined as MyMeetingListResponse['nextCursor'] | undefined,
+ getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined),
+ })
+}
diff --git a/src/features/meetings/hooks/usePlaceSearch.ts b/src/features/meetings/hooks/usePlaceSearch.ts
new file mode 100644
index 0000000..6386dd2
--- /dev/null
+++ b/src/features/meetings/hooks/usePlaceSearch.ts
@@ -0,0 +1,168 @@
+/**
+ * @file usePlaceSearch.ts
+ * @description 장소 검색 모달의 상태 관리 훅
+ *
+ * SDK 로드, 지도 초기화, 장소 검색 API 등 모든 비동기 로직과 에러를
+ * 하나의 searchState로 추상화하여 UI가 선언적으로 상태를 수신할 수 있도록 합니다.
+ *
+ * searchState:
+ * - 'idle' 초기 화면 (검색 전)
+ * - 'searching' 검색 중 (리스트 스켈레톤 표시)
+ * - 'hasResults' 검색 결과 있음 (지도 + 리스트 표시)
+ * - 'noResults' 일치하는 결과 없음
+ * - 'error' SDK 로드 실패 또는 검색 API 오류
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+import type { KakaoMap, KakaoPlace } from '@/features/kakaomap'
+import { useKakaoLoader, useKakaoPlaceSearch } from '@/features/kakaomap'
+
+export type PlaceSearchState = 'idle' | 'searching' | 'hasResults' | 'noResults' | 'error'
+
+type SelectedPlace = {
+ name: string
+ address: string
+ latitude: number
+ longitude: number
+}
+
+export type UsePlaceSearchOptions = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSelectPlace: (place: SelectedPlace) => void
+}
+
+export function usePlaceSearch({ open, onOpenChange, onSelectPlace }: UsePlaceSearchOptions) {
+ const [sdkLoading, sdkError] = useKakaoLoader()
+
+ const [searchState, setSearchState] = useState('idle')
+ const [mapInstance, setMapInstance] = useState(null)
+ const [hoveredPlaceId, setHoveredPlaceId] = useState(null)
+
+ // 카카오 Map SDK는 마운트 시점의 컨테이너 크기로 지도를 초기화합니다.
+ // display:none 상태에서 마운트되면 크기가 0으로 계산되어 지도가 깨지므로,
+ // 첫 검색이 실행되어 지도 영역이 화면에 보이는 시점에 처음 마운트합니다.
+ const [hasBeenSearched, setHasBeenSearched] = useState(false)
+
+ const keywordRef = useRef(null)
+
+ const {
+ places,
+ error: searchError,
+ search,
+ reset,
+ } = useKakaoPlaceSearch({
+ onSearchSuccess: (results) => {
+ setSearchState(results.length > 0 ? 'hasResults' : 'noResults')
+ },
+ onSearchError: () => {
+ setSearchState('error')
+ },
+ })
+
+ // SDK 로드 실패 시 error 상태로 전환
+ const effectiveSearchState: PlaceSearchState = sdkError ? 'error' : searchState
+
+ // places 또는 mapInstance가 준비되면 지도 범위를 자동 조정
+ // (첫 검색 시 places가 먼저 오거나 mapInstance가 먼저 올 수 있으므로 둘 다 dep에 포함)
+ useEffect(() => {
+ if (!mapInstance || places.length === 0) return
+
+ const { kakao } = window
+ const bounds = new kakao.maps.LatLngBounds()
+ places.forEach((p) => bounds.extend(new kakao.maps.LatLng(Number(p.y), Number(p.x))))
+ mapInstance.setBounds(bounds)
+ }, [mapInstance, places])
+
+ const resetState = useCallback(() => {
+ setSearchState('idle')
+ setHoveredPlaceId(null)
+ setHasBeenSearched(false)
+ setMapInstance(null)
+ reset()
+ }, [reset])
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key !== 'Enter') return
+ // 한국어 등 IME 입력 중 composition 이벤트는 무시
+ if (e.nativeEvent.isComposing) return
+ e.preventDefault()
+
+ const keyword = keywordRef.current?.value.trim() ?? ''
+ if (!keyword) return
+
+ // SDK 로드 실패 상태에서는 검색 불가
+ if (sdkError) return
+
+ // SDK 아직 로드 중이라면 무시
+ if (sdkLoading) return
+
+ reset()
+ setHoveredPlaceId(null)
+ setSearchState('searching')
+ setHasBeenSearched(true)
+ search(keyword)
+ },
+ [sdkLoading, sdkError, reset, search]
+ )
+
+ const handlePlaceClick = useCallback(
+ (place: KakaoPlace) => {
+ if (mapInstance) {
+ mapInstance.setCenter(new window.kakao.maps.LatLng(Number(place.y), Number(place.x)))
+ }
+ onSelectPlace({
+ name: place.place_name,
+ address: place.road_address_name || place.address_name,
+ latitude: Number(place.y),
+ longitude: Number(place.x),
+ })
+ onOpenChange(false)
+ resetState()
+ },
+ [mapInstance, onSelectPlace, onOpenChange, resetState]
+ )
+
+ const handlePlaceFocus = useCallback(
+ (place: KakaoPlace) => {
+ if (!mapInstance) return
+ mapInstance.setLevel(4)
+ mapInstance.setCenter(new window.kakao.maps.LatLng(Number(place.y), Number(place.x)))
+ },
+ [mapInstance]
+ )
+
+ const handleClose = useCallback(() => {
+ onOpenChange(false)
+ resetState()
+ }, [onOpenChange, resetState])
+
+ // error 상태에서 노출할 메시지 — SDK 오류 우선
+ const errorMessage = sdkError?.message ?? searchError ?? '오류가 발생했습니다. 다시 시도해주세요.'
+
+ // Map 컴포넌트를 DOM에 마운트할지 여부
+ // error 상태는 인스턴스 보존이 불필요하므로 unmount (다음 검색 시 새로 초기화)
+ const isMapMounted = open && hasBeenSearched && effectiveSearchState !== 'error'
+
+ // 지도 영역을 화면에 표시할지 여부 (isMapMounted가 true일 때만 유의미)
+ // noResults에서는 Map 인스턴스를 유지한 채 CSS로만 숨김 → 재검색 시 재초기화 없이 재사용
+ const isMapVisible = effectiveSearchState === 'searching' || effectiveSearchState === 'hasResults'
+
+ return {
+ searchState: effectiveSearchState,
+ errorMessage,
+ places,
+ isMapMounted,
+ isMapVisible,
+ hoveredPlaceId,
+ keywordRef,
+ setMapInstance,
+ setHoveredPlaceId,
+ handleKeyDown,
+ handlePlaceClick,
+ handlePlaceFocus,
+ handleClose,
+ }
+}
diff --git a/src/features/meetings/hooks/useRejectMeeting.ts b/src/features/meetings/hooks/useRejectMeeting.ts
index a1bf449..b0e81f9 100644
--- a/src/features/meetings/hooks/useRejectMeeting.ts
+++ b/src/features/meetings/hooks/useRejectMeeting.ts
@@ -6,6 +6,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { ApiError, ApiResponse } from '@/api'
+import { gatheringQueryKeys } from '@/features/gatherings'
import { rejectMeeting, type RejectMeetingResponse } from '@/features/meetings'
import { meetingQueryKeys } from './meetingQueryKeys'
@@ -17,12 +18,8 @@ import { meetingQueryKeys } from './meetingQueryKeys'
* 약속을 거부하고 관련 쿼리 캐시를 무효화합니다.
* - 약속 승인 리스트 캐시 무효화
* - 약속 승인 카운트 캐시 무효화
- *
- * @example
- * const rejectMutation = useRejectMeeting()
- * rejectMutation.mutate(meetingId)
*/
-export const useRejectMeeting = () => {
+export const useRejectMeeting = (gatheringId: number) => {
const queryClient = useQueryClient()
return useMutation, ApiError, number>({
@@ -32,6 +29,8 @@ export const useRejectMeeting = () => {
queryClient.invalidateQueries({
queryKey: meetingQueryKeys.approvals(),
})
+ // 모임 약속 리스트 캐시 무효화
+ queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.meetings(gatheringId) })
},
})
}
diff --git a/src/features/meetings/hooks/useUpdateMeeting.ts b/src/features/meetings/hooks/useUpdateMeeting.ts
new file mode 100644
index 0000000..7199fc8
--- /dev/null
+++ b/src/features/meetings/hooks/useUpdateMeeting.ts
@@ -0,0 +1,46 @@
+/**
+ * @file useUpdateMeeting.ts
+ * @description 약속 수정 mutation 훅
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { ApiError } from '@/api/errors'
+import type { ApiResponse } from '@/api/types'
+import {
+ updateMeeting,
+ type UpdateMeetingRequest,
+ type UpdateMeetingResponse,
+} from '@/features/meetings'
+
+import { meetingQueryKeys } from './meetingQueryKeys'
+
+type UpdateMeetingVariables = {
+ meetingId: number
+ data: UpdateMeetingRequest
+}
+
+/**
+ * 약속 수정 mutation 훅
+ *
+ * @description
+ * 약속 정보를 수정하고 관련 쿼리 캐시를 무효화합니다.
+ */
+export const useUpdateMeeting = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation, ApiError, UpdateMeetingVariables>({
+ mutationFn: ({ meetingId, data }: UpdateMeetingVariables) => updateMeeting(meetingId, data),
+ onSuccess: (_, variables) => {
+ // 수정된 약속의 상세 정보 캐시 무효화
+ queryClient.invalidateQueries({
+ queryKey: meetingQueryKeys.detail(variables.meetingId),
+ })
+
+ // 약속 승인 관련 모든 캐시 무효화
+ queryClient.invalidateQueries({
+ queryKey: meetingQueryKeys.approvals(),
+ })
+ },
+ })
+}
diff --git a/src/features/meetings/index.ts b/src/features/meetings/index.ts
index e0d6b0e..3e59a6f 100644
--- a/src/features/meetings/index.ts
+++ b/src/features/meetings/index.ts
@@ -11,21 +11,25 @@ export * from './lib'
export * from './meetings.api'
// Types
-export type {
- KakaoPlace,
- KakaoSearchMeta,
- KakaoSearchParams,
- KakaoSearchResponse,
-} from './kakaoMap.types'
export type {
ConfirmMeetingResponse,
CreateMeetingRequest,
CreateMeetingResponse,
GetMeetingApprovalsParams,
GetMeetingDetailResponse,
+ GetMyMeetingsParams,
MeetingApprovalItem as MeetingApprovalItemType,
MeetingDetailActionStateType,
MeetingLocation,
MeetingStatus,
+ MyMeetingCursor,
+ MyMeetingFilter,
+ MyMeetingListItem,
+ MyMeetingListResponse,
+ MyMeetingProgressStatus,
+ MyMeetingRole,
+ MyMeetingTabCountsResponse,
RejectMeetingResponse,
+ UpdateMeetingRequest,
+ UpdateMeetingResponse,
} from './meetings.types'
diff --git a/src/features/meetings/kakaoMap.types.ts b/src/features/meetings/kakaoMap.types.ts
deleted file mode 100644
index 2f13f71..0000000
--- a/src/features/meetings/kakaoMap.types.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * @file kakao.types.ts
- * @description 카카오 로컬 API 관련 타입 정의
- * @note 외부 API 응답 스펙을 따르기 위해 snake_case 사용
- */
-
-/* eslint-disable @typescript-eslint/naming-convention */
-
-/**
- * 카카오 장소 검색 응답 문서 타입
- */
-export type KakaoPlace = {
- /** 장소명, 업체명 */
- place_name: string
- /** 전체 지번 주소 */
- address_name: string
- /** 전체 도로명 주소 */
- road_address_name: string
- /** X 좌표값, 경도(longitude) */
- x: string
- /** Y 좌표값, 위도(latitude) */
- y: string
- /** 장소 ID */
- id: string
- /** 카테고리 그룹 코드 */
- category_group_code: string
- /** 카테고리 그룹명 */
- category_group_name: string
- /** 카테고리 이름 */
- category_name: string
- /** 전화번호 */
- phone: string
- /** 장소 상세페이지 URL */
- place_url: string
- /** 중심좌표까지의 거리 (단, x,y 파라미터를 준 경우에만 존재) */
- distance?: string
-}
-
-/**
- * 카카오 장소 검색 API 응답 메타 정보
- */
-export type KakaoSearchMeta = {
- /** 검색된 문서 수 */
- total_count: number
- /** total_count 중 노출 가능 문서 수 */
- pageable_count: number
- /** 현재 페이지가 마지막 페이지인지 여부 */
- is_end: boolean
- /** 질의어의 지역 및 키워드 분석 정보 */
- same_name?: {
- /** 질의어에서 인식된 지역의 리스트 */
- region: string[]
- /** 질의어에서 지역 정보를 제외한 키워드 */
- keyword: string
- /** 인식된 지역 리스트 중, 현재 검색에 사용된 지역 정보 */
- selected_region: string
- }
-}
-
-/**
- * 카카오 장소 검색 API 응답 타입
- */
-export type KakaoSearchResponse = {
- /** 검색 결과 문서 리스트 */
- documents: KakaoPlace[]
- /** 응답 관련 정보 */
- meta: KakaoSearchMeta
-}
-
-/**
- * 카카오 장소 검색 API 요청 파라미터
- */
-export type KakaoSearchParams = {
- /** 검색을 원하는 질의어 (필수) */
- query: string
- /** 카테고리 그룹 코드 (선택) */
- category_group_code?: string
- /** 중심 좌표의 X 혹은 경도(longitude) */
- x?: string
- /** 중심 좌표의 Y 혹은 위도(latitude) */
- y?: string
- /** 중심 좌표부터의 반경거리. 미터(m) 단위 */
- radius?: number
- /** 결과 페이지 번호 (1~45, 기본값: 1) */
- page?: number
- /** 한 페이지에 보여질 문서의 개수 (1~15, 기본값: 15) */
- size?: number
- /** 결과 정렬 순서 (distance: 거리순, accuracy: 정확도순) */
- sort?: 'distance' | 'accuracy'
-}
diff --git a/src/features/meetings/meetings.api.ts b/src/features/meetings/meetings.api.ts
index a1989f4..e21ab9b 100644
--- a/src/features/meetings/meetings.api.ts
+++ b/src/features/meetings/meetings.api.ts
@@ -13,17 +13,18 @@ import type {
CreateMeetingResponse,
GetMeetingApprovalsParams,
GetMeetingDetailResponse,
+ GetMyMeetingsParams,
MeetingApprovalItem,
+ MyMeetingListResponse,
+ MyMeetingTabCountsResponse,
RejectMeetingResponse,
+ UpdateMeetingRequest,
+ UpdateMeetingResponse,
} from '@/features/meetings/meetings.types'
import { PAGE_SIZES } from '@/shared/constants'
-/**
- * 목데이터 사용 여부
- * @description 로그인 기능 개발 전까지 true로 설정하여 목데이터 사용
- * TODO: 로그인 기능 완료 후 false로 변경하여 실제 API 호출
- */
-const USE_MOCK_DATA = true
+/** 목데이터 사용 여부 플래그 */
+const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'
/**
* 약속 승인 리스트 조회
@@ -48,7 +49,7 @@ export const getMeetingApprovals = async (
// 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용
// TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거
- if (USE_MOCK_DATA) {
+ if (USE_MOCK) {
// 실제 API 호출을 시뮬레이션하기 위한 지연
await new Promise((resolve) => setTimeout(resolve, 500))
return getMockMeetingApprovals(status, page, size)
@@ -137,7 +138,7 @@ export const deleteMeeting = async (meetingId: number) => {
export const getMeetingDetail = async (meetingId: number): Promise => {
// 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용
// TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거
- if (USE_MOCK_DATA) {
+ if (USE_MOCK) {
// 실제 API 호출을 시뮬레이션하기 위한 지연
await new Promise((resolve) => setTimeout(resolve, 500))
return getMockMeetingDetail(meetingId)
@@ -194,6 +195,54 @@ export const cancelJoinMeeting = async (meetingId: number) => {
* - B001: 책을 찾을 수 없습니다.
*/
export const createMeeting = async (data: CreateMeetingRequest) => {
- const response = await apiClient.post>('/api/meetings', data)
+ const response = await apiClient.post>(
+ MEETINGS_ENDPOINTS.CREATE,
+ data
+ )
return response.data
}
+
+/**
+ * 약속 수정
+ *
+ * @description
+ * 약속 정보를 수정합니다.
+ * 책 정보는 수정할 수 없습니다.
+ *
+ * @param meetingId - 약속 ID
+ * @param data - 약속 수정 요청 데이터
+ *
+ * @returns 수정된 약속 정보
+ *
+ * @throws
+ * - M001: 약속을 찾을 수 없습니다.
+ * - M013: 최대 참가 인원이 유효하지 않습니다.
+ */
+export const updateMeeting = async (meetingId: number, data: UpdateMeetingRequest) => {
+ const response = await apiClient.patch>(
+ MEETINGS_ENDPOINTS.UPDATE(meetingId),
+ data
+ )
+ return response.data
+}
+
+/**
+ * 메인페이지 내 약속 리스트 조회
+ *
+ * @param params - 조회 파라미터 (filter, cursor, size)
+ * @returns 내 약속 리스트 (커서 기반 페이지네이션)
+ */
+export const getMyMeetings = async (
+ params: GetMyMeetingsParams
+): Promise => {
+ return api.get(MEETINGS_ENDPOINTS.MY_MEETINGS, { params })
+}
+
+/**
+ * 메인페이지 내 약속 탭 카운트 조회
+ *
+ * @returns 탭별 약속 카운트 (all, upcoming, done)
+ */
+export const getMyMeetingTabCounts = async (): Promise => {
+ return api.get(MEETINGS_ENDPOINTS.MY_MEETING_TAB_COUNTS)
+}
diff --git a/src/features/meetings/meetings.endpoints.ts b/src/features/meetings/meetings.endpoints.ts
index e5fc740..e153a18 100644
--- a/src/features/meetings/meetings.endpoints.ts
+++ b/src/features/meetings/meetings.endpoints.ts
@@ -7,6 +7,9 @@ export const MEETINGS_ENDPOINTS = {
// 약속 상세 조회 (GET /api/meetings/{meetingId})
DETAIL: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}`,
+ // 약속 생성 (POST /api/meetings)
+ CREATE: `${API_PATHS.MEETINGS}`,
+
// 약속 거부 (POST /api/meetings/{meetingId}/reject)
REJECT: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}/reject`,
@@ -21,4 +24,13 @@ export const MEETINGS_ENDPOINTS = {
// 약속 참가취소 (DELETE /api/meetings/{meetingId}/join)
CANCEL_JOIN: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}/join`,
+
+ // 약속 수정 (PATCH /api/meetings/{meetingId})
+ UPDATE: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}`,
+
+ // 메인페이지 내 약속 리스트 조회 (GET /api/meetings/me)
+ MY_MEETINGS: `${API_PATHS.MEETINGS}/me`,
+
+ // 메인페이지 내 약속 탭 카운트 조회 (GET /api/meetings/me/tab-counts)
+ MY_MEETING_TAB_COUNTS: `${API_PATHS.MEETINGS}/me/tab-counts`,
} as const
diff --git a/src/features/meetings/meetings.mock.ts b/src/features/meetings/meetings.mock.ts
index 60863c1..c6f7be4 100644
--- a/src/features/meetings/meetings.mock.ts
+++ b/src/features/meetings/meetings.mock.ts
@@ -218,6 +218,8 @@ const mockMeetingDetails: Record = {
bookId: 1001,
bookName: '클린 코드',
thumbnail: 'https://picsum.photos/seed/cleancode/200/300',
+ authors: '로버트 C. 마틴',
+ publisher: '인사이트',
},
schedule: {
startDateTime: '2026-02-01T14:00:00',
@@ -259,7 +261,7 @@ const mockMeetingDetails: Record = {
buttonLabel: '약속이 끝났어요',
enabled: false,
},
- confirmedTopicExpand: true,
+ confirmedTopic: true,
confirmedTopicDate: '2026-01-20T14:00:00',
},
11: {
@@ -267,7 +269,7 @@ const mockMeetingDetails: Record = {
progressStatus: 'PRE',
meetingName: '킥오프 모임',
meetingStatus: 'CONFIRMED',
- confirmedTopicExpand: false,
+ confirmedTopic: false,
confirmedTopicDate: null,
gathering: {
gatheringId: 102,
@@ -277,6 +279,8 @@ const mockMeetingDetails: Record = {
bookId: 1002,
bookName: '실용주의 프로그래머',
thumbnail: 'https://picsum.photos/seed/pragmatic/200/300',
+ authors: '데이비드 토머스, 앤드류 헌트',
+ publisher: '인사이트',
},
schedule: {
startDateTime: '2026-02-11T14:00:00',
diff --git a/src/features/meetings/meetings.types.ts b/src/features/meetings/meetings.types.ts
index a2333bf..35381f5 100644
--- a/src/features/meetings/meetings.types.ts
+++ b/src/features/meetings/meetings.types.ts
@@ -3,6 +3,8 @@
* @description Meeting API 관련 타입 정의
*/
+import type { CreateBookBody } from '@/features/book'
+
/**
* 약속 상태 타입
*/
@@ -84,8 +86,8 @@ export type MeetingLocation = {
export type CreateMeetingRequest = {
/** 모임 ID */
gatheringId: number
- /** 책 ID */
- bookId: number
+ /** 책 정보 */
+ book: CreateBookBody
/** 약속 이름 */
meetingName: string
/** 약속 시작 일시 (ISO 8601 형식) */
@@ -99,7 +101,7 @@ export type CreateMeetingRequest = {
}
/**
- * 약속 생성 응답 타입
+ * 약속 생성 응답 타입 Todo:실제 응답값이랑 비교해봐야 함
*/
export type CreateMeetingResponse = {
/** 약속 ID */
@@ -117,6 +119,9 @@ export type CreateMeetingResponse = {
book: {
bookId: number
bookName: string
+ thumbnail: string
+ authors: string
+ publisher: string
}
/** 일정 정보 */
schedule: {
@@ -139,6 +144,40 @@ export type CreateMeetingResponse = {
}
}
+/**
+ * 약속 수정 요청 타입
+ */
+export type UpdateMeetingRequest = {
+ /** 약속 이름 */
+ meetingName: string
+ /** 약속 시작 일시 (ISO 8601 형식) */
+ startDate: string
+ /** 약속 종료 일시 (ISO 8601 형식) */
+ endDate: string
+ /** 장소 (선택 사항) */
+ location: MeetingLocation | null
+ /** 최대 참가 인원 */
+ maxParticipants: number
+}
+
+/**
+ * 약속 수정 응답 타입
+ */
+export type UpdateMeetingResponse = {
+ /** 약속 ID */
+ meetingId: number
+ /** 약속 이름 */
+ meetingName: string
+ /** 약속 시작 일시 (ISO 8601 형식) */
+ startDate: string
+ /** 약속 종료 일시 (ISO 8601 형식) */
+ endDate: string
+ /** 장소 (선택 사항) */
+ location: MeetingLocation | null
+ /** 최대 참가 인원 */
+ maxParticipants: number
+}
+
/**
* 약속 일정 타입
*/
@@ -176,7 +215,7 @@ export type GetMeetingDetailResponse = {
/** 약속 진행 상태 */
progressStatus: MeetingProgressStatus
/** 주제 확정 여부 */
- confirmedTopicExpand: boolean
+ confirmedTopic: boolean
/** 주제 확정 일시 */
confirmedTopicDate: string | null
/** 모임 정보 */
@@ -189,6 +228,8 @@ export type GetMeetingDetailResponse = {
bookId: number
bookName: string
thumbnail: string
+ authors: string
+ publisher: string
}
/** 일정 정보 */
schedule: MeetingSchedule
@@ -212,3 +253,61 @@ export type GetMeetingDetailResponse = {
enabled: boolean
}
}
+
+// ============================================================
+// 메인페이지 내 약속 리스트 관련 타입
+// ============================================================
+
+/** 메인페이지 약속 진행 상태 (시간 기준) */
+export type MyMeetingProgressStatus = 'UPCOMING' | 'ONGOING' | 'DONE' | 'UNKNOWN'
+
+/** 메인페이지 내 역할 */
+export type MyMeetingRole = 'LEADER' | 'GATHERING_LEADER' | 'MEMBER' | 'NONE'
+
+/** 메인페이지 약속 필터 */
+export type MyMeetingFilter = 'ALL' | 'UPCOMING' | 'DONE'
+
+/** 메인페이지 내 약속 아이템 */
+export interface MyMeetingListItem {
+ meetingId: number
+ meetingName: string
+ gatheringId: number
+ gatheringName: string
+ meetingLeaderName: string
+ bookName: string
+ startDateTime: string
+ endDateTime: string
+ meetingStatus: MeetingStatus | 'REJECTED' | 'DONE'
+ myRole: MyMeetingRole
+ progressStatus: MyMeetingProgressStatus
+}
+
+/** 메인페이지 내 약속 커서 */
+export interface MyMeetingCursor {
+ startDateTime: string
+ meetingId: number
+}
+
+/** 메인페이지 내 약속 리스트 응답 */
+export interface MyMeetingListResponse {
+ items: MyMeetingListItem[]
+ totalCount: number
+ pageSize: number
+ hasNext: boolean
+ nextCursor: MyMeetingCursor | null
+}
+
+/** 메인페이지 내 약속 조회 파라미터 */
+export interface GetMyMeetingsParams {
+ filter: MyMeetingFilter
+ startDateTime?: string
+ meetingId?: number
+ size?: number
+}
+
+/** 메인페이지 내 약속 탭 카운트 응답 */
+export interface MyMeetingTabCountsResponse {
+ all: number
+ upcoming: number
+ done: number
+}
diff --git a/src/features/pre-opinion/components/BookReviewSection.tsx b/src/features/pre-opinion/components/BookReviewSection.tsx
new file mode 100644
index 0000000..8be7ef3
--- /dev/null
+++ b/src/features/pre-opinion/components/BookReviewSection.tsx
@@ -0,0 +1,59 @@
+import {
+ BookReviewForm,
+ type BookReviewFormValues,
+} from '@/features/book/components/BookReviewForm'
+import type { PreOpinionReview } from '@/features/pre-opinion/preOpinion.types'
+import { Container } from '@/shared/ui'
+
+/**
+ * 사전 의견 작성 페이지의 책 평가 섹션
+ *
+ * @description 기존 평가가 있으면 해당 데이터를 채워서 보여주고,
+ * 없으면 빈 폼을 보여줘서 사용자가 평가를 남길 수 있게 합니다.
+ * 제출은 상위 페이지에서 일괄 처리합니다.
+ *
+ * @example
+ * ```tsx
+ * setReviewValues(values)} />
+ * ```
+ */
+interface BookReviewSectionProps {
+ review: PreOpinionReview | null
+ onChange?: (values: BookReviewFormValues) => void
+}
+
+const BookReviewSection = ({ review, onChange }: BookReviewSectionProps) => {
+ const hasReview = review !== null
+
+ return (
+
+
+ 이 책은 어떠셨나요?
+
+
+ k.id)
+ .sort()
+ .join(',')}`
+ : 'empty'
+ }
+ initialRating={review?.rating ?? 0}
+ initialKeywordIds={review?.keywords.map((k) => k.id) ?? []}
+ onChange={onChange}
+ />
+
+
+ )
+}
+
+export default BookReviewSection
diff --git a/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx b/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx
new file mode 100644
index 0000000..9975621
--- /dev/null
+++ b/src/features/pre-opinion/components/PreOpinionWriteHeader.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useRef, useState } from 'react'
+
+import type { PreOpinionBook } from '@/features/pre-opinion/preOpinion.types'
+import { cn } from '@/shared/lib/utils'
+import { Button } from '@/shared/ui'
+
+import { formatUpdatedAt } from '../lib/date'
+
+interface PreOpinionWriteHeaderProps {
+ book: PreOpinionBook
+ updatedAt: string | null
+ onSave: () => void
+ onSubmit: () => void
+ isSaving?: boolean
+ isSubmitting?: boolean
+ isReviewValid?: boolean
+}
+
+/**
+ * 사전 의견 작성 페이지 헤더
+ *
+ * @description
+ * 책 제목, 저자, 마지막 저장 시각을 표시하고
+ * 스크롤 시 sticky로 고정되며 하단 그림자가 생깁니다.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+const PreOpinionWriteHeader = ({
+ book,
+ updatedAt,
+ onSave,
+ onSubmit,
+ isSaving,
+ isSubmitting,
+ isReviewValid = false,
+}: PreOpinionWriteHeaderProps) => {
+ const sentinelRef = useRef(null)
+ const [isStuck, setIsStuck] = useState(false)
+
+ useEffect(() => {
+ const sentinel = sentinelRef.current
+ if (!sentinel) return
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ setIsStuck(!entry.isIntersecting)
+ },
+ { threshold: 0 }
+ )
+
+ observer.observe(sentinel)
+ return () => observer.disconnect()
+ }, [])
+
+ return (
+ <>
+
+
+
+
+
+
사전 의견 작성하기
+
+ {book.title} · {book.author}
+
+
+
+ {updatedAt && (
+
{formatUpdatedAt(updatedAt)}
+ )}
+
+
+
+
+
+
+ >
+ )
+}
+
+export default PreOpinionWriteHeader
diff --git a/src/features/pre-opinion/components/TopicItem.tsx b/src/features/pre-opinion/components/TopicItem.tsx
new file mode 100644
index 0000000..f3bfee5
--- /dev/null
+++ b/src/features/pre-opinion/components/TopicItem.tsx
@@ -0,0 +1,45 @@
+import { useState } from 'react'
+
+import type { PreOpinionTopic } from '@/features/pre-opinion/preOpinion.types'
+import { Badge, Container, Textarea } from '@/shared/ui'
+
+interface TopicItemProps {
+ topic: PreOpinionTopic
+ onChange?: (topicId: number, content: string) => void
+}
+
+function TopicItem({ topic, onChange }: TopicItemProps) {
+ const [value, setValue] = useState(topic.content ?? '')
+ const [prevContent, setPrevContent] = useState(topic.content)
+
+ if (topic.content !== prevContent) {
+ setPrevContent(topic.content)
+ setValue(topic.content ?? '')
+ }
+
+ return (
+
+ {topic.topicTypeLabel}}
+ >
+ {`주제 ${topic.confirmOrder}. ${topic.topicTitle}`}
+
+
+
+
{topic.topicDescription}
+
+
+
+ )
+}
+
+export default TopicItem
diff --git a/src/features/pre-opinion/hooks/index.ts b/src/features/pre-opinion/hooks/index.ts
new file mode 100644
index 0000000..46f7dec
--- /dev/null
+++ b/src/features/pre-opinion/hooks/index.ts
@@ -0,0 +1,4 @@
+export * from './preOpinionQueryKeys'
+export * from './usePreOpinion'
+export * from './useSavePreOpinion'
+export * from './useSubmitPreOpinion'
diff --git a/src/features/pre-opinion/hooks/preOpinionQueryKeys.ts b/src/features/pre-opinion/hooks/preOpinionQueryKeys.ts
new file mode 100644
index 0000000..5d5057f
--- /dev/null
+++ b/src/features/pre-opinion/hooks/preOpinionQueryKeys.ts
@@ -0,0 +1,13 @@
+/**
+ * @file preOpinionQueryKeys.ts
+ * @description 사전 의견 관련 Query Key Factory
+ */
+
+import type { GetPreOpinionParams } from '@/features/pre-opinion/preOpinion.types'
+
+export const preOpinionQueryKeys = {
+ all: ['preOpinions'] as const,
+
+ details: () => [...preOpinionQueryKeys.all, 'detail'] as const,
+ detail: (params: GetPreOpinionParams) => [...preOpinionQueryKeys.details(), params] as const,
+}
diff --git a/src/features/pre-opinion/hooks/usePreOpinion.ts b/src/features/pre-opinion/hooks/usePreOpinion.ts
new file mode 100644
index 0000000..85301ac
--- /dev/null
+++ b/src/features/pre-opinion/hooks/usePreOpinion.ts
@@ -0,0 +1,44 @@
+/**
+ * @file usePreOpinion.ts
+ * @description 사전 의견 조회 훅
+ */
+
+import { useQuery } from '@tanstack/react-query'
+
+import type { ApiError } from '@/api'
+import { getPreOpinion } from '@/features/pre-opinion/preOpinion.api'
+import type {
+ GetPreOpinionParams,
+ GetPreOpinionResponse,
+} from '@/features/pre-opinion/preOpinion.types'
+
+import { preOpinionQueryKeys } from './preOpinionQueryKeys'
+
+/**
+ * 사전 의견 조회 훅
+ *
+ * @description
+ * TanStack Query를 사용하여 사전 의견 정보를 조회합니다.
+ * 책 정보, 리뷰(평가), 주제별 사전 의견 내용을 포함합니다.
+ *
+ * @param params - 모임 ID와 약속 ID
+ *
+ * @returns TanStack Query 결과 객체
+ *
+ * @example
+ * ```tsx
+ * const { data, isLoading } = usePreOpinion({ gatheringId: 1, meetingId: 2 })
+ * ```
+ */
+export const usePreOpinion = (params: GetPreOpinionParams) => {
+ const { gatheringId, meetingId } = params
+ const isValidParams =
+ !Number.isNaN(gatheringId) && gatheringId > 0 && !Number.isNaN(meetingId) && meetingId > 0
+
+ return useQuery({
+ queryKey: preOpinionQueryKeys.detail(params),
+ queryFn: () => getPreOpinion(params),
+ enabled: isValidParams,
+ gcTime: 10 * 60 * 1000,
+ })
+}
diff --git a/src/features/pre-opinion/hooks/useSavePreOpinion.ts b/src/features/pre-opinion/hooks/useSavePreOpinion.ts
new file mode 100644
index 0000000..06da2db
--- /dev/null
+++ b/src/features/pre-opinion/hooks/useSavePreOpinion.ts
@@ -0,0 +1,41 @@
+/**
+ * @file useSavePreOpinion.ts
+ * @description 사전 의견 저장 뮤테이션 훅
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import type { ApiError } from '@/api'
+
+import { savePreOpinion } from '../preOpinion.api'
+import type { SavePreOpinionBody, SavePreOpinionParams } from '../preOpinion.types'
+import { preOpinionQueryKeys } from './preOpinionQueryKeys'
+
+/**
+ * 사전 의견을 저장하는 뮤테이션 훅
+ *
+ * @description
+ * updatedAt이 null이면 최초 저장(POST), 값이 있으면 수정(PATCH)으로 요청합니다.
+ * 성공 시 사전 의견 조회 쿼리를 무효화합니다.
+ *
+ * @example
+ * ```tsx
+ * const { mutate: save, isPending } = useSavePreOpinion({
+ * gatheringId,
+ * meetingId,
+ * isFirstSave: preOpinion.preOpinion.updatedAt === null,
+ * })
+ *
+ * save({ review: { rating: 4.5, keywordIds: [3, 7] }, answers: [{ topicId: 1, content: '...' }] })
+ * ```
+ */
+export function useSavePreOpinion(params: SavePreOpinionParams) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (body) => savePreOpinion(params, body),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: preOpinionQueryKeys.all })
+ },
+ })
+}
diff --git a/src/features/pre-opinion/hooks/useSubmitPreOpinion.ts b/src/features/pre-opinion/hooks/useSubmitPreOpinion.ts
new file mode 100644
index 0000000..a589b26
--- /dev/null
+++ b/src/features/pre-opinion/hooks/useSubmitPreOpinion.ts
@@ -0,0 +1,43 @@
+/**
+ * @file useSubmitPreOpinion.ts
+ * @description 사전 의견 공유(제출) 뮤테이션 훅
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import type { ApiError } from '@/api'
+import { topicQueryKeys } from '@/features/topics/hooks/topicQueryKeys'
+
+import { submitPreOpinion } from '../preOpinion.api'
+import type { GetPreOpinionParams, SubmitPreOpinionBody } from '../preOpinion.types'
+import { preOpinionQueryKeys } from './preOpinionQueryKeys'
+
+/**
+ * 사전 의견을 공유(제출)하는 뮤테이션 훅
+ *
+ * @description
+ * 작성한 사전 의견을 멤버들에게 공유합니다.
+ * 성공 시 사전 의견 조회 쿼리를 무효화합니다.
+ *
+ * @example
+ * ```tsx
+ * const { mutate: submit, isPending } = useSubmitPreOpinion({ gatheringId, meetingId })
+ *
+ * submit({ review: { rating: 4.5, keywordIds: [3, 7] }, topicIds: [1, 2, 3] })
+ * ```
+ */
+export function useSubmitPreOpinion({ gatheringId, meetingId }: GetPreOpinionParams) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (body) => submitPreOpinion(gatheringId, meetingId, body),
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: preOpinionQueryKeys.all }),
+ // confirmed 전체 무효화: 제출 후 어떤 gatheringId/meetingId 조합이 영향 받는지
+ // 특정할 수 없으므로 confirmed 범위 전체를 무효화합니다.
+ queryClient.invalidateQueries({ queryKey: topicQueryKeys.confirmed() }),
+ ])
+ },
+ })
+}
diff --git a/src/features/pre-opinion/lib/date.ts b/src/features/pre-opinion/lib/date.ts
new file mode 100644
index 0000000..d64d628
--- /dev/null
+++ b/src/features/pre-opinion/lib/date.ts
@@ -0,0 +1,11 @@
+export const formatUpdatedAt = (dateStr: string) => {
+ const date = new Date(dateStr)
+ if (isNaN(date.getTime())) return ''
+
+ const year = date.getFullYear()
+ const month = date.getMonth() + 1
+ const day = date.getDate()
+ const hours = String(date.getHours()).padStart(2, '0')
+ const minutes = String(date.getMinutes()).padStart(2, '0')
+ return `${year}. ${month}.${day} ${hours}:${minutes} 마지막 저장`
+}
diff --git a/src/features/pre-opinion/preOpinion.api.ts b/src/features/pre-opinion/preOpinion.api.ts
new file mode 100644
index 0000000..f141310
--- /dev/null
+++ b/src/features/pre-opinion/preOpinion.api.ts
@@ -0,0 +1,78 @@
+/**
+ * @file preOpinion.api.ts
+ * @description 사전 의견 API 요청 함수
+ */
+
+import { api } from '@/api/client'
+import { PRE_OPINION_ENDPOINTS } from '@/features/pre-opinion/preOpinion.endpoints'
+import { getMockPreOpinionDetail } from '@/features/pre-opinion/preOpinion.mock'
+import type {
+ GetPreOpinionParams,
+ GetPreOpinionResponse,
+ SavePreOpinionBody,
+ SavePreOpinionParams,
+ SubmitPreOpinionBody,
+} from '@/features/pre-opinion/preOpinion.types'
+
+/** 목데이터 사용 여부 플래그 */
+const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'
+
+/**
+ * 사전 의견 조회
+ *
+ * @description
+ * 약속에 대한 사전 의견 정보를 조회합니다.
+ * 책 정보, 리뷰(평가), 주제별 사전 의견 내용을 포함합니다.
+ *
+ * @param params - 모임 ID와 약속 ID
+ *
+ * @returns 사전 의견 응답 데이터
+ */
+export const getPreOpinion = async ({
+ gatheringId,
+ meetingId,
+}: GetPreOpinionParams): Promise => {
+ if (USE_MOCK) {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return getMockPreOpinionDetail()
+ }
+
+ return api.get(PRE_OPINION_ENDPOINTS.DETAIL(gatheringId, meetingId))
+}
+
+/**
+ * 사전 의견 저장
+ *
+ * @description
+ * updatedAt이 null이면 최초 저장(POST), 값이 있으면 수정(PATCH)으로 요청합니다.
+ *
+ * @param params - 모임 ID, 약속 ID, 최초 저장 여부
+ * @param body - 리뷰 평가 및 주제별 답변
+ */
+export const savePreOpinion = async (
+ { gatheringId, meetingId, isFirstSave }: SavePreOpinionParams,
+ body: SavePreOpinionBody
+): Promise => {
+ if (isFirstSave) {
+ return api.post(PRE_OPINION_ENDPOINTS.CREATE(gatheringId, meetingId), body)
+ }
+ return api.patch(PRE_OPINION_ENDPOINTS.UPDATE(gatheringId, meetingId), body)
+}
+
+/**
+ * 사전 의견 공유(제출)
+ *
+ * @description
+ * 작성한 사전 의견을 멤버들에게 공유합니다.
+ *
+ * @param gatheringId - 모임 ID
+ * @param meetingId - 약속 ID
+ * @param body - 리뷰 평가 및 제출할 주제 ID 목록
+ */
+export const submitPreOpinion = async (
+ gatheringId: number,
+ meetingId: number,
+ body: SubmitPreOpinionBody
+): Promise => {
+ return api.patch(PRE_OPINION_ENDPOINTS.SUBMIT(gatheringId, meetingId), body)
+}
diff --git a/src/features/pre-opinion/preOpinion.endpoints.ts b/src/features/pre-opinion/preOpinion.endpoints.ts
new file mode 100644
index 0000000..7f6b5af
--- /dev/null
+++ b/src/features/pre-opinion/preOpinion.endpoints.ts
@@ -0,0 +1,16 @@
+import { API_PATHS } from '@/api'
+
+export const PRE_OPINION_ENDPOINTS = {
+ // 사전 의견 조회 (GET /api/gatherings/{gatheringId}/meetings/{meetingId}/answers/me)
+ DETAIL: (gatheringId: number, meetingId: number) =>
+ `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/answers/me`,
+ // 사전 의견 최초 저장 (POST /api/gatherings/{gatheringId}/meetings/{meetingId}/answers)
+ CREATE: (gatheringId: number, meetingId: number) =>
+ `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/answers`,
+ // 사전 의견 수정 (PATCH /api/gatherings/{gatheringId}/meetings/{meetingId}/answers/me)
+ UPDATE: (gatheringId: number, meetingId: number) =>
+ `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/answers/me`,
+ // 사전 의견 공유(제출) (PATCH /api/gatherings/{gatheringId}/meetings/{meetingId}/answers/submit)
+ SUBMIT: (gatheringId: number, meetingId: number) =>
+ `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/answers/submit`,
+} as const
diff --git a/src/features/pre-opinion/preOpinion.mock.ts b/src/features/pre-opinion/preOpinion.mock.ts
new file mode 100644
index 0000000..7be34e6
--- /dev/null
+++ b/src/features/pre-opinion/preOpinion.mock.ts
@@ -0,0 +1,60 @@
+/**
+ * @file preOpinion.mock.ts
+ * @description 사전 의견 API 목데이터
+ */
+
+import type { GetPreOpinionResponse } from '@/features/pre-opinion/preOpinion.types'
+
+/**
+ * 사전 의견 조회 목데이터
+ */
+const mockPreOpinionDetail: GetPreOpinionResponse = {
+ book: {
+ bookId: 10,
+ title: '아주 작은 습관의 힘',
+ author: '제임스 클리어',
+ },
+ review: {
+ reviewId: 1,
+ bookId: 10,
+ userId: 1,
+ rating: 4.5,
+ keywords: [
+ { id: 47, name: '성장', type: 'BOOK' },
+ { id: 10, name: '감동적인', type: 'IMPRESSION' },
+ ],
+ },
+ preOpinion: {
+ updatedAt: '2026-02-06T09:12:30',
+ topics: [
+ {
+ topicId: 1,
+ topicTitle: '책의 주요 메시지',
+ topicDescription: '이 책에서 전달하고자 하는 핵심 메시지는 무엇인가요?',
+ topicType: 'DISCUSSION',
+ topicTypeLabel: '토론형',
+ confirmOrder: 1,
+ content: '이 책은 작은 행동의 반복이 인생을 바꾼다고 생각합니다.',
+ },
+ {
+ topicId: 2,
+ topicTitle: '인상 깊은 구절',
+ topicDescription: '가장 인상 깊었던 문장을 공유해주세요.',
+ topicType: 'EMOTION',
+ topicTypeLabel: '감정 공유형',
+ confirmOrder: 2,
+ content: null,
+ },
+ ],
+ },
+}
+
+/**
+ * 사전 의견 조회 목데이터 반환 함수
+ *
+ * @description
+ * 실제 API 호출을 시뮬레이션하여 사전 의견 목데이터를 반환합니다.
+ */
+export const getMockPreOpinionDetail = (): GetPreOpinionResponse => {
+ return mockPreOpinionDetail
+}
diff --git a/src/features/pre-opinion/preOpinion.types.ts b/src/features/pre-opinion/preOpinion.types.ts
new file mode 100644
index 0000000..598737c
--- /dev/null
+++ b/src/features/pre-opinion/preOpinion.types.ts
@@ -0,0 +1,129 @@
+/**
+ * @file preOpinion.types.ts
+ * @description 사전 의견 API 관련 타입 정의
+ */
+
+import type { ReviewKeyword } from '@/features/book/book.types'
+
+import type { TopicType } from '../topics'
+
+/**
+ * 사전 의견 주제 항목
+ */
+export type PreOpinionTopic = {
+ /** 주제 ID */
+ topicId: number
+ /** 주제 이름 */
+ topicTitle: string
+ /** 주제 설명 */
+ topicDescription: string
+ /** 주제 유형 */
+ topicType: TopicType
+ /** 주제 유형 라벨 */
+ topicTypeLabel: string
+ /** 확정 순서 */
+ confirmOrder: number
+ /** 작성한 내용 (미작성 시 null) */
+ content: string | null
+}
+
+/**
+ * 사전 의견 응답 내 책 정보
+ */
+export type PreOpinionBook = {
+ /** 책 ID */
+ bookId: number
+ /** 책 제목 */
+ title: string
+ /** 저자 */
+ author: string
+}
+
+/**
+ * 사전 의견 데이터
+ */
+export type PreOpinionData = {
+ /** 마지막 수정 일시 */
+ updatedAt: string | null
+ /** 주제 목록 */
+ topics: PreOpinionTopic[]
+}
+
+/**
+ * 사전 의견 응답 내 리뷰(평가) 정보
+ */
+export type PreOpinionReview = {
+ /** 리뷰 ID */
+ reviewId: number
+ /** 책 ID */
+ bookId: number
+ /** 사용자 ID */
+ userId: number
+ /** 평점 */
+ rating: number
+ /** 키워드 목록 */
+ keywords: ReviewKeyword[]
+}
+
+/**
+ * 사전 의견 조회 요청 파라미터
+ */
+export type GetPreOpinionParams = {
+ /** 모임 ID */
+ gatheringId: number
+ /** 약속 ID */
+ meetingId: number
+}
+
+/**
+ * 사전 의견 조회 응답 타입
+ */
+export type GetPreOpinionResponse = {
+ /** 책 정보 */
+ book: PreOpinionBook
+ /** 리뷰(평가) 정보 (평가 전적이 없으면 null) */
+ review: PreOpinionReview | null
+ /** 사전 의견 데이터 */
+ preOpinion: PreOpinionData
+}
+
+/**
+ * 사전 의견 저장 요청 바디
+ */
+export type SavePreOpinionBody = {
+ /** 리뷰(평가) 정보 */
+ review: {
+ rating: number
+ keywordIds: number[]
+ }
+ /** 주제별 답변 목록 */
+ answers: {
+ topicId: number
+ content: string | null
+ }[]
+}
+
+/**
+ * 사전 의견 저장 요청 파라미터
+ */
+export type SavePreOpinionParams = {
+ /** 모임 ID */
+ gatheringId: number
+ /** 약속 ID */
+ meetingId: number
+ /** 최초 저장 여부 (updatedAt이 null이면 true) */
+ isFirstSave: boolean
+}
+
+/**
+ * 사전 의견 공유(제출) 요청 바디
+ */
+export type SubmitPreOpinionBody = {
+ /** 리뷰(평가) 정보 */
+ review: {
+ rating: number
+ keywordIds: number[]
+ }
+ /** 제출할 주제 ID 목록 */
+ topicIds: number[]
+}
diff --git a/src/features/preOpinions/components/PreOpinionDetail.tsx b/src/features/preOpinions/components/PreOpinionDetail.tsx
new file mode 100644
index 0000000..2339bab
--- /dev/null
+++ b/src/features/preOpinions/components/PreOpinionDetail.tsx
@@ -0,0 +1,140 @@
+import { useAuth } from '@/features/auth'
+import { StarRate } from '@/shared/components/StarRate'
+import { Avatar, AvatarFallback, AvatarImage, Badge, TextButton } from '@/shared/ui'
+import { Chip } from '@/shared/ui/Chip'
+import { useGlobalModalStore } from '@/store'
+
+import { useDeleteMyPreOpinionAnswer } from '../hooks/useDeleteMyPreOpinionAnswer'
+import { ROLE_TO_AVATAR_VARIANT } from '../preOpinions.constants'
+import type { PreOpinionMember, PreOpinionTopic } from '../preOpinions.types'
+
+type PreOpinionDetailProps = {
+ member: PreOpinionMember
+ topics: PreOpinionTopic[]
+ gatheringId: number
+ meetingId: number
+}
+
+/**
+ * 사전 의견 상세 (선택된 멤버의 책 평가 + 주제별 의견)
+ *
+ * @description
+ * 선택된 멤버의 책 평가(별점, 키워드)와 주제별 의견을 표시합니다.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+function PreOpinionDetail({ member, topics, gatheringId, meetingId }: PreOpinionDetailProps) {
+ const { data: currentUser } = useAuth()
+ const { openConfirm, openError } = useGlobalModalStore()
+ const { bookReview, topicOpinions, memberInfo } = member
+ const isMyOpinion = currentUser?.userId === memberInfo?.userId
+ const deleteMutation = useDeleteMyPreOpinionAnswer({ gatheringId, meetingId })
+
+ const handleDelete = async () => {
+ const confirmed = await openConfirm(
+ '내 의견 삭제하기',
+ '내 의견을 삭제하면 다른 멤버들의 의견을 보는 권한도 함께 사라져요.\n삭제를 진행할까요?',
+ { confirmText: '삭제', variant: 'danger' }
+ )
+ if (!confirmed) return
+
+ deleteMutation.mutate(undefined, {
+ onError: (error) => openError('에러', error.userMessage),
+ })
+ }
+
+ return (
+
+ {/* 회원 정보 섹션 */}
+ {memberInfo && (
+
+
+
+
+ {memberInfo.nickname[0]}
+
+
{memberInfo.nickname} 님의 의견
+
+ {isMyOpinion &&
handleDelete()}>내 의견 삭제하기}
+
+ )}
+ {/* 책 평가 섹션 */}
+ {bookReview && (
+
+ {/* 별점 */}
+
+
별점
+
+
+ {bookReview.rating.toFixed(1)}
+
+
+
+ {/* 책 키워드 */}
+ {bookReview.keywordInfo.filter((k) => k.type === 'BOOK').length > 0 && (
+
+
책 키워드
+
+ {bookReview.keywordInfo
+ .filter((k) => k.type === 'BOOK')
+ .map((keyword) => (
+
+ {keyword.name}
+
+ ))}
+
+
+ )}
+
+ {/* 감상 키워드 */}
+ {bookReview.keywordInfo.filter((k) => k.type === 'IMPRESSION').length > 0 && (
+
+
감상 키워드
+
+ {bookReview.keywordInfo
+ .filter((k) => k.type === 'IMPRESSION')
+ .map((keyword) => (
+
+ {keyword.name}
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* 주제별 의견 섹션 */}
+ {topicOpinions.length > 0 && (
+
+ {topics.map((topic) => {
+ const opinion = topicOpinions.find((o) => o.topicId === topic.topicId)
+ if (!opinion) return null
+
+ return (
+
+
+
+
+ 주제 {topic.confirmOrder}. {topic.title}
+
+ {topic.topicTypeLabel}
+
+
{topic.description}
+
+ {opinion.content && (
+
{opinion.content}
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+export { PreOpinionDetail }
diff --git a/src/features/preOpinions/components/PreOpinionMemberList.tsx b/src/features/preOpinions/components/PreOpinionMemberList.tsx
new file mode 100644
index 0000000..26e843d
--- /dev/null
+++ b/src/features/preOpinions/components/PreOpinionMemberList.tsx
@@ -0,0 +1,54 @@
+import { UserChip } from '@/shared/ui/UserChip'
+
+import { ROLE_TO_AVATAR_VARIANT } from '../preOpinions.constants'
+import type { PreOpinionMember } from '../preOpinions.types'
+
+type PreOpinionMemberListProps = {
+ members: PreOpinionMember[]
+ selectedMemberId: number | null
+ onSelectMember: (memberId: number) => void
+}
+
+/**
+ * 사전 의견 멤버 리스트
+ *
+ * @description
+ * 사전 의견을 작성한/작성하지 않은 멤버들을 UserChip 형태로 표시합니다.
+ * 사전 의견을 제출하지 않은 멤버는 disabled 상태로 표시됩니다.
+ *
+ * @example
+ * ```tsx
+ * setSelectedMemberId(id)}
+ * />
+ * ```
+ */
+function PreOpinionMemberList({
+ members,
+ selectedMemberId,
+ onSelectMember,
+}: PreOpinionMemberListProps) {
+ return (
+
+ {members.map((member) => (
+ {
+ if (member.isSubmitted) {
+ onSelectMember(member.memberInfo.userId)
+ }
+ }}
+ />
+ ))}
+
+ )
+}
+
+export { PreOpinionMemberList }
diff --git a/src/features/preOpinions/components/index.ts b/src/features/preOpinions/components/index.ts
new file mode 100644
index 0000000..317611c
--- /dev/null
+++ b/src/features/preOpinions/components/index.ts
@@ -0,0 +1,2 @@
+export * from './PreOpinionDetail'
+export * from './PreOpinionMemberList'
diff --git a/src/features/preOpinions/hooks/index.ts b/src/features/preOpinions/hooks/index.ts
new file mode 100644
index 0000000..305c6e7
--- /dev/null
+++ b/src/features/preOpinions/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './preOpinionQueryKeys'
+export * from './useDeleteMyPreOpinionAnswer'
+export * from './usePreOpinionAnswers'
diff --git a/src/features/preOpinions/hooks/preOpinionQueryKeys.ts b/src/features/preOpinions/hooks/preOpinionQueryKeys.ts
new file mode 100644
index 0000000..d4d25d6
--- /dev/null
+++ b/src/features/preOpinions/hooks/preOpinionQueryKeys.ts
@@ -0,0 +1,21 @@
+/**
+ * @file preOpinionQueryKeys.ts
+ * @description 사전 의견 관련 Query Key Factory
+ */
+
+import type { GetPreOpinionAnswersParams } from '../preOpinions.types'
+
+/**
+ * Query Key Factory
+ *
+ * @description
+ * 사전 의견 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수
+ */
+export const preOpinionQueryKeys = {
+ all: ['preOpinions'] as const,
+
+ // 사전 의견 목록 관련
+ answers: () => [...preOpinionQueryKeys.all, 'answers'] as const,
+ answerList: (params: GetPreOpinionAnswersParams) =>
+ [...preOpinionQueryKeys.answers(), params] as const,
+}
diff --git a/src/features/preOpinions/hooks/useDeleteMyPreOpinionAnswer.ts b/src/features/preOpinions/hooks/useDeleteMyPreOpinionAnswer.ts
new file mode 100644
index 0000000..555376d
--- /dev/null
+++ b/src/features/preOpinions/hooks/useDeleteMyPreOpinionAnswer.ts
@@ -0,0 +1,32 @@
+/**
+ * @file useDeleteMyPreOpinionAnswer.ts
+ * @description 내 사전 의견 삭제 뮤테이션 훅
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import type { ApiError } from '@/api'
+
+import { deleteMyPreOpinionAnswer } from '../preOpinions.api'
+import type { DeleteMyPreOpinionAnswerParams } from '../preOpinions.types'
+import { preOpinionQueryKeys } from './preOpinionQueryKeys'
+
+/**
+ * 내 사전 의견을 삭제하는 뮤테이션 훅
+ *
+ * @example
+ * ```tsx
+ * const deleteMutation = useDeleteMyPreOpinionAnswer({ gatheringId, meetingId })
+ * deleteMutation.mutate()
+ * ```
+ */
+export function useDeleteMyPreOpinionAnswer(params: DeleteMyPreOpinionAnswerParams) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: () => deleteMyPreOpinionAnswer(params),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: preOpinionQueryKeys.answers() })
+ },
+ })
+}
diff --git a/src/features/preOpinions/hooks/usePreOpinionAnswers.ts b/src/features/preOpinions/hooks/usePreOpinionAnswers.ts
new file mode 100644
index 0000000..2c0860b
--- /dev/null
+++ b/src/features/preOpinions/hooks/usePreOpinionAnswers.ts
@@ -0,0 +1,38 @@
+/**
+ * @file usePreOpinionAnswers.ts
+ * @description 사전 의견 목록 조회 훅
+ */
+
+import { useQuery } from '@tanstack/react-query'
+
+import type { ApiError } from '@/api'
+
+import { getPreOpinionAnswers } from '../preOpinions.api'
+import type { GetPreOpinionAnswersParams, PreOpinionAnswersData } from '../preOpinions.types'
+import { preOpinionQueryKeys } from './preOpinionQueryKeys'
+
+/**
+ * 사전 의견 목록 조회 훅
+ *
+ * @description
+ * TanStack Query를 사용하여 약속의 사전 의견 목록을 조회합니다.
+ * 멤버별 책 평가, 주제별 의견 등을 포함합니다.
+ *
+ * @param params - 조회 파라미터
+ * @param params.gatheringId - 모임 식별자
+ * @param params.meetingId - 약속 식별자
+ *
+ * @returns TanStack Query 결과 객체
+ */
+export const usePreOpinionAnswers = (params: GetPreOpinionAnswersParams) => {
+ const { gatheringId, meetingId } = params
+ const isValidParams =
+ !Number.isNaN(gatheringId) && gatheringId > 0 && !Number.isNaN(meetingId) && meetingId > 0
+
+ return useQuery({
+ queryKey: preOpinionQueryKeys.answerList({ gatheringId, meetingId }),
+ queryFn: () => getPreOpinionAnswers({ gatheringId, meetingId }),
+ enabled: isValidParams,
+ gcTime: 10 * 60 * 1000,
+ })
+}
diff --git a/src/features/preOpinions/index.ts b/src/features/preOpinions/index.ts
new file mode 100644
index 0000000..e804ad9
--- /dev/null
+++ b/src/features/preOpinions/index.ts
@@ -0,0 +1,13 @@
+// Components
+export * from './components'
+
+// Hooks
+export * from './hooks'
+
+// API
+export * from './preOpinions.api'
+export * from './preOpinions.endpoints'
+export * from './preOpinions.mock'
+
+// Types
+export * from './preOpinions.types'
diff --git a/src/features/preOpinions/preOpinions.api.ts b/src/features/preOpinions/preOpinions.api.ts
new file mode 100644
index 0000000..158a104
--- /dev/null
+++ b/src/features/preOpinions/preOpinions.api.ts
@@ -0,0 +1,59 @@
+/**
+ * @file preOpinions.api.ts
+ * @description 사전 의견 API 요청 함수
+ */
+
+import { api } from '@/api/client'
+
+import { PRE_OPINIONS_ENDPOINTS } from './preOpinions.endpoints'
+import { getMockPreOpinionAnswers } from './preOpinions.mock'
+import type {
+ DeleteMyPreOpinionAnswerParams,
+ GetPreOpinionAnswersParams,
+ PreOpinionAnswersData,
+} from './preOpinions.types'
+
+/** 목데이터 사용 여부 플래그 */
+const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'
+
+/**
+ * 사전 의견 목록 조회
+ *
+ * @description
+ * 약속의 사전 의견 목록(멤버별 책 평가 + 주제 의견)을 조회합니다.
+ *
+ * @param params - 조회 파라미터
+ * @param params.gatheringId - 모임 식별자
+ * @param params.meetingId - 약속 식별자
+ *
+ * @returns 사전 의견 목록 데이터 (topics + members)
+ */
+export const getPreOpinionAnswers = async (
+ params: GetPreOpinionAnswersParams
+): Promise => {
+ const { gatheringId, meetingId } = params
+
+ if (USE_MOCK) {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return getMockPreOpinionAnswers()
+ }
+
+ return api.get(PRE_OPINIONS_ENDPOINTS.ANSWERS(gatheringId, meetingId))
+}
+
+/**
+ * 내 사전 의견 삭제
+ *
+ * @description
+ * 현재 로그인한 사용자의 사전 의견을 삭제합니다.
+ *
+ * @param params - 삭제 파라미터
+ * @param params.gatheringId - 모임 식별자
+ * @param params.meetingId - 약속 식별자
+ */
+export const deleteMyPreOpinionAnswer = async (
+ params: DeleteMyPreOpinionAnswerParams
+): Promise => {
+ const { gatheringId, meetingId } = params
+ return api.delete(PRE_OPINIONS_ENDPOINTS.DELETE_MY_ANSWER(gatheringId, meetingId))
+}
diff --git a/src/features/preOpinions/preOpinions.constants.ts b/src/features/preOpinions/preOpinions.constants.ts
new file mode 100644
index 0000000..1671b60
--- /dev/null
+++ b/src/features/preOpinions/preOpinions.constants.ts
@@ -0,0 +1,8 @@
+import type { MemberRole } from './preOpinions.types'
+
+/** API MemberRole → Avatar variant 매핑 */
+export const ROLE_TO_AVATAR_VARIANT: Record = {
+ GATHERING_LEADER: 'leader',
+ MEETING_LEADER: 'host',
+ MEMBER: 'member',
+}
diff --git a/src/features/preOpinions/preOpinions.endpoints.ts b/src/features/preOpinions/preOpinions.endpoints.ts
new file mode 100644
index 0000000..d4a748b
--- /dev/null
+++ b/src/features/preOpinions/preOpinions.endpoints.ts
@@ -0,0 +1,11 @@
+import { API_PATHS } from '@/api'
+
+export const PRE_OPINIONS_ENDPOINTS = {
+ // 사전 의견 목록 조회 (GET /api/gatherings/{gatheringId}/meetings/{meetingId}/answers)
+ ANSWERS: (gatheringId: number, meetingId: number) =>
+ `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/answers`,
+
+ // 내 사전 의견 삭제 (DELETE /api/gatherings/{gatheringId}/meetings/{meetingId}/topics/answers/me)
+ DELETE_MY_ANSWER: (gatheringId: number, meetingId: number) =>
+ `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/answers/me`,
+} as const
diff --git a/src/features/preOpinions/preOpinions.mock.ts b/src/features/preOpinions/preOpinions.mock.ts
new file mode 100644
index 0000000..3b7eef8
--- /dev/null
+++ b/src/features/preOpinions/preOpinions.mock.ts
@@ -0,0 +1,102 @@
+/**
+ * @file preOpinions.mock.ts
+ * @description 사전 의견 API 목데이터
+ */
+
+import type { PreOpinionAnswersData } from './preOpinions.types'
+
+/**
+ * 사전 의견 목록 목데이터
+ */
+const mockPreOpinionAnswers: PreOpinionAnswersData = {
+ topics: [
+ {
+ topicId: 1,
+ title: '책의 주요 메시지',
+ description: '이 책에서 전달하고자 하는 핵심 메시지는 무엇인가요?',
+ topicType: 'DISCUSSION',
+ topicTypeLabel: '토론형',
+ confirmOrder: 1,
+ },
+ {
+ topicId: 2,
+ title: '가장 인상 깊었던 장면',
+ description: '책을 읽으며 가장 기억에 남았던 장면은 무엇인가요?',
+ topicType: 'DISCUSSION',
+ topicTypeLabel: '토론형',
+ confirmOrder: 2,
+ },
+ ],
+ members: [
+ {
+ memberInfo: {
+ userId: 1,
+ nickname: '독서왕',
+ profileImage: 'https://picsum.photos/seed/user1/100/100',
+ role: 'GATHERING_LEADER',
+ },
+ isSubmitted: true,
+ bookReview: {
+ rating: 4.5,
+ keywordInfo: [
+ { id: 3, name: '성장', type: 'BOOK' },
+ { id: 7, name: '여운이 남는', type: 'IMPRESSION' },
+ ],
+ },
+ topicOpinions: [
+ {
+ topicId: 1,
+ content: '이 책의 핵심 메시지는 자기 성찰이라고 생각합니다.',
+ },
+ {
+ topicId: 2,
+ content: '주인공이 선택의 기로에 서는 장면이 가장 인상 깊었습니다.',
+ },
+ ],
+ },
+ {
+ memberInfo: {
+ userId: 10,
+ nickname: '밤독서',
+ profileImage: 'https://picsum.photos/seed/user3/100/100',
+ role: 'MEETING_LEADER',
+ },
+ isSubmitted: true,
+ bookReview: {
+ rating: 3.0,
+ keywordInfo: [
+ { id: 5, name: '관계', type: 'BOOK' },
+ { id: 7, name: '여운이 남는', type: 'IMPRESSION' },
+ ],
+ },
+ topicOpinions: [
+ {
+ topicId: 1,
+ content: null,
+ },
+ {
+ topicId: 2,
+ content: '잔잔하지만 오래 남는 장면들이 많았습니다.',
+ },
+ ],
+ },
+ {
+ memberInfo: {
+ userId: 2,
+ nickname: '페이지러버',
+ profileImage: 'https://picsum.photos/seed/user2/100/100',
+ role: 'MEMBER',
+ },
+ isSubmitted: false,
+ bookReview: null,
+ topicOpinions: [],
+ },
+ ],
+}
+
+/**
+ * 사전 의견 목록 목데이터 반환 함수
+ */
+export const getMockPreOpinionAnswers = (): PreOpinionAnswersData => {
+ return mockPreOpinionAnswers
+}
diff --git a/src/features/preOpinions/preOpinions.types.ts b/src/features/preOpinions/preOpinions.types.ts
new file mode 100644
index 0000000..8e5fbd2
--- /dev/null
+++ b/src/features/preOpinions/preOpinions.types.ts
@@ -0,0 +1,73 @@
+/**
+ * @file preOpinions.types.ts
+ * @description 사전 의견 API 관련 타입 정의
+ */
+
+import type { KeywordType } from '@/features/keywords/keywords.types'
+import type { TopicType } from '@/features/topics/topics.types'
+
+/** 멤버 역할 타입 */
+export type MemberRole = 'GATHERING_LEADER' | 'MEETING_LEADER' | 'MEMBER'
+
+/** 사전 의견 키워드 */
+export type PreOpinionKeyword = {
+ id: number
+ name: string
+ type: KeywordType
+}
+
+/** 책 평가 정보 */
+export type BookReviewSummary = {
+ rating: number
+ keywordInfo: PreOpinionKeyword[]
+}
+
+/** 멤버 정보 */
+export type PreOpinionMemberInfo = {
+ userId: number
+ nickname: string
+ profileImage: string
+ role: MemberRole
+}
+
+/** 주제별 의견 */
+export type TopicOpinion = {
+ topicId: number
+ content: string | null
+}
+
+/** 확정된 주제 항목 */
+export type PreOpinionTopic = {
+ topicId: number
+ title: string
+ description: string
+ topicType: TopicType
+ topicTypeLabel: string
+ confirmOrder: number
+}
+
+/** 멤버별 사전 의견 */
+export type PreOpinionMember = {
+ memberInfo: PreOpinionMemberInfo
+ isSubmitted: boolean
+ bookReview: BookReviewSummary | null
+ topicOpinions: TopicOpinion[]
+}
+
+/** 사전 의견 목록 조회 응답 데이터 */
+export type PreOpinionAnswersData = {
+ topics: PreOpinionTopic[]
+ members: PreOpinionMember[]
+}
+
+/** 사전 의견 목록 조회 파라미터 */
+export type GetPreOpinionAnswersParams = {
+ gatheringId: number
+ meetingId: number
+}
+
+/** 내 사전 의견 삭제 파라미터 */
+export type DeleteMyPreOpinionAnswerParams = {
+ gatheringId: number
+ meetingId: number
+}
diff --git a/src/features/retrospectives/components/AiGradientIcon.tsx b/src/features/retrospectives/components/AiGradientIcon.tsx
new file mode 100644
index 0000000..551044f
--- /dev/null
+++ b/src/features/retrospectives/components/AiGradientIcon.tsx
@@ -0,0 +1,59 @@
+import { useId } from 'react'
+
+type AiGradientIconProps = {
+ className?: string
+}
+
+export default function AiGradientIcon({ className = 'size-6' }: AiGradientIconProps) {
+ const gradientId = `${useId()}-ai-gradient`
+
+ return (
+
+ )
+}
diff --git a/src/features/retrospectives/components/AiLoadingOverlay.tsx b/src/features/retrospectives/components/AiLoadingOverlay.tsx
new file mode 100644
index 0000000..1cc57f8
--- /dev/null
+++ b/src/features/retrospectives/components/AiLoadingOverlay.tsx
@@ -0,0 +1,36 @@
+import { createPortal } from 'react-dom'
+
+import { Button } from '@/shared/ui'
+
+import AiGradientIcon from './AiGradientIcon'
+
+type AiLoadingOverlayProps = {
+ isOpen: boolean
+ message?: string
+ onCancel?: () => void
+}
+
+export default function AiLoadingOverlay({
+ isOpen,
+ message = 'AI가 사전 의견을 요약 중이에요',
+ onCancel,
+}: AiLoadingOverlayProps) {
+ if (!isOpen) return null
+
+ return createPortal(
+
+
+
+ {onCancel && (
+
+ )}
+
+
,
+ document.body
+ )
+}
diff --git a/src/features/retrospectives/components/AiSummaryToast.tsx b/src/features/retrospectives/components/AiSummaryToast.tsx
new file mode 100644
index 0000000..73d154f
--- /dev/null
+++ b/src/features/retrospectives/components/AiSummaryToast.tsx
@@ -0,0 +1,67 @@
+import { Check } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+
+type AiSummaryToastProps = {
+ isVisible: boolean
+ message?: string
+ onDismiss: () => void
+ duration?: number
+}
+
+export default function AiSummaryToast({
+ isVisible,
+ message = '독서 모임 내용 요약이 완료됐어요',
+ onDismiss,
+ duration = 3000,
+}: AiSummaryToastProps) {
+ const [opacity, setOpacity] = useState(false)
+ const onDismissRef = useRef(onDismiss)
+ const fadeOutTimerRef = useRef | null>(null)
+
+ useEffect(() => {
+ onDismissRef.current = onDismiss
+ })
+
+ useEffect(() => {
+ if (!isVisible) return
+
+ const fadeInTimer = requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setOpacity(true)
+ })
+ })
+
+ const dismissTimer = setTimeout(() => {
+ setOpacity(false)
+ fadeOutTimerRef.current = setTimeout(() => onDismissRef.current(), 300)
+ }, duration)
+
+ return () => {
+ cancelAnimationFrame(fadeInTimer)
+ clearTimeout(dismissTimer)
+ if (fadeOutTimerRef.current) clearTimeout(fadeOutTimerRef.current)
+ setOpacity(false)
+ }
+ }, [isVisible, duration])
+
+ if (!isVisible) return null
+
+ return createPortal(
+ ,
+ document.body
+ )
+}
diff --git a/src/features/retrospectives/components/RetrospectiveCardButtons.tsx b/src/features/retrospectives/components/RetrospectiveCardButtons.tsx
new file mode 100644
index 0000000..d390d09
--- /dev/null
+++ b/src/features/retrospectives/components/RetrospectiveCardButtons.tsx
@@ -0,0 +1,53 @@
+import { useNavigate } from 'react-router-dom'
+
+import { ROUTES } from '@/shared/constants'
+
+interface RetrospectiveCardButtonsProps {
+ gatheringId: number
+ meetingId: number
+}
+
+export default function RetrospectiveCardButtons({
+ gatheringId,
+ meetingId,
+}: RetrospectiveCardButtonsProps) {
+ const navigate = useNavigate()
+
+ return (
+
+ {/* 약속 회고 카드 */}
+
+
+ {/* 개인 회고 카드 */}
+
+
+ )
+}
diff --git a/src/features/retrospectives/components/RetrospectiveSummarySkeleton.tsx b/src/features/retrospectives/components/RetrospectiveSummarySkeleton.tsx
new file mode 100644
index 0000000..f08e760
--- /dev/null
+++ b/src/features/retrospectives/components/RetrospectiveSummarySkeleton.tsx
@@ -0,0 +1,37 @@
+import { Card } from '@/shared/ui'
+
+type RetrospectiveSummarySkeletonProps = {
+ count?: number
+}
+
+export default function RetrospectiveSummarySkeleton({
+ count = 3,
+}: RetrospectiveSummarySkeletonProps) {
+ return (
+
+ {[...Array(count).keys()].map((i) => (
+
+
+ {/* 토픽 헤더 */}
+
+ {/* 요약 텍스트 */}
+
+ {/* 키포인트 */}
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/features/retrospectives/components/index.ts b/src/features/retrospectives/components/index.ts
new file mode 100644
index 0000000..91b716a
--- /dev/null
+++ b/src/features/retrospectives/components/index.ts
@@ -0,0 +1,4 @@
+export { default as AiLoadingOverlay } from './AiLoadingOverlay'
+export { default as AiSummaryToast } from './AiSummaryToast'
+export { default as RetrospectiveCardButtons } from './RetrospectiveCardButtons'
+export { default as RetrospectiveSummarySkeleton } from './RetrospectiveSummarySkeleton'
diff --git a/src/features/retrospectives/index.ts b/src/features/retrospectives/index.ts
new file mode 100644
index 0000000..4ffb8fb
--- /dev/null
+++ b/src/features/retrospectives/index.ts
@@ -0,0 +1,2 @@
+// Components
+export * from './components'
diff --git a/src/features/topics/components/ConfirmModalTopicCard.tsx b/src/features/topics/components/ConfirmModalTopicCard.tsx
new file mode 100644
index 0000000..ffa8c87
--- /dev/null
+++ b/src/features/topics/components/ConfirmModalTopicCard.tsx
@@ -0,0 +1,49 @@
+import { useRef } from 'react'
+
+import { cn } from '@/shared/lib/utils'
+import { Badge, Card } from '@/shared/ui'
+import { NumberedCheckbox } from '@/shared/ui/NumberedCheckbox'
+
+type ConfirmModalTopicCardProps = {
+ title: string
+ topicTypeLabel: string
+ description: string
+ createdByNickname: string
+ topicId: number
+ isSelected: boolean
+}
+
+export default function ConfirmModalTopicCard({
+ title,
+ topicTypeLabel,
+ description,
+ createdByNickname,
+ topicId,
+ isSelected,
+}: ConfirmModalTopicCardProps) {
+ const checkboxRef = useRef(null)
+
+ return (
+ checkboxRef.current?.click()}>
+
+ e.stopPropagation()}>
+
+
+
+
+
+
{title}
+
+ {topicTypeLabel}
+
+
+
+
{description}
+
제안 : {createdByNickname}
+
+
+
+ )
+}
diff --git a/src/features/topics/components/ConfirmTopicModal.tsx b/src/features/topics/components/ConfirmTopicModal.tsx
new file mode 100644
index 0000000..f12b01c
--- /dev/null
+++ b/src/features/topics/components/ConfirmTopicModal.tsx
@@ -0,0 +1,143 @@
+import { useState } from 'react'
+
+import { ConfirmModalTopicCard, TopicListSkeleton } from '@/features/topics/components'
+import { useConfirmTopics, useProposedTopics } from '@/features/topics/hooks'
+import {
+ Button,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalTitle,
+ TextButton,
+} from '@/shared/ui'
+import { NumberedCheckboxGroup } from '@/shared/ui/NumberedCheckbox'
+import { useGlobalModalStore } from '@/store'
+
+export type ConfirmTopicModalProps = {
+ /** 모달 열림 상태 */
+ open: boolean
+ /** 모달 열림 상태 변경 핸들러 */
+ onOpenChange: (open: boolean) => void
+ gatheringId: number
+ meetingId: number
+}
+
+export default function ConfirmTopicModal({
+ open,
+ onOpenChange,
+ gatheringId,
+ meetingId,
+}: ConfirmTopicModalProps) {
+ const [selectedTopicIds, setSelectedTopicIds] = useState([])
+
+ const { openConfirm, openError } = useGlobalModalStore()
+ const confirmMutation = useConfirmTopics()
+
+ // 모달 전용 독립 데이터 - 한 번에 전체 로드 (pageSize 크게)
+ const { data: topicsInfiniteData, isLoading } = useProposedTopics({
+ gatheringId,
+ meetingId,
+ pageSize: 100,
+ })
+
+ const topics = topicsInfiniteData?.pages.flatMap((page) => page.items) ?? []
+
+ const resetSelected = () => {
+ setSelectedTopicIds([])
+ }
+
+ const handleClose = () => {
+ resetSelected()
+ onOpenChange(false)
+ }
+
+ const handleConfirm = async () => {
+ const confirmed = await openConfirm(
+ '주제 확정',
+ `한 번 확정하면 이후에는 순서를 바꾸거나 주제를 추가하기 어려워요. \n이대로 확정할까요?`,
+ { confirmText: '확정하기' }
+ )
+
+ if (!confirmed) return
+
+ confirmMutation.mutate(
+ {
+ gatheringId,
+ meetingId,
+ topicIds: selectedTopicIds.map(Number),
+ },
+ {
+ onSuccess: () => {
+ handleClose()
+ },
+ onError: (error) => {
+ openError('확정 실패', error.userMessage)
+ },
+ }
+ )
+ }
+
+ return (
+
+ e.preventDefault()}>
+
+
+
+
주제 확정하기
+
+
확정할 주제를 순서대로 선택해주세요
+ {selectedTopicIds.length > 0 && (
+
+ 전체해제
+
+ )}
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {topics.map((topic) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ 선택 {selectedTopicIds.length}개
+
+
+
+
+
+
+ )
+}
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..d3ba223
--- /dev/null
+++ b/src/features/topics/components/ProposedTopicList.tsx
@@ -0,0 +1,68 @@
+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
+ gatheringId: number
+ meetingId: number
+ confirmedTopic: boolean
+}
+
+export default function ProposedTopicList({
+ topics,
+ hasNextPage,
+ isFetchingNextPage,
+ onLoadMore,
+ gatheringId,
+ meetingId,
+ confirmedTopic,
+}: 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..baa6e95
--- /dev/null
+++ b/src/features/topics/components/TopicCard.tsx
@@ -0,0 +1,110 @@
+import { showErrorToast, showToast } from '@/shared/lib/toast'
+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, 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 },
+ {
+ onSuccess: () => {
+ showToast('주제가 삭제되었습니다.')
+ },
+ onError: (error) => {
+ openError('삭제 실패', error.userMessage)
+ },
+ }
+ )
+ }
+
+ const handleLike = () => {
+ if (!gatheringId || !meetingId || !topicId) {
+ return
+ }
+
+ if (likeMutation.isPending) return
+
+ likeMutation.mutate(
+ { gatheringId, meetingId, topicId },
+ {
+ onError: (error) => {
+ showErrorToast(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..172becb
--- /dev/null
+++ b/src/features/topics/components/TopicHeader.tsx
@@ -0,0 +1,129 @@
+import { format } from 'date-fns'
+import { Check } from 'lucide-react'
+import { useNavigate } from 'react-router-dom'
+
+import { ROUTES } from '@/shared/constants'
+import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui'
+
+type ProposedHeaderProps = {
+ activeTab: 'PROPOSED'
+ actions: { canConfirm: boolean; canSuggest: boolean }
+ confirmedTopic: boolean
+ confirmedTopicDate: string | null
+ proposedTopicsCount: number
+ onOpenChange: (open: boolean) => void
+ gatheringId: number
+ meetingId: number
+}
+
+type ConfirmedHeaderProps = {
+ activeTab: 'CONFIRMED'
+ actions: { canViewPreOpinions: boolean; canWritePreOpinions: boolean }
+ confirmedTopic: boolean
+ confirmedTopicDate: string | null
+}
+
+type TopicHeaderProps = ProposedHeaderProps | ConfirmedHeaderProps
+
+export default function TopicHeader(props: TopicHeaderProps) {
+ const navigate = useNavigate()
+ return (
+ <>
+ {/* 제안탭 */}
+ {props.activeTab === 'PROPOSED' && (
+
+
+ {props.confirmedTopic && props.confirmedTopicDate ? (
+ // 주제 확정됨
+ <>
+
+ 주제 제안이 마감되었어요. 확정된 주제를 확인해보세요!
+
+
+ {format(props.confirmedTopicDate, 'yyyy.MM.dd HH:mm')} 마감
+
+ >
+ ) : (
+ // 주제 제안 중
+ <>
+
+ 약속에서 나누고 싶은 주제를 제안해보세요
+
+
+ 주제를 미리 정하면 우리 모임이 훨씬 풍성하고 즐거워질 거예요
+
+ >
+ )}
+
+
+
+ {props.actions.canConfirm && props.proposedTopicsCount > 0 && (
+
+ )}
+
+
+
+
+ )}
+
+ {/* 제안탭 */}
+
+ {/* 확정탭 */}
+ {props.activeTab === 'CONFIRMED' && (
+
+
+ {props.confirmedTopic ? (
+ // 주제 확정됨
+ <>
+
+ 주제가 확정되었어요!
+
+
+ 나의 생각을 미리 정리해서 공유하면 다른 멤버들의 의견도 바로 확인할 수 있어요
+
+ >
+ ) : (
+ // 주제 제안 중
+ <>
+
약속장이 주제를 선정하고 있어요
+
+ 주제가 확정되면 사전 의견을 남길 수 있는 창이 열려요
+
+ >
+ )}
+
+
+
+ {props.actions.canViewPreOpinions ? (
+
+ ) : (
+
+
+
+
+
+ 내 의견을 먼저 공유해야 다른
+ 멤버들의 의견도 확인할 수 있어요!
+
+
+ )}
+
+
+
+
+ )}
+ {/* 확정탭 */}
+ >
+ )
+}
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/TopicSkeleton.tsx b/src/features/topics/components/TopicSkeleton.tsx
new file mode 100644
index 0000000..24f88c7
--- /dev/null
+++ b/src/features/topics/components/TopicSkeleton.tsx
@@ -0,0 +1,13 @@
+import TopicListSkeleton from '@/features/topics/components/TopicListSkeleton'
+
+export default function TopicSkeleton() {
+ return (
+
+ )
+}
diff --git a/src/features/topics/components/index.ts b/src/features/topics/components/index.ts
new file mode 100644
index 0000000..8d810b3
--- /dev/null
+++ b/src/features/topics/components/index.ts
@@ -0,0 +1,10 @@
+export { default as ConfirmedTopicList } from './ConfirmedTopicList'
+export { default as ConfirmModalTopicCard } from './ConfirmModalTopicCard'
+export { default as ConfirmTopicModal } from './ConfirmTopicModal'
+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'
+export { default as TopicSkeleton } from './TopicSkeleton'
diff --git a/src/features/topics/hooks/index.ts b/src/features/topics/hooks/index.ts
new file mode 100644
index 0000000..672efba
--- /dev/null
+++ b/src/features/topics/hooks/index.ts
@@ -0,0 +1,7 @@
+export * from './topicQueryKeys'
+export * from './useConfirmedTopics'
+export * from './useConfirmTopics'
+export * from './useCreateTopic'
+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/useConfirmTopics.ts b/src/features/topics/hooks/useConfirmTopics.ts
new file mode 100644
index 0000000..ea8e036
--- /dev/null
+++ b/src/features/topics/hooks/useConfirmTopics.ts
@@ -0,0 +1,65 @@
+/**
+ * @file useConfirmTopics.ts
+ * @description 주제 확정 mutation 훅
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { ApiError } from '@/api/errors'
+import { meetingQueryKeys } from '@/features/meetings/hooks/meetingQueryKeys'
+
+import { confirmTopics } from '../topics.api'
+import type { ConfirmTopicsParams, ConfirmTopicsResponse } from '../topics.types'
+import { topicQueryKeys } from './topicQueryKeys'
+
+/**
+ * 주제 확정 mutation 훅
+ *
+ * @description
+ * 선택한 주제들을 순서대로 확정하고 관련 쿼리 캐시를 무효화합니다.
+ * - 제안된 주제 리스트 캐시 무효화
+ * - 확정된 주제 리스트 캐시 무효화
+ *
+ * @example
+ * ```tsx
+ * const confirmMutation = useConfirmTopics()
+ * confirmMutation.mutate(
+ * { gatheringId: 1, meetingId: 2, topicIds: [3, 1, 2] },
+ * {
+ * onSuccess: () => {
+ * console.log('주제가 확정되었습니다.')
+ * },
+ * onError: (error) => {
+ * console.error('확정 실패:', error.userMessage)
+ * },
+ * }
+ * )
+ * ```
+ */
+export const useConfirmTopics = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (params: ConfirmTopicsParams) => confirmTopics(params),
+ onSuccess: (_, variables) => {
+ // 제안된 주제 무효화
+ queryClient.invalidateQueries({
+ queryKey: topicQueryKeys.proposedList({
+ gatheringId: variables.gatheringId,
+ meetingId: variables.meetingId,
+ }),
+ })
+ // 확정된 주제 무효화
+ queryClient.invalidateQueries({
+ queryKey: topicQueryKeys.confirmedList({
+ gatheringId: variables.gatheringId,
+ meetingId: variables.meetingId,
+ }),
+ })
+ // 약속 상세 무효화
+ queryClient.invalidateQueries({
+ queryKey: meetingQueryKeys.detail(variables.meetingId),
+ })
+ },
+ })
+}
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/useCreateTopic.ts b/src/features/topics/hooks/useCreateTopic.ts
new file mode 100644
index 0000000..7fa9db2
--- /dev/null
+++ b/src/features/topics/hooks/useCreateTopic.ts
@@ -0,0 +1,52 @@
+/**
+ * @file useCreateTopic.ts
+ * @description 주제 제안 mutation 훅
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { ApiError } from '@/api/errors'
+
+import { createTopic } from '../topics.api'
+import type { CreateTopicParams, CreateTopicResponse } from '../topics.types'
+import { topicQueryKeys } from './topicQueryKeys'
+
+/**
+ * 주제 제안 mutation 훅
+ *
+ * @description
+ * 주제를 제안하고 관련 쿼리 캐시를 무효화합니다.
+ * - 제안된 주제 리스트 캐시 무효화
+ *
+ * @example
+ * ```tsx
+ * const createMutation = useCreateTopic()
+ * createMutation.mutate(
+ * { gatheringId: 1, meetingId: 2, body: { title: '주제 제목', topicType: 'FREE' } },
+ * {
+ * onSuccess: () => {
+ * console.log('주제가 제안되었습니다.')
+ * },
+ * onError: (error) => {
+ * console.error('제안 실패:', error.userMessage)
+ * },
+ * }
+ * )
+ * ```
+ */
+export const useCreateTopic = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (params: CreateTopicParams) => createTopic(params),
+ onSuccess: (_, variables) => {
+ // 제안된 주제 목록 캐시 무효화
+ queryClient.invalidateQueries({
+ queryKey: topicQueryKeys.proposedList({
+ gatheringId: variables.gatheringId,
+ meetingId: variables.meetingId,
+ }),
+ })
+ },
+ })
+}
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..deeb147
--- /dev/null
+++ b/src/features/topics/index.ts
@@ -0,0 +1,19 @@
+// 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'
+
+// Constants
+export * from './topics.constants'
diff --git a/src/features/topics/topics.api.ts b/src/features/topics/topics.api.ts
new file mode 100644
index 0000000..6f1abf6
--- /dev/null
+++ b/src/features/topics/topics.api.ts
@@ -0,0 +1,243 @@
+/**
+ * @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, getMockConfirmTopics, getMockProposedTopics } from './topics.mock'
+import type {
+ ConfirmTopicsParams,
+ ConfirmTopicsResponse,
+ CreateTopicParams,
+ CreateTopicResponse,
+ DeleteTopicParams,
+ GetConfirmedTopicsParams,
+ GetConfirmedTopicsResponse,
+ GetProposedTopicsParams,
+ GetProposedTopicsResponse,
+ LikeTopicParams,
+ LikeTopicResponse,
+} from './topics.types'
+
+/** 목데이터 사용 여부 플래그 */
+const USE_MOCK = import.meta.env.VITE_USE_MOCK === '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) {
+ // 실제 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) {
+ // 실제 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) {
+ // 실제 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.body - 요청 바디 (제목, 설명, 주제 타입)
+ *
+ * @returns 생성된 주제 정보
+ */
+export const createTopic = async (params: CreateTopicParams): Promise => {
+ const { gatheringId, meetingId, body } = params
+
+ // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용
+ // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거
+ if (USE_MOCK) {
+ // 실제 API 호출을 시뮬레이션하기 위한 지연
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return {
+ topicId: Math.floor(Math.random() * 1000),
+ title: body.title,
+ description: body.description || null,
+ topicType: body.topicType,
+ }
+ }
+
+ // 실제 API 호출 (로그인 완료 후 사용)
+ return api.post(TOPICS_ENDPOINTS.CREATE(gatheringId, meetingId), body)
+}
+
+/**
+ * 주제 좋아요 토글
+ *
+ * @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) {
+ // 실제 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))
+}
+
+/**
+ * 주제 확정
+ *
+ * @description
+ * 선택한 주제들을 순서대로 확정합니다.
+ *
+ * @param params - 확정 파라미터
+ * @param params.gatheringId - 모임 식별자
+ * @param params.meetingId - 약속 식별자
+ * @param params.topicIds - 확정할 주제 ID 목록 (순서대로)
+ *
+ * @returns 확정된 주제 정보
+ */
+export const confirmTopics = async (
+ params: ConfirmTopicsParams
+): Promise => {
+ const { gatheringId, meetingId, topicIds } = params
+
+ // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용
+ // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거
+ if (USE_MOCK) {
+ // 실제 API 호출을 시뮬레이션하기 위한 지연
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return getMockConfirmTopics(meetingId, topicIds)
+ }
+
+ // 실제 API 호출 (로그인 완료 후 사용)
+ return api.patch(TOPICS_ENDPOINTS.CONFIRM(gatheringId, meetingId), {
+ topicIds,
+ })
+}
diff --git a/src/features/topics/topics.constants.ts b/src/features/topics/topics.constants.ts
new file mode 100644
index 0000000..34e439b
--- /dev/null
+++ b/src/features/topics/topics.constants.ts
@@ -0,0 +1,55 @@
+import type { TopicType } from '@/features/topics/topics.types'
+
+export const TOPIC_TYPE_META: Record<
+ TopicType,
+ {
+ label: string
+ hint: string
+ }
+> = {
+ FREE: {
+ label: '자유형',
+ hint: '자유롭게 이야기 나누는 주제입니다',
+ },
+ DISCUSSION: {
+ label: '토론형',
+ hint: '찬반 토론이나 다양한 관점을 나누는 주제입니다',
+ },
+ EMOTION: {
+ label: '감정 공유형',
+ hint: '책을 읽으며 느낀 감정을 공유하는 주제입니다',
+ },
+ EXPERIENCE: {
+ label: '경험 연결형',
+ hint: '책의 내용을 개인 경험과 연결하는 주제입니다',
+ },
+ CHARACTER_ANALYSIS: {
+ label: '인물 분석형',
+ hint: '등장인물의 성격, 동기, 변화를 분석하는 주제입니다',
+ },
+ COMPARISON: {
+ label: '비교 분석형',
+ hint: '다른 작품이나 현실과 비교하는 주제입니다',
+ },
+ STRUCTURE: {
+ label: '구조 분석형',
+ hint: '책의 구성, 서술 방식, 문제를 분석하는 주제입니다',
+ },
+ IN_DEPTH: {
+ label: '심층 분석형',
+ hint: '주제, 상징, 메시지를 깊이 있게 분석하는 주제입니다',
+ },
+ CREATIVE: {
+ label: '창작형',
+ hint: '후속 이야기나 다른 결말을 창작하는 주제입니다',
+ },
+ CUSTOM: {
+ label: '질문형',
+ hint: '궁금한 점을 질문하고 함께 답을 찾는 주제입니다',
+ },
+}
+
+export const TOPIC_TYPE_OPTIONS = (Object.keys(TOPIC_TYPE_META) as TopicType[]).map((key) => ({
+ value: key,
+ ...TOPIC_TYPE_META[key],
+}))
diff --git a/src/features/topics/topics.endpoints.ts b/src/features/topics/topics.endpoints.ts
new file mode 100644
index 0000000..391a01e
--- /dev/null
+++ b/src/features/topics/topics.endpoints.ts
@@ -0,0 +1,26 @@
+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`,
+
+ // 주제 확정 (POST /api/gatherings/{gatheringId}/meetings/{meetingId}/topics/confirm)
+ CONFIRM: (gatheringId: number, meetingId: number) =>
+ `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/confirm`,
+ // 주제 제안 (POST /api/gatherings/{gatheringId}/meetings/{meetingId}/topics)
+ CREATE: (gatheringId: number, meetingId: number) =>
+ `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics`,
+} as const
diff --git a/src/features/topics/topics.mock.ts b/src/features/topics/topics.mock.ts
new file mode 100644
index 0000000..e535579
--- /dev/null
+++ b/src/features/topics/topics.mock.ts
@@ -0,0 +1,512 @@
+/**
+ * @file topics.mock.ts
+ * @description Topic API 목데이터
+ */
+
+import type {
+ ConfirmedTopicItem,
+ ConfirmTopicsResponse,
+ 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: true,
+ 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,
+ },
+ }
+}
+
+/**
+ * 주제 확정 목데이터 반환 함수
+ *
+ * @description
+ * 실제 API 호출을 시뮬레이션하여 주제 확정 응답 목데이터를 반환합니다.
+ */
+export const getMockConfirmTopics = (
+ meetingId: number,
+ topicIds: number[]
+): ConfirmTopicsResponse => {
+ return {
+ meetingId,
+ topicStatus: 'CONFIRMED',
+ topics: topicIds.map((topicId, index) => ({
+ topicId,
+ confirmOrder: index + 1,
+ })),
+ }
+}
diff --git a/src/features/topics/topics.types.ts b/src/features/topics/topics.types.ts
new file mode 100644
index 0000000..68e8b5a
--- /dev/null
+++ b/src/features/topics/topics.types.ts
@@ -0,0 +1,265 @@
+/**
+ * @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
+ /** 주제 설명 (Todo : 없을떄 null인지 빈값인지 체크해야함)*/
+ 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
+ /** 주제 설명 (Todo : 없을떄 null인지 빈값인지 체크해야함)*/
+ 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
+}
+
+/**
+ * 주제 확정 요청 파라미터
+ */
+export type ConfirmTopicsParams = {
+ /** 모임 식별자 */
+ gatheringId: number
+ /** 약속 식별자 */
+ meetingId: number
+ /** 확정할 주제 ID 목록 (순서대로) */
+ topicIds: number[]
+}
+
+/**
+ * 주제 확정 응답
+ */
+export type ConfirmTopicsResponse = {
+ /** 약속 식별자 */
+ meetingId: number
+ /** 주제 상태 */
+ topicStatus: TopicStatus
+ /** 확정된 주제 목록 */
+ topics: Array<{
+ topicId: number
+ confirmOrder: number
+ }>
+}
+
+/**
+ * 주제 제안 요청 파라미터
+ */
+export type CreateTopicParams = {
+ /** 모임 식별자 */
+ gatheringId: number
+ /** 약속 식별자 */
+ meetingId: number
+ /** 요청 바디 */
+ body: CreateTopicRequest
+}
+
+/**
+ * 주제 제안 요청 바디
+ */
+export type CreateTopicRequest = {
+ /** 주제 제목 */
+ title: string
+ /** 주제 설명 (Todo: 없을때 어떻게 보낼지 체크해야 함)*/
+ description: string | null
+ /** 주제 타입 */
+ topicType: TopicType
+}
+
+/**
+ * 주제 제안 응답
+ */
+export type CreateTopicResponse = {
+ /** 주제 ID */
+ topicId: number
+ /** 주제 제목 */
+ title: string
+ /** 주제 설명 (Todo: 없을때 null인지 빈값인지 체크해야 함)*/
+ description: string | null
+ /** 주제 타입 */
+ topicType: TopicType
+}
diff --git a/src/features/user/components/ProfileImagePicker.tsx b/src/features/user/components/ProfileImagePicker.tsx
index a164ca3..fe52694 100644
--- a/src/features/user/components/ProfileImagePicker.tsx
+++ b/src/features/user/components/ProfileImagePicker.tsx
@@ -3,8 +3,8 @@ import { useId, useRef } from 'react'
import UserAvatarIcon from '@/shared/assets/icon/UserAvatar.svg'
import { ALLOWED_IMAGE_ACCEPT, MAX_IMAGE_SIZE } from '@/shared/constants'
+import { showErrorToast } from '@/shared/lib/toast'
import { Avatar, AvatarFallback, AvatarImage, Button, TextButton } from '@/shared/ui'
-import { useGlobalModalStore } from '@/store'
interface ProfileImagePickerProps {
/** 현재 표시할 이미지 URL */
@@ -49,13 +49,11 @@ export function ProfileImagePicker({
}: ProfileImagePickerProps) {
const inputId = useId()
const fileInputRef = useRef(null)
- const { openAlert } = useGlobalModalStore()
-
const handleFileChange = (e: React.ChangeEvent) => {
const file = e.target.files?.[0]
if (file) {
if (file.size > MAX_IMAGE_SIZE) {
- openAlert('파일 크기 초과', '이미지 파일은 5MB 이하만 업로드할 수 있습니다.')
+ showErrorToast('이미지 파일은 5MB 이하만 업로드할 수 있습니다.')
e.target.value = ''
return
}
diff --git a/src/pages/Books/BookDetailPage.tsx b/src/pages/Books/BookDetailPage.tsx
index 86096ca..4495476 100644
--- a/src/pages/Books/BookDetailPage.tsx
+++ b/src/pages/Books/BookDetailPage.tsx
@@ -17,11 +17,13 @@ export default function BookDetailPage() {
return (
<>
- toggleReadingStatus()}
- />
+
+ toggleReadingStatus()}
+ />
+
>
)
diff --git a/src/pages/Books/BookListPage.tsx b/src/pages/Books/BookListPage.tsx
index 550f12b..45d0f76 100644
--- a/src/pages/Books/BookListPage.tsx
+++ b/src/pages/Books/BookListPage.tsx
@@ -1,3 +1,255 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+import type { SearchBookItem } from '@/features/book'
+import { BookList, BookSearchModal, useBooks, useCreateBook, useDeleteBook } from '@/features/book'
+import { showToast } from '@/shared/lib/toast'
+import { Button, Tabs, TabsContent, TabsList, TabsTrigger, TextButton } from '@/shared/ui'
+import { useGlobalModalStore } from '@/store'
+
export default function BookListPage() {
- return Book List Page
+ const { mutateAsync: deleteBook } = useDeleteBook()
+ const { mutateAsync: createBook, isPending: isCreating } = useCreateBook()
+ const { openConfirm } = useGlobalModalStore()
+
+ // 현재 활성 탭 상태
+ const [activeTab, setActiveTab] = useState<'all' | 'reading' | 'completed'>('all')
+
+ // 편집 모드 상태
+ const [isEditMode, setIsEditMode] = useState(false)
+ const [selectedBookIds, setSelectedBookIds] = useState>(new Set())
+
+ // 편집 모드 ref (콜백 안정성을 위해)
+ const isEditModeRef = useRef(isEditMode)
+ useEffect(() => {
+ isEditModeRef.current = isEditMode
+ }, [isEditMode])
+
+ // 도서 검색 모달 상태
+ const [isSearchModalOpen, setIsSearchModalOpen] = useState(false)
+
+ // 현재 탭에서 필터링된 책 ID 목록 (BookList에서 전달받음)
+ const [filteredBookIds, setFilteredBookIds] = useState([])
+
+ // 카운트 전용 쿼리 (탭과 무관하게 전체 데이터에서 카운트 조회)
+ const { data: countData } = useBooks()
+
+ // 첫 페이지에서 카운트 정보 가져오기
+ const firstPage = countData?.pages[0]
+ const totalCount = firstPage?.totalCount ?? 0
+ const readingCount = firstPage?.readingCount ?? 0
+ const completedCount = firstPage?.completedCount ?? 0
+
+ // 선택 토글
+ const handleSelectToggle = (bookId: number) => {
+ setSelectedBookIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(bookId)) {
+ next.delete(bookId)
+ } else {
+ next.add(bookId)
+ }
+ return next
+ })
+ }
+
+ // 전체 선택 (현재 화면에 표시된 책 기준, 멤버십 기반)
+ const handleSelectAll = () => {
+ const allSelected =
+ filteredBookIds.length > 0 && filteredBookIds.every((id) => selectedBookIds.has(id))
+
+ if (allSelected) {
+ // 전체 해제
+ setSelectedBookIds(new Set())
+ } else {
+ // 전체 선택
+ setSelectedBookIds(new Set(filteredBookIds))
+ }
+ }
+
+ // 삭제하기
+ const handleDelete = async () => {
+ if (selectedBookIds.size === 0) return
+
+ const confirmed = await openConfirm(
+ '책 삭제하기',
+ '책장 속 책을 삭제하면 해당 책의 감상 기록도 모두 삭제되며,\n이 과정은 되돌릴 수 없어요. 삭제를 진행할까요?',
+ {
+ confirmText: '삭제',
+ variant: 'danger',
+ }
+ )
+
+ if (!confirmed) return
+
+ const bookIds = [...selectedBookIds]
+ const results = await Promise.allSettled(bookIds.map((id) => deleteBook(id)))
+
+ // 성공한 ID만 선택 해제
+ const succeededIds = new Set(
+ results
+ .map((result, index) => (result.status === 'fulfilled' ? bookIds[index] : null))
+ .filter((id): id is number => id !== null)
+ )
+
+ const failedCount = results.filter((r) => r.status === 'rejected').length
+
+ if (failedCount > 0) {
+ await openConfirm(
+ '삭제 실패',
+ `${bookIds.length}권 중 ${failedCount}권 삭제에 실패했습니다.\n잠시 후 다시 시도해주세요.`,
+ { confirmText: '확인' }
+ )
+ }
+
+ // 성공한 항목만 선택에서 제거
+ setSelectedBookIds((prev) => {
+ const next = new Set(prev)
+ succeededIds.forEach((id) => {
+ next.delete(id)
+ })
+ return next
+ })
+
+ if (failedCount === 0) {
+ setIsEditMode(false)
+ }
+ }
+
+ // 편집 모드 토글
+ const handleEditModeToggle = () => {
+ if (isEditMode) {
+ // 편집 모드 종료 시 선택 초기화
+ setSelectedBookIds(new Set())
+ }
+ setIsEditMode(!isEditMode)
+ }
+
+ // 탭 변경 핸들러
+ const handleTabChange = (value: string) => {
+ setActiveTab(value as 'all' | 'reading' | 'completed')
+ // 탭 변경 시 선택 초기화
+ setSelectedBookIds(new Set())
+ }
+
+ // 필터링된 책 목록 변경 핸들러 (필터 변경 시 selectedBookIds도 정리)
+ const handleFilteredBooksChange = useCallback((bookIds: number[]) => {
+ setFilteredBookIds(bookIds)
+
+ // 편집 모드일 때만 selectedBookIds 정리
+ if (isEditModeRef.current) {
+ const filteredSet = new Set(bookIds)
+ setSelectedBookIds((prev) => {
+ const cleaned = new Set([...prev].filter((id) => filteredSet.has(id)))
+ if (cleaned.size !== prev.size) {
+ return cleaned
+ }
+ return prev
+ })
+ }
+ }, [])
+
+ // 멤버십 기반 전체 선택 여부 확인
+ const isAllSelected =
+ filteredBookIds.length > 0 && filteredBookIds.every((id) => selectedBookIds.has(id))
+
+ return (
+
+
내 책장
+
+
+
+
+ 전체
+
+
+ 기록 중
+
+
+ 기록 완료
+
+
+
+ {isEditMode ? (
+ <>
+
+ {isAllSelected ? '전체해제' : '전체선택'}
+
+
+ 삭제하기
+
+ {/* 취소 */}
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ try {
+ await createBook({
+ title: book.title,
+ authors: book.authors.join(', '),
+ publisher: book.publisher,
+ isbn: book.isbn,
+ thumbnail: book.thumbnail,
+ })
+ showToast('책이 추가되었습니다.')
+ } catch {
+ openConfirm('등록 실패', '책 등록에 실패했습니다.\n잠시 후 다시 시도해주세요.', {
+ confirmText: '확인',
+ })
+ }
+ }}
+ isPending={isCreating}
+ />
+
+ )
}
diff --git a/src/pages/Books/BookReviewHistoryPage.tsx b/src/pages/Books/BookReviewHistoryPage.tsx
index 8b7216e..cdfcb52 100644
--- a/src/pages/Books/BookReviewHistoryPage.tsx
+++ b/src/pages/Books/BookReviewHistoryPage.tsx
@@ -18,23 +18,25 @@ export default function BookReviewHistoryPage() {
return (
<>
-
- 지난 평가
- {isLoading && 로딩중...
}
- {historyData?.items.map((item, idx) => (
-
-
- {historyData?.items.length !== idx + 1 && }
-
- ))}
- {!isLoading && historyData?.items.length === 0 && (
-
-
- 아직 이 책을 평가하지 않았어요.
다 읽고 나서 이 책이 어땠는지 알려주세요!
-
-
- )}
-
+
+
+ 지난 평가
+ {isLoading && 로딩중...
}
+ {historyData?.items.map((item, idx) => (
+
+
+ {historyData?.items.length !== idx + 1 && }
+
+ ))}
+ {!isLoading && historyData?.items.length === 0 && (
+
+
+ 아직 이 책을 평가하지 않았어요.
다 읽고 나서 이 책이 어땠는지 알려주세요!
+
+
+ )}
+
+
>
)
}
diff --git a/src/pages/ComponentGuide/ComponentGuidePage.tsx b/src/pages/ComponentGuide/ComponentGuidePage.tsx
index 128d073..6ba8b85 100644
--- a/src/pages/ComponentGuide/ComponentGuidePage.tsx
+++ b/src/pages/ComponentGuide/ComponentGuidePage.tsx
@@ -1,6 +1,7 @@
import { ChevronDown, ChevronRight, Search } from 'lucide-react'
import { useState } from 'react'
+import { showErrorToast, showToast } from '@/shared/lib/toast'
import {
Avatar,
AvatarFallback,
@@ -39,6 +40,9 @@ import {
TabsTrigger,
Textarea,
TextButton,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
TopicTypeSelectGroup,
TopicTypeSelectItem,
UserChip,
@@ -72,6 +76,8 @@ function ComponentGuidePage() {
{ id: 'tabs', name: 'Tabs', category: '내비게이션' },
{ id: 'pagination', name: 'Pagination', category: '내비게이션' },
{ id: 'modal', name: 'Modal', category: '오버레이' },
+ { id: 'tooltip', name: 'Tooltip', category: '오버레이' },
+ { id: 'toast', name: 'Toast', category: '오버레이' },
]
const filteredSections = sections.filter(
@@ -156,6 +162,8 @@ function ComponentGuidePage() {
{selectedSection === 'tabs' && }
{selectedSection === 'pagination' && }
{selectedSection === 'modal' && }
+ {selectedSection === 'tooltip' && }
+ {selectedSection === 'toast' && }
@@ -1801,4 +1809,194 @@ function StarRatingFilterSection() {
)
}
+function TooltipSection() {
+ return (
+
+
+
+
+
+
+ 툴팁 내용
+
+`}
+ >
+
+
+
+
+
+ 이것은 기본 툴팁입니다
+
+
+
+
+ 위
+오른쪽
+아래
+왼쪽`}
+ >
+
+
+
+
+
+ 위에 표시됩니다
+
+
+
+
+
+
+
+ 오른쪽에 표시됩니다
+
+
+
+
+
+
+
+ 아래에 표시됩니다
+
+
+
+
+
+
+
+ 왼쪽에 표시됩니다
+
+
+
+
+
+
+
+
+
+ X 버튼으로만 닫을 수 있습니다
+
+`}
+ >
+
+
+
+
+
+ 내 의견을 먼저 공유해야 다른 멤버들의 의견도 확인할 수 있어요!
+
+
+
+
+
+ 간격이 넓습니다
+`}
+ >
+
+
+
+
+
+ 트리거와 거리가 멉니다
+
+
+
+
+
+
+
• 기본 툴팁: hover 시 자동으로 표시/숨김
+
• dismissable 툴팁: Tooltip에 dismissable prop 추가
+
• dismissable 모드: 항상 열려있고 X 버튼으로만 닫힘
+
• 한 번 닫으면 트리거에 hover해도 다시 열리지 않음
+
• 외부 클릭, ESC 키, 스크롤로 닫히지 않음 (dismissable 모드)
+
• asChild prop을 사용하여 자식 요소에 직접 이벤트 연결
+
• TooltipProvider는 App.tsx에서 전역으로 설정됨
+
+
+
+ )
+}
+
+function ToastSection() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
• Default: bg primary-200/50%, text grey-900, 3초 후 자동 닫힘
+
• Error: bg accent-200/50%, text accent-300, 3초 후 자동 닫힘
+
• position: bottom-center, max-width: 360px
+
• 최대 3개까지 동시 표시, 간격 12px
+
• Toaster는 App.tsx에서 전역으로 설정됨
+
+
+
+ )
+}
+
export default ComponentGuidePage
diff --git a/src/pages/Gatherings/CreateGatheringPage.tsx b/src/pages/Gatherings/CreateGatheringPage.tsx
index 8e71465..e7bef2b 100644
--- a/src/pages/Gatherings/CreateGatheringPage.tsx
+++ b/src/pages/Gatherings/CreateGatheringPage.tsx
@@ -6,6 +6,7 @@ import type { CreateGatheringResponse } from '@/features/gatherings'
import { useCreateGathering } from '@/features/gatherings'
import PaperPlane from '@/shared/assets/icon/paper-plane.svg'
import { ROUTES } from '@/shared/constants'
+import { showErrorToast, showToast } from '@/shared/lib/toast'
import { Button, Input, Textarea, TextButton } from '@/shared/ui'
import { useGlobalModalStore } from '@/store'
@@ -59,15 +60,17 @@ export default function CreateGatheringPage() {
try {
await navigator.clipboard.writeText(fullUrl)
- // TODO: toast 알림 표시
- } catch (error) {
- console.error('클립보드 복사 실패:', error)
+ showToast('초대 링크가 복사되었습니다.')
+ } catch {
+ showErrorToast('링크 복사에 실패했습니다.')
}
}
const handleComplete = () => {
if (createdData?.gatheringId) {
- navigate(ROUTES.GATHERING_DETAIL(createdData.gatheringId))
+ navigate(ROUTES.GATHERING_DETAIL(createdData.gatheringId), {
+ state: { justCreated: true },
+ })
} else {
navigate(ROUTES.GATHERINGS)
}
diff --git a/src/pages/Gatherings/GatheringDetailPage.tsx b/src/pages/Gatherings/GatheringDetailPage.tsx
index fbae97e..850bc0a 100644
--- a/src/pages/Gatherings/GatheringDetailPage.tsx
+++ b/src/pages/Gatherings/GatheringDetailPage.tsx
@@ -1,3 +1,132 @@
+import { useCallback, useEffect } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+
+import { type ApiError, ErrorCode } from '@/api'
+import {
+ GatheringBookshelfSection,
+ GatheringDetailHeader,
+ GatheringDetailInfo,
+ GatheringMeetingSection,
+ useGatheringDetail,
+ useToggleFavorite,
+} from '@/features/gatherings'
+import { ROUTES } from '@/shared/constants'
+import { useScrollCollapse } from '@/shared/hooks'
+import { showErrorToast, showToast } from '@/shared/lib/toast'
+import { Spinner } from '@/shared/ui'
+import { useGlobalModalStore } from '@/store/globalModalStore'
+
export default function GatheringDetailPage() {
- return Gathering Detail Page
+ const { id } = useParams<{ id: string }>()
+ const navigate = useNavigate()
+ const { openError } = useGlobalModalStore()
+
+ const parsedId = id ? Number(id) : NaN
+ const gatheringId = Number.isFinite(parsedId) ? parsedId : 0
+
+ // 스크롤 상태 (헤더 접힘 여부)
+ const isHeaderCollapsed = useScrollCollapse({ collapseThreshold: 100, expandThreshold: 20 })
+
+ // 모임 상세 조회
+ const { data: gathering, isLoading, error } = useGatheringDetail(gatheringId)
+
+ // 즐겨찾기 토글
+ const { mutate: toggleFavorite } = useToggleFavorite()
+
+ // 즐겨찾기 핸들러
+ const handleFavoriteToggle = useCallback(() => {
+ if (!gathering) return
+
+ toggleFavorite(gatheringId, {
+ onError: (error: ApiError) => {
+ if (error.is(ErrorCode.FAVORITE_LIMIT_EXCEEDED)) {
+ showErrorToast('즐겨찾기는 최대 4개까지만 등록할 수 있습니다.')
+ } else {
+ showErrorToast('즐겨찾기 변경에 실패했습니다.')
+ }
+ },
+ })
+ }, [gatheringId, gathering, toggleFavorite])
+
+ // 설정 버튼 핸들러
+ const handleSettingsClick = useCallback(() => {
+ navigate(ROUTES.GATHERING_SETTING(gatheringId))
+ }, [navigate, gatheringId])
+
+ // 초대 링크 복사 핸들러
+ const handleInviteClick = useCallback(async () => {
+ if (!gathering?.invitationLink) return
+
+ try {
+ const inviteUrl = `${window.location.origin}/invite/${gathering.invitationLink}`
+ await navigator.clipboard.writeText(inviteUrl)
+ showToast('초대 링크가 복사되었습니다.')
+ } catch {
+ showErrorToast('링크 복사에 실패했습니다.')
+ }
+ }, [gathering])
+
+ // 유효하지 않은 ID 처리
+ useEffect(() => {
+ if (gatheringId === 0) {
+ openError('오류', '잘못된 모임 ID입니다.', () => {
+ navigate(ROUTES.GATHERINGS, { replace: true })
+ })
+ }
+ }, [gatheringId, navigate, openError])
+
+ // API 에러 처리
+ useEffect(() => {
+ if (error) {
+ openError('오류', '모임 정보를 불러오는데 실패했습니다.', () => {
+ // 브라우저 히스토리가 없으면 모임 목록으로 이동
+ if (window.history.length > 1) {
+ navigate(-1)
+ } else {
+ navigate(ROUTES.GATHERINGS, { replace: true })
+ }
+ })
+ }
+ }, [error, navigate, openError])
+
+ // 로딩 상태
+ if (isLoading || !gathering) {
+ return
+ }
+
+ return (
+
+ {/* 헤더 (sticky, 전체 너비) */}
+
+
+ {/* 컨텐츠 영역 (패딩 적용) */}
+
+ {/* 모임 정보 (헤더 접힘 시 숨김) */}
+
+
+ {/* 약속 섹션 */}
+
+
+ {/* 모임 책장 섹션 */}
+
+
+
+ )
}
diff --git a/src/pages/Gatherings/GatheringListPage.tsx b/src/pages/Gatherings/GatheringListPage.tsx
index 69516fe..1d5074f 100644
--- a/src/pages/Gatherings/GatheringListPage.tsx
+++ b/src/pages/Gatherings/GatheringListPage.tsx
@@ -1,3 +1,160 @@
+import { useCallback, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import { type ApiError, ErrorCode } from '@/api'
+import {
+ EmptyState,
+ GatheringCard,
+ useFavoriteGatherings,
+ useGatherings,
+ useToggleFavorite,
+} from '@/features/gatherings'
+import { ROUTES } from '@/shared/constants'
+import { useInfiniteScroll } from '@/shared/hooks'
+import { showErrorToast } from '@/shared/lib/toast'
+import { Button, Spinner, Tabs, TabsList, TabsTrigger } from '@/shared/ui'
+
+type TabValue = 'all' | 'favorites'
+
export default function GatheringListPage() {
- return Gathering List Page
+ const navigate = useNavigate()
+ const [activeTab, setActiveTab] = useState('all')
+
+ // 전체 모임 목록 (무한 스크롤)
+ const {
+ data: gatheringsData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ } = useGatherings()
+
+ // 즐겨찾기 모임 목록
+ const { data: favoritesData, isLoading: isFavoritesLoading } = useFavoriteGatherings()
+
+ // 즐겨찾기 토글
+ const { mutate: toggleFavorite } = useToggleFavorite()
+
+ // 모임 목록 평탄화
+ const gatherings = gatheringsData?.pages.flatMap((page) => page.items) ?? []
+ const favorites = favoritesData?.gatherings ?? []
+
+ // 전체 개수 (첫 페이지 응답의 totalCount 사용)
+ const totalCount = gatheringsData?.pages[0]?.totalCount ?? 0
+ const favoritesCount = favorites.length
+
+ // 무한 스크롤
+ const observerRef = useInfiniteScroll(fetchNextPage, {
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ enabled: activeTab === 'all',
+ })
+
+ // 즐겨찾기 토글 핸들러
+ const handleFavoriteToggle = useCallback(
+ (gatheringId: number) => {
+ toggleFavorite(gatheringId, {
+ onError: (error: ApiError) => {
+ if (error.is(ErrorCode.FAVORITE_LIMIT_EXCEEDED)) {
+ showErrorToast('즐겨찾기는 최대 4개까지만 등록할 수 있습니다.')
+ } else {
+ showErrorToast('즐겨찾기 변경에 실패했습니다.')
+ }
+ },
+ })
+ },
+ [toggleFavorite]
+ )
+
+ // 카드 클릭 핸들러
+ const handleCardClick = useCallback(
+ (gatheringId: number) => {
+ navigate(ROUTES.GATHERING_DETAIL(gatheringId))
+ },
+ [navigate]
+ )
+
+ // 모임 만들기 버튼 클릭
+ const handleCreateClick = () => {
+ navigate(ROUTES.GATHERING_CREATE)
+ }
+
+ return (
+
+ {/* 타이틀 */}
+
독서모임
+
+ {/* 탭 + 버튼 영역 */}
+
+ setActiveTab(value as TabValue)}>
+
+
+ 전체
+
+
+ 즐겨찾기
+
+
+
+
+
+
+ {/* 컨텐츠 영역 */}
+ {activeTab === 'all' && (
+ <>
+ {isLoading ? (
+
+
+
+ ) : gatherings.length === 0 ? (
+
+ ) : (
+ <>
+
+ {gatherings.map((gathering) => (
+ handleCardClick(gathering.gatheringId)}
+ />
+ ))}
+
+ {/* 무한 스크롤 트리거 - 그리드 아래에 위치 */}
+ {hasNextPage &&
}
+ >
+ )}
+ {isFetchingNextPage && (
+
+
+
+ )}
+ >
+ )}
+
+ {activeTab === 'favorites' && (
+ <>
+ {isFavoritesLoading ? (
+
+
+
+ ) : favorites.length === 0 ? (
+
+ ) : (
+
+ {favorites.map((gathering) => (
+ handleCardClick(gathering.gatheringId)}
+ />
+ ))}
+
+ )}
+ >
+ )}
+
+ )
}
diff --git a/src/pages/Gatherings/GatheringSettingPage.tsx b/src/pages/Gatherings/GatheringSettingPage.tsx
new file mode 100644
index 0000000..f5988a3
--- /dev/null
+++ b/src/pages/Gatherings/GatheringSettingPage.tsx
@@ -0,0 +1,328 @@
+import { useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+
+import type { GatheringMember, MemberCardAction } from '@/features/gatherings'
+import {
+ MAX_DESCRIPTION_LENGTH,
+ MAX_NAME_LENGTH,
+ MemberCard,
+ useDeleteGathering,
+ useGatheringDetail,
+ useGatheringMembers,
+ useGatheringSettingForm,
+ useHandleJoinRequest,
+ useRemoveMember,
+ useUpdateGathering,
+} from '@/features/gatherings'
+import FormPageHeader from '@/shared/components/FormPageHeader'
+import { ROUTES } from '@/shared/constants'
+import { showErrorToast, showToast } from '@/shared/lib/toast'
+import {
+ Button,
+ Container,
+ Input,
+ Spinner,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+ Textarea,
+} from '@/shared/ui'
+import { useGlobalModalStore } from '@/store'
+
+type MemberTab = 'PENDING' | 'ACTIVE'
+
+export default function GatheringSettingPage() {
+ const { id } = useParams<{ id: string }>()
+ const parsedId = id ? Number(id) : NaN
+ const gatheringId = Number.isFinite(parsedId) ? parsedId : 0
+ const navigate = useNavigate()
+ const { openConfirm, openError } = useGlobalModalStore()
+
+ const { data: gathering, isLoading, error } = useGatheringDetail(gatheringId)
+ const { name, setName, description, setDescription, isValid, getFormData } =
+ useGatheringSettingForm(gathering)
+
+ const [activeTab, setActiveTab] = useState('PENDING')
+
+ const {
+ data: pendingData,
+ fetchNextPage: fetchNextPending,
+ hasNextPage: hasNextPending,
+ isFetchingNextPage: isFetchingNextPending,
+ } = useGatheringMembers(gatheringId, 'PENDING')
+
+ const {
+ data: activeData,
+ fetchNextPage: fetchNextActive,
+ hasNextPage: hasNextActive,
+ isFetchingNextPage: isFetchingNextActive,
+ } = useGatheringMembers(gatheringId, 'ACTIVE')
+
+ const pendingMembers = pendingData?.pages.flatMap((page) => page.items) ?? []
+ const activeMembers = activeData?.pages.flatMap((page) => page.items) ?? []
+ const pendingTotalCount = pendingData?.pages[0]?.totalCount ?? 0
+ const activeTotalCount = activeData?.pages[0]?.totalCount ?? 0
+
+ const updateMutation = useUpdateGathering()
+ const deleteMutation = useDeleteGathering()
+ const joinRequestMutation = useHandleJoinRequest()
+ const removeMemberMutation = useRemoveMember()
+
+ // 에러 처리
+ useEffect(() => {
+ if (error) {
+ openError('오류', '모임 정보를 불러오는 데 실패했습니다.', () => {
+ navigate(ROUTES.GATHERINGS)
+ })
+ }
+ }, [error, openError, navigate])
+
+ // 모임 정보 저장
+ const handleSave = () => {
+ if (!isValid || updateMutation.isPending) return
+
+ updateMutation.mutate(
+ { gatheringId, data: getFormData() },
+ {
+ onSuccess: () => {
+ showToast('모임 정보가 수정되었습니다.')
+ },
+ onError: () => {
+ showErrorToast('모임 정보 수정에 실패했습니다.')
+ },
+ }
+ )
+ }
+
+ // 모임 삭제
+ const handleDeleteGathering = async () => {
+ const confirmed = await openConfirm(
+ '모임 삭제하기',
+ '모임의 모든 정보와 기록이 사라지며, 다시 되돌릴 수 없어요.\n정말 이 모임을 삭제할까요?',
+ { confirmText: '모임 삭제', variant: 'danger' }
+ )
+ if (!confirmed) return
+
+ deleteMutation.mutate(gatheringId, {
+ onSuccess: () => {
+ navigate(ROUTES.GATHERINGS, { replace: true })
+ },
+ onError: () => {
+ openError('오류', '모임 삭제에 실패했습니다.')
+ },
+ })
+ }
+
+ // 가입 요청 승인
+ const handleApprove = (member: GatheringMember) => {
+ joinRequestMutation.mutate(
+ { gatheringId, memberId: member.userId, approveType: 'ACTIVE' },
+ {
+ onSuccess: () => {
+ showToast(`${member.nickname}님의 가입을 승인했습니다.`)
+ },
+ onError: () => {
+ showErrorToast('가입 승인에 실패했습니다.')
+ },
+ }
+ )
+ }
+
+ // 가입 요청 거절
+ const handleReject = (member: GatheringMember) => {
+ joinRequestMutation.mutate(
+ { gatheringId, memberId: member.userId, approveType: 'REJECTED' },
+ {
+ onSuccess: () => {
+ showToast(`${member.nickname}님의 가입을 거절했습니다.`)
+ },
+ onError: () => {
+ showErrorToast('가입 거절에 실패했습니다.')
+ },
+ }
+ )
+ }
+
+ // 멤버 삭제(강퇴)
+ const handleRemoveMember = async (member: GatheringMember) => {
+ const confirmed = await openConfirm(
+ `${member.nickname} 내보내기`,
+ `내보낸 멤버는 이 모임에 더 이상 접근할 수 없어요.\n정말 이 멤버를 내보낼까요?`,
+ { confirmText: '내보내기', variant: 'danger' }
+ )
+ if (!confirmed) return
+
+ removeMemberMutation.mutate(
+ { gatheringId, userId: member.userId },
+ {
+ onSuccess: () => {
+ showToast(`${member.nickname}님을 모임에서 내보냈습니다.`)
+ },
+ onError: () => {
+ showErrorToast('멤버 내보내기에 실패했습니다.')
+ },
+ }
+ )
+ }
+
+ const handleMemberAction = (action: MemberCardAction, member: GatheringMember) => {
+ switch (action) {
+ case 'approve':
+ handleApprove(member)
+ break
+ case 'reject':
+ handleReject(member)
+ break
+ case 'remove':
+ handleRemoveMember(member)
+ break
+ }
+ }
+
+ if (isLoading) {
+ return
+ }
+
+ if (!gathering || gathering.currentUserRole !== 'LEADER') return null
+
+ return (
+ <>
+
+
+
+
+ {/* 독서모임 정보 섹션 */}
+
+
+ 독서모임 정보
+
+
+
+ setName(e.target.value)}
+ maxLength={MAX_NAME_LENGTH}
+ />
+
+
+
+ {/* 멤버 관리 섹션 */}
+
+ 멤버 관리
+
+ setActiveTab(value as MemberTab)}
+ className="gap-0"
+ >
+
+
+ 승인 대기
+
+
+ 승인 완료
+
+
+
+ {pendingMembers.length === 0 ? (
+
+
+ 승인 대기 중인 멤버가 없어요.
+
+
+ ) : (
+
+
+ {pendingMembers.map((member) => (
+
+ ))}
+
+ {hasNextPending && (
+
+ )}
+
+ )}
+
+
+ {activeMembers.length === 0 ? (
+
+ ) : (
+
+
+ {activeMembers.map((member) => (
+
+ ))}
+
+ {hasNextActive && (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/pages/Gatherings/index.ts b/src/pages/Gatherings/index.ts
index 29c0d51..df3c1e7 100644
--- a/src/pages/Gatherings/index.ts
+++ b/src/pages/Gatherings/index.ts
@@ -1,4 +1,5 @@
export { default as CreateGatheringPage } from './CreateGatheringPage'
export { default as GatheringDetailPage } from './GatheringDetailPage'
export { default as GatheringListPage } from './GatheringListPage'
+export { default as GatheringSettingPage } from './GatheringSettingPage'
export { default as InvitePage } from './InvitePage'
diff --git a/src/pages/Home/HomePage.tsx b/src/pages/Home/HomePage.tsx
index c8c9393..f9e4a79 100644
--- a/src/pages/Home/HomePage.tsx
+++ b/src/pages/Home/HomePage.tsx
@@ -1,12 +1,32 @@
import { useUserProfile } from '@/features/user'
+import FavoriteGatheringsSection from './components/FavoriteGatheringsSection'
+import MyMeetingsSection from './components/MyMeetingsSection'
+import ReadingBooksSection from './components/ReadingBooksSection'
+
export default function HomePage() {
const { data: user } = useUserProfile()
return (
-
-
안녕하세요, {user?.nickname ?? ''}님!
-
읽고 있는 책과 생각을 기록해보세요
+
+ {/* 인사말 */}
+
+
+ {user ? `안녕하세요, ${user.nickname}님!` : '\u00A0'}
+
+
읽고 있는 책과 생각을 기록해보세요
+
+
+
+ {/* 지금 읽고 있는 책 */}
+
+
+ {/* 내 약속 */}
+
+
+ {/* 즐겨찾는 모임 */}
+
+
)
}
diff --git a/src/pages/Home/components/FavoriteGatheringsSection.tsx b/src/pages/Home/components/FavoriteGatheringsSection.tsx
new file mode 100644
index 0000000..4ac4d2c
--- /dev/null
+++ b/src/pages/Home/components/FavoriteGatheringsSection.tsx
@@ -0,0 +1,61 @@
+import { useNavigate } from 'react-router-dom'
+
+import { GatheringCard, useFavoriteGatherings, useToggleFavorite } from '@/features/gatherings'
+import { ROUTES } from '@/shared/constants'
+import { useDeferredLoading } from '@/shared/hooks'
+
+import HomeSectionHeader from './HomeSectionHeader'
+
+export default function FavoriteGatheringsSection() {
+ const navigate = useNavigate()
+ const { data, isLoading } = useFavoriteGatherings()
+ const showSkeleton = useDeferredLoading(isLoading)
+ const { mutate: toggleFavorite } = useToggleFavorite()
+
+ const gatherings = data?.gatherings ?? []
+
+ return (
+
+
+
+ {showSkeleton ? (
+
+ {[...Array(3).keys()].map((i) => (
+
+ ))}
+
+ ) : !data ? null : gatherings.length === 0 ? (
+
+
즐겨찾기한 모임이 없어요.
+
+ 자주 찾는 모임을 등록하고 한눈에 확인해 보세요.
+
+
+ ) : (
+
+ {gatherings.map((gathering) => (
+ navigate(ROUTES.GATHERING_DETAIL(gathering.gatheringId))}
+ />
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/pages/Home/components/HomeBookCard.tsx b/src/pages/Home/components/HomeBookCard.tsx
new file mode 100644
index 0000000..cde53ec
--- /dev/null
+++ b/src/pages/Home/components/HomeBookCard.tsx
@@ -0,0 +1,32 @@
+import { Link } from 'react-router-dom'
+
+import { ROUTES } from '@/shared/constants'
+
+interface HomeBookCardProps {
+ bookId: number
+ title: string
+ authors: string
+ thumbnail: string
+}
+
+export default function HomeBookCard({ bookId, title, authors, thumbnail }: HomeBookCardProps) {
+ return (
+
+ {/* 썸네일 */}
+
+

+
+
+ {/* 책 정보 */}
+
+
+ )
+}
diff --git a/src/pages/Home/components/HomeMeetingCard.tsx b/src/pages/Home/components/HomeMeetingCard.tsx
new file mode 100644
index 0000000..507be72
--- /dev/null
+++ b/src/pages/Home/components/HomeMeetingCard.tsx
@@ -0,0 +1,127 @@
+import { differenceInCalendarDays, format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+import type { MouseEvent } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import type { MyMeetingListItem } from '@/features/meetings'
+import { ROUTES } from '@/shared/constants'
+import { cn } from '@/shared/lib/utils'
+import { Badge, Button } from '@/shared/ui'
+
+interface HomeMeetingCardProps {
+ meeting: MyMeetingListItem
+}
+
+function formatMeetingDate(dateStr: string) {
+ return format(new Date(dateStr), 'yy.MM.dd(eee) HH:mm', { locale: ko })
+}
+
+function getDDay(startDateTime: string): string {
+ const diffDays = differenceInCalendarDays(new Date(startDateTime), new Date())
+ if (diffDays === 0) return 'D-Day'
+ if (diffDays > 0) return `D-${diffDays}`
+ return ''
+}
+
+const STATUS_CONFIG = {
+ ONGOING: { label: '약속 중', color: 'red' as const },
+ UPCOMING: { label: '예정', color: 'yellow' as const },
+ DONE: { label: '종료', color: 'grey' as const },
+ UNKNOWN: { label: '', color: 'grey' as const },
+}
+
+export default function HomeMeetingCard({ meeting }: HomeMeetingCardProps) {
+ const navigate = useNavigate()
+ const {
+ meetingId,
+ meetingName,
+ gatheringId,
+ gatheringName,
+ startDateTime,
+ endDateTime,
+ myRole,
+ progressStatus,
+ } = meeting
+
+ const status = STATUS_CONFIG[progressStatus]
+ const isOngoing = progressStatus === 'ONGOING'
+ const isUpcoming = progressStatus === 'UPCOMING'
+ const isDone = progressStatus === 'DONE'
+ const isLeader = myRole === 'LEADER'
+ const dDay = isUpcoming ? getDDay(startDateTime) : ''
+
+ const handleCardClick = () => {
+ navigate(ROUTES.MEETING_DETAIL(gatheringId, meetingId))
+ }
+
+ const handleActionClick = (e: MouseEvent) => {
+ e.stopPropagation()
+ if (isUpcoming) {
+ navigate(ROUTES.PRE_OPINIONS(gatheringId, meetingId))
+ }
+ // TODO: 종료 → 개인 회고 작성 페이지 연결
+ }
+
+ return (
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ handleCardClick()
+ }
+ }}
+ >
+ {/* 상태 태그 */}
+
+ {status.label && (
+
+ {status.label}
+
+ )}
+
+
+ {/* 약속 정보 */}
+
+
+
+ {gatheringName} | {meetingName}
+
+ {isLeader && (
+
+ 약속장
+
+ )}
+
+
+ {dDay && {dDay}}
+
+ {formatMeetingDate(startDateTime)} ~ {formatMeetingDate(endDateTime)}
+
+
+
+
+ {/* 액션 버튼 */}
+ {/* TODO: API 응답에 hasPreOpinionTemplate 필드 추가 후, 템플릿 미제작 시 disabled 처리 */}
+ {isUpcoming && (
+
+ )}
+ {isDone && (
+ // TODO: 개인 회고 작성 페이지 연결 후 disabled 제거
+
+ )}
+
+ )
+}
diff --git a/src/pages/Home/components/HomeSectionHeader.tsx b/src/pages/Home/components/HomeSectionHeader.tsx
new file mode 100644
index 0000000..2eca4d4
--- /dev/null
+++ b/src/pages/Home/components/HomeSectionHeader.tsx
@@ -0,0 +1,40 @@
+import { ChevronRight } from 'lucide-react'
+import type { ReactNode } from 'react'
+import { Link } from 'react-router-dom'
+
+import { cn } from '@/shared/lib/utils'
+
+interface HomeSectionHeaderProps {
+ title: string
+ linkTo?: string
+ linkLabel?: string
+ children?: ReactNode
+ className?: string
+}
+
+export default function HomeSectionHeader({
+ title,
+ linkTo,
+ linkLabel,
+ children,
+ className,
+}: HomeSectionHeaderProps) {
+ return (
+
+
+
{title}
+ {children}
+
+
+ {linkTo && linkLabel && (
+
+
{linkLabel}
+
+
+ )}
+
+ )
+}
diff --git a/src/pages/Home/components/MyMeetingsSection.tsx b/src/pages/Home/components/MyMeetingsSection.tsx
new file mode 100644
index 0000000..d1f1922
--- /dev/null
+++ b/src/pages/Home/components/MyMeetingsSection.tsx
@@ -0,0 +1,116 @@
+import { ChevronDown, ChevronUp } from 'lucide-react'
+import { useState } from 'react'
+
+import { type MyMeetingFilter, useMyMeetings, useMyMeetingTabCounts } from '@/features/meetings'
+import { PAGE_SIZES } from '@/shared/constants'
+import { useDeferredLoading } from '@/shared/hooks'
+import { Tabs, TabsList, TabsTrigger } from '@/shared/ui'
+
+import HomeMeetingCard from './HomeMeetingCard'
+import HomeSectionHeader from './HomeSectionHeader'
+
+export default function MyMeetingsSection() {
+ const [activeTab, setActiveTab] = useState
('ALL')
+ const [isCollapsed, setIsCollapsed] = useState(false)
+
+ const { data: tabCounts } = useMyMeetingTabCounts()
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
+ useMyMeetings(activeTab)
+ const showSkeleton = useDeferredLoading(isLoading)
+
+ const allItems = data?.pages.flatMap((page) => page.items) ?? []
+ const hasMultiplePages = (data?.pages.length ?? 0) > 1
+ const displayItems = isCollapsed ? allItems.slice(0, PAGE_SIZES.MY_MEETINGS) : allItems
+
+ const handleTabChange = (value: string) => {
+ setActiveTab(value as MyMeetingFilter)
+ setIsCollapsed(false)
+ }
+
+ const handleExpand = () => {
+ if (hasNextPage) {
+ fetchNextPage()
+ }
+ setIsCollapsed(false)
+ }
+
+ const handleCollapse = () => {
+ setIsCollapsed(true)
+ }
+
+ return (
+
+
+
+
+
+ 전체
+
+
+ 다가오는 약속
+
+
+ 종료된 약속
+
+
+
+
+
+ {showSkeleton ? (
+
+ {[...Array(PAGE_SIZES.MY_MEETINGS).keys()].map((i) => (
+
+ ))}
+
+ ) : allItems.length === 0 ? (
+
+ ) : (
+
+ {/* 약속 리스트 — 최대 높이 392px, 스크롤 */}
+
+ {displayItems.map((meeting) => (
+
+ ))}
+ {isFetchingNextPage && (
+
+ )}
+
+
+ {/* 펼치기 / 접기 버튼 */}
+ {(hasNextPage || hasMultiplePages) && (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/pages/Home/components/ReadingBooksSection.tsx b/src/pages/Home/components/ReadingBooksSection.tsx
new file mode 100644
index 0000000..b416ea2
--- /dev/null
+++ b/src/pages/Home/components/ReadingBooksSection.tsx
@@ -0,0 +1,91 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import { BookCarousel, useBooks } from '@/features/book'
+import { ROUTES } from '@/shared/constants'
+import { useDeferredLoading } from '@/shared/hooks'
+import { Button, Tabs, TabsList, TabsTrigger } from '@/shared/ui'
+
+import HomeBookCard from './HomeBookCard'
+import HomeSectionHeader from './HomeSectionHeader'
+
+type BookTab = 'all' | 'pre' | 'post'
+
+export default function ReadingBooksSection() {
+ // TODO: pre/post 탭 활성화 시 activeTab을 useBooks filter 파라미터로 연결 필요
+ const [activeTab, setActiveTab] = useState('all')
+ const navigate = useNavigate()
+
+ const { data, isLoading } = useBooks({ status: 'READING' })
+ const showSkeleton = useDeferredLoading(isLoading)
+
+ const books = data?.pages.flatMap((page) => page.items) ?? []
+ const totalCount = data?.pages[0]?.readingCount ?? 0
+
+ return (
+
+
+ setActiveTab(v as BookTab)}>
+
+
+ 전체
+
+
+ 약속 전
+
+
+ 약속 후
+
+
+
+
+
+ {showSkeleton ? (
+
+ {[...Array(6).keys()].map((i) => (
+
+ ))}
+
+ ) : books.length === 0 ? (
+
+
+
내 책장이 비어있어요.
+
+ 첫 번째 책을 등록하고 독서 기록을 시작해 보세요!
+
+
+
+
+ ) : (
+
+ {books.map((book) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/pages/Landing/LandingPage.tsx b/src/pages/Landing/LandingPage.tsx
new file mode 100644
index 0000000..b173e03
--- /dev/null
+++ b/src/pages/Landing/LandingPage.tsx
@@ -0,0 +1,131 @@
+import { useNavigate } from 'react-router-dom'
+
+import LandingImage from '@/shared/assets/images/landing.png'
+import LandingImageWebP from '@/shared/assets/images/landing.webp'
+import { ROUTES } from '@/shared/constants'
+import { Button } from '@/shared/ui'
+export default function LandingPage() {
+ const navigate = useNavigate()
+ return (
+
+
+
+
+
+
+
+ 대화로 넓히고 기록으로 깊어지는 독서생활
+ 독크독크
+
+ 독서모임에서 나눈 이야기, 그날의 감정과 관점 변화까지 책 한 권 안에 차곡차곡 남겨보세요.
+
+
+
+
+
+
+
+ 독서모임이 끝나면, 이런 경험 없으셨나요?
+
+ - “모임에서 나눈 대화가 시간이 지나면 잘 떠오르지 않지 않아요”
+ - “기록을 남기고 싶어도, 어떻게 정리해야 할지 막막해 결국 기록을 미루게 돼요”
+ - “이 책을 어떻게 읽었는지 한 번에 돌아볼 수 없어요”
+
+
+
+
+ 독크독크는 흩어지던 독서모임의 대화와 생각을 책 중심의 기록으로 연결합니다
+
+
+ - 모든 기록의 중심은, ‘책 한 권’입니다
+ -
+ 독서모임 전 생각, 모임에서의 대화, 모임 후의 변화까지 모두 한 권의 책 안에 쌓입니다.
+
+
+
+
+ - 생각이 자연스럽게 정리되도록 돕습니다
+ -
+ 질문, 주제, 관점에 따라 생각을 적을 수 있는 구조를 제공해 기록의 부담을 줄입니다.
+
+
+
+
+ - 한 번의 모임으로 끝나지 않는 독서 경험
+ - 모임에서 나눈 이야기와 그로 인해 바뀐 나의 생각이 다음 독서로 이어집니다.
+
+
+
+
+ 독서모임의 대화와 생각을 기록으로 이어주는 핵심 기능
+
+
+ - 모임 전 기록 : 대화를 준비하는 기록
+ -
+ 모임 전에 주제를 정하고, 각자의 생각을 미리 정리해 둡니다. 모임에서 “무슨 얘기를 할지”
+ 고민하는 대신, 이미 준비된 생각 위에서 대화가 시작됩니다.
+
+ - 주제 제안 & 반응으로 대화 방향을 함께 결정
+ - 사전 의견 템플릿으로 생각을 미리 정리하며 기록
+ - 모임 중 ‘기록 대신 대화’에만 집중
+
+
+
+ - 모임 후 기록 : 대화의 순간을 남기는 기록
+ -
+ 모임에서 나눈 이야기를 AI가 대신 정리해 기록합니다. 대화하는 순간에는 온전히 대화에
+ 집중하고, 흐름과 맥락이 살아 있는 기록을 다시 꺼내볼 수 있습니다.
+
+ - 주요 쟁점과 관점 차이 자동 정리
+ - 반복해서 언급된 생각과 흐름 중심 요약
+ - 공동 기록 + 개인 회고 자연스럽게 연결
+
+
+
+ - 내 책장 : 책 한 권 안에 모든 독서 경험
+ -
+ 책 한 권 안에 모임 기록과 개인 기록을 모두 담습니다. 독서 경험이 분산되지 않고, 하나의
+ 흐름으로 이어집니다.
+
+ - 개인 메모, 발췌, 회고 기록 통합
+ - 독서모임을 통해 바뀐 생각의 흐름 확인
+ - “책을 읽었다”가 아닌 “책이 나를 어떻게 바꿨는지”가 남는 구조
+
+
+
+
+ 책 한 권을 둘러싼 시간과 생각, 하나의 흐름으로 남겨보세요
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/Landing/index.ts b/src/pages/Landing/index.ts
new file mode 100644
index 0000000..768f0a3
--- /dev/null
+++ b/src/pages/Landing/index.ts
@@ -0,0 +1 @@
+export { default as LandingPage } from './LandingPage'
diff --git a/src/pages/Meetings/MeetingCreatePage.tsx b/src/pages/Meetings/MeetingCreatePage.tsx
index 633350c..892ecea 100644
--- a/src/pages/Meetings/MeetingCreatePage.tsx
+++ b/src/pages/Meetings/MeetingCreatePage.tsx
@@ -1,26 +1,89 @@
-import { ChevronLeft, Search } from 'lucide-react'
-import { useState } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { Search } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { BookSearchModal } from '@/features/book'
+import { useGatheringDetail } from '@/features/gatherings'
import {
combineDateAndTime,
type CreateMeetingRequest,
PlaceSearchModal,
+ type UpdateMeetingRequest,
useCreateMeeting,
+ useMeetingDetail,
useMeetingForm,
+ useUpdateMeeting,
} from '@/features/meetings'
+import FormPageHeader from '@/shared/components/FormPageHeader'
+import { ROUTES } from '@/shared/constants'
import { Button, Card, Container, DatePicker, Input, Select } from '@/shared/ui'
import { useGlobalModalStore } from '@/store'
export default function MeetingCreatePage() {
const navigate = useNavigate()
- const { openError, openAlert } = useGlobalModalStore()
+ const openError = useGlobalModalStore((state) => state.openError)
+ const openAlert = useGlobalModalStore((state) => state.openAlert)
+ const openConfirm = useGlobalModalStore((state) => state.openConfirm)
const createMutation = useCreateMeeting()
+ const updateMutation = useUpdateMeeting()
const [isPlaceSearchOpen, setIsPlaceSearchOpen] = useState(false)
+ const [isBookSearchOpen, setIsBookSearchOpen] = useState(false)
- // 임시: 모임 정보 (실제로는 API에서 가져와야 함)
- const gatheringId = 1 // TODO: 실제 모임 ID로 교체
- const gatheringMaxCount = 16 // TODO: API에서 가져오기
+ const { gatheringId: gatheringIdParam, meetingId: meetingIdParam } = useParams<{
+ gatheringId: string
+ meetingId?: string
+ }>()
+ const parsedId = gatheringIdParam ? Number(gatheringIdParam) : NaN
+ const gatheringId = Number.isFinite(parsedId) ? parsedId : 0
+
+ // 수정 모드 판별
+ const parsedMeetingId = meetingIdParam ? Number(meetingIdParam) : NaN
+ const meetingId = Number.isFinite(parsedMeetingId) ? parsedMeetingId : null
+ const isEditMode = !!meetingId
+
+ // 수정 모드일 때 약속 상세 조회
+ const {
+ data: meetingDetail,
+ error: meetingError,
+ isLoading: isMeetingLoading,
+ } = useMeetingDetail(meetingId ?? 0)
+
+ // 모임 상세 조회
+ const {
+ data: gathering,
+ error: gatheringError,
+ isLoading: isGatheringLoading,
+ } = useGatheringDetail(gatheringId)
+
+ // 유효하지 않은 ID 처리
+ useEffect(() => {
+ if (gatheringId === 0) {
+ openError('오류', '잘못된 모임 ID입니다.', () => {
+ navigate(ROUTES.GATHERINGS, { replace: true })
+ })
+ }
+ }, [gatheringId, navigate, openError])
+
+ // API 에러 처리 (gatheringError 우선)
+ useEffect(() => {
+ if (gatheringId === 0) return
+ if (!gatheringError && !meetingError) return
+
+ const message = gatheringError
+ ? '모임 정보를 불러오는데 실패했습니다.'
+ : '약속 정보를 불러오는데 실패했습니다.'
+
+ openError('오류', message, () => {
+ // 브라우저 히스토리가 없으면 홈으로 이동
+ if (window.history.length > 1) {
+ navigate(-1)
+ } else {
+ navigate(ROUTES.HOME, { replace: true })
+ }
+ })
+ }, [gatheringId, gatheringError, meetingError, navigate, openError])
+
+ const gatheringMaxCount = gathering?.totalMembers || 1
// 폼 로직 및 유효성 검사 (커스텀 훅으로 분리)
const {
@@ -34,12 +97,15 @@ export default function MeetingCreatePage() {
formattedSchedule,
refs,
handlers,
- } = useMeetingForm({ gatheringMaxCount })
+ } = useMeetingForm({ gatheringMaxCount, initialData: meetingDetail })
const {
meetingName,
bookId,
bookName,
+ bookThumbnail,
+ bookAuthors,
+ bookPublisher,
maxParticipants,
startDate,
startTime,
@@ -51,7 +117,6 @@ export default function MeetingCreatePage() {
longitude,
} = formData
- //setBookId, setBookName
const {
setMeetingName,
setMaxParticipants,
@@ -63,34 +128,71 @@ export default function MeetingCreatePage() {
setLocationName,
setLatitude,
setLongitude,
+ setBook,
} = handlers
const { bookButtonRef, startDateRef, endDateRef, maxParticipantsRef } = refs
- // 제출 핸들러
- const handleSubmit = async () => {
- //유효성 검사
- const isValid = validateForm()
- if (!isValid) {
+ // 수정 처리
+ const handleUpdate = (id: number) => {
+ if (!startDate || !startTime || !endDate || !endTime || !bookName) {
return
}
- // validation 통과 후 필수 값 타입 가드
- if (!bookId || !bookName || !startDate || !startTime || !endDate || !endTime) {
- return
+ const updateData: UpdateMeetingRequest = {
+ meetingName: (meetingName?.trim() || bookName).slice(0, 24),
+ startDate: combineDateAndTime(startDate, startTime),
+ endDate: combineDateAndTime(endDate, endTime),
+ maxParticipants: maxParticipants ? Number(maxParticipants) : gatheringMaxCount,
+ location:
+ locationName && locationAddress && latitude !== null && longitude !== null
+ ? { name: locationName, address: locationAddress, latitude, longitude }
+ : null,
}
- const startDateTime = combineDateAndTime(startDate, startTime)
- const endDateTime = combineDateAndTime(endDate, endTime)
+ updateMutation.mutate(
+ { meetingId: id, data: updateData },
+ {
+ onSuccess: () => {
+ openAlert('약속 수정 완료', '약속이 성공적으로 수정되었습니다.', () => {
+ navigate(ROUTES.MEETING_DETAIL(gatheringId, id), { replace: true })
+ })
+ },
+ onError: (error) => {
+ openError('약속 수정 실패', error.userMessage)
+ },
+ }
+ )
+ }
- const trimmedMeetingName = meetingName?.trim() || null
+ // 생성 처리
+ const handleCreate = () => {
+ if (
+ !startDate ||
+ !startTime ||
+ !endDate ||
+ !endTime ||
+ !bookId ||
+ !bookName ||
+ !bookThumbnail ||
+ !bookAuthors ||
+ !bookPublisher
+ ) {
+ return
+ }
- const requestData: CreateMeetingRequest = {
+ const createData: CreateMeetingRequest = {
gatheringId,
- bookId,
- meetingName: trimmedMeetingName || bookName, // 약속명이 없으면 책 이름 사용
- meetingStartDate: startDateTime,
- meetingEndDate: endDateTime,
+ book: {
+ title: bookName,
+ authors: bookAuthors,
+ publisher: bookPublisher,
+ isbn: bookId,
+ thumbnail: bookThumbnail,
+ },
+ meetingName: (meetingName?.trim() || bookName).slice(0, 24),
+ meetingStartDate: combineDateAndTime(startDate, startTime),
+ meetingEndDate: combineDateAndTime(endDate, endTime),
maxParticipants: maxParticipants ? Number(maxParticipants) : gatheringMaxCount,
location:
locationName && locationAddress && latitude !== null && longitude !== null
@@ -98,10 +200,11 @@ export default function MeetingCreatePage() {
: null,
}
- createMutation.mutate(requestData, {
- onSuccess: async () => {
- await openAlert('약속 생성 완료', '약속이 성공적으로 생성되었습니다.')
- navigate('/') //이동경로 수정해야 함
+ createMutation.mutate(createData, {
+ onSuccess: () => {
+ openAlert('약속 생성 완료', '약속이 성공적으로 생성되었습니다.', () => {
+ navigate(ROUTES.GATHERING_DETAIL(gatheringId), { replace: true })
+ })
},
onError: (error) => {
openError('약속 생성 실패', error.userMessage)
@@ -109,199 +212,246 @@ export default function MeetingCreatePage() {
})
}
+ // 제출 핸들러: 유효성 검사 → confirm 모달 → 생성/수정 처리
+ const handleSubmit = async () => {
+ if (!validateForm()) return
+
+ const confirmed = await openConfirm(
+ isEditMode ? '약속 수정' : '약속 생성',
+ isEditMode ? '약속을 수정하시겠습니까?' : '약속을 생성하시겠습니까?'
+ )
+ if (!confirmed) return
+
+ if (isEditMode && meetingId) {
+ handleUpdate(meetingId)
+ } else {
+ handleCreate()
+ }
+ }
+
+ const isSubmitting = isEditMode ? updateMutation.isPending : createMutation.isPending
+ const isLoading = isGatheringLoading || isMeetingLoading
+
return (
<>
- {/* 공통컴포넌트로 교체 예정 */}
-
-
- 뒤로가기
-
-
-
약속 만들기
-
-
-
- {/* 공통컴포넌트로 교체 예정 */}
-
-
- 작성한 내용은 모임장의 승인 후 약속으로 등록돼요.
-
-
-
- 약속명
-
- setMeetingName(e.target.value)}
- />
-
-
-
-
-
- 도서
-
-
-
- {errors?.bookId && (
- {errors.bookId}
- )}
-
-
-
-
- 장소
-
- {locationAddress && (
-
- {locationName}
- {locationAddress}
+
+
+
+
+ {!isEditMode && (
+
+ 작성한 내용은 모임장의 승인 후 약속으로 등록돼요.
)}
-
-
- {errors?.location && (
-
{errors.location}
- )}
-
-
-
-
{
- setLocationName(place.name)
- setLocationAddress(place.address)
- setLatitude(place.latitude)
- setLongitude(place.longitude)
- }}
- />
-
-
-
- 날짜 및 시간
-
-
-
-
-
- 시작 일정
-
-
-
- 종료 일정
-
-
-
-
-
-
+
+
+ 약속명
+
+ setMeetingName(e.target.value)}
+ />
+
+
+
+
+
+ 도서
+
+
+
+ {bookThumbnail && bookName && bookAuthors && (
+
+
+

+
+
+
{bookName}
+
{bookAuthors}
+
+
+ )}
+ {!isEditMode && (
+
+ )}
- ~
- 종료 일정
-
-
-
+ {errors?.bookId && (
+
{errors.bookId}
+ )}
+
+
+
+
+ 장소
+
+ {locationAddress && locationName && (
+
+ {locationName}
+ {locationAddress}
+
+ )}
+
+
+ {errors?.location && (
+ {errors.location}
+ )}
+
+
+
+
+
+ 날짜 및 시간
+
+
+
+
+
+ 시작 일정
+
+
+
+ 종료 일정
+
+
+
+
+
+
+
+
~
+
종료 일정
+
+
+
+
+
-
-
- {/* 시작일정, 종료일정 선택 완료되면 노출*/}
- {formattedSchedule && (
-
- 선택된 일정
- {formattedSchedule}
-
+ {/* 시작일정, 종료일정 선택 완료되면 노출*/}
+ {formattedSchedule && (
+
+ 선택된 일정
+ {formattedSchedule}
+
+ )}
+ {errors?.schedule && (
+
{errors.schedule}
+ )}
+
+
+
+
+ 참가 인원
+
+ setMaxParticipants(e.target.value)}
+ min={1}
+ max={gatheringMaxCount}
+ />
+
+
+
+ {isPlaceSearchOpen && (
+
{
+ setLocationName(place.name)
+ setLocationAddress(place.address)
+ setLatitude(place.latitude)
+ setLongitude(place.longitude)
+ }}
+ />
)}
- {errors?.schedule && (
- {errors.schedule}
+ {!isEditMode && isBookSearchOpen && (
+ setBook(book)}
+ />
)}
-
-
-
-
- 참가 인원
-
- setMaxParticipants(e.target.value)}
- min={1}
- max={gatheringMaxCount}
- />
-
-
+
+
>
)
diff --git a/src/pages/Meetings/MeetingDetailPage.tsx b/src/pages/Meetings/MeetingDetailPage.tsx
index 3602d8a..c9c96b1 100644
--- a/src/pages/Meetings/MeetingDetailPage.tsx
+++ b/src/pages/Meetings/MeetingDetailPage.tsx
@@ -1,4 +1,4 @@
-import { ChevronLeft } from 'lucide-react'
+import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import {
@@ -7,62 +7,214 @@ import {
MeetingDetailInfo,
useMeetingDetail,
} from '@/features/meetings'
-import { TextButton } from '@/shared/ui'
+import { RetrospectiveCardButtons } from '@/features/retrospectives'
+import type {
+ GetConfirmedTopicsResponse,
+ GetProposedTopicsResponse,
+ TopicStatus,
+} from '@/features/topics'
+import {
+ ConfirmedTopicList,
+ ConfirmTopicModal,
+ ProposedTopicList,
+ TopicHeader,
+ TopicSkeleton,
+ useConfirmedTopics,
+ useProposedTopics,
+} from '@/features/topics'
+import SubPageHeader from '@/shared/components/SubPageHeader'
+import { ROUTES } from '@/shared/constants'
+import { showErrorToast } from '@/shared/lib/toast'
+import { Spinner, Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui'
export default function MeetingDetailPage() {
- const { meetingId } = useParams<{ gatheringId: string; meetingId: string }>()
+ const { gatheringId, meetingId } = useParams<{
+ gatheringId: string
+ meetingId: string
+ }>()
- const { data: meeting, isLoading, error } = useMeetingDetail(Number(meetingId))
+ const [activeTab, setActiveTab] = useState
('PROPOSED')
+ const [isConfirmTopicOpen, setIsConfirmTopicOpen] = useState(false)
- if (error) {
- return (
-
- )
- }
+ const {
+ data: meeting,
+ isLoading: meetingLoading,
+ error: meetingError,
+ } = 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) {
+ showErrorToast(proposedError.userMessage)
+ }
+ if (confirmedError) {
+ showErrorToast(confirmedError.userMessage)
+ }
+ if (meetingError) {
+ showErrorToast(meetingError.userMessage)
+ }
+ // navigate(ROUTES.GATHERING_DETAIL(gatheringId), { replace: true })
+ }, [proposedError, confirmedError, meetingError])
+
+ if (!gatheringId || !meetingId) return null
return (
<>
- {/* 공통컴포넌트로 분리 예정 */}
-
-
- {meeting?.gathering.gatheringName ?? ''}
-
-
- {/* 공통컴포넌트로 분리 예정 */}
-
-
- {/* 약속 로딩 적용 */}
-
- {isLoading ? (
-
- ) : meeting ? (
- <>
-
- {meeting.meetingName}
-
-
-
-
-
+
+
+
+ {/* 약속 로딩 적용 */}
+
+ {meetingLoading ? (
+
+
+
+ ) : meeting ? (
+ <>
+
+ {meeting.meetingName}
+
+
+
+
+
+ >
+ ) : null}
+
+ {/* 약속 로딩 적용 */}
+
+
+ {meeting?.progressStatus === 'POST' && (
+
- >
- ) : null}
-
- {/* 약속 로딩 적용 */}
+ )}
+
+
주제
+
+
setActiveTab(value as TopicStatus)}
+ className="gap-medium"
+ >
+
+
+ 제안
+
+
+ 확정된 주제
+
+
+
+ {isProposedLoading || !proposedTopicsInfiniteData ? (
+
+ ) : (
+
+
+
page.items
+ )}
+ confirmedTopic={meeting?.confirmedTopic ?? false}
+ hasNextPage={hasNextProposedPage}
+ isFetchingNextPage={isFetchingNextProposedPage}
+ onLoadMore={fetchNextProposedPage}
+ gatheringId={Number(gatheringId)}
+ meetingId={Number(meetingId)}
+ />
+
+ )}
+
-
- {/* 주제 로딩 적용 */}
-
주제
- {/* 주제 로딩 적용 */}
+
+ {isConfirmedLoading || !confirmedTopicsInfiniteData ? (
+
+ ) : (
+
+
+ page.items
+ )}
+ hasNextPage={hasNextConfirmedPage}
+ isFetchingNextPage={isFetchingNextConfirmedPage}
+ onLoadMore={fetchNextConfirmedPage}
+ />
+
+ )}
+
+
+
+ {isConfirmTopicOpen && (
+
+ )}
>
)
diff --git a/src/pages/Meetings/MeetingSettingPage.tsx b/src/pages/Meetings/MeetingSettingPage.tsx
index 1c3d1e3..3d311bc 100644
--- a/src/pages/Meetings/MeetingSettingPage.tsx
+++ b/src/pages/Meetings/MeetingSettingPage.tsx
@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react'
-import { useParams } from 'react-router-dom'
+import { useNavigate, useParams } from 'react-router-dom'
import {
MeetingApprovalList,
+ MeetingApprovalListSkeleton,
type MeetingStatus,
- useMeetingApprovalsCount,
+ useMeetingApprovals,
} from '@/features/meetings'
+import SubPageHeader from '@/shared/components/SubPageHeader'
+import { PAGE_SIZES } from '@/shared/constants'
import { Container } from '@/shared/ui/Container'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/Tabs'
import { useGlobalModalStore } from '@/store'
@@ -13,73 +16,122 @@ import { useGlobalModalStore } from '@/store'
type MeetingTab = Extract
export default function MeetingSettingPage() {
- const { id } = useParams<{ id: string }>()
- const gatheringId = Number(id)
+ const { gatheringId: gatheringIdParam } = useParams<{ gatheringId: string }>()
+ const parsedId = gatheringIdParam ? Number(gatheringIdParam) : NaN
+ const gatheringId = Number.isFinite(parsedId) ? parsedId : 0
+
+ const navigate = useNavigate()
const [activeTab, setActiveTab] = useState('PENDING')
+ const [pendingPage, setPendingPage] = useState(0)
+ const [confirmedPage, setConfirmedPage] = useState(0)
const { openError } = useGlobalModalStore()
- // 각 탭의 totalCount만 가져오기
+ // PENDING 리스트 조회
+ const {
+ data: pendingData,
+ isLoading: isPendingLoading,
+ isError: isPendingError,
+ error: pendingError,
+ } = useMeetingApprovals({
+ gatheringId,
+ status: 'PENDING',
+ page: pendingPage,
+ size: PAGE_SIZES.MEETING_APPROVALS,
+ })
+
+ // CONFIRMED 리스트 조회
const {
- pendingCount,
- confirmedCount,
- isPendingLoading,
- isConfirmedLoading,
- pendingError,
- confirmedError,
- } = useMeetingApprovalsCount(gatheringId)
+ data: confirmedData,
+ isLoading: isConfirmedLoading,
+ isError: isConfirmedError,
+ error: confirmedError,
+ } = useMeetingApprovals({
+ gatheringId,
+ status: 'CONFIRMED',
+ page: confirmedPage,
+ size: PAGE_SIZES.MEETING_APPROVALS,
+ })
- // 에러 발생 시 모달 표시
+ // 에러 발생 시 모달 표시 (동시 에러 발생 시 첫 번째 에러만 처리)
useEffect(() => {
- if (pendingError) {
- openError('오류', '확정 대기 약속 수를 불러오는 데 실패했습니다.')
- }
- if (confirmedError) {
- openError('오류', '확정 완료 약속 수를 불러오는 데 실패했습니다.')
+ if (isPendingError) {
+ openError('에러', pendingError.userMessage, () => {
+ navigate('/', { replace: true })
+ })
+ } else if (isConfirmedError) {
+ openError('에러', confirmedError.userMessage, () => {
+ navigate('/', { replace: true })
+ })
}
- }, [pendingError, confirmedError, openError])
+ }, [isPendingError, isConfirmedError, openError, pendingError, confirmedError, navigate])
- return (
-
- {/* 공통컴포넌트로 대체 예정 */}
- {/*
뒤로가기
-
약속 설정
*/}
+ const pendingCount = pendingData?.totalCount
+ const confirmedCount = confirmedData?.totalCount
-
- 약속 관리
-
- setActiveTab(value as MeetingTab)}
- className="gap-0"
- >
-
-
- 확정 대기
-
-
+
+
+
+
+ 약속 관리
+
+ setActiveTab(value as MeetingTab)}
+ className="gap-0"
>
- 확정 완료
-
-
-
-
-
-
-
-
-
+
+
+ 확정 대기
+
+
+ 확정 완료
+
+
+
+ {isPendingLoading || isPendingError ? (
+
+ ) : (
+ pendingData && (
+
+ )
+ )}
+
+
+ {isConfirmedLoading || isConfirmedError ? (
+
+ ) : (
+ confirmedData && (
+
+ )
+ )}
+
+
+
+
+
+
+ >
)
}
diff --git a/src/pages/PreOpinions/PreOpinionListPage.tsx b/src/pages/PreOpinions/PreOpinionListPage.tsx
new file mode 100644
index 0000000..2e54739
--- /dev/null
+++ b/src/pages/PreOpinions/PreOpinionListPage.tsx
@@ -0,0 +1,73 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+
+import {
+ PreOpinionDetail,
+ PreOpinionMemberList,
+ usePreOpinionAnswers,
+} from '@/features/preOpinions'
+import SubPageHeader from '@/shared/components/SubPageHeader'
+import { Spinner } from '@/shared/ui'
+import { useGlobalModalStore } from '@/store'
+
+export default function PreOpinionListPage() {
+ const { gatheringId, meetingId } = useParams<{ gatheringId: string; meetingId: string }>()
+ const navigate = useNavigate()
+ const openError = useGlobalModalStore((state) => state.openError)
+ const [selectedMemberId, setSelectedMemberId] = useState(null)
+
+ const { data, isLoading, error } = usePreOpinionAnswers({
+ gatheringId: Number(gatheringId),
+ meetingId: Number(meetingId),
+ })
+
+ useEffect(() => {
+ if (error) {
+ openError('조회 불가', error.userMessage, () => navigate(-1))
+ }
+ }, [error, openError, navigate])
+
+ // 선택된 멤버 ID: 유저가 선택한 값이 있으면 사용, 없으면 첫 번째 제출한 멤버를 기본값으로
+ const activeMemberId = useMemo(() => {
+ if (selectedMemberId !== null) return selectedMemberId
+ const firstSubmitted = data?.members.find((m) => m.isSubmitted)
+ return firstSubmitted?.memberInfo.userId ?? null
+ }, [selectedMemberId, data])
+
+ const selectedMember = data?.members.find((m) => m.memberInfo.userId === activeMemberId)
+
+ if (isLoading) return
+
+ return (
+ <>
+
+
+
사전 의견
+
+ {/* 왼쪽: 멤버 리스트 */}
+ {data && (
+
+ )}
+
+ {/* 오른쪽: 선택된 멤버의 의견 상세 */}
+ {selectedMember && data ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ )
+}
diff --git a/src/pages/PreOpinions/PreOpinionWritePage.tsx b/src/pages/PreOpinions/PreOpinionWritePage.tsx
new file mode 100644
index 0000000..d59d000
--- /dev/null
+++ b/src/pages/PreOpinions/PreOpinionWritePage.tsx
@@ -0,0 +1,184 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+
+import type { BookReviewFormValues } from '@/features/book/components/BookReviewForm'
+import BookReviewSection from '@/features/pre-opinion/components/BookReviewSection'
+import PreOpinionWriteHeader from '@/features/pre-opinion/components/PreOpinionWriteHeader'
+import TopicItem from '@/features/pre-opinion/components/TopicItem'
+import { usePreOpinion, useSavePreOpinion, useSubmitPreOpinion } from '@/features/pre-opinion/hooks'
+import SubPageHeader from '@/shared/components/SubPageHeader'
+import { Card, Spinner } from '@/shared/ui'
+import { useGlobalModalStore } from '@/store'
+
+export default function PreOpinionWritePage() {
+ const { gatheringId, meetingId } = useParams<{ gatheringId: string; meetingId: string }>()
+ const numGatheringId = Number(gatheringId)
+ const numMeetingId = Number(meetingId)
+
+ const { openError } = useGlobalModalStore()
+
+ const navigate = useNavigate()
+
+ const {
+ data: preOpinion,
+ isLoading,
+ isError,
+ error,
+ } = usePreOpinion({
+ gatheringId: numGatheringId,
+ meetingId: numMeetingId,
+ })
+
+ const reviewRef = useRef({ rating: 0, keywordIds: [], isValid: false })
+ const [formReviewValid, setFormReviewValid] = useState(null)
+ const isReviewValid = formReviewValid ?? !!preOpinion?.review
+ const answersRef = useRef