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/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.styled.ts b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts index a75a27f4..68803dd3 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts @@ -104,63 +104,56 @@ export const IconButton = styled.button` export const TabContainer = styled.div` display: flex; gap: 34px; - margin-bottom: 24px; + margin-bottom: 16px; flex-shrink: 0; `; 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` @@ -172,10 +165,15 @@ export const BookList = styled.div` 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; 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` @@ -183,7 +181,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 +193,82 @@ export const BookCover = styled.div` export const BookInfo = styled.div` flex: 1; + min-width: 0; +`; + +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; flex-direction: column; - gap: 4px; + justify-content: center; + align-items: center; + height: 200px; + gap: 8px; `; -export const BookTitle = styled.div` - color: ${semanticColors.text.primary}; +export const EmptyText = styled.p` + color: #e0e0e0;}; font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.regular}; - line-height: 1.4; + 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 88843320..8216743d 100644 --- a/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx +++ b/src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.tsx @@ -1,33 +1,15 @@ -import { useState, useEffect } from 'react'; -import closeIcon from '../../../assets/group/close.svg'; -import whitesearchIcon from '../../../assets/group/search_white.svg'; +import { useEffect } from 'react'; import { Overlay, BottomSheetContainer, Content, - SearchContainer, - SearchInputWrapper, - SearchInput, - ButtonGroup, - IconButton, - TabContainer, - Tab, BookListContainer, - BookList, - BookItem, - BookCover, - BookInfo, - BookTitle, -} 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; @@ -35,81 +17,28 @@ interface BookSearchBottomSheetProps { onSelectBook: (book: Book) => void; } -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 [activeTab, setActiveTab] = useState('saved'); - - // Effects + const { + searchQuery, + filteredBooks, + activeTab, + isLoading, + error, + showEmptyState, + showTabs, + setSearchQuery, + handleTabChange, + loadInitialData, + } = useBookSearch(); + + // 컴포넌트가 열릴 때 초기 데이터 로드 useEffect(() => { - // 현재 활성화된 탭의 책 목록 가져오기 - const currentTabBooks = activeTab === 'saved' ? mockSavedBooks : mockGroupBooks; - - if (searchQuery.trim() === '') { - // 검색어가 없을 때는 선택된 탭의 전체 목록 표시 - setFilteredBooks(currentTabBooks); - } else { - // 검색어가 있을 때는 선택된 탭 내에서만 검색 - const filtered = currentTabBooks.filter( - book => - book.title.toLowerCase().includes(searchQuery.toLowerCase()) || - book.author.toLowerCase().includes(searchQuery.toLowerCase()), - ); - setFilteredBooks(filtered); + if (isOpen) { + loadInitialData(); } - }, [searchQuery, activeTab]); + }, [isOpen]); + // 바디 스크롤 제어 useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; @@ -135,94 +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 = (tab: TabType) => { - setActiveTab(tab); - // 탭 변경 시 현재 검색어로 새로운 탭에서 다시 검색 - const newTabBooks = tab === 'saved' ? mockSavedBooks : mockGroupBooks; - - 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); - } - }; - - // 검색어가 없을 때만 탭 표시 - const showTabs = searchQuery.trim() === ''; + const showBookList = !isLoading && !error && !showEmptyState; return ( - {/* 검색 영역 */} - - - setSearchQuery(e.target.value)} - onKeyPress={handleKeyPress} - /> - - - - 닫기 - - - 검색 - - - + {/* 검색 헤더 */} + - {/* 탭 영역 - 검색어가 없을 때만 표시 */} - {showTabs && ( - - handleTabChange('saved')}> - 저장한 책 - - handleTabChange('group')}> - 모임 책 - - - )} + {/* 탭 영역 */} + {showTabs && } {/* 책 목록 영역 */} - - {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, + }; +};