diff --git a/apps/what-today/src/pages/main/index.tsx b/apps/what-today/src/pages/main/index.tsx index b07af229..2f7c4b50 100644 --- a/apps/what-today/src/pages/main/index.tsx +++ b/apps/what-today/src/pages/main/index.tsx @@ -1,9 +1,11 @@ import { useQuery } from '@tanstack/react-query'; import type { SelectItem } from '@what-today/design-system'; import { + ActivityCardGridSkeleton, ArtIcon, BusIcon, Carousel, + CarouselSkeleton, FoodIcon, MainBanner, MainCard, @@ -22,24 +24,28 @@ import { useNavigate } from 'react-router-dom'; import { type Activity, getActivities } from '@/apis/activities'; -// React.memo로 MainCard 최적화 const MemoizedMainCard = React.memo(MainCard.Root); +// ✅ 화면 너비에 따른 카드 개수 +const getCount = () => { + const w = window.innerWidth; + if (w < 768) return 6; // 모바일 + if (w < 1280) return 4; // 태블릿 + return 8; // 데스크탑 +}; + export default function MainPage() { const [currentPage, setCurrentPage] = useState(1); - const [itemsPerPage, setItemsPerPage] = useState(4); + const [itemsPerPage, setItemsPerPage] = useState(() => getCount()); const [searchKeyword, setSearchKeyword] = useState(''); const [sortOrder, setSortOrder] = useState<'latest' | 'asc' | 'desc'>('latest'); const [selectedValue, setSelectedValue] = useState(null); const [selectedCategory, setSelectedCategory] = useState(''); const navigate = useNavigate(); - // ✅ 반응형 카드 수 + // 반응형 카드 수 const handleResize = useCallback(() => { - const width = window.innerWidth; - if (width < 790) setItemsPerPage(6); - else if (width < 1024) setItemsPerPage(4); - else setItemsPerPage(8); + setItemsPerPage(getCount()); }, []); useEffect(() => { @@ -49,13 +55,16 @@ export default function MainPage() { }, [handleResize]); // ✅ 데이터 불러오기 - const { data: activities = [] } = useQuery({ + const { + data: activities = [], + isLoading, + isFetching, + } = useQuery({ queryKey: ['activities'], queryFn: () => getActivities({ size: 100 }), - staleTime: 1000 * 60 * 10, + refetchOnMount: 'always', gcTime: 1000 * 60 * 30, refetchOnWindowFocus: false, - refetchOnMount: false, }); // 인기 체험 @@ -63,12 +72,11 @@ export default function MainPage() { if (!activities.length) return []; return activities .slice() - .sort((a, b) => { - if (b.reviewCount === a.reviewCount) { - return new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime(); - } - return b.reviewCount - a.reviewCount; - }) + .sort((a, b) => + b.reviewCount === a.reviewCount + ? new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime() + : b.reviewCount - a.reviewCount, + ) .slice(0, 12); }, [activities]); @@ -89,17 +97,13 @@ export default function MainPage() { (a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime(), ); } - return [...filteredItems].sort((a, b) => { - if (sortOrder === 'asc') return a.price - b.price; - return b.price - a.price; - }); + return [...filteredItems].sort((a, b) => (sortOrder === 'asc' ? a.price - b.price : b.price - a.price)); }, [filteredItems, sortOrder]); // 3단계: 페이지 아이템 const pagedItems = useMemo(() => { - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - return sortedItems.slice(startIndex, endIndex); + const start = (currentPage - 1) * itemsPerPage; + return sortedItems.slice(start, start + itemsPerPage); }, [sortedItems, currentPage, itemsPerPage]); const totalPages = useMemo(() => Math.ceil(sortedItems.length / itemsPerPage), [sortedItems.length, itemsPerPage]); @@ -107,8 +111,7 @@ export default function MainPage() { // 이벤트 핸들러 const handlePageChange = useCallback( (page: number) => { - if (page === currentPage) return; - setCurrentPage(page); + if (page !== currentPage) setCurrentPage(page); }, [currentPage], ); @@ -134,9 +137,9 @@ export default function MainPage() { // 카드 렌더링 최적화 const renderCards = useCallback(() => { - return pagedItems.map((item, index) => ( + return pagedItems.map((item, idx) => (

🔥 인기 체험

-
+ {isLoading ? ( + + ) : ( navigate(`/activities/${id}`)} /> -
+ )} {/* 검색 */} @@ -176,7 +181,6 @@ export default function MainPage() { {/* 제목 + 가격 드롭다운 */}

🛼 모든 체험

- @@ -202,35 +206,31 @@ export default function MainPage() { onSelect={handleCategoryChange} > - - 문화 예술 + 문화 예술 - - 식음료 + 식음료 - - 스포츠 + 스포츠 - - 투어 + 투어 - - 관광 + 관광 - - 웰빙 + 웰빙
{/* 카드 리스트 */} -
- {filteredItems.length === 0 ? ( +
+ {isLoading || isFetching ? ( + + ) : filteredItems.length === 0 ? (
@@ -239,7 +239,7 @@ export default function MainPage() { )}
- {filteredItems.length > 0 && ( + {!isLoading && filteredItems.length > 0 && ( )}
diff --git a/packages/design-system/src/components/Carousel/Carousel.tsx b/packages/design-system/src/components/Carousel/Carousel.tsx index 18ed30da..6e929c09 100644 --- a/packages/design-system/src/components/Carousel/Carousel.tsx +++ b/packages/design-system/src/components/Carousel/Carousel.tsx @@ -1,26 +1,12 @@ import { motion } from 'motion/react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { MainCard } from '../MainCard'; import NavigationButton from './NavigationButton'; import type { CarouselProps, Props } from './types'; -export default function Carousel({ items, itemsPerPage: initialItemsPerPage = 4, onClick }: Props) { +export default function Carousel({ items, itemsPerPage, onClick }: Props) { const [page, setPage] = useState(0); - const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage); - - useEffect(() => { - const handleResize = () => { - const width = window.innerWidth; - if (width < 768) return; - else if (width < 1024) setItemsPerPage(2); - else setItemsPerPage(4); - }; - - handleResize(); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); const totalPages = Math.ceil(items.length / itemsPerPage); const itemWidthPercent = 100 / itemsPerPage; diff --git a/packages/design-system/src/components/MainPageSkeleton/MainPageSkeleton.tsx b/packages/design-system/src/components/MainPageSkeleton/MainPageSkeleton.tsx new file mode 100644 index 00000000..f1f5023b --- /dev/null +++ b/packages/design-system/src/components/MainPageSkeleton/MainPageSkeleton.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface SkeletonProps { + className?: string; +} +export function Skeleton({ className }: SkeletonProps) { + return
; +} + +// 단일 카드 스켈레톤 +export function ActivityCardSkeleton() { + return ( +
+ +
+
+ + +
+ + + +
+ +
+
+
+ ); +} + +// 반응형 스켈레톤 개수 계산 +const getResponsiveCount = () => { + const w = window.innerWidth; + if (w < 768) return 6; // 모바일 + if (w < 1280) return 4; // 태블릿 + return 8; // 데스크탑 +}; + +export function ActivityCardGridSkeleton() { + const [count, setCount] = useState(() => getResponsiveCount()); + + useEffect(() => { + const onResize = () => setCount(getResponsiveCount()); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + return ( + <> + {Array.from({ length: count }).map((_, i) => ( + + ))} + + ); +} + +// Carousel용 스켈레톤 (인기 체험 반응형) + +interface CarouselSkeletonProps { + count?: number; +} +export function CarouselSkeleton({ count = 4 }: CarouselSkeletonProps) { + const MOBILE_BREAK = 768; + const TABLET_BREAK = 1280; + + const getInitial = () => { + const w = window.innerWidth; + if (w < MOBILE_BREAK) return count; + if (w < TABLET_BREAK) return 2; + return count; + }; + + const [perPage, setPerPage] = useState(() => getInitial()); + + useEffect(() => { + const onResize = () => setPerPage(getInitial()); + // 마운트 시 한 번 실행 + onResize(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [count]); + + return ( +
+
+ {/* 태블릿·데스크탑 */} +
+
+ {Array.from({ length: perPage }).map((_, i) => ( +
+ +
+ ))} +
+
+ {/* 모바일 */} +
+ + {Array.from({ length: count }).map((_, i) => ( +
+ +
+ ))} +
+
+
+ ); +} diff --git a/packages/design-system/src/components/MainPageSkeleton/index.ts b/packages/design-system/src/components/MainPageSkeleton/index.ts new file mode 100644 index 00000000..b4671035 --- /dev/null +++ b/packages/design-system/src/components/MainPageSkeleton/index.ts @@ -0,0 +1,3 @@ +export { ActivityCardSkeleton } from './MainPageSkeleton'; +export { ActivityCardGridSkeleton } from './MainPageSkeleton'; +export { CarouselSkeleton } from './MainPageSkeleton'; diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 14eb3611..7f27b637 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -14,6 +14,7 @@ export * from './input'; export * from './logos'; export { default as MainBanner } from './MainBanner/MainBanner'; export * from './MainCard'; +export * from './MainPageSkeleton'; export { default as MainSearchInput } from './MainSearchInput/MainSearchInput'; export * from './modal'; export { default as NoResult } from './NoResult'; diff --git a/packages/design-system/src/layouts/Sidebar.tsx b/packages/design-system/src/layouts/Sidebar.tsx index e9c4c76f..3ab101ee 100644 --- a/packages/design-system/src/layouts/Sidebar.tsx +++ b/packages/design-system/src/layouts/Sidebar.tsx @@ -47,6 +47,7 @@ export default function DesignSystemLayout() { + diff --git a/packages/design-system/src/pages/MainPageSkeletonDoc.tsx b/packages/design-system/src/pages/MainPageSkeletonDoc.tsx new file mode 100644 index 00000000..09729abb --- /dev/null +++ b/packages/design-system/src/pages/MainPageSkeletonDoc.tsx @@ -0,0 +1,54 @@ +import Playground from '@/layouts/Playground'; + +import { ActivityCardGridSkeleton, CarouselSkeleton } from '../components/MainPageSkeleton/MainPageSkeleton'; +import DocTemplate, { DocCode } from '../layouts/DocTemplate'; + +/* Playground에서 사용할 예시 코드 */ +const code = ` +<> + {/* 인기 체험 섹션 */} +
+

인기 체험

+ +
+ + {/* 모든 체험 섹션 */} +
+

모든 체험

+
+ +
+
+ +`; + +export default function MainPageSkeletonDoc() { + return ( + <> + + + {/* 예시 코드 */} + + + {/* Playground */} +
+ +
+ + ); +} diff --git a/packages/design-system/src/routes/index.tsx b/packages/design-system/src/routes/index.tsx index 279bdbf4..62bb1de8 100644 --- a/packages/design-system/src/routes/index.tsx +++ b/packages/design-system/src/routes/index.tsx @@ -14,6 +14,7 @@ import LandingPage from '@pages/LandingPage'; import LogoDoc from '@pages/LogoDoc'; import MainBannerDoc from '@pages/MainBannerDoc'; import MainCardDoc from '@pages/MainCardDoc'; +import MainPageSkeletonDoc from '@pages/MainPageSkeletonDoc'; import MainSearchInputDoc from '@pages/MainSearchInputDoc'; import ModalDoc from '@pages/ModalDoc'; import MypageProfileHeaderDoc from '@pages/MypageProfileHeaderDoc'; @@ -196,6 +197,10 @@ const router = createBrowserRouter([ path: 'AddressInput', element: , }, + { + path: 'MainPageSkeleton', + element: , + }, ], }, ]);