-
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 6 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 |
|---|---|---|
| @@ -1,7 +1,11 @@ | ||
| import Image from 'next/image'; | ||
| import SearchBar from './SearchBar'; | ||
| import SearchBar from '@/app/(with-header)/components/SearchBar'; | ||
|
|
||
| export default function BannerSection() { | ||
| 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'> | ||
| {/* 배경 이미지 */} | ||
|
|
@@ -27,7 +31,7 @@ export default function BannerSection() { | |
| </p> | ||
| </div> | ||
| <div className='absolute -bottom-100 left-0 right-0'> | ||
| <SearchBar /> | ||
| <SearchBar onSearch={onSearch} /> | ||
|
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) JSX props 정렬 규칙 적용 권장 ESLint 규칙에 따라 props를 알파벳 순으로 정렬하는 것을 권장합니다. 현재 🤖 Prompt for AI Agents |
||
| </div> | ||
| </section> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,34 @@ | ||||||||||||
| 'use client'; | ||||||||||||
|
|
||||||||||||
| import cn from '@/lib/cn'; | ||||||||||||
| import Button from '@/components/Button'; | ||||||||||||
| import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories'; | ||||||||||||
|
|
||||||||||||
| 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} | ||||||||||||
| variant='category' | ||||||||||||
| selected={selectedCategory === category} | ||||||||||||
| onClick={() => onChange(category)} | ||||||||||||
| className='flex-shrink-0 max-w-80 max-h-41 py-12 text-[16px] rounded-[15px]' | ||||||||||||
| > | ||||||||||||
| {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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| src={imageUrl} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| alt={title} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fill | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className='object-cover' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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. 이미지 컴포넌트의 props 순서를 수정해주세요. Pipeline 오류에서 지적한 대로 props 순서를 정렬해야 합니다. - <Image
- src={imageUrl}
- alt={title}
- fill
- className='object-cover'
- />
+ <Image
+ fill
+ alt={title}
+ className='object-cover'
+ src={imageUrl}
+ />📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Actions: CI[warning] 24-26: Props should be sorted alphabetically and shorthand props must be listed before all other props (react/jsx-sort-props). 🤖 Prompt for AI Agentsprops 정렬 순서를 수정해주세요. 파이프라인 경고에 따라 Image 컴포넌트의 props를 올바른 순서로 정렬해야 합니다. <Image
+ alt={title}
+ className='object-cover'
+ fill
src={imageUrl}
- alt={title}
- fill
- className='object-cover'
/>📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Actions: CI[warning] 24-26: Props should be sorted alphabetically and shorthand props must be listed before all other props (react/jsx-sort-props). 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </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,98 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'use client'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect, useState } from 'react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import CategoryFilter from '@/app/(with-header)/components/CategoryFilter'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import ExperienceCard from '@/app/(with-header)/components/ExperienceCard'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import Pagination from '@components/Pagination'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import Dropdown from '@components/Dropdown'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { SORT_OPTIONS, SortOption } from '@/constants/SortPrices'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Experience } from '@/types/experienceListTypes'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getExperiences } from '@/app/api/experiences/getExperiences'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect, useState } from 'react'; | |
| import CategoryFilter from '@/app/(with-header)/components/CategoryFilter'; | |
| import ExperienceCard from '@/app/(with-header)/components/ExperienceCard'; | |
| import Pagination from '@components/Pagination'; | |
| import Dropdown from '@components/Dropdown'; | |
| import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories'; | |
| import { SORT_OPTIONS, SortOption } from '@/constants/SortPrices'; | |
| import { Experience } from '@/types/experienceListTypes'; | |
| import { getExperiences } from '@/app/api/experiences/getExperiences'; | |
| import { useEffect, useState } from 'react'; | |
| import { getExperiences } from '@/app/api/experiences/getExperiences'; | |
| import CategoryFilter from '@/app/(with-header)/components/CategoryFilter'; | |
| import ExperienceCard from '@/app/(with-header)/components/ExperienceCard'; | |
| import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories'; | |
| import { SORT_OPTIONS, SortOption } from '@/constants/SortPrices'; | |
| import { Experience } from '@/types/experienceListTypes'; | |
| import Dropdown from '@components/Dropdown'; | |
| import Pagination from '@components/Pagination'; |
🧰 Tools
🪛 GitHub Actions: CI
[warning] 3-84: Run autofix to sort imports (simple-import-sort/imports); Callbacks must be listed after all other props and props should be sorted alphabetically (react/jsx-sort-props).
🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceList.tsx between lines 3 and 11,
the import statements are not sorted correctly according to the project's import
order rules. Fix this by running the autofix command for import sorting or
manually reorder the imports so that they follow the correct grouping and
alphabetical order as per the linting configuration.
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.
카테고리 필터링이 API 호출에 누락되었습니다.
selectedCategory 상태가 있지만 실제 API 호출에서 카테고리 파라미터가 전달되지 않고 있습니다.
const res = await getExperiences({
page: currentPage,
+ category: selectedCategory.value !== 'all' ? selectedCategory.value : undefined,
sort: sortOption,
keyword, // 검색어
});📝 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.
| const res = await getExperiences({ | |
| page: currentPage, | |
| sort: sortOption, | |
| keyword, // 검색어 | |
| }); | |
| const res = await getExperiences({ | |
| page: currentPage, | |
| category: selectedCategory.value !== 'all' ? selectedCategory.value : undefined, | |
| sort: sortOption, | |
| keyword, // 검색어 | |
| }); |
🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceList.tsx around lines 27 to 31,
the API call to getExperiences is missing the category filter parameter. Add the
selectedCategory state as a parameter in the getExperiences call to ensure the
API request includes the category filter.
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.
API 응답 필드명 불일치와 에러 처리를 수정해주세요.
API에서 activities 필드를 반환하지만 experiences로 접근하고 있습니다. 또한 API 호출 실패에 대한 에러 처리가 없습니다.
const fetchExperiences = async () => {
+ try {
const res = await getExperiences({
page: currentPage,
sort: sortOption,
keyword, // 검색어
});
- setExperiences(res.experiences);
+ setExperiences(res.activities);
setTotalCount(res.totalCount);
+ } catch (error) {
+ console.error('Failed to fetch experiences:', error);
+ setExperiences([]);
+ setTotalCount(0);
+ }
};📝 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.
| const res = await getExperiences({ | |
| page: currentPage, | |
| sort: sortOption, | |
| keyword, // 검색어 | |
| }); | |
| setExperiences(res.experiences); | |
| setTotalCount(res.totalCount); | |
| }; | |
| const fetchExperiences = async () => { | |
| try { | |
| const res = await getExperiences({ | |
| page: currentPage, | |
| sort: sortOption, | |
| keyword, // 검색어 | |
| }); | |
| setExperiences(res.activities); | |
| setTotalCount(res.totalCount); | |
| } catch (error) { | |
| console.error('Failed to fetch experiences:', error); | |
| setExperiences([]); | |
| setTotalCount(0); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceList.tsx between lines 27 and 35,
the code incorrectly accesses the API response field as 'experiences' instead of
'activities', and lacks error handling for the API call. Update the code to use
'res.activities' instead of 'res.experiences' and add a try-catch block around
the API call to handle potential errors gracefully, such as logging the error or
showing a user-friendly message.
Outdated
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.
🛠️ Refactor suggestion
API 호출에 대한 에러 처리와 로딩 상태가 누락되었습니다.
네트워크 오류나 API 실패 시 사용자에게 적절한 피드백을 제공해야 합니다.
로딩 상태와 에러 처리를 추가해주세요:
export default function ExperienceList({ keyword }: ExperienceListProps) {
const [currentPage, setCurrentPage] = useState(1);
const [selectedCategory, setSelectedCategory] = useState<ActivityCategory>(ACTIVITY_CATEGORIES[0]);
const [sortOption, setSortOption] = useState<SortOption | ''>('');
const [experiences, setExperiences] = useState<Experience[]>([]);
const [totalCount, setTotalCount] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
// API 호출
useEffect(() => {
const fetchExperiences = async () => {
+ setLoading(true);
+ setError(null);
+ try {
const res = await getExperiences({
page: currentPage,
sort: sortOption,
keyword, // 검색어
});
setExperiences(res.experiences);
setTotalCount(res.totalCount);
+ } catch (err) {
+ setError('체험 목록을 불러오는데 실패했습니다.');
+ console.error('Failed to fetch experiences:', err);
+ } finally {
+ setLoading(false);
+ }
};
fetchExperiences();
}, [currentPage, sortOption, keyword]);📝 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.
| useEffect(() => { | |
| const fetchExperiences = async () => { | |
| const res = await getExperiences({ | |
| page: currentPage, | |
| sort: sortOption, | |
| keyword, // 검색어 | |
| }); | |
| setExperiences(res.experiences); | |
| setTotalCount(res.totalCount); | |
| }; | |
| fetchExperiences(); | |
| }, [currentPage, sortOption, keyword]); | |
| export default function ExperienceList({ keyword }: ExperienceListProps) { | |
| const [currentPage, setCurrentPage] = useState(1); | |
| const [selectedCategory, setSelectedCategory] = useState<ActivityCategory>(ACTIVITY_CATEGORIES[0]); | |
| const [sortOption, setSortOption] = useState<SortOption | ''>(''); | |
| const [experiences, setExperiences] = useState<Experience[]>([]); | |
| const [totalCount, setTotalCount] = useState(0); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| // API 호출 | |
| useEffect(() => { | |
| const fetchExperiences = async () => { | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const res = await getExperiences({ | |
| page: currentPage, | |
| sort: sortOption, | |
| keyword, // 검색어 | |
| }); | |
| setExperiences(res.experiences); | |
| setTotalCount(res.totalCount); | |
| } catch (err) { | |
| setError('체험 목록을 불러오는데 실패했습니다.'); | |
| console.error('Failed to fetch experiences:', err); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| fetchExperiences(); | |
| }, [currentPage, sortOption, keyword]); | |
| } |
🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceList.tsx around lines 25 to 38,
the useEffect hook that fetches experiences lacks error handling and loading
state management. Add a loading state variable to indicate when the fetch is in
progress and an error state variable to capture any API call failures. Update
the fetchExperiences function to set loading true before the call, catch any
errors to set the error state, and set loading false after completion. Also,
update the component to display appropriate feedback based on loading and error
states.
Outdated
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.
🛠️ Refactor suggestion
페이지 크기 상수를 통일해주세요.
하드코딩된 페이지 크기(8)가 API 파일과 중복됩니다. 공통 상수로 분리하는 것을 고려해보세요.
+const PAGE_SIZE = 8;
+
export default function ExperienceList({ keyword }: ExperienceListProps) {
// ...
- const totalPage = Math.ceil(totalCount / 8); // 한 페이지당 8개 기준
+ const totalPage = Math.ceil(totalCount / PAGE_SIZE);📝 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.
| const totalPage = Math.ceil(totalCount / 8); // 한 페이지당 8개 기준 | |
| const PAGE_SIZE = 8; | |
| export default function ExperienceList({ keyword }: ExperienceListProps) { | |
| // ... | |
| const totalPage = Math.ceil(totalCount / PAGE_SIZE); | |
| // ... | |
| } |
🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceList.tsx at line 48, the page size
is hardcoded as 8, which duplicates the value in the API file. To fix this,
define a common constant for the page size in a shared constants file and import
it here. Replace the hardcoded 8 with this imported constant to ensure
consistency and maintainability.
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
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)
컴포넌트가 여러 책임을 가지고 있어 분리를 고려해보세요.
현재 컴포넌트가 상태 관리, API 호출, UI 렌더링을 모두 담당하고 있습니다. 더 나은 유지보수성을 위해 분리를 권장합니다.
다음과 같이 분리를 고려해보세요:
useExperiences커스텀 훅으로 상태 관리와 API 로직 분리ExperienceGrid컴포넌트로 카드 목록 렌더링 분리ExperienceFilters컴포넌트로 필터링 UI 분리
// hooks/useExperiences.ts
export const useExperiences = (keyword?: string) => {
// 상태 관리 및 API 로직
};
// components/ExperienceGrid.tsx
export const ExperienceGrid = ({ experiences }: { experiences: Experience[] }) => {
// 그리드 렌더링 로직
};🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceList.tsx around lines 74 to 88,
the component currently handles state management, API calls, and UI rendering
all together. To improve maintainability, refactor by extracting the state and
API logic into a custom hook named useExperiences, move the experience cards
rendering into a separate ExperienceGrid component, and isolate any filtering UI
into an ExperienceFilters component. This separation will modularize concerns
and simplify the ExperienceList component.
| 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 | ||
| src={imageUrl} | ||
| alt={title} | ||
| className='w-full object-cover' | ||
| fill | ||
| /> | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| {/* 어두운 오버레이 */} | ||
| <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> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,33 +1,48 @@ | ||||||||||||||||||||||||||||||||||
| 'use client'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import { useRef } from 'react'; | ||||||||||||||||||||||||||||||||||
| import ExperienceCard from '@/app/(with-header)/components/ExperienceCard'; | ||||||||||||||||||||||||||||||||||
| import { useEffect, useRef, useState } from 'react'; | ||||||||||||||||||||||||||||||||||
| import IconArrowRight from '@assets/svg/right-arrow'; | ||||||||||||||||||||||||||||||||||
| import IconArrowLeft from '@assets/svg/left-arrow'; | ||||||||||||||||||||||||||||||||||
| import PopularCard from '@/app/(with-header)/components/PopularCard'; | ||||||||||||||||||||||||||||||||||
| import { getPopularExperiences } from '../../api/experiences/getPopularExperiences'; | ||||||||||||||||||||||||||||||||||
| import { Experience } from '@/types/experienceListTypes'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| export default function PopularExperiences() { | ||||||||||||||||||||||||||||||||||
| // 카드 슬라이더를 참조할 DOM ref | ||||||||||||||||||||||||||||||||||
| const sliderRef = useRef<HTMLDivElement>(null); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // 좌우 버튼 클릭 시 한 장씩 슬라이드 이동 | ||||||||||||||||||||||||||||||||||
| const [popularExperiences, setPopularExperiences] = useState<Experience[]>([]); | ||||||||||||||||||||||||||||||||||
|
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) 로딩 상태 처리 추가를 고려해보세요 사용자 경험 향상을 위해 로딩 상태를 추가하는 것을 고려해보세요. const [popularExperiences, setPopularExperiences] = useState<Experience[]>([]);
+const [isLoading, setIsLoading] = useState(true);
// useEffect 내부에서
const fetchPopular = async () => {
try {
+ setIsLoading(true);
const res = await getPopularExperiences({ cursorId: 0 });
setPopularExperiences(res.activities);
} catch (error) {
console.error('인기 체험을 불러오는 데 실패했습니다:', error);
+ } finally {
+ setIsLoading(false);
}
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // ✅ 좌우 버튼 클릭 시 한 장씩 슬라이드 이동 | ||||||||||||||||||||||||||||||||||
| 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({ cursorId: 0 }); | ||||||||||||||||||||||||||||||||||
| setPopularExperiences(res.activities); | ||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||
| console.error('인기 체험을 불러오는 데 실패했습니다:', error); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+41
to
+43
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) 에러 처리 개선 고려사항 현재 콘솔 로그만 사용하고 있지만, 사용자에게 에러 상태를 표시하는 것을 고려해보세요. } catch (error) {
console.error('인기 체험을 불러오는 데 실패했습니다:', error);
+ // 에러 상태를 UI에 표시하는 로직 추가 고려
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| fetchPopular(); | ||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||
| <section className='pt-24 md:pt-34 pl-24 lg:pl-0 pb-40 lg:pb-33 lg:max-w-1200 lg:w-full mx-auto'> | ||||||||||||||||||||||||||||||||||
| {/* 섹션 제목 + 좌우 화살표 버튼 */} | ||||||||||||||||||||||||||||||||||
|
|
@@ -44,10 +59,15 @@ 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' 클래스로 식별 | ||||||||||||||||||||||||||||||||||
| <div key={idx} className='flex-shrink-0 card'> | ||||||||||||||||||||||||||||||||||
| <ExperienceCard /> | ||||||||||||||||||||||||||||||||||
| {popularExperiences.map((exp) => ( | ||||||||||||||||||||||||||||||||||
| <div key={exp.id} className='flex-shrink-0 card'> | ||||||||||||||||||||||||||||||||||
| <PopularCard | ||||||||||||||||||||||||||||||||||
| imageUrl={exp.bannerImageUrl} | ||||||||||||||||||||||||||||||||||
| title={exp.title} | ||||||||||||||||||||||||||||||||||
| rating={exp.rating} | ||||||||||||||||||||||||||||||||||
| reviews={exp.reviewCount} | ||||||||||||||||||||||||||||||||||
| price={exp.price} | ||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,12 +4,17 @@ import { useState, FormEvent } from 'react'; | |||||||||||||||
| import Input from '@components/Input'; | ||||||||||||||||
| import Button from '@components/Button'; | ||||||||||||||||
|
|
||||||||||||||||
| 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(''); // 선택 사항: 검색어 초기화 | ||||||||||||||||
| }; | ||||||||||||||||
|
Comment on lines
+16
to
18
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) 검색 후 입력 필드 초기화에 대한 UX 고려사항 검색 후 입력 필드를 자동으로 초기화하는 것이 항상 좋은 사용자 경험은 아닐 수 있습니다. 사용자가 검색어를 수정하거나 다시 확인하고 싶을 수 있습니다. const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSearch(searchValue);
- setSearchValue(''); // 선택 사항: 검색어 초기화
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
|
|
||||||||||||||||
| return ( | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,18 @@ | ||
| '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 './components/ExperienceList'; | ||
|
|
||
| export default function HomePage() { | ||
| const [searchKeyword, setSearchKeyword] = useState(''); | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return ( | ||
| <main> | ||
| <BannerSection /> | ||
| <BannerSection onSearch={setSearchKeyword} /> | ||
| <PopularExperiences /> | ||
| <ExperienceList /> | ||
| </main> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.