diff --git a/apps/what-today/src/components/skeletons/index.ts b/apps/what-today/src/components/skeletons/index.ts
new file mode 100644
index 00000000..1eb481e0
--- /dev/null
+++ b/apps/what-today/src/components/skeletons/index.ts
@@ -0,0 +1,2 @@
+export * from './activities';
+export * from './reservations-list';
diff --git a/apps/what-today/src/components/skeletons/reservations-list/ReservationCardSkeleton.tsx b/apps/what-today/src/components/skeletons/reservations-list/ReservationCardSkeleton.tsx
new file mode 100644
index 00000000..f2d00e57
--- /dev/null
+++ b/apps/what-today/src/components/skeletons/reservations-list/ReservationCardSkeleton.tsx
@@ -0,0 +1,33 @@
+export default function ReservationCardSkeleton() {
+ return (
+
+ {/* 메인 예약 카드 */}
+
+ {/* 왼쪽: 정보 영역 */}
+
+ {/* 상태 뱃지 */}
+
+
+ {/* 체험 제목 */}
+
+
+
+ {/* 시간 */}
+
+
+ {/* 가격과 인원수 */}
+
+
+
+ {/* 오른쪽: 체험 이미지 */}
+
+
+
+ {/* 액션 버튼 */}
+
+
+ );
+}
diff --git a/apps/what-today/src/components/skeletons/reservations-list/ReservationsListPageSkeleton.tsx b/apps/what-today/src/components/skeletons/reservations-list/ReservationsListPageSkeleton.tsx
new file mode 100644
index 00000000..30fd4b15
--- /dev/null
+++ b/apps/what-today/src/components/skeletons/reservations-list/ReservationsListPageSkeleton.tsx
@@ -0,0 +1,43 @@
+import ReservationCardSkeleton from './ReservationCardSkeleton';
+
+export default function ReservationsListPageSkeleton() {
+ return (
+
+ {/* 날짜 그룹 1 */}
+
+ {/* 날짜 헤더 */}
+
+
+ {/* 예약 카드들 */}
+
+
+
+
+
+
+ {/* 날짜 그룹 2 */}
+
+ {/* 날짜 헤더 */}
+
+
+ {/* 예약 카드들 */}
+
+
+
+
+
+ {/* 날짜 그룹 3 */}
+
+ {/* 날짜 헤더 */}
+
+
+ {/* 예약 카드들 */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/what-today/src/components/skeletons/reservations-list/index.ts b/apps/what-today/src/components/skeletons/reservations-list/index.ts
new file mode 100644
index 00000000..dc1c5d88
--- /dev/null
+++ b/apps/what-today/src/components/skeletons/reservations-list/index.ts
@@ -0,0 +1,2 @@
+export { default as ReservationCardSkeleton } from './ReservationCardSkeleton';
+export { default as ReservationsListPageSkeleton } from './ReservationsListPageSkeleton';
diff --git a/apps/what-today/src/pages/mypage/reservations-list/index.tsx b/apps/what-today/src/pages/mypage/reservations-list/index.tsx
index b7898be7..40a3b768 100644
--- a/apps/what-today/src/pages/mypage/reservations-list/index.tsx
+++ b/apps/what-today/src/pages/mypage/reservations-list/index.tsx
@@ -1,4 +1,4 @@
-import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Button,
ChevronIcon,
@@ -7,17 +7,16 @@ import {
NoResult,
RadioGroup,
ReservationCard,
- SpinIcon,
StarRating,
} from '@what-today/design-system';
import { WarningLogo } from '@what-today/design-system';
import { useToast } from '@what-today/design-system';
-import { useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { twJoin } from 'tailwind-merge';
import { cancelMyReservation, createReview, fetchMyReservations } from '@/apis/myReservations';
-import useIntersectionObserver from '@/hooks/useIntersectionObserver';
+import { ReservationCardSkeleton, ReservationsListPageSkeleton } from '@/components/skeletons';
import type { MyReservationsResponse, Reservation, ReservationStatus } from '@/schemas/myReservations';
// 필터링 가능한 상태 타입 (전체 상태 + 빈 문자열)
@@ -50,23 +49,79 @@ export default function ReservationsListPage() {
const [starRating, setStarRating] = useState(0);
const isReviewValid = starRating > 0 && reviewContent.trim().length > 0;
- const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery<
- MyReservationsResponse,
- Error
- >({
+ // 🎯 수동 페이지네이션 상태 관리
+ const [allReservations, setAllReservations] = useState([]);
+ const [currentCursor, setCurrentCursor] = useState(null);
+ const [hasMoreData, setHasMoreData] = useState(true);
+ const [isFetchingMore, setIsFetchingMore] = useState(false);
+
+ const pageSize = 10;
+
+ // 🎯 첫 페이지 로드 (일반 useQuery 사용)
+ const { data: firstPageData, isLoading } = useQuery({
queryKey: ['reservations', selectedStatus],
- queryFn: ({ pageParam = null }) =>
+ queryFn: () =>
fetchMyReservations({
- cursorId: pageParam as number | null,
- size: 10,
+ cursorId: null,
+ size: pageSize,
status: selectedStatus ? (selectedStatus as ReservationStatus) : null,
}),
- initialPageParam: null,
- getNextPageParam: (lastPage) => lastPage.cursorId ?? undefined,
staleTime: 1000 * 30,
});
- const reservations = data?.pages.flatMap((page) => page.reservations) ?? [];
+ // 🎯 첫 페이지 데이터가 로드되면 상태 업데이트 (중복 제거 포함)
+ useEffect(() => {
+ if (firstPageData) {
+ const reservations = firstPageData.reservations || [];
+ // 🎯 id + scheduleId 조합으로 중복 체크
+ const uniqueReservations = reservations.filter(
+ (res, index, arr) =>
+ arr.findIndex((r) => `${r.id}_${r.scheduleId}` === `${res.id}_${res.scheduleId}`) === index,
+ );
+ setAllReservations(uniqueReservations);
+ setCurrentCursor(firstPageData.cursorId);
+ setHasMoreData(!!firstPageData.cursorId);
+ }
+ }, [firstPageData]);
+
+ // 🎯 선택된 상태가 변경되면 리셋
+ useEffect(() => {
+ setAllReservations([]);
+ setCurrentCursor(null);
+ setHasMoreData(true);
+ }, [selectedStatus]);
+
+ // 🎯 수동 다음 페이지 로드 함수
+ const loadMoreData = useCallback(async () => {
+ if (!hasMoreData || isFetchingMore) return;
+
+ setIsFetchingMore(true);
+ try {
+ const nextPageData = await fetchMyReservations({
+ cursorId: currentCursor,
+ size: pageSize,
+ status: selectedStatus ? (selectedStatus as ReservationStatus) : null,
+ });
+
+ // 🎯 id + scheduleId 조합으로 중복 체크
+ setAllReservations((prev) => {
+ const newReservations = nextPageData.reservations || [];
+ const existingKeys = new Set(prev.map((r) => `${r.id}_${r.scheduleId}`));
+ const uniqueNewReservations = newReservations.filter((r) => !existingKeys.has(`${r.id}_${r.scheduleId}`));
+ return [...prev, ...uniqueNewReservations];
+ });
+ setCurrentCursor(nextPageData.cursorId);
+ setHasMoreData(!!nextPageData.cursorId);
+ } catch (error) {
+ toast({
+ title: '데이터 로드 실패',
+ description: error instanceof Error ? error.message : '더 많은 데이터를 불러오는 중 오류가 발생했습니다.',
+ type: 'error',
+ });
+ } finally {
+ setIsFetchingMore(false);
+ }
+ }, [hasMoreData, isFetchingMore, currentCursor, pageSize, selectedStatus, toast]);
const noResultMessage = NO_RESULT_MESSAGES[selectedStatus];
@@ -77,14 +132,28 @@ export default function ReservationsListPage() {
setStarRating(0);
};
- const scrollContainerRef = useRef(null);
- const observerRef = useIntersectionObserver(
- fetchNextPage,
- isFetchingNextPage,
- !hasNextPage,
- scrollContainerRef.current,
- selectedStatus,
- );
+ // 🎯 자체 무한스크롤 observer 구현 (수동 페이지네이션용)
+ const observerRef = useRef(null);
+
+ useEffect(() => {
+ const target = observerRef.current;
+ if (!target || isFetchingMore || !hasMoreData) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting) {
+ loadMoreData();
+ }
+ },
+ { threshold: 0.1 },
+ );
+
+ observer.observe(target);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [loadMoreData, hasMoreData, isFetchingMore]);
const cancelReservation = useMutation({
mutationFn: (id: number) => cancelMyReservation(id, { status: 'canceled' }),
@@ -136,13 +205,8 @@ export default function ReservationsListPage() {
return acc;
}, {});
- // 날짜 기준으로 내림차순 정렬 (최근 날짜가 먼저)
- const sortedByDateDesc = Object.entries(grouped).sort(([dateA], [dateB]) => {
- const shouldSwap = dateA < dateB;
- return shouldSwap ? 1 : -1;
- });
-
- return sortedByDateDesc.map(([date, group], index) => (
+ // 🎯 날짜별 그룹핑만 하고 정렬 제거 (자연스러운 순서 유지)
+ return Object.entries(grouped).map(([date, group], index) => (
{date}
@@ -203,13 +267,9 @@ export default function ReservationsListPage() {
let content;
if (isLoading) {
- content = (
-
-
-
- );
- } else if (reservations.length > 0) {
- content = {renderGroupedReservations(reservations)}
;
+ content = ;
+ } else if (allReservations.length > 0) {
+ content = {renderGroupedReservations(allReservations)}
;
} else {
content = (
@@ -219,7 +279,7 @@ export default function ReservationsListPage() {
}
return (
-
+