diff --git a/src/components/home/context/SearchContext.tsx b/src/components/home/context/SearchContext.tsx index eea77e4..1d22ead 100644 --- a/src/components/home/context/SearchContext.tsx +++ b/src/components/home/context/SearchContext.tsx @@ -1,7 +1,13 @@ import type { ReactNode } from 'react'; import { createContext, useContext, useState } from 'react'; -type ModalType = 'location' | 'calendar' | 'guests' | null; +type ModalType = + | 'location' + | 'calendar' + | 'guests' + | 'roomCalendar' + | 'roomGuests' + | null; type Location = { sido: string; diff --git a/src/components/roomdetail/HeartModal.tsx b/src/components/roomdetail/HeartModal.tsx index 00fed9b..cb586e6 100644 --- a/src/components/roomdetail/HeartModal.tsx +++ b/src/components/roomdetail/HeartModal.tsx @@ -44,10 +44,7 @@ const ShareModal = ({ onClose }: HeartModalProps) => {
{mockHeart.map((list) => ( -
+

{list.name}

diff --git a/src/components/roomdetail/Info.tsx b/src/components/roomdetail/Info.tsx index 2e06c14..3374bda 100644 --- a/src/components/roomdetail/Info.tsx +++ b/src/components/roomdetail/Info.tsx @@ -93,44 +93,29 @@ const Info = ({ data }: InfoProps) => { )}

- +
{issuperhost ? '슈퍼호스트' : '훌륭한 호스트'}
- +
{isluggage ? '여행 가방 보관 가능' : '여행 가방 보관 풀가'}
- +
{ischeckin ? '셀프체크인' : '편의성이 뛰어난 체크인 절차'}
- +
{istv ? 'TV' : 'TV 없음'}
- +
{iswifi ? '와이파이' : '와이파이 없음'}
diff --git a/src/components/roomdetail/Reservation.tsx b/src/components/roomdetail/Reservation.tsx index 232ef0d..fce6b67 100644 --- a/src/components/roomdetail/Reservation.tsx +++ b/src/components/roomdetail/Reservation.tsx @@ -3,8 +3,8 @@ import { useState } from 'react'; import BaseModal from '@/components/common/Modal/BaseModal'; import { useSearch } from '@/components/home/context/SearchContext'; -import CalendarModal from '@/components/home/Topbar/Search/modals/CalendarModal'; -import GuestsModal from '@/components/home/Topbar/Search/modals/GuestsModal'; +import RoomCalendarModal from './roomCalendarModal'; +import RoomGuestsModal from '@/components/roomdetail/RoomGuestsModal'; import clock from '@/assets/icons/reservation/clock.svg'; import type { roomType } from '@/types/roomType'; @@ -13,8 +13,8 @@ interface InfoProps { } type roomReservationResponseType = { - reservationId: number -} + reservationId: number; +}; const Reservation = ({ data }: InfoProps) => { const [isLoading, setIsLoading] = useState(false); @@ -31,8 +31,13 @@ const Reservation = ({ data }: InfoProps) => { if (token == null) { throw new Error('로그인이 필요합니다.'); } - console.debug(data) - if (data.roomId === 0 || checkIn == null || checkOut == null || guests <= 0) { + console.debug(data); + if ( + data.roomId === 0 || + checkIn == null || + checkOut == null || + guests <= 0 + ) { throw new Error('모든 필드를 입력해주세요.'); } @@ -57,8 +62,11 @@ const Reservation = ({ data }: InfoProps) => { if (!response.ok) { throw new Error('숙소 예약에 실패했습니다.'); } - const responseData = (await response.json()) as roomReservationResponseType; - alert(`숙소가 성공적으로 예약되었습니다! ID: ${responseData.reservationId}`); + const responseData = + (await response.json()) as roomReservationResponseType; + alert( + `숙소가 성공적으로 예약되었습니다! ID: ${responseData.reservationId}`, + ); } catch (err) { const errorMessage = err instanceof Error ? err.message : '오류가 발생했습니다.'; @@ -77,7 +85,7 @@ const Reservation = ({ data }: InfoProps) => {
- + - + ); diff --git a/src/components/roomdetail/ReviewModal.tsx b/src/components/roomdetail/ReviewModal.tsx index 5ecec00..5c4eba0 100644 --- a/src/components/roomdetail/ReviewModal.tsx +++ b/src/components/roomdetail/ReviewModal.tsx @@ -4,7 +4,7 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import { useEffect, useState } from 'react'; import { CheckinIcon } from '@/components/common/constants/icons'; -import accuracy from '@/assets/icons/reviews/clean.svg'; +import accuracy from '@/assets/icons/reviews/accuracy.svg'; import clean from '@/assets/icons/reviews/clean.svg'; import type { ReviewsResponse } from '@/types/reviewType'; import type { roomType } from '@/types/roomType'; @@ -161,7 +161,9 @@ const ReviewModal = ({ onClose, data }: ReviewProps) => {
후기{' '} - {reviewData?.content.length} + + {reviewData?.content.length} +

{review.content}

@@ -217,14 +221,17 @@ const ReviewModal = ({ onClose, data }: ReviewProps) => {
- {error !== null && -
에러: {error}
- } - {isLoading && -
서버에서 데이터를 가져오는 중...
- } + {error !== null && ( +
+ 에러: {error} +
+ )} + {isLoading && ( +
+ 서버에서 데이터를 가져오는 중... +
+ )} - ); }; diff --git a/src/components/roomdetail/RoomGuestsModal.tsx b/src/components/roomdetail/RoomGuestsModal.tsx new file mode 100644 index 0000000..ad2fcbd --- /dev/null +++ b/src/components/roomdetail/RoomGuestsModal.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; + +import { useSearch } from '@/components/home/context/SearchContext'; +import ErrorIcon from '@mui/icons-material/Error'; + +type GuestsModalProps = { + onClose: () => void; + maxOccupancy: number; +}; + +const GuestsModal = ({ onClose, maxOccupancy }: GuestsModalProps) => { + const { setGuests } = useSearch(); + const [guestCount, setGuestCount] = useState(0); + + const handleIncrement = () => { + setGuestCount((prev) => prev + 1); + }; + + const handleDecrement = () => { + if (guestCount > 0) { + setGuestCount((prev) => prev - 1); + } + }; + + const handleSave = () => { + setGuests(guestCount); + onClose(); + }; + + return ( +
+
+
+

게스트

+

숙박 인원을 선택해주세요

+
+
+ + {guestCount} + +
+
+ +
+ + + {maxOccupancy < guestCount && ( +
+ +
+ 게스트 인원이 최대 인원을 초과했습니다 +
+
+ )} +
+
+ ); +}; + +export default GuestsModal; diff --git a/src/components/roomdetail/roomCalendarModal.tsx b/src/components/roomdetail/roomCalendarModal.tsx new file mode 100644 index 0000000..ba979e4 --- /dev/null +++ b/src/components/roomdetail/roomCalendarModal.tsx @@ -0,0 +1,245 @@ +import dayjs from 'dayjs'; +import { useEffect, useState } from 'react'; + +import { useSearch } from '@/components/home/context/SearchContext'; + +type AvailabilityResponse = { + availableDates: string[]; + unavailableDates: string[]; +}; // 간단하므로 따로 type 파일 안만듦 + +type CalendarModalProps = { + onClose: () => void; + id: number; +}; + +const roomCalendarModal = ({ onClose, id }: CalendarModalProps) => { + const { checkIn, checkOut, setCheckIn, setCheckOut } = useSearch(); + const [currentMonth, setCurrentMonth] = useState(dayjs()); + const [selecting, setSelecting] = useState<'checkIn' | 'checkOut'>('checkIn'); + const [availableDates, setAvailableDates] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const currentYear: number = currentMonth.year(); + + // 다음/이전 달로 이동 + const goToNextMonth = () => { + setCurrentMonth(currentMonth.add(1, 'month')); + }; + const goToPrevMonth = () => { + setCurrentMonth(currentMonth.subtract(1, 'month')); + }; + + const fetchAvailableDates = async () => { + try { + setIsLoading(true); + setError(null); + + if (id === undefined) { + throw new Error('존재하지 않는 숙소입니다.'); + } + + const url = `/api/v1/reservations/availability/${id}?year=${currentYear}&month=${currentMonth.month() + 1}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('숙소 예약 가능 날짜 로딩에 실패했습니다.'); + } + + const responseData = (await response.json()) as AvailabilityResponse; + console.debug(responseData); + setAvailableDates(responseData.availableDates); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : '오류가 발생했습니다.'; + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void fetchAvailableDates(); + }, []); + + // 날짜가 선택 가능한지 확인 + const isDateSelectable = (date: dayjs.Dayjs) => { + const today = dayjs().startOf('day'); + const isBeforeToday = date.isBefore(today); + const isAvailable = availableDates.includes(date.format('YYYY-MM-DD')); + + // 체크아웃 선택 중이고 체크인 날짜가 있는 경우 + if (selecting === 'checkOut' && checkIn !== null) { + const checkInDate = dayjs(checkIn); // Date를 dayjs 객체로 변환 + const isBeforeCheckIn = date.isBefore(checkInDate.add(1, 'day'), 'day'); + return !isBeforeToday && !isBeforeCheckIn && isAvailable; + } //리팩토링: 체크인, 체크아웃 범위에 예약 불가한 날짜가 있는 경우도 제외해야 함 + + return !isBeforeToday && isAvailable; + }; + + // 날짜 선택 핸들러 + const handleDateSelect = (date: dayjs.Dayjs) => { + if (!isDateSelectable(date)) return; + + if (selecting === 'checkIn') { + setCheckIn(date.toDate()); + setSelecting('checkOut'); + } else { + setCheckOut(date.toDate()); + onClose(); + } + }; + + // 캘린더 생성 함수 + const renderCalendar = (monthDate: dayjs.Dayjs) => { + const daysInMonth = monthDate.daysInMonth(); + const firstDayOfMonth = monthDate.startOf('month').day(); + const weeks = []; + let days = []; + + // 첫 주의 빈 날짜 채우기 + for (let i = 0; i < firstDayOfMonth; i++) { + days.push(); + } + + // 날짜 채우기 + for (let day = 1; day <= daysInMonth; day++) { + const date = monthDate.date(day); + const isSelectable = isDateSelectable(date); + const isSelected = (currentDate: dayjs.Dayjs) => { + const checkInDate = checkIn !== null ? dayjs(checkIn) : null; + const checkOutDate = checkOut !== null ? dayjs(checkOut) : null; + + return ( + (checkInDate !== null && checkInDate.isSame(currentDate, 'day')) || + (checkOutDate !== null && checkOutDate.isSame(currentDate, 'day')) + ); + }; + const isInRange = + checkIn !== null && + checkOut !== null && + date.isAfter(checkIn) && + date.isBefore(checkOut); + + days.push( + + + , + ); + + if (days.length === 7) { + weeks.push({days}); + days = []; + } + } + + if (days.length > 0) { + weeks.push({days}); + } + + return weeks; + }; + + return ( +
+ {isLoading && ( +
+ 숙소 예약 가능 날짜 로딩중... +
+ )} + {error && ( +
+ 에러: 숙소 예약 가능 날짜를 불러올 수 없습니다 +
+ )} +
+ +
+ {/* 현재 달 */} +
+

+ {currentMonth.format('YYYY년 M월')} +

+ + + + {['일', '월', '화', '수', '목', '금', '토'].map((day) => ( + + ))} + + + {renderCalendar(currentMonth)} +
+ {day} +
+
+ + {/* 다음 달 */} +
+

+ {currentMonth.add(1, 'month').format('YYYY년 M월')} +

+ + + + {['일', '월', '화', '수', '목', '금', '토'].map((day) => ( + + ))} + + + + {renderCalendar(currentMonth.add(1, 'month'))} + +
+ {day} +
+
+
+ +
+ +
+ {selecting === 'checkIn' + ? '체크인 날짜를 선택해주세요.' + : '체크아웃 날짜를 선택해주세요.'} +
+
+ ); +}; + +export default roomCalendarModal; diff --git a/src/routes/roomDetail.tsx b/src/routes/roomDetail.tsx index 000b238..30f920c 100644 --- a/src/routes/roomDetail.tsx +++ b/src/routes/roomDetail.tsx @@ -2,7 +2,7 @@ import { PhotoSizeSelectActual as PhotoSizeSelectActualIcon } from '@mui/icons-m import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import gallery from '@/assets/icons/roomdetail/gallery.svg' +import gallery from '@/assets/icons/roomdetail/gallery.svg'; import Topbar from '@/components/home/Topbar'; import Info from '@/components/roomdetail/Info'; import PhotoModal from '@/components/roomdetail/PhotoModal'; diff --git a/src/types/reviewType.ts b/src/types/reviewType.ts index dc8e709..b731427 100644 --- a/src/types/reviewType.ts +++ b/src/types/reviewType.ts @@ -5,7 +5,7 @@ interface Review { content: string; rating: number; startDate: string; // ISO 8601 날짜 형식 (예: "2025-01-01") - endDate: string; // ISO 8601 날짜 형식 (예: "2025-01-02") + endDate: string; // ISO 8601 날짜 형식 (예: "2025-01-02") } interface Sort { @@ -24,15 +24,15 @@ interface Pageable { } export interface ReviewsResponse { - content: Review[]; // 리뷰 항목 - pageable: Pageable; // 페이징 정보 - totalElements: number; // 전체 리뷰 수 - totalPages: number; // 전체 페이지 수 - last: boolean; // 마지막 페이지 여부 - size: number; // 한 페이지의 리뷰 수 - number: number; // 현재 페이지 번호 - sort: Sort; // 정렬 정보 + content: Review[]; // 리뷰 항목 + pageable: Pageable; // 페이징 정보 + totalElements: number; // 전체 리뷰 수 + totalPages: number; // 전체 페이지 수 + last: boolean; // 마지막 페이지 여부 + size: number; // 한 페이지의 리뷰 수 + number: number; // 현재 페이지 번호 + sort: Sort; // 정렬 정보 numberOfElements: number; // 현재 페이지의 리뷰 개수 - first: boolean; // 첫 번째 페이지 여부 - empty: boolean; // 페이지 내용이 비어 있는지 여부 + first: boolean; // 첫 번째 페이지 여부 + empty: boolean; // 페이지 내용이 비어 있는지 여부 }