-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[#526] 도서 검색 페이지 개선 #528
[#526] 도서 검색 페이지 개선 #528
Changes from all commits
d4752da
cdc412d
34a0043
d0492db
e936f56
ba61c14
4439b86
9ade93a
5d9e9b1
c117041
f79b32d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,58 +1,99 @@ | ||
'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<FormValues>({ | ||
mode: 'all', | ||
defaultValues: { | ||
searchValue: '', | ||
}, | ||
}); | ||
|
||
const queryKeyword = useDebounceValue(watch('searchValue'), 1000); | ||
const watchedKeyword = watch('searchValue'); | ||
const debouncedKeyword = useDebounceValue(watchedKeyword, 1000); | ||
|
||
return ( | ||
<> | ||
<TopHeader text={'Discover'} /> | ||
<article className="flex max-h-[calc(100%-6rem)] w-full flex-col gap-[3rem]"> | ||
<div className="flex w-full items-center gap-[2rem] border-b-[0.05rem] border-black-900 p-[1rem] focus-within:border-main-900 [&>div]:w-full"> | ||
<IconSearch className="fill-black h-[2.1rem] w-[2.1rem]" /> | ||
<Input | ||
className="w-full appearance-none text-sm font-normal focus:outline-none" | ||
placeholder="책 제목, 작가를 검색해보세요" | ||
{...register('searchValue')} | ||
/> | ||
</div> | ||
|
||
{/** 최근 검색어 + 베스트 셀러 */} | ||
<section | ||
className={`flex flex-col gap-[1.6rem] ${watchedKeyword && 'hidden'}`} | ||
> | ||
<SSRSafeSuspense fallback={<ContentsSkelton />}> | ||
<RecentSearchResult | ||
onItemClick={keyword => setValue('searchValue', keyword)} | ||
/> | ||
<BestSellers /> | ||
</SSRSafeSuspense> | ||
</section> | ||
|
||
{/** 도서 검색 결과 */} | ||
{watchedKeyword && ( | ||
<section className="flex-grow overflow-y-scroll pb-[1rem]"> | ||
<Suspense fallback={<Loading fullpage />}> | ||
{watchedKeyword === debouncedKeyword ? ( | ||
<BookSearchResult queryKeyword={debouncedKeyword} /> | ||
) : ( | ||
/* 타이핑 중 debounce가 적용되어 keyword가 업데이트 되지 않는 경우에 Loading 컴포넌트로 대체 */ | ||
<Loading fullpage /> | ||
)} | ||
</Suspense> | ||
Comment on lines
+67
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 검색에 1초간 debounce가 걸려있어서 도서 검색 api 요청도 1초 후에 발생하고 있어요. 결과 UI는 사용자가 타이핑할 때마다 바로 반응하는 것이 좋을 것 같아 이 때 Loading 컴포넌트가 렌더링되도록 수정했어요. |
||
</section> | ||
)} | ||
</article> | ||
</> | ||
); | ||
}; | ||
|
||
const BookSearchResult = ({ queryKeyword }: { queryKeyword: string }) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 컴포넌트가 하나의 역할만 수행할 수 있도록 page에서 분리했어요. keyword를 받아서 도서 검색 결과를 렌더링하는 컴포넌트예요. |
||
const { ref: inViewRef, inView } = useInView(); | ||
|
||
const bookSearchInfo = useBookSearchQuery({ | ||
query: queryKeyword, | ||
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 ( | ||
<> | ||
<TopHeader text={'Discover'} /> | ||
<article className="flex h-full w-full flex-col gap-[3.8rem]"> | ||
<div className="flex w-full items-center gap-[2rem] border-b-[0.05rem] border-black-900 p-[1rem] focus-within:border-main-900 [&>div]:w-full"> | ||
<IconSearch className="fill-black h-[2.1rem] w-[2.1rem]" /> | ||
<Input | ||
className="w-full appearance-none text-sm font-normal focus:outline-none" | ||
placeholder="책 제목, 작가를 검색해보세요" | ||
{...register('searchValue')} | ||
/> | ||
</div> | ||
{watch('searchValue') ? ( | ||
<> | ||
<BookSearchResults searchedBooks={searchedBooks} /> | ||
<div ref={inViewRef} /> | ||
</> | ||
) : ( | ||
<SSRSafeSuspense fallback={<ContentsSkelton />}> | ||
<RecentSearch | ||
recentSearches={recentSearches} | ||
onClick={(keyword: string) => setValue('searchValue', keyword)} | ||
/> | ||
<BestSellers /> | ||
</SSRSafeSuspense> | ||
)} | ||
</article> | ||
<BookSearchList books={searchedBooks} totalCount={totalResultCount} /> | ||
<div ref={inViewRef} /> | ||
</> | ||
); | ||
}; | ||
|
||
const RecentSearchResult = ({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 컴포넌트가 하나의 역할만 수행할 수 있도록 page에서 분리했어요. 최근 검색어 api 요청을 보내고 결과를 렌더링하는 컴포넌트예요! |
||
onItemClick, | ||
}: { | ||
onItemClick?: (item: string) => void; | ||
}) => { | ||
const isAuthenticated = checkAuthentication(); | ||
|
||
const { data: keywords } = useRecentSearchListQuery({ | ||
enabled: isAuthenticated, | ||
}); | ||
|
||
return <RecentSearchList keywords={keywords} onClick={onItemClick} />; | ||
}; | ||
|
||
const ContentsSkelton = () => { | ||
return ( | ||
<> | ||
<RecentSearchSkeleton /> | ||
<RecentSearchListSkeleton /> | ||
<BestSellersSkeleton /> | ||
</> | ||
); | ||
}; | ||
|
||
export default BookSearch; | ||
export default BookSearchPage; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,6 +24,7 @@ const useBookSearchQuery = ({ | |
}, | ||
staleTime: 3000, | ||
enabled: !!query, | ||
suspense: true, | ||
} | ||
); | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ComponentPropsWithoutRef<typeof Image>, 'src'> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. src가 전달되지않으면 기본 BookCover 레이아웃을 렌더링해요. |
||
> & { | ||
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%' }, | ||
}; | ||
} | ||
Comment on lines
+62
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 책 이미지가 5:7 비율로 컨테이너 width에 가득찰 수 있도록 fill size prop을 추가했어요. |
||
}; | ||
|
||
const BookCover = ({ src, title, size = 'medium' }: BookCoverProps) => { | ||
const [isError, setIsError] = useState(false); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이미지 요청에 실패한 경우, default cover를 보여줄 수 있도록 isError 상태를 추가했어요. |
||
const { sizeClasses, sizeProps } = getCoverSize(size); | ||
const defaultCoverClass = src ? '' : 'shadow-bookcover'; | ||
|
||
return ( | ||
<div className={`relative flex-shrink-0 ${sizeClasses}`}> | ||
<Image | ||
src={src} | ||
alt={title || 'book-cover'} | ||
placeholder="blur" | ||
blurDataURL={DATA_URL['placeholder']} | ||
className={`object-fit h-full w-full rounded-[0.5rem] shadow-bookcover`} | ||
{...sizeProps} | ||
/> | ||
<div | ||
className={`relative flex-shrink-0 ${sizeClasses} rounded-[0.5rem] bg-black-300 ${defaultCoverClass}`} | ||
> | ||
{src && !isError ? ( | ||
<Image | ||
unoptimized | ||
src={src} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BookCover 컴포넌트가 무한스크롤 기능이 들어간 페이지에서 많이 사용되고 있어요. next/Image는 이미지 최적화 옵션을 통해 이미지 캐싱을 제공하고 있는데, vercel에서 제공하는 캐싱 카운트가 한도량을 초과하여 이미지를 받아올 수 없는 상황이 생겼어요. 그래서 BookCover 컴포넌트에서는 next/Image의 이미지 최적화 기능을 사용하지 않도록 수정했어요. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. comment; 갑각스럽게 해당 이슈로 이미지 최적화와 구현된 몇몇 기능들이 동작하지 않는게 되게 아쉽네요 😢 우선적 목표인 v1이 구현되기전까지 해당 방법으로 vercel측에서 제공하는 이미지 캐싱 카운트를 컨트롤하는 방법도 좋아 보여요! v1 이후에 최적화 이슈를 적극적으로 대응해봐도 좋을 것 같네요! 😄 |
||
alt={title || 'book-cover'} | ||
placeholder="blur" | ||
blurDataURL={DATA_URL['placeholder']} | ||
className={`object-fit h-full w-full rounded-[0.5rem] shadow-bookcover`} | ||
onError={() => setIsError(true)} | ||
{...sizeProps} | ||
/> | ||
) : ( | ||
/** default cover line */ | ||
<div className="absolute left-[5%] h-full w-[0.3rem] bg-black-400"></div> | ||
)} | ||
</div> | ||
); | ||
}; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
검색어가 없어질 때 마다 최근검색어, 베스트셀러 api를 다시 요청하는 문제를 해결했어요. 초기 렌더링 이후 검색어가 없을 때는
display: none
속성이 적용되도록 수정했어요.