Skip to content

feat: books 도메인 API 연동#105

Merged
ho0010 merged 12 commits intodevelopfrom
feat/api-books
Aug 14, 2025
Merged

feat: books 도메인 API 연동#105
ho0010 merged 12 commits intodevelopfrom
feat/api-books

Conversation

@ho0010
Copy link
Collaborator

@ho0010 ho0010 commented Aug 13, 2025

#️⃣연관된 이슈

[API] Books API 연동 #86

📝작업 내용

연동한 API 목록

  • 책 저장 상태변경
  • 책으로 모집중인 모임방 조회
  • 책 상세 조회
  • 책 검색 결과
  • 가장 많이 검색된 책 조회

Summary by CodeRabbit

  • New Features
    • 도서 상세, 검색, 북마크, 모집중 그룹을 API와 연동하고 로딩/에러 상태를 표시합니다.
    • 가장 많이 검색한 도서를 실시간 데이터로 표시하고 항목 클릭 시 상세로 이동합니다.
    • 검색 페이지에 URL 질의 동기화, 디바운스 검색, 진행/완료 상태별 결과 표시를 추가했습니다.
    • ISBN 기반 동적 경로(/search/book/:isbn)로 상세 페이지 네비게이션을 지원합니다.
  • Style
    • 클릭 가능한 요소에 커서 표시, 저장 버튼 테두리 제거, 목록 호버 효과 및 로딩 메시지 스타일 추가.

@coderabbitai
Copy link

coderabbitai bot commented Aug 13, 2025

Walkthrough

도서 검색/상세 기능을 서버 연동으로 전환했다. 신규 Books API 클라이언트를 추가하고, 검색/랭킹/상세/그룹 페이지를 해당 API에 맞게 리팩터링했다. 라우트에 ISBN 파라미터를 도입했고, 저장(북마크) 토글 및 로딩/에러 상태 처리와 클릭 내비게이션을 추가했다.

Changes

Cohort / File(s) Summary
Books API Clients
src/api/books/getBookDetail.ts, src/api/books/getMostSearchedBooks.ts, src/api/books/getRecruitingRooms.ts, src/api/books/getSearchBooks.ts, src/api/books/postSaveBook.ts
신규 타입 정의와 API 래퍼 추가: 도서 상세, 최다 검색 도서, 모집중 방, 검색, 저장 POST. 공통적으로 apiClient 사용, 성공 시 response.data 반환, 오류 로깅 후 재throw.
Search Components
src/components/search/BookSearchResult.tsx, src/components/search/MostSearchedBooks.tsx
클릭 시 ISBN 기반 상세로 이동 추가. MostSearchedBooks는 API 연동으로 전환, 로딩/에러/빈 상태 처리 및 동적 목록 렌더링.
Routing
src/pages/index.tsx
검색 도서 상세 라우트 경로를 search/book/:isbn로 변경.
Search Page
src/pages/search/Search.tsx
디바운스된 서버 검색 도입, URL q 동기화, 초기 로드 지원. getSearchBooks/convertToSearchedBooks 사용, 로딩/초기화 상태 추가, 결과 타입에 isbn 포함.
SearchBook Page
src/pages/searchBook/SearchBook.tsx
ISBN 파라미터로 도서 상세/모집방 병렬 로드. 저장 토글(postSaveBook)과 저장 상태 관리. 소개 모달, 동적 UI 데이터, 그룹 페이지로 상태 전달 내비게이션.
SearchBook Group Page
src/pages/searchBook/SearchBookGroup.tsx
라우트 state로 전달된 모집방/도서 정보 사용. 목록/카운트 계산 및 GroupCard 매핑.
Styling
src/pages/searchBook/SearchBook.styled.ts
버튼들에 cursor: pointer 추가, SaveButton 보더 제거.

Sequence Diagram(s)

sequenceDiagram
  actor User
  participant SearchPage as Search Page
  participant API as Books API
  participant Result as BookSearchResult

  User->>SearchPage: 입력(q) 변경
  SearchPage-->>SearchPage: 디바운스 타이머
  SearchPage->>API: GET /books?keyword=q&page=1&isFinalized=false
  API-->>SearchPage: SearchBooksResponse
  SearchPage->>Result: props(type='searching'/'searched', results)
  User->>Result: 항목 클릭
  Result->>SearchPage: navigate(/search/book/:isbn)
Loading
sequenceDiagram
  actor User
  participant Detail as SearchBook Page
  participant API as Books API
  participant Group as SearchBookGroup Page

  User->>Detail: 접속(/search/book/:isbn)
  Detail->>API: GET /books/{isbn}
  Detail->>API: GET /books/{isbn}/recruiting-rooms
  API-->>Detail: BookDetailResponse
  API-->>Detail: RecruitingRoomsResponse
  User->>Detail: 저장 버튼 클릭
  Detail->>API: POST /books/{isbn}/saved { type: !isSaved }
  API-->>Detail: SaveBookResponse (isSaved)
  User->>Detail: 모집그룹 보기 클릭
  Detail->>Group: navigate(/search/book/group, state={recruitingRooms, bookInfo})
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • [API] Books API 연동 #86: 본 PR이 동일한 Books API 연동(getBookDetail, getSearchBooks, getRecruitingRooms, getMostSearchedBooks, postSaveBook)을 구현하므로 직접적으로 연관됨.

Possibly related PRs

Suggested reviewers

  • heeeeyong
  • ljh130334

Poem

토끼는 콩콩, 페이지를 건너
ISBN 따라 숲길을 걷네
바람은 로딩, 별은 응답 코드
살짝 북마크, 마음에 저장! ⭐
오늘의 랭킹, 내일의 모임
캬, API 향기 진하게 난다 🥕

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/api-books

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@vercel
Copy link

vercel bot commented Aug 13, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Project Deployment Preview Comments Updated (UTC)
thip Ready Preview Comment Aug 13, 2025 9:12am

@ho0010 ho0010 requested review from heeeeyong and ljh130334 August 13, 2025 09:12
@ho0010 ho0010 self-assigned this Aug 13, 2025
@ho0010 ho0010 added ✨ Feature 기능 개발 📬 API 서버 API 통신 labels Aug 13, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🔭 Outside diff range comments (2)
src/components/search/BookSearchResult.tsx (2)

33-43: 클릭 가능한 div → Link로 교체 및 key 안정화, ISBN URL 인코딩 적용

현재 div+onClick은 키보드 접근성/스크린리더 친화성이 떨어집니다. 의미상 링크이므로 Link 사용이 적절합니다. 또한 key를 id가 아닌 고유한 isbn으로 사용하는 게 안전합니다. URL에는 encodeURIComponent를 적용하세요.

-          searchedBookList.map(book => (
-            <BookItem key={book.id} onClick={() => navigate(`/search/book/${book.isbn}`)}>
+          searchedBookList.map(book => (
+            <BookItem key={book.isbn} to={`/search/book/${encodeURIComponent(book.isbn)}`}>
               <Cover src={book.coverUrl} alt={`${book.title} 커버`} />
               <BookInfo>
                 <Title>{book.title}</Title>
                 <Subtitle>
                   {book.author} 저 · {book.publisher}
                 </Subtitle>
               </BookInfo>
             </BookItem>
           ))

추가로, Link 사용을 위해 import가 필요합니다(아래 스타일 변경 코멘트에 포함).


62-67: BookItem을 Link로 스타일링하고 기본 링크 스타일 제거

Link 컴포넌트에 스타일을 입혀 시맨틱을 유지합니다.

-const BookItem = styled.div`
+const BookItem = styled(Link)`
   display: flex;
   border-bottom: 1px solid ${colors.darkgrey.dark};
   padding: 12px 0;
   cursor: pointer;
+  text-decoration: none;
+  color: inherit;
 `;

아래 보조 변경(파일 외 범위)도 함께 적용해 주세요:

// import 보완 (파일 상단)
import { Link, useNavigate } from 'react-router-dom';
🧹 Nitpick comments (11)
src/api/books/getMostSearchedBooks.ts (1)

11-14: 응답 메타데이터(페이지네이션) 필드 반영 고려

API가 전체 개수/커서/마지막 여부 등의 메타데이터를 제공한다면 인터페이스에 옵션으로 반영해 둬야 후속 기능(무한스크롤 등) 구현이 수월합니다.

 export interface MostSearchedBooksData {
   bookList: MostSearchedBook[];
+  totalCount?: number;
+  nextCursor?: string | null;
+  isLast?: boolean;
 }
src/pages/searchBook/SearchBookGroup.tsx (1)

29-31: null 병합 연산자로 의도 명확화

0과 빈 배열은 유효한 값입니다. OR(||) 대신 nullish 병합(??)을 사용하면 의도가 더 명확해지고 잠재적 오해를 줄일 수 있습니다.

적용 diff:

-  const groupList = recruitingRooms?.recruitingRoomList || [];
-  const totalCount = recruitingRooms?.totalRoomCount || 0;
+  const groupList = recruitingRooms?.recruitingRoomList ?? [];
+  const totalCount = recruitingRooms?.totalRoomCount ?? 0;
src/api/books/postSaveBook.ts (1)

3-6: 불명확한 필드명(type) 검토 권장

요청 바디의 type: boolean은 의미가 모호합니다(예: 저장/해제인지, 토글인지). 백엔드 계약이 허용한다면 action: 'save' | 'unsave' 또는 isSaved: boolean처럼 의미가 명확한 필드명으로 교체하는 것을 권장합니다.

src/api/books/getRecruitingRooms.ts (1)

29-33: ISBN 경로 파라미터 인코딩

ISBN에 특수문자가 포함될 가능성에 대비해 인코딩하는 것이 안전합니다.

적용 diff:

-    const response = await apiClient.get<RecruitingRoomsResponse>(
-      `/books/${isbn}/recruiting-rooms`,
-    );
+    const response = await apiClient.get<RecruitingRoomsResponse>(
+      `/books/${encodeURIComponent(isbn)}/recruiting-rooms`,
+    );
src/api/books/getSearchBooks.ts (1)

41-59: 빈 검색어 처리 가드 추가 제안

query.trim() 결과가 빈 문자열이면 서버 호출을 생략하고 빈 결과를 반환/처리하도록 상위 레이어와 계약을 맞추는 것이 UX와 트래픽 측면에서 유리합니다. 구현 방식은 팀 합의에 따라 결정하세요(예: 호출 생략, 에러 throw, 클라이언트 캐시 반환 등).

src/components/search/MostSearchedBooks.tsx (2)

13-34: 언마운트 시 setState 호출 방지

비동기 응답 도중 언마운트되면 경고가 발생할 수 있습니다. 마운트 플래그로 방지하세요.

적용 diff:

   useEffect(() => {
-    const fetchMostSearchedBooks = async () => {
+    let mounted = true;
+    const fetchMostSearchedBooks = async () => {
       try {
         setIsLoading(true);
         const response = await getMostSearchedBooks();

         if (response.isSuccess) {
-          setBooks(response.data.bookList);
+          if (mounted) setBooks(response.data.bookList);
         } else {
-          setError(response.message);
+          if (mounted) setError(response.message);
         }
       } catch (error) {
         console.error('인기 검색 도서 조회 오류:', error);
-        setError('인기 검색 도서를 불러오는데 실패했습니다.');
+        if (mounted) setError('인기 검색 도서를 불러오는데 실패했습니다.');
       } finally {
-        setIsLoading(false);
+        if (mounted) setIsLoading(false);
       }
     };

     fetchMostSearchedBooks();
-  }, []);
+    return () => {
+      mounted = false;
+    };
+  }, []);

111-122: 접근성: 클릭 가능한 li에 키보드 접근성 추가 고려

li를 클릭 가능하게 만들었으므로 role, tabIndex, 키보드 이벤트(Enter/Space)를 추가하거나, 시맨틱하게 버튼/앵커로 구조화하는 것을 권장합니다.

원하시면 버튼/앵커로 마크업 전환하는 패치 제안 드리겠습니다.

src/pages/search/Search.tsx (1)

247-254: 로딩 메시지 UX 개선 필요

현재 "검색 중..." 텍스트만 표시되고 있는데, 실제 로딩 스피너나 스켈레톤 UI를 추가하면 사용자 경험이 향상될 것입니다.

로딩 컴포넌트를 개선하거나 스켈레톤 UI를 도입하는 것을 고려해보세요. 예를 들어:

  • 로딩 스피너 추가
  • 책 검색 결과의 스켈레톤 UI 표시
  • 프로그레스 바 표시
src/pages/searchBook/SearchBook.tsx (3)

128-145: 북마크 토글 시 낙관적 업데이트 적용 권장

현재는 API 응답을 기다린 후 상태를 업데이트하고 있어 사용자 경험이 느릴 수 있습니다.

낙관적 업데이트를 적용하여 UX를 개선하세요:

   const handleSaveButton = async () => {
     if (!isbn || isSaving) return;

+    const previousSavedState = isSaved;
+    setIsSaved(!isSaved); // 낙관적 업데이트

     try {
       setIsSaving(true);
       const response = await postSaveBook(isbn, !isSaved);

       if (response.isSuccess) {
         setIsSaved(response.data.isSaved);
       } else {
+        setIsSaved(previousSavedState); // 실패 시 롤백
         console.error('북마크 실패:', response.message);
       }
     } catch (error) {
+      setIsSaved(previousSavedState); // 에러 시 롤백
       console.error('북마크 중 오류 발생:', error);
     } finally {
       setIsSaving(false);
     }
   };

203-208: mock 데이터와 실제 API 데이터 혼용

책 상세 정보는 API에서 가져오면서 피드 데이터는 여전히 mock 데이터를 사용하고 있습니다. 이는 데이터 불일치를 야기할 수 있습니다.

피드 데이터도 API에서 가져오도록 수정이 필요합니다. 피드 API가 준비되어 있다면 연동 작업을 도와드릴 수 있습니다. 새로운 이슈를 생성할까요?


147-159: 로딩/에러 UI 개선 필요

인라인 스타일을 사용하고 있고, 로딩과 에러 상태에 대한 적절한 컴포넌트가 없습니다.

별도의 로딩/에러 컴포넌트를 만들어 사용하세요:

-        <div style={{ padding: '100px 20px', textAlign: 'center', color: 'white' }}>
-          {isLoading ? '로딩 중...' : error || '책 정보를 찾을 수 없습니다.'}
-        </div>
+        <LoadingErrorContainer>
+          {isLoading ? (
+            <LoadingSpinner />
+          ) : (
+            <ErrorMessage>{error || '책 정보를 찾을 수 없습니다.'}</ErrorMessage>
+          )}
+        </LoadingErrorContainer>

styled-components를 SearchBook.styled.ts 파일에 추가하세요.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 007a16c and fcfeb0b.

⛔ Files ignored due to path filters (2)
  • src/assets/common/SaveIcon.svg is excluded by !**/*.svg
  • src/assets/common/filledSaveIcon.svg is excluded by !**/*.svg
📒 Files selected for processing (12)
  • src/api/books/getBookDetail.ts (1 hunks)
  • src/api/books/getMostSearchedBooks.ts (1 hunks)
  • src/api/books/getRecruitingRooms.ts (1 hunks)
  • src/api/books/getSearchBooks.ts (1 hunks)
  • src/api/books/postSaveBook.ts (1 hunks)
  • src/components/search/BookSearchResult.tsx (2 hunks)
  • src/components/search/MostSearchedBooks.tsx (3 hunks)
  • src/pages/index.tsx (1 hunks)
  • src/pages/search/Search.tsx (4 hunks)
  • src/pages/searchBook/SearchBook.styled.ts (2 hunks)
  • src/pages/searchBook/SearchBook.tsx (4 hunks)
  • src/pages/searchBook/SearchBookGroup.tsx (2 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (10)
src/api/books/getBookDetail.ts (1)
src/api/index.ts (1)
  • apiClient (27-33)
src/api/books/getRecruitingRooms.ts (1)
src/api/index.ts (1)
  • apiClient (27-33)
src/components/search/BookSearchResult.tsx (1)
src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts (1)
  • BookItem (172-179)
src/api/books/postSaveBook.ts (1)
src/api/index.ts (1)
  • apiClient (27-33)
src/api/books/getSearchBooks.ts (2)
src/pages/search/Search.tsx (1)
  • SearchedBook (14-21)
src/api/index.ts (1)
  • apiClient (27-33)
src/api/books/getMostSearchedBooks.ts (1)
src/api/index.ts (1)
  • apiClient (27-33)
src/pages/search/Search.tsx (3)
src/api/books/getSearchBooks.ts (3)
  • SearchedBook (13-20)
  • getSearchBooks (41-59)
  • convertToSearchedBooks (61-70)
src/components/search/BookSearchResult.tsx (1)
  • BookSearchResult (11-48)
src/styles/global/global.ts (2)
  • colors (4-53)
  • typography (56-77)
src/components/search/MostSearchedBooks.tsx (5)
src/api/books/getMostSearchedBooks.ts (2)
  • MostSearchedBook (4-9)
  • getMostSearchedBooks (24-32)
src/components/today-words/EmptyState.styled.ts (1)
  • EmptyMessage (14-19)
src/pages/searchBook/SearchBook.styled.ts (1)
  • SubText (98-107)
src/components/common/BookSearchBottomSheet/BookSearchBottomSheet.styled.ts (2)
  • BookList (166-170)
  • BookItem (172-179)
src/styles/global/global.ts (2)
  • colors (4-53)
  • typography (56-77)
src/pages/searchBook/SearchBookGroup.tsx (2)
src/api/books/getRecruitingRooms.ts (1)
  • RecruitingRoomsData (14-19)
src/components/group/GroupCard.tsx (1)
  • GroupCard (14-41)
src/pages/searchBook/SearchBook.tsx (5)
src/api/books/getBookDetail.ts (2)
  • BookDetail (4-14)
  • getBookDetail (24-33)
src/api/books/getRecruitingRooms.ts (2)
  • RecruitingRoomsData (14-19)
  • getRecruitingRooms (29-40)
src/api/books/postSaveBook.ts (1)
  • postSaveBook (22-32)
src/mocks/searchBook.mock.ts (1)
  • mockSearchBook (1-76)
src/components/search/IntroModal.tsx (1)
  • IntroModal (11-23)
🔇 Additional comments (4)
src/pages/searchBook/SearchBook.styled.ts (2)

129-129: 버튼에 포인터 커서 추가 LGTM

인터랙션 요소의 가시성이 좋아집니다.


154-154: 포인터 커서 추가 LGTM

클릭 가능성이 명확해졌습니다.

src/api/books/getRecruitingRooms.ts (1)

14-19: nextCursor null 가능성 확인

페이지네이션에서 마지막 페이지면 nextCursor가 null로 내려오는 API도 많습니다. 현재는 string으로만 선언되어 있어 런타임 불일치가 발생할 수 있습니다. 백엔드 스펙을 확인하고 필요 시 string | null로 조정해 주세요.

src/pages/search/Search.tsx (1)

106-114: useEffect 내 무한 루프 위험

isInitialized 상태가 useEffect 내부에서 변경되고 있어, 잠재적인 무한 루프 위험이 있습니다.

isInitialized를 ref로 변경하여 불필요한 리렌더링을 방지하세요:

-  const [isInitialized, setIsInitialized] = useState(false);
+  const isInitializedRef = useRef(false);

   useEffect(() => {
     const query = searchParams.get('q') || '';
-    if (query && !isInitialized) {
+    if (query && !isInitializedRef.current) {
       setSearchTerm(query);
       setIsSearching(true);
       handleSearch(query, true);
-      setIsInitialized(true);
+      isInitializedRef.current = true;
     }
-  }, [searchParams, handleSearch, isInitialized]);
+  }, [searchParams, handleSearch]);

Likely an incorrect or invalid review comment.

Comment on lines +24 to +33
export const getBookDetail = async (isbn: string): Promise<BookDetailResponse> => {
try {
const response = await apiClient.get<BookDetailResponse>(`/books/${isbn}`);

return response.data;
} catch (error) {
console.error('책 상세 정보 API 오류:', error);
throw error;
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

ISBN을 URL 인코딩하고 로그에 컨텍스트 추가

특수문자(하이픈 등) 포함 가능성을 고려해 경로 파라미터는 인코딩하는 게 안전합니다. 에러 로그에 isbn을 포함해 디버깅 가시성을 높이십시오.

 export const getBookDetail = async (isbn: string): Promise<BookDetailResponse> => {
   try {
-    const response = await apiClient.get<BookDetailResponse>(`/books/${isbn}`);
+    const response = await apiClient.get<BookDetailResponse>(`/books/${encodeURIComponent(isbn)}`);
 
     return response.data;
   } catch (error) {
-    console.error('책 상세 정보 API 오류:', error);
+    console.error(`책 상세 정보 API 오류 (isbn: ${isbn}):`, error);
     throw error;
   }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getBookDetail = async (isbn: string): Promise<BookDetailResponse> => {
try {
const response = await apiClient.get<BookDetailResponse>(`/books/${isbn}`);
return response.data;
} catch (error) {
console.error('책 상세 정보 API 오류:', error);
throw error;
}
};
export const getBookDetail = async (isbn: string): Promise<BookDetailResponse> => {
try {
const response = await apiClient.get<BookDetailResponse>(
`/books/${encodeURIComponent(isbn)}`
);
return response.data;
} catch (error) {
console.error(`책 상세 정보 API 오류 (isbn: ${isbn}):`, error);
throw error;
}
};
🤖 Prompt for AI Agents
In src/api/books/getBookDetail.ts around lines 24 to 33, the ISBN path parameter
isn't URL-encoded and the error log lacks context; update the request to use an
encoded ISBN (e.g., apiClient.get(`/books/${encodeURIComponent(isbn)}`)) and
modify the catch to log the isbn alongside the error (e.g., console.error('책 상세
정보 API 오류', { isbn, error })) before rethrowing so debugging has the
encoded-safe path and clear context.

Comment on lines +12 to +20
// 검색된 책 타입 (컴포넌트에서 사용하는 형태)
export interface SearchedBook {
id: number;
title: string;
author: string;
publisher: string;
coverUrl: string;
isbn: string;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

SearchedBook 타입 중복 정의 제거

UI 레이어(src/pages/search/Search.tsx)에서도 같은 이름의 SearchedBook 인터페이스가 존재합니다. 중복 정의는 드리프트를 유발합니다. 공용 타입 모듈(예: src/types/book.ts)로 옮기고 단일 출처를 사용하세요.

중복 정의 및 사용처를 점검하는 스크립트:


🏁 Script executed:

#!/bin/bash
# SearchedBook 타입 선언 및 사용 위치 점검
rg -n --no-heading "interface SearchedBook|type SearchedBook" -A 2 -B 2
rg -n --no-heading "SearchedBook" -A 2 -B 2

Length of output: 8084


SearchedBook 타입 공통 모듈로 이동 및 중복 정의 제거 필요

UI 레이어와 API 레이어에서 각각 정의된 SearchedBook 인터페이스가 아래 세 곳에 중복되어 있습니다. 단일 소스에 정의를 모아두고, 각 파일에서는 해당 타입을 import 하도록 리팩터링하세요.

• src/api/books/getSearchBooks.ts
• src/pages/search/Search.tsx
• src/components/search/BookSearchResult.tsx

제안된 작업 순서:

  1. src/types/book.ts (또는 src/types/index.ts)에 SearchedBook 인터페이스 정의 추가

    // src/types/book.ts
    export interface SearchedBook {
      id: number;
      title: string;
      author: string;
      publisher: string;
      coverUrl: string;
      isbn: string;
    }
  2. 모든 파일에서 로컬 정의 제거 후 공통 타입 모듈에서 import

    • src/api/books/getSearchBooks.ts
      - // 검색된 책 타입 (컴포넌트에서 사용하는 형태)
      - export interface SearchedBook { … }
      + import type { SearchedBook } from '@/types/book';
    • src/pages/search/Search.tsx
      - export interface SearchedBook { … }
      + import type { SearchedBook } from '@/types/book';
    • src/components/search/BookSearchResult.tsx
      - import type { SearchedBook } from '@/pages/search/Search';
      + import type { SearchedBook } from '@/types/book';
  3. convertToSearchedBooks의 반환 타입도 공통 SearchedBook[]으로 유지되는지 확인

이후 빌드 에러가 없는지, 컴포넌트들이 올바르게 타입을 참조하는지 검증해 주세요.

🤖 Prompt for AI Agents
In src/api/books/getSearchBooks.ts around lines 12 to 20, the SearchedBook
interface is duplicated across multiple files; move the interface to a single
shared module (e.g., src/types/book.ts exporting SearchedBook), remove the local
SearchedBook declarations in src/api/books/getSearchBooks.ts,
src/pages/search/Search.tsx, and src/components/search/BookSearchResult.tsx, and
update those files to import { SearchedBook } from the new types module; also
ensure convertToSearchedBooks and any function signatures use the shared
SearchedBook[] type and run the build to verify no type errors remain.

Comment on lines +61 to +70
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,
}));
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

id로 index 사용은 불안정 — 키/식별자 용도로 ISBN 사용 권장

id를 index 기반으로 생성하면 페이지네이션/정렬 변경 시 불안정합니다. 가능하면 React key와 식별자는 ISBN을 사용하세요. 현재 UI에서 key로 id를 쓰지 않는지 확인해 주세요.

검증 스크립트(리스트 렌더링에서 book.id를 key로 쓰는지 검색):


🏁 Script executed:

#!/bin/bash
# book.id를 React key로 사용하는 구문 탐색
rg -n --no-heading "key=\{[^}]*book\.id[^}]*\}" -A 2 -B 2

Length of output: 1167


페이지네이션·정렬 안정화를 위해 id를 ISBN으로 변경하세요

현재 convertToSearchedBooks에서 idindex + 1로 생성하고,
BookSearchResult.tsx(34)·BookSearchBottomSheet.tsx(216)에서 key={book.id}로 사용하고 있습니다.
인덱스 기반 ID는 리스트가 변경될 때 React key 충돌을 일으킬 수 있으므로, 고유 불변 값인 ISBN을 사용하도록 수정하세요.

수정 제안:

// File: src/api/books/getSearchBooks.ts

-export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => {
-  return apiBooks.map((book, index) => ({
-    id: index + 1,
+export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => {
+  return apiBooks.map((book) => ({
+    id: book.isbn,           // index → ISBN으로 대체
     title: book.title,
     author: book.authorName,
     publisher: book.publisher,
     coverUrl: book.imageUrl,
-    isbn: book.isbn,
+    isbn: book.isbn,         // id와 중복되지 않도록, 필요한 경우 타입도 함께 검토
  }));
};

• BookSearchResult.tsx (34)
• BookSearchBottomSheet.tsx (216)
– 위 파일들의 key={book.id}key={book.isbn} 또는 key={book.isbn}로 변경하면 안전합니다.
– 필요 시 SearchedBook.id 타입을 string으로 조정하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
}));
};
export const convertToSearchedBooks = (apiBooks: BookSearchItem[]): SearchedBook[] => {
return apiBooks.map((book) => ({
id: book.isbn, // index → ISBN으로 대체
title: book.title,
author: book.authorName,
publisher: book.publisher,
coverUrl: book.imageUrl,
isbn: book.isbn, // id와 중복되지 않도록, 필요한 경우 타입도 함께 검토
}));
};
🤖 Prompt for AI Agents
In src/api/books/getSearchBooks.ts around lines 61-70, the function creates id
using index+1 which can cause React key collisions; change id to use the
immutable ISBN (id: book.isbn), update the SearchedBook type so id is a string
(or remove id and rely on isbn field), and then update both BookSearchResult.tsx
(line ~34) and BookSearchBottomSheet.tsx (line ~216) to use key={book.isbn} (or
key={book.id} if you keep id as isbn) to ensure stable keys and correct typing
throughout.

Comment on lines +22 to +27
export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => {
try {
const response = await apiClient.post<SaveBookResponse>(`/books/${isbn}/saved`, {
type: type,
});
return response.data;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

요청 바디 타입 적용 및 ISBN URL 인코딩

  • SaveBookRequest 타입을 실제 요청 바디에 적용해 타입 안정성을 높이세요.
  • 경로 파라미터인 ISBN은 안전하게 encodeURIComponent로 인코딩하는 것이 좋습니다.

적용 diff:

-export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => {
+export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => {
   try {
-    const response = await apiClient.post<SaveBookResponse>(`/books/${isbn}/saved`, {
-      type: type,
-    });
+    const payload: SaveBookRequest = { type };
+    const response = await apiClient.post<SaveBookResponse>(
+      `/books/${encodeURIComponent(isbn)}/saved`,
+      payload,
+    );
     return response.data;
   } catch (error) {
     console.error('책 저장 API 오류:', error);
     throw error;
   }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => {
try {
const response = await apiClient.post<SaveBookResponse>(`/books/${isbn}/saved`, {
type: type,
});
return response.data;
export const postSaveBook = async (isbn: string, type: boolean): Promise<SaveBookResponse> => {
try {
const payload: SaveBookRequest = { type };
const response = await apiClient.post<SaveBookResponse>(
`/books/${encodeURIComponent(isbn)}/saved`,
payload,
);
return response.data;
} catch (error) {
console.error('책 저장 API 오류:', error);
throw error;
}
};
🤖 Prompt for AI Agents
In src/api/books/postSaveBook.ts around lines 22 to 27, the function currently
posts a plain object and inserts raw isbn into the URL; update it to construct a
payload typed as SaveBookRequest (e.g. const body: SaveBookRequest = { type })
and pass that to apiClient.post to ensure type safety, and encode the isbn in
the path using encodeURIComponent(isbn) so the URL is safe; keep returning
response.data as before.

<Route path="search" element={<Search />} />
<Route path="search/applybook" element={<ApplyBook />} />
<Route path="search/book" element={<SearchBook />} />
<Route path="search/book/:isbn" element={<SearchBook />} />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

동적 라우트 전환 LGTM. 경로 충돌 및 URL 인코딩 점검 권장

  • React Router v6는 정적 세그먼트가 파라미터보다 우선 매칭되므로, search/book/groupsearch/book/:isbn는 충돌하지 않습니다. 그래도 회귀가 없는지 한 번만 확인해 주세요.
  • 이동 시 ISBN에 특수문자가 포함될 수 있으니 encodeURIComponent 적용을 권장합니다.

다음 스크립트로 링크/네비게이션에서 인코딩 누락 가능성을 훑어볼 수 있습니다.


🏁 Script executed:

#!/bin/bash
# /search/book/${...} 사용 지점 나열
rg -n -S "/search/book/\\$\\{" src -g "src/**/*.tsx" -H

echo
echo "encodeURIComponent 사용 지점"
rg -n -S "encodeURIComponent\\(" src -g "src/**/*.tsx" -H

Length of output: 403


동적 라우트 전환 LGTM – URL 인코딩 적용 필요

  • React Router v6는 정적 세그먼트가 파라미터보다 우선 매칭하므로 search/book/groupsearch/book/:isbn는 충돌하지 않습니다. 다만 회귀가 없는지 한 번만 확인해 주세요.
  • 특수문자가 포함된 ISBN을 안전하게 전달하려면 encodeURIComponent를 적용해야 합니다.

수정 대상 위치:

  • src/components/search/MostSearchedBooks.tsx:36
  • src/components/search/BookSearchResult.tsx:34

예시 변경(diff):

- navigate(`/search/book/${isbn}`);
+ navigate(`/search/book/${encodeURIComponent(isbn)}`);
- onClick={() => navigate(`/search/book/${book.isbn}`)}
+ onClick={() => navigate(`/search/book/${encodeURIComponent(book.isbn)}`)}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/pages/index.tsx around line 66 and in the two components
src/components/search/MostSearchedBooks.tsx:36 and
src/components/search/BookSearchResult.tsx:34, ensure ISBN values are
URL-encoded when constructing links or navigate calls (use
encodeURIComponent(isbn)) so special characters are passed safely to the dynamic
route /search/book/:isbn; update any <Link> or navigate(...) usages to wrap the
isbn with encodeURIComponent, and verify the static route /search/book/group
still matches (no change needed for route order), running a quick sanity check
to avoid regressions.

Comment on lines 157 to 167
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;
`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

SaveButton 포커스 가시성 보완 권장 (:focus-visible 추가)

border 제거로 키보드 포커스가 화면에 드러나지 않습니다. 접근성 관점에서 포커스 링을 추가하는 게 안전합니다. 약한 hover/active 피드백도 함께 제안합니다.

 export const SaveButton = styled.button`
   width: 48px;
   height: 48px;
   background: transparent;
-  border: none;
+  border: 1px solid transparent;
   border-radius: 12px;
   display: flex;
   align-items: center;
   justify-content: center;
-  cursor: pointer;
+  cursor: pointer;
+  transition: background-color 0.2s ease, transform 0.05s ease;
+  &:hover {
+    background: rgba(255, 255, 255, 0.06);
+  }
+  &:active {
+    transform: translateY(1px);
+  }
+  &:focus-visible {
+    outline: 2px solid ${colors.purple.main};
+    outline-offset: 2px;
+  }
 `;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 SaveButton = styled.button`
width: 48px;
height: 48px;
background: transparent;
border: 1px solid transparent;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.05s ease;
&:hover {
background: rgba(255, 255, 255, 0.06);
}
&:active {
transform: translateY(1px);
}
&:focus-visible {
outline: 2px solid ${colors.purple.main};
outline-offset: 2px;
}
`;
🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBook.styled.ts around lines 157 to 167, the
SaveButton removes the border so keyboard focus is invisible; add an accessible
focus indicator and subtle hover/active feedback: keep border: none but add a
:focus-visible rule that applies a visible outline or box-shadow with an
outline-offset (e.g., 2px) and sufficient contrast, add :hover and :active
styles that slightly change background or transform for pointer feedback, and
include a short transition for smoothness; ensure these styles only affect
visual states and do not change layout.

Comment on lines +56 to +93
useEffect(() => {
const fetchBookDetail = async () => {
if (!isbn) {
setError('ISBN이 필요합니다.');
setIsLoading(false);
return;
}

try {
setIsLoading(true);

const [bookResponse, recruitingResponse] = await Promise.all([
getBookDetail(isbn),
getRecruitingRooms(isbn),
]);

if (bookResponse.isSuccess) {
setBookDetail(bookResponse.data);
setIsSaved(bookResponse.data.isSaved);
} else {
setError(bookResponse.message);
}

if (recruitingResponse.isSuccess) {
setRecruitingRoomsData(recruitingResponse.data);
} else {
console.error('모집중인 모임방 조회 실패:', recruitingResponse.message);
}
} catch (error) {
console.error('데이터 조회 오류:', error);
setError('정보를 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};

fetchBookDetail();
}, [isbn]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

에러 처리 로직 개선 필요

API 호출 실패 시 부분적으로만 에러를 처리하고 있습니다. recruitingResponse 실패는 console.error만 하고 있어 사용자에게 적절한 피드백이 없습니다.

모든 API 실패에 대해 일관된 에러 처리를 적용하세요:

         if (recruitingResponse.isSuccess) {
           setRecruitingRoomsData(recruitingResponse.data);
         } else {
-          console.error('모집중인 모임방 조회 실패:', recruitingResponse.message);
+          // 모집중인 모임방은 optional이므로 실패해도 기본값 설정
+          setRecruitingRoomsData({
+            recruitingRoomList: [],
+            totalRoomCount: 0,
+            nextCursor: '',
+            isLast: true,
+          });
+          console.warn('모집중인 모임방 조회 실패:', recruitingResponse.message);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const fetchBookDetail = async () => {
if (!isbn) {
setError('ISBN이 필요합니다.');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
const [bookResponse, recruitingResponse] = await Promise.all([
getBookDetail(isbn),
getRecruitingRooms(isbn),
]);
if (bookResponse.isSuccess) {
setBookDetail(bookResponse.data);
setIsSaved(bookResponse.data.isSaved);
} else {
setError(bookResponse.message);
}
if (recruitingResponse.isSuccess) {
setRecruitingRoomsData(recruitingResponse.data);
} else {
console.error('모집중인 모임방 조회 실패:', recruitingResponse.message);
}
} catch (error) {
console.error('데이터 조회 오류:', error);
setError('정보를 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
fetchBookDetail();
}, [isbn]);
useEffect(() => {
const fetchBookDetail = async () => {
if (!isbn) {
setError('ISBN이 필요합니다.');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
const [bookResponse, recruitingResponse] = await Promise.all([
getBookDetail(isbn),
getRecruitingRooms(isbn),
]);
if (bookResponse.isSuccess) {
setBookDetail(bookResponse.data);
setIsSaved(bookResponse.data.isSaved);
} else {
setError(bookResponse.message);
}
if (recruitingResponse.isSuccess) {
setRecruitingRoomsData(recruitingResponse.data);
} else {
// 모집중인 모임방은 optional이므로 실패해도 기본값 설정
setRecruitingRoomsData({
recruitingRoomList: [],
totalRoomCount: 0,
nextCursor: '',
isLast: true,
});
console.warn('모집중인 모임방 조회 실패:', recruitingResponse.message);
}
} catch (error) {
console.error('데이터 조회 오류:', error);
setError('정보를 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
fetchBookDetail();
}, [isbn]);
🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBook.tsx around lines 56 to 93, the
recruitingResponse failure is only logged to console so the user gets no
feedback; update the effect to handle all API failures consistently by setting
setError when recruitingResponse.isSuccess is false (either set a combined
message if bookResponse also failed or a specific recruiting error message),
ensure setIsSaved/setBookDetail only run on success, and keep the existing
try/catch/finally so loading state is cleared; in short, propagate recruiting
API errors into state (setError) instead of only console.error and present a
user-friendly message.

Comment on lines 105 to 124
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,
},
},
});
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

navigate state 전달 시 데이터 검증 부족

recruitingRoomsData가 null일 경우 기본값을 제공하고 있지만, bookDetail의 필드들이 undefined일 가능성을 체크하지 않습니다.

데이터 검증을 강화하세요:

   const handleRecruitingGroupButton = () => {
-    if (bookDetail) {
+    if (bookDetail && bookDetail.isbn && bookDetail.title) {
       navigate('/search/book/group', {
         state: {
           recruitingRooms: recruitingRoomsData || {
             recruitingRoomList: [],
             totalRoomCount: 0,
             nextCursor: '',
             isLast: true,
           },
           bookInfo: {
             isbn: bookDetail.isbn,
             title: bookDetail.title,
-            author: bookDetail.authorName,
+            author: bookDetail.authorName || '작가 미상',
             imageUrl: bookDetail.imageUrl,
           },
         },
       });
     }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 handleRecruitingGroupButton = () => {
if (bookDetail && bookDetail.isbn && bookDetail.title) {
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,
},
},
});
}
};
🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBook.tsx around lines 105 to 124, the navigate
call assumes bookDetail fields (isbn, title, authorName, imageUrl) are always
defined; validate and sanitize those fields before passing them in state: ensure
bookDetail exists and each field is checked and replaced with safe defaults
(e.g., empty string) or bail out (do not navigate) if required identifiers like
isbn are missing; keep the existing recruitingRoomsData fallback but apply the
same null/undefined checks to nested fields if needed; update the navigate state
to use the validated/sanitized values so undefined never propagates.

Comment on lines +21 to +22
const location = useLocation();
const { recruitingRooms, bookInfo } = (location.state as LocationState) || {};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

useLocation state 안전 분해: any 유출 및 런타임 안전성 개선 제안

현재 (location.state as LocationState) || {}로 분해하면 TS가 속성 타입을 좁히지 못해 any로 흐르거나, 런타임에서 state가 없을 때 의도치 않은 동작이 생길 수 있습니다. 명시적으로 state를 변수로 받고 optional 체이닝으로 접근해 주세요.

적용 diff:

-  const location = useLocation();
-  const { recruitingRooms, bookInfo } = (location.state as LocationState) || {};
+  const location = useLocation();
+  const state = location.state as LocationState | undefined;
+  const recruitingRooms = state?.recruitingRooms;
+  const bookInfo = state?.bookInfo;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const location = useLocation();
const { recruitingRooms, bookInfo } = (location.state as LocationState) || {};
const location = useLocation();
const state = location.state as LocationState | undefined;
const recruitingRooms = state?.recruitingRooms;
const bookInfo = state?.bookInfo;
🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBookGroup.tsx around lines 21-22, instead of
destructuring directly from (location.state as LocationState) || {} (which can
leak any and is unsafe at runtime), first assign a typed state variable like
const state = location.state as LocationState | undefined and then safely access
properties with optional chaining and nullish coalescing (e.g., const
recruitingRooms = state?.recruitingRooms ?? []; const bookInfo = state?.bookInfo
?? undefined) so TypeScript narrows types and runtime access is protected.

Comment on lines +43 to +56
{groupList.map((room, index) => (
<GroupCard
key={room.roomId || index}
group={{
id: room.roomId,
title: room.roomName,
participants: room.memberCount,
maximumParticipants: room.recruitCount,
deadLine: 0,
coverUrl: room.bookImageUrl || bookInfo?.imageUrl,
}}
isOngoing={true}
type={'modal'}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

키 안정성과 마감 D-day 계산 보완

  • key에서 OR(||)는 0을 무효로 취급합니다. nullish 병합(??)로 전환해 예외를 방지하세요.
  • deadLine을 0으로 고정하면 잘못된 UI가 노출됩니다. API의 deadlineEndDate를 기반으로 남은 일수를 계산하세요.

적용 diff:

-          {groupList.map((room, index) => (
+          {groupList.map((room, index) => (
             <GroupCard
-              key={room.roomId || index}
+              key={room.roomId ?? index}
               group={{
                 id: room.roomId,
                 title: room.roomName,
                 participants: room.memberCount,
                 maximumParticipants: room.recruitCount,
-                deadLine: 0,
+                deadLine: getDaysLeft(room.deadlineEndDate),
                 coverUrl: room.bookImageUrl || bookInfo?.imageUrl,
               }}
               isOngoing={true}
               type={'modal'}
             />
           ))}

파일 내 보조 유틸(추가 코드):

// 남은 일수 계산: 마감일이 과거면 0일로 처리
function getDaysLeft(isoDate: string | undefined): number {
  if (!isoDate) return 0;
  const due = new Date(isoDate).getTime();
  if (Number.isNaN(due)) return 0;
  const msPerDay = 1000 * 60 * 60 * 24;
  const diff = Math.ceil((due - Date.now()) / msPerDay);
  return Math.max(0, diff);
}
🤖 Prompt for AI Agents
In src/pages/searchBook/SearchBookGroup.tsx around lines 43 to 56, the code uses
|| for the GroupCard key (which treats 0 as falsy) and hardcodes deadLine to 0;
change the key to use nullish coalescing (??) so room.roomId of 0 is preserved,
add a small helper in the file to compute remaining days from the API field
deadlineEndDate (validate undefined and invalid dates and clamp to 0), and pass
deadLine={getDaysLeft(room.deadlineEndDate)} (or the correct API property name)
and coverUrl as now to GroupCard; ensure the helper handles ISO strings, NaN
dates, and returns Math.max(0, computedDays).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📬 API 서버 API 통신 ✨ Feature 기능 개발

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant