diff --git a/src/features/meetings/components/MapModal.tsx b/src/features/meetings/components/MapModal.tsx new file mode 100644 index 0000000..c9f5581 --- /dev/null +++ b/src/features/meetings/components/MapModal.tsx @@ -0,0 +1,25 @@ +import type { MeetingLocation } from '@/features/meetings/meetings.types' +import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle } from '@/shared/ui' + +interface MapModalProps { + open: boolean + onOpenChange: (open: boolean) => void + location: MeetingLocation +} + +export default function MapModal({ open, onOpenChange, location }: MapModalProps) { + return ( + + + + {location.name} + + +
+ 지도 API 연동 예정 +
+
+
+
+ ) +} diff --git a/src/features/meetings/components/MeetingDetailButton.tsx b/src/features/meetings/components/MeetingDetailButton.tsx new file mode 100644 index 0000000..0a43a82 --- /dev/null +++ b/src/features/meetings/components/MeetingDetailButton.tsx @@ -0,0 +1,86 @@ +import { useCancelJoinMeeting, useJoinMeeting } from '@/features/meetings/hooks' +import type { MeetingDetailActionStateType } from '@/features/meetings/meetings.types' +import { Button } from '@/shared/ui' +import { useGlobalModalStore } from '@/store' + +interface MeetingDetailButtonProps { + buttonLabel: string + isEnabled: boolean + type: MeetingDetailActionStateType + meetingId: number +} + +export default function MeetingDetailButton({ + buttonLabel, + isEnabled, + type, + meetingId, +}: MeetingDetailButtonProps) { + const joinMutation = useJoinMeeting() + const cancelJoinMutation = useCancelJoinMeeting() + const { openError, openConfirm } = useGlobalModalStore() + + const isPending = joinMutation.isPending || cancelJoinMutation.isPending + + const handleClick = async () => { + if (!isEnabled || isPending) return + + // 약속 수정 - 페이지 이동 예정 (TODO) + if (type === 'CAN_EDIT') { + // 페이지 이동 로직 추가 예정 + return + } + + // 약속 참가신청 + if (type === 'CAN_JOIN') { + const confirmed = await openConfirm('참가 신청', '약속 참가 신청을 하시겠습니까?') + if (!confirmed) return + + joinMutation.mutate(meetingId, { + onSuccess: () => { + alert('참가 신청이 완료되었습니다.') + }, + onError: (error) => { + openError('에러', error.userMessage) + }, + }) + return + } + + // 약속 참가취소 + if (type === 'CAN_CANCEL') { + const confirmed = await openConfirm('참가 신청 취소', '약속 참가 신청을 취소하시겠습니까?') + if (!confirmed) return + + cancelJoinMutation.mutate(meetingId, { + onSuccess: () => { + alert('참가 취소가 완료되었습니다.') + }, + onError: (error) => { + openError('에러', error.userMessage) + }, + }) + return + } + } + + return ( +
+ + {isEnabled && ( +

+ {type === 'EDIT_TIME_EXPIRED' && '약속 24시간 전까지만 약속 정보를 수정할 수 있어요'} + {(type === 'CANCEL_TIME_EXPIRED' || type === 'JOIN_TIME_EXPIRED') && + '* 약속 24시간 전까지만 참가 신청 및 취소가 가능해요'} +

+ )} +
+ ) +} diff --git a/src/features/meetings/components/MeetingDetailHeader.tsx b/src/features/meetings/components/MeetingDetailHeader.tsx new file mode 100644 index 0000000..ab2d34e --- /dev/null +++ b/src/features/meetings/components/MeetingDetailHeader.tsx @@ -0,0 +1,27 @@ +import type { MeetingProgressStatus } from '@/features/meetings/meetings.types' +import { Badge } from '@/shared/ui' + +interface MeetingDetailHeaderProps { + children: string + progressStatus: MeetingProgressStatus +} +type ProgressBadge = { + text: '약속 전' | '약속 중' | '약속 후' + color: 'yellow' | 'blue' | 'red' +} +export function MeetingDetailHeader({ children, progressStatus }: MeetingDetailHeaderProps) { + const progressStatusLabelMap: Record = { + PRE: { text: '약속 전', color: 'yellow' }, + ONGOING: { text: '약속 중', color: 'red' }, + POST: { text: '약속 후', color: 'blue' }, + } + const { text, color } = progressStatusLabelMap[progressStatus] + return ( +
+

{children}

+ + {text} + +
+ ) +} diff --git a/src/features/meetings/components/MeetingDetailInfo.tsx b/src/features/meetings/components/MeetingDetailInfo.tsx new file mode 100644 index 0000000..797b12f --- /dev/null +++ b/src/features/meetings/components/MeetingDetailInfo.tsx @@ -0,0 +1,144 @@ +import { MapPin } from 'lucide-react' +import { useState } from 'react' + +import MapModal from '@/features/meetings/components/MapModal' +import { + Avatar, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarImage, + TextButton, +} from '@/shared/ui' + +import type { GetMeetingDetailResponse } from '../meetings.types' + +const MAX_DISPLAYED_AVATARS = 4 +const DT_VARIANTS = 'w-[68px] text-grey-600 typo-caption1' + +interface MeetingDetailInfoProps { + meeting: GetMeetingDetailResponse +} + +export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) { + const [isMapModalOpen, setIsMapModalOpen] = useState(false) + + 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) + const remainingMembers = members.slice(MAX_DISPLAYED_AVATARS) + const hasRegularMembers = members.length > 0 + const hasRemainingMembers = remainingMembers.length > 0 + + const [startDate, endDate] = meeting.schedule.displayDate.split(' ~ ') + + return ( +
+
+ {/* 도서 */} +
+
도서
+
+

{meeting.book.bookName}

+
+ {meeting.book.bookName} +
+
+
+ + {/* 참가인원 */} +
+
참가인원
+
+

+ {meeting.participants.currentCount}{' '} + /{meeting.participants.maxCount} +

+ + {/* 약속장 */} + {leader && ( +
+

약속장

+ + + {leader.nickname[0]} + +
+ )} + + {/* 멤버 */} + {hasRegularMembers && ( +
+

멤버

+ + {displayedMembers.map((member) => ( + + + {member.nickname[0]} + + ))} + {hasRemainingMembers && ( + ({ + id: String(member.userId), + name: member.nickname, + src: member.profileImageUrl, + fallbackText: member.nickname[0], + }))} + preview={{ + name: remainingMembers[0].nickname, + src: remainingMembers[0].profileImageUrl, + fallbackText: remainingMembers[0].nickname[0], + }} + > + +{remainingMembers.length} + + )} + +
+ )} +
+
+ + {/* 날짜 및 시간 */} +
+
날짜 및 시간
+
+

{startDate}

+

~ {endDate}

+
+
+ + {/* 장소 */} +
+
장소
+
+ {meeting.location && ( + setIsMapModalOpen(true)} + > + {meeting.location.name} + + )} +
+
+
+ + {/* 지도 모달 */} + {meeting.location && ( + + )} +
+ ) +} diff --git a/src/features/meetings/components/PlaceList.tsx b/src/features/meetings/components/PlaceList.tsx index 16ab148..9f99b42 100644 --- a/src/features/meetings/components/PlaceList.tsx +++ b/src/features/meetings/components/PlaceList.tsx @@ -16,7 +16,12 @@ export type PlaceListProps = { onPlaceHoverEnd?: () => void } -export function PlaceList({ places, onPlaceClick, onPlaceHover, onPlaceHoverEnd }: PlaceListProps) { +export default function PlaceList({ + places, + onPlaceClick, + onPlaceHover, + onPlaceHoverEnd, +}: PlaceListProps) { if (places.length === 0) { return (
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 ( +
+

{error.userMessage}

+
+ ) + } + + 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(' '),