diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d19a6dc2..75c275ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4270,4 +4270,4 @@ snapshots: zustand@5.0.6(@types/react@19.1.8)(react@19.1.0): optionalDependencies: '@types/react': 19.1.8 - react: 19.1.0 \ No newline at end of file + react: 19.1.0 diff --git a/src/app/(with-header)/components/BannerSection.tsx b/src/app/(with-header)/components/BannerSection.tsx index 5e18d2aa..dd7c745e 100644 --- a/src/app/(with-header)/components/BannerSection.tsx +++ b/src/app/(with-header)/components/BannerSection.tsx @@ -22,7 +22,7 @@ export default function BannerSection({ keyword }: BannerSectionProps) {
{/* 텍스트 콘텐츠 */} -
+

오로라와 함께하는
여름의 북극 감성 체험 diff --git a/src/app/(with-header)/components/BasePage.tsx b/src/app/(with-header)/components/BasePage.tsx index 14210609..b81de553 100644 --- a/src/app/(with-header)/components/BasePage.tsx +++ b/src/app/(with-header)/components/BasePage.tsx @@ -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'; @@ -12,13 +14,39 @@ export default function BasePage() { return (
- + + + + {isSearchMode ? ( - + + + ) : ( <> - - + + + + + + + )}
diff --git a/src/app/(with-header)/components/CategoryFilter.tsx b/src/app/(with-header)/components/CategoryFilter.tsx index 08a2a847..0eb93da7 100644 --- a/src/app/(with-header)/components/CategoryFilter.tsx +++ b/src/app/(with-header)/components/CategoryFilter.tsx @@ -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; @@ -15,20 +16,78 @@ export default function CategoryFilter({ onChange, className, }: CategoryFilterProps) { + const scrollRef = useRef(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) => { + setIsDragging(true); + startX.current = e.pageX - (scrollRef.current?.offsetLeft ?? 0); + scrollLeft.current = scrollRef.current?.scrollLeft ?? 0; + handleFirstInteraction(); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + 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); + }, []); + return ( -
- {ACTIVITY_CATEGORIES.map((category) => ( - - ))} -
+
+ {/* 스크롤 가능한 영역 */} +
+ {ACTIVITY_CATEGORIES.map((category) => ( + + ))} + + {/* 그라데이션: 처음만 보이고 상호작용하면 사라짐 */} + {!hasInteracted && ( +
+ )} +
); } diff --git a/src/app/(with-header)/components/ExperienceCard.tsx b/src/app/(with-header)/components/ExperienceCard.tsx index df212042..2051f610 100644 --- a/src/app/(with-header)/components/ExperienceCard.tsx +++ b/src/app/(with-header)/components/ExperienceCard.tsx @@ -16,13 +16,13 @@ export default function ExperienceCard({ price, }: Props) { return ( -
+
{/* 썸네일 */}
{title}
@@ -32,7 +32,7 @@ export default function ExperienceCard({ ⭐ {rating} ({reviews}) -

+

{title}

diff --git a/src/app/(with-header)/components/ExperienceList.tsx b/src/app/(with-header)/components/ExperienceList.tsx index 441ea655..ef3969d4 100644 --- a/src/app/(with-header)/components/ExperienceList.tsx +++ b/src/app/(with-header)/components/ExperienceList.tsx @@ -44,14 +44,14 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList const totalPage = Math.ceil(totalCount / 8); return ( -

+
{/* 🔍 검색 모드일 때 문구 표시 */} {isSearchMode && keyword && ( <> -

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

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

-

+

{totalCount}개의 결과

{experiences.length === 0 && !isLoading && ( @@ -61,7 +61,7 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList )} {!isSearchMode && ( -
+
{ @@ -69,21 +69,26 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList setCurrentPage(1); }} /> - { - const value = SORT_VALUE_MAP[label]; - setSortOption(value); - setCurrentPage(1); - }} - /> + {/*
*/} + { + const value = SORT_VALUE_MAP[label]; + setSortOption(value); + setCurrentPage(1); + }} + /> + {/*
*/}
)} -
+
{!isSearchMode && (

🛼 모든 체험

)} diff --git a/src/app/(with-header)/components/PopularCard.tsx b/src/app/(with-header)/components/PopularCard.tsx index c2791ccd..4823fe27 100644 --- a/src/app/(with-header)/components/PopularCard.tsx +++ b/src/app/(with-header)/components/PopularCard.tsx @@ -31,7 +31,7 @@ export default function PopularCard({ {/* 별점 정보 */} ⭐ {rating} ({reviews}) {/* 체험명 (줄바꿈 포함, 반응형 크기) */} -

{title}

+

{title}

{/* 가격 정보 */}

₩ {price.toLocaleString()} / 인

diff --git a/src/app/(with-header)/components/PopularExperiences.tsx b/src/app/(with-header)/components/PopularExperiences.tsx index b2f3f872..3c339441 100644 --- a/src/app/(with-header)/components/PopularExperiences.tsx +++ b/src/app/(with-header)/components/PopularExperiences.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useRef } from 'react'; +import { useRef, useEffect } from 'react'; import Link from 'next/link'; -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, type InfiniteData } from '@tanstack/react-query'; import IconArrowLeft from '@assets/svg/left-arrow'; import IconArrowRight from '@assets/svg/right-arrow'; @@ -11,15 +11,53 @@ import PopularCard from '@/app/(with-header)/components/PopularCard'; import PopularCardSkeleton from '@/app/(with-header)/components/Skeletons/PopularCardSkeleton'; import { getPopularExperiences } from '@/app/api/experiences/getPopularExperiences'; +import { ExperienceResponse } from '@/types/experienceListTypes'; export default function PopularExperiences() { const sliderRef = useRef(null); + const loadMoreRef = useRef(null); - const { data, isLoading, error } = useQuery({ + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + status, + } = useInfiniteQuery< + ExperienceResponse, // 단일 page 응답 타입 + Error, // 에러 타입 + InfiniteData // 전체 infinite data 타입 + >({ queryKey: ['popularExperiences'], - queryFn: getPopularExperiences, + queryFn: ({ pageParam = undefined }) => + getPopularExperiences(pageParam as number | undefined), // 타입 단언 필요 + initialPageParam: undefined, + getNextPageParam: (lastPage) => + Array.isArray(lastPage.activities) && lastPage.activities.length > 0 + ? lastPage.cursorId + : undefined, }); + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { root: sliderRef.current, threshold: 1.0 }, + ); + + const target = loadMoreRef.current; + if (target) observer.observe(target); + return () => { + if (target) observer.unobserve(target); + }; + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const allActivities = + data?.pages.flatMap((page) => page.activities) ?? []; + const scrollByCard = (direction: 'left' | 'right') => { if (!sliderRef.current) return; @@ -37,8 +75,7 @@ export default function PopularExperiences() { }; return ( -
- {/* 제목 + 버튼 */} +

🔥 인기 체험

@@ -47,36 +84,34 @@ export default function PopularExperiences() {
- {/* 카드 영역 */}
- {error ? ( -

인기 체험을 불러오는 데 실패했습니다 😢

- ) : isLoading || !data ? ( + {status === 'pending' ? ( Array.from({ length: 4 }).map((_, idx) => (
)) ) : ( - data.activities.map((exp) => ( -
- - - -
- )) + <> + {allActivities.map((exp) => ( +
+ + + +
+ ))} + {/* 무한스크롤 감지용 */} +
+ )}
diff --git a/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx b/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx index 9f67b35b..75595352 100644 --- a/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx +++ b/src/app/(with-header)/myactivity/[id]/components/EditActivityForm.tsx @@ -50,7 +50,7 @@ export default function EditActivityForm() { if (isError) return
오류가 발생했습니다: {isError}
; return ( -
+
diff --git a/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts b/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts index 3e19dc49..b9f0bd59 100644 --- a/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts +++ b/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts @@ -156,6 +156,8 @@ export const useEditActivityForm = () => { onSuccess: () => { toast.success('수정되었습니다!'); queryClient.invalidateQueries({ queryKey: ['activity', id] }); + queryClient.invalidateQueries({ queryKey: ['experiences'] }); + queryClient.invalidateQueries({ queryKey: ['popularExperiences'] }); router.push(`/activities/${id}`); }, onError: (err: unknown) => { diff --git a/src/app/(with-header)/myactivity/components/CategoryInput.tsx b/src/app/(with-header)/myactivity/components/CategoryInput.tsx index e4c5b542..2fecdacf 100644 --- a/src/app/(with-header)/myactivity/components/CategoryInput.tsx +++ b/src/app/(with-header)/myactivity/components/CategoryInput.tsx @@ -1,6 +1,7 @@ +import ChevronIcon from '@assets/svg/chevron'; // 아이콘 경로는 맞게 조정 + interface CategoryProps { category?: string; - onCategoryChange: (value: string) => void; } @@ -13,9 +14,11 @@ export default function CategoryInput({ {/* */} -
+
+ + {/* 커스텀 화살표 아이콘 */} +
+ +
); diff --git a/src/app/(with-header)/myactivity/components/ReservationForm.tsx b/src/app/(with-header)/myactivity/components/ReservationForm.tsx index da167016..3b251cb9 100644 --- a/src/app/(with-header)/myactivity/components/ReservationForm.tsx +++ b/src/app/(with-header)/myactivity/components/ReservationForm.tsx @@ -33,7 +33,7 @@ export default function ReservationForm() { } = useCreateActivityForm(); return ( -
+
diff --git a/src/app/(with-header)/myactivity/components/ScheduleSelect.tsx b/src/app/(with-header)/myactivity/components/ScheduleSelect.tsx index d5b92025..c00f2cdd 100644 --- a/src/app/(with-header)/myactivity/components/ScheduleSelect.tsx +++ b/src/app/(with-header)/myactivity/components/ScheduleSelect.tsx @@ -30,8 +30,8 @@ export function ScheduleSelect({ }: ScheduleSelectProps) { return (
-
-
+
+
-
+
-
+
-
+
{isRemovable && ( - +
+ +
)}
diff --git a/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts b/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts index 6dc69264..e3ca6d53 100644 --- a/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts +++ b/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import axios from 'axios'; import { uploadImage } from '../utils/uploadImage'; import { privateInstance } from '@/apis/privateInstance'; +import { useQueryClient } from '@tanstack/react-query'; export interface DateSlot { date: string; @@ -25,6 +26,7 @@ export const useCreateActivityForm = () => { const [price, setPrice] = useState(''); const [description, setDescription] = useState(''); const [address, setAddress] = useState(''); + const queryClient = useQueryClient(); const router = useRouter(); @@ -55,6 +57,8 @@ export const useCreateActivityForm = () => { }, onSuccess: (data) => { toast.success('체험이 성공적으로 등록되었습니다!'); + queryClient.invalidateQueries({ queryKey: ['experiences'] }); // 모든 체험 리스트 새로고침 + queryClient.invalidateQueries({ queryKey: ['popularExperiences'] }); // 인기 체험 리스트도 새로고침 router.push(`/activities/${data.id}`); }, onError: (err) => { diff --git a/src/app/api/experiences/getPopularExperiences.ts b/src/app/api/experiences/getPopularExperiences.ts index 6421d9f7..72367638 100644 --- a/src/app/api/experiences/getPopularExperiences.ts +++ b/src/app/api/experiences/getPopularExperiences.ts @@ -1,21 +1,27 @@ import { instance } from '@/apis/instance'; import { Experience } from '@/types/experienceListTypes'; -interface PopularExperiencesResponse { +export interface ExperienceResponse { + cursorId: number; + totalCount: number; activities: Experience[]; } const baseUrl = process.env.NEXT_PUBLIC_API_SERVER_URL; const url = `${baseUrl}/activities`; -export const getPopularExperiences = async (): Promise => { - const res = await instance.get(url, { +// 커서 기반 파라미터를 받도록 수정 +export const getPopularExperiences = async ( + cursorId?: number, +): Promise => { + const res = await instance.get(url, { params: { method: 'cursor', sort: 'most_reviewed', - size: 12, + size: 10, + cursorId, // null이면 첫 페이지로 처리됨 }, }); return res.data; -}; \ No newline at end of file +}; diff --git a/src/app/globals.css b/src/app/globals.css index 97032006..9bca2295 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -144,6 +144,32 @@ scrollbar-width: none; } +@layer utilities { + .line-clamp-1-custom { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } + + .line-clamp-2-custom { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } + + .line-clamp-3-custom { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index 38e78fb5..aad3f90e 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -5,8 +5,8 @@ import { motion, AnimatePresence } from 'framer-motion'; import cn from '@lib/cn'; import useOutsideClick from '@hooks/useOutsideClick'; import ChevronIcon from '@assets/svg/chevron'; -import CheckIcon from '@assets/svg/check'; import { DropdownProps } from '@/types/dropdownTypes'; +// import CheckIcon from '@assets/svg/check'; /** * 드롭다운 컴포넌트입니다. @@ -34,6 +34,9 @@ export default function Dropdown({ className, disabled = false, disableScroll = false, + buttonClassName, + listboxClassName, + optionClassName, }: DropdownProps) { // 내부 상태 관리 const [internalValue, setInternalValue] = useState(''); @@ -131,6 +134,7 @@ export default function Dropdown({ 'focus:border-green-300 focus:outline-none', disabled && 'cursor-not-allowed bg-gray-100 opacity-50', isOpen && !disabled && 'border-green-300', + buttonClassName, )} aria-expanded={isOpen} aria-haspopup='listbox' @@ -166,6 +170,7 @@ export default function Dropdown({ className={cn( 'p-8', disableScroll ? '' : 'max-h-240 overflow-auto', + listboxClassName, )} > {options.map((option, index) => { @@ -185,12 +190,12 @@ export default function Dropdown({ isSelected ? 'bg-nomad rounded text-white' : 'hover:bg-gray-100', - )} + optionClassName,)} onClick={() => handleSelect(option)} onMouseEnter={() => setFocusedIndex(index)} > {/* 아이콘 영역 */} -
+ {/*
{isSelected && ( ({ className='text-white' /> )} -
+
*/} {option} ); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 81d08374..9c45f4ee 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -5,9 +5,9 @@ export default function Footer() {