Skip to content

Commit ef5f83c

Browse files
authored
Merge pull request #83 from codeit-2team/feat/45-3
Feat/45-3 메인페이지 모든 체험 UI 구현 및 API 연동
2 parents c4d9e92 + 563231f commit ef5f83c

File tree

13 files changed

+432
-51
lines changed

13 files changed

+432
-51
lines changed

src/app/(with-header)/components/BannerSection.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import Image from 'next/image';
2-
import SearchBar from './SearchBar';
32

4-
export default function BannerSection() {
3+
import SearchBar from '@/app/(with-header)/components/SearchBar';
4+
5+
interface BannerSectionProps {
6+
onSearch: (keyword: string) => void;
7+
}
8+
9+
export default function BannerSection({ onSearch }: BannerSectionProps) {
510
return (
611
<section className='relative w-full h-240 md:h-550 mb-93'>
712
{/* 배경 이미지 */}
813
<Image
9-
src='/test/image1.png'
10-
alt='스트릿 댄스'
1114
fill
12-
className='object-cover'
1315
priority
16+
alt='스트릿 댄스'
17+
className='object-cover'
18+
src='/test/image1.png'
1419
/>
1520

1621
{/* 어두운 오버레이 */}
@@ -27,7 +32,7 @@ export default function BannerSection() {
2732
</p>
2833
</div>
2934
<div className='absolute -bottom-100 left-0 right-0'>
30-
<SearchBar />
35+
<SearchBar onSearch={onSearch} />
3136
</div>
3237
</section>
3338
);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
import Button from '@/components/Button';
4+
import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories';
5+
import cn from '@/lib/cn';
6+
7+
interface CategoryFilterProps {
8+
selectedCategory: ActivityCategory;
9+
onChange: (category: ActivityCategory) => void;
10+
className?: string;
11+
}
12+
13+
export default function CategoryFilter({
14+
selectedCategory,
15+
onChange,
16+
className,
17+
}: CategoryFilterProps) {
18+
return (
19+
<div className={cn('relative flex w-full gap-8 overflow-x-auto whitespace-nowrap no-scrollbar', className)}>
20+
{ACTIVITY_CATEGORIES.map((category) => (
21+
<Button
22+
key={category}
23+
className='flex-shrink-0 max-w-80 max-h-41 py-12 text-[16px] rounded-[15px]'
24+
selected={selectedCategory === category}
25+
variant='category'
26+
onClick={() => onChange(category)}
27+
>
28+
{category}
29+
</Button>
30+
))}
31+
<div className='pointer-events-none absolute top-0 right-0 h-full w-100 bg-gradient-to-l from-white to-transparent' />
32+
</div>
33+
);
34+
}

src/app/(with-header)/components/ExperienceCard.tsx

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,43 @@
11
import Image from 'next/image';
22

3-
export default function ExperienceCard() {
3+
interface Props {
4+
imageUrl: string;
5+
title: string;
6+
rating: number;
7+
reviews: number;
8+
price: number;
9+
}
10+
11+
export default function ExperienceCard({
12+
imageUrl,
13+
title,
14+
rating,
15+
reviews,
16+
price,
17+
}: Props) {
418
return (
5-
<div className='relative w-186 h-186 md:w-384 md:h-384 rounded-[20px] overflow-hidden shadow-md bg-white'>
6-
{/* 배경 이미지 */}
7-
<Image
8-
src='/test/image1.png'
9-
alt='체험 이미지'
10-
className='w-full object-cover'
11-
fill
12-
/>
13-
{/* 어두운 오버레이 */}
14-
<div className='absolute inset-0 bg-gradient-to-r from-black to-transparent' />
15-
{/* 텍스트 정보 블록 (카드 하단 위치 고정) */}
16-
<div className='absolute bottom-12 flex flex-col gap-6 md:gap-20 px-20 py-12 text-white'>
17-
{/* 별점 정보 */}
18-
<span className='text-md'>⭐ 4.9 (293)</span>
19-
{/* 체험명 (줄바꿈 포함, 반응형 크기) */}
20-
<p className='text-2lg md:text-3xl font-semibold'>함께 배우면 즐거운<br />스트릿 댄스</p>
21-
{/* 가격 정보 */}
22-
<p className='text-lg md:text-xl'>₩ 38,000 <span className='text-gray-600 text-md'>/ 인</span></p>
19+
<div className='flex flex-col w-full gap-16'>
20+
{/* 썸네일 */}
21+
<div className='relative w-full h-168 md:h-221 lg:h-283 rounded-[20px] overflow-hidden'>
22+
<Image
23+
fill
24+
alt={title}
25+
className='object-cover'
26+
src={imageUrl}
27+
/>
28+
</div>
29+
30+
{/* 텍스트 정보 */}
31+
<div className='flex flex-col'>
32+
<span className='pb-10 text-lg text-black'>
33+
{rating} <span className='text-gray-700 text-lg'>({reviews})</span>
34+
</span>
35+
<p className='pb-15 text-2lg font-semibold text-black line-clamp-2'>
36+
{title}
37+
</p>
38+
<p className='text-xl text-black font-bold'>
39+
{price.toLocaleString()} <span className='text-gray-900 text-lg font-medium'>/ 인</span>
40+
</p>
2341
</div>
2442
</div>
2543
);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use client';
2+
3+
import Dropdown from '@components/Dropdown';
4+
import Pagination from '@components/Pagination';
5+
import { useEffect, useState } from 'react';
6+
import Link from 'next/link';
7+
8+
import CategoryFilter from '@/app/(with-header)/components/CategoryFilter';
9+
import ExperienceCard from '@/app/(with-header)/components/ExperienceCard';
10+
import { getExperiences } from '@/app/api/experiences/getExperiences';
11+
import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories';
12+
import {
13+
SORT_OPTIONS,
14+
SORT_VALUE_MAP,
15+
SORT_LABEL_MAP,
16+
} from '@/constants/SortPrices';
17+
import { Experience } from '@/types/experienceListTypes';
18+
19+
interface ExperienceListProps {
20+
keyword?: string;
21+
isSearchMode?: boolean;
22+
}
23+
24+
export default function ExperienceList({ keyword, isSearchMode }: ExperienceListProps) {
25+
const [currentPage, setCurrentPage] = useState(1);
26+
const [selectedCategory, setSelectedCategory] = useState<ActivityCategory>(ACTIVITY_CATEGORIES[0]);
27+
const [sortOption, setSortOption] = useState<string | undefined>('');
28+
const [experiences, setExperiences] = useState<Experience[]>([]);
29+
const [totalCount, setTotalCount] = useState(0);
30+
31+
useEffect(() => {
32+
const resync = async () => {
33+
const res = await getExperiences({
34+
page: currentPage,
35+
sort: sortOption,
36+
category: selectedCategory,
37+
keyword,
38+
});
39+
40+
setExperiences(res.experiences);
41+
setTotalCount(res.totalCount);
42+
};
43+
44+
resync();
45+
}, [currentPage, sortOption, selectedCategory, keyword]);
46+
47+
useEffect(() => {
48+
if (keyword) {
49+
setSelectedCategory(ACTIVITY_CATEGORIES[0]);
50+
setSortOption('');
51+
setCurrentPage(1);
52+
}
53+
}, [keyword]);
54+
55+
const totalPage = Math.ceil(totalCount / 8);
56+
57+
return (
58+
<section className='max-w-1200 m-auto px-24 md:px-0 pb-83'>
59+
60+
{/* 🔍 검색 모드일 때 문구 표시 */}
61+
{isSearchMode && keyword && (
62+
<>
63+
<p className="text-left text-lg font-semibold ml-4 md:ml-0 mt-32">
64+
<span className="text-primary font-bold">"{keyword}"</span>
65+
(으)로 검색한 결과입니다.
66+
</p>
67+
68+
<p className="text-left text-sm font-normal ml-4 md:ml-0 mt-8 mb-16">
69+
<span className="font-semibold">{totalCount}</span>개의 결과
70+
</p>
71+
72+
{experiences.length === 0 && (
73+
<p className="text-center text-gray-500 mt-32">검색 결과가 없습니다.</p>
74+
)}
75+
</>
76+
)}
77+
78+
{/* 🧭 필터/정렬 UI (검색 모드 아닐 때만) */}
79+
{!isSearchMode && (
80+
<div className='flex justify-between items-center mb-40'>
81+
<CategoryFilter
82+
selectedCategory={selectedCategory}
83+
onChange={(category) => {
84+
setSelectedCategory(category);
85+
setCurrentPage(1);
86+
}}
87+
/>
88+
<Dropdown
89+
className='w-200'
90+
placeholder='가격'
91+
options={SORT_OPTIONS}
92+
value={sortOption && SORT_LABEL_MAP[sortOption as keyof typeof SORT_LABEL_MAP] || ''}
93+
onChange={(label: keyof typeof SORT_VALUE_MAP) => {
94+
const value = SORT_VALUE_MAP[label];
95+
setSortOption(value);
96+
setCurrentPage(1);
97+
}}
98+
/>
99+
</div>
100+
)}
101+
102+
<div className='m-0'>
103+
{/* 🚂 모든 체험 제목 (검색 아닐 때만) */}
104+
{!isSearchMode && (
105+
<h2 className='text-xl md:text-3xl font-bold'>🛼 모든 체험</h2>
106+
)}
107+
108+
{/* 체험 카드 목록 */}
109+
<div className='grid grid-cols-2 grid-rows-2 md:grid-cols-3 md:grid-rows-3 lg:grid-cols-4 lg:grid-rows-2 gap-8 md:gap-16 lg:gap-24 mt-24'>
110+
{experiences.map((exp) => (
111+
<Link
112+
key={exp.id}
113+
href={`/activities/${exp.id}`} // 아이디 기반 라우팅
114+
>
115+
<ExperienceCard
116+
imageUrl={exp.bannerImageUrl}
117+
price={exp.price}
118+
rating={exp.rating}
119+
reviews={exp.reviewCount}
120+
title={exp.title}
121+
/>
122+
</Link>
123+
))}
124+
</div>
125+
</div>
126+
127+
{/* 페이지네이션: 결과 있을 때만 표시 */}
128+
{experiences.length > 0 && (
129+
<Pagination
130+
currentPage={currentPage}
131+
totalPage={totalPage}
132+
onPageChange={setCurrentPage}
133+
/>
134+
)}
135+
</section>
136+
);
137+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Image from 'next/image';
2+
3+
interface PopularCardProps {
4+
imageUrl: string;
5+
title: string;
6+
rating: number;
7+
reviews: number;
8+
price: number;
9+
}
10+
11+
export default function PopularCard({
12+
imageUrl,
13+
title,
14+
rating,
15+
reviews,
16+
price,
17+
}: PopularCardProps) {
18+
return (
19+
<div className='relative w-186 h-186 md:w-384 md:h-384 rounded-[20px] overflow-hidden shadow-md bg-white'>
20+
{/* 배경 이미지 */}
21+
<Image
22+
fill
23+
alt={title}
24+
className='w-full object-cover'
25+
src={imageUrl}
26+
/>
27+
{/* 어두운 오버레이 */}
28+
<div className='absolute inset-0 bg-gradient-to-r from-black to-transparent' />
29+
{/* 텍스트 정보 블록 (카드 하단 위치 고정) */}
30+
<div className='absolute bottom-12 flex flex-col gap-6 md:gap-20 px-20 py-12 text-white'>
31+
{/* 별점 정보 */}
32+
<span className='text-md'>{rating} ({reviews})</span>
33+
{/* 체험명 (줄바꿈 포함, 반응형 크기) */}
34+
<p className='text-2lg md:text-3xl font-semibold'>{title}</p>
35+
{/* 가격 정보 */}
36+
<p className='text-lg md:text-xl'>{price.toLocaleString()} <span className='text-gray-600 text-md'>/ 인</span></p>
37+
</div>
38+
</div>
39+
);
40+
}

0 commit comments

Comments
 (0)