diff --git a/src/app/(with-header)/components/BannerSection.tsx b/src/app/(with-header)/components/BannerSection.tsx index 529ac80..5a6762a 100644 --- a/src/app/(with-header)/components/BannerSection.tsx +++ b/src/app/(with-header)/components/BannerSection.tsx @@ -1,16 +1,21 @@ import Image from 'next/image'; -import SearchBar from './SearchBar'; -export default function BannerSection() { +import SearchBar from '@/app/(with-header)/components/SearchBar'; + +interface BannerSectionProps { + onSearch: (keyword: string) => void; +} + +export default function BannerSection({ onSearch }: BannerSectionProps) { return (
{/* 배경 이미지 */} 스트릿 댄스 {/* 어두운 오버레이 */} @@ -27,7 +32,7 @@ export default function BannerSection() {

- +
); diff --git a/src/app/(with-header)/components/CategoryFilter.tsx b/src/app/(with-header)/components/CategoryFilter.tsx new file mode 100644 index 0000000..08a2a84 --- /dev/null +++ b/src/app/(with-header)/components/CategoryFilter.tsx @@ -0,0 +1,34 @@ +'use client'; + +import Button from '@/components/Button'; +import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories'; +import cn from '@/lib/cn'; + +interface CategoryFilterProps { + selectedCategory: ActivityCategory; + onChange: (category: ActivityCategory) => void; + className?: string; +} + +export default function CategoryFilter({ + selectedCategory, + onChange, + className, +}: CategoryFilterProps) { + return ( +
+ {ACTIVITY_CATEGORIES.map((category) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(with-header)/components/ExperienceCard.tsx b/src/app/(with-header)/components/ExperienceCard.tsx index 021a1b8..df21204 100644 --- a/src/app/(with-header)/components/ExperienceCard.tsx +++ b/src/app/(with-header)/components/ExperienceCard.tsx @@ -1,25 +1,43 @@ import Image from 'next/image'; -export default function ExperienceCard() { +interface Props { + imageUrl: string; + title: string; + rating: number; + reviews: number; + price: number; +} + +export default function ExperienceCard({ + imageUrl, + title, + rating, + reviews, + price, +}: Props) { return ( -
- {/* 배경 이미지 */} - 체험 이미지 - {/* 어두운 오버레이 */} -
- {/* 텍스트 정보 블록 (카드 하단 위치 고정) */} -
- {/* 별점 정보 */} - ⭐ 4.9 (293) - {/* 체험명 (줄바꿈 포함, 반응형 크기) */} -

함께 배우면 즐거운
스트릿 댄스

- {/* 가격 정보 */} -

₩ 38,000 / 인

+
+ {/* 썸네일 */} +
+ {title} +
+ + {/* 텍스트 정보 */} +
+ + ⭐ {rating} ({reviews}) + +

+ {title} +

+

+ ₩ {price.toLocaleString()} / 인 +

); diff --git a/src/app/(with-header)/components/ExperienceList.tsx b/src/app/(with-header)/components/ExperienceList.tsx new file mode 100644 index 0000000..4eb7677 --- /dev/null +++ b/src/app/(with-header)/components/ExperienceList.tsx @@ -0,0 +1,137 @@ +'use client'; + +import Dropdown from '@components/Dropdown'; +import Pagination from '@components/Pagination'; +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +import CategoryFilter from '@/app/(with-header)/components/CategoryFilter'; +import ExperienceCard from '@/app/(with-header)/components/ExperienceCard'; +import { getExperiences } from '@/app/api/experiences/getExperiences'; +import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories'; +import { + SORT_OPTIONS, + SORT_VALUE_MAP, + SORT_LABEL_MAP, +} from '@/constants/SortPrices'; +import { Experience } from '@/types/experienceListTypes'; + +interface ExperienceListProps { + keyword?: string; + isSearchMode?: boolean; +} + +export default function ExperienceList({ keyword, isSearchMode }: ExperienceListProps) { + const [currentPage, setCurrentPage] = useState(1); + const [selectedCategory, setSelectedCategory] = useState(ACTIVITY_CATEGORIES[0]); + const [sortOption, setSortOption] = useState(''); + const [experiences, setExperiences] = useState([]); + const [totalCount, setTotalCount] = useState(0); + + useEffect(() => { + const resync = async () => { + const res = await getExperiences({ + page: currentPage, + sort: sortOption, + category: selectedCategory, + keyword, + }); + + setExperiences(res.experiences); + setTotalCount(res.totalCount); + }; + + resync(); + }, [currentPage, sortOption, selectedCategory, keyword]); + + useEffect(() => { + if (keyword) { + setSelectedCategory(ACTIVITY_CATEGORIES[0]); + setSortOption(''); + setCurrentPage(1); + } + }, [keyword]); + + const totalPage = Math.ceil(totalCount / 8); + + return ( +
+ + {/* 🔍 검색 모드일 때 문구 표시 */} + {isSearchMode && keyword && ( + <> +

+ "{keyword}" + (으)로 검색한 결과입니다. +

+ +

+ 총 {totalCount}개의 결과 +

+ + {experiences.length === 0 && ( +

검색 결과가 없습니다.

+ )} + + )} + + {/* 🧭 필터/정렬 UI (검색 모드 아닐 때만) */} + {!isSearchMode && ( +
+ { + setSelectedCategory(category); + setCurrentPage(1); + }} + /> + { + const value = SORT_VALUE_MAP[label]; + setSortOption(value); + setCurrentPage(1); + }} + /> +
+ )} + +
+ {/* 🚂 모든 체험 제목 (검색 아닐 때만) */} + {!isSearchMode && ( +

🛼 모든 체험

+ )} + + {/* 체험 카드 목록 */} +
+ {experiences.map((exp) => ( + + + + ))} +
+
+ + {/* 페이지네이션: 결과 있을 때만 표시 */} + {experiences.length > 0 && ( + + )} +
+ ); +} diff --git a/src/app/(with-header)/components/PopularCard.tsx b/src/app/(with-header)/components/PopularCard.tsx new file mode 100644 index 0000000..c2791cc --- /dev/null +++ b/src/app/(with-header)/components/PopularCard.tsx @@ -0,0 +1,40 @@ +import Image from 'next/image'; + +interface PopularCardProps { + imageUrl: string; + title: string; + rating: number; + reviews: number; + price: number; +} + +export default function PopularCard({ + imageUrl, + title, + rating, + reviews, + price, +}: PopularCardProps) { + return ( +
+ {/* 배경 이미지 */} + {title} + {/* 어두운 오버레이 */} +
+ {/* 텍스트 정보 블록 (카드 하단 위치 고정) */} +
+ {/* 별점 정보 */} + ⭐ {rating} ({reviews}) + {/* 체험명 (줄바꿈 포함, 반응형 크기) */} +

{title}

+ {/* 가격 정보 */} +

₩ {price.toLocaleString()} / 인

+
+
+ ); +} diff --git a/src/app/(with-header)/components/PopularExperiences.tsx b/src/app/(with-header)/components/PopularExperiences.tsx index 1441b96..de61a2f 100644 --- a/src/app/(with-header)/components/PopularExperiences.tsx +++ b/src/app/(with-header)/components/PopularExperiences.tsx @@ -1,41 +1,59 @@ 'use client'; -import { useRef } from 'react'; -import ExperienceCard from '@/app/(with-header)/components/ExperienceCard'; -import IconArrowRight from '@assets/svg/right-arrow'; import IconArrowLeft from '@assets/svg/left-arrow'; +import IconArrowRight from '@assets/svg/right-arrow'; +import { useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; + +import PopularCard from '@/app/(with-header)/components/PopularCard'; +import { Experience } from '@/types/experienceListTypes'; + +import { getPopularExperiences } from '../../api/experiences/getPopularExperiences'; export default function PopularExperiences() { - // 카드 슬라이더를 참조할 DOM ref const sliderRef = useRef(null); + const [popularExperiences, setPopularExperiences] = useState([]); + // 좌우 버튼 클릭 시 한 장씩 슬라이드 이동 const scrollByCard = (direction: 'left' | 'right') => { if (!sliderRef.current) return; - // 첫 번째 카드 요소를 찾아서 너비 측정 const card = sliderRef.current.querySelector('.card'); if (!(card instanceof HTMLElement)) return; - const cardWidth = card.offsetWidth; // 카드 너비 - const gap = parseInt(getComputedStyle(sliderRef.current).gap) || 0; // gap 값 - const distance = cardWidth + gap; // 한 번에 이동할 거리 + const cardWidth = card.offsetWidth; + const gap = parseInt(getComputedStyle(sliderRef.current).gap) || 0; + const distance = cardWidth + gap; - // 슬라이더 스크롤 이동 (좌/우 방향에 따라) sliderRef.current.scrollBy({ left: direction === 'left' ? -distance : distance, behavior: 'smooth', }); }; + // 인기 체험 목록 불러오기 + useEffect(() => { + const fetchPopular = async () => { + try { + const res = await getPopularExperiences(); + setPopularExperiences(res.activities); + } catch (error) { + console.error('인기 체험을 불러오는 데 실패했습니다:', error); + } + }; + + fetchPopular(); + }, []); + return (
{/* 섹션 제목 + 좌우 화살표 버튼 */}

🔥 인기 체험

- scrollByCard('left')} className='text-2xl px-3' /> - scrollByCard('right')} className='text-2xl px-3' /> + scrollByCard('left')} /> + scrollByCard('right')} />
@@ -44,10 +62,21 @@ export default function PopularExperiences() { ref={sliderRef} className='flex gap-16 md:gap-32 lg:gap-24 overflow-x-auto scroll-smooth no-scrollbar' > - {[...Array(4)].map((_, idx) => ( - // 카드 wrapper: flex-shrink-0으로 크기 고정 + 'card' 클래스로 식별 -
- + {popularExperiences.map((exp) => ( +
+ + +
))}
diff --git a/src/app/(with-header)/components/SearchBar.tsx b/src/app/(with-header)/components/SearchBar.tsx index e5f6662..868e435 100644 --- a/src/app/(with-header)/components/SearchBar.tsx +++ b/src/app/(with-header)/components/SearchBar.tsx @@ -1,15 +1,20 @@ 'use client'; -import { useState, FormEvent } from 'react'; -import Input from '@components/Input'; import Button from '@components/Button'; +import Input from '@components/Input'; +import { FormEvent,useState } from 'react'; -export default function SearchBar() { +interface SearchBarProps { + onSearch: (keyword: string) => void; +} + +export default function SearchBar({ onSearch }: SearchBarProps) { const [searchValue, setSearchValue] = useState(''); const handleSubmit = (e: FormEvent) => { e.preventDefault(); - console.log('검색어:', searchValue); // 검색 로직은 추후 API 연동 + onSearch(searchValue); // 부모(HomePage)로 검색어 전달 + setSearchValue(''); // 선택 사항: 검색어 초기화 }; return ( @@ -22,21 +27,21 @@ export default function SearchBar() {
setSearchValue(e.target.value)} - placeholder='내가 원하는 체험은' />
diff --git a/src/app/(with-header)/page.tsx b/src/app/(with-header)/page.tsx index ae80e34..9c3148d 100644 --- a/src/app/(with-header)/page.tsx +++ b/src/app/(with-header)/page.tsx @@ -1,11 +1,25 @@ +'use client'; + +import { useState } from 'react'; import BannerSection from '@/app/(with-header)/components/BannerSection'; import PopularExperiences from '@/app/(with-header)/components/PopularExperiences'; +import ExperienceList from '@/app/(with-header)/components/ExperienceList'; export default function HomePage() { + const [searchKeyword, setSearchKeyword] = useState(''); + return (
- - + + + {searchKeyword ? ( + + ) : ( + <> + + + + )}
); -} \ No newline at end of file +} diff --git a/src/app/api/experiences/getExperiences.ts b/src/app/api/experiences/getExperiences.ts new file mode 100644 index 0000000..0eb0c8d --- /dev/null +++ b/src/app/api/experiences/getExperiences.ts @@ -0,0 +1,39 @@ +import { instance } from '@/apis/instance'; +import { Experience } from '@/types/experienceListTypes'; + +interface Params { + page: number; + category?: string; + sort?: string; + keyword?: string; +} + +interface ExperienceResponse { + activities: Experience[]; + totalCount: number; + cursorId: number; +} + +const teamId = process.env.NEXT_PUBLIC_TEAM_ID; +const url = `/${teamId}/activities`; +const validSorts = ['price_asc', 'price_desc']; + +export const getExperiences = async ({ page, category, sort, keyword }: Params) => { + const isAllCategory = category === '전체'; + + const res = await instance.get(url, { + params: { + method: 'offset', + page, + size: 8, + ...(!isAllCategory && category && { category }), // 전체인 경우 카테고리 제외 + ...(sort && validSorts.includes(sort) && { sort }), + ...(keyword && { keyword }), + }, + }); + + return { + experiences: res.data.activities, // 이름 변환 + totalCount: res.data.totalCount, + }; +}; diff --git a/src/app/api/experiences/getPopularExperiences.ts b/src/app/api/experiences/getPopularExperiences.ts new file mode 100644 index 0000000..40e20e4 --- /dev/null +++ b/src/app/api/experiences/getPopularExperiences.ts @@ -0,0 +1,23 @@ +import { instance } from '@/apis/instance'; +import { Experience } from '@/types/experienceListTypes'; + +interface ResponseData { + cursorId: number; + totalCount: number; + activities: Experience[]; +} + +const teamId = process.env.NEXT_PUBLIC_TEAM_ID; +const url = `/${teamId}/activities`; + +export const getPopularExperiences = async (): Promise => { + const res = await instance.get(url, { + params: { + method: 'offset', + sort: 'most_reviewed', + size: 10, + }, + }); + + return res.data; +}; diff --git a/src/constants/SortPrices.ts b/src/constants/SortPrices.ts new file mode 100644 index 0000000..c92a229 --- /dev/null +++ b/src/constants/SortPrices.ts @@ -0,0 +1,16 @@ +export const SORT_OPTIONS = ['가격 낮은순', '가격 높은순'] as const; + +export type SortOptionLabel = typeof SORT_OPTIONS[number]; // 표시용 (한글) +export type SortOptionValue = 'price_asc' | 'price_desc'; // 서버에 보내는 값 + +// 한글 → 서버 값 +export const SORT_VALUE_MAP: Record = { + '가격 낮은순': 'price_asc', + '가격 높은순': 'price_desc', +}; + +// 서버 값 → 한글 +export const SORT_LABEL_MAP: Record = { + price_asc: '가격 낮은순', + price_desc: '가격 높은순', +}; diff --git a/src/constants/categories.ts b/src/constants/categories.ts index 574a86c..a00acd4 100644 --- a/src/constants/categories.ts +++ b/src/constants/categories.ts @@ -6,7 +6,8 @@ */ export const ACTIVITY_CATEGORIES = [ - '문화 예술', + '전체', // 프론트 단에서만 사용 + '문화 · 예술', '식음료', '스포츠', '투어', diff --git a/src/types/experienceListTypes.ts b/src/types/experienceListTypes.ts new file mode 100644 index 0000000..8b1aefd --- /dev/null +++ b/src/types/experienceListTypes.ts @@ -0,0 +1,20 @@ +export interface Experience { + id: number; + userId: number; + title: string; + description: string; + category: string; + price: number; + address: string; + bannerImageUrl: string; + rating: number; + reviewCount: number; + createdAt: string; + updatedAt: string; +} + +export interface ExperienceResponse { + cursorId: number; + totalCount: number; + activities: Experience[]; +} \ No newline at end of file