diff --git a/src/app/book/search/page.tsx b/src/app/book/search/page.tsx index 8cc087a9..0df5aaa3 100644 --- a/src/app/book/search/page.tsx +++ b/src/app/book/search/page.tsx @@ -1,32 +1,31 @@ 'use client'; -import { useEffect } from 'react'; -import { useInView } from 'react-intersection-observer'; +import { Suspense, useEffect } from 'react'; import { useForm } from 'react-hook-form'; +import { useInView } from 'react-intersection-observer'; import useBookSearchQuery from '@/queries/book/useBookSearchQuery'; -import useRecentSearchesQuery from '@/queries/book/useRecentSearchesQuery'; +import { useRecentSearchListQuery } from '@/queries/book/useRecentSearchesQuery'; import SSRSafeSuspense from '@/components/SSRSafeSuspense'; import useDebounceValue from '@/hooks/useDebounce'; import { checkAuthentication } from '@/utils/helpers'; -import { IconSearch } from '@public/icons'; -import TopHeader from '@/v1/base/TopHeader'; +import Loading from '@/v1/base/Loading'; import Input from '@/v1/base/Input'; -import RecentSearch, { - RecentSearchSkeleton, -} from '@/v1/bookSearch/RecentSearch'; +import TopHeader from '@/v1/base/TopHeader'; import BestSellers, { BestSellersSkeleton } from '@/v1/bookSearch/BestSellers'; -import BookSearchResults from '@/v1/bookSearch/SearchResult'; +import RecentSearchList, { + RecentSearchListSkeleton, +} from '@/v1/bookSearch/RecentSearchList'; +import BookSearchList from '@/v1/bookSearch/BookSearchList'; +import { IconSearch } from '@public/icons'; type FormValues = { searchValue: string; }; -const BookSearch = () => { - const isAuthenticated = checkAuthentication(); - +const BookSearchPage = () => { const { register, watch, setValue } = useForm({ mode: 'all', defaultValues: { @@ -34,8 +33,53 @@ const BookSearch = () => { }, }); - const queryKeyword = useDebounceValue(watch('searchValue'), 1000); + const watchedKeyword = watch('searchValue'); + const debouncedKeyword = useDebounceValue(watchedKeyword, 1000); + + return ( + <> + +
+
+ + +
+ + {/** 최근 검색어 + 베스트 셀러 */} +
+ }> + setValue('searchValue', keyword)} + /> + + +
+ {/** 도서 검색 결과 */} + {watchedKeyword && ( +
+ }> + {watchedKeyword === debouncedKeyword ? ( + + ) : ( + /* 타이핑 중 debounce가 적용되어 keyword가 업데이트 되지 않는 경우에 Loading 컴포넌트로 대체 */ + + )} + +
+ )} +
+ + ); +}; + +const BookSearchResult = ({ queryKeyword }: { queryKeyword: string }) => { const { ref: inViewRef, inView } = useInView(); const bookSearchInfo = useBookSearchQuery({ @@ -43,16 +87,13 @@ const BookSearch = () => { page: 1, pageSize: 12, }); - const recentSearchesInfo = useRecentSearchesQuery({ - enabled: isAuthenticated, - }); const searchedBooks = bookSearchInfo.isSuccess ? bookSearchInfo.data.pages.flatMap(page => page.searchBookResponseList) : []; - const recentSearches = recentSearchesInfo.isSuccess - ? recentSearchesInfo.data.bookRecentSearchResponses - : undefined; + const totalResultCount = bookSearchInfo.isSuccess + ? bookSearchInfo.data.pages[0].totalCount + : 0; useEffect(() => { if (inView && bookSearchInfo.hasNextPage) { @@ -68,42 +109,33 @@ const BookSearch = () => { return ( <> - -
-
- - -
- {watch('searchValue') ? ( - <> - -
- - ) : ( - }> - setValue('searchValue', keyword)} - /> - - - )} -
+ +
); }; +const RecentSearchResult = ({ + onItemClick, +}: { + onItemClick?: (item: string) => void; +}) => { + const isAuthenticated = checkAuthentication(); + + const { data: keywords } = useRecentSearchListQuery({ + enabled: isAuthenticated, + }); + + return ; +}; + const ContentsSkelton = () => { return ( <> - + ); }; -export default BookSearch; +export default BookSearchPage; diff --git a/src/app/bookarchive/page.tsx b/src/app/bookarchive/page.tsx index 9d4d0297..db02d48f 100644 --- a/src/app/bookarchive/page.tsx +++ b/src/app/bookarchive/page.tsx @@ -10,7 +10,7 @@ import TopHeader from '@/v1/base/TopHeader'; export default function BookArchivePage() { return ( -
+
{/* TODO: 스켈레톤 컴포넌트로 교체 */} diff --git a/src/queries/book/useBookSearchQuery.ts b/src/queries/book/useBookSearchQuery.ts index e1f0e1c8..ee67008e 100644 --- a/src/queries/book/useBookSearchQuery.ts +++ b/src/queries/book/useBookSearchQuery.ts @@ -24,6 +24,7 @@ const useBookSearchQuery = ({ }, staleTime: 3000, enabled: !!query, + suspense: true, } ); diff --git a/src/queries/book/useRecentSearchesQuery.ts b/src/queries/book/useRecentSearchesQuery.ts index 4838b88a..c97f9f97 100644 --- a/src/queries/book/useRecentSearchesQuery.ts +++ b/src/queries/book/useRecentSearchesQuery.ts @@ -17,3 +17,9 @@ const useRecentSearchesQuery = ( ); export default useRecentSearchesQuery; + +export const useRecentSearchListQuery = ({ enabled }: { enabled: boolean }) => + useRecentSearchesQuery({ + select: ({ bookRecentSearchResponses }) => bookRecentSearchResponses, + enabled, + }); diff --git a/src/stories/bookSearch/RecentSearch.stories.tsx b/src/stories/bookSearch/RecentSearch.stories.tsx index c4f2ffc3..b5e54f01 100644 --- a/src/stories/bookSearch/RecentSearch.stories.tsx +++ b/src/stories/bookSearch/RecentSearch.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; -import RecentSearch from '@/v1/bookSearch/RecentSearch'; +import RecentSearch from '@/v1/bookSearch/RecentSearchList'; const meta: Meta = { title: 'bookSearch/RecentSearch', @@ -13,14 +13,14 @@ type Story = StoryObj; export const Default: Story = { args: { - recentSearches: undefined, + keywords: undefined, onClick: () => alert('선택한 검색어 검색!'), }, }; export const RecentSearches: Story = { args: { - recentSearches: [ + keywords: [ { keyword: '21', modifiedAt: 'now' }, { keyword: 'I Love It', modifiedAt: 'now' }, { keyword: 'D (Half Moon)', modifiedAt: 'now' }, diff --git a/src/stories/bookSearch/SearchResult.stories.tsx b/src/stories/bookSearch/SearchResult.stories.tsx index 979f77ab..9edf0f07 100644 --- a/src/stories/bookSearch/SearchResult.stories.tsx +++ b/src/stories/bookSearch/SearchResult.stories.tsx @@ -1,16 +1,16 @@ import { Meta, StoryObj } from '@storybook/react'; import type { APISearchedBook } from '@/types/book'; -import SearchResult from '@/v1/bookSearch/SearchResult'; +import BookSearchList from '@/v1/bookSearch/BookSearchList'; -const meta: Meta = { - title: 'bookSearch/SearchResult', - component: SearchResult, +const meta: Meta = { + title: 'bookSearch/BookSearchList', + component: BookSearchList, tags: ['autodocs'], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; const SEARCHED_BOOK: APISearchedBook = { title: '리팩터링', @@ -27,6 +27,6 @@ const SEARCHED_BOOK: APISearchedBook = { export const Default: Story = { args: { - searchedBooks: [SEARCHED_BOOK, SEARCHED_BOOK, SEARCHED_BOOK, SEARCHED_BOOK], + books: [SEARCHED_BOOK, SEARCHED_BOOK, SEARCHED_BOOK, SEARCHED_BOOK], }, }; diff --git a/src/styles/global.css b/src/styles/global.css index 3c03fd3b..2e94c2c7 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -21,6 +21,11 @@ html { @apply w-full bg-background font-[LineSeedKR] text-[62.5%] text-black-700; } + + body { + font-size: 1.6rem; + } + .app-layout { /* TODO: Chakra UI 걷어내면 제거 */ max-width: 43rem; @@ -43,8 +48,4 @@ .w-app { @apply relative -left-[2rem] w-[calc(100%+4rem)]; } - .h-app { - height: 100vh; - height: 100dvh; - } } diff --git a/src/v1/book/BookCover.tsx b/src/v1/book/BookCover.tsx index 7390731a..b99f295e 100644 --- a/src/v1/book/BookCover.tsx +++ b/src/v1/book/BookCover.tsx @@ -1,4 +1,6 @@ -import { ComponentPropsWithoutRef } from 'react'; +'use client'; + +import { ComponentPropsWithoutRef, useState } from 'react'; import Image from 'next/image'; import { DATA_URL } from '@/constants/url'; @@ -9,9 +11,10 @@ type BookCoverSize = | 'medium' | 'large' | 'xlarge' - | '2xlarge'; + | '2xlarge' + | 'fill'; -type BookCoverProps = Required< +type BookCoverProps = Partial< Pick, 'src'> > & { title?: string; @@ -56,22 +59,39 @@ const getCoverSize = (size: BookCoverSize) => { sizeProps: { width: 140, height: 196 }, } as const; } + case 'fill': + return { + sizeClasses: 'w-full aspect-[5/7]', + sizeProps: { fill: true, sizes: '100%' }, + }; } }; const BookCover = ({ src, title, size = 'medium' }: BookCoverProps) => { + const [isError, setIsError] = useState(false); + const { sizeClasses, sizeProps } = getCoverSize(size); + const defaultCoverClass = src ? '' : 'shadow-bookcover'; return ( -
- {title +
+ {src && !isError ? ( + {title setIsError(true)} + {...sizeProps} + /> + ) : ( + /** default cover line */ +
+ )}
); }; diff --git a/src/v1/bookSearch/BookSearchList.tsx b/src/v1/bookSearch/BookSearchList.tsx new file mode 100644 index 00000000..5b8532c3 --- /dev/null +++ b/src/v1/bookSearch/BookSearchList.tsx @@ -0,0 +1,85 @@ +import { useRouter } from 'next/navigation'; + +import bookAPI from '@/apis/book'; +import type { APISearchedBook } from '@/types/book'; + +import useToast from '@/v1/base/Toast/useToast'; + +import BookCover from '../book/BookCover'; + +type BookSearchListProps = { + books: APISearchedBook[]; + totalCount?: number; +}; + +const BookSearchList = ({ books, totalCount }: BookSearchListProps) => { + const router = useRouter(); + const toast = useToast(); + + const handleClickBook = async (book: APISearchedBook) => { + try { + const { + data: { bookId }, + } = await bookAPI.createBook({ book }); + + router.push(`/book/${bookId}`); + } catch (error) { + toast.show({ + type: 'error', + message: '잠시 후 다시 시도해주세요', + }); + console.error(error); + } + }; + + if (!books.length) { + return ( +

+ 검색된 도서가 없어요 🥲 +

+ ); + } + + return ( + <> +

+ 검색 결과 + {totalCount} +

+
    + {books.map((book, idx) => ( + handleClickBook(book)} + /> + ))} +
+ + ); +}; + +export default BookSearchList; + +const BookSearchItem = ({ + imageUrl, + title, + onClick, +}: { + imageUrl: string; + title: string; + onClick: () => Promise; +}) => { + return ( +
  • + +

    + {title} +

    +
  • + ); +}; diff --git a/src/v1/bookSearch/RecentSearch.tsx b/src/v1/bookSearch/RecentSearchList.tsx similarity index 79% rename from src/v1/bookSearch/RecentSearch.tsx rename to src/v1/bookSearch/RecentSearchList.tsx index be8c89c2..81a020e3 100644 --- a/src/v1/bookSearch/RecentSearch.tsx +++ b/src/v1/bookSearch/RecentSearchList.tsx @@ -3,25 +3,25 @@ import type { APIBookRecentSearchResponse } from '@/types/book'; import Button from '@/v1/base/Button'; import Skeleton from '@/v1/base/Skeleton'; -type RecentSearchProps = { - recentSearches?: APIBookRecentSearchResponse[]; - onClick: (keyword: string) => void; +type RecentSearchListProps = { + keywords?: APIBookRecentSearchResponse[]; + onClick?: (keyword: string) => void; }; -const RecentSearch = ({ recentSearches, onClick }: RecentSearchProps) => { +const RecentSearchList = ({ keywords, onClick }: RecentSearchListProps) => { return (

    최근 검색어

    - {recentSearches ? ( + {keywords ? (
      - {recentSearches.map(item => ( + {keywords.map(item => (
    • @@ -37,9 +37,9 @@ const RecentSearch = ({ recentSearches, onClick }: RecentSearchProps) => { ); }; -export default RecentSearch; +export default RecentSearchList; -export const RecentSearchSkeleton = () => { +export const RecentSearchListSkeleton = () => { return (
      diff --git a/src/v1/bookSearch/SearchResult.tsx b/src/v1/bookSearch/SearchResult.tsx deleted file mode 100644 index 84492ab1..00000000 --- a/src/v1/bookSearch/SearchResult.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import Image from 'next/image'; -import { useRouter } from 'next/navigation'; - -import bookAPI from '@/apis/book'; -import type { APISearchedBook } from '@/types/book'; - -import useToast from '@/v1/base/Toast/useToast'; -import { DATA_URL } from '@/constants/url'; - -import { LogoWithText } from '@public/icons'; - -type BookSearchResultsProps = { - searchedBooks: APISearchedBook[]; -}; - -const BookSearchResults = ({ searchedBooks }: BookSearchResultsProps) => { - const router = useRouter(); - const toast = useToast(); - - const handleClickBook = async (book: APISearchedBook) => { - try { - const { - data: { bookId }, - } = await bookAPI.createBook({ book }); - - router.push(`/book/${bookId}`); - } catch (error) { - toast.show({ - type: 'error', - message: '잠시 후 다시 시도해주세요', - }); - console.error(error); - } - }; - - return ( -
        - {searchedBooks.map((searchedBook, idx) => ( - handleClickBook(searchedBook)} - /> - ))} -
      - ); -}; - -export default BookSearchResults; - -const SearchedBook = ({ - imageUrl, - title, - onClick, -}: { - imageUrl: string; - title: string; - onClick: () => Promise; -}) => { - return ( -
    • -
      - {imageUrl ? ( - {title} - ) : ( -
      - -
      - )} -
      -

      - {title} -

      -
    • - ); -}; diff --git a/src/v1/layout/Layout.tsx b/src/v1/layout/Layout.tsx index b785d741..e05db217 100644 --- a/src/v1/layout/Layout.tsx +++ b/src/v1/layout/Layout.tsx @@ -15,13 +15,13 @@ const Layout = ({ children }: LayoutProps) => { const isRootPath = pathname && rootPaths.includes(pathname); const dynamicClass = isRootPath - ? 'pb-[9rem] pt-[2rem]' + ? 'pb-[7rem] pt-[2rem]' : 'pt-[5.4rem] pb-[2rem]'; return ( <>
      {children}