diff --git a/index.html b/index.html index accc0fd..e42eaac 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,10 @@ 독크독크 +
diff --git a/src/features/meetings/components/PlaceList.tsx b/src/features/meetings/components/PlaceList.tsx new file mode 100644 index 0000000..16ab148 --- /dev/null +++ b/src/features/meetings/components/PlaceList.tsx @@ -0,0 +1,48 @@ +/** + * @file PlaceList.tsx + * @description 장소 검색 결과 목록 컴포넌트 + */ + +import type { KakaoPlace } from '../kakaoMap.types' + +export type PlaceListProps = { + /** 장소 목록 */ + places: KakaoPlace[] + /** 장소 클릭 핸들러 */ + onPlaceClick: (place: KakaoPlace) => void + /** 장소 hover 핸들러 */ + onPlaceHover?: (place: KakaoPlace, index: number) => void + /** 장소 hover 종료 핸들러 */ + onPlaceHoverEnd?: () => void +} + +export function PlaceList({ places, onPlaceClick, onPlaceHover, onPlaceHoverEnd }: PlaceListProps) { + if (places.length === 0) { + return ( +
+

검색 결과가 없습니다

+
+ ) + } + + return ( +
+ {places.map((place, index) => ( + + ))} +
+ ) +} diff --git a/src/features/meetings/components/PlaceSearchModal.tsx b/src/features/meetings/components/PlaceSearchModal.tsx new file mode 100644 index 0000000..59f70c7 --- /dev/null +++ b/src/features/meetings/components/PlaceSearchModal.tsx @@ -0,0 +1,152 @@ +/** + * @file PlaceSearchModal.tsx + * @description 카카오 장소 검색 모달 컴포넌트 + */ + +import { Search } from 'lucide-react' +import { useEffect, useRef } from 'react' + +import { + Button, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalTitle, +} from '@/shared/ui' + +import { useKakaoMap } from '../hooks/useKakaoMap' +import { useKakaoPlaceSearch } from '../hooks/useKakaoPlaceSearch' +import type { KakaoPlace } from '../kakaoMap.types' +import { PlaceList } from './PlaceList' + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + kakao: any + } +} + +export type PlaceSearchModalProps = { + /** 모달 열림 상태 */ + open: boolean + /** 모달 열림 상태 변경 핸들러 */ + onOpenChange: (open: boolean) => void + /** 장소 선택 핸들러 */ + onSelectPlace: (place: { + name: string + address: string + latitude: number + longitude: number + }) => void +} + +export function PlaceSearchModal({ open, onOpenChange, onSelectPlace }: PlaceSearchModalProps) { + // 지도 관리 + const { mapElement, isInitialized, initializeMap, renderMarkers, setCenter, cleanup } = + useKakaoMap() + + // 장소 검색 관리 + const keywordRef = useRef(null) + const { places, search, reset } = useKakaoPlaceSearch({ + onSearchSuccess: renderMarkers, + }) + + // 모달 열릴 때 지도 초기화 + useEffect(() => { + if (open && !isInitialized) { + initializeMap() + } + }, [open, isInitialized, initializeMap]) + + // 검색 실행 + const handleSearch = () => { + const keyword = keywordRef.current?.value || '' + search(keyword) + } + + // Enter 키 처리 + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSearch() + } + } + + // 장소 선택 + const handlePlaceClick = (place: KakaoPlace) => { + setCenter(Number(place.y), Number(place.x)) + + onSelectPlace({ + name: place.place_name, + address: place.road_address_name || place.address_name, + latitude: Number(place.y), + longitude: Number(place.x), + }) + + onOpenChange(false) + reset() + cleanup() + } + + // 모달 닫기 + const handleClose = () => { + onOpenChange(false) + reset() + cleanup() + } + + return ( + + + + 장소 검색 + + + +
+ + +
+ +
+ {/* 지도 영역 */} +
+
+ + {/* 검색 전 안내 메시지 오버레이 */} + {!isInitialized && ( +
+
+ +

장소를 검색하면

+

지도에 표시됩니다

+
+
+ )} +
+ + {/* 장소 리스트 */} + +
+ + + + + + + + ) +} diff --git a/src/features/meetings/components/index.ts b/src/features/meetings/components/index.ts index 32ff009..2ef7c1b 100644 --- a/src/features/meetings/components/index.ts +++ b/src/features/meetings/components/index.ts @@ -1,2 +1,4 @@ export { default as MeetingApprovalItem } from './MeetingApprovalItem' export { default as MeetingApprovalList } from './MeetingApprovalList' +export * from './PlaceList' +export * from './PlaceSearchModal' diff --git a/src/features/meetings/hooks/index.ts b/src/features/meetings/hooks/index.ts index b1c32e3..4106760 100644 --- a/src/features/meetings/hooks/index.ts +++ b/src/features/meetings/hooks/index.ts @@ -1,6 +1,10 @@ export * from './meetingQueryKeys' export * from './useConfirmMeeting' +export * from './useCreateMeeting' export * from './useDeleteMeeting' +export * from './useKakaoMap' +export * from './useKakaoPlaceSearch' export * from './useMeetingApprovals' export * from './useMeetingApprovalsCount' +export * from './useMeetingForm' export * from './useRejectMeeting' diff --git a/src/features/meetings/hooks/useCreateMeeting.ts b/src/features/meetings/hooks/useCreateMeeting.ts new file mode 100644 index 0000000..e56733b --- /dev/null +++ b/src/features/meetings/hooks/useCreateMeeting.ts @@ -0,0 +1,50 @@ +/** + * @file useCreateMeeting.ts + * @description 약속 생성 mutation 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError } from '@/api/errors' +import type { ApiResponse } from '@/api/types' +import { + createMeeting, + type CreateMeetingRequest, + type CreateMeetingResponse, +} from '@/features/meetings' + +import { meetingQueryKeys } from './meetingQueryKeys' + +/** + * 약속 생성 mutation 훅 + * + * @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() + + return useMutation, ApiError, CreateMeetingRequest>({ + mutationFn: (data: CreateMeetingRequest) => createMeeting(data), + onSuccess: () => { + // 약속 승인 관련 모든 캐시 무효화 (리스트 + 카운트) + queryClient.invalidateQueries({ + queryKey: meetingQueryKeys.approvals(), + }) + }, + }) +} diff --git a/src/features/meetings/hooks/useKakaoMap.ts b/src/features/meetings/hooks/useKakaoMap.ts new file mode 100644 index 0000000..9d67bee --- /dev/null +++ b/src/features/meetings/hooks/useKakaoMap.ts @@ -0,0 +1,164 @@ +/** + * @file useKakaoMap.ts + * @description Kakao Maps 지도 및 마커 관리 훅 + */ + +import { useRef, useState } from 'react' + +import type { KakaoPlace } from '../kakaoMap.types' + +export type UseKakaoMapOptions = { + /** 초기 중심 좌표 */ + initialCenter?: { lat: number; lng: number } + /** 초기 줌 레벨 */ + initialLevel?: number +} + +export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOptions = {}) { + const [mapElement, setMapElement] = useState(null) + const [isInitialized, setIsInitialized] = useState(false) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mapRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const markersRef = useRef([]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const infowindowRef = useRef(null) + + const defaultCenter = useRef(initialCenter ?? { lat: 37.566826, lng: 126.9786567 }) + + // 마커 제거 + const clearMarkers = () => { + markersRef.current.forEach((marker) => { + marker.setMap(null) + }) + markersRef.current = [] + } + + // 인포윈도우 닫기 + const closeInfoWindow = () => { + infowindowRef.current?.close() + } + + // HTML escape 유틸리티 + const escapeHtml = (text: string) => { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } + + // 인포윈도우 열기 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const openInfoWindow = (marker: any, title: string) => { + if (!mapRef.current || !infowindowRef.current) return + const escapedTitle = escapeHtml(title) + infowindowRef.current.setContent(`
${escapedTitle}
`) + infowindowRef.current.open(mapRef.current, marker) + } + + // 지도 수동 초기화 + const initializeMap = () => { + if (!mapElement) { + console.warn('Map element not ready') + return false + } + + if (mapRef.current) { + // 이미 초기화된 경우 relayout만 실행 + mapRef.current.relayout() + return true + } + + const kakao = window.kakao + + if (!kakao?.maps) { + console.error('Kakao Maps SDK not loaded') + return false + } + + // 지도 생성 + const map = new kakao.maps.Map(mapElement, { + center: new kakao.maps.LatLng(defaultCenter.current.lat, defaultCenter.current.lng), + level: initialLevel, + }) + + mapRef.current = map + + infowindowRef.current = new kakao.maps.InfoWindow({ zIndex: 1 }) + setIsInitialized(true) + + // Portal/Modal에서 사이즈 계산 이슈 방지 + setTimeout(() => { + map.relayout() + map.setCenter(new kakao.maps.LatLng(defaultCenter.current.lat, defaultCenter.current.lng)) + }, 0) + + return true + } + + // 지도 정리 + const cleanup = () => { + clearMarkers() + closeInfoWindow() + mapRef.current = null + infowindowRef.current = null + setIsInitialized(false) + } + + // 장소 목록에 대한 마커 렌더링 + const renderMarkers = (places: KakaoPlace[]) => { + if (!mapRef.current || !window.kakao) return + + const kakao = window.kakao + const map = mapRef.current + + clearMarkers() + closeInfoWindow() + + const bounds = new kakao.maps.LatLngBounds() + + places.forEach((place) => { + const position = new kakao.maps.LatLng(Number(place.y), Number(place.x)) + + const marker = new kakao.maps.Marker({ + position, + map, + }) + + // 마커 hover 이벤트 + kakao.maps.event.addListener(marker, 'mouseover', () => { + openInfoWindow(marker, place.place_name) + }) + kakao.maps.event.addListener(marker, 'mouseout', () => { + closeInfoWindow() + }) + + markersRef.current.push(marker) + bounds.extend(position) + }) + + // 마커들이 모두 보이도록 bounds 조정 + if (places.length > 0) { + map.setBounds(bounds) + } + } + + // 특정 좌표로 지도 중심 이동 + const setCenter = (lat: number, lng: number) => { + if (!mapRef.current || !window.kakao) return + const kakao = window.kakao + const position = new kakao.maps.LatLng(lat, lng) + mapRef.current.setCenter(position) + } + + return { + mapElement: setMapElement, + isInitialized, + initializeMap, + renderMarkers, + closeInfoWindow, + openInfoWindow, + setCenter, + cleanup, + } +} diff --git a/src/features/meetings/hooks/useKakaoPlaceSearch.ts b/src/features/meetings/hooks/useKakaoPlaceSearch.ts new file mode 100644 index 0000000..e9829c8 --- /dev/null +++ b/src/features/meetings/hooks/useKakaoPlaceSearch.ts @@ -0,0 +1,68 @@ +/** + * @file useKakaoPlaceSearch.ts + * @description Kakao Places API 검색 로직 훅 + */ + +import { useRef, useState } from 'react' + +import type { KakaoPlace } from '../kakaoMap.types' + +export type UseKakaoPlaceSearchOptions = { + /** 검색 성공 콜백 */ + onSearchSuccess?: (places: KakaoPlace[]) => void +} + +export function useKakaoPlaceSearch({ onSearchSuccess }: UseKakaoPlaceSearchOptions = {}) { + const [places, setPlaces] = useState([]) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const placesServiceRef = useRef(null) + + // 검색 실행 + const search = (searchKeyword: string) => { + if (!searchKeyword.trim()) { + return false + } + + const kakao = window.kakao + if (!kakao?.maps?.services) { + return false + } + + // Places 서비스 + if (!placesServiceRef.current) { + placesServiceRef.current = new kakao.maps.services.Places() + } + + const ps = placesServiceRef.current + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ps.keywordSearch(searchKeyword, (data: KakaoPlace[], status: any) => { + if (status === kakao.maps.services.Status.OK) { + setPlaces(data) + onSearchSuccess?.(data) + } else if (status === kakao.maps.services.Status.ZERO_RESULT) { + setPlaces([]) + onSearchSuccess?.([]) + } else { + setPlaces([]) + onSearchSuccess?.([]) + alert('검색 중 오류가 발생했습니다.') + } + }) + + return true + } + + // 검색 상태 초기화 + const reset = () => { + setPlaces([]) + placesServiceRef.current = null + } + + return { + places, + search, + reset, + } +} diff --git a/src/features/meetings/hooks/useMeetingForm.ts b/src/features/meetings/hooks/useMeetingForm.ts new file mode 100644 index 0000000..fcc8b10 --- /dev/null +++ b/src/features/meetings/hooks/useMeetingForm.ts @@ -0,0 +1,273 @@ +import { useMemo, useRef, useState } from 'react' + +import { + combineDateAndTime, + formatScheduleRange, + generateTimeOptions, + isStartBeforeEnd, +} from '@/features/meetings' + +type UseMeetingFormParams = { + gatheringMaxCount: number +} + +type ValidationErrors = { + bookId?: string | null + schedule?: string | null + maxParticipants?: string | null + 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) + + // 유효성 검사 에러 상태 + const [errors, setErrors] = useState(null) + + // 각 필드의 ref (포커스 이동용) + const bookButtonRef = useRef(null) + const startDateRef = useRef(null) + const endDateRef = useRef(null) + const maxParticipantsRef = useRef(null) + + // 시간 옵션 메모이제이션 (렌더링마다 재생성 방지) + const timeOptions = useMemo(() => generateTimeOptions(), []) + + const validateForm = (): boolean => { + const newError: ValidationErrors = {} + + if (!bookId || !bookName) { + newError.bookId = '* 도서를 선택해주세요.' + } + + if (!startDate || !startTime || !endDate || !endTime) { + newError.schedule = '* 일정을 선택해주세요.' + } else { + // 시작/종료 일시 비교 (둘 다 있을 때만) + const startDateTime = combineDateAndTime(startDate, startTime) + const endDateTime = combineDateAndTime(endDate, endTime) + + if (!isStartBeforeEnd(startDateTime, endDateTime)) { + newError.schedule = '* 종료 일정은 시작 일정보다 늦어야 합니다.' + } + } + + if (maxParticipants) { + const participants = Number(maxParticipants) + if (isNaN(participants) || participants < 1 || participants > gatheringMaxCount) { + newError.maxParticipants = `현재 모임의 전체 멤버 수는 ${gatheringMaxCount}명이에요. 최대 ${gatheringMaxCount}명까지 참가 가능해요.` + } + } + + // 장소 검증: 4개 필드가 모두 있거나 모두 없어야 함 + const locationFields = [ + locationName !== null, + locationAddress !== null, + latitude !== null, + longitude !== null, + ] + const filledCount = locationFields.filter(Boolean).length + + if (filledCount > 0 && filledCount < 4) { + newError.location = '장소를 재등록 해주세요' + } + + setErrors(newError) + + // 첫 번째 에러 필드로 이동 + if (Object.keys(newError).length > 0) { + if (newError.bookId) { + bookButtonRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } else if (newError.schedule) { + startDateRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } else if (newError.maxParticipants) { + maxParticipantsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + return false + } + + 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) + } + } + + /** + * 종료 날짜 비활성화 조건 + * 시작 날짜보다 이전 날짜는 선택 불가 + */ + const getEndDateDisabled = () => { + if (!startDate || !startTime) return undefined + return { before: startDate } + } + + /** + * 종료 시간 옵션 필터링 + * 같은 날짜인 경우 시작 시간 이후만 선택 가능 + */ + const getEndTimeOptions = () => { + if (!startDate || !endDate || !startTime) return timeOptions + + // 같은 날짜인 경우 + if (startDate.toDateString() === endDate.toDateString()) { + return timeOptions.filter((option) => option.value > startTime) + } + + return timeOptions + } + + /** + * 시작 날짜 비활성화 조건 + * 오늘은 불가, 내일부터 선택 가능 + */ + const getStartDateDisabled = () => { + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + tomorrow.setHours(0, 0, 0, 0) + + return { before: tomorrow } + } + + /** + * 시작 날짜 변경 (에러 초기화 포함) + */ + 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 formattedSchedule = formatScheduleRange(startDate, startTime, endDate, endTime) + + return { + // 폼 데이터 + formData: { + meetingName, + bookId, + bookName, + locationName, + locationAddress, + latitude, + longitude, + maxParticipants, + startDate, + startTime, + endDate, + endTime, + }, + // 시간 옵션 + timeOptions, + // 유효성 검사 + validateForm, + errors, + // 날짜/시간 제약 + getStartDateDisabled, + getEndDateDisabled, + getEndTimeOptions, + // 포맷된 일정 + formattedSchedule, + // Ref + refs: { + bookButtonRef, + startDateRef, + endDateRef, + maxParticipantsRef, + }, + // 상태 업데이트 핸들러 + handlers: { + setMeetingName, + setBookId, + setBookName, + setMaxParticipants: handleMaxParticipantsChange, + setStartDate: handleStartDateChange, + setStartTime: handleStartTimeChange, + setEndDate: handleEndDateChange, + setEndTime: handleEndTimeChange, + setLocationAddress, + setLocationName, + setLatitude, + setLongitude, + }, + } +} diff --git a/src/features/meetings/index.ts b/src/features/meetings/index.ts index 5f5ab81..a1214e4 100644 --- a/src/features/meetings/index.ts +++ b/src/features/meetings/index.ts @@ -13,6 +13,8 @@ export * from './meetings.api' // Types export type { ConfirmMeetingResponse, + CreateMeetingRequest, + CreateMeetingResponse, GetMeetingApprovalsParams, MeetingApprovalItem as MeetingApprovalItemType, MeetingStatus, diff --git a/src/features/meetings/kakaoMap.types.ts b/src/features/meetings/kakaoMap.types.ts new file mode 100644 index 0000000..2f13f71 --- /dev/null +++ b/src/features/meetings/kakaoMap.types.ts @@ -0,0 +1,90 @@ +/** + * @file kakao.types.ts + * @description 카카오 로컬 API 관련 타입 정의 + * @note 외부 API 응답 스펙을 따르기 위해 snake_case 사용 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * 카카오 장소 검색 응답 문서 타입 + */ +export type KakaoPlace = { + /** 장소명, 업체명 */ + place_name: string + /** 전체 지번 주소 */ + address_name: string + /** 전체 도로명 주소 */ + road_address_name: string + /** X 좌표값, 경도(longitude) */ + x: string + /** Y 좌표값, 위도(latitude) */ + y: string + /** 장소 ID */ + id: string + /** 카테고리 그룹 코드 */ + category_group_code: string + /** 카테고리 그룹명 */ + category_group_name: string + /** 카테고리 이름 */ + category_name: string + /** 전화번호 */ + phone: string + /** 장소 상세페이지 URL */ + place_url: string + /** 중심좌표까지의 거리 (단, x,y 파라미터를 준 경우에만 존재) */ + distance?: string +} + +/** + * 카카오 장소 검색 API 응답 메타 정보 + */ +export type KakaoSearchMeta = { + /** 검색된 문서 수 */ + total_count: number + /** total_count 중 노출 가능 문서 수 */ + pageable_count: number + /** 현재 페이지가 마지막 페이지인지 여부 */ + is_end: boolean + /** 질의어의 지역 및 키워드 분석 정보 */ + same_name?: { + /** 질의어에서 인식된 지역의 리스트 */ + region: string[] + /** 질의어에서 지역 정보를 제외한 키워드 */ + keyword: string + /** 인식된 지역 리스트 중, 현재 검색에 사용된 지역 정보 */ + selected_region: string + } +} + +/** + * 카카오 장소 검색 API 응답 타입 + */ +export type KakaoSearchResponse = { + /** 검색 결과 문서 리스트 */ + documents: KakaoPlace[] + /** 응답 관련 정보 */ + meta: KakaoSearchMeta +} + +/** + * 카카오 장소 검색 API 요청 파라미터 + */ +export type KakaoSearchParams = { + /** 검색을 원하는 질의어 (필수) */ + query: string + /** 카테고리 그룹 코드 (선택) */ + category_group_code?: string + /** 중심 좌표의 X 혹은 경도(longitude) */ + x?: string + /** 중심 좌표의 Y 혹은 위도(latitude) */ + y?: string + /** 중심 좌표부터의 반경거리. 미터(m) 단위 */ + radius?: number + /** 결과 페이지 번호 (1~45, 기본값: 1) */ + page?: number + /** 한 페이지에 보여질 문서의 개수 (1~15, 기본값: 15) */ + size?: number + /** 결과 정렬 순서 (distance: 거리순, accuracy: 정확도순) */ + sort?: 'distance' | 'accuracy' +} diff --git a/src/features/meetings/lib/index.ts b/src/features/meetings/lib/index.ts index 4f2cf1a..b76fb19 100644 --- a/src/features/meetings/lib/index.ts +++ b/src/features/meetings/lib/index.ts @@ -1 +1,9 @@ export { default as formatDateTime } from './formatDateTime' +export { + combineDateAndTime, + extractTime, + formatScheduleRange, + generateTimeOptions, + type TimeOption, +} from './timeUtils' +export * from './validation' diff --git a/src/features/meetings/lib/timeUtils.ts b/src/features/meetings/lib/timeUtils.ts new file mode 100644 index 0000000..2a919fe --- /dev/null +++ b/src/features/meetings/lib/timeUtils.ts @@ -0,0 +1,109 @@ +/** + * @file timeUtils.ts + * @description 약속 생성 시 시간 선택 유틸리티 함수 + */ + +import { format } from 'date-fns' +import { ko } from 'date-fns/locale' + +/** + * 시간 선택 옵션 타입 + */ +export type TimeOption = { + /** 표시할 시간 문자열 (HH:mm 형식) */ + label: string + /** 실제 값 (HH:mm 형식) */ + value: string +} + +/** + * 30분 단위 시간 목록 생성 + * + * @description + * 00:00부터 23:30까지 30분 단위로 시간 목록을 생성합니다. + * + * @returns 시간 선택 옵션 배열 + * + * @example + * const times = generateTimeOptions() + * // [ + * // { label: '00:00', value: '00:00' }, + * // { label: '00:30', value: '00:30' }, + * // ... + * // { label: '23:30', value: '23:30' } + * // ] + */ +export const generateTimeOptions = (): TimeOption[] => { + const options: TimeOption[] = [] + + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += 30) { + const hourStr = String(hour).padStart(2, '0') + const minuteStr = String(minute).padStart(2, '0') + const time = `${hourStr}:${minuteStr}` + + options.push({ + label: time, + value: time, + }) + } + } + + return options +} + +/** + * 날짜와 시간을 ISO 8601 형식으로 결합 + * + * @description + * 날짜(Date)와 시간(HH:mm)을 결합하여 ISO 8601 형식 문자열로 변환합니다. + * + * @example + * const dateTime = combineDateAndTime(new Date('2025-02-01'), '14:00') + * // → '2025-02-01T14:00:00' + */ +export const combineDateAndTime = (date: Date, time: string): string => { + return `${format(date, 'yyyy-MM-dd')}T${time}:00` +} + +/** + * ISO 8601 형식 문자열에서 시간 추출 + * + * @description + * ISO 8601 형식 문자열에서 시간(HH:mm)만 추출합니다. + * + * @example + * const time = extractTime('2025-02-01T14:00:00') + * // → '14:00' + */ +export const extractTime = (dateTime: string): string => { + if (!dateTime) return '' + + const timePart = dateTime.split('T')[1] + if (!timePart) return '' + + // 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/validation.ts b/src/features/meetings/lib/validation.ts new file mode 100644 index 0000000..2f3b86e --- /dev/null +++ b/src/features/meetings/lib/validation.ts @@ -0,0 +1,53 @@ +/** + * @file validation.ts + * @description 약속 생성 폼 유효성 검사 함수 + */ + +/** + * 시작 일시가 종료 일시보다 이전인지 검사 + * + * @param startDateTime - 시작 일시 (ISO 8601 형식) + * @param endDateTime - 종료 일시 (ISO 8601 형식) + * + * @returns 유효하면 true, 아니면 false + */ +export const isStartBeforeEnd = (startDateTime: string, endDateTime: string): boolean => { + if (!startDateTime || !endDateTime) return false + + const startDate = new Date(startDateTime) + const endDate = new Date(endDateTime) + + return startDate < endDate +} + +/** + * 날짜가 과거인지 검사 + * + * @param date - 검사할 날짜 + * + * @returns 과거 날짜이면 true, 아니면 false + */ +export const isPastDate = (date: Date): boolean => { + const today = new Date() + today.setHours(0, 0, 0, 0) + + const checkDate = new Date(date) + checkDate.setHours(0, 0, 0, 0) + + return checkDate < today +} + +/** + * 참가 인원 유효성 검사 + * + * @param maxParticipants - 최대 참가 인원 + * @param gatheringMaxCount - 모임 전체 최대 인원 + * + * @returns 유효하면 true, 아니면 false + */ +export const isValidParticipants = ( + maxParticipants: number, + gatheringMaxCount: number +): boolean => { + return maxParticipants >= 1 && maxParticipants <= gatheringMaxCount +} diff --git a/src/features/meetings/meetings.api.ts b/src/features/meetings/meetings.api.ts index 6840f7a..1252067 100644 --- a/src/features/meetings/meetings.api.ts +++ b/src/features/meetings/meetings.api.ts @@ -8,6 +8,8 @@ import type { ApiResponse, PaginatedResponse } from '@/api/types' import { getMockMeetingApprovals } from '@/features/meetings/meetings.mock' import type { ConfirmMeetingResponse, + CreateMeetingRequest, + CreateMeetingResponse, GetMeetingApprovalsParams, MeetingApprovalItem, RejectMeetingResponse, @@ -127,3 +129,24 @@ export const deleteMeeting = async (meetingId: number) => { const response = await apiClient.delete>(`/api/meetings/${meetingId}`) return response.data } + +/** + * 약속 생성 + * + * @description + * 새로운 약속을 생성합니다. + * 생성된 약속은 PENDING 상태로 시작되며, 모임장의 승인을 기다립니다. + * + * @param data - 약속 생성 요청 데이터 + * + * @returns 생성된 약속 정보 + * + * @throws + * - M013: 최대 참가 인원이 유효하지 않습니다. + * - GA001: 모임을 찾을 수 없습니다. + * - B001: 책을 찾을 수 없습니다. + */ +export const createMeeting = async (data: CreateMeetingRequest) => { + const response = await apiClient.post>('/api/meetings', data) + return response.data +} diff --git a/src/features/meetings/meetings.types.ts b/src/features/meetings/meetings.types.ts index 857fafe..2a99f6b 100644 --- a/src/features/meetings/meetings.types.ts +++ b/src/features/meetings/meetings.types.ts @@ -67,3 +67,70 @@ export type ConfirmMeetingResponse = { /** 확정 시각 (ISO 8601 형식) */ confirmedAt: string } + +/** + * 약속 생성 요청 타입 + */ +type MeetingLocation = { + name: string + address: string + latitude: number + longitude: number +} +export type CreateMeetingRequest = { + /** 모임 ID */ + gatheringId: number + /** 책 ID */ + bookId: number + /** 약속 이름 */ + meetingName: string + /** 약속 시작 일시 (ISO 8601 형식) */ + meetingStartDate: string + /** 약속 종료 일시 (ISO 8601 형식) */ + meetingEndDate: string + /** 최대 참가 인원 */ + maxParticipants: number + /** 장소 (선택 사항) */ + location: MeetingLocation | null +} + +/** + * 약속 생성 응답 타입 + */ +export type CreateMeetingResponse = { + /** 약속 ID */ + meetingId: number + /** 약속 이름 */ + meetingName: string + /** 약속 상태 */ + meetingStatus: MeetingStatus + /** 모임 정보 */ + gathering: { + gatheringId: number + gatheringName: string + } + /** 책 정보 */ + book: { + bookId: number + bookName: string + } + /** 일정 정보 */ + schedule: { + date: string + time: string + startDateTime: string + endDateTime: string + } | null + /** 장소 */ + location: MeetingLocation | null + /** 참가자 정보 */ + participants: { + currentCount: number + maxCount: number + members: Array<{ + userId: number + nickname: string + profileImageUrl: string + }> + } +} diff --git a/src/pages/Meetings/MeetingCreatePage.tsx b/src/pages/Meetings/MeetingCreatePage.tsx new file mode 100644 index 0000000..633350c --- /dev/null +++ b/src/pages/Meetings/MeetingCreatePage.tsx @@ -0,0 +1,308 @@ +import { ChevronLeft, Search } from 'lucide-react' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { + combineDateAndTime, + type CreateMeetingRequest, + PlaceSearchModal, + useCreateMeeting, + useMeetingForm, +} from '@/features/meetings' +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 createMutation = useCreateMeeting() + const [isPlaceSearchOpen, setIsPlaceSearchOpen] = useState(false) + + // 임시: 모임 정보 (실제로는 API에서 가져와야 함) + const gatheringId = 1 // TODO: 실제 모임 ID로 교체 + const gatheringMaxCount = 16 // TODO: API에서 가져오기 + + // 폼 로직 및 유효성 검사 (커스텀 훅으로 분리) + const { + formData, + timeOptions, + validateForm, + errors, + getStartDateDisabled, + getEndDateDisabled, + getEndTimeOptions, + formattedSchedule, + refs, + handlers, + } = useMeetingForm({ gatheringMaxCount }) + + const { + meetingName, + bookId, + bookName, + maxParticipants, + startDate, + startTime, + endDate, + endTime, + locationName, + locationAddress, + latitude, + longitude, + } = formData + + //setBookId, setBookName + const { + setMeetingName, + setMaxParticipants, + setStartDate, + setStartTime, + setEndDate, + setEndTime, + setLocationAddress, + setLocationName, + setLatitude, + setLongitude, + } = handlers + + const { bookButtonRef, startDateRef, endDateRef, maxParticipantsRef } = refs + + // 제출 핸들러 + const handleSubmit = async () => { + //유효성 검사 + const isValid = validateForm() + if (!isValid) { + return + } + + // validation 통과 후 필수 값 타입 가드 + if (!bookId || !bookName || !startDate || !startTime || !endDate || !endTime) { + return + } + + const startDateTime = combineDateAndTime(startDate, startTime) + const endDateTime = combineDateAndTime(endDate, endTime) + + const trimmedMeetingName = meetingName?.trim() || null + + const requestData: CreateMeetingRequest = { + gatheringId, + bookId, + meetingName: trimmedMeetingName || bookName, // 약속명이 없으면 책 이름 사용 + meetingStartDate: startDateTime, + meetingEndDate: endDateTime, + maxParticipants: maxParticipants ? Number(maxParticipants) : gatheringMaxCount, + location: + locationName && locationAddress && latitude !== null && longitude !== null + ? { name: locationName, address: locationAddress, latitude, longitude } + : null, + } + + createMutation.mutate(requestData, { + onSuccess: async () => { + await openAlert('약속 생성 완료', '약속이 성공적으로 생성되었습니다.') + navigate('/') //이동경로 수정해야 함 + }, + onError: (error) => { + openError('약속 생성 실패', error.userMessage) + }, + }) + } + + return ( + <> + {/* 공통컴포넌트로 교체 예정 */} +
+

+ 뒤로가기 +

+
+

약속 만들기

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

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

+
+ + + 약속명 + + setMeetingName(e.target.value)} + /> + + + + + + 도서 + + + + {errors?.bookId && ( +

{errors.bookId}

+ )} +
+
+ + + 장소 + + {locationAddress && ( + +

{locationName}

+

{locationAddress}

+
+ )} + + + {errors?.location && ( +

{errors.location}

+ )} +
+
+ + { + setLocationName(place.name) + setLocationAddress(place.address) + setLatitude(place.latitude) + setLongitude(place.longitude) + }} + /> + + + + 날짜 및 시간 + + +
+
+
+ 시작 일정 +
+
+
+ 종료 일정 +
+
+
+
+ + +
+ ~ + 종료 일정 +
+ + +
+
+
+ {/* 시작일정, 종료일정 선택 완료되면 노출*/} + {formattedSchedule && ( + +

선택된 일정

+

{formattedSchedule}

+
+ )} + {errors?.schedule && ( +

{errors.schedule}

+ )} + + + + + 참가 인원 + + setMaxParticipants(e.target.value)} + min={1} + max={gatheringMaxCount} + /> + + +
+ + ) +} diff --git a/src/pages/Meetings/index.ts b/src/pages/Meetings/index.ts index 56e962a..905ef2d 100644 --- a/src/pages/Meetings/index.ts +++ b/src/pages/Meetings/index.ts @@ -1,2 +1,3 @@ +export { default as MeetingCreatePage } from './MeetingCreatePage' export { default as MeetingListPage } from './MeetingListPage' export { default as MeetingSettingPage } from './MeetingSettingPage' diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 38ea1ba..193ad3a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -10,6 +10,7 @@ import { HomePage, InvitePage, LoginPage, + MeetingCreatePage, MeetingListPage, MeetingSettingPage, OnboardingPage, @@ -95,9 +96,13 @@ export const router = createBrowserRouter([ element: , }, { - path: `${ROUTES.GATHERINGS}/:id/${ROUTES.MEETING_SETTING}`, + path: `${ROUTES.GATHERINGS}/:id${ROUTES.MEETING_SETTING}`, element: , }, + { + path: `${ROUTES.GATHERINGS}/:id${ROUTES.MEETING_CREATE}`, + element: , + }, { path: ROUTES.RECORDS, element: , diff --git a/src/shared/lib/utils.ts b/src/shared/lib/utils.ts index 35b5bc0..113354b 100644 --- a/src/shared/lib/utils.ts +++ b/src/shared/lib/utils.ts @@ -3,11 +3,51 @@ import { extendTailwindMerge } from 'tailwind-merge' const customTwMerge = extendTailwindMerge({ extend: { - classGroups: { - // 예시 - // 'font-size': [ - // 'text-heading1', - // ], + theme: { + spacing: [ + 'xxtiny', + 'xtiny', + 'tiny', + 'xsmall', + 'small', + 'base', + 'medium', + 'large', + 'xlarge', + 'layout-max', + 'layout-padding', + 'gnb-height', + ], + color: [ + 'black', + 'white', + 'grey-900', + 'grey-800', + 'grey-700', + 'grey-600', + 'grey-500', + 'grey-400', + 'grey-300', + 'grey-200', + 'grey-100', + 'dark-100', + 'primary-400', + 'primary-300', + 'primary-200', + 'primary-150', + 'primary-100', + 'accent-300', + 'accent-200', + 'accent-100', + 'yellow-300', + 'yellow-200', + 'yellow-100', + 'blue-200', + 'blue-100', + 'purple-200', + 'purple-100', + ], + radius: ['tiny', 'small', 'base', 'medium'], }, }, }) diff --git a/src/shared/styles/base.css b/src/shared/styles/base.css index 0e254e3..6cac366 100644 --- a/src/shared/styles/base.css +++ b/src/shared/styles/base.css @@ -11,4 +11,11 @@ html { letter-spacing: var(--tracking-tight-2); } + input { + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } } diff --git a/src/shared/ui/Container.tsx b/src/shared/ui/Container.tsx index dafd54e..056a941 100644 --- a/src/shared/ui/Container.tsx +++ b/src/shared/ui/Container.tsx @@ -1,3 +1,6 @@ +import { Info } from 'lucide-react' +import * as React from 'react' + import { cn } from '@/shared/lib/utils' type ContainerProps = { @@ -8,6 +11,8 @@ type ContainerProps = { type TitleProps = { className?: string children: string + required?: boolean + errorMessage?: string } type ContentProps = { @@ -15,9 +20,13 @@ type ContentProps = { children?: React.ReactNode } -function Container({ className, children }: ContainerProps) { +const Container = React.forwardRef(function Container( + { className, children }, + ref +) { return (
) -} +}) -function Title({ className, children }: TitleProps) { - return

{children}

+function Title({ className, children, required, errorMessage }: TitleProps) { + return ( +
+
+

+ {children} + {required && ( + + )} +

+
+ {errorMessage && ( + + {errorMessage} + + )} +
+ ) } function Content({ className, children }: ContentProps) { @@ -44,11 +74,12 @@ function Content({ className, children }: ContentProps) { * @features * - 기본 스타일: `rounded-base`, `p-large`, `shadow-drop`, `flex-col gap-medium` * - `className`을 통해 스타일 확장 가능 + * - Title에 `errorMessage` 프롭을 전달하면 타이틀 옆에 에러 메시지 표시 * * @example * ```tsx * - * 컨테이너 타이틀 + * 컨테이너 타이틀 * 내용이 들어갑니다 * * ``` diff --git a/src/shared/ui/Datepicker.tsx b/src/shared/ui/Datepicker.tsx index eb7876c..a9a1200 100644 --- a/src/shared/ui/Datepicker.tsx +++ b/src/shared/ui/Datepicker.tsx @@ -1,6 +1,7 @@ import { format } from 'date-fns' import { CalendarDays } from 'lucide-react' import * as React from 'react' +import type { Matcher } from 'react-day-picker' import { cn } from '@/shared/lib/utils' import { Calendar } from '@/shared/ui/Calendar' @@ -11,6 +12,8 @@ type DatePickerProps = { onChange: (date: Date | null) => void placeholder?: string className?: string + disabled?: Matcher | Matcher[] + isDisabled?: boolean } /** @@ -27,7 +30,10 @@ type DatePickerProps = { * ``` */ -function DatePicker({ value, onChange, placeholder = '날짜 선택', className }: DatePickerProps) { +const DatePicker = React.forwardRef(function DatePicker( + { value, onChange, placeholder = '날짜 선택', className, disabled, isDisabled = false }, + ref +) { const [date, setDate] = React.useState(value) React.useEffect(() => { @@ -43,26 +49,34 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', className - + ) -} +}) export { DatePicker } diff --git a/src/shared/ui/Input.tsx b/src/shared/ui/Input.tsx index d6521cc..21ecc60 100644 --- a/src/shared/ui/Input.tsx +++ b/src/shared/ui/Input.tsx @@ -42,18 +42,10 @@ type InputProps = React.ComponentProps<'input'> & { * * ``` */ -function Input({ - className, - type, - label, - error, - errorMessage, - helperText, - maxLength, - disabled, - value, - ...props -}: InputProps) { +const Input = React.forwardRef(function Input( + { className, type, label, error, errorMessage, helperText, maxLength, disabled, value, ...props }, + ref +) { const currentLength = typeof value === 'string' ? value.length : 0 const showCount = maxLength !== undefined const showFooter = error || helperText || showCount @@ -62,6 +54,7 @@ function Input({
{label &&

{label}

} ) -} +}) export { Input } diff --git a/src/shared/ui/Select.tsx b/src/shared/ui/Select.tsx index c4b06e1..6e05635 100644 --- a/src/shared/ui/Select.tsx +++ b/src/shared/ui/Select.tsx @@ -38,22 +38,30 @@ function SelectTrigger({ label, ...props }: React.ComponentProps & { label?: string }) { + const trigger = ( + + {children} + + + + + ) + + if (!label) { + return trigger + } + return (
- {label &&

{label}

} - - {children} - - - - +

{label}

+ {trigger}
) } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index e2d7f08..589d602 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly VITE_API_URL: string readonly VITE_APP_URL: string + readonly VITE_KAKAO_MAP_KEY: string } interface ImportMeta {