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
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/app/(with-header)/components/BannerSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function BannerSection({ keyword }: BannerSectionProps) {
<div className='absolute inset-0 bg-gradient-to-r from-black to-transparent' />

{/* 텍스트 콘텐츠 */}
<div className='relative z-10 flex flex-col items-start w-220 max-w-1200 md:w-440 lg:w-full pl-24 pt-74 md:pl-32 lg:pl-0 md:pt-144 lg:pt-159 lg:ml-auto lg:mr-auto gap-8 lg:gap-20 h-full text-white font-bold break-keep'>
<div className='relative z-10 flex flex-col items-start max-w-1200 md:w-440 lg:w-full pl-24 pt-74 md:pl-32 lg:pl-0 md:pt-144 lg:pt-159 lg:ml-auto lg:mr-auto gap-8 lg:gap-20 h-full text-white font-bold break-keep'>
<h2 className='text-2xl md:text-[54px] md:leading-[64px] lg:text-[68px] lg:leading-[78px]'>
오로라와 함께하는<br />
여름의 북극 감성 체험
Expand Down
36 changes: 32 additions & 4 deletions src/app/(with-header)/components/BasePage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client';

import { useSearchParams } from 'next/navigation';
import { motion } from 'framer-motion';

import BannerSection from '@/app/(with-header)/components/BannerSection';
import PopularExperiences from '@/app/(with-header)/components/PopularExperiences';
import ExperienceList from '@/app/(with-header)/components/ExperienceList';
Expand All @@ -12,13 +14,39 @@ export default function BasePage() {

return (
<main>
<BannerSection keyword={keyword} />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
<BannerSection keyword={keyword} />
</motion.div>

{isSearchMode ? (
<ExperienceList keyword={keyword} isSearchMode />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<ExperienceList keyword={keyword} isSearchMode />
</motion.div>
) : (
<>
<PopularExperiences />
<ExperienceList />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<PopularExperiences />
</motion.div>

<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
<ExperienceList />
</motion.div>
Comment on lines +17 to +49
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

애니메이션 구현 승인 및 접근성 개선 제안

framer-motion을 사용한 스태거드 애니메이션이 잘 구현되었습니다. 사용자 경험을 향상시키는 좋은 개선사항입니다.

다만, 접근성을 위해 prefers-reduced-motion 미디어 쿼리를 고려하는 것이 좋겠습니다.

+  const shouldReduceMotion = typeof window !== 'undefined' && 
+    window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+  const animationProps = shouldReduceMotion ? {} : {
+    initial: { opacity: 0, y: 20 },
+    animate: { opacity: 1, y: 0 },
+    transition: { duration: 0.6, ease: 'easeOut' }
+  };

   return (
     <main>
       <motion.div
-        initial={{ opacity: 0, y: 20 }}
-        animate={{ opacity: 1, y: 0 }}
-        transition={{ duration: 0.6, ease: 'easeOut' }}
+        {...animationProps}
       >
📝 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
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
<BannerSection keyword={keyword} />
</motion.div>
{isSearchMode ? (
<ExperienceList keyword={keyword} isSearchMode />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<ExperienceList keyword={keyword} isSearchMode />
</motion.div>
) : (
<>
<PopularExperiences />
<ExperienceList />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<PopularExperiences />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
<ExperienceList />
</motion.div>
// Inside your BasePage component, before the return:
const shouldReduceMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const animationProps = shouldReduceMotion
? {}
: {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.6, ease: 'easeOut' },
};
return (
<main>
<motion.div {...animationProps}>
<BannerSection keyword={keyword} />
</motion.div>
{isSearchMode ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<ExperienceList keyword={keyword} isSearchMode />
</motion.div>
) : (
<>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<PopularExperiences />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
<ExperienceList />
</motion.div>
</>
)}
</main>
);
🤖 Prompt for AI Agents
In src/app/(with-header)/components/BasePage.tsx around lines 17 to 49, the
staggered animations using framer-motion are implemented well but lack
consideration for users who prefer reduced motion. To fix this, detect the
user's `prefers-reduced-motion` setting using a media query and conditionally
disable or simplify the animations accordingly. This can be done by checking the
media query in a React hook or utility and adjusting the motion props to avoid
motion when the preference is set.

</>
)}
</main>
Expand Down
85 changes: 72 additions & 13 deletions src/app/(with-header)/components/CategoryFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import Button from '@/components/Button';
import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories';
import cn from '@/lib/cn';
import { useRef, useState, useEffect } from 'react';

interface CategoryFilterProps {
selectedCategory: ActivityCategory;
Expand All @@ -15,20 +16,78 @@ export default function CategoryFilter({
onChange,
className,
}: CategoryFilterProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [hasInteracted, setHasInteracted] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const startX = useRef(0);
const scrollLeft = useRef(0);

const handleFirstInteraction = () => {
if (!hasInteracted) setHasInteracted(true);
};

const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true);
startX.current = e.pageX - (scrollRef.current?.offsetLeft ?? 0);
scrollLeft.current = scrollRef.current?.scrollLeft ?? 0;
handleFirstInteraction();
};

const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || !scrollRef.current) return;
e.preventDefault();
const x = e.pageX - scrollRef.current.offsetLeft;
const walk = (x - startX.current) * 1;
scrollRef.current.scrollLeft = scrollLeft.current - walk;
handleFirstInteraction();
};

const handleMouseUpOrLeave = () => {
setIsDragging(false);
};

const handleScroll = () => {
handleFirstInteraction();
};

useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', handleScroll);
return () => el.removeEventListener('scroll', handleScroll);
}, []);
Comment on lines +19 to +58
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

드래그 스크롤 구현이 잘 되었지만 접근성을 개선하세요

드래그 스크롤 기능이 잘 구현되었고, 사용자 상호작용에 따른 그라데이션 오버레이 제어도 훌륭한 UX입니다. 하지만 접근성 측면에서 개선이 필요합니다.

정적 분석 도구의 경고를 해결하기 위해 다음과 같이 개선하세요:

      <div
        ref={scrollRef}
        className={cn(
          'relative z-20 flex w-full gap-8 pr-15 overflow-x-auto whitespace-nowrap no-scrollbar cursor-grab active:cursor-grabbing select-none',
          className,
        )}
+       role="tablist"
+       tabIndex={0}
+       aria-label="카테고리 필터"
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUpOrLeave}
        onMouseLeave={handleMouseUpOrLeave}
+       onKeyDown={(e) => {
+         if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
+           e.preventDefault();
+           const scrollAmount = 100;
+           if (scrollRef.current) {
+             scrollRef.current.scrollLeft += e.key === 'ArrowRight' ? scrollAmount : -scrollAmount;
+             handleFirstInteraction();
+           }
+         }
+       }}
      >

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

🤖 Prompt for AI Agents
In src/app/(with-header)/components/CategoryFilter.tsx around lines 19 to 58,
the drag scroll implementation lacks accessibility improvements and triggers
static analysis warnings. To fix this, add keyboard event handlers to support
keyboard navigation for scrolling, ensure the scroll container has appropriate
ARIA roles and tabIndex for focusability, and clean up event listeners properly
in useEffect. This will enhance accessibility and resolve static analysis tool
warnings.


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' />
<div className='relative w-full'>
{/* 스크롤 가능한 영역 */}
<div
ref={scrollRef}
className={cn(
'relative z-20 flex w-full gap-8 pr-15 overflow-x-auto whitespace-nowrap no-scrollbar cursor-grab active:cursor-grabbing select-none',
className,
)}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUpOrLeave}
onMouseLeave={handleMouseUpOrLeave}
>
{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>
))}

{/* 그라데이션: 처음만 보이고 상호작용하면 사라짐 */}
{!hasInteracted && (
<div className='md:hidden pointer-events-none absolute top-0 right-0 h-full w-[100px] bg-gradient-to-l from-white to-transparent z-10 transition-opacity duration-300' />
)}
</div>
</div>
);
}
6 changes: 3 additions & 3 deletions src/app/(with-header)/components/ExperienceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ export default function ExperienceCard({
price,
}: Props) {
return (
<div className='flex flex-col w-full gap-16'>
<div className='flex flex-col w-full gap-16 transition-all duration-300 hover:-translate-y-2'>
{/* 썸네일 */}
<div className='relative w-full h-168 md:h-221 lg:h-283 rounded-[20px] overflow-hidden'>
<Image
fill
alt={title}
className='object-cover'
className='object-cover transition-transform duration-300 hover:scale-105'
src={imageUrl}
/>
</div>
Expand All @@ -32,7 +32,7 @@ export default function ExperienceCard({
<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'>
<p className='mb-15 text-2lg font-semibold text-black line-clamp-2-custom'>
{title}
</p>
<p className='text-xl text-black font-bold'>
Expand Down
39 changes: 22 additions & 17 deletions src/app/(with-header)/components/ExperienceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList
const totalPage = Math.ceil(totalCount / 8);

return (
<section className='max-w-1200 m-auto px-24 lg:px-0 pb-83'>
<section className='max-w-1200 m-auto px-16 md:px-24 lg: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 className="text-left pt-24 lg:pt-40 text-black text-2xl md:text-3xl">
<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">
<p className="text-left text-lg font-normal mt-8 mb-16">
총 <span className="font-semibold">{totalCount}</span>개의 결과
</p>
Comment on lines +51 to 56
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

검색 안내 문구에 접근성 힌트 추가 권장

검색 결과 변화가 동적으로 발생하므로, 스크린리더 사용자를 위해 role="status" 혹은 aria-live="polite" 속성을 부여하면 접근성이 향상됩니다.

-<p className="text-left pt-24 lg:pt-40 text-black text-2xl md:text-3xl">
+<p
+  className="text-left pt-24 lg:pt-40 text-black text-2xl md:text-3xl"
+  role="status"
+  aria-live="polite"
+>
📝 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
<p className="text-left pt-24 lg:pt-40 text-black text-2xl md:text-3xl">
<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">
<p className="text-left text-lg font-normal mt-8 mb-16">
<span className="font-semibold">{totalCount}</span>
</p>
<p
className="text-left pt-24 lg:pt-40 text-black text-2xl md:text-3xl"
role="status"
aria-live="polite"
>
<span className="text-primary font-bold">{keyword}</span>() 검색한 결과입니다.
</p>
<p className="text-left text-lg font-normal mt-8 mb-16">
<span className="font-semibold">{totalCount}</span>
</p>
🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceList.tsx around lines 51 to 56,
the search result text updates dynamically but lacks accessibility hints for
screen readers. Add either role="status" or aria-live="polite" attribute to the
container element wrapping the dynamic search result text to notify assistive
technologies of content changes, improving accessibility.

{experiences.length === 0 && !isLoading && (
Expand All @@ -61,29 +61,34 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList
)}

{!isSearchMode && (
<div className='flex justify-between items-center mb-40'>
<div className='relative flex justify-between items-center mb-40 pr-120'>
<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 className=''> */}
<Dropdown
className='absolute right-0 md:w-130'
buttonClassName='flex flex-row items-center justify-between gap-0 border-nomad border rounded-[15px] text-md py-9 px-15'
listboxClassName='px-0 py-0'
optionClassName='pl-10 pr-0 py-9 text-md'
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>
Comment on lines +64 to 88
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

절대 위치 Dropdown이 다른 요소를 가릴 가능성

relative + absolute 조합에 pr-120 / md:w-130 과 같은 커스텀 spacing-token을 사용하면
① Tailwind 설정 누락 시 빌드 오류 발생
② 작은 화면에서 카테고리 필터 영역을 덮어 클릭이 안 될 수 있습니다.

플렉스 레이아웃을 그대로 활용하면 여유 padding 없이도 동일한 정렬이 가능합니다.

-<div className='relative flex justify-between items-center mb-40 pr-120'>
+<div className='flex justify-between items-center mb-40'>-  <Dropdown
-    className='absolute right-0 md:w-130'
+  <Dropdown
+    className='ml-auto w-[130px]'

덧붙여, Lines 72 / 87의 주석 처리된 div 는 불필요하니 정리해주세요.

📝 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='relative flex justify-between items-center mb-40 pr-120'>
<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 className=''> */}
<Dropdown
className='absolute right-0 md:w-130'
buttonClassName='flex flex-row items-center justify-between gap-0 border-nomad border rounded-[15px] text-md py-9 px-15'
listboxClassName='px-0 py-0'
optionClassName='pl-10 pr-0 py-9 text-md'
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>
<div className='flex justify-between items-center mb-40'>
<CategoryFilter
selectedCategory={selectedCategory}
onChange={(category) => {
setSelectedCategory(category);
setCurrentPage(1);
}}
/>
{/* <div className=''> */}
<Dropdown
className='ml-auto w-[130px]'
buttonClassName='flex flex-row items-center justify-between gap-0 border-nomad border rounded-[15px] text-md py-9 px-15'
listboxClassName='px-0 py-0'
optionClassName='pl-10 pr-0 py-9 text-md'
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>
🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceList.tsx between lines 64 and 88,
the Dropdown component uses absolute positioning with custom padding and width
tokens that may cause build errors if Tailwind config is missing and can overlap
the CategoryFilter on small screens. Remove the absolute positioning and custom
padding/width classes, instead rely on the existing flex layout to align
elements properly without extra spacing. Also, delete the commented-out div tags
on lines 72 and 87 as they are unnecessary.

)}

<div className='m-0'>
<div className='m-0 pb-30 md:pb-150 lg:pb-100'>
{!isSearchMode && (
<h2 className='text-xl md:text-3xl font-bold'>🛼 모든 체험</h2>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/app/(with-header)/components/PopularCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function PopularCard({
{/* 별점 정보 */}
<span className='text-md'>⭐ {rating} ({reviews})</span>
{/* 체험명 (줄바꿈 포함, 반응형 크기) */}
<p className='text-2lg md:text-3xl font-semibold'>{title}</p>
<p className='text-2lg md:text-3xl font-semibold line-clamp-2-custom'>{title}</p>
{/* 가격 정보 */}
<p className='text-lg md:text-xl'>₩ {price.toLocaleString()} <span className='text-gray-600 text-md'>/ 인</span></p>
</div>
Expand Down
Loading