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
90 changes: 45 additions & 45 deletions apps/what-today/src/pages/main/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<SelectItem | null>(null);
const [selectedCategory, setSelectedCategory] = useState<string | number>('');
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(() => {
Expand All @@ -49,26 +55,28 @@ export default function MainPage() {
}, [handleResize]);

// ✅ 데이터 불러오기
const { data: activities = [] } = useQuery<Activity[]>({
const {
data: activities = [],
isLoading,
isFetching,
} = useQuery<Activity[]>({
queryKey: ['activities'],
queryFn: () => getActivities({ size: 100 }),
staleTime: 1000 * 60 * 10,
refetchOnMount: 'always',
gcTime: 1000 * 60 * 30,
refetchOnWindowFocus: false,
refetchOnMount: false,
});

// 인기 체험
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;
})
.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]);

Expand All @@ -89,26 +97,21 @@ 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]);

// 이벤트 핸들러
const handlePageChange = useCallback(
(page: number) => {
if (page === currentPage) return;
setCurrentPage(page);
if (page !== currentPage) setCurrentPage(page);
},
[currentPage],
);
Expand All @@ -134,9 +137,9 @@ export default function MainPage() {

// 카드 렌더링 최적화
const renderCards = useCallback(() => {
return pagedItems.map((item, index) => (
return pagedItems.map((item, idx) => (
<MemoizedMainCard
key={`${item.id}-${currentPage}-${index}`}
key={`${item.id}-${currentPage}-${idx}`}
bannerImageUrl={item.bannerImageUrl}
category={item.category}
price={item.price}
Expand All @@ -160,9 +163,11 @@ export default function MainPage() {
{/* 인기 체험 */}
<div className='flex flex-col gap-20'>
<h2 className='title-text'>🔥 인기 체험</h2>
<div className='flex'>
{isLoading ? (
<CarouselSkeleton />
) : (
<Carousel items={popularActivities} itemsPerPage={4} onClick={(id) => navigate(`/activities/${id}`)} />
</div>
)}
</div>

{/* 검색 */}
Expand All @@ -176,7 +181,6 @@ export default function MainPage() {
{/* 제목 + 가격 드롭다운 */}
<div className='flex flex-wrap items-center justify-between gap-12'>
<h2 className='title-text flex items-center gap-12'>🛼 모든 체험</h2>

<Select.Root value={selectedValue} onChangeValue={handleSortChange}>
<Select.Trigger className='flex min-w-fit gap-6 rounded-lg border border-gray-300 bg-white px-8 text-sm'>
<Select.Value className='body-text text-gray-950' placeholder='가격' />
Expand All @@ -202,35 +206,31 @@ export default function MainPage() {
onSelect={handleCategoryChange}
>
<RadioGroup.Radio className='flex gap-8' value='문화 · 예술'>
<ArtIcon className='size-12' />
문화 예술
<ArtIcon className='size-12' /> 문화 예술
</RadioGroup.Radio>
<RadioGroup.Radio value='식음료'>
<FoodIcon className='size-12' />
식음료
<FoodIcon className='size-12' /> 식음료
</RadioGroup.Radio>
<RadioGroup.Radio value='스포츠'>
<SportIcon className='size-12' />
스포츠
<SportIcon className='size-12' /> 스포츠
</RadioGroup.Radio>
<RadioGroup.Radio value='투어'>
<WellbeingIcon className='size-12' />
투어
<WellbeingIcon className='size-12' /> 투어
</RadioGroup.Radio>
<RadioGroup.Radio value='관광'>
<BusIcon className='size-12' />
관광
<BusIcon className='size-12' /> 관광
</RadioGroup.Radio>
<RadioGroup.Radio value='웰빙'>
<TourIcon className='size-12' />
웰빙
<TourIcon className='size-12' /> 웰빙
</RadioGroup.Radio>
</RadioGroup>
</div>

{/* 카드 리스트 */}
<div className='grid grid-cols-2 gap-12 md:grid-cols-2 lg:grid-cols-4'>
{filteredItems.length === 0 ? (
<div className='grid grid-cols-2 gap-12 md:grid-cols-2 xl:grid-cols-4'>
{isLoading || isFetching ? (
<ActivityCardGridSkeleton />
) : filteredItems.length === 0 ? (
<div className='col-span-full flex justify-center py-40'>
<NoResult />
</div>
Expand All @@ -239,7 +239,7 @@ export default function MainPage() {
)}
</div>

{filteredItems.length > 0 && (
{!isLoading && filteredItems.length > 0 && (
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={handlePageChange} />
)}
</div>
Expand Down
18 changes: 2 additions & 16 deletions packages/design-system/src/components/Carousel/Carousel.tsx
Original file line number Diff line number Diff line change
@@ -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<CarouselProps>) {
export default function Carousel({ items, itemsPerPage, onClick }: Props<CarouselProps>) {
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';

interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return <div className={twMerge('animate-pulse rounded-lg bg-gray-100', className)} />;
}

// 단일 카드 스켈레톤
export function ActivityCardSkeleton() {
return (
<div className='relative w-full'>
<Skeleton className='h-260 w-full rounded-xl border border-gray-50 md:h-366 lg:h-340' />
<div className='absolute bottom-0 left-0 w-full'>
<div
className={twMerge(
'flex flex-col gap-8 rounded-xl border border-gray-50 bg-white',
'px-20 py-12 md:px-20 md:py-30 lg:py-15',
)}
>
<Skeleton className='h-16 w-1/3' />
<Skeleton className='h-16 w-3/4' />
<div className='flex items-center gap-8'>
<Skeleton className='h-14 w-14 rounded-full' />
<Skeleton className='h-14 w-12' />
<Skeleton className='h-14 w-12' />
</div>
<Skeleton className='h-16 w-1/2' />
</div>
</div>
</div>
);
}

// 반응형 스켈레톤 개수 계산
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) => (
<ActivityCardSkeleton key={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 (
<div className='relative w-full overflow-visible'>
<div className='relative mx-auto'>
{/* 태블릿·데스크탑 */}
<div className='relative hidden w-full overflow-hidden md:block'>
<div className='flex'>
{Array.from({ length: perPage }).map((_, i) => (
<div
key={i}
className={twMerge('box-border shrink-0', i !== perPage - 1 ? 'pr-10' : '')}
style={{ width: `${100 / perPage}%` }}
>
<ActivityCardSkeleton />
</div>
))}
</div>
</div>
{/* 모바일 */}
<div
className='flex w-full gap-6 overflow-x-auto px-4 md:hidden'
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<style>{`div::-webkit-scrollbar { display: none; }`}</style>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className='w-265 shrink-0'>
<ActivityCardSkeleton />
</div>
))}
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { ActivityCardSkeleton } from './MainPageSkeleton';
export { ActivityCardGridSkeleton } from './MainPageSkeleton';
export { CarouselSkeleton } from './MainPageSkeleton';
1 change: 1 addition & 0 deletions packages/design-system/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/design-system/src/layouts/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export default function DesignSystemLayout() {
<SidebarNavItem label='MypageSummaryCard' to='/docs/MypageSummaryCard' />
<SidebarNavItem label='UpcomingSchedule' to='/docs/UpcomingSchedule' />
<SidebarNavItem label='OngoingExperienceCard' to='/docs/OngoingExperienceCard' />
<SidebarNavItem label='MainPageSkeleton' to='/docs/MainPageSkeleton' />
</ul>
</nav>
</aside>
Expand Down
Loading