diff --git a/public/assets/svg/empty-document.tsx b/public/assets/svg/empty-document.tsx new file mode 100644 index 0000000..3671f01 --- /dev/null +++ b/public/assets/svg/empty-document.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +const EmptyDocumentIcon = ({ size = 24, ...props }) => ( + + + + +); + +export default EmptyDocumentIcon; diff --git a/public/assets/svg/star-empty.tsx b/public/assets/svg/star-empty.tsx new file mode 100644 index 0000000..828d3f8 --- /dev/null +++ b/public/assets/svg/star-empty.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { SvgProps } from '@/types/svgType'; + +const StarEmpty = ({ size = 20, color = '#DDDDDD', ...props }: SvgProps) => ( + + + +); + +export default StarEmpty; \ No newline at end of file diff --git a/src/apis/reservations.ts b/src/apis/reservations.ts new file mode 100644 index 0000000..6838d15 --- /dev/null +++ b/src/apis/reservations.ts @@ -0,0 +1,64 @@ +import { privateInstance } from './privateInstance'; +import { + MyReservationsResponse, + GetMyReservationsParams, + UpdateReservationRequest, + Reservation, + CreateReviewRequest, + ReviewResponse, +} from '@/types/reservationTypes'; + +/** + * 내 예약 리스트 조회 + * GET /api/reservations + */ +export const getMyReservations = async ( + params: GetMyReservationsParams, +): Promise => { + const queryParams = new URLSearchParams(); + + if (params.cursorId) { + queryParams.append('cursorId', params.cursorId.toString()); + } + if (params.size) { + queryParams.append('size', params.size.toString()); + } + if (params.status) { + queryParams.append('status', params.status); + } + + const response = await privateInstance.get( + `/reservations?${queryParams.toString()}`, + ); + return response.data; +}; + +/** + * 내 예약 수정(취소) + * PATCH /api/reservations/{reservationId} + */ +export const updateMyReservation = async ( + reservationId: number, + data: UpdateReservationRequest, +): Promise => { + const response = await privateInstance.patch( + `/reservations/${reservationId}`, + data, + ); + return response.data; +}; + +/** + * 내 예약 리뷰 작성 + * POST /api/reservations/{reservationId}/reviews + */ +export const createReview = async ( + reservationId: number, + data: CreateReviewRequest, +): Promise => { + const response = await privateInstance.post( + `/reservations/${reservationId}/reviews`, + data, + ); + return response.data; +}; diff --git a/src/app/(with-header)/mypage/reservations/components/CancelReservationModal.tsx b/src/app/(with-header)/mypage/reservations/components/CancelReservationModal.tsx new file mode 100644 index 0000000..eedefe2 --- /dev/null +++ b/src/app/(with-header)/mypage/reservations/components/CancelReservationModal.tsx @@ -0,0 +1,65 @@ +'use client'; + +import Modal from '@/components/Modal'; +import Button from '@/components/Button'; +import CheckIcon from '@assets/svg/check'; + +interface CancelReservationModalProps { + isOpen: boolean; + onCancel: () => void; + onConfirm: () => void; + isLoading?: boolean; +} + +export default function CancelReservationModal({ + isOpen, + onCancel, + onConfirm, + isLoading = false, +}: CancelReservationModalProps) { + return ( + !open && onCancel()}> + +
+ {/* 체크 아이콘 */} +
+ +
+ + {/* 메시지 */} +

+ 예약을 취소하시겠어요? +

+ + {/* 버튼 */} +
+ + +
+
+
+
+ ); +} diff --git a/src/app/(with-header)/mypage/reservations/components/EmptyReservations.tsx b/src/app/(with-header)/mypage/reservations/components/EmptyReservations.tsx new file mode 100644 index 0000000..1350e32 --- /dev/null +++ b/src/app/(with-header)/mypage/reservations/components/EmptyReservations.tsx @@ -0,0 +1,17 @@ +import EmptyDocumentIcon from '@assets/svg/empty-document'; + +export default function EmptyReservations() { + return ( +
+ {/* 빈 상태 아이콘 */} +
+ +
+ + {/* 빈 상태 메시지 */} +

+ 아직 등록한 체험이 없어요 +

+
+ ); +} diff --git a/src/app/(with-header)/mypage/reservations/components/ReservationCard.tsx b/src/app/(with-header)/mypage/reservations/components/ReservationCard.tsx new file mode 100644 index 0000000..2c6fcc5 --- /dev/null +++ b/src/app/(with-header)/mypage/reservations/components/ReservationCard.tsx @@ -0,0 +1,111 @@ +'use client'; + +import Image from 'next/image'; +import Button from '@/components/Button'; +import { Reservation } from '@/types/reservationTypes'; +import { STATUS_LABELS, STATUS_COLORS } from '@/constants/reservationConstants'; +import cn from '@/lib/cn'; + +interface ReservationCardProps { + reservation: Reservation; + onCancel?: (reservationId: number) => void; + onReview?: (reservationId: number) => void; +} + +// 체험 완료 여부 확인 (현재 시간이 체험 종료 시간을 지났는지) +const isExperienceCompleted = (date: string, endTime: string): boolean => { + const experienceEndDateTime = new Date(`${date}T${endTime}+09:00`); + return new Date() > experienceEndDateTime; +}; + +export default function ReservationCard({ + reservation, + onCancel, + onReview, +}: ReservationCardProps) { + const { + id, + activity, + status, + reviewSubmitted, + totalPrice, + headCount, + date, + startTime, + endTime, + } = reservation; + + const isCompleted = isExperienceCompleted(date, endTime); + const showCancelButton = status === 'pending'; + const showReviewButton = isCompleted && !reviewSubmitted; + const showReviewCompleted = isCompleted && reviewSubmitted; + + return ( +
+ {/* 이미지 영역 */} +
+ {activity.title} +
+ + {/* 콘텐츠 영역 */} +
+ {/* 상태 라벨 */} +
+ + {STATUS_LABELS[status]} + +
+ + {/* 제목 */} +
+

{activity.title}

+
+ + {/* 날짜 및 인원 정보 */} +
+

+ {date} · {startTime} - {endTime} · {headCount}명 +

+
+ + {/* 가격 + 버튼 */} +
+ {/* 가격 */} +

+ ₩{totalPrice.toLocaleString()} +

+ + {/* 버튼/상태 */} +
+ {showCancelButton && ( + + )} + {showReviewButton && ( + + )} + {showReviewCompleted && ( +
후기 완료
+ )} +
+
+
+
+ ); +} diff --git a/src/app/(with-header)/mypage/reservations/components/ReservationFilter.tsx b/src/app/(with-header)/mypage/reservations/components/ReservationFilter.tsx new file mode 100644 index 0000000..602d2f6 --- /dev/null +++ b/src/app/(with-header)/mypage/reservations/components/ReservationFilter.tsx @@ -0,0 +1,42 @@ +'use client'; + +import Dropdown from '@/components/Dropdown'; +import { + FilterOption, + FILTER_OPTIONS, + FILTER_LABELS, +} from '@/constants/reservationConstants'; + +interface ReservationFilterProps { + value: FilterOption; + onChange: (value: FilterOption) => void; +} + +export default function ReservationFilter({ + value, + onChange, +}: ReservationFilterProps) { + // 표시용 라벨 옵션 배열 + const labelOptions = FILTER_OPTIONS.map((option) => FILTER_LABELS[option]); + + // 라벨 선택 시 실제 값으로 변환 + const handleChange = (selectedLabel: string) => { + const selectedOption = FILTER_OPTIONS.find( + (option) => FILTER_LABELS[option] === selectedLabel, + ); + if (selectedOption !== undefined) { + onChange(selectedOption); + } + }; + + return ( + + ); +} diff --git a/src/app/(with-header)/mypage/reservations/components/ReviewModal.tsx b/src/app/(with-header)/mypage/reservations/components/ReviewModal.tsx new file mode 100644 index 0000000..0dd352e --- /dev/null +++ b/src/app/(with-header)/mypage/reservations/components/ReviewModal.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import Modal from '@/components/Modal'; +import Button from '@/components/Button'; +import Rating from '@/components/Rating'; +import Close from '@/../public/assets/svg/close'; + +interface ReviewModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (rating: number, content: string) => void; + isLoading?: boolean; + activityTitle?: string; + activityImage?: string; + activityDate?: string; + activityTime?: string; + headCount?: number; + totalPrice?: number; +} + +export default function ReviewModal({ + isOpen, + onClose, + onConfirm, + isLoading = false, + activityTitle, + activityImage, + activityDate, + activityTime, + headCount, + totalPrice, +}: ReviewModalProps) { + const [rating, setRating] = useState(0); + const [content, setContent] = useState(''); + + const handleSubmit = () => { + if (rating === 0) { + alert('별점을 선택해주세요.'); + return; + } + if (content.trim() === '') { + alert('후기를 작성해주세요.'); + return; + } + onConfirm(rating, content); + }; + + const handleClose = () => { + setRating(0); + setContent(''); + onClose(); + }; + + return ( + !open && handleClose()}> + +
+ {/* 헤더 */} +
+

후기 작성

+ +
+ + {/* 본문 */} +
+ {/* 체험 정보 카드 */} +
+ {/* 체험 이미지 */} +
+ {activityImage ? ( + {activityTitle + ) : ( +
+ )} +
+ + {/* 체험 정보 */} +
+

+ {activityTitle} +

+
+ {activityDate} · {activityTime} · {headCount}명 +
+
+ ₩{totalPrice?.toLocaleString()} +
+
+
+ + {/* 별점 */} +
+ +
+ + {/* 후기 내용 */} +
+