Skip to content
29 changes: 19 additions & 10 deletions src/app/(pages)/contest/[id]/_components/recommended-courses.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { cn } from '@/utils/cn';

interface Course {
id: string;
Expand All @@ -22,21 +23,29 @@ function ContestCourseCard({ course }: { course: Course }) {
}
};

// 이미지 URL 유효성 검사
const hasValidImage = course.imageUrl && course.imageUrl.trim() !== '';

return (
<div
className='relative h-[196px] w-[168px] flex-shrink-0 cursor-pointer overflow-hidden rounded-[20px] transition-transform hover:scale-105'
className={cn(
'relative h-[196px] w-[168px] flex-shrink-0 cursor-pointer overflow-hidden rounded-[20px] transition-transform hover:scale-105',
!hasValidImage && 'bg-black',
)}
onClick={handleClick}
>
<Image
src={course.imageUrl}
alt={`${course.title} 코스 이미지`}
fill
className='object-cover'
/>
{hasValidImage && (
<Image
src={course.imageUrl}
alt={`${course.title} 코스 이미지`}
fill
className='object-cover'
/>
)}

<div className='absolute inset-0 bg-gradient-to-t from-black/60 to-transparent' />

<div className='absolute bottom-4 left-4 text-white'>
<div className='keep absolute bottom-4 px-4 text-white'>
<p className='text-white000 text-[18px] leading-[140%] font-bold'>
{course.title}
</p>
Expand All @@ -61,9 +70,9 @@ export function RecommendedCourses({ courses }: RecommendedCoursesProps) {
<nav className='bg-gray-0 h-2 w-full' />
<div className='h-7' />

<div className='px-4'>
<div className='pl-4'>
<h2 className='text-title2 mb-4'>같이 달리기 좋은 코스</h2>
<div className='scrollbar-hide flex gap-3 overflow-x-auto'>
<div className='scrollbar-hide flex gap-3 overflow-x-auto pr-4'>
{courses.map(course => (
<ContestCourseCard key={course.id} course={course} />
))}
Expand Down
85 changes: 42 additions & 43 deletions src/app/(pages)/contest/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default function ContestDetailPage({ params }: ContestDetailPageProps) {
isLeftIcon
onClickLeftIcon={() => router.back()}
/>
<div className='flex flex-1 items-center justify-center'>
<div className='mt-14 flex flex-1 items-center justify-center'>
<div>로딩 중...</div>
</div>
</div>
Expand All @@ -60,7 +60,7 @@ export default function ContestDetailPage({ params }: ContestDetailPageProps) {
isLeftIcon
onClickLeftIcon={() => router.back()}
/>
<div className='flex flex-1 items-center justify-center'>
<div className='mt-14 flex flex-1 items-center justify-center'>
<div>대회 정보를 찾을 수 없습니다.</div>
</div>
</div>
Expand All @@ -74,55 +74,54 @@ export default function ContestDetailPage({ params }: ContestDetailPageProps) {
isLeftIcon
onClickLeftIcon={() => router.back()}
/>
<div className='mt-14'>
<nav className='bg-gray-0 h-2 w-full' />

<nav className='bg-gray-0 h-2 w-full' />
<div className='flex-1 overflow-y-auto'>
<ContestInfo
date={`${contestData.month}. ${contestData.day}.`}
day={contestData.dayOfWeek}
title={contestData.title}
location={contestData.addr}
distances={contestData.prices.map(price => price.type)}
/>

<div className='flex-1 overflow-y-auto'>
<ContestInfo
date={`${contestData.month}. ${contestData.day}.`}
day={contestData.dayOfWeek}
title={contestData.title}
location={contestData.addr}
distances={contestData.prices.map(price => price.type)}
/>
<ContestDetails
organizer={contestData.host}
fees={contestData.prices.map(price => ({
distance: price.type,
price: price.price,
label: `(${price.type})`,
}))}
/>

<ContestDetails
organizer={contestData.host}
fees={contestData.prices.map(price => ({
distance: price.type,
price: price.price,
label: `(${price.type})`,
}))}
/>
<div className='h-9' />

<div className='h-9' />
<RecommendedCourses
courses={contestData.courseInfos.map(course => ({
id: course.crsIdx,
title: course.crsKorNm,
location: course.sigun,
imageUrl: course.crsImgUrl,
crsIdx: course.crsIdx,
}))}
/>

<RecommendedCourses
courses={contestData.courseInfos.map(course => ({
id: course.crsIdx,
title: course.crsKorNm,
location: course.sigun,
imageUrl: course.crsImgUrl,
crsIdx: course.crsIdx,
}))}
/>

<div className='h-12' />
<div
className={cn('px-5 pt-5', {
'border-gray-1 border-t': contestData.courseInfos.length > 0,
})}
>
<button
className='text-title2 bg-point-400 w-full rounded-[12px] py-3 text-white'
onClick={() => window.open(contestData.homepageUrl, '_blank')}
<div className='h-12' />
<div
className={cn('px-5 pt-5', {
'border-gray-1 border-t': contestData.courseInfos.length > 0,
})}
>
홈페이지로 이동하기
</button>
<button
className='text-title2 bg-point-400 w-full rounded-[12px] py-3 text-white'
onClick={() => window.open(contestData.homepageUrl, '_blank')}
>
홈페이지로 이동하기
</button>
</div>
</div>
</div>

<div className='h-25' />
</div>
);
}
2 changes: 1 addition & 1 deletion src/app/(pages)/contest/_components/contest-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function ContestCard({
<div
className={cn(
'cursor-pointer rounded-[20px] bg-white px-6 py-5 transition-transform active:scale-95',
'shadow-[0_4px_16px_0_rgba(158,170,181,0.20)]',
'mb-8 shadow-[0_4px_16px_0_rgba(158,170,181,0.20)]',
className,
)}
onClick={() => onClick?.(id)}
Expand Down
93 changes: 65 additions & 28 deletions src/app/(pages)/contest/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,53 @@

import { useQuery } from '@tanstack/react-query';
import { ContestCard } from './_components/contest-card';
import { PageHeader } from '@/components/page-header';
import { useRouter } from 'next/navigation';
import { getMarathons } from '@/lib/api/contest';
import { ContestSkeleton } from './_components/contest-skeleton';
import { useInfiniteScroll } from '../course/_hooks/use-infinite-scroll';
import { useCallback, useEffect, useState } from 'react';
import { Marathon } from '@/interfaces/contest/contest.types';

export default function ContestPage() {
const router = useRouter();
const [currentPage, setCurrentPage] = useState(1);
const [allMarathons, setAllMarathons] = useState<Marathon[]>([]);
const [hasNextPage, setHasNextPage] = useState(true);
const [isLoading, setIsLoading] = useState(false);

const { data: marathonData, isLoading } = useQuery({
queryKey: ['marathons', 1],
queryFn: () => getMarathons(1),
const { data: marathonData, isLoading: loading } = useQuery({
queryKey: ['marathons', currentPage],
queryFn: () => getMarathons(currentPage),
});

// 새로운 대회 데이터가 로드될 때마다 상태 업데이트
useEffect(() => {
if (marathonData?.data) {
const newMarathons = marathonData.data.content.map(item => item.data);

if (currentPage === 1) {
setAllMarathons(newMarathons);
} else {
setAllMarathons(prev => [...prev, ...newMarathons]);
}

setHasNextPage(!marathonData.data.last);
setIsLoading(false);
}
}, [marathonData, currentPage]);

const handleLoadMore = useCallback(() => {
if (!loading && hasNextPage) {
setIsLoading(true);
setCurrentPage(prev => prev + 1);
}
}, [loading, hasNextPage]);

const { lastElementRef } = useInfiniteScroll({
hasNextPage,
isLoading: loading || isLoading,
onLoadMore: handleLoadMore,
threshold: 0.1,
rootMargin: '100px',
});

const handleContestClick = (id: string) => {
Expand All @@ -21,34 +57,35 @@ export default function ContestPage() {

return (
<div className='flex h-screen flex-col'>
<PageHeader title='러닝 대회' />
<header className='border-gray-0 bg-gray-bg mobile-area fixed top-0 right-0 left-0 z-50 flex h-[62px] flex-col items-center justify-center border-b-4 pt-1.5 pb-1'>
<p className='text-title1'>러닝 대회</p>
</header>
<div className='mt-14'>
{/* 대회 목록 */}
<div className='flex-1 overflow-y-auto px-5 pt-9'>
{allMarathons.map((marathon, index) => {
const shouldAttachObserver = index % 10 === 5;

<nav className='bg-gray-0 h-2 w-full' />

{/* 대회 목록 */}
{isLoading ? (
<ContestSkeleton />
) : (
<div className='flex flex-col gap-7 overflow-y-auto px-4 py-5'>
{marathonData?.data?.content?.map(item => {
const marathon = item.data;
return (
<ContestCard
key={marathon.marathonId}
id={marathon.marathonId.toString()}
date={`${marathon.month}. ${marathon.day}.`}
day={marathon.dayOfWeek}
title={marathon.title}
location={marathon.addr}
distances={marathon.types}
onClick={handleContestClick}
/>
<div
key={`${marathon.marathonId}-${index}`}
ref={shouldAttachObserver ? lastElementRef : null}
>
<ContestCard
id={marathon.marathonId.toString()}
date={`${marathon.month}. ${marathon.day}.`}
day={marathon.dayOfWeek}
title={marathon.title}
location={marathon.addr}
distances={marathon.types}
onClick={handleContestClick}
/>
</div>
);
})}
</div>
)}

<div className='h-50' />
<div className='h-16' />
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function CourseCard({

<div className='absolute inset-0 bg-gradient-to-t from-black/60 to-transparent' />

<div className='absolute bottom-4 left-4 text-white'>
<div className='absolute bottom-4 px-4 break-keep text-white'>
<p className='text-white000 text-[18px] leading-[140%] font-bold'>
{title}
</p>
Expand Down
22 changes: 14 additions & 8 deletions src/app/(pages)/save/_components/course-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,28 @@ export function CourseCard({
router.push(`/course/${crsIdx}`);
};

// 이미지 URL 유효성 검사
const hasValidImage = imageUrl && imageUrl.trim() !== '';

return (
<div
className={cn(
'relative cursor-pointer overflow-hidden rounded-2xl transition-transform hover:scale-101',
!hasValidImage && 'bg-black',
className,
)}
onClick={handleClick}
>
<Image
src={imageUrl}
alt={title}
fill
className='h-full w-full object-cover'
priority
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
/>
{hasValidImage && (
<Image
src={imageUrl}
alt={title}
fill
className='h-full w-full object-cover'
priority
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
/>
)}
<div className='absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/60 to-transparent p-4'>
<h3 className='text-title2 text-white'>{title}</h3>
<p className='text-body4 text-white'>{location}</p>
Expand Down
2 changes: 1 addition & 1 deletion src/app/(pages)/save/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export default async function SavePage() {
</header>
<div className='mt-14'>
<SavePageContent favoriteCourses={favoriteCourses.data!} />
<div className='h-16' />
</div>
<div className='h-25' />
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/course-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default function CourseCard({

<div className='absolute inset-0 bg-gradient-to-t from-black/60 to-transparent' />

<div className='absolute bottom-4 left-4 text-white'>
<div className='absolute bottom-4 px-4 break-keep text-white'>
<p className='text-white000 text-[18px] leading-[140%] font-bold'>
{title}
</p>
Expand Down
2 changes: 1 addition & 1 deletion src/components/page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function PageHeader({
return (
<header
className={cn(
'relative mt-[6px] mb-1 flex h-[52px] shrink-0 items-center justify-center',
'border-gray-0 bg-gray-bg mobile-area fixed top-0 right-0 left-0 z-50 flex h-[62px] flex-col items-center justify-center border-b-4 pt-1.5 pb-1',
className,
)}
>
Expand Down
Loading