From fde2ba1f9c4b6308d7956fa8d2edbccebe12ad9f Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:36:07 +0900 Subject: [PATCH 01/11] feat: searchBooks api --- src/api/books/searchBooks.ts | 68 ++++++++++++++++++++++++++++++++++++ src/api/index.ts | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/api/books/searchBooks.ts diff --git a/src/api/books/searchBooks.ts b/src/api/books/searchBooks.ts new file mode 100644 index 00000000..0c14f92d --- /dev/null +++ b/src/api/books/searchBooks.ts @@ -0,0 +1,68 @@ +import { apiClient } from '../index'; + +// 검색된 책 타입 (API 응답에서 받는 형태) +export interface BookSearchItem { + title: string; + imageUrl: string; + authorName: string; + publisher: string; + isbn: string; +} + +// 검색된 책 타입 (컴포넌트에서 사용하는 형태) +export interface SearchedBook { + id: number; + title: string; + author: string; + publisher: string; + coverUrl: string; + isbn: string; +} + +// API 응답 데이터 타입 +export interface SearchBooksData { + searchResult: BookSearchItem[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + last: boolean; + first: boolean; +} + +// API 응답 타입 +export interface SearchBooksResponse { + isSuccess: boolean; + code: number; + message: string; + data: SearchBooksData; +} + +export const searchBooks = async ( + query: string, + page: number = 1, +): Promise => { + try { + const response = await apiClient.get('/books', { + params: { + keyword: query.trim(), + page: page, + }, + }); + return response.data; + } catch (error) { + console.error('책 검색 API 오류:', error); + throw error; + } +}; + +export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => { + return apiBooks.map((book, index) => ({ + id: index + 1, + title: book.title, + author: book.authorName, + publisher: book.publisher, + coverUrl: book.imageUrl, + isbn: book.isbn, + })); +}; diff --git a/src/api/index.ts b/src/api/index.ts index a2bc9589..c6f61522 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,7 +2,7 @@ import axios, { type AxiosResponse, type AxiosError } from 'axios'; // 하드코딩된 액세스 토큰 const ACCESS_TOKEN = - 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc1NDIwMTY4OCwiZXhwIjoxNzU2NzkzNjg4fQ.oOyJ7JI_t2-Xq1-gfAv4ZaYNrbyplvqdxhCk76-Txe4'; + 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjgsImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.giRdeg9HWsdhLxg9JZhE0LaMg0hv7ReP0UBMsEUsNxs'; // 토큰 관리 유틸리티 export const TokenManager = { From bc6924471d0176088d7cf6f02a6f1aea71313c8b Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:36:59 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20searchBooks=20api=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/search/Search.tsx | 167 ++++++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 47 deletions(-) diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index b5f4007a..d6581b2c 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -6,8 +6,10 @@ import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import SearchBar from '@/components/search/SearchBar'; import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; +import { searchBooks, convertToSearchedBooks } from '@/api/books/searchBooks'; export interface SearchedBook { id: number; @@ -17,57 +19,107 @@ export interface SearchedBook { coverUrl: string; } -const dummySearchedBook: SearchedBook[] = [ - { - id: 1, - title: '채식주의자', - author: '한강', - publisher: '창비', - coverUrl: 'https://image.yes24.com/goods/17122707/XL', - }, - { - id: 2, - title: '채소 마스터 클래스', - author: '백지혜', - publisher: '세미콜론', - coverUrl: 'https://image.yes24.com/goods/109378551/XL', - }, - { - id: 3, - title: '채소 식탁', - author: '김경민', - publisher: '래디시', - coverUrl: 'https://image.yes24.com/goods/117194041/XL', - }, -]; const Search = () => { + const [searchParams, setSearchParams] = useSearchParams(); const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(false); const [isSearched, setIsSearched] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); - const [recentSearches, setRecentSearches] = useState([ - '딸기12', - '당근', - '수박245', - '참', - '메론1', - ]); + const [recentSearches, setRecentSearches] = useState([]); + const [searchTimeoutId, setSearchTimeoutId] = useState(null); const handleChange = (value: string) => { setSearchTerm(value); setIsSearched(false); setIsSearching(value.trim() !== ''); + + if (value.trim()) { + setSearchParams({ q: value.trim() }, { replace: true }); + + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + } + + const timeoutId = setTimeout(() => { + handleSearch(value.trim(), false); + }, 300); + + setSearchTimeoutId(timeoutId); + } else { + setSearchParams({}, { replace: true }); + + setSearchResults([]); + setIsSearched(false); + + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + } }; - const handleSearch = (term: string) => { + const handleSearch = useCallback(async (term: string, isManualSearch: boolean = false) => { if (!term.trim()) return; + setIsSearching(true); - setIsSearched(true); + + if (isManualSearch) { + setIsSearched(false); + } + + setIsLoading(true); + + try { + const response = await searchBooks(term, 1); + console.log('API 응답:', response); + + if (response.isSuccess) { + const convertedResults = convertToSearchedBooks(response.data.searchResult); + console.log('변환된 결과:', convertedResults); + setSearchResults(convertedResults); + } else { + console.log('검색 실패:', response.message); + setSearchResults([]); + } + + if (isManualSearch) { + setIsSearched(true); + } + } catch (error) { + console.error('검색 중 오류 발생:', error); + setSearchResults([]); + if (isManualSearch) { + setIsSearched(true); + } + } finally { + setIsLoading(false); + } + setRecentSearches(prev => { const filtered = prev.filter(t => t !== term); return [term, ...filtered].slice(0, 5); }); - }; + }, []); + + useEffect(() => { + const query = searchParams.get('q') || ''; + if (query && !isInitialized) { + setSearchTerm(query); + setIsSearching(true); + handleSearch(query, true); + setIsInitialized(true); + } + }, [searchParams, handleSearch, isInitialized]); + + useEffect(() => { + if (searchTerm.trim() === '') { + setIsSearching(false); + setIsSearched(false); + } + }, [searchTerm]); const handleDelete = (recentSearch: string) => { setRecentSearches(prev => prev.filter(t => t !== recentSearch)); @@ -75,14 +127,31 @@ const Search = () => { const handleRecentSearchClick = (recentSearch: string) => { setSearchTerm(recentSearch); - setIsSearched(true); - setIsSearching(true); + handleSearch(recentSearch, true); }; const handleBackButton = () => { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + setSearchTerm(''); + setSearchResults([]); + setIsSearching(false); + setIsSearched(false); + setIsInitialized(false); + setSearchParams({}, { replace: true }); }; + useEffect(() => { + return () => { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + } + }; + }, [searchTimeoutId]); + useEffect(() => { if (searchTerm.trim() === '') { setIsSearching(false); @@ -106,26 +175,21 @@ const Search = () => { placeholder="책 제목, 작가명을 검색해보세요." value={searchTerm} onChange={handleChange} - onSearch={() => handleSearch(searchTerm.trim())} + onSearch={() => handleSearch(searchTerm.trim(), true)} isSearched={isSearched} /> {isSearching ? ( <> - ( - {isSearched ? ( - + {isLoading ? ( + 검색 중... ) : ( + type={isSearched ? 'searched' : 'searching'} + searchedBookList={searchResults} + /> )} - ) ) : ( <> @@ -186,3 +250,12 @@ const SearchBarContainer = styled.div` const Content = styled.div` margin-top: 132px; `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; + color: ${colors.white}; + font-size: ${typography.fontSize.base}; +`; From 4ca579f146879992250bd41e112a12d6b00210de Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:04:12 +0900 Subject: [PATCH 03/11] feat: getBookDetail api --- src/api/books/getBookDetail.ts | 33 +++++++ src/components/search/BookSearchResult.tsx | 3 +- src/pages/index.tsx | 2 +- src/pages/search/Search.tsx | 1 + src/pages/searchBook/SearchBook.tsx | 104 ++++++++++++--------- 5 files changed, 96 insertions(+), 47 deletions(-) create mode 100644 src/api/books/getBookDetail.ts diff --git a/src/api/books/getBookDetail.ts b/src/api/books/getBookDetail.ts new file mode 100644 index 00000000..44b6d86b --- /dev/null +++ b/src/api/books/getBookDetail.ts @@ -0,0 +1,33 @@ +import { apiClient } from '../index'; + +// 책 상세 정보 타입 +export interface BookDetail { + title: string; + imageUrl: string; + authorName: string; + publisher: string; + isbn: string; + description: string; + recruitingRoomCount: number; + readCount: number; + isSaved: boolean; +} + +// API 응답 타입 +export interface BookDetailResponse { + isSuccess: boolean; + code: number; + message: string; + data: BookDetail; +} + +export const getBookDetail = async (isbn: string): Promise => { + try { + const response = await apiClient.get(`/books/${isbn}`); + + return response.data; + } catch (error) { + console.error('책 상세 정보 API 오류:', error); + throw error; + } +}; diff --git a/src/components/search/BookSearchResult.tsx b/src/components/search/BookSearchResult.tsx index 39b26624..a061e8ef 100644 --- a/src/components/search/BookSearchResult.tsx +++ b/src/components/search/BookSearchResult.tsx @@ -31,7 +31,7 @@ export function BookSearchResult({ type, searchedBookList }: BookSearchResultPro ) : ( searchedBookList.map(book => ( - + navigate(`/search/book/${book.isbn}`)}> {book.title} @@ -63,6 +63,7 @@ const BookItem = styled.div` display: flex; border-bottom: 1px solid ${colors.darkgrey.dark}; padding: 12px 0; + cursor: pointer; `; const ResultHeader = styled.div` diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 065a811c..85513ba0 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -59,7 +59,7 @@ const Router = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index d6581b2c..09e912f1 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -17,6 +17,7 @@ export interface SearchedBook { author: string; publisher: string; coverUrl: string; + isbn: string; } const Search = () => { diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 9d3b0085..7fd37d6a 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -15,34 +15,55 @@ import { WritePostButton, SaveButton, FeedSection, - FeedTitle, - FilterContainer, - EmptyState, - EmptyTitle, - EmptySubText, } from './SearchBook.styled'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; import moreIcon from '../../assets/common/more.svg'; import { IconButton } from '@/components/common/IconButton'; -import { mockSearchBook } from '@/mocks/searchBook.mock'; import saveIcon from '../../assets/common/SaveIcon.svg'; import rightChevron from '../../assets/common/right-Chevron.svg'; import plusIcon from '../../assets/common/plus.svg'; -import { Filter } from '@/components/common/Filter'; -import { useState } from 'react'; -import FeedPost from '@/components/feed/FeedPost'; +import { useState, useEffect } from 'react'; import { IntroModal } from '@/components/search/IntroModal'; - -const FILTER = ['최신순', '인기순']; +import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; const SearchBook = () => { - const { title, author, introduction, coverUrl, recruitGroups, posts } = mockSearchBook; - const [selectedFilter, setSelectedFilter] = useState('인기순'); + const { isbn } = useParams<{ isbn: string }>(); const [showIntroModal, setShowIntroModal] = useState(false); + const [bookDetail, setBookDetail] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const navigate = useNavigate(); + useEffect(() => { + const fetchBookDetail = async () => { + if (!isbn) { + setError('ISBN이 필요합니다.'); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + const response = await getBookDetail(isbn); + + if (response.isSuccess) { + setBookDetail(response.data); + } else { + setError(response.message); + } + } catch (error) { + console.error('책 상세 정보 조회 오류:', error); + setError('책 정보를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchBookDetail(); + }, [isbn]); + const handleBackButton = () => { navigate(-1); }; @@ -61,28 +82,40 @@ const SearchBook = () => { const handleSaveButton = () => {}; - const hasFeeds = mockSearchBook.posts.length > 0; + if (isLoading || error || !bookDetail) { + return ( + +
+ + +
+
+ {isLoading ? '로딩 중...' : error || '책 정보를 찾을 수 없습니다.'} +
+
+ ); + } return ( - +
- {title} - {author} + {bookDetail.title} + {bookDetail.authorName} 소개 - {introduction} + {bookDetail.description} - 모집중인 모임방 {recruitGroups.length}개{' '} + 모집중인 모임방 {bookDetail.recruitingRoomCount}개{' '} 오른쪽 화살표 아이콘 @@ -95,33 +128,14 @@ const SearchBook = () => { - - 피드 글 둘러보기 - - - - - {hasFeeds ? ( - <> - {posts.map(post => ( - - ))} - - ) : ( - - 이 책으로 작성된 피드가 없어요. - 첫 번째 피드를 작성해보세요! - - )} - - + 이 책과 관련된 피드 +
+ 관련 피드가 없습니다. +
+ {' '} {showIntroModal && ( - + )}
); From 3c120b45d60e741888883716aa8a9105f8d6b9f3 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:34:32 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20api=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD,=20isFinalized=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80=20searchBooks=20->=20getSearchBo?= =?UTF-8?q?oks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{searchBooks.ts => getSearchBooks.ts} | 4 ++- src/pages/search/Search.tsx | 31 +++++++--------- src/pages/searchBook/SearchBook.tsx | 36 ++++++++++++++++--- 3 files changed, 47 insertions(+), 24 deletions(-) rename src/api/books/{searchBooks.ts => getSearchBooks.ts} (93%) diff --git a/src/api/books/searchBooks.ts b/src/api/books/getSearchBooks.ts similarity index 93% rename from src/api/books/searchBooks.ts rename to src/api/books/getSearchBooks.ts index 0c14f92d..6f3add94 100644 --- a/src/api/books/searchBooks.ts +++ b/src/api/books/getSearchBooks.ts @@ -38,15 +38,17 @@ export interface SearchBooksResponse { data: SearchBooksData; } -export const searchBooks = async ( +export const getSearchBooks = async ( query: string, page: number = 1, + isFinalized: boolean = false, ): Promise => { try { const response = await apiClient.get('/books', { params: { keyword: query.trim(), page: page, + isFinalized: isFinalized, }, }); return response.data; diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index 09e912f1..ecb1f061 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -9,7 +9,7 @@ import styled from '@emotion/styled'; import { useEffect, useState, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; -import { searchBooks, convertToSearchedBooks } from '@/api/books/searchBooks'; +import { getSearchBooks, convertToSearchedBooks } from '@/api/books/getSearchBooks'; export interface SearchedBook { id: number; @@ -24,7 +24,7 @@ const Search = () => { const [searchParams, setSearchParams] = useSearchParams(); const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(false); - const [isSearched, setIsSearched] = useState(false); + const [isFinalized, setIsFinalized] = useState(false); const [searchResults, setSearchResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isInitialized, setIsInitialized] = useState(false); @@ -34,7 +34,7 @@ const Search = () => { const handleChange = (value: string) => { setSearchTerm(value); - setIsSearched(false); + setIsFinalized(false); setIsSearching(value.trim() !== ''); if (value.trim()) { @@ -53,7 +53,7 @@ const Search = () => { setSearchParams({}, { replace: true }); setSearchResults([]); - setIsSearched(false); + setIsFinalized(false); if (searchTimeoutId) { clearTimeout(searchTimeoutId); @@ -68,13 +68,13 @@ const Search = () => { setIsSearching(true); if (isManualSearch) { - setIsSearched(false); + setIsFinalized(false); } setIsLoading(true); try { - const response = await searchBooks(term, 1); + const response = await getSearchBooks(term, 1, isManualSearch); console.log('API 응답:', response); if (response.isSuccess) { @@ -87,13 +87,13 @@ const Search = () => { } if (isManualSearch) { - setIsSearched(true); + setIsFinalized(true); } } catch (error) { console.error('검색 중 오류 발생:', error); setSearchResults([]); if (isManualSearch) { - setIsSearched(true); + setIsFinalized(true); } } finally { setIsLoading(false); @@ -118,7 +118,7 @@ const Search = () => { useEffect(() => { if (searchTerm.trim() === '') { setIsSearching(false); - setIsSearched(false); + setIsFinalized(false); } }, [searchTerm]); @@ -140,7 +140,7 @@ const Search = () => { setSearchTerm(''); setSearchResults([]); setIsSearching(false); - setIsSearched(false); + setIsFinalized(false); setIsInitialized(false); setSearchParams({}, { replace: true }); }; @@ -153,13 +153,6 @@ const Search = () => { }; }, [searchTimeoutId]); - useEffect(() => { - if (searchTerm.trim() === '') { - setIsSearching(false); - setIsSearched(false); - } - }, [searchTerm]); - return ( {isSearching ? ( @@ -177,7 +170,7 @@ const Search = () => { value={searchTerm} onChange={handleChange} onSearch={() => handleSearch(searchTerm.trim(), true)} - isSearched={isSearched} + isSearched={isFinalized} /> @@ -187,7 +180,7 @@ const Search = () => { 검색 중... ) : ( )} diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 7fd37d6a..4999b91b 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -15,6 +15,11 @@ import { WritePostButton, SaveButton, FeedSection, + FeedTitle, + FilterContainer, + EmptyState, + EmptyTitle, + EmptySubText, } from './SearchBook.styled'; import { useNavigate, useParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; @@ -26,9 +31,15 @@ import plusIcon from '../../assets/common/plus.svg'; import { useState, useEffect } from 'react'; import { IntroModal } from '@/components/search/IntroModal'; import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; +import { Filter } from '@/components/common/Filter'; +import FeedPost from '@/components/feed/FeedPost'; +import { mockSearchBook } from '@/mocks/searchBook.mock'; + +const FILTER = ['최신순', '인기순']; const SearchBook = () => { const { isbn } = useParams<{ isbn: string }>(); + const [selectedFilter, setSelectedFilter] = useState('인기순'); const [showIntroModal, setShowIntroModal] = useState(false); const [bookDetail, setBookDetail] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -129,10 +140,27 @@ const SearchBook = () => { - 이 책과 관련된 피드 -
- 관련 피드가 없습니다. -
+ 피드 글 둘러보기 + + + + + {mockSearchBook.posts.length > 0 ? ( + <> + {mockSearchBook.posts.map((post, index) => ( + + ))} + + ) : ( + + 이 책으로 작성된 피드가 없어요. + 첫 번째 피드를 작성해보세요! + + )}
{' '} {showIntroModal && ( From ccfd27e6e0bad0c1e25387934313a67630582a85 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:20:49 +0900 Subject: [PATCH 05/11] feat: getRecruitingRooms API --- src/api/books/getRecruitingRooms.ts | 40 +++++++++++++++++++++ src/api/index.ts | 2 +- src/pages/searchBook/SearchBook.tsx | 45 +++++++++++++++++++----- src/pages/searchBook/SearchBookGroup.tsx | 38 ++++++++++++++++---- 4 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 src/api/books/getRecruitingRooms.ts diff --git a/src/api/books/getRecruitingRooms.ts b/src/api/books/getRecruitingRooms.ts new file mode 100644 index 00000000..1d09c9da --- /dev/null +++ b/src/api/books/getRecruitingRooms.ts @@ -0,0 +1,40 @@ +import { apiClient } from '../index'; + +// 모집중인 모임방 타입 +export interface RecruitingRoom { + roomId: number; + bookImageUrl: string; + roomName: string; + memberCount: number; + recruitCount: number; + deadlineEndDate: string; +} + +// API 응답 데이터 타입 +export interface RecruitingRoomsData { + recruitingRoomList: RecruitingRoom[]; + totalRoomCount: number; + nextCursor: string; + isLast: boolean; +} + +// API 응답 타입 +export interface RecruitingRoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: RecruitingRoomsData; +} + +export const getRecruitingRooms = async (isbn: string): Promise => { + try { + const response = await apiClient.get( + `/books/${isbn}/recruiting-rooms`, + ); + console.log(response.data); + return response.data; + } catch (error) { + console.error('모집중인 모임방 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/api/index.ts b/src/api/index.ts index c6f61522..55edf8a1 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,7 +2,7 @@ import axios, { type AxiosResponse, type AxiosError } from 'axios'; // 하드코딩된 액세스 토큰 const ACCESS_TOKEN = - 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjgsImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.giRdeg9HWsdhLxg9JZhE0LaMg0hv7ReP0UBMsEUsNxs'; + 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjgsImlhdCI6MTc1NTA2NjE4NywiZXhwIjoxNzU3NjU4MTg3fQ.2bWnH8Gi5thmzZaR0cjijoyEEr2hCgd351h3HtBqm14'; // 토큰 관리 유틸리티 export const TokenManager = { diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 4999b91b..06ebfc6c 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -31,6 +31,7 @@ import plusIcon from '../../assets/common/plus.svg'; import { useState, useEffect } from 'react'; import { IntroModal } from '@/components/search/IntroModal'; import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; +import { getRecruitingRooms, type RecruitingRoomsData } from '@/api/books/getRecruitingRooms'; import { Filter } from '@/components/common/Filter'; import FeedPost from '@/components/feed/FeedPost'; import { mockSearchBook } from '@/mocks/searchBook.mock'; @@ -42,6 +43,7 @@ const SearchBook = () => { const [selectedFilter, setSelectedFilter] = useState('인기순'); const [showIntroModal, setShowIntroModal] = useState(false); const [bookDetail, setBookDetail] = useState(null); + const [recruitingRoomsData, setRecruitingRoomsData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -57,16 +59,26 @@ const SearchBook = () => { try { setIsLoading(true); - const response = await getBookDetail(isbn); - if (response.isSuccess) { - setBookDetail(response.data); + const [bookResponse, recruitingResponse] = await Promise.all([ + getBookDetail(isbn), + getRecruitingRooms(isbn), + ]); + + if (bookResponse.isSuccess) { + setBookDetail(bookResponse.data); + } else { + setError(bookResponse.message); + } + + if (recruitingResponse.isSuccess) { + setRecruitingRoomsData(recruitingResponse.data); } else { - setError(response.message); + console.error('모집중인 모임방 조회 실패:', recruitingResponse.message); } } catch (error) { - console.error('책 상세 정보 조회 오류:', error); - setError('책 정보를 불러오는데 실패했습니다.'); + console.error('데이터 조회 오류:', error); + setError('정보를 불러오는데 실패했습니다.'); } finally { setIsLoading(false); } @@ -86,7 +98,24 @@ const SearchBook = () => { const handleMoreButton = () => {}; const handleRecruitingGroupButton = () => { - navigate('./group'); + if (bookDetail) { + navigate('/search/book/group', { + state: { + recruitingRooms: recruitingRoomsData || { + recruitingRoomList: [], + totalRoomCount: 0, + nextCursor: '', + isLast: true, + }, + bookInfo: { + isbn: bookDetail.isbn, + title: bookDetail.title, + author: bookDetail.authorName, + imageUrl: bookDetail.imageUrl, + }, + }, + }); + } }; const handleWritePostButton = () => {}; @@ -126,7 +155,7 @@ const SearchBook = () => { - 모집중인 모임방 {bookDetail.recruitingRoomCount}개{' '} + 모집중인 모임방 {recruitingRoomsData?.totalRoomCount || 0}개{' '} 오른쪽 화살표 아이콘 diff --git a/src/pages/searchBook/SearchBookGroup.tsx b/src/pages/searchBook/SearchBookGroup.tsx index 02fe4941..7db261db 100644 --- a/src/pages/searchBook/SearchBookGroup.tsx +++ b/src/pages/searchBook/SearchBookGroup.tsx @@ -2,19 +2,33 @@ import TitleHeader from '@/components/common/TitleHeader'; import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; import leftArrow from '../../assets/common/leftArrow.svg'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { GroupCard } from '@/components/group/GroupCard'; -import { mockSearchBookGroup } from '@/mocks/searchBook.mock'; +import { type RecruitingRoomsData } from '@/api/books/getRecruitingRooms'; + +interface LocationState { + recruitingRooms: RecruitingRoomsData; + bookInfo: { + isbn: string; + title: string; + author: string; + imageUrl: string; + }; +} const SearchBookGroup = () => { const navigate = useNavigate(); + const location = useLocation(); + const { recruitingRooms, bookInfo } = (location.state as LocationState) || {}; const handleBackButton = () => { navigate(-1); }; const handleMakeGroup = () => {}; - const hasGroups = mockSearchBookGroup.length > 0; + const groupList = recruitingRooms?.recruitingRoomList || []; + const totalCount = recruitingRooms?.totalRoomCount || 0; + const hasGroups = groupList.length > 0; return ( @@ -23,11 +37,23 @@ const SearchBookGroup = () => { leftIcon={뒤로 가기} onLeftClick={handleBackButton} /> - 전체 {mockSearchBookGroup.length} + 전체 {totalCount} {hasGroups ? ( - {mockSearchBookGroup.map(group => ( - + {groupList.map((room, index) => ( + ))} ) : ( From 24c3bde931de719fef7fc3a926aa663d25ddbae5 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:04:05 +0900 Subject: [PATCH 06/11] =?UTF-8?q?remove:=20=EC=BD=98=EC=86=94=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/books/getRecruitingRooms.ts | 2 +- src/pages/search/Search.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/api/books/getRecruitingRooms.ts b/src/api/books/getRecruitingRooms.ts index 1d09c9da..f83eb397 100644 --- a/src/api/books/getRecruitingRooms.ts +++ b/src/api/books/getRecruitingRooms.ts @@ -31,7 +31,7 @@ export const getRecruitingRooms = async (isbn: string): Promise( `/books/${isbn}/recruiting-rooms`, ); - console.log(response.data); + return response.data; } catch (error) { console.error('모집중인 모임방 조회 API 오류:', error); diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index ecb1f061..624c955f 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -75,11 +75,9 @@ const Search = () => { try { const response = await getSearchBooks(term, 1, isManualSearch); - console.log('API 응답:', response); if (response.isSuccess) { const convertedResults = convertToSearchedBooks(response.data.searchResult); - console.log('변환된 결과:', convertedResults); setSearchResults(convertedResults); } else { console.log('검색 실패:', response.message); @@ -209,7 +207,7 @@ const Wrapper = styled.div` flex-direction: column; min-width: 320px; max-width: 767px; - height: 100vh; + height: 100%; margin: 0 auto; background: ${colors.black.main}; `; From 4b2817e66ba5f5d913a6fc131fcb90475fe6b920 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:04:19 +0900 Subject: [PATCH 07/11] feat: getMostSearchedBooks API --- src/api/books/getMostSearchedBooks.ts | 32 ++++++ src/components/search/MostSearchedBooks.tsx | 113 ++++++++++++-------- 2 files changed, 100 insertions(+), 45 deletions(-) create mode 100644 src/api/books/getMostSearchedBooks.ts diff --git a/src/api/books/getMostSearchedBooks.ts b/src/api/books/getMostSearchedBooks.ts new file mode 100644 index 00000000..fbdbcf3e --- /dev/null +++ b/src/api/books/getMostSearchedBooks.ts @@ -0,0 +1,32 @@ +import { apiClient } from '../index'; + +// 인기 검색 도서 타입 +export interface MostSearchedBook { + rank: number; + title: string; + imageUrl: string; + isbn: string; +} + +// API 응답 데이터 타입 +export interface MostSearchedBooksData { + bookList: MostSearchedBook[]; +} + +// API 응답 타입 +export interface MostSearchedBooksResponse { + isSuccess: boolean; + code: number; + message: string; + data: MostSearchedBooksData; +} + +export const getMostSearchedBooks = async (): Promise => { + try { + const response = await apiClient.get('/books/most-searched'); + return response.data; + } catch (error) { + console.error('인기 검색 도서 조회 API 오류:', error); + throw error; + } +}; diff --git a/src/components/search/MostSearchedBooks.tsx b/src/components/search/MostSearchedBooks.tsx index a485b25c..cacab507 100644 --- a/src/components/search/MostSearchedBooks.tsx +++ b/src/components/search/MostSearchedBooks.tsx @@ -1,64 +1,71 @@ import { colors, typography } from '@/styles/global/global'; import styled from '@emotion/styled'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getMostSearchedBooks, type MostSearchedBook } from '@/api/books/getMostSearchedBooks'; -interface Book { - id: number; - title: string; - coverUrl: string; -} +export default function MostSearchedBooks() { + const [books, setBooks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const navigate = useNavigate(); -const dummyBooks: Book[] = [ - { - id: 1, - title: '토마토 컵라면', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 2, - title: '사슴', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 3, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 4, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 5, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, - { - id: 6, - title: '호르몬 체인지지', - coverUrl: 'https://cdn.imweb.me/upload/S20230204e049098f5e744/e6fd3d849546d.jpg', - }, -]; + useEffect(() => { + const fetchMostSearchedBooks = async () => { + try { + setIsLoading(true); + const response = await getMostSearchedBooks(); -export default function MostSearchedBooks() { + if (response.isSuccess) { + setBooks(response.data.bookList); + } else { + setError(response.message); + } + } catch (error) { + console.error('인기 검색 도서 조회 오류:', error); + setError('인기 검색 도서를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchMostSearchedBooks(); + }, []); + + const handleBookClick = (isbn: string) => { + navigate(`/search/book/${isbn}`); + }; + + const getCurrentDate = () => { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${month}.${day}. 기준`; + }; return (
가장 많이 검색된 책 - {/* 서버 응답 포맷을 모르기에 우선 하드 코딩 */} - 01.12. 기준 + {getCurrentDate()}
- {dummyBooks.length === 0 ? ( + {isLoading ? ( + 로딩 중... + ) : error ? ( + + 데이터를 불러올 수 없어요. + {error} + + ) : books.length === 0 ? ( 아직 순위가 집계되지 않았어요. 조금만 기다려주세요! ) : ( - {dummyBooks.map((book, index) => ( - - {index + 1}. - + {books.map(book => ( + handleBookClick(book.isbn)}> + {book.rank}. + {book.title} ))} @@ -106,6 +113,12 @@ const BookItem = styled.li` align-items: center; padding: 12px 0; border-bottom: 1px solid ${colors.darkgrey.dark}; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: ${colors.darkgrey.main}; + } `; const Rank = styled.span` @@ -151,3 +164,13 @@ const SubText = styled.div` color: ${colors.grey[100]}; font-weight: ${typography.fontWeight.regular}; `; + +const LoadingMessage = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; + font-size: ${typography.fontSize.base}; + color: ${colors.grey[200]}; + font-weight: ${typography.fontWeight.regular}; +`; From 88367093fe081f1e103eba24a49a7c4526131333 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:43:40 +0900 Subject: [PATCH 08/11] feat: saveIcon --- src/assets/common/SaveIcon.svg | 5 +++-- src/assets/common/filledSaveIcon.svg | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/assets/common/filledSaveIcon.svg diff --git a/src/assets/common/SaveIcon.svg b/src/assets/common/SaveIcon.svg index f0f5b383..34504748 100644 --- a/src/assets/common/SaveIcon.svg +++ b/src/assets/common/SaveIcon.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/src/assets/common/filledSaveIcon.svg b/src/assets/common/filledSaveIcon.svg new file mode 100644 index 00000000..9fffb2da --- /dev/null +++ b/src/assets/common/filledSaveIcon.svg @@ -0,0 +1,4 @@ + + + + From 860200cd4a4aae890174b505d2bcf2b3241861fe Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:44:03 +0900 Subject: [PATCH 09/11] =?UTF-8?q?design:=20cursor=20pointer=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/searchBook/SearchBook.styled.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/searchBook/SearchBook.styled.ts b/src/pages/searchBook/SearchBook.styled.ts index 7ada1f38..20f5c38c 100644 --- a/src/pages/searchBook/SearchBook.styled.ts +++ b/src/pages/searchBook/SearchBook.styled.ts @@ -126,6 +126,7 @@ export const RecruitingGroupButton = styled.button` justify-content: center; align-items: center; padding: 10px 12px; + cursor: pointer; `; export const RightArea = styled.div` @@ -150,17 +151,19 @@ export const WritePostButton = styled.button` gap: 8px; min-width: 200px; border: none; + cursor: pointer; `; export const SaveButton = styled.button` width: 48px; height: 48px; background: transparent; - border: 1px solid ${colors.grey[200]}; + border: none; border-radius: 12px; display: flex; align-items: center; justify-content: center; + cursor: pointer; `; export const FeedSection = styled.section` From 8dc269a0afa73826e2ebd78bb80c14d0bd83533d Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:45:07 +0900 Subject: [PATCH 10/11] feat: postSaveBook API --- src/api/books/postSaveBook.ts | 32 +++++++++++++++++++++++++++++ src/pages/searchBook/SearchBook.tsx | 28 ++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 src/api/books/postSaveBook.ts diff --git a/src/api/books/postSaveBook.ts b/src/api/books/postSaveBook.ts new file mode 100644 index 00000000..8822e4d3 --- /dev/null +++ b/src/api/books/postSaveBook.ts @@ -0,0 +1,32 @@ +import { apiClient } from '../index'; + +// 북마크 요청 타입 +export interface SaveBookRequest { + type: boolean; +} + +// 북마크 응답 데이터 타입 +export interface SaveBookData { + isbn: string; + isSaved: boolean; +} + +// 북마크 응답 타입 +export interface SaveBookResponse { + isSuccess: boolean; + code: number; + message: string; + data: SaveBookData; +} + +export const postSaveBook = async (isbn: string, type: boolean): Promise => { + try { + const response = await apiClient.post(`/books/${isbn}/saved`, { + type: type, + }); + return response.data; + } catch (error) { + console.error('책 저장 API 오류:', error); + throw error; + } +}; diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 06ebfc6c..3aeb03b7 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -26,12 +26,14 @@ import leftArrow from '../../assets/common/leftArrow.svg'; import moreIcon from '../../assets/common/more.svg'; import { IconButton } from '@/components/common/IconButton'; import saveIcon from '../../assets/common/SaveIcon.svg'; +import filledSaveIcon from '../../assets/common/filledSaveIcon.svg'; import rightChevron from '../../assets/common/right-Chevron.svg'; import plusIcon from '../../assets/common/plus.svg'; import { useState, useEffect } from 'react'; import { IntroModal } from '@/components/search/IntroModal'; import { getBookDetail, type BookDetail } from '@/api/books/getBookDetail'; import { getRecruitingRooms, type RecruitingRoomsData } from '@/api/books/getRecruitingRooms'; +import { postSaveBook } from '@/api/books/postSaveBook'; import { Filter } from '@/components/common/Filter'; import FeedPost from '@/components/feed/FeedPost'; import { mockSearchBook } from '@/mocks/searchBook.mock'; @@ -44,6 +46,8 @@ const SearchBook = () => { const [showIntroModal, setShowIntroModal] = useState(false); const [bookDetail, setBookDetail] = useState(null); const [recruitingRoomsData, setRecruitingRoomsData] = useState(null); + const [isSaved, setIsSaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -67,6 +71,7 @@ const SearchBook = () => { if (bookResponse.isSuccess) { setBookDetail(bookResponse.data); + setIsSaved(bookResponse.data.isSaved); } else { setError(bookResponse.message); } @@ -120,7 +125,24 @@ const SearchBook = () => { const handleWritePostButton = () => {}; - const handleSaveButton = () => {}; + const handleSaveButton = async () => { + if (!isbn || isSaving) return; + + try { + setIsSaving(true); + const response = await postSaveBook(isbn, !isSaved); + + if (response.isSuccess) { + setIsSaved(response.data.isSaved); + } else { + console.error('북마크 실패:', response.message); + } + } catch (error) { + console.error('북마크 중 오류 발생:', error); + } finally { + setIsSaving(false); + } + }; if (isLoading || error || !bookDetail) { return ( @@ -162,8 +184,8 @@ const SearchBook = () => { 피드에 글쓰기 더하기 아이콘 - - 저장 버튼 + + 저장 버튼
From 461789622de255b945906fbb396cb049271cdb64 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:08:04 +0900 Subject: [PATCH 11/11] design: minHeight add --- src/pages/search/Search.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/search/Search.tsx b/src/pages/search/Search.tsx index 624c955f..e71b882a 100644 --- a/src/pages/search/Search.tsx +++ b/src/pages/search/Search.tsx @@ -208,6 +208,7 @@ const Wrapper = styled.div` min-width: 320px; max-width: 767px; height: 100%; + min-height: 100vh; margin: 0 auto; background: ${colors.black.main}; `;