diff --git a/index.html b/index.html index e42eaac..accc0fd 100644 --- a/index.html +++ b/index.html @@ -8,10 +8,6 @@ 독크독크 -
diff --git a/package.json b/package.json index c783cb8..bd1b474 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "axios": "^1.13.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dbc859..3bd2c96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: '@radix-ui/react-toggle-group': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@20.19.28)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -1227,6 +1230,22 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: + { + integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==, + } + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: { @@ -4552,6 +4571,26 @@ snapshots: '@types/react': 18.2.14 '@types/react-dom': 18.2.7 + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.2.7)(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.14 + '@types/react-dom': 18.2.7 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.2.14)(react@18.2.0)': dependencies: react: 18.2.0 diff --git a/src/App.tsx b/src/App.tsx index e3f3454..38fd719 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom' import { setupInterceptors } from '@/api' import { queryClient } from '@/shared/lib/tanstack-query' import { GlobalModalHost } from '@/shared/ui/GlobalModalHost' +import { TooltipProvider } from '@/shared/ui/Tooltip' import { router } from './routes' @@ -13,8 +14,10 @@ setupInterceptors() function App() { return ( - - + + + + ) } diff --git a/src/features/meetings/components/MeetingApprovalItem.tsx b/src/features/meetings/components/MeetingApprovalItem.tsx index 978467f..c9fea42 100644 --- a/src/features/meetings/components/MeetingApprovalItem.tsx +++ b/src/features/meetings/components/MeetingApprovalItem.tsx @@ -16,6 +16,8 @@ import { useGlobalModalStore } from '@/store' export type MeetingApprovalItemProps = { /** 약속 승인 아이템 데이터 */ item: MeetingApprovalItemType + /** 모임 ID */ + gatheringId: number } /** @@ -24,13 +26,13 @@ export type MeetingApprovalItemProps = { * @description * 약속 승인 리스트의 개별 아이템을 렌더링합니다. */ -export default function MeetingApprovalItem({ item }: MeetingApprovalItemProps) { +export default function MeetingApprovalItem({ item, gatheringId }: MeetingApprovalItemProps) { const { meetingName, bookName, nickname, startDateTime, endDateTime, meetingStatus, meetingId } = item const confirmMutation = useConfirmMeeting() - const rejectMutation = useRejectMeeting() - const deleteMutation = useDeleteMeeting() + const rejectMutation = useRejectMeeting(gatheringId) + const deleteMutation = useDeleteMeeting(gatheringId) const isPending = confirmMutation.isPending || rejectMutation.isPending || deleteMutation.isPending const { openConfirm, openError } = useGlobalModalStore() @@ -41,6 +43,7 @@ export default function MeetingApprovalItem({ item }: MeetingApprovalItemProps) if (!confirmed) return confirmMutation.mutate(meetingId, { + //Todo : 동시간에 승인할 수 없다고 별도로 알려주면 좋을듯 onError: (error) => openError('에러', error.userMessage), }) } @@ -94,7 +97,7 @@ export default function MeetingApprovalItem({ item }: MeetingApprovalItemProps) 거절 ) : ( diff --git a/src/features/meetings/components/MeetingApprovalList.tsx b/src/features/meetings/components/MeetingApprovalList.tsx index 6039ab0..476958e 100644 --- a/src/features/meetings/components/MeetingApprovalList.tsx +++ b/src/features/meetings/components/MeetingApprovalList.tsx @@ -3,74 +3,50 @@ * @description 약속 승인 리스트 컴포넌트 */ -import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' - -import { MeetingApprovalItem, type MeetingStatus, useMeetingApprovals } from '@/features/meetings' +import type { PaginatedResponse } from '@/api/types' +import { MeetingApprovalItem, type MeetingApprovalItemType } from '@/features/meetings' import { PAGE_SIZES } from '@/shared/constants' import { Pagination } from '@/shared/ui/Pagination' -import { useGlobalModalStore } from '@/store' export type MeetingApprovalListProps = { - /** 모임 식별자 */ + /** 약속 승인 리스트 데이터 */ + data: PaginatedResponse + /** 모임 ID */ gatheringId: number - /** 약속 상태 (PENDING: 확정 대기, CONFIRMED: 확정 완료) */ - status: MeetingStatus + /** 현재 페이지 */ + currentPage: number + /** 페이지 변경 핸들러 */ + onPageChange: (page: number) => void } -export default function MeetingApprovalList({ gatheringId, status }: MeetingApprovalListProps) { - const navigate = useNavigate() - const [currentPage, setCurrentPage] = useState(0) - const pageSize = PAGE_SIZES.MEETING_APPROVALS - const { openError } = useGlobalModalStore() - - const { data, isLoading, isError, error } = useMeetingApprovals({ - gatheringId, - status, - page: currentPage, - size: pageSize, - }) - - useEffect(() => { - if (isError) { - openError('에러', error.userMessage, () => { - navigate('/', { replace: true }) - }) - } - }, [isError, openError, error, navigate]) - - if (isLoading) { - return ( -
-

로딩 중...

-
- ) - } +export default function MeetingApprovalList({ + data, + gatheringId, + currentPage, + onPageChange, +}: MeetingApprovalListProps) { if (!data || data.items.length === 0) { return ( -
+

약속이 없습니다.

) } 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 ( + + ) +} diff --git a/src/features/meetings/components/MeetingDetailButton.tsx b/src/features/meetings/components/MeetingDetailButton.tsx index 0a43a82..6625064 100644 --- a/src/features/meetings/components/MeetingDetailButton.tsx +++ b/src/features/meetings/components/MeetingDetailButton.tsx @@ -74,7 +74,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/MeetingDetailInfo.tsx b/src/features/meetings/components/MeetingDetailInfo.tsx index 797b12f..3710ea6 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, @@ -21,8 +19,6 @@ interface MeetingDetailInfoProps { } 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) @@ -32,6 +28,8 @@ export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) { const [startDate, endDate] = meeting.schedule.displayDate.split(' ~ ') + const location = meeting.location + return (

@@ -117,28 +115,25 @@ export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) {
장소
- {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..aede7a6 100644 --- a/src/features/meetings/components/PlaceList.tsx +++ b/src/features/meetings/components/PlaceList.tsx @@ -39,7 +39,7 @@ export default function PlaceList({ onClick={() => onPlaceClick(place)} onMouseEnter={() => onPlaceHover?.(place, index)} onMouseLeave={onPlaceHoverEnd} - className="text-left transition-colors bg-white border p-small rounded-small border-grey-300 hover:bg-grey-50" + className="text-left transition-colors bg-white border p-small rounded-small border-grey-300 hover:bg-grey-50 cursor-pointer" >

{place.place_name}

diff --git a/src/features/meetings/components/PlaceSearchModal.tsx b/src/features/meetings/components/PlaceSearchModal.tsx index dfd54f5..b5a995a 100644 --- a/src/features/meetings/components/PlaceSearchModal.tsx +++ b/src/features/meetings/components/PlaceSearchModal.tsx @@ -3,7 +3,7 @@ * @description 카카오 장소 검색 모달 컴포넌트 */ -import { Search } from 'lucide-react' +import { AlertCircle, Search } from 'lucide-react' import { useEffect, useRef } from 'react' import { @@ -49,12 +49,24 @@ export default function PlaceSearchModal({ onSelectPlace, }: PlaceSearchModalProps) { // 지도 관리 - const { mapElement, isInitialized, initializeMap, renderMarkers, setCenter, cleanup } = - useKakaoMap() + const { + mapElement, + isInitialized, + error: mapError, + initializeMap, + renderMarkers, + setCenter, + cleanup, + } = useKakaoMap() // 장소 검색 관리 const keywordRef = useRef(null) - const { places, search, reset } = useKakaoPlaceSearch({ + const { + places, + error: searchError, + search, + reset, + } = useKakaoPlaceSearch({ onSearchSuccess: renderMarkers, }) @@ -123,13 +135,23 @@ export default function PlaceSearchModal({

-
+
{/* 지도 영역 */}
+ {/* SDK 로드 에러 오버레이 */} + {mapError && ( +
+
+ +

{mapError}

+
+
+ )} + {/* 검색 전 안내 메시지 오버레이 */} - {!isInitialized && ( + {!isInitialized && !mapError && (
@@ -141,7 +163,15 @@ export default function PlaceSearchModal({
{/* 장소 리스트 */} - +
+ {searchError && ( +
+ + {searchError} +
+ )} + +
diff --git a/src/features/meetings/components/index.ts b/src/features/meetings/components/index.ts index 272ec4f..dbb1aa8 100644 --- a/src/features/meetings/components/index.ts +++ b/src/features/meetings/components/index.ts @@ -1,6 +1,7 @@ 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' diff --git a/src/features/meetings/hooks/index.ts b/src/features/meetings/hooks/index.ts index 68684c0..34d21ae 100644 --- a/src/features/meetings/hooks/index.ts +++ b/src/features/meetings/hooks/index.ts @@ -7,7 +7,7 @@ export * from './useJoinMeeting' export * from './useKakaoMap' export * from './useKakaoPlaceSearch' export * from './useMeetingApprovals' -export * from './useMeetingApprovalsCount' export * from './useMeetingDetail' export * from './useMeetingForm' export * from './useRejectMeeting' +export * from './useUpdateMeeting' 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/useKakaoMap.ts b/src/features/meetings/hooks/useKakaoMap.ts index 9d67bee..08f01fd 100644 --- a/src/features/meetings/hooks/useKakaoMap.ts +++ b/src/features/meetings/hooks/useKakaoMap.ts @@ -6,6 +6,7 @@ import { useRef, useState } from 'react' import type { KakaoPlace } from '../kakaoMap.types' +import { loadKakaoSdk } from '../loadKakaoSdk' export type UseKakaoMapOptions = { /** 초기 중심 좌표 */ @@ -17,6 +18,7 @@ export type UseKakaoMapOptions = { export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOptions = {}) { const [mapElement, setMapElement] = useState(null) const [isInitialized, setIsInitialized] = useState(false) + const [error, setError] = useState(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef(null) @@ -57,7 +59,7 @@ export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOpti } // 지도 수동 초기화 - const initializeMap = () => { + const initializeMap = async () => { if (!mapElement) { console.warn('Map element not ready') return false @@ -69,10 +71,20 @@ export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOpti return true } + try { + await loadKakaoSdk() + } catch (err) { + const message = err instanceof Error ? err.message : '카카오 지도 SDK 로드에 실패했습니다.' + setError(message) + return false + } + const kakao = window.kakao if (!kakao?.maps) { - console.error('Kakao Maps SDK not loaded') + const message = '카카오 지도 SDK가 로드되지 않았습니다.' + console.error('[카카오 지도]', message) + setError(message) return false } @@ -85,6 +97,7 @@ export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOpti mapRef.current = map infowindowRef.current = new kakao.maps.InfoWindow({ zIndex: 1 }) + setError(null) setIsInitialized(true) // Portal/Modal에서 사이즈 계산 이슈 방지 @@ -103,6 +116,7 @@ export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOpti mapRef.current = null infowindowRef.current = null setIsInitialized(false) + setError(null) } // 장소 목록에 대한 마커 렌더링 @@ -154,6 +168,7 @@ export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOpti return { mapElement: setMapElement, isInitialized, + error, initializeMap, renderMarkers, closeInfoWindow, diff --git a/src/features/meetings/hooks/useKakaoPlaceSearch.ts b/src/features/meetings/hooks/useKakaoPlaceSearch.ts index e9829c8..0bf9e44 100644 --- a/src/features/meetings/hooks/useKakaoPlaceSearch.ts +++ b/src/features/meetings/hooks/useKakaoPlaceSearch.ts @@ -10,10 +10,16 @@ import type { KakaoPlace } from '../kakaoMap.types' export type UseKakaoPlaceSearchOptions = { /** 검색 성공 콜백 */ onSearchSuccess?: (places: KakaoPlace[]) => void + /** 검색 오류 콜백 */ + onSearchError?: (message: string) => void } -export function useKakaoPlaceSearch({ onSearchSuccess }: UseKakaoPlaceSearchOptions = {}) { +export function useKakaoPlaceSearch({ + onSearchSuccess, + onSearchError, +}: UseKakaoPlaceSearchOptions = {}) { const [places, setPlaces] = useState([]) + const [error, setError] = useState(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const placesServiceRef = useRef(null) @@ -39,15 +45,20 @@ export function useKakaoPlaceSearch({ onSearchSuccess }: UseKakaoPlaceSearchOpti // eslint-disable-next-line @typescript-eslint/no-explicit-any ps.keywordSearch(searchKeyword, (data: KakaoPlace[], status: any) => { if (status === kakao.maps.services.Status.OK) { + setError(null) setPlaces(data) onSearchSuccess?.(data) } else if (status === kakao.maps.services.Status.ZERO_RESULT) { + setError(null) setPlaces([]) onSearchSuccess?.([]) } else { + // Status.ERROR: 네트워크 오류, 서버 오류 등 다양한 원인으로 발생 + const message = '검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + setError(message) setPlaces([]) - onSearchSuccess?.([]) - alert('검색 중 오류가 발생했습니다.') + onSearchError?.(message) + console.error('[카카오 장소 검색] 오류 발생 - status:', status) } }) @@ -57,11 +68,13 @@ export function useKakaoPlaceSearch({ onSearchSuccess }: UseKakaoPlaceSearchOpti // 검색 상태 초기화 const reset = () => { setPlaces([]) + setError(null) placesServiceRef.current = null } return { places, + error, 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..060dda7 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,40 @@ 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 ( + !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 +153,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 +181,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 +235,6 @@ export const useMeetingForm = ({ gatheringMaxCount }: UseMeetingFormParams) => { /** * 시작 날짜 비활성화 조건 - * 오늘은 불가, 내일부터 선택 가능 */ const getStartDateDisabled = () => { const tomorrow = new Date() @@ -165,77 +245,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 +296,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/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..1e5b581 100644 --- a/src/features/meetings/index.ts +++ b/src/features/meetings/index.ts @@ -28,4 +28,6 @@ export type { MeetingLocation, MeetingStatus, RejectMeetingResponse, + UpdateMeetingRequest, + UpdateMeetingResponse, } from './meetings.types' diff --git a/src/features/meetings/loadKakaoSdk.ts b/src/features/meetings/loadKakaoSdk.ts new file mode 100644 index 0000000..18b994a --- /dev/null +++ b/src/features/meetings/loadKakaoSdk.ts @@ -0,0 +1,83 @@ +/** + * @file loadKakaoSdk.ts + * @description 카카오 Maps SDK 싱글톤 로더 + * + * - 앱 전체에서 SDK를 한 번만 로드 (중복 script 삽입 방지) + * - 지도를 실제로 사용할 때만 로드 (불필요한 토큰/쿼터 소모 방지) + * - autoload=false + maps.load() 콜백으로 초기화 완료 시점 보장 + */ + +let kakaoSdkPromise: Promise | null = null + +export function loadKakaoSdk(): Promise { + // 이미 초기화 완료된 경우 + if (window.kakao?.maps) { + return Promise.resolve() + } + + // 이미 로드 중인 경우 동일 Promise 반환 (중복 요청 방지) + if (kakaoSdkPromise) { + return kakaoSdkPromise + } + + kakaoSdkPromise = new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${ + import.meta.env.VITE_KAKAO_MAP_KEY + }&autoload=false&libraries=services` + script.async = true + + script.onload = () => { + try { + window.kakao.maps.load(() => resolve()) + } catch (err) { + kakaoSdkPromise = null + const message = '카카오 지도 SDK 초기화에 실패했습니다.' + console.error('[카카오 지도]', message, err) + reject(new Error(message)) + } + } + + script.onerror = () => { + // 실패 시 Promise 초기화하여 재시도 가능하게 + kakaoSdkPromise = null + + // fetch로 실제 HTTP 상태 코드 확인 후 카카오 공식 상태 메시지 사용 + fetch(script.src) + .then((res) => { + if (res.ok) { + const message = + '카카오 지도 SDK 로드에 실패했습니다. 일시적인 네트워크 오류일 수 있으니 잠시 후 다시 시도해주세요.' + console.error('[카카오 지도] SDK 로드 실패 (진단 불일치):', message) + reject(new Error(message)) + return + } + + const kakaoStatusMessages: Record = { + 400: '잘못된 요청입니다. API에 필요한 필수 파라미터를 확인해주세요. (400 Bad Request)', + 401: '인증 오류입니다. 앱키(VITE_KAKAO_MAP_KEY)가 올바른지 확인해주세요. (401 Unauthorized)', + 403: '권한 오류입니다. 앱 등록 및 도메인 설정을 확인해주세요. (403 Forbidden)', + 429: '쿼터를 초과했습니다. 정해진 사용량이나 초당 요청 한도를 초과했습니다. (429 Too Many Request)', + 500: '카카오 서버 내부 오류입니다. 잠시 후 다시 시도해주세요. (500 Internal Server Error)', + 502: '카카오 게이트웨이 오류입니다. 잠시 후 다시 시도해주세요. (502 Bad Gateway)', + 503: '카카오 서비스 점검 중입니다. 잠시 후 다시 시도해주세요. (503 Service Unavailable)', + } + const message = + kakaoStatusMessages[res.status] ?? + `카카오 지도 SDK 로드에 실패했습니다. (HTTP ${res.status})` + const error = new Error(message) + console.error('[카카오 지도] SDK 로드 실패:', message) + reject(error) + }) + .catch(() => { + const message = '카카오 지도 SDK를 로드할 수 없습니다. 네트워크 연결을 확인해주세요.' + console.error('[카카오 지도] SDK 로드 실패 (네트워크 오류):', message) + reject(new Error(message)) + }) + } + + document.head.appendChild(script) + }) + + return kakaoSdkPromise +} diff --git a/src/features/meetings/meetings.api.ts b/src/features/meetings/meetings.api.ts index a1989f4..1a193f9 100644 --- a/src/features/meetings/meetings.api.ts +++ b/src/features/meetings/meetings.api.ts @@ -15,15 +15,13 @@ import type { GetMeetingDetailResponse, MeetingApprovalItem, 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 +46,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 +135,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 +192,33 @@ 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 } diff --git a/src/features/meetings/meetings.endpoints.ts b/src/features/meetings/meetings.endpoints.ts index e5fc740..7485524 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,7 @@ 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}`, } as const diff --git a/src/features/meetings/meetings.mock.ts b/src/features/meetings/meetings.mock.ts index 60863c1..65191b2 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', @@ -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..e3d380c 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 +} + /** * 약속 일정 타입 */ @@ -189,6 +228,8 @@ export type GetMeetingDetailResponse = { bookId: number bookName: string thumbnail: string + authors: string + publisher: string } /** 일정 정보 */ schedule: MeetingSchedule diff --git a/src/features/topics/components/TopicHeader.tsx b/src/features/topics/components/TopicHeader.tsx index 329d88d..d570a5f 100644 --- a/src/features/topics/components/TopicHeader.tsx +++ b/src/features/topics/components/TopicHeader.tsx @@ -2,6 +2,7 @@ import { format } from 'date-fns' import { Check } from 'lucide-react' import { Button } from '@/shared/ui' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/Tooltip' type ProposedHeaderProps = { activeTab: 'PROPOSED' @@ -89,9 +90,23 @@ export default function TopicHeader(props: TopicHeaderProps) {
- + {props.actions.canViewPreOpinions ? ( + + ) : ( + + + + + +

내 의견을 먼저 공유해야 다른

+

멤버들의 의견도 확인할 수 있어요!

+
+
+ )}
diff --git a/src/features/topics/topics.api.ts b/src/features/topics/topics.api.ts index 703aad3..83ed1d7 100644 --- a/src/features/topics/topics.api.ts +++ b/src/features/topics/topics.api.ts @@ -20,12 +20,8 @@ import type { LikeTopicResponse, } from './topics.types' -/** - * 목데이터 사용 여부 - * @description 로그인 기능 개발 전까지 true로 설정하여 목데이터 사용 - * TODO: 로그인 기능 완료 후 false로 변경하여 실제 API 호출 - */ -const USE_MOCK_DATA = true +/** 목데이터 사용 여부 플래그 */ +const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' /** * 제안된 주제 조회 @@ -56,7 +52,7 @@ export const getProposedTopics = async ( // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 - if (USE_MOCK_DATA) { + if (USE_MOCK) { // 실제 API 호출을 시뮬레이션하기 위한 지연 await new Promise((resolve) => setTimeout(resolve, 500)) return getMockProposedTopics(pageSize, cursorLikeCount, cursorTopicId) @@ -101,7 +97,7 @@ export const getConfirmedTopics = async ( // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 - if (USE_MOCK_DATA) { + if (USE_MOCK) { // 실제 API 호출을 시뮬레이션하기 위한 지연 await new Promise((resolve) => setTimeout(resolve, 500)) return getMockConfirmedTopics(pageSize, cursorConfirmOrder, cursorTopicId) @@ -135,7 +131,7 @@ export const deleteTopic = async (params: DeleteTopicParams): Promise => { // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 - if (USE_MOCK_DATA) { + if (USE_MOCK) { // 실제 API 호출을 시뮬레이션하기 위한 지연 await new Promise((resolve) => setTimeout(resolve, 500)) return @@ -163,7 +159,7 @@ export const createTopic = async (params: CreateTopicParams): Promise setTimeout(resolve, 500)) return { @@ -196,7 +192,7 @@ export const likeTopicToggle = async (params: LikeTopicParams): Promise setTimeout(resolve, 300)) // 목 응답 (랜덤하게 좋아요/취소) diff --git a/src/pages/ComponentGuide/ComponentGuidePage.tsx b/src/pages/ComponentGuide/ComponentGuidePage.tsx index 128d073..4572959 100644 --- a/src/pages/ComponentGuide/ComponentGuidePage.tsx +++ b/src/pages/ComponentGuide/ComponentGuidePage.tsx @@ -43,6 +43,7 @@ import { TopicTypeSelectItem, UserChip, } from '@/shared/ui' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/Tooltip' function ComponentGuidePage() { const [selectedSection, setSelectedSection] = useState('button') @@ -72,6 +73,7 @@ function ComponentGuidePage() { { id: 'tabs', name: 'Tabs', category: '내비게이션' }, { id: 'pagination', name: 'Pagination', category: '내비게이션' }, { id: 'modal', name: 'Modal', category: '오버레이' }, + { id: 'tooltip', name: 'Tooltip', category: '오버레이' }, ] const filteredSections = sections.filter( @@ -156,6 +158,7 @@ function ComponentGuidePage() { {selectedSection === 'tabs' && } {selectedSection === 'pagination' && } {selectedSection === 'modal' && } + {selectedSection === 'tooltip' && }
@@ -1801,4 +1804,125 @@ function StarRatingFilterSection() { ) } +function TooltipSection() { + return ( +
+ + + + + +

툴팁 내용

+
+`} + > + + + + + +

이것은 기본 툴팁입니다

+
+
+
+ + 위 +오른쪽 +아래 +왼쪽`} + > + + + + + +

위에 표시됩니다

+
+
+ + + + + +

오른쪽에 표시됩니다

+
+
+ + + + + +

아래에 표시됩니다

+
+
+ + + + + +

왼쪽에 표시됩니다

+
+
+
+ + + + + + +

X 버튼으로만 닫을 수 있습니다

+
+`} + > + + + + + +

내 의견을 먼저 공유해야 다른 멤버들의 의견도 확인할 수 있어요!

+
+
+
+ + +

간격이 넓습니다

+`} + > + + + + + +

트리거와 거리가 멉니다

+
+
+
+ + +
+

• 기본 툴팁: hover 시 자동으로 표시/숨김

+

• dismissable 툴팁: Tooltip에 dismissable prop 추가

+

• dismissable 모드: 항상 열려있고 X 버튼으로만 닫힘

+

• 한 번 닫으면 트리거에 hover해도 다시 열리지 않음

+

• 외부 클릭, ESC 키, 스크롤로 닫히지 않음 (dismissable 모드)

+

• asChild prop을 사용하여 자식 요소에 직접 이벤트 연결

+

• TooltipProvider는 App.tsx에서 전역으로 설정됨

+
+
+
+ ) +} + export default ComponentGuidePage diff --git a/src/pages/Meetings/MeetingCreatePage.tsx b/src/pages/Meetings/MeetingCreatePage.tsx index 633350c..77dbb03 100644 --- a/src/pages/Meetings/MeetingCreatePage.tsx +++ b/src/pages/Meetings/MeetingCreatePage.tsx @@ -1,26 +1,88 @@ import { ChevronLeft, Search } from 'lucide-react' -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' +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 { 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 +96,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 +116,6 @@ export default function MeetingCreatePage() { longitude, } = formData - //setBookId, setBookName const { setMeetingName, setMaxParticipants, @@ -63,34 +127,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.GATHERING_DETAIL(gatheringId), { 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 +199,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,6 +211,26 @@ 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 ( <> {/* 공통컴포넌트로 교체 예정 */} @@ -117,22 +239,24 @@ export default function MeetingCreatePage() { 뒤로가기

-

약속 만들기

+

{isEditMode ? '약속 수정하기' : '약속 만들기'}

{/* 공통컴포넌트로 교체 예정 */}
- -

작성한 내용은 모임장의 승인 후 약속으로 등록돼요.

-
+ {!isEditMode && ( + +

작성한 내용은 모임장의 승인 후 약속으로 등록돼요.

+
+ )} 약속명 @@ -147,19 +271,43 @@ export default function MeetingCreatePage() { - + 도서 - +
+ {bookThumbnail && bookName && bookAuthors && ( + +
+ {bookName} +
+
+

{bookName}

+

{bookAuthors}

+
+
+ )} + {!isEditMode && ( + + )} +
{errors?.bookId && (

{errors.bookId}

)} @@ -169,7 +317,7 @@ export default function MeetingCreatePage() { 장소 - {locationAddress && ( + {locationAddress && locationName && (

{locationName}

{locationAddress}

@@ -191,17 +339,6 @@ export default function MeetingCreatePage() {
- { - setLocationName(place.name) - setLocationAddress(place.address) - setLatitude(place.latitude) - setLongitude(place.longitude) - }} - /> - 날짜 및 시간 @@ -302,6 +439,26 @@ export default function MeetingCreatePage() { />
+ + {isPlaceSearchOpen && ( + { + setLocationName(place.name) + setLocationAddress(place.address) + setLatitude(place.latitude) + setLongitude(place.longitude) + }} + /> + )} + {!isEditMode && isBookSearchOpen && ( + setBook(book)} + /> + )}
) diff --git a/src/pages/Meetings/MeetingSettingPage.tsx b/src/pages/Meetings/MeetingSettingPage.tsx index 1c3d1e3..fe6a3be 100644 --- a/src/pages/Meetings/MeetingSettingPage.tsx +++ b/src/pages/Meetings/MeetingSettingPage.tsx @@ -1,11 +1,13 @@ 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 { 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,30 +15,57 @@ 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 (isPendingError) { + openError('에러', pendingError.userMessage, () => { + navigate('/', { replace: true }) + }) + } else if (isConfirmedError) { + openError('에러', confirmedError.userMessage, () => { + navigate('/', { replace: true }) + }) } - if (confirmedError) { - openError('오류', '확정 완료 약속 수를 불러오는 데 실패했습니다.') - } - }, [pendingError, confirmedError, openError]) + }, [isPendingError, isConfirmedError, openError, pendingError, confirmedError, navigate]) + + const pendingCount = pendingData?.totalCount + const confirmedCount = confirmedData?.totalCount return (
@@ -56,7 +85,7 @@ export default function MeetingSettingPage() { 확정 대기 @@ -64,18 +93,39 @@ export default function MeetingSettingPage() { 확정 완료 - - + + {isPendingLoading || isPendingError ? ( + + ) : ( + pendingData && ( + + ) + )} + + + {isConfirmedLoading || isConfirmedError ? ( + + ) : ( + confirmedData && ( + + ) + )} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 4ea4d46..60cc503 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -127,7 +127,11 @@ export const router = createBrowserRouter([ element: , }, { - path: `${ROUTES.GATHERINGS}/:id/meetings/create`, + path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/create`, + element: , + }, + { + path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId/update`, element: , }, { diff --git a/src/shared/ui/Tooltip.tsx b/src/shared/ui/Tooltip.tsx new file mode 100644 index 0000000..ca7290a --- /dev/null +++ b/src/shared/ui/Tooltip.tsx @@ -0,0 +1,197 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip' +import { X } from 'lucide-react' +import * as React from 'react' + +import { cn } from '@/shared/lib/utils' + +const TooltipContext = React.createContext<{ + dismissable: boolean + close: () => void +}>({ + dismissable: false, + close: () => {}, +}) + +/** + * TooltipProvider + * - 앱 최상위에서 감싸서 사용합니다 (App.tsx에 이미 설정됨). + * - `delayDuration`으로 툴팁이 나타나는 딜레이를 조절할 수 있습니다 (기본값: 0ms). + */ +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +/** + * Tooltip (Root) + * - 기본: hover 시 자동으로 표시/숨김되는 일반 툴팁 + * - dismissable 모드: `dismissable` prop을 true로 설정 + * - 컴포넌트 내부에서 자동으로 상태 관리 (useState 불필요) + * - 처음에 항상 열려있음 + * - X 버튼으로만 닫을 수 있음 + * - 한 번 닫으면 hover로 다시 열리지 않음 + * - 외부 클릭, ESC 키, 스크롤로 닫히지 않음 + * - 고급: `open`과 `onOpenChange`로 외부에서 제어 가능 (controlled 방식) + */ +function Tooltip({ + dismissable = false, + children, + ...props +}: React.ComponentProps & { + dismissable?: boolean +}) { + const [internalOpen, setInternalOpen] = React.useState(true) + + const handleOpenChange = (open: boolean) => { + if (dismissable) { + // dismissable 모드에서는 hover로 인한 상태 변경을 모두 무시 + // (X 버튼은 close() 함수를 직접 호출하므로 별도 처리됨) + if (props.onOpenChange) { + // 외부에서 제어하는 경우에만 전달 + props.onOpenChange(open) + } + return + } + + // 일반 모드에서는 정상적으로 처리 + if (props.onOpenChange) { + props.onOpenChange(open) + } + } + + const close = () => { + setInternalOpen(false) + if (props.onOpenChange) { + props.onOpenChange(false) + } + } + + const actualOpen = dismissable + ? props.open !== undefined + ? props.open + : internalOpen + : props.open + + return ( + + + {children} + + + ) +} + +/** + * TooltipTrigger + * - 툴팁을 트리거하는 요소입니다. + * - `asChild` prop을 사용하여 자식 요소에 직접 이벤트를 연결할 수 있습니다. + */ +function TooltipTrigger({ ...props }: React.ComponentProps) { + return +} + +/** + * TooltipContent + * - 툴팁의 실제 내용을 담는 컴포넌트입니다. + * - dismissable 모드일 때 자동으로 X 버튼이 표시됩니다 (Tooltip Root의 dismissable prop으로 제어). + * - `side` prop으로 툴팁 위치를 조절할 수 있습니다 (top, right, bottom, left). + * - `sideOffset`으로 트리거 요소와의 거리를 조절할 수 있습니다 (기본값: 0). + * - `onDismiss` 콜백을 통해 X 버튼 클릭 시 커스텀 동작을 정의할 수 있습니다 (선택사항). + * + * @example + * ```tsx + * // 기본 사용 (hover) + * + * + * + * + * + *

툴팁 내용

+ *
+ *
+ * + * // 닫을 수 있는 툴팁 (상태 관리 불필요) + * + * + * + * + * + *

X 버튼으로만 닫을 수 있는 툴팁

+ *
+ *
+ * + * // 툴팁 위치 조절 + * + * + * + * + * + *

위쪽에 여유있게 표시됩니다

+ *
+ *
+ * ``` + */ +function TooltipContent({ + className, + sideOffset = 0, + children, + onDismiss, + ...props +}: React.ComponentProps & { + onDismiss?: () => void +}) { + const { dismissable, close } = React.useContext(TooltipContext) + + const handleDismiss = () => { + close() + onDismiss?.() + } + + return ( + + e.preventDefault() : undefined} + onEscapeKeyDown={dismissable ? (e) => e.preventDefault() : undefined} + {...props} + > + {dismissable ? ( + <> +
{children}
+ + + ) : ( + children + )} + +
+
+ ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/src/store/globalModalStore.ts b/src/store/globalModalStore.ts index 8277377..901dacd 100644 --- a/src/store/globalModalStore.ts +++ b/src/store/globalModalStore.ts @@ -48,7 +48,7 @@ export type ConfirmModalOptions = { /** 전역 모달 스토어 타입 */ type GlobalModalStore = ModalState & { /** Alert 모달 열기 */ - openAlert: (title: string, description: string) => void + openAlert: (title: string, description: string, onClose?: () => void) => void /** Error 모달 열기 */ openError: (title: string, description: string, onClose?: () => void) => void /** Confirm 모달 열기 (Promise 반환) */ @@ -72,7 +72,7 @@ const initialState: ModalState = { export const useGlobalModalStore = create((set, get) => ({ ...initialState, - openAlert: (title: string, description: string) => { + openAlert: (title: string, description: string, onClose?: () => void) => { set({ isOpen: true, type: 'alert', @@ -82,7 +82,10 @@ export const useGlobalModalStore = create((set, get) => ({ { text: '확인', variant: 'primary', - onClick: () => get().close(), + onClick: () => { + get().close() + onClose?.() + }, }, ], })