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
25 changes: 21 additions & 4 deletions apps/what-today/src/pages/main/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,39 @@ import { type Activity, getActivities } from '@/apis/activities';

const MemoizedMainCard = React.memo(MainCard.Root);

// ✅ 화면 너비에 따른 카드 개수
// ✅ 화면 너비에 따른 카드 개수 (모든 체험용)
const getCount = () => {
const w = window.innerWidth;
if (w < 768) return 6; // 모바일
if (w < 1280) return 4; // 태블릿
return 8; // 데스크탑
};

// ✅ 인기 체험용 반응형 카드 개수
const MOBILE_BREAK = 768;
const TABLET_BREAK = 1280;

const getPopularPerPage = () => {
const w = window.innerWidth;
if (w < MOBILE_BREAK) return 4; // 모바일
if (w < TABLET_BREAK) return 2; // 태블릿
return 4; // 데스크탑
};

export default function MainPage() {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(() => getCount());
const [popularPerPage, setPopularPerPage] = useState(() => getPopularPerPage());
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(() => {
setItemsPerPage(getCount());
setPopularPerPage(getPopularPerPage());
}, []);

useEffect(() => {
Expand Down Expand Up @@ -157,7 +170,7 @@ export default function MainPage() {
return (
<>
<div className='to-primary-500/40 absolute top-0 left-0 h-1/2 w-full bg-gradient-to-t from-transparent' />
<div className='relative z-10 mt-40 flex h-auto flex-col gap-60'>
<div className='relative z-10 flex h-auto flex-col gap-60'>
<MainBanner />

{/* 인기 체험 */}
Expand All @@ -166,7 +179,11 @@ export default function MainPage() {
{isLoading ? (
<CarouselSkeleton />
) : (
<Carousel items={popularActivities} itemsPerPage={4} onClick={(id) => navigate(`/activities/${id}`)} />
<Carousel
items={popularActivities}
itemsPerPage={popularPerPage}
onClick={(id) => navigate(`/activities/${id}`)}
/>
)}
</div>

Expand Down
124 changes: 66 additions & 58 deletions packages/design-system/src/components/Carousel/Carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,71 +16,79 @@ export default function Carousel({ items, itemsPerPage, onClick }: Props<Carouse

return (
<div className='relative w-full overflow-visible'>
<div className='relative mx-auto flex items-center justify-center'>
{/* 왼쪽 버튼 */}
<NavigationButton direction='left' disabled={page === 0} onClick={handlePrev} />
{/* 왼쪽 버튼 (absolute 배치) */}
<NavigationButton
className='absolute top-1/2 left-0 z-10 -translate-x-1/2 -translate-y-1/2'
direction='left'
disabled={page === 0}
onClick={handlePrev}
/>

{/* 데스크탑/태블릿 캐러셀 */}
<div className='relative hidden w-full overflow-hidden md:block'>
<motion.div
animate={{ x: `-${page * 100}%` }}
className='flex'
style={{ pointerEvents: 'auto' }}
transition={{ duration: 0.5, ease: [0.45, 0.05, 0.55, 0.95] }}
>
{items.map((item, idx) => (
<div
key={item.id}
className={`box-border shrink-0 ${idx % itemsPerPage !== itemsPerPage - 1 ? 'pr-10' : ''}`}
style={{ width: `${itemWidthPercent}%` }}
>
<MainCard.Root
bannerImageUrl={item.bannerImageUrl}
category={item.category}
price={item.price}
rating={item.rating}
reviewCount={item.reviewCount}
title={item.title}
onClick={() => onClick?.(item.id)}
>
<MainCard.Image />
<MainCard.Content />
</MainCard.Root>
</div>
))}
</motion.div>
</div>

{/* 모바일 캐러셀 */}
<div
className='flex w-full gap-6 overflow-x-auto px-4 md:hidden'
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
{/* 데스크탑/태블릿 캐러셀 */}
<div className='relative hidden w-full overflow-hidden md:block'>
<motion.div
animate={{ x: `-${page * 100}%` }}
className='flex'
style={{ pointerEvents: 'auto' }}
transition={{ duration: 0.5, ease: [0.45, 0.05, 0.55, 0.95] }}
>
<style>{`div::-webkit-scrollbar { display: none; }`}</style>
{items.map((item) => (
<MainCard.Root
{items.map((item, idx) => (
<div
key={item.id}
bannerImageUrl={item.bannerImageUrl}
category={item.category}
className='w-265 shrink-0'
price={item.price}
rating={item.rating}
reviewCount={item.reviewCount}
title={item.title}
onClick={() => onClick?.(item.id)}
className={`box-border shrink-0 ${idx % itemsPerPage !== itemsPerPage - 1 ? 'pr-10' : ''}`}
style={{ width: `${itemWidthPercent}%` }}
>
<MainCard.Image className='brightness-90 contrast-125' />
<MainCard.Content />
</MainCard.Root>
<MainCard.Root
bannerImageUrl={item.bannerImageUrl}
category={item.category}
price={item.price}
rating={item.rating}
reviewCount={item.reviewCount}
title={item.title}
onClick={() => onClick?.(item.id)}
>
<MainCard.Image />
<MainCard.Content />
</MainCard.Root>
</div>
))}
</div>
</motion.div>
</div>

{/* 오른쪽 버튼 */}
<NavigationButton direction='right' disabled={page === totalPages - 1} onClick={handleNext} />
{/* 모바일 캐러셀 */}
<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>
{items.map((item) => (
<MainCard.Root
key={item.id}
bannerImageUrl={item.bannerImageUrl}
category={item.category}
className='w-265 shrink-0'
price={item.price}
rating={item.rating}
reviewCount={item.reviewCount}
title={item.title}
onClick={() => onClick?.(item.id)}
>
<MainCard.Image className='brightness-90 contrast-125' />
<MainCard.Content />
</MainCard.Root>
))}
</div>

{/* 오른쪽 버튼 (absolute 배치) */}
<NavigationButton
className='absolute top-1/2 right-0 z-10 translate-x-1/2 -translate-y-1/2'
direction='right'
disabled={page === totalPages - 1}
onClick={handleNext}
/>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,24 @@ interface Props {
* 버튼 비활성화 여부
*/
disabled: boolean;

/**
* 추가 클래스명
*/
className?: string;
}

/**
* 캐러셀의 페이지 이동을 위한 네비게이션 버튼 컴포넌트입니다.
* - 화면 너비가 md 이상일 때만 표시됩니다.
* - 방향에 따라 왼쪽 또는 오른쪽 버튼을 렌더링합니다.
*/
export default function NavigationButton({ direction, onClick, disabled }: Props) {
export default function NavigationButton({ direction, onClick, disabled, className = '' }: Props) {
const marginClass = direction === 'left' ? '-mr-20' : '-ml-20';

return (
<button
className={`z-10 ${marginClass} hidden size-40 cursor-pointer items-center justify-center rounded-full border border-gray-50 bg-white text-xl hover:bg-gray-50 disabled:opacity-0 md:flex`}
className={`z-10 ${marginClass} hidden size-40 cursor-pointer items-center justify-center rounded-full border border-gray-50 bg-white text-xl hover:bg-gray-50 disabled:opacity-0 md:flex ${className}`}
disabled={disabled}
onClick={onClick}
>
Expand Down
2 changes: 1 addition & 1 deletion packages/design-system/src/components/MainCard/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function MainCardContent({
<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',
'flex flex-col gap-8 rounded-xl border border-gray-50 bg-white px-20 py-12 md:px-20 md:py-20 xl:py-15',
className,
)}
>
Expand Down
2 changes: 1 addition & 1 deletion packages/design-system/src/components/MainCard/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default function MainCardImage({ className }: { className?: string }) {
<div className={twMerge('w-full rounded-xl border border-gray-50', className)}>
<img
alt={`${title} 체험 이미지`}
className='h-260 w-full rounded-xl object-cover md:h-366 lg:h-340'
className='h-260 w-full rounded-xl object-cover md:h-366 xl:h-340'
loading='lazy'
src={bannerImageUrl}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,28 @@ export function Skeleton({ className }: SkeletonProps) {
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' />
<Skeleton className='h-260 w-full rounded-xl border border-gray-50 md:h-366 xl: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',
'px-20 py-12 md:px-20 md:py-20 xl: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 className='flex flex-col gap-13 md:gap-21 xl:gap-30'>
<div className='flex flex-col gap-13 md:gap-16 xl:gap-12'>
<Skeleton className='h-16 w-1/3' />
<Skeleton className='h-16 w-3/4' />
<div className='flex gap-4'>
<Skeleton className='h-14 w-14 rounded-full' />
<Skeleton className='h-14 w-12' />
<Skeleton className='h-14 w-12' />
</div>
</div>
<div>
<Skeleton className='h-16 w-1/2' />
</div>
</div>
<Skeleton className='h-16 w-1/2' />
</div>
</div>
</div>
Expand Down Expand Up @@ -61,7 +67,6 @@ export function ActivityCardGridSkeleton() {
}

// Carousel용 스켈레톤 (인기 체험 반응형)

interface CarouselSkeletonProps {
count?: number;
}
Expand All @@ -77,11 +82,10 @@ export function CarouselSkeleton({ count = 4 }: CarouselSkeletonProps) {
};

const [perPage, setPerPage] = useState(() => getInitial());
const itemWidthPercent = 100 / perPage; // ✅ 실제 Carousel과 동일 계산

useEffect(() => {
const onResize = () => setPerPage(getInitial());
// 마운트 시 한 번 실행
onResize();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [count]);
Expand All @@ -95,14 +99,15 @@ export function CarouselSkeleton({ count = 4 }: CarouselSkeletonProps) {
{Array.from({ length: perPage }).map((_, i) => (
<div
key={i}
className={twMerge('box-border shrink-0', i !== perPage - 1 ? 'pr-10' : '')}
style={{ width: `${100 / perPage}%` }}
className={`box-border shrink-0 ${i % perPage !== perPage - 1 ? 'pr-10' : ''}`} // ✅ gap 대신 padding-right 적용
style={{ width: `${itemWidthPercent}%` }}
>
<ActivityCardSkeleton />
</div>
))}
</div>
</div>

{/* 모바일 */}
<div
className='flex w-full gap-6 overflow-x-auto px-4 md:hidden'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,17 @@ export default function MainSearchInput({ onClick }: MainSearchInputProps) {

return (
<div className='relative flex w-full items-center justify-between bg-white'>
<div className='absolute inset-y-0 left-0 flex items-center pl-20 md:pl-32'>
<SearchIcon className='cursor-pointer text-gray-400' />
</div>

<Input.Root className='w-full'>
<Input.Wrapper className='body-textn rounded-3xl border-none py-20 shadow-sm'>
<Input.Icon className='cursor-pointer'>🔎</Input.Icon>
<Input.Field placeholder='내가 원하는 체험은...' value={value} onChange={(e) => setValue(e.target.value)} />
<Input.Wrapper className='body-textn relative rounded-3xl border-gray-50 py-20'>
<div className='absolute inset-y-0 left-0 flex items-center pl-20 md:pl-32'>
<SearchIcon className='cursor-pointer text-gray-400' />
</div>
<Input.Field
className='px-30 md:px-40'
placeholder='내가 원하는 체험은...'
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Input.Wrapper>
<Input.ErrorMessage />
</Input.Root>
Expand Down