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
17 changes: 11 additions & 6 deletions src/app/(with-header)/components/BannerSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className='relative w-full h-240 md:h-550 mb-93'>
{/* 배경 이미지 */}
<Image
src='/test/image1.png'
alt='스트릿 댄스'
fill
className='object-cover'
priority
alt='스트릿 댄스'
className='object-cover'
src='/test/image1.png'
/>

{/* 어두운 오버레이 */}
Expand All @@ -27,7 +32,7 @@ export default function BannerSection() {
</p>
</div>
<div className='absolute -bottom-100 left-0 right-0'>
<SearchBar />
<SearchBar onSearch={onSearch} />
Copy link

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를 알파벳 순으로 정렬하는 것을 권장합니다.

현재 onSearch prop만 있어서 정렬이 필요하지 않지만, 향후 props 추가 시 알파벳 순 정렬을 유지하세요.

🤖 Prompt for AI Agents
In src/app/(with-header)/components/BannerSection.tsx at line 34, the JSX props
should be alphabetically sorted according to ESLint rules. Although there is
currently only one prop, onSearch, ensure that when adding more props in the
future, they are arranged in alphabetical order to maintain consistency and
comply with linting standards.

</div>
</section>
);
Expand Down
34 changes: 34 additions & 0 deletions src/app/(with-header)/components/CategoryFilter.tsx
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' />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

접근성 고려사항

그라디언트 오버레이에 aria-hidden="true" 속성을 추가하는 것을 고려해보세요.

-<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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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"
/>
🤖 Prompt for AI Agents
In src/app/(with-header)/components/CategoryFilter.tsx at line 31, the gradient
overlay div lacks accessibility consideration. Add the attribute
aria-hidden="true" to this div to ensure screen readers ignore it, improving
accessibility.

</div>
);
}
56 changes: 37 additions & 19 deletions src/app/(with-header)/components/ExperienceCard.tsx
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

인터페이스명을 더 구체적으로 변경하는 것을 권장합니다.

Props보다는 ExperienceCardProps와 같이 구체적인 이름을 사용하면 코드 가독성과 유지보수성이 향상됩니다.

-interface Props {
+interface ExperienceCardProps {
   imageUrl: string;
   title: string;
   rating: number;
   reviews: number;
   price: number;
 }

-}: Props) {
+}: ExperienceCardProps) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interface Props {
imageUrl: string;
title: string;
rating: number;
reviews: number;
price: number;
}
interface ExperienceCardProps {
imageUrl: string;
title: string;
rating: number;
reviews: number;
price: number;
}
// ...
export default function ExperienceCard({
imageUrl,
title,
rating,
reviews,
price,
}: ExperienceCardProps) {
// component implementation
}
🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceCard.tsx between lines 3 and 9,
rename the interface from Props to ExperienceCardProps to make the interface
name more specific and improve code readability and maintainability.

🧹 Nitpick (assertive)

인터페이스 이름을 더 구체적으로 변경하는 것을 고려해보세요.

현재 Props라는 일반적인 이름 대신 ExperienceCardProps와 같이 더 구체적인 이름을 사용하면 코드 가독성이 향상됩니다.

-interface Props {
+interface ExperienceCardProps {
   imageUrl: string;
   title: string;
   rating: number;
   reviews: number;
   price: number;
 }

-}: Props) {
+}: ExperienceCardProps) {

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceCard.tsx between lines 3 and 9,
rename the interface from the generic name 'Props' to a more specific name like
'ExperienceCardProps' to improve code readability and clarity about what the
props represent.


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>
);
Expand Down
137 changes: 137 additions & 0 deletions src/app/(with-header)/components/ExperienceList.tsx
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>
);
}
40 changes: 40 additions & 0 deletions src/app/(with-header)/components/PopularCard.tsx
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'>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

하드코딩된 크기 값들을 상수로 관리하는 것을 고려해보세요.

w-186 h-186 md:w-384 md:h-384 같은 크기 값들이 여러 카드 컴포넌트에서 반복될 가능성이 있습니다.

크기 값들을 상수 파일로 분리하거나 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
In src/app/(with-header)/components/PopularCard.tsx at line 19, the width and
height values are hardcoded directly in the className string. To improve
maintainability and avoid repetition, extract these size values into a constants
file or define custom Tailwind classes in the Tailwind config. Replace the
hardcoded class strings with references to these constants or custom classes to
centralize size management.

{/* 배경 이미지 */}
<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>
Copy link

Choose a reason for hiding this comment

The 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
In src/app/(with-header)/components/PopularCard.tsx at line 32, replace the
emoji star (⭐) with a semantic star rating element accessible to screen readers.
Use appropriate ARIA attributes or visually hidden text to convey the rating
meaningfully for assistive technologies, ensuring the star rating is
understandable without relying on the emoji alone.

{/* 체험명 (줄바꿈 포함, 반응형 크기) */}
<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>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

가격 표시 텍스트의 일관성을 확인하세요.

"/ 인" 텍스트가 회색으로 표시되어 가독성이 떨어질 수 있습니다. 다른 카드 컴포넌트와 일관성을 유지하는지 확인해주세요.

🤖 Prompt for AI Agents
In src/app/(with-header)/components/PopularCard.tsx at line 36, the "/ 인" text
is styled with a gray color that may reduce readability and cause inconsistency
with other card components. Review the styling of this span element and update
its className to match the color and font style used in other card components
for price display, ensuring consistent and clear text appearance.

</div>
</div>
);
}
Loading