-
Notifications
You must be signed in to change notification settings - Fork 1
Feat/45-3 메인페이지 모든 체험 UI 구현 및 API 연동 #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d29182d
a63904f
0deeb76
a32fcec
4a8b838
4b60f39
8689298
acd1d68
0986cf3
2117940
180091a
65deb36
3573d8e
3416b09
563231f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 ( | ||||||||||||
| <div className={cn('relative flex w-full gap-8 overflow-x-auto whitespace-nowrap no-scrollbar', className)}> | ||||||||||||
| {ACTIVITY_CATEGORIES.map((category) => ( | ||||||||||||
| <Button | ||||||||||||
| key={category} | ||||||||||||
| className='flex-shrink-0 max-w-80 max-h-41 py-12 text-[16px] rounded-[15px]' | ||||||||||||
| selected={selectedCategory === category} | ||||||||||||
| variant='category' | ||||||||||||
| onClick={() => onChange(category)} | ||||||||||||
| > | ||||||||||||
| {category} | ||||||||||||
| </Button> | ||||||||||||
| ))} | ||||||||||||
| <div className='pointer-events-none absolute top-0 right-0 h-full w-100 bg-gradient-to-l from-white to-transparent' /> | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 접근성 고려사항 그라디언트 오버레이에 -<div className='pointer-events-none absolute top-0 right-0 h-full w-100 bg-gradient-to-l from-white to-transparent' />
+<div className='pointer-events-none absolute top-0 right-0 h-full w-100 bg-gradient-to-l from-white to-transparent' aria-hidden="true" />📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
| </div> | ||||||||||||
| ); | ||||||||||||
| } | ||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+3
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 인터페이스명을 더 구체적으로 변경하는 것을 권장합니다.
-interface Props {
+interface ExperienceCardProps {
imageUrl: string;
title: string;
rating: number;
reviews: number;
price: number;
}
-}: Props) {
+}: ExperienceCardProps) {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents🧹 Nitpick (assertive) 인터페이스 이름을 더 구체적으로 변경하는 것을 고려해보세요. 현재 -interface Props {
+interface ExperienceCardProps {
imageUrl: string;
title: string;
rating: number;
reviews: number;
price: number;
}
-}: Props) {
+}: ExperienceCardProps) {
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default function ExperienceCard({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| imageUrl, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| title, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rating, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reviews, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| price, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }: Props) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className='relative w-186 h-186 md:w-384 md:h-384 rounded-[20px] overflow-hidden shadow-md bg-white'> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* 배경 이미지 */} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Image | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| src='/test/image1.png' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| alt='체험 이미지' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className='w-full object-cover' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fill | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* 어두운 오버레이 */} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className='absolute inset-0 bg-gradient-to-r from-black to-transparent' /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* 텍스트 정보 블록 (카드 하단 위치 고정) */} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className='absolute bottom-12 flex flex-col gap-6 md:gap-20 px-20 py-12 text-white'> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* 별점 정보 */} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <span className='text-md'>⭐ 4.9 (293)</span> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* 체험명 (줄바꿈 포함, 반응형 크기) */} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className='text-2lg md:text-3xl font-semibold'>함께 배우면 즐거운<br />스트릿 댄스</p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* 가격 정보 */} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className='text-lg md:text-xl'>₩ 38,000 <span className='text-gray-600 text-md'>/ 인</span></p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className='flex flex-col w-full gap-16'> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* 썸네일 */} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className='relative w-full h-168 md:h-221 lg:h-283 rounded-[20px] overflow-hidden'> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Image | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fill | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| alt={title} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className='object-cover' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| src={imageUrl} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* 텍스트 정보 */} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className='flex flex-col'> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <span className='pb-10 text-lg text-black'> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ⭐ {rating} <span className='text-gray-700 text-lg'>({reviews})</span> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className='pb-15 text-2lg font-semibold text-black line-clamp-2'> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {title} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className='text-xl text-black font-bold'> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ₩ {price.toLocaleString()} <span className='text-gray-900 text-lg font-medium'>/ 인</span> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ActivityCategory>(ACTIVITY_CATEGORIES[0]); | ||
| const [sortOption, setSortOption] = useState<string | undefined>(''); | ||
| const [experiences, setExperiences] = useState<Experience[]>([]); | ||
| 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 ( | ||
| <section className='max-w-1200 m-auto px-24 md:px-0 pb-83'> | ||
|
|
||
| {/* 🔍 검색 모드일 때 문구 표시 */} | ||
| {isSearchMode && keyword && ( | ||
| <> | ||
| <p className="text-left text-lg font-semibold ml-4 md:ml-0 mt-32"> | ||
| <span className="text-primary font-bold">"{keyword}"</span> | ||
| (으)로 검색한 결과입니다. | ||
| </p> | ||
|
|
||
| <p className="text-left text-sm font-normal ml-4 md:ml-0 mt-8 mb-16"> | ||
| 총 <span className="font-semibold">{totalCount}</span>개의 결과 | ||
| </p> | ||
|
|
||
| {experiences.length === 0 && ( | ||
| <p className="text-center text-gray-500 mt-32">검색 결과가 없습니다.</p> | ||
| )} | ||
| </> | ||
| )} | ||
|
|
||
| {/* 🧭 필터/정렬 UI (검색 모드 아닐 때만) */} | ||
| {!isSearchMode && ( | ||
| <div className='flex justify-between items-center mb-40'> | ||
| <CategoryFilter | ||
| selectedCategory={selectedCategory} | ||
| onChange={(category) => { | ||
| setSelectedCategory(category); | ||
| setCurrentPage(1); | ||
| }} | ||
| /> | ||
| <Dropdown | ||
| className='w-200' | ||
| placeholder='가격' | ||
| options={SORT_OPTIONS} | ||
| value={sortOption && SORT_LABEL_MAP[sortOption as keyof typeof SORT_LABEL_MAP] || ''} | ||
| onChange={(label: keyof typeof SORT_VALUE_MAP) => { | ||
| const value = SORT_VALUE_MAP[label]; | ||
| setSortOption(value); | ||
| setCurrentPage(1); | ||
| }} | ||
| /> | ||
| </div> | ||
| )} | ||
|
|
||
| <div className='m-0'> | ||
| {/* 🚂 모든 체험 제목 (검색 아닐 때만) */} | ||
| {!isSearchMode && ( | ||
| <h2 className='text-xl md:text-3xl font-bold'>🛼 모든 체험</h2> | ||
| )} | ||
|
|
||
| {/* 체험 카드 목록 */} | ||
| <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'> | ||
| {experiences.map((exp) => ( | ||
| <Link | ||
| key={exp.id} | ||
| href={`/activities/${exp.id}`} // 아이디 기반 라우팅 | ||
| > | ||
| <ExperienceCard | ||
| imageUrl={exp.bannerImageUrl} | ||
| price={exp.price} | ||
| rating={exp.rating} | ||
| reviews={exp.reviewCount} | ||
| title={exp.title} | ||
| /> | ||
| </Link> | ||
| ))} | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* 페이지네이션: 결과 있을 때만 표시 */} | ||
| {experiences.length > 0 && ( | ||
| <Pagination | ||
| currentPage={currentPage} | ||
| totalPage={totalPage} | ||
| onPageChange={setCurrentPage} | ||
| /> | ||
| )} | ||
| </section> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className='relative w-186 h-186 md:w-384 md:h-384 rounded-[20px] overflow-hidden shadow-md bg-white'> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 하드코딩된 크기 값들을 상수로 관리하는 것을 고려해보세요.
크기 값들을 상수 파일로 분리하거나 Tailwind config에서 커스텀 클래스로 관리하는 것을 권장합니다: // constants/cardSizes.ts
export const CARD_SIZES = {
popular: {
mobile: 'w-186 h-186',
desktop: 'md:w-384 md:h-384'
}
} as const;🤖 Prompt for AI Agents |
||
| {/* 배경 이미지 */} | ||
| <Image | ||
| fill | ||
| alt={title} | ||
| className='w-full object-cover' | ||
| src={imageUrl} | ||
| /> | ||
| {/* 어두운 오버레이 */} | ||
| <div className='absolute inset-0 bg-gradient-to-r from-black to-transparent' /> | ||
| {/* 텍스트 정보 블록 (카드 하단 위치 고정) */} | ||
| <div className='absolute bottom-12 flex flex-col gap-6 md:gap-20 px-20 py-12 text-white'> | ||
| {/* 별점 정보 */} | ||
| <span className='text-md'>⭐ {rating} ({reviews})</span> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 접근성을 위해 별점 표시 개선을 고려해보세요. 현재 이모지 별(⭐)을 사용하고 있는데, 스크린 리더 사용자를 위해 의미적인 별점 표시를 고려해보세요. - <span className='text-md'>⭐ {rating} ({reviews})</span>
+ <span className='text-md' aria-label={`평점 ${rating}점, 리뷰 ${reviews}개`}>
+ ⭐ {rating} ({reviews})
+ </span>🤖 Prompt for AI Agents |
||
| {/* 체험명 (줄바꿈 포함, 반응형 크기) */} | ||
| <p className='text-2lg md:text-3xl font-semibold'>{title}</p> | ||
| {/* 가격 정보 */} | ||
| <p className='text-lg md:text-xl'>₩ {price.toLocaleString()} <span className='text-gray-600 text-md'>/ 인</span></p> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 가격 표시 텍스트의 일관성을 확인하세요. "/ 인" 텍스트가 회색으로 표시되어 가독성이 떨어질 수 있습니다. 다른 카드 컴포넌트와 일관성을 유지하는지 확인해주세요. 🤖 Prompt for AI Agents |
||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick (assertive)
JSX props 정렬 규칙 적용 권장
ESLint 규칙에 따라 props를 알파벳 순으로 정렬하는 것을 권장합니다.
현재
onSearchprop만 있어서 정렬이 필요하지 않지만, 향후 props 추가 시 알파벳 순 정렬을 유지하세요.🤖 Prompt for AI Agents