From 569952c81010041b3933992d3fa59c98116b7f38 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Tue, 19 Aug 2025 12:24:34 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=B1=85=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=83=AD=20=EC=9C=A0=EC=A7=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AC=B4=ED=95=9C=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/books/getSearchBooks.ts | 4 +- .../common/BookSearchBottomSheet/BookList.tsx | 57 +++++++++++++++++- .../BookSearchBottomSheet.tsx | 14 ++++- .../BookSearchBottomSheet/useBookSearch.ts | 59 ++++++++++++++++--- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/src/api/books/getSearchBooks.ts b/src/api/books/getSearchBooks.ts index 6f3add94..1ffd090d 100644 --- a/src/api/books/getSearchBooks.ts +++ b/src/api/books/getSearchBooks.ts @@ -58,9 +58,9 @@ export const getSearchBooks = async ( } }; -export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => { +export const convertToSearchedBooks = (apiBooks: BookSearchItem[], startIndex: number = 0): SearchedBook[] => { return apiBooks.map((book, index) => ({ - id: index + 1, + id: startIndex + index + 1, title: book.title, author: book.authorName, publisher: book.publisher, diff --git a/src/components/common/BookSearchBottomSheet/BookList.tsx b/src/components/common/BookSearchBottomSheet/BookList.tsx index 416211c7..9d9450ca 100644 --- a/src/components/common/BookSearchBottomSheet/BookList.tsx +++ b/src/components/common/BookSearchBottomSheet/BookList.tsx @@ -1,9 +1,12 @@ +import { useEffect, useRef, useCallback } from 'react'; import { BookList as StyledBookList, BookItem, BookCover, BookInfo, BookTitle, + LoadingContainer, + LoadingText, } from './BookSearchBottomSheet.styled'; export interface Book { @@ -17,17 +20,58 @@ export interface Book { interface BookListProps { books: Book[]; onBookSelect: (book: Book) => void; + onLoadMore?: () => Promise; + hasNextPage?: boolean; + isLoadingMore?: boolean; + isSearchMode?: boolean; } -const BookList = ({ books, onBookSelect }: BookListProps) => { +const BookList = ({ + books, + onBookSelect, + onLoadMore, + hasNextPage = false, + isLoadingMore = false, + isSearchMode = false +}: BookListProps) => { + const observerRef = useRef(null); + const loadingRef = useRef(null); + const handleImageError = (e: React.SyntheticEvent) => { e.currentTarget.style.display = 'none'; }; + // 무한스크롤을 위한 Intersection Observer 설정 + const lastBookElementRef = useCallback((node: HTMLDivElement | null) => { + if (isLoadingMore) return; + + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && hasNextPage && onLoadMore && isSearchMode) { + onLoadMore(); + } + }); + + if (node) observerRef.current.observe(node); + }, [isLoadingMore, hasNextPage, onLoadMore, isSearchMode]); + + useEffect(() => { + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, []); + return ( - {books.map(book => ( - onBookSelect(book)}> + {books.map((book, index) => ( + onBookSelect(book)} + ref={index === books.length - 1 ? lastBookElementRef : null} + > {book.title} @@ -36,6 +80,13 @@ const BookList = ({ books, onBookSelect }: BookListProps) => { ))} + + {/* 검색 모드에서 더 많은 데이터가 있고 로딩 중일 때 로딩 표시 */} + {isSearchMode && isLoadingMore && ( + + 더 많은 책을 불러오는 중... + + )} ); }; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 4fbd0d2b..1da3faae 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -26,10 +26,13 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott error, showEmptyState, showTabs, + hasNextPage, + isLoadingMore, setSearchQuery, handleTabChange, loadInitialData, performSearch, + loadMoreSearchResults, } = useBookSearch(); // 컴포넌트가 열릴 때 초기 데이터 로드 @@ -101,7 +104,16 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott onClose={onClose} /> - {showBookList && } + {showBookList && ( + + )} diff --git a/src/components/common/BookSearchBottomSheet/useBookSearch.ts b/src/components/common/BookSearchBottomSheet/useBookSearch.ts index 6fa7174e..82f24323 100644 --- a/src/components/common/BookSearchBottomSheet/useBookSearch.ts +++ b/src/components/common/BookSearchBottomSheet/useBookSearch.ts @@ -14,6 +14,9 @@ export const useBookSearch = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [searchTimeoutId, setSearchTimeoutId] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [hasNextPage, setHasNextPage] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); // API에서 받은 데이터를 Book 타입으로 변환하는 함수 const convertSavedBookToBook = (savedBook: SavedBook): Book => ({ @@ -76,34 +79,67 @@ export const useBookSearch = () => { }; // 실제 검색 API 호출 함수 - const performSearch = async (query: string) => { + const performSearch = async (query: string, page: number = 1, isNewSearch: boolean = true) => { if (!query.trim()) { setSearchResults([]); + setCurrentPage(1); + setHasNextPage(false); return; } try { - setIsLoading(true); + if (isNewSearch) { + setIsLoading(true); + setCurrentPage(1); + } else { + setIsLoadingMore(true); + } setError(null); - const response = await getSearchBooks(query.trim(), 1, true); + const response = await getSearchBooks(query.trim(), page, true); if (response.isSuccess) { - const convertedResults = convertToSearchedBooks(response.data.searchResult); - setSearchResults(convertedResults); + const startIndex = isNewSearch ? 0 : searchResults.length; + const convertedResults = convertToSearchedBooks(response.data.searchResult, startIndex); + + if (isNewSearch) { + setSearchResults(convertedResults); + } else { + setSearchResults(prev => [...prev, ...convertedResults]); + } + + setCurrentPage(page); + setHasNextPage(!response.data.last); } else { setError(response.message || '검색에 실패했습니다.'); - setSearchResults([]); + if (isNewSearch) { + setSearchResults([]); + } } } catch (err) { console.error('검색 오류:', err); setError('검색 중 오류가 발생했습니다.'); - setSearchResults([]); + if (isNewSearch) { + setSearchResults([]); + } } finally { - setIsLoading(false); + if (isNewSearch) { + setIsLoading(false); + } else { + setIsLoadingMore(false); + } } }; + // 더 많은 검색 결과 로드 + const loadMoreSearchResults = async () => { + if (!searchQuery.trim() || isLoadingMore || !hasNextPage) { + return; + } + + await performSearch(searchQuery.trim(), currentPage + 1, false); + }; + // 검색어 변경 핸들러 (디바운싱 적용) const handleSearchQueryChange = (query: string) => { setSearchQuery(query); @@ -120,6 +156,8 @@ export const useBookSearch = () => { } else { setSearchResults([]); setError(null); + setCurrentPage(1); + setHasNextPage(false); } }; @@ -165,7 +203,7 @@ export const useBookSearch = () => { const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; const hasBooks = isSearchMode ? searchResults.length > 0 : currentTabBooks.length > 0; const showEmptyState = !isLoading && !error && !hasBooks; - const showTabs = !isSearchMode && !showEmptyState; + const showTabs = !isSearchMode; // 검색 모드가 아닐 때는 항상 탭 표시 // 컴포넌트 언마운트 시 타이머 정리 useEffect(() => { @@ -186,11 +224,14 @@ export const useBookSearch = () => { hasBooks, showEmptyState, showTabs, + hasNextPage, + isLoadingMore, // Actions setSearchQuery: handleSearchQueryChange, handleTabChange, loadInitialData, performSearch, + loadMoreSearchResults, }; };