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) => {
{
- openModal('calendar');
+ openModal('roomCalendar');
}}
className="flex flex-col items-start mt-1 w-full border border-red-700 rounded-md py-2 px-3 text-gray-700 bg-white cursor-pointer"
>
@@ -92,7 +100,7 @@ const Reservation = ({ data }: InfoProps) => {
{
- openModal('calendar');
+ openModal('roomCalendar');
}}
className="flex flex-col items-start mt-1 w-full border border-red-700 rounded-md py-2 px-3 text-gray-700 bg-white cursor-pointer"
>
@@ -110,7 +118,7 @@ const Reservation = ({ data }: InfoProps) => {
{
- openModal('guests');
+ openModal('roomGuests');
}}
className="flex flex-col items-start mt-1 w-full border border-gray-300 rounded-md py-2 px-3 bg-white cursor-pointer text-gray-700"
>
@@ -165,18 +173,21 @@ 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.nickname}
-
숙박 일시 {review.startDate}~{review.endDate}
+
+ 숙박 일시 {review.startDate}~{review.endDate}
+
{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(
+
+ {
+ handleDateSelect(date);
+ }}
+ disabled={!isSelectable}
+ className={`w-12 h-12 rounded-full mx-auto flex items-center justify-center
+ ${!isSelectable ? 'text-gray-300 cursor-not-allowed' : 'hover:bg-gray-100'}
+ ${isSelected(date) ? 'bg-black text-white hover:bg-black' : ''}
+ ${isInRange ? 'bg-gray-100' : ''}
+ `}
+ >
+ {day}
+
+ ,
+ );
+
+ 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) => (
+
+ {day}
+
+ ))}
+
+
+ {renderCalendar(currentMonth)}
+
+
+
+ {/* 다음 달 */}
+
+
+ {currentMonth.add(1, 'month').format('YYYY년 M월')}
+
+
+
+
+ {['일', '월', '화', '수', '목', '금', '토'].map((day) => (
+
+ {day}
+
+ ))}
+
+
+
+ {renderCalendar(currentMonth.add(1, 'month'))}
+
+
+
+
+
+ →
+
+
+
+
+ {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; // 페이지 내용이 비어 있는지 여부
}