@@ -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[];