diff --git a/src/features/meetings/components/PlaceSearchModal.tsx b/src/features/meetings/components/PlaceSearchModal.tsx
index 59f70c7..dfd54f5 100644
--- a/src/features/meetings/components/PlaceSearchModal.tsx
+++ b/src/features/meetings/components/PlaceSearchModal.tsx
@@ -20,7 +20,7 @@ import {
import { useKakaoMap } from '../hooks/useKakaoMap'
import { useKakaoPlaceSearch } from '../hooks/useKakaoPlaceSearch'
import type { KakaoPlace } from '../kakaoMap.types'
-import { PlaceList } from './PlaceList'
+import PlaceList from './PlaceList'
declare global {
interface Window {
@@ -43,7 +43,11 @@ export type PlaceSearchModalProps = {
}) => void
}
-export function PlaceSearchModal({ open, onOpenChange, onSelectPlace }: PlaceSearchModalProps) {
+export default function PlaceSearchModal({
+ open,
+ onOpenChange,
+ onSelectPlace,
+}: PlaceSearchModalProps) {
// 지도 관리
const { mapElement, isInitialized, initializeMap, renderMarkers, setCenter, cleanup } =
useKakaoMap()
diff --git a/src/features/meetings/components/index.ts b/src/features/meetings/components/index.ts
index 2ef7c1b..272ec4f 100644
--- a/src/features/meetings/components/index.ts
+++ b/src/features/meetings/components/index.ts
@@ -1,4 +1,8 @@
+export { default as MapModal } from './MapModal'
export { default as MeetingApprovalItem } from './MeetingApprovalItem'
export { default as MeetingApprovalList } from './MeetingApprovalList'
-export * from './PlaceList'
-export * from './PlaceSearchModal'
+export { default as MeetingDetailButton } from './MeetingDetailButton'
+export { MeetingDetailHeader } from './MeetingDetailHeader'
+export { MeetingDetailInfo } from './MeetingDetailInfo'
+export { default as PlaceList } from './PlaceList'
+export { default as PlaceSearchModal } from './PlaceSearchModal'
diff --git a/src/features/meetings/hooks/index.ts b/src/features/meetings/hooks/index.ts
index 4106760..68684c0 100644
--- a/src/features/meetings/hooks/index.ts
+++ b/src/features/meetings/hooks/index.ts
@@ -1,10 +1,13 @@
export * from './meetingQueryKeys'
+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 './useRejectMeeting'
diff --git a/src/features/meetings/hooks/meetingQueryKeys.ts b/src/features/meetings/hooks/meetingQueryKeys.ts
index 4daf02a..41dfedc 100644
--- a/src/features/meetings/hooks/meetingQueryKeys.ts
+++ b/src/features/meetings/hooks/meetingQueryKeys.ts
@@ -22,4 +22,8 @@ export const meetingQueryKeys = {
approvalCounts: () => [...meetingQueryKeys.approvals(), 'count'] as const,
approvalCount: (gatheringId: number, status: GetMeetingApprovalsParams['status']) =>
[...meetingQueryKeys.approvalCounts(), gatheringId, status] as const,
+
+ // 약속 상세 관련
+ details: () => [...meetingQueryKeys.all, 'detail'] as const,
+ detail: (meetingId: number) => [...meetingQueryKeys.details(), meetingId] as const,
}
diff --git a/src/features/meetings/hooks/useCancelJoinMeeting.ts b/src/features/meetings/hooks/useCancelJoinMeeting.ts
new file mode 100644
index 0000000..3d80a28
--- /dev/null
+++ b/src/features/meetings/hooks/useCancelJoinMeeting.ts
@@ -0,0 +1,35 @@
+/**
+ * @file useCancelJoinMeeting.ts
+ * @description 약속 참가취소 mutation 훅
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { ApiError } from '@/api/errors'
+import type { ApiResponse } from '@/api/types'
+import { cancelJoinMeeting } from '@/features/meetings'
+
+import { meetingQueryKeys } from './meetingQueryKeys'
+
+/**
+ * 약속 참가취소 mutation 훅
+ *
+ * @description
+ * 약속 참가를 취소하고 관련 쿼리 캐시를 무효화합니다.
+ * - 약속 상세 캐시 무효화
+ *
+ */
+export const useCancelJoinMeeting = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation
, ApiError, number>({
+ mutationFn: (meetingId: number) => cancelJoinMeeting(meetingId),
+ onSuccess: (data, variables) => {
+ void data // 사용하지 않는 파라미터
+ // 약속 상세 캐시 무효화
+ queryClient.invalidateQueries({
+ queryKey: meetingQueryKeys.detail(variables),
+ })
+ },
+ })
+}
diff --git a/src/features/meetings/hooks/useJoinMeeting.ts b/src/features/meetings/hooks/useJoinMeeting.ts
new file mode 100644
index 0000000..34aa980
--- /dev/null
+++ b/src/features/meetings/hooks/useJoinMeeting.ts
@@ -0,0 +1,35 @@
+/**
+ * @file useJoinMeeting.ts
+ * @description 약속 참가신청 mutation 훅
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { ApiError } from '@/api/errors'
+import type { ApiResponse } from '@/api/types'
+import { joinMeeting } from '@/features/meetings'
+
+import { meetingQueryKeys } from './meetingQueryKeys'
+
+/**
+ * 약속 참가신청 mutation 훅
+ *
+ * @description
+ * 약속에 참가신청하고 관련 쿼리 캐시를 무효화합니다.
+ * - 약속 상세 캐시 무효화
+ *
+ */
+export const useJoinMeeting = () => {
+ const queryClient = useQueryClient()
+
+ return useMutation, ApiError, number>({
+ mutationFn: (meetingId: number) => joinMeeting(meetingId),
+ onSuccess: (data, variables) => {
+ void data // 사용하지 않는 파라미터
+ // 약속 상세 캐시 무효화
+ queryClient.invalidateQueries({
+ queryKey: meetingQueryKeys.detail(variables),
+ })
+ },
+ })
+}
diff --git a/src/features/meetings/hooks/useMeetingDetail.ts b/src/features/meetings/hooks/useMeetingDetail.ts
new file mode 100644
index 0000000..02e5405
--- /dev/null
+++ b/src/features/meetings/hooks/useMeetingDetail.ts
@@ -0,0 +1,43 @@
+/**
+ * @file useMeetingDetail.ts
+ * @description 약속 상세 조회 훅
+ */
+
+import { useQuery } from '@tanstack/react-query'
+
+import type { ApiError } from '@/api'
+import { getMeetingDetail, type GetMeetingDetailResponse } from '@/features/meetings'
+
+import { meetingQueryKeys } from './meetingQueryKeys'
+
+/**
+ * 약속 상세 조회 훅
+ *
+ * @description
+ * TanStack Query를 사용하여 약속의 상세 정보를 조회합니다.
+ * 모임 정보, 책 정보, 일정, 장소, 참가자 목록 등을 포함합니다.
+ *
+ * @param meetingId - 약속 ID
+ *
+ * @returns TanStack Query 결과 객체
+ *
+ */
+export const useMeetingDetail = (meetingId: number) => {
+ const isValidMeetingId = !Number.isNaN(meetingId) && meetingId > 0
+
+ // 유효하지 않은 meetingId는 detail 키 대신 details 키 사용
+ // NaN이 null로 직렬화되어 캐시 충돌하는 것을 방지
+ const queryKey = isValidMeetingId
+ ? meetingQueryKeys.detail(meetingId)
+ : meetingQueryKeys.details()
+
+ return useQuery({
+ // eslint-disable-next-line @tanstack/query/exhaustive-deps
+ queryKey,
+ queryFn: () => getMeetingDetail(meetingId),
+ // meetingId가 유효할 때만 쿼리 실행
+ enabled: isValidMeetingId,
+ // 캐시 데이터 10분간 유지 (전역 설정 staleTime: 5분 사용)
+ gcTime: 10 * 60 * 1000,
+ })
+}
diff --git a/src/features/meetings/index.ts b/src/features/meetings/index.ts
index a1214e4..e0d6b0e 100644
--- a/src/features/meetings/index.ts
+++ b/src/features/meetings/index.ts
@@ -11,12 +11,21 @@ export * from './lib'
export * from './meetings.api'
// Types
+export type {
+ KakaoPlace,
+ KakaoSearchMeta,
+ KakaoSearchParams,
+ KakaoSearchResponse,
+} from './kakaoMap.types'
export type {
ConfirmMeetingResponse,
CreateMeetingRequest,
CreateMeetingResponse,
GetMeetingApprovalsParams,
+ GetMeetingDetailResponse,
MeetingApprovalItem as MeetingApprovalItemType,
+ MeetingDetailActionStateType,
+ MeetingLocation,
MeetingStatus,
RejectMeetingResponse,
} from './meetings.types'
diff --git a/src/features/meetings/lib/dateTimeFormatters.ts b/src/features/meetings/lib/dateTimeFormatters.ts
new file mode 100644
index 0000000..1b3af25
--- /dev/null
+++ b/src/features/meetings/lib/dateTimeFormatters.ts
@@ -0,0 +1,54 @@
+/**
+ * @file dateTimeFormatters.ts
+ * @description 날짜/시간 포맷팅 유틸리티 함수
+ */
+
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+
+/**
+ * ISO 8601 형식 문자열을 한글 날짜/시간 형식으로 변환
+ *
+ * @param dateTimeString - ISO 8601 형식의 날짜/시간 문자열
+ * @returns 'yy.MM.dd(요일) HH:mm' 형식의 문자열
+ *
+ * @example
+ * formatDateTime('2025-02-01T14:30:00')
+ * // → '25.02.01(토) 14:30'
+ */
+export function formatDateTime(dateTimeString: string): string {
+ const date = new Date(dateTimeString)
+ return format(date, 'yy.MM.dd(eee) HH:mm', { locale: ko })
+}
+
+/**
+ * 선택된 일정을 한글 범위 형식으로 포맷팅
+ *
+ * @description
+ * 시작/종료 날짜와 시간을 'YYYY.MM.DD(요일) HH:mm ~ YYYY.MM.DD(요일) HH:mm' 형식으로 변환합니다.
+ *
+ * @param startDate - 시작 날짜
+ * @param startTime - 시작 시간 (HH:mm 형식)
+ * @param endDate - 종료 날짜
+ * @param endTime - 종료 시간 (HH:mm 형식)
+ * @returns 포맷팅된 일정 범위 문자열 또는 null
+ *
+ * @example
+ * formatScheduleRange(new Date('2025-02-01'), '14:00', new Date('2025-02-01'), '16:00')
+ * // → '2025.02.01(토) 14:00 ~ 2025.02.01(토) 16:00'
+ */
+export function formatScheduleRange(
+ startDate: Date | null,
+ startTime: string | null,
+ endDate: Date | null,
+ endTime: string | null
+): string | null {
+ if (!startDate || !startTime || !endDate || !endTime) {
+ return null
+ }
+
+ const startDateStr = format(startDate, 'yyyy.MM.dd(E)', { locale: ko })
+ const endDateStr = format(endDate, 'yyyy.MM.dd(E)', { locale: ko })
+
+ return `${startDateStr} ${startTime} ~ ${endDateStr} ${endTime}`
+}
diff --git a/src/features/meetings/lib/timeUtils.ts b/src/features/meetings/lib/dateTimeUtils.ts
similarity index 64%
rename from src/features/meetings/lib/timeUtils.ts
rename to src/features/meetings/lib/dateTimeUtils.ts
index 2a919fe..67d3da0 100644
--- a/src/features/meetings/lib/timeUtils.ts
+++ b/src/features/meetings/lib/dateTimeUtils.ts
@@ -1,10 +1,9 @@
/**
- * @file timeUtils.ts
- * @description 약속 생성 시 시간 선택 유틸리티 함수
+ * @file dateTimeUtils.ts
+ * @description 날짜/시간 조작 및 생성 유틸리티 함수
*/
import { format } from 'date-fns'
-import { ko } from 'date-fns/locale'
/**
* 시간 선택 옵션 타입
@@ -33,7 +32,7 @@ export type TimeOption = {
* // { label: '23:30', value: '23:30' }
* // ]
*/
-export const generateTimeOptions = (): TimeOption[] => {
+export function generateTimeOptions(): TimeOption[] {
const options: TimeOption[] = []
for (let hour = 0; hour < 24; hour++) {
@@ -58,11 +57,15 @@ export const generateTimeOptions = (): TimeOption[] => {
* @description
* 날짜(Date)와 시간(HH:mm)을 결합하여 ISO 8601 형식 문자열로 변환합니다.
*
+ * @param date - 날짜 객체
+ * @param time - 시간 문자열 (HH:mm 형식)
+ * @returns ISO 8601 형식 문자열 (YYYY-MM-DDTHH:mm:00)
+ *
* @example
* const dateTime = combineDateAndTime(new Date('2025-02-01'), '14:00')
* // → '2025-02-01T14:00:00'
*/
-export const combineDateAndTime = (date: Date, time: string): string => {
+export function combineDateAndTime(date: Date, time: string): string {
return `${format(date, 'yyyy-MM-dd')}T${time}:00`
}
@@ -72,11 +75,14 @@ export const combineDateAndTime = (date: Date, time: string): string => {
* @description
* ISO 8601 형식 문자열에서 시간(HH:mm)만 추출합니다.
*
+ * @param dateTime - ISO 8601 형식 문자열
+ * @returns 시간 문자열 (HH:mm 형식) 또는 빈 문자열
+ *
* @example
* const time = extractTime('2025-02-01T14:00:00')
* // → '14:00'
*/
-export const extractTime = (dateTime: string): string => {
+export function extractTime(dateTime: string): string {
if (!dateTime) return ''
const timePart = dateTime.split('T')[1]
@@ -85,25 +91,3 @@ export const extractTime = (dateTime: string): string => {
// HH:mm:ss에서 HH:mm만 추출
return timePart.substring(0, 5)
}
-
-/**
- * 선택된 일정을 한글 형식으로 포맷팅
- *
- * @description
- * 시작/종료 날짜와 시간을 'YYYY.MM.DD(요일) HH:mm ~ YYYY.MM.DD(요일) HH:mm' 형식으로 변환합니다.
- */
-export const formatScheduleRange = (
- startDate: Date | null,
- startTime: string | null,
- endDate: Date | null,
- endTime: string | null
-): string | null => {
- if (!startDate || !startTime || !endDate || !endTime) {
- return null
- }
-
- const startDateStr = format(startDate, 'yyyy.MM.dd(E)', { locale: ko })
- const endDateStr = format(endDate, 'yyyy.MM.dd(E)', { locale: ko })
-
- return `${startDateStr} ${startTime} ~ ${endDateStr} ${endTime}`
-}
diff --git a/src/features/meetings/lib/formatDateTime.ts b/src/features/meetings/lib/formatDateTime.ts
deleted file mode 100644
index 401da3b..0000000
--- a/src/features/meetings/lib/formatDateTime.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { format } from 'date-fns'
-import { ko } from 'date-fns/locale'
-
-export default function formatDateTime(dateTimeString: string): string {
- const date = new Date(dateTimeString)
- return format(date, 'yy.MM.dd(eee) HH:mm', { locale: ko })
-}
diff --git a/src/features/meetings/lib/index.ts b/src/features/meetings/lib/index.ts
index b76fb19..be7c48e 100644
--- a/src/features/meetings/lib/index.ts
+++ b/src/features/meetings/lib/index.ts
@@ -1,9 +1,13 @@
-export { default as formatDateTime } from './formatDateTime'
+// 날짜/시간 포맷팅 함수
+export { formatDateTime, formatScheduleRange } from './dateTimeFormatters'
+
+// 날짜/시간 조작 유틸리티
export {
combineDateAndTime,
extractTime,
- formatScheduleRange,
generateTimeOptions,
type TimeOption,
-} from './timeUtils'
-export * from './validation'
+} from './dateTimeUtils'
+
+// 약속 유효성 검사
+export { isPastDate, isStartBeforeEnd, isValidParticipants } from './meetingValidation'
diff --git a/src/features/meetings/lib/validation.ts b/src/features/meetings/lib/meetingValidation.ts
similarity index 100%
rename from src/features/meetings/lib/validation.ts
rename to src/features/meetings/lib/meetingValidation.ts
diff --git a/src/features/meetings/meetings.api.ts b/src/features/meetings/meetings.api.ts
index 1252067..a1989f4 100644
--- a/src/features/meetings/meetings.api.ts
+++ b/src/features/meetings/meetings.api.ts
@@ -5,12 +5,14 @@
import { api, apiClient } from '@/api/client'
import type { ApiResponse, PaginatedResponse } from '@/api/types'
-import { getMockMeetingApprovals } from '@/features/meetings/meetings.mock'
+import { MEETINGS_ENDPOINTS } from '@/features/meetings/meetings.endpoints'
+import { getMockMeetingApprovals, getMockMeetingDetail } from '@/features/meetings/meetings.mock'
import type {
ConfirmMeetingResponse,
CreateMeetingRequest,
CreateMeetingResponse,
GetMeetingApprovalsParams,
+ GetMeetingDetailResponse,
MeetingApprovalItem,
RejectMeetingResponse,
} from '@/features/meetings/meetings.types'
@@ -54,7 +56,7 @@ export const getMeetingApprovals = async (
// 실제 API 호출 (로그인 완료 후 사용)
return api.get>(
- `/api/gatherings/${gatheringId}/meetings/approvals`,
+ MEETINGS_ENDPOINTS.APPROVALS(gatheringId),
{
params: {
status,
@@ -76,13 +78,10 @@ export const getMeetingApprovals = async (
*
* @returns 거부된 약속 정보와 서버 메시지
*
- * @throws
- * - M009: 약속 상태를 변경할 수 없습니다.
- * - M001: 약속을 찾을 수 없습니다.
*/
export const rejectMeeting = async (meetingId: number) => {
const response = await apiClient.post>(
- `/api/meetings/${meetingId}/reject`
+ MEETINGS_ENDPOINTS.REJECT(meetingId)
)
return response.data
}
@@ -97,13 +96,10 @@ export const rejectMeeting = async (meetingId: number) => {
*
* @returns 승인된 약속 정보와 서버 메시지
*
- * @throws
- * - M009: 약속 상태를 변경할 수 없습니다.
- * - M001: 약속을 찾을 수 없습니다.
*/
export const confirmMeeting = async (meetingId: number) => {
const response = await apiClient.post>(
- `/api/meetings/${meetingId}/confirm`
+ MEETINGS_ENDPOINTS.CONFIRM(meetingId)
)
return response.data
}
@@ -120,13 +116,64 @@ export const confirmMeeting = async (meetingId: number) => {
*
* @returns 삭제 성공 메시지
*
- * @throws
- * - M015: 약속 시작 24시간 이내에는 삭제할 수 없습니다.
- * - ACCESS_DENIED: 접근 권한이 없습니다.
- * - M001: 약속을 찾을 수 없습니다.
*/
export const deleteMeeting = async (meetingId: number) => {
- const response = await apiClient.delete>(`/api/meetings/${meetingId}`)
+ const response = await apiClient.delete>(MEETINGS_ENDPOINTS.DELETE(meetingId))
+ return response.data
+}
+
+/**
+ * 약속 상세 조회
+ *
+ * @description
+ * 약속의 상세 정보를 조회합니다.
+ * 모임 정보, 책 정보, 일정, 장소, 참가자 목록 등을 포함합니다.
+ *
+ * @param meetingId - 약속 ID
+ *
+ * @returns 약속 상세 정보
+ *
+ */
+export const getMeetingDetail = async (meetingId: number): Promise => {
+ // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용
+ // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거
+ if (USE_MOCK_DATA) {
+ // 실제 API 호출을 시뮬레이션하기 위한 지연
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return getMockMeetingDetail(meetingId)
+ }
+
+ // 실제 API 호출 (로그인 완료 후 사용)
+ return api.get(MEETINGS_ENDPOINTS.DETAIL(meetingId))
+}
+
+/**
+ * 약속 참가신청
+ *
+ * @description
+ * 약속에 참가신청합니다.
+ *
+ * @param meetingId - 약속 ID
+ *
+ */
+export const joinMeeting = async (meetingId: number) => {
+ const response = await apiClient.post>(MEETINGS_ENDPOINTS.JOIN(meetingId))
+ return response.data
+}
+
+/**
+ * 약속 참가취소
+ *
+ * @description
+ * 약속 참가를 취소합니다.
+ *
+ * @param meetingId - 약속 ID
+ *
+ */
+export const cancelJoinMeeting = async (meetingId: number) => {
+ const response = await apiClient.delete>(
+ MEETINGS_ENDPOINTS.CANCEL_JOIN(meetingId)
+ )
return response.data
}
diff --git a/src/features/meetings/meetings.endpoints.ts b/src/features/meetings/meetings.endpoints.ts
new file mode 100644
index 0000000..e5fc740
--- /dev/null
+++ b/src/features/meetings/meetings.endpoints.ts
@@ -0,0 +1,24 @@
+import { API_PATHS } from '@/api'
+
+export const MEETINGS_ENDPOINTS = {
+ // 약속 승인 리스트 조회 (GET /api/gatherings/{gatheringId}/meetings/approvals)
+ APPROVALS: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/approvals`,
+
+ // 약속 상세 조회 (GET /api/meetings/{meetingId})
+ DETAIL: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}`,
+
+ // 약속 거부 (POST /api/meetings/{meetingId}/reject)
+ REJECT: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}/reject`,
+
+ // 약속 승인 (POST /api/meetings/{meetingId}/confirm)
+ CONFIRM: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}/confirm`,
+
+ // 약속 삭제 (DELETE /api/meetings/{meetingId})
+ DELETE: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}`,
+
+ // 약속 참가신청 (POST /api/meetings/{meetingId}/join)
+ JOIN: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}/join`,
+
+ // 약속 참가취소 (DELETE /api/meetings/{meetingId}/join)
+ CANCEL_JOIN: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}/join`,
+} as const
diff --git a/src/features/meetings/meetings.mock.ts b/src/features/meetings/meetings.mock.ts
index e14a6eb..60863c1 100644
--- a/src/features/meetings/meetings.mock.ts
+++ b/src/features/meetings/meetings.mock.ts
@@ -4,7 +4,10 @@
*/
import type { PaginatedResponse } from '@/api/types'
-import type { MeetingApprovalItem } from '@/features/meetings/meetings.types'
+import type {
+ GetMeetingDetailResponse,
+ MeetingApprovalItem,
+} from '@/features/meetings/meetings.types'
/**
* 약속 승인 리스트 목데이터 (확정 대기)
@@ -172,7 +175,7 @@ const mockConfirmedMeetings: MeetingApprovalItem[] = [
]
/**
- * 목데이터 반환 함수
+ * 약속 승인 리스트 목데이터 반환 함수
*
* @description
* 실제 API 호출을 시뮬레이션하여 목데이터를 페이지네이션 형태로 반환합니다.
@@ -197,3 +200,173 @@ export const getMockMeetingApprovals = (
totalPages: Math.ceil(mockData.length / size),
}
}
+
+/**
+ * 약속 상세 목데이터
+ */
+const mockMeetingDetails: Record = {
+ 1: {
+ meetingId: 1,
+ meetingName: '1차 독서모임',
+ meetingStatus: 'PENDING',
+ progressStatus: 'POST',
+ gathering: {
+ gatheringId: 101,
+ gatheringName: '클린 코드 스터디',
+ },
+ book: {
+ bookId: 1001,
+ bookName: '클린 코드',
+ thumbnail: 'https://picsum.photos/seed/cleancode/200/300',
+ },
+ schedule: {
+ startDateTime: '2026-02-01T14:00:00',
+ endDateTime: '2026-02-01T16:00:00',
+ displayDate: '2월 1일 (토) 오후 2:00 ~ 2월 1일 (토) 오후 4:00',
+ },
+ location: {
+ name: '강남 스터디 카페',
+ address: '서울특별시 강남구 테헤란로 123',
+ latitude: 37.5012,
+ longitude: 127.0396,
+ },
+ participants: {
+ currentCount: 3,
+ maxCount: 8,
+ members: [
+ {
+ userId: 1,
+ nickname: '독서왕김민수',
+ profileImageUrl: 'https://picsum.photos/seed/user1/100/100',
+ role: 'LEADER',
+ },
+ {
+ userId: 2,
+ nickname: '코드리뷰어',
+ profileImageUrl: 'https://picsum.photos/seed/user2/100/100',
+ role: 'MEMBER',
+ },
+ {
+ userId: 3,
+ nickname: '객체지향전문가',
+ profileImageUrl: 'https://picsum.photos/seed/user3/100/100',
+ role: 'MEMBER',
+ },
+ ],
+ },
+ actionState: {
+ type: 'DONE',
+ buttonLabel: '약속이 끝났어요',
+ enabled: false,
+ },
+ confirmedTopicExpand: true,
+ confirmedTopicDate: '2026-01-20T14:00:00',
+ },
+ 11: {
+ meetingId: 11,
+ progressStatus: 'PRE',
+ meetingName: '킥오프 모임',
+ meetingStatus: 'CONFIRMED',
+ confirmedTopicExpand: false,
+ confirmedTopicDate: null,
+ gathering: {
+ gatheringId: 102,
+ gatheringName: '실용주의 프로그래머 독서모임',
+ },
+ book: {
+ bookId: 1002,
+ bookName: '실용주의 프로그래머',
+ thumbnail: 'https://picsum.photos/seed/pragmatic/200/300',
+ },
+ schedule: {
+ startDateTime: '2026-02-11T14:00:00',
+ endDateTime: '2026-02-11T16:00:00',
+ displayDate: '2월 11일 (수) 오후 2:00 ~ 2월 11일 (수) 오후 4:00',
+ },
+ location: {
+ name: '홍대 북카페',
+ address: '서울특별시 마포구 와우산로 94',
+ latitude: 37.5563,
+ longitude: 126.9236,
+ },
+ participants: {
+ currentCount: 8,
+ maxCount: 8,
+ members: [
+ {
+ userId: 4,
+ nickname: '프로그래머박지성',
+ profileImageUrl: 'https://picsum.photos/seed/user4/100/100',
+ role: 'LEADER',
+ },
+ {
+ userId: 5,
+ nickname: '성장하는개발자',
+ profileImageUrl: 'https://picsum.photos/seed/user5/100/100',
+ role: 'LEADER',
+ },
+ {
+ userId: 6,
+ nickname: '타입스크립트러버',
+ profileImageUrl: 'https://picsum.photos/seed/user6/100/100',
+ role: 'MEMBER',
+ },
+ {
+ userId: 7,
+ nickname: '아키텍트김철수',
+ profileImageUrl: 'https://picsum.photos/seed/user7/100/100',
+ role: 'MEMBER',
+ },
+ {
+ userId: 8,
+ nickname: '장인정신실천가',
+ profileImageUrl: 'https://picsum.photos/seed/user8/100/100',
+ role: 'MEMBER',
+ },
+ {
+ userId: 9,
+ nickname: '리팩터링마스터',
+ profileImageUrl: 'https://picsum.photos/seed/user9/100/100',
+ role: 'MEMBER',
+ },
+ {
+ userId: 10,
+ nickname: '알고리즘천재',
+ profileImageUrl: 'https://picsum.photos/seed/user10/100/100',
+ role: 'MEMBER',
+ },
+ {
+ userId: 11,
+ nickname: '패턴연구가',
+ profileImageUrl: 'https://picsum.photos/seed/user11/100/100',
+ role: 'MEMBER',
+ },
+ ],
+ },
+ actionState: {
+ type: 'RECRUITMENT_CLOSED',
+ buttonLabel: '모집 마감',
+ enabled: false,
+ },
+ },
+}
+
+/**
+ * 약속 상세 목데이터 반환 함수
+ *
+ * @description
+ * 실제 API 호출을 시뮬레이션하여 약속 상세 목데이터를 반환합니다.
+ *
+ * @param meetingId - 약속 ID
+ * @returns 약속 상세 정보
+ * @throws meetingId에 해당하는 데이터가 없으면 에러
+ */
+export const getMockMeetingDetail = (meetingId: number): GetMeetingDetailResponse => {
+ const detail = mockMeetingDetails[meetingId]
+
+ if (!detail) {
+ throw new Error(`Meeting with id ${meetingId} not found`)
+ }
+
+ return detail
+}
diff --git a/src/features/meetings/meetings.types.ts b/src/features/meetings/meetings.types.ts
index 2a99f6b..a2333bf 100644
--- a/src/features/meetings/meetings.types.ts
+++ b/src/features/meetings/meetings.types.ts
@@ -69,14 +69,18 @@ export type ConfirmMeetingResponse = {
}
/**
- * 약속 생성 요청 타입
+ * 약속 장소 타입
*/
-type MeetingLocation = {
+export type MeetingLocation = {
name: string
address: string
latitude: number
longitude: number
}
+
+/**
+ * 약속 생성 요청 타입
+ */
export type CreateMeetingRequest = {
/** 모임 ID */
gatheringId: number
@@ -120,7 +124,74 @@ export type CreateMeetingResponse = {
time: string
startDateTime: string
endDateTime: string
- } | null
+ }
+ /** 장소 */
+ location: MeetingLocation | null
+ /** 참가자 정보 */
+ participants: {
+ currentCount: number
+ maxCount: number
+ members: Array<{
+ userId: number
+ nickname: string
+ profileImageUrl: string
+ }>
+ }
+}
+
+/**
+ * 약속 일정 타입
+ */
+export type MeetingSchedule = {
+ startDateTime: string
+ endDateTime: string
+ displayDate: string
+}
+
+/**
+ * 약속 상세 조회 액션 상태 타입
+ */
+export type MeetingDetailActionStateType =
+ | 'CAN_EDIT'
+ | 'EDIT_TIME_EXPIRED'
+ | 'CAN_JOIN'
+ | 'CAN_CANCEL'
+ | 'RECRUITMENT_CLOSED'
+ | 'DONE'
+ | 'REJECTED'
+ | 'CANCEL_TIME_EXPIRED'
+ | 'JOIN_TIME_EXPIRED'
+
+export type MeetingProgressStatus = 'PRE' | 'ONGOING' | 'POST'
+/**
+ * 약속 상세 조회 응답 타입
+ */
+export type GetMeetingDetailResponse = {
+ /** 약속 ID */
+ meetingId: number
+ /** 약속 이름 */
+ meetingName: string
+ /** 약속 승인 상태 */
+ meetingStatus: MeetingStatus
+ /** 약속 진행 상태 */
+ progressStatus: MeetingProgressStatus
+ /** 주제 확정 여부 */
+ confirmedTopicExpand: boolean
+ /** 주제 확정 일시 */
+ confirmedTopicDate: string | null
+ /** 모임 정보 */
+ gathering: {
+ gatheringId: number
+ gatheringName: string
+ }
+ /** 책 정보 */
+ book: {
+ bookId: number
+ bookName: string
+ thumbnail: string
+ }
+ /** 일정 정보 */
+ schedule: MeetingSchedule
/** 장소 */
location: MeetingLocation | null
/** 참가자 정보 */
@@ -131,6 +202,13 @@ export type CreateMeetingResponse = {
userId: number
nickname: string
profileImageUrl: string
+ role: 'LEADER' | 'MEMBER'
}>
}
+ /** 버튼 상태 */
+ actionState: {
+ type: MeetingDetailActionStateType
+ buttonLabel: string
+ enabled: boolean
+ }
}
diff --git a/src/pages/Meetings/MeetingDetailPage.tsx b/src/pages/Meetings/MeetingDetailPage.tsx
new file mode 100644
index 0000000..3602d8a
--- /dev/null
+++ b/src/pages/Meetings/MeetingDetailPage.tsx
@@ -0,0 +1,69 @@
+import { ChevronLeft } from 'lucide-react'
+import { useParams } from 'react-router-dom'
+
+import {
+ MeetingDetailButton,
+ MeetingDetailHeader,
+ MeetingDetailInfo,
+ useMeetingDetail,
+} from '@/features/meetings'
+import { TextButton } from '@/shared/ui'
+
+export default function MeetingDetailPage() {
+ const { meetingId } = useParams<{ gatheringId: string; meetingId: string }>()
+
+ const { data: meeting, isLoading, error } = useMeetingDetail(Number(meetingId))
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+ {/* 공통컴포넌트로 분리 예정 */}
+
+
+ {meeting?.gathering.gatheringName ?? ''}
+
+
+ {/* 공통컴포넌트로 분리 예정 */}
+
+
+ {/* 약속 로딩 적용 */}
+
+ {isLoading ? (
+
+ ) : meeting ? (
+ <>
+
+ {meeting.meetingName}
+
+
+
+
+
+ >
+ ) : null}
+
+ {/* 약속 로딩 적용 */}
+
+
+ {/* 주제 로딩 적용 */}
+
주제
+ {/* 주제 로딩 적용 */}
+
+
+ >
+ )
+}
diff --git a/src/pages/Meetings/MeetingListPage.tsx b/src/pages/Meetings/MeetingListPage.tsx
deleted file mode 100644
index 321f127..0000000
--- a/src/pages/Meetings/MeetingListPage.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function MeetingListPage() {
- return Meeting List Page
-}
diff --git a/src/pages/Meetings/index.ts b/src/pages/Meetings/index.ts
index 905ef2d..35db1de 100644
--- a/src/pages/Meetings/index.ts
+++ b/src/pages/Meetings/index.ts
@@ -1,3 +1,3 @@
export { default as MeetingCreatePage } from './MeetingCreatePage'
-export { default as MeetingListPage } from './MeetingListPage'
+export { default as MeetingDetailPage } from './MeetingDetailPage'
export { default as MeetingSettingPage } from './MeetingSettingPage'
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 193ad3a..ec9baec 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -11,7 +11,7 @@ import {
InvitePage,
LoginPage,
MeetingCreatePage,
- MeetingListPage,
+ MeetingDetailPage,
MeetingSettingPage,
OnboardingPage,
RecordListPage,
@@ -92,15 +92,15 @@ export const router = createBrowserRouter([
element: ,
},
{
- path: ROUTES.MEETINGS,
- element: ,
+ path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId`,
+ element: ,
},
{
- path: `${ROUTES.GATHERINGS}/:id${ROUTES.MEETING_SETTING}`,
+ path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/setting`,
element: ,
},
{
- path: `${ROUTES.GATHERINGS}/:id${ROUTES.MEETING_CREATE}`,
+ path: `${ROUTES.GATHERINGS}/:id/meetings/create`,
element: ,
},
{
diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts
index 3faecad..f174daf 100644
--- a/src/shared/constants/routes.ts
+++ b/src/shared/constants/routes.ts
@@ -21,11 +21,12 @@ export const ROUTES = {
INVITE: (invitationCode: string) => `/invite/${invitationCode}`,
// Meetings
- MEETINGS: '/meetings',
- MEETING_DETAIL: (id: number | string) => `/meetings/${id}`,
- MEETING_CREATE: '/meetings/create',
- MEETING_UPDATE: '/meetings/update',
- MEETING_SETTING: '/meetings/setting',
+ MEETING_DETAIL: (gatheringId: number | string, meetingId: number | string) =>
+ `/gatherings/${gatheringId}/meetings/${meetingId}`,
+ MEETING_CREATE: (gatheringId: number | string) => `/gatherings/${gatheringId}/meetings/create`,
+ MEETING_UPDATE: (gatheringId: number | string, meetingId: number | string) =>
+ `/gatherings/${gatheringId}/meetings/${meetingId}/update`,
+ MEETING_SETTING: (gatheringId: number | string) => `/gatherings/${gatheringId}/meetings/setting`,
// Records
RECORDS: '/records',
diff --git a/src/shared/ui/TextButton.tsx b/src/shared/ui/TextButton.tsx
index db0212a..31d900e 100644
--- a/src/shared/ui/TextButton.tsx
+++ b/src/shared/ui/TextButton.tsx
@@ -6,7 +6,7 @@ import { cn } from '../lib/utils'
const textButtonVariants = cva(
[
- 'inline-flex items-center font-normal select-none text-grey-600',
+ 'inline-flex items-center select-none text-grey-600',
'cursor-pointer transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:rounded-xtiny',
].join(' '),