diff --git a/src/app/(with-header)/myprofile/_components/MyWineListContainer.tsx b/src/app/(with-header)/myprofile/_components/MyWineListContainer.tsx index ed56111..a6671fe 100644 --- a/src/app/(with-header)/myprofile/_components/MyWineListContainer.tsx +++ b/src/app/(with-header)/myprofile/_components/MyWineListContainer.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import Image from 'next/image'; import { fetchWithAuth } from '@/lib/auth'; -import { MyWineListResponse, WineDetails } from '@/types/wine'; +import { WineListResponse, WineDetails } from '@/types/wine'; import emptyData from '@/assets/icons/empty_review.svg'; import WineCard from '@/components/WineCard'; import { WineDataProps } from './MyWIneKebabDropDown '; @@ -26,7 +26,7 @@ export default function MyWineListContainer({ setDataCount }: { setDataCount: (v return; } - const data: MyWineListResponse = await response.json(); + const data: WineListResponse = await response.json(); setMyWineData(data.list); setDataCount(data.totalCount); } catch (error) { diff --git a/src/app/(with-header)/wines/Wines.tsx b/src/app/(with-header)/wines/Wines.tsx index 3fae938..8fd10eb 100644 --- a/src/app/(with-header)/wines/Wines.tsx +++ b/src/app/(with-header)/wines/Wines.tsx @@ -3,7 +3,7 @@ import WineListContainer from './_components/WineListContainer'; export default function Wines() { return ( -
+
diff --git a/src/app/(with-header)/wines/_components/WineCard.tsx b/src/app/(with-header)/wines/_components/WineCard.tsx index 98c37df..769cd58 100644 --- a/src/app/(with-header)/wines/_components/WineCard.tsx +++ b/src/app/(with-header)/wines/_components/WineCard.tsx @@ -1,5 +1,6 @@ 'use client'; +import { forwardRef } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthProvider'; @@ -11,7 +12,7 @@ type WineCardProps = { wine: WineDetails; }; -export default function WineCard({ wine }: WineCardProps) { +const WineCard = forwardRef(({ wine }, ref) => { const router = useRouter(); const { isLoggedIn } = useAuth(); const handleClick = () => { @@ -23,7 +24,7 @@ export default function WineCard({ wine }: WineCardProps) { }; return ( -
+
{wine.name} @@ -58,4 +59,8 @@ export default function WineCard({ wine }: WineCardProps) {
); -} +}); + +WineCard.displayName = 'WineCard'; + +export default WineCard; diff --git a/src/app/(with-header)/wines/_components/WineFilterModal.tsx b/src/app/(with-header)/wines/_components/WineFilterModal.tsx index 61ed7b9..2f5fb32 100644 --- a/src/app/(with-header)/wines/_components/WineFilterModal.tsx +++ b/src/app/(with-header)/wines/_components/WineFilterModal.tsx @@ -78,7 +78,7 @@ export default function WineFilterModal({ isOpen, onClose, onApply, onFilterChan >

필터

- 닫기 아이콘 + 닫기 아이콘
diff --git a/src/app/(with-header)/wines/_components/WineListContainer.tsx b/src/app/(with-header)/wines/_components/WineListContainer.tsx index 50529bd..2b97b68 100644 --- a/src/app/(with-header)/wines/_components/WineListContainer.tsx +++ b/src/app/(with-header)/wines/_components/WineListContainer.tsx @@ -1,14 +1,16 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useAuth } from '@/contexts/AuthProvider'; import Image from 'next/image'; +import { fetchWines } from '@/lib/fetchWines'; import { WineDetails } from '@/types/wine'; import SearchBar from './SearchBar'; import WineFilter from './WineFilter'; import WineFilterModal from './WineFilterModal'; import WineCard from './WineCard'; import PostWineModal from '@/components/modal/PostWineModal'; +import LoadingSpinner from '@/components/LoadingSpinner'; import filterIcon from '@/assets/icons/filter.svg'; const MAX_PRICE = 2000000; @@ -25,6 +27,62 @@ export default function WineListContainer() { }); const [isFilterModalOpen, setFilterModalOpen] = useState(false); const [pendingFilters, setPendingFilters] = useState(filters); + const [isLoading, setIsLoading] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(true); + const lastWineRef = useRef(null); + + const loadMoreWines = useCallback(async () => { + if (isLoading || !hasMore) return; + setIsLoading(true); + try { + const response = await fetchWines({ + limit: 8, + cursor: nextCursor ?? undefined, + type: filters.type ?? undefined, + minPrice: filters.minPrice || undefined, + maxPrice: filters.maxPrice || undefined, + rating: filters.rating ?? undefined, + name: searchQuery || undefined, + }); + setWines((prev) => [...prev, ...response.list]); + setNextCursor(response.nextCursor); + setHasMore(response.nextCursor !== null); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }, [isLoading, hasMore, nextCursor, filters, searchQuery]); + + useEffect(() => { + if (!hasMore || !lastWineRef.current) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + loadMoreWines(); + } + }, + { + root: null, + threshold: 0.5, + }, + ); + observer.observe(lastWineRef.current); + return () => observer.disconnect(); + }, [loadMoreWines, hasMore]); + + useEffect(() => { + setWines([]); + setNextCursor(null); + setHasMore(true); + }, [filters, searchQuery]); + + useEffect(() => { + if (wines.length === 0 && hasMore) { + loadMoreWines(); + } + }, [wines, hasMore, loadMoreWines]); useEffect(() => { if (isFilterModalOpen) { @@ -55,29 +113,6 @@ export default function WineListContainer() { setFilterModalOpen(false); }; - useEffect(() => { - const fetchWines = async () => { - const params = new URLSearchParams(); - if (filters.type) params.append('type', filters.type.toUpperCase()); - if (filters.minPrice) params.append('minPrice', filters.minPrice.toString()); - if (filters.maxPrice) params.append('maxPrice', filters.maxPrice.toString()); - if (filters.rating) params.append('rating', filters.rating.toString()); - if (searchQuery) params.append('name', searchQuery); - - const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/wines?limit=20&${params.toString()}`); - - if (!res.ok) { - console.error('Failed to fetch wines'); - return; - } - - const data = await res.json(); - setWines(data.list); - }; - - fetchWines(); - }, [filters, searchQuery]); - return (
@@ -109,10 +144,13 @@ export default function WineListContainer() { )}
setFilterModalOpen(false)} onApply={applyFilters} initialFilters={pendingFilters} onFilterChange={setPendingFilters} /> -
- {wines.map((wine) => ( - - ))} +
+
+ {wines.map((wine, index) => ( + + ))} + {isLoading && } +
); diff --git a/src/app/globals.css b/src/app/globals.css index 4d2e1fc..556b809 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -24,4 +24,10 @@ body { -webkit-box-orient: vertical; overflow: hidden; } + .scrollbar-hidden { + overflow-y: auto; + } + .scrollbar-hidden::-webkit-scrollbar { + width: 0px; + } } diff --git a/src/lib/fetchWines.ts b/src/lib/fetchWines.ts new file mode 100644 index 0000000..7ca069c --- /dev/null +++ b/src/lib/fetchWines.ts @@ -0,0 +1,30 @@ +import { WineListResponse } from '@/types/wine'; + +interface FetchWinesParams { + limit: number; + cursor?: number; + type?: string; + minPrice?: number; + maxPrice?: number; + rating?: number; + name?: string; +} + +export async function fetchWines({ limit, cursor, type, minPrice, maxPrice, rating, name }: FetchWinesParams): Promise { + const params = new URLSearchParams(); + params.append('limit', limit.toString()); + if (cursor) params.append('cursor', cursor.toString()); + if (type) params.append('type', type.toUpperCase()); + if (minPrice) params.append('minPrice', minPrice.toString()); + if (maxPrice) params.append('maxPrice', maxPrice.toString()); + if (rating) params.append('rating', rating.toString()); + if (name) params.append('name', name); + + const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/wines?${params.toString()}`); + + if (!res.ok) { + throw new Error('와인 목록을 가져오는 데 실패했습니다.'); + } + + return res.json(); +} diff --git a/src/types/wine.ts b/src/types/wine.ts index 2c6b2e9..5adca0d 100644 --- a/src/types/wine.ts +++ b/src/types/wine.ts @@ -26,13 +26,13 @@ export interface Wine { avgRating: number; } -export interface WineListResponse { +export interface RecommendWineListResponse { totalCount: number; nextCursor: number | null; list: Wine[]; } -export interface MyWineListResponse { +export interface WineListResponse { totalCount: number; nextCursor: number | null; list: WineDetails[];