diff --git a/apps/what-today/src/pages/main/index.tsx b/apps/what-today/src/pages/main/index.tsx index 28d49a4d..2d0a9669 100644 --- a/apps/what-today/src/pages/main/index.tsx +++ b/apps/what-today/src/pages/main/index.tsx @@ -16,101 +16,139 @@ import { TourIcon, WellbeingIcon, } from '@what-today/design-system'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import React from 'react'; import { useNavigate } from 'react-router-dom'; -import type { Activity } from '@/apis/activities'; -import { getActivities } from '@/apis/activities'; +import { type Activity, getActivities } from '@/apis/activities'; + +// React.memo로 MainCard 최적화 +const MemoizedMainCard = React.memo(MainCard.Root); export default function MainPage() { const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(4); - const [searchResult, setSearchResult] = useState([]); - const [sortOrder, setSortOrder] = useState<'latest' | 'asc' | 'desc'>('latest'); // 기본값 최신순 + 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); + }, []); + useEffect(() => { - const handleResize = () => { - const width = window.innerWidth; - if (width < 790) setItemsPerPage(6); - else if (width < 1024) setItemsPerPage(4); - else setItemsPerPage(8); - }; handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); - }, []); + }, [handleResize]); - // 활동 리스트 요청 - const { data: activities = [] } = useQuery({ + // ✅ 데이터 불러오기 + const { data: activities = [] } = useQuery({ queryKey: ['activities'], - queryFn: () => getActivities(), - staleTime: 1000 * 60 * 5, + queryFn: () => getActivities({ size: 100 }), + staleTime: 1000 * 60 * 10, + gcTime: 1000 * 60 * 30, + refetchOnWindowFocus: false, + refetchOnMount: false, }); - // ✅ 인기 체험: 리뷰 많은 순 - const popularActivities = [...activities] - .sort((a, b) => { - if (b.reviewCount === a.reviewCount) { - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - } - return b.reviewCount - a.reviewCount; - }) - .slice(0, 12); - - // ✅ 모든 체험 초기값: 최신순 - useEffect(() => { - if (activities.length > 0 && searchResult.length === 0) { - const latestSorted = [...activities].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - setSearchResult(latestSorted); - } + // 인기 체험 + const popularActivities = useMemo(() => { + 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; + }) + .slice(0, 12); }, [activities]); - // 검색 - const handleSearch = (keyword: string) => { - const sortedLatest = (list: Activity[]) => - [...list].sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()); - - if (keyword === '') { - setSearchResult(sortedLatest(activities)); - setCurrentPage(1); - setSortOrder('latest'); - setSelectedValue(null); - setSelectedCategory(''); - return; + // 1단계: 필터링 + const filteredItems = useMemo(() => { + if (!searchKeyword && selectedCategory === '') return activities; + return activities.filter((item) => { + const matchesSearch = !searchKeyword || item.title.toLowerCase().includes(searchKeyword.toLowerCase()); + const matchesCategory = selectedCategory === '' || item.category === selectedCategory; + return matchesSearch && matchesCategory; + }); + }, [activities, searchKeyword, selectedCategory]); + + // 2단계: 정렬 + const sortedItems = useMemo(() => { + if (sortOrder === 'latest') { + return [...filteredItems].sort( + (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; + }); + }, [filteredItems, sortOrder]); + + // 3단계: 페이지 아이템 + const pagedItems = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return sortedItems.slice(startIndex, endIndex); + }, [sortedItems, currentPage, itemsPerPage]); + + const totalPages = useMemo(() => Math.ceil(sortedItems.length / itemsPerPage), [sortedItems.length, itemsPerPage]); + + // 이벤트 핸들러 + const handlePageChange = useCallback( + (page: number) => { + if (page === currentPage) return; + setCurrentPage(page); + }, + [currentPage], + ); - const result = activities.filter((item) => item.title.toLowerCase().includes(keyword.toLowerCase())); - - setSearchResult(sortedLatest(result)); // ✅ 검색 후에도 최신순 유지 + const handleSearch = useCallback((keyword: string) => { + setSearchKeyword(keyword); setCurrentPage(1); setSortOrder('latest'); setSelectedValue(null); setSelectedCategory(''); - }; + }, []); - // 정렬 변경 시 페이지 초기화 - useEffect(() => { + const handleSortChange = useCallback((item: SelectItem | null) => { + setSelectedValue(item); + if (item) setSortOrder(item.value as 'asc' | 'desc'); setCurrentPage(1); - }, [sortOrder]); - - const filteredItems = - selectedCategory !== '' ? searchResult.filter((item) => item.category === selectedCategory) : searchResult; + }, []); - // 정렬 로직 - const sortedItems = [...filteredItems].sort((a, b) => { - if (sortOrder === 'asc') return a.price - b.price; - if (sortOrder === 'desc') return b.price - a.price; - return new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime(); // 최신순 - }); + const handleCategoryChange = useCallback((category: string | number) => { + setSelectedCategory(category); + setCurrentPage(1); + }, []); - const totalPages = Math.ceil(sortedItems.length / itemsPerPage); - const pagedItems = sortedItems.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); + // 카드 렌더링 최적화 + const renderCards = useCallback(() => { + return pagedItems.map((item, index) => ( + navigate(`/activities/${item.id}`)} + > + + + + )); + }, [pagedItems, currentPage, navigate]); return ( <> @@ -122,11 +160,7 @@ export default function MainPage() {

🔥 인기 체험

- navigate(`/activities/${id}`)} - /> + navigate(`/activities/${id}`)} />
@@ -142,15 +176,7 @@ export default function MainPage() {

🛼 모든 체험

- { - setSelectedValue(item); - if (item) { - setSortOrder(item.value as 'asc' | 'desc'); - } - }} - > + @@ -167,12 +193,12 @@ export default function MainPage() {
- {/* 카테고리 라디오 버튼 */} + {/* 카테고리 */}
@@ -208,25 +234,12 @@ export default function MainPage() {
) : ( - pagedItems.map((item) => ( - navigate(`/activities/${item.id}`)} - > - - - - )) + renderCards() )} {filteredItems.length > 0 && ( - + )} diff --git a/packages/design-system/src/components/Carousel/MobileCarousel.tsx b/packages/design-system/src/components/Carousel/MobileCarousel.tsx deleted file mode 100644 index b639df10..00000000 --- a/packages/design-system/src/components/Carousel/MobileCarousel.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { MainCard } from '../MainCard'; -import type { CarouselProps, Props } from './types'; - -export default function MobileCarousel({ items }: Props) { - return ( -
- - {items.map((item) => ( - - - - - ))} -
- ); -} diff --git a/packages/design-system/src/components/MainCard/Image.tsx b/packages/design-system/src/components/MainCard/Image.tsx index 989cb63b..0081f7df 100644 --- a/packages/design-system/src/components/MainCard/Image.tsx +++ b/packages/design-system/src/components/MainCard/Image.tsx @@ -9,6 +9,7 @@ export default function MainCardImage({ className }: { className?: string }) { {`${title}