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 ( -
    +