Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/what-today/src/components/skeletons/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './activities';
export * from './reservations-list';
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export default function ReservationCardSkeleton() {
return (
<div className='mb-24'>
{/* 메인 예약 카드 */}
<div className='flex gap-16 rounded-xl border border-gray-50 p-16'>
{/* 왼쪽: 정보 영역 */}
<div className='flex flex-1 flex-col gap-8'>
{/* 상태 뱃지 */}
<div className='h-24 w-80 animate-pulse rounded-full bg-gray-200' />

{/* 체험 제목 */}
<div className='h-20 w-full animate-pulse rounded bg-gray-200' />
<div className='h-20 w-4/5 animate-pulse rounded bg-gray-200' />

{/* 시간 */}
<div className='h-16 w-2/3 animate-pulse rounded bg-gray-200' />

{/* 가격과 인원수 */}
<div className='flex items-center gap-8'>
<div className='h-18 w-100 animate-pulse rounded bg-gray-200' />
<div className='h-16 w-40 animate-pulse rounded bg-gray-200' />
</div>
</div>

{/* 오른쪽: 체험 이미지 */}
<div className='h-120 w-120 animate-pulse rounded-lg bg-gray-200' />
</div>

{/* 액션 버튼 */}
<div className='mt-8 h-44 w-full animate-pulse rounded-lg bg-gray-200' />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import ReservationCardSkeleton from './ReservationCardSkeleton';

export default function ReservationsListPageSkeleton() {
return (
<div className='space-y-10'>
{/* 날짜 그룹 1 */}
<section className='space-y-12 pt-20 pb-30'>
{/* 날짜 헤더 */}
<div className='h-28 w-140 animate-pulse rounded bg-gray-200' />

{/* 예약 카드들 */}
<div>
<ReservationCardSkeleton />
<ReservationCardSkeleton />
</div>
</section>

{/* 날짜 그룹 2 */}
<section className='space-y-12 border-t border-gray-50 pt-20 pb-30'>
{/* 날짜 헤더 */}
<div className='h-28 w-140 animate-pulse rounded bg-gray-200' />

{/* 예약 카드들 */}
<div>
<ReservationCardSkeleton />
</div>
</section>

{/* 날짜 그룹 3 */}
<section className='space-y-12 border-t border-gray-50 pt-20 pb-30'>
{/* 날짜 헤더 */}
<div className='h-28 w-140 animate-pulse rounded bg-gray-200' />

{/* 예약 카드들 */}
<div>
<ReservationCardSkeleton />
<ReservationCardSkeleton />
<ReservationCardSkeleton />
</div>
</section>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as ReservationCardSkeleton } from './ReservationCardSkeleton';
export { default as ReservationsListPageSkeleton } from './ReservationsListPageSkeleton';
144 changes: 106 additions & 38 deletions apps/what-today/src/pages/mypage/reservations-list/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Button,
ChevronIcon,
Expand All @@ -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';

// 필터링 가능한 상태 타입 (전체 상태 + 빈 문자열)
Expand Down Expand Up @@ -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<Reservation[]>([]);
const [currentCursor, setCurrentCursor] = useState<number | null>(null);
const [hasMoreData, setHasMoreData] = useState(true);
const [isFetchingMore, setIsFetchingMore] = useState(false);

const pageSize = 10;

// 🎯 첫 페이지 로드 (일반 useQuery 사용)
const { data: firstPageData, isLoading } = useQuery<MyReservationsResponse>({
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];

Expand All @@ -77,14 +132,28 @@ export default function ReservationsListPage() {
setStarRating(0);
};

const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const observerRef = useIntersectionObserver(
fetchNextPage,
isFetchingNextPage,
!hasNextPage,
scrollContainerRef.current,
selectedStatus,
);
// 🎯 자체 무한스크롤 observer 구현 (수동 페이지네이션용)
const observerRef = useRef<HTMLDivElement>(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' }),
Expand Down Expand Up @@ -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) => (
<section key={date} className={twJoin('space-y-12 pt-20 pb-30', index !== 0 && 'border-t border-gray-50')}>
<h3 className='section-text'>{date}</h3>
<ul>
Expand Down Expand Up @@ -203,13 +267,9 @@ export default function ReservationsListPage() {

let content;
if (isLoading) {
content = (
<div className='flex items-center justify-center p-40'>
<SpinIcon className='size-200' color='var(--color-gray-100)' />
</div>
);
} else if (reservations.length > 0) {
content = <div className='space-y-10'>{renderGroupedReservations(reservations)}</div>;
content = <ReservationsListPageSkeleton />;
} else if (allReservations.length > 0) {
content = <div className='space-y-10'>{renderGroupedReservations(allReservations)}</div>;
} else {
content = (
<div className='flex justify-center p-40'>
Expand All @@ -219,7 +279,7 @@ export default function ReservationsListPage() {
}

return (
<div ref={scrollContainerRef} className='flex flex-col gap-13 md:gap-20'>
<div className='flex flex-col gap-13 md:gap-20'>
<header className='mb-16 flex flex-col gap-12'>
<div className='flex items-center gap-4 border-b border-b-gray-50 pb-8 md:pb-12'>
<Button className='w-30 p-0' size='sm' variant='none' onClick={() => navigate('/mypage')}>
Expand All @@ -246,7 +306,15 @@ export default function ReservationsListPage() {

<section aria-label='예약 카드 목록' className='flex flex-col gap-30 xl:gap-24'>
{content}
<div ref={observerRef} />
<div ref={observerRef} className='h-4' />

{/* 무한스크롤 로딩 중 스켈레톤 */}
{isFetchingMore && (
<div>
<ReservationCardSkeleton />
<ReservationCardSkeleton />
</div>
)}
</section>

{/* 예약 취소 확인 모달 */}
Expand Down
1 change: 0 additions & 1 deletion apps/what-today/src/schemas/myReservations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ export const reviewResponseSchema = z.object({
content: z.string(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
deletedAt: z.string().datetime().nullable(),
});

/**
Expand Down