From 9fc233cff62d376c44e6bc7833fe8c9c229c0d12 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 00:56:50 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=B1=85=20=EB=B0=8F=20=EC=B0=B8=EC=97=AC=20=EC=A4=91=20?= =?UTF-8?q?=EB=AA=A8=EC=9E=84=20=EC=B1=85=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/books/getSavedBooks.ts | 39 ++++ .../BookSearchBottomSheet.styled.ts | 119 +++++++---- .../BookSearchBottomSheet.tsx | 202 +++++++++++------- 3 files changed, 241 insertions(+), 119 deletions(-) create mode 100644 src/api/books/getSavedBooks.ts diff --git a/src/api/books/getSavedBooks.ts b/src/api/books/getSavedBooks.ts new file mode 100644 index 00000000..8157a8b8 --- /dev/null +++ b/src/api/books/getSavedBooks.ts @@ -0,0 +1,39 @@ +import { apiClient } from '../index'; + +// 저장한 책 정보 타입 +export interface SavedBook { + bookId: number; + bookTitle: string; + authorName: string; + publisher: string; + bookImageUrl: string; + isbn: string; +} + +// API 응답 데이터 타입 +export interface SavedBooksData { + bookList: SavedBook[]; +} + +// API 응답 타입 +export interface SavedBooksResponse { + isSuccess: boolean; + code: number; + message: string; + data: SavedBooksData; +} + +// 저장한 책 또는 참여 중 모임의 책 조회 +export const getSavedBooks = async (type: 'saved' | 'joining'): Promise => { + try { + const response = await apiClient.get('/books/selectable-list', { + params: { + type: type.toUpperCase(), + }, + }); + return response.data; + } catch (error) { + console.error('저장한 책 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts index a75a27f4..7cea5f53 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts @@ -111,71 +111,65 @@ export const TabContainer = styled.div` export const Tab = styled.button<{ active: boolean }>` background: none; border: none; - color: ${({ active }) => (active ? semanticColors.text.primary : semanticColors.text.ghost)}; + color: ${({ active }) => (active ? colors.white : colors.grey[300])}; font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.semibold}; - padding: 8px 0 8px 0; cursor: pointer; position: relative; - ${({ active }) => - active && - ` - &::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background-color: ${semanticColors.text.primary}; - } - `} + &:after { + content: ''; + position: absolute; + bottom: -8px; + left: 5px; + right: 5px; + height: 2px; + background-color: ${({ active }) => (active ? colors.white : 'transparent')}; + transition: background-color 0.2s ease; + } + + &:hover { + color: ${colors.white}; + } `; export const BookListContainer = styled.div` flex: 1; overflow-y: auto; - margin-right: -16px; - padding-right: 16px; + min-height: 0; &::-webkit-scrollbar { - width: 4px; + width: 3px; } &::-webkit-scrollbar-track { - background: ${colors.grey[400]}; - border-radius: 2px; - margin-right: 14px; + background: transparent; } &::-webkit-scrollbar-thumb { - background-color: ${colors.white}; - border-radius: 2px; + background: ${colors.white}; + border-radius: 3px; } &::-webkit-scrollbar-thumb:hover { - background-color: ${colors.grey[200]}; + background: ${colors.grey[100]}; } - - /* Firefox 스크롤바 */ - scrollbar-width: thin; - scrollbar-color: ${colors.white} ${colors.grey[400]}; `; export const BookList = styled.div` display: flex; flex-direction: column; - gap: 0; + gap: 12px; `; export const BookItem = styled.div` display: flex; align-items: center; - gap: 8px; - padding: 12px 0; - border-bottom: 1px solid ${colors.grey[400]}; + padding: 12px 2px; + background-color: none; + border-radius: 12px; cursor: pointer; + transition: background-color 0.2s ease; `; export const BookCover = styled.div` @@ -183,7 +177,8 @@ export const BookCover = styled.div` height: 60px; overflow: hidden; flex-shrink: 0; - background-color: ${semanticColors.background.card}; + margin-right: 8px; + border: 1px solid ${colors.grey[300]}; img { width: 100%; @@ -194,14 +189,62 @@ export const BookCover = styled.div` export const BookInfo = styled.div` flex: 1; - display: flex; - flex-direction: column; - gap: 4px; + min-width: 0; `; -export const BookTitle = styled.div` - color: ${semanticColors.text.primary}; +export const BookTitle = styled.h3` font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.regular}; + color: ${colors.white}; + margin: 0; line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +// 로딩 상태 스타일 +export const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; +`; + +export const LoadingText = styled.p` + color: ${colors.grey[300]}; + font-size: ${typography.fontSize.base}; + margin: 0; +`; + +// 에러 상태 스타일 +export const ErrorContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; +`; + +export const ErrorText = styled.p` + color: ${colors.red}; + font-size: ${typography.fontSize.base}; + margin: 0; + text-align: center; +`; + +// 빈 상태 스타일 +export const EmptyContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; +`; + +export const EmptyText = styled.p` + color: ${colors.grey[300]}; + font-size: ${typography.fontSize.base}; + margin: 0; + text-align: center; `; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 88843320..09d59436 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import closeIcon from '../../../assets/group/close.svg'; import whitesearchIcon from '../../../assets/group/search_white.svg'; +import { getSavedBooks, type SavedBook } from '@/api/books/getSavedBooks'; import { Overlay, BottomSheetContainer, @@ -18,6 +19,12 @@ import { BookCover, BookInfo, BookTitle, + LoadingContainer, + LoadingText, + ErrorContainer, + ErrorText, + EmptyContainer, + EmptyText, } from './BookSearchBottomSheet.styled.ts'; // Types @@ -37,78 +44,94 @@ interface BookSearchBottomSheetProps { type TabType = 'saved' | 'group'; -const mockSavedBooks: Book[] = [ - { - id: 1, - title: '토마토 컵라면', - author: '작가명', - cover: '/src/assets/books/tomato.svg', - isbn: '9780374500016', - }, - { - id: 2, - title: '사슴', - author: '작가명', - cover: '/src/assets/books/deer.svg', - isbn: '9781234567891', - }, - { - id: 3, - title: '호르몬 체인지', - author: '작가명', - cover: '/src/assets/books/hormone.svg', - isbn: '9781234567892', - }, -]; - -const mockGroupBooks: Book[] = [ - { - id: 4, - title: '단 한번의 삶', - author: '작가명', - cover: '/src/assets/books/life.svg', - isbn: '9781234567893', - }, - { - id: 5, - title: '호르몬 체인지', - author: '작가명', - cover: '/src/assets/books/hormone.svg', - isbn: '9781234567892', - }, - { - id: 6, - title: '토마토 컵라면', - author: '작가명', - cover: '/src/assets/books/tomato.svg', - isbn: '9781234567890', - }, -]; - const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { // State const [searchQuery, setSearchQuery] = useState(''); - const [filteredBooks, setFilteredBooks] = useState(mockSavedBooks); + const [filteredBooks, setFilteredBooks] = useState([]); const [activeTab, setActiveTab] = useState('saved'); + const [savedBooks, setSavedBooks] = useState([]); + const [groupBooks, setGroupBooks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // API에서 받은 데이터를 Book 타입으로 변환하는 함수 + const convertSavedBookToBook = (savedBook: SavedBook): Book => ({ + id: savedBook.bookId, + title: savedBook.bookTitle, + author: savedBook.authorName, + cover: savedBook.bookImageUrl, + isbn: savedBook.isbn, + }); + + // 저장한 책 데이터 가져오기 + const fetchSavedBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('saved'); + + if (response.isSuccess && response.data) { + setSavedBooks(response.data.bookList); + } else { + setError(response.message || '저장한 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('저장한 책 조회 오류:', err); + setError('저장한 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // 모임 책 데이터 가져오기 + const fetchGroupBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('joining'); + + if (response.isSuccess && response.data) { + setGroupBooks(response.data.bookList); + } else { + setError(response.message || '모임 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('모임 책 조회 오류:', err); + setError('모임 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; - // Effects + // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { - // 현재 활성화된 탭의 책 목록 가져오기 - const currentTabBooks = activeTab === 'saved' ? mockSavedBooks : mockGroupBooks; + if (isOpen) { + if (activeTab === 'saved' && savedBooks.length === 0) { + fetchSavedBooks(); + } else if (activeTab === 'group' && groupBooks.length === 0) { + fetchGroupBooks(); + } + } + }, [isOpen, activeTab, savedBooks.length, groupBooks.length]); + + // 필터링 로직 + useEffect(() => { + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const convertedBooks = currentTabBooks.map(convertSavedBookToBook); if (searchQuery.trim() === '') { - // 검색어가 없을 때는 선택된 탭의 전체 목록 표시 - setFilteredBooks(currentTabBooks); + setFilteredBooks(convertedBooks); } else { - // 검색어가 있을 때는 선택된 탭 내에서만 검색 - const filtered = currentTabBooks.filter( + const filtered = convertedBooks.filter( book => book.title.toLowerCase().includes(searchQuery.toLowerCase()) || book.author.toLowerCase().includes(searchQuery.toLowerCase()), ); setFilteredBooks(filtered); } - }, [searchQuery, activeTab]); + }, [searchQuery, activeTab, savedBooks, groupBooks]); useEffect(() => { if (isOpen) { @@ -153,26 +176,25 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott setSearchQuery(''); }; - const handleTabChange = (tab: TabType) => { + const handleTabChange = async (tab: TabType) => { setActiveTab(tab); - // 탭 변경 시 현재 검색어로 새로운 탭에서 다시 검색 - const newTabBooks = tab === 'saved' ? mockSavedBooks : mockGroupBooks; + setError(null); - if (searchQuery.trim() === '') { - setFilteredBooks(newTabBooks); - } else { - const filtered = newTabBooks.filter( - book => - book.title.toLowerCase().includes(searchQuery.toLowerCase()) || - book.author.toLowerCase().includes(searchQuery.toLowerCase()), - ); - setFilteredBooks(filtered); + // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 + if (tab === 'saved' && savedBooks.length === 0) { + await fetchSavedBooks(); + } else if (tab === 'group' && groupBooks.length === 0) { + await fetchGroupBooks(); } }; // 검색어가 없을 때만 탭 표시 const showTabs = searchQuery.trim() === ''; + // 현재 탭의 책 개수 확인 + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const hasBooks = currentTabBooks.length > 0; + return ( @@ -211,18 +233,36 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott {/* 책 목록 영역 */} - - {filteredBooks.map(book => ( - handleBookSelect(book)}> - - {book.title} - - - {book.title} - - - ))} - + {isLoading ? ( + + 책 목록을 불러오는 중... + + ) : error ? ( + + {error} + + ) : !hasBooks ? ( + + + {activeTab === 'saved' + ? '저장한 책이 없습니다.' + : '참여 중인 모임의 책이 없습니다.'} + + + ) : ( + + {filteredBooks.map(book => ( + handleBookSelect(book)}> + + {book.title} + + + {book.title} + + + ))} + + )} From 945328e12221c0c9670e2264ac0af7830abbace9 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 00:59:55 +0900 Subject: [PATCH 2/6] =?UTF-8?q?style:=20=EB=B6=81=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=AD=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=EC=84=A0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookSearchBottomSheet/BookSearchBottomSheet.styled.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts index 7cea5f53..ffe50b6c 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts @@ -159,7 +159,7 @@ export const BookListContainer = styled.div` export const BookList = styled.div` display: flex; flex-direction: column; - gap: 12px; + gap: 0; `; export const BookItem = styled.div` @@ -167,9 +167,13 @@ export const BookItem = styled.div` align-items: center; padding: 12px 2px; background-color: none; - border-radius: 12px; cursor: pointer; transition: background-color 0.2s ease; + border-bottom: 1px solid ${colors.grey[400]}; + + &:last-child { + border-bottom: none; + } `; export const BookCover = styled.div` From f493da84e315527c6771769200734ebb53654bd9 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:12:41 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EB=B9=88=20=EC=83=81=ED=83=9C=20UI?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=B1=85=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=ED=95=98=EA=B8=B0=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookSearchBottomSheet.styled.ts | 26 ++++++++- .../BookSearchBottomSheet.tsx | 57 ++++++++++++++----- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts index ffe50b6c..68803dd3 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts @@ -104,7 +104,7 @@ export const IconButton = styled.button` export const TabContainer = styled.div` display: flex; gap: 34px; - margin-bottom: 24px; + margin-bottom: 16px; flex-shrink: 0; `; @@ -241,14 +241,34 @@ export const ErrorText = styled.p` // 빈 상태 스타일 export const EmptyContainer = styled.div` display: flex; + flex-direction: column; justify-content: center; align-items: center; height: 200px; + gap: 8px; `; export const EmptyText = styled.p` - color: ${colors.grey[300]}; - font-size: ${typography.fontSize.base}; + color: #e0e0e0;}; + font-size: ${typography.fontSize.sm}; + font-weight: ${typography.fontWeight.regular}; margin: 0; text-align: center; `; + +export const ApplyButton = styled.button` + background-color: ${colors.purple.main}; + color: ${colors.white}; + padding: 10px 12px; + font-size: ${typography.fontSize.base}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 24px; + border: none; + border-radius: 12px; + cursor: pointer; + margin-top: 16px; + + &:hover { + background-color: ${colors.purple.dark}; + } +`; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 09d59436..f496b0fa 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import closeIcon from '../../../assets/group/close.svg'; import whitesearchIcon from '../../../assets/group/search_white.svg'; import { getSavedBooks, type SavedBook } from '@/api/books/getSavedBooks'; @@ -25,6 +26,7 @@ import { ErrorText, EmptyContainer, EmptyText, + ApplyButton, } from './BookSearchBottomSheet.styled.ts'; // Types @@ -40,11 +42,19 @@ interface BookSearchBottomSheetProps { isOpen: boolean; onClose: () => void; onSelectBook: (book: Book) => void; + forceEmpty?: boolean; // 임시방편: 빈 상태 강제 표시 } type TabType = 'saved' | 'group'; -const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { +const BookSearchBottomSheet = ({ + isOpen, + onClose, + onSelectBook, + forceEmpty = true, +}: BookSearchBottomSheetProps) => { + const navigate = useNavigate(); + // State const [searchQuery, setSearchQuery] = useState(''); const [filteredBooks, setFilteredBooks] = useState([]); @@ -107,17 +117,22 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { - if (isOpen) { + if (isOpen && !forceEmpty) { if (activeTab === 'saved' && savedBooks.length === 0) { fetchSavedBooks(); } else if (activeTab === 'group' && groupBooks.length === 0) { fetchGroupBooks(); } } - }, [isOpen, activeTab, savedBooks.length, groupBooks.length]); + }, [isOpen, activeTab, savedBooks.length, groupBooks.length, forceEmpty]); // 필터링 로직 useEffect(() => { + if (forceEmpty) { + setFilteredBooks([]); + return; + } + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; const convertedBooks = currentTabBooks.map(convertSavedBookToBook); @@ -131,7 +146,7 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott ); setFilteredBooks(filtered); } - }, [searchQuery, activeTab, savedBooks, groupBooks]); + }, [searchQuery, activeTab, savedBooks, groupBooks, forceEmpty]); useEffect(() => { if (isOpen) { @@ -181,19 +196,27 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott setError(null); // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 - if (tab === 'saved' && savedBooks.length === 0) { - await fetchSavedBooks(); - } else if (tab === 'group' && groupBooks.length === 0) { - await fetchGroupBooks(); + if (!forceEmpty) { + if (tab === 'saved' && savedBooks.length === 0) { + await fetchSavedBooks(); + } else if (tab === 'group' && groupBooks.length === 0) { + await fetchGroupBooks(); + } } }; - // 검색어가 없을 때만 탭 표시 - const showTabs = searchQuery.trim() === ''; + const handleApplyBook = () => { + navigate('/apply-book'); + onClose(); + }; - // 현재 탭의 책 개수 확인 + // 현재 탭의 책 개수 확인 (임시방편: forceEmpty가 true면 강제로 빈 상태) const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; - const hasBooks = currentTabBooks.length > 0; + const hasBooks = !forceEmpty && currentTabBooks.length > 0; + const showEmptyState = forceEmpty || (!isLoading && !error && !hasBooks); + + // 검색어가 없을 때만 탭 표시 (단, 빈 상태일 때는 탭 숨김) + const showTabs = searchQuery.trim() === '' && !showEmptyState; return ( @@ -219,7 +242,7 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott - {/* 탭 영역 - 검색어가 없을 때만 표시 */} + {/* 탭 영역 - 검색어가 없고 빈 상태가 아닐 때만 표시 */} {showTabs && ( handleTabChange('saved')}> @@ -233,7 +256,13 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott {/* 책 목록 영역 */} - {isLoading ? ( + {showEmptyState ? ( + + 현재 등록된 책이 아닙니다. + 원하시는 책을 신청해주세요. + 책 신청하기 + + ) : isLoading ? ( 책 목록을 불러오는 중... From d295af05ff083bd57d70d9a03829253edbb9a681 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:15:48 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=EC=9E=84=EC=8B=9C=EB=B0=A9?= =?UTF-8?q?=ED=8E=B8=20forceEmpty=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookSearchBottomSheet.tsx | 53 ++++++------------- 1 file changed, 16 insertions(+), 37 deletions(-) diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index f496b0fa..a25f9740 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -42,17 +42,11 @@ interface BookSearchBottomSheetProps { isOpen: boolean; onClose: () => void; onSelectBook: (book: Book) => void; - forceEmpty?: boolean; // 임시방편: 빈 상태 강제 표시 } type TabType = 'saved' | 'group'; -const BookSearchBottomSheet = ({ - isOpen, - onClose, - onSelectBook, - forceEmpty = true, -}: BookSearchBottomSheetProps) => { +const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { const navigate = useNavigate(); // State @@ -117,22 +111,17 @@ const BookSearchBottomSheet = ({ // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { - if (isOpen && !forceEmpty) { + if (isOpen) { if (activeTab === 'saved' && savedBooks.length === 0) { fetchSavedBooks(); } else if (activeTab === 'group' && groupBooks.length === 0) { fetchGroupBooks(); } } - }, [isOpen, activeTab, savedBooks.length, groupBooks.length, forceEmpty]); + }, [isOpen, activeTab, savedBooks.length, groupBooks.length]); // 필터링 로직 useEffect(() => { - if (forceEmpty) { - setFilteredBooks([]); - return; - } - const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; const convertedBooks = currentTabBooks.map(convertSavedBookToBook); @@ -146,7 +135,7 @@ const BookSearchBottomSheet = ({ ); setFilteredBooks(filtered); } - }, [searchQuery, activeTab, savedBooks, groupBooks, forceEmpty]); + }, [searchQuery, activeTab, savedBooks, groupBooks]); useEffect(() => { if (isOpen) { @@ -196,12 +185,10 @@ const BookSearchBottomSheet = ({ setError(null); // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 - if (!forceEmpty) { - if (tab === 'saved' && savedBooks.length === 0) { - await fetchSavedBooks(); - } else if (tab === 'group' && groupBooks.length === 0) { - await fetchGroupBooks(); - } + if (tab === 'saved' && savedBooks.length === 0) { + await fetchSavedBooks(); + } else if (tab === 'group' && groupBooks.length === 0) { + await fetchGroupBooks(); } }; @@ -210,10 +197,10 @@ const BookSearchBottomSheet = ({ onClose(); }; - // 현재 탭의 책 개수 확인 (임시방편: forceEmpty가 true면 강제로 빈 상태) + // 현재 탭의 책 개수 확인 const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; - const hasBooks = !forceEmpty && currentTabBooks.length > 0; - const showEmptyState = forceEmpty || (!isLoading && !error && !hasBooks); + const hasBooks = currentTabBooks.length > 0; + const showEmptyState = !isLoading && !error && !hasBooks; // 검색어가 없을 때만 탭 표시 (단, 빈 상태일 때는 탭 숨김) const showTabs = searchQuery.trim() === '' && !showEmptyState; @@ -256,13 +243,7 @@ const BookSearchBottomSheet = ({ {/* 책 목록 영역 */} - {showEmptyState ? ( - - 현재 등록된 책이 아닙니다. - 원하시는 책을 신청해주세요. - 책 신청하기 - - ) : isLoading ? ( + {isLoading ? ( 책 목록을 불러오는 중... @@ -270,13 +251,11 @@ const BookSearchBottomSheet = ({ {error} - ) : !hasBooks ? ( + ) : showEmptyState ? ( - - {activeTab === 'saved' - ? '저장한 책이 없습니다.' - : '참여 중인 모임의 책이 없습니다.'} - + 현재 등록된 책이 아닙니다. + 원하시는 책을 신청해주세요. + 책 신청하기 ) : ( From 35f22885b7656041229db6cf2f907b98fa1389a0 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:19:29 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20=EC=B1=85=20=EC=8B=A0=EC=B2=AD?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/BookSearchBottomSheet/BookSearchBottomSheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index a25f9740..683f381e 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -193,7 +193,7 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott }; const handleApplyBook = () => { - navigate('/apply-book'); + navigate('/search/applybook'); onClose(); }; From 05c58a0303b1c49c92b1c489f92cca78e27dbd89 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Sat, 16 Aug 2025 01:30:38 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20BookSearchBottomSheet=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/BookSearchBottomSheet/BookList.tsx | 43 +++ .../BookSearchBottomSheet.tsx | 254 +++--------------- .../BookSearchHeader.tsx | 52 ++++ .../BookSearchStates.tsx | 57 ++++ .../BookSearchBottomSheet/BookSearchTabs.tsx | 23 ++ .../BookSearchBottomSheet/useBookSearch.ts | 127 +++++++++ 6 files changed, 343 insertions(+), 213 deletions(-) create mode 100644 src/components/common/BookSearchBottomSheet/BookList.tsx create mode 100644 src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx create mode 100644 src/components/common/BookSearchBottomSheet/BookSearchStates.tsx create mode 100644 src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx create mode 100644 src/components/common/BookSearchBottomSheet/useBookSearch.ts diff --git a/src/components/common/BookSearchBottomSheet/BookList.tsx b/src/components/common/BookSearchBottomSheet/BookList.tsx new file mode 100644 index 00000000..416211c7 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookList.tsx @@ -0,0 +1,43 @@ +import { + BookList as StyledBookList, + BookItem, + BookCover, + BookInfo, + BookTitle, +} from './BookSearchBottomSheet.styled'; + +export interface Book { + id: number; + title: string; + author: string; + cover: string; + isbn: string; +} + +interface BookListProps { + books: Book[]; + onBookSelect: (book: Book) => void; +} + +const BookList = ({ books, onBookSelect }: BookListProps) => { + const handleImageError = (e: React.SyntheticEvent) => { + e.currentTarget.style.display = 'none'; + }; + + return ( + + {books.map(book => ( + onBookSelect(book)}> + + {book.title} + + + {book.title} + + + ))} + + ); +}; + +export default BookList; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx index 683f381e..8216743d 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -1,42 +1,15 @@ -import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import closeIcon from '../../../assets/group/close.svg'; -import whitesearchIcon from '../../../assets/group/search_white.svg'; -import { getSavedBooks, type SavedBook } from '@/api/books/getSavedBooks'; +import { useEffect } from 'react'; import { Overlay, BottomSheetContainer, Content, - SearchContainer, - SearchInputWrapper, - SearchInput, - ButtonGroup, - IconButton, - TabContainer, - Tab, BookListContainer, - BookList, - BookItem, - BookCover, - BookInfo, - BookTitle, - LoadingContainer, - LoadingText, - ErrorContainer, - ErrorText, - EmptyContainer, - EmptyText, - ApplyButton, -} from './BookSearchBottomSheet.styled.ts'; - -// Types -interface Book { - id: number; - title: string; - author: string; - cover: string; - isbn: string; -} +} from './BookSearchBottomSheet.styled'; +import BookSearchHeader from './BookSearchHeader'; +import BookSearchTabs from './BookSearchTabs'; +import BookList, { type Book } from './BookList'; +import BookSearchStates from './BookSearchStates'; +import { useBookSearch } from './useBookSearch'; interface BookSearchBottomSheetProps { isOpen: boolean; @@ -44,99 +17,28 @@ interface BookSearchBottomSheetProps { onSelectBook: (book: Book) => void; } -type TabType = 'saved' | 'group'; - const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => { - const navigate = useNavigate(); - - // State - const [searchQuery, setSearchQuery] = useState(''); - const [filteredBooks, setFilteredBooks] = useState([]); - const [activeTab, setActiveTab] = useState('saved'); - const [savedBooks, setSavedBooks] = useState([]); - const [groupBooks, setGroupBooks] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // API에서 받은 데이터를 Book 타입으로 변환하는 함수 - const convertSavedBookToBook = (savedBook: SavedBook): Book => ({ - id: savedBook.bookId, - title: savedBook.bookTitle, - author: savedBook.authorName, - cover: savedBook.bookImageUrl, - isbn: savedBook.isbn, - }); - - // 저장한 책 데이터 가져오기 - const fetchSavedBooks = async () => { - try { - setIsLoading(true); - setError(null); - - const response = await getSavedBooks('saved'); - - if (response.isSuccess && response.data) { - setSavedBooks(response.data.bookList); - } else { - setError(response.message || '저장한 책을 불러오는데 실패했습니다.'); - } - } catch (err) { - console.error('저장한 책 조회 오류:', err); - setError('저장한 책을 불러오는데 실패했습니다.'); - } finally { - setIsLoading(false); - } - }; - - // 모임 책 데이터 가져오기 - const fetchGroupBooks = async () => { - try { - setIsLoading(true); - setError(null); - - const response = await getSavedBooks('joining'); - - if (response.isSuccess && response.data) { - setGroupBooks(response.data.bookList); - } else { - setError(response.message || '모임 책을 불러오는데 실패했습니다.'); - } - } catch (err) { - console.error('모임 책 조회 오류:', err); - setError('모임 책을 불러오는데 실패했습니다.'); - } finally { - setIsLoading(false); - } - }; + const { + searchQuery, + filteredBooks, + activeTab, + isLoading, + error, + showEmptyState, + showTabs, + setSearchQuery, + handleTabChange, + loadInitialData, + } = useBookSearch(); // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { if (isOpen) { - if (activeTab === 'saved' && savedBooks.length === 0) { - fetchSavedBooks(); - } else if (activeTab === 'group' && groupBooks.length === 0) { - fetchGroupBooks(); - } - } - }, [isOpen, activeTab, savedBooks.length, groupBooks.length]); - - // 필터링 로직 - useEffect(() => { - const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; - const convertedBooks = currentTabBooks.map(convertSavedBookToBook); - - if (searchQuery.trim() === '') { - setFilteredBooks(convertedBooks); - } else { - const filtered = convertedBooks.filter( - book => - book.title.toLowerCase().includes(searchQuery.toLowerCase()) || - book.author.toLowerCase().includes(searchQuery.toLowerCase()), - ); - setFilteredBooks(filtered); + loadInitialData(); } - }, [searchQuery, activeTab, savedBooks, groupBooks]); + }, [isOpen]); + // 바디 스크롤 제어 useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; @@ -162,115 +64,41 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott }; const handleSearch = () => { - // 실제 검색 API 호출 로직 console.log('검색:', searchQuery); }; - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch(); - } - }; - - const handleImageError = (e: React.SyntheticEvent) => { - e.currentTarget.style.display = 'none'; - }; - const handleClearSearch = () => { setSearchQuery(''); }; - const handleTabChange = async (tab: TabType) => { - setActiveTab(tab); - setError(null); - - // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 - if (tab === 'saved' && savedBooks.length === 0) { - await fetchSavedBooks(); - } else if (tab === 'group' && groupBooks.length === 0) { - await fetchGroupBooks(); - } - }; - - const handleApplyBook = () => { - navigate('/search/applybook'); - onClose(); - }; - - // 현재 탭의 책 개수 확인 - const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; - const hasBooks = currentTabBooks.length > 0; - const showEmptyState = !isLoading && !error && !hasBooks; - - // 검색어가 없을 때만 탭 표시 (단, 빈 상태일 때는 탭 숨김) - const showTabs = searchQuery.trim() === '' && !showEmptyState; + const showBookList = !isLoading && !error && !showEmptyState; return ( - {/* 검색 영역 */} - - - setSearchQuery(e.target.value)} - onKeyPress={handleKeyPress} - /> - - - - 닫기 - - - 검색 - - - + {/* 검색 헤더 */} + - {/* 탭 영역 - 검색어가 없고 빈 상태가 아닐 때만 표시 */} - {showTabs && ( - - handleTabChange('saved')}> - 저장한 책 - - handleTabChange('group')}> - 모임 책 - - - )} + {/* 탭 영역 */} + {showTabs && } {/* 책 목록 영역 */} - {isLoading ? ( - - 책 목록을 불러오는 중... - - ) : error ? ( - - {error} - - ) : showEmptyState ? ( - - 현재 등록된 책이 아닙니다. - 원하시는 책을 신청해주세요. - 책 신청하기 - - ) : ( - - {filteredBooks.map(book => ( - handleBookSelect(book)}> - - {book.title} - - - {book.title} - - - ))} - - )} + + + {showBookList && } diff --git a/src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx b/src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx new file mode 100644 index 00000000..8a602165 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchHeader.tsx @@ -0,0 +1,52 @@ +import closeIcon from '../../../assets/group/close.svg'; +import whitesearchIcon from '../../../assets/group/search_white.svg'; +import { + SearchContainer, + SearchInputWrapper, + SearchInput, + ButtonGroup, + IconButton, +} from './BookSearchBottomSheet.styled'; + +interface BookSearchHeaderProps { + searchQuery: string; + onSearchChange: (value: string) => void; + onSearch: () => void; + onClear: () => void; +} + +const BookSearchHeader = ({ + searchQuery, + onSearchChange, + onSearch, + onClear, +}: BookSearchHeaderProps) => { + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onSearch(); + } + }; + + return ( + + + onSearchChange(e.target.value)} + onKeyPress={handleKeyPress} + /> + + + + 닫기 + + + 검색 + + + + ); +}; + +export default BookSearchHeader; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchStates.tsx b/src/components/common/BookSearchBottomSheet/BookSearchStates.tsx new file mode 100644 index 00000000..f67b2729 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchStates.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from 'react-router-dom'; +import { + LoadingContainer, + LoadingText, + ErrorContainer, + ErrorText, + EmptyContainer, + EmptyText, + ApplyButton, +} from './BookSearchBottomSheet.styled'; + +interface BookSearchStatesProps { + isLoading: boolean; + error: string | null; + isEmpty: boolean; + activeTab: 'saved' | 'group'; + onClose: () => void; +} + +const BookSearchStates = ({ isLoading, error, isEmpty, onClose }: BookSearchStatesProps) => { + const navigate = useNavigate(); + + const handleApplyBook = () => { + navigate('/search/applybook'); + onClose(); + }; + + if (isLoading) { + return ( + + 책 목록을 불러오는 중... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (isEmpty) { + return ( + + 현재 등록된 책이 아닙니다. + 원하시는 책을 신청해주세요. + 책 신청하기 + + ); + } + + return null; +}; + +export default BookSearchStates; diff --git a/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx b/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx new file mode 100644 index 00000000..ef5e8660 --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/BookSearchTabs.tsx @@ -0,0 +1,23 @@ +import { TabContainer, Tab } from './BookSearchBottomSheet.styled'; + +export type TabType = 'saved' | 'group'; + +interface BookSearchTabsProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +const BookSearchTabs = ({ activeTab, onTabChange }: BookSearchTabsProps) => { + return ( + + onTabChange('saved')}> + 저장한 책 + + onTabChange('group')}> + 모임 책 + + + ); +}; + +export default BookSearchTabs; diff --git a/src/components/common/BookSearchBottomSheet/useBookSearch.ts b/src/components/common/BookSearchBottomSheet/useBookSearch.ts new file mode 100644 index 00000000..fe47675a --- /dev/null +++ b/src/components/common/BookSearchBottomSheet/useBookSearch.ts @@ -0,0 +1,127 @@ +import { useState, useEffect } from 'react'; +import { getSavedBooks, type SavedBook } from '@/api/books/getSavedBooks'; +import type { Book } from './BookList'; +import type { TabType } from './BookSearchTabs'; + +export const useBookSearch = () => { + const [searchQuery, setSearchQuery] = useState(''); + const [filteredBooks, setFilteredBooks] = useState([]); + const [activeTab, setActiveTab] = useState('saved'); + const [savedBooks, setSavedBooks] = useState([]); + const [groupBooks, setGroupBooks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // API에서 받은 데이터를 Book 타입으로 변환하는 함수 + const convertSavedBookToBook = (savedBook: SavedBook): Book => ({ + id: savedBook.bookId, + title: savedBook.bookTitle, + author: savedBook.authorName, + cover: savedBook.bookImageUrl, + isbn: savedBook.isbn, + }); + + // 저장한 책 데이터 가져오기 + const fetchSavedBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('saved'); + + if (response.isSuccess && response.data) { + setSavedBooks(response.data.bookList); + } else { + setError(response.message || '저장한 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('저장한 책 조회 오류:', err); + setError('저장한 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // 모임 책 데이터 가져오기 + const fetchGroupBooks = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await getSavedBooks('joining'); + + if (response.isSuccess && response.data) { + setGroupBooks(response.data.bookList); + } else { + setError(response.message || '모임 책을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('모임 책 조회 오류:', err); + setError('모임 책을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + // 필터링 로직 + useEffect(() => { + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const convertedBooks = currentTabBooks.map(convertSavedBookToBook); + + if (searchQuery.trim() === '') { + setFilteredBooks(convertedBooks); + } else { + const filtered = convertedBooks.filter( + book => + book.title.toLowerCase().includes(searchQuery.toLowerCase()) || + book.author.toLowerCase().includes(searchQuery.toLowerCase()), + ); + setFilteredBooks(filtered); + } + }, [searchQuery, activeTab, savedBooks, groupBooks]); + + // 탭 변경 핸들러 + const handleTabChange = async (tab: TabType) => { + setActiveTab(tab); + setError(null); + + // 탭 변경 시 해당 탭의 데이터가 없으면 API 호출 + if (tab === 'saved' && savedBooks.length === 0) { + await fetchSavedBooks(); + } else if (tab === 'group' && groupBooks.length === 0) { + await fetchGroupBooks(); + } + }; + + // 초기 데이터 로드 + const loadInitialData = () => { + if (activeTab === 'saved' && savedBooks.length === 0) { + fetchSavedBooks(); + } else if (activeTab === 'group' && groupBooks.length === 0) { + fetchGroupBooks(); + } + }; + + // 현재 상태 계산 + const currentTabBooks = activeTab === 'saved' ? savedBooks : groupBooks; + const hasBooks = currentTabBooks.length > 0; + const showEmptyState = !isLoading && !error && !hasBooks; + const showTabs = searchQuery.trim() === '' && !showEmptyState; + + return { + // State + searchQuery, + filteredBooks, + activeTab, + isLoading, + error, + hasBooks, + showEmptyState, + showTabs, + + // Actions + setSearchQuery, + handleTabChange, + loadInitialData, + }; +};