+ {/* 제목과 필터 */}
+
+
+ 예약 내역
+
+
+
+
+ {/* 예약 내역 목록 */}
+ {allReservations.length === 0 ? (
+
+ ) : (
+
+ {allReservations.map((reservation, index) => (
+
+
+
+ ))}
- {/* 예약 내역 컨텐츠 */}
-
-
예약 내역 페이지입니다.
- {/* TODO: 예약 내역 컴포넌트 구현 */}
+ {/* 무한 스크롤 로딩 */}
+ {isFetchingNextPage && (
+
+ )}
+
+ )}
+
+ {/* 예약 취소 확인 모달 */}
+
+
+ {/* 후기 작성 모달 */}
+
>
);
}
diff --git a/src/app/api/reservations/[id]/reviews/route.ts b/src/app/api/reservations/[id]/reviews/route.ts
new file mode 100644
index 0000000..a10625b
--- /dev/null
+++ b/src/app/api/reservations/[id]/reviews/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import axios from 'axios';
+
+const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_API_SERVER_URL;
+
+/**
+ * 후기 작성
+ * POST /api/reservations/[id]/reviews
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const cookieStore = await cookies();
+ const accessToken = cookieStore.get('accessToken')?.value;
+
+ if (!accessToken) {
+ return NextResponse.json(
+ { message: '인증 토큰이 없습니다.' },
+ { status: 401 },
+ );
+ }
+
+ const body = await request.json();
+ const resolvedParams = await params;
+ const reservationId = resolvedParams.id;
+
+ const response = await axios.post(
+ `${BACKEND_BASE_URL}/my-reservations/${reservationId}/reviews`,
+ body,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ return NextResponse.json(response.data);
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ const status = error.response?.status || 500;
+ const message =
+ error.response?.data?.message || '후기 작성에 실패했습니다.';
+
+ return NextResponse.json({ message }, { status });
+ }
+
+ return NextResponse.json(
+ { message: '서버 오류가 발생했습니다.' },
+ { status: 500 },
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/reservations/[id]/route.ts b/src/app/api/reservations/[id]/route.ts
new file mode 100644
index 0000000..ed2f93a
--- /dev/null
+++ b/src/app/api/reservations/[id]/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import axios from 'axios';
+
+const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_API_SERVER_URL;
+
+/**
+ * 예약 수정(취소)
+ * PATCH /api/reservations/[id]
+ */
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const cookieStore = await cookies();
+ const accessToken = cookieStore.get('accessToken')?.value;
+
+ if (!accessToken) {
+ return NextResponse.json(
+ { message: '인증 토큰이 없습니다.' },
+ { status: 401 },
+ );
+ }
+
+ const body = await request.json();
+ const resolvedParams = await params;
+ const reservationId = resolvedParams.id;
+
+ const response = await axios.patch(
+ `${BACKEND_BASE_URL}/my-reservations/${reservationId}`,
+ body,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ return NextResponse.json(response.data);
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ const status = error.response?.status || 500;
+ const message =
+ error.response?.data?.message || '예약 수정에 실패했습니다.';
+
+ return NextResponse.json({ message }, { status });
+ }
+
+ return NextResponse.json(
+ { message: '서버 오류가 발생했습니다.' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/reservations/route.ts b/src/app/api/reservations/route.ts
new file mode 100644
index 0000000..888f6c5
--- /dev/null
+++ b/src/app/api/reservations/route.ts
@@ -0,0 +1,59 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import axios from 'axios';
+
+const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_API_SERVER_URL;
+
+/**
+ * 내 예약 리스트 조회
+ * GET /api/reservations
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const cookieStore = await cookies();
+ const accessToken = cookieStore.get('accessToken')?.value;
+
+ if (!accessToken) {
+ return NextResponse.json(
+ { message: '인증 토큰이 없습니다.' },
+ { status: 401 },
+ );
+ }
+
+ // URL에서 쿼리 파라미터 추출
+ const { searchParams } = new URL(request.url);
+ const cursorId = searchParams.get('cursorId');
+ const size = searchParams.get('size') || '10';
+ const status = searchParams.get('status');
+
+ // 쿼리 파라미터 구성
+ const queryParams = new URLSearchParams();
+ if (cursorId) queryParams.append('cursorId', cursorId);
+ queryParams.append('size', size);
+ if (status) queryParams.append('status', status);
+
+ const response = await axios.get(
+ `${BACKEND_BASE_URL}/my-reservations?${queryParams.toString()}`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+
+ return NextResponse.json(response.data);
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ const status = error.response?.status || 500;
+ const message =
+ error.response?.data?.message || '예약 내역 조회에 실패했습니다.';
+ return NextResponse.json({ message }, { status });
+ }
+
+ return NextResponse.json(
+ { message: '서버 오류가 발생했습니다.' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx
index 2cb3273..38e78fb 100644
--- a/src/components/Dropdown.tsx
+++ b/src/components/Dropdown.tsx
@@ -33,6 +33,7 @@ export default function Dropdown
({
placeholder,
className,
disabled = false,
+ disableScroll = false,
}: DropdownProps) {
// 내부 상태 관리
const [internalValue, setInternalValue] = useState('');
@@ -160,7 +161,13 @@ export default function Dropdown({
'overflow-hidden shadow-lg',
)}
>
-
+
{options.map((option, index) => {
const isSelected = option === selectedValue;
const isFocused = index === focusedIndex;
diff --git a/src/components/Rating.tsx b/src/components/Rating.tsx
new file mode 100644
index 0000000..6ca2a72
--- /dev/null
+++ b/src/components/Rating.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { useState } from 'react';
+import Star from '@/../public/assets/svg/star';
+import StarEmpty from '@/../public/assets/svg/star-empty';
+
+interface RatingProps {
+ value: number;
+ onChange: (rating: number) => void;
+ size?: number;
+ className?: string;
+}
+
+export default function Rating({
+ value,
+ onChange,
+ size = 56,
+ className = '',
+}: RatingProps) {
+ const [hoverRating, setHoverRating] = useState(0);
+
+ const handleClick = (rating: number) => {
+ onChange(rating);
+ };
+
+ const handleMouseEnter = (rating: number) => {
+ setHoverRating(rating);
+ };
+
+ const handleMouseLeave = () => {
+ setHoverRating(0);
+ };
+
+ return (
+
+ {[1, 2, 3, 4, 5].map((star) => {
+ const isActive = star <= (hoverRating || value);
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/constants/reservationConstants.ts b/src/constants/reservationConstants.ts
new file mode 100644
index 0000000..baaf831
--- /dev/null
+++ b/src/constants/reservationConstants.ts
@@ -0,0 +1,38 @@
+import { ReservationStatus } from '@/types/reservationTypes';
+
+// 필터 옵션 타입
+export type FilterOption = '' | ReservationStatus;
+
+// 필터 옵션 배열
+export const FILTER_OPTIONS: readonly FilterOption[] = [
+ '', // 전체
+ 'pending', // 예약 신청
+ 'confirmed', // 예약 완료
+ 'declined', // 예약 거절
+ 'canceled', // 예약 취소
+ 'completed', // 체험 완료
+] as const;
+
+// 상태별 표시 라벨
+export const STATUS_LABELS: Record = {
+ pending: '예약 신청',
+ confirmed: '예약 승인',
+ declined: '예약 거절',
+ canceled: '예약 취소',
+ completed: '체험 완료',
+};
+
+// 필터 라벨
+export const FILTER_LABELS: Record = {
+ '': '전체',
+ ...STATUS_LABELS,
+};
+
+// 상태별 색상 매핑
+export const STATUS_COLORS: Record = {
+ pending: 'text-blue-200', // 예약 신청
+ confirmed: 'text-orange-200', // 예약 완료
+ declined: 'text-red-300', // 예약 거절
+ canceled: 'text-gray-800', // 예약 취소
+ completed: 'text-gray-800', // 체험 완료
+};
diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts
new file mode 100644
index 0000000..733f625
--- /dev/null
+++ b/src/hooks/useInfiniteScroll.ts
@@ -0,0 +1,46 @@
+'use client';
+
+import { useRef, useCallback } from 'react';
+
+interface UseInfiniteScrollProps {
+ hasNextPage: boolean | undefined;
+ isFetchingNextPage: boolean;
+ isLoading: boolean;
+ fetchNextPage: () => void;
+}
+
+/*
+ * 무한 스크롤을 구현하기 위한 커스텀 훅
+ * @description
+ * - Intersection Observer API를 사용하여 마지막 요소가 화면에 보이면 자동으로 다음 페이지 로드
+ * - 중복 요청을 방지하기 위한 로딩 상태 체크 포함
+ */
+
+export default function useInfiniteScroll({
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ fetchNextPage,
+}: UseInfiniteScrollProps) {
+ const observerRef = useRef(null);
+
+ const lastElementRef = useCallback(
+ (node: HTMLDivElement | null) => {
+ // 로딩중이거나 다음 페이지를 가져오는 중이면 중복요처 방지
+ if (isLoading || isFetchingNextPage) return;
+ if (observerRef.current) observerRef.current.disconnect();
+
+ observerRef.current = new IntersectionObserver((entries) => {
+ // 화면에 보이고 + 다음 페이지가 있다면 실행
+ if (entries[0].isIntersecting && hasNextPage) {
+ fetchNextPage();
+ }
+ });
+
+ if (node) observerRef.current.observe(node);
+ },
+ [isLoading, isFetchingNextPage, hasNextPage, fetchNextPage],
+ );
+
+ return { lastElementRef };
+}
diff --git a/src/hooks/useReservationQueries.ts b/src/hooks/useReservationQueries.ts
new file mode 100644
index 0000000..bbb758e
--- /dev/null
+++ b/src/hooks/useReservationQueries.ts
@@ -0,0 +1,70 @@
+'use client';
+
+import {
+ useMutation,
+ useQueryClient,
+ useInfiniteQuery,
+} from '@tanstack/react-query';
+import { getMyReservations, updateMyReservation, createReview } from '@/apis/reservations';
+import { ReservationStatus, CreateReviewRequest } from '@/types/reservationTypes';
+
+export const RESERVATION_QUERY_KEYS = {
+ RESERVATIONS: ['reservations'] as const,
+} as const;
+
+// 내 예약 리스트 조회 (무한 스크롤)
+export const useMyReservations = (status?: ReservationStatus) => {
+ return useInfiniteQuery({
+ queryKey: [...RESERVATION_QUERY_KEYS.RESERVATIONS, status],
+ queryFn: ({ pageParam = 0 }) =>
+ getMyReservations({
+ cursorId: pageParam,
+ size: 10,
+ status,
+ }),
+ getNextPageParam: (lastPage) => {
+ // 다음 페이지가 있으면 cursorId 반환, 없으면 undefined
+ return lastPage.reservations.length === 10
+ ? lastPage.cursorId
+ : undefined;
+ },
+ initialPageParam: 0,
+ staleTime: 1000 * 60 * 5,
+ });
+};
+
+// 예약 취소 훅
+export const useCancelReservation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (reservationId: number) =>
+ updateMyReservation(reservationId, { status: 'canceled' }),
+ onSuccess: () => {
+ // 예약 리스트 캐시 무효화하여 최신 데이터 다시 가져오기
+ queryClient.invalidateQueries({
+ queryKey: RESERVATION_QUERY_KEYS.RESERVATIONS,
+ });
+ alert('예약이 취소되었습니다.');
+ },
+ onError: (error) => {
+ alert(`예약 취소 실패: ${error.message}`);
+ },
+ });
+};
+
+// 후기 작성 훅
+export const useCreateReview = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ reservationId, data }: { reservationId: number; data: CreateReviewRequest }) =>
+ createReview(reservationId, data),
+ onSuccess: () => {
+ // 예약 리스트 캐시 무효화하여 reviewSubmitted 상태 업데이트
+ queryClient.invalidateQueries({
+ queryKey: RESERVATION_QUERY_KEYS.RESERVATIONS,
+ });
+ },
+ });
+};
diff --git a/src/types/dropdownTypes.ts b/src/types/dropdownTypes.ts
index 09c5f12..4d71dc8 100644
--- a/src/types/dropdownTypes.ts
+++ b/src/types/dropdownTypes.ts
@@ -12,6 +12,7 @@ import { ClassValue } from 'clsx';
* @property placeholder - 선택되지 않았을 때 표시되는 텍스트
* @property className - 컴포넌트에 적용할 CSS 클래스 (크기, 위치 등)
* @property disabled - 비활성화 여부
+ * @property disableScroll - 스크롤 비활성화 여부
*/
export interface DropdownProps {
options: readonly T[];
@@ -20,4 +21,5 @@ export interface DropdownProps {
placeholder?: string;
className?: ClassValue;
disabled?: boolean;
+ disableScroll?: boolean;
}
diff --git a/src/types/reservationTypes.ts b/src/types/reservationTypes.ts
new file mode 100644
index 0000000..dea14c1
--- /dev/null
+++ b/src/types/reservationTypes.ts
@@ -0,0 +1,67 @@
+// 예약 상태
+export type ReservationStatus =
+ | 'pending'
+ | 'confirmed'
+ | 'declined'
+ | 'canceled'
+ | 'completed';
+
+// 예약
+export interface Reservation {
+ id: number;
+ teamId: string;
+ userId: number;
+ activity: {
+ bannerImageUrl: string;
+ title: string;
+ id: number;
+ };
+ scheduleId: number;
+ status: ReservationStatus;
+ reviewSubmitted: boolean;
+ totalPrice: number;
+ headCount: number;
+ date: string;
+ startTime: string;
+ endTime: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+// 예약 리스트 응답
+export interface MyReservationsResponse {
+ cursorId: number;
+ reservations: Reservation[];
+ totalCount: number;
+}
+
+// 예약 수정 요청
+export interface UpdateReservationRequest {
+ status: 'canceled';
+}
+
+// 예약 리스트 조회 파라미터
+export interface GetMyReservationsParams {
+ cursorId?: number;
+ size?: number;
+ status?: ReservationStatus;
+}
+
+// 후기 작성 요청
+export interface CreateReviewRequest {
+ rating: number;
+ content: string;
+}
+
+// 후기 작성 응답
+export interface ReviewResponse {
+ id: number;
+ teamId: string;
+ userId: number;
+ activityId: number;
+ rating: number;
+ content: string;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}