Skip to content

Commit

Permalink
[#526] 도서 검색 페이지 개선 (#528)
Browse files Browse the repository at this point in the history
* refactor: 도서 검색 페이지에서 추출 가능한 컴포넌트 분리

* style: 공통 layout에서 padding-bottom 줄임

* feat: 검색 중 로딩, 검색 결과 없는 경우 메세지 노출

* chore: 도서 검색 페이지 주석 추가

* feat: BookCover 컴포넌트 fill size 추가

* feat: 도서검색 결과 UI 수정

* style: 도서검색 페이지 gap 수정

* fix: recent search 스토리북 build 오류 해결

* fix: 도서 검색 결과 개수 보여주는 UI 추가

- 도서 검색 관련 컴포넌트명 개선

* fix: BookCover 에러시 default 커버 렌더링

* fix: 최근검색어와 베스트셀러 불필요한 요청 가지않도록 개선
  • Loading branch information
gxxrxn authored Apr 22, 2024
1 parent 2354183 commit a6ed111
Show file tree
Hide file tree
Showing 12 changed files with 228 additions and 172 deletions.
124 changes: 78 additions & 46 deletions src/app/book/search/page.tsx
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>
</section>
)}
</article>
</>
);
};

const BookSearchResult = ({ queryKeyword }: { queryKeyword: string }) => {
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) {
Expand All @@ -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 = ({
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;
2 changes: 1 addition & 1 deletion src/app/bookarchive/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import TopHeader from '@/v1/base/TopHeader';

export default function BookArchivePage() {
return (
<div className="flex w-full flex-col gap-[1rem]">
<div className="flex w-full flex-col gap-[1rem] pb-[2rem]">
<TopHeader text="BookArchive" />
{/* TODO: 스켈레톤 컴포넌트로 교체 */}
<Suspense fallback={null}>
Expand Down
1 change: 1 addition & 0 deletions src/queries/book/useBookSearchQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const useBookSearchQuery = ({
},
staleTime: 3000,
enabled: !!query,
suspense: true,
}
);

Expand Down
6 changes: 6 additions & 0 deletions src/queries/book/useRecentSearchesQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ const useRecentSearchesQuery = <TData = APIRecentSearches>(
);

export default useRecentSearchesQuery;

export const useRecentSearchListQuery = ({ enabled }: { enabled: boolean }) =>
useRecentSearchesQuery({
select: ({ bookRecentSearchResponses }) => bookRecentSearchResponses,
enabled,
});
6 changes: 3 additions & 3 deletions src/stories/bookSearch/RecentSearch.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RecentSearch> = {
title: 'bookSearch/RecentSearch',
Expand All @@ -13,14 +13,14 @@ type Story = StoryObj<typeof RecentSearch>;

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' },
Expand Down
12 changes: 6 additions & 6 deletions src/stories/bookSearch/SearchResult.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof SearchResult> = {
title: 'bookSearch/SearchResult',
component: SearchResult,
const meta: Meta<typeof BookSearchList> = {
title: 'bookSearch/BookSearchList',
component: BookSearchList,
tags: ['autodocs'],
};

export default meta;

type Story = StoryObj<typeof SearchResult>;
type Story = StoryObj<typeof BookSearchList>;

const SEARCHED_BOOK: APISearchedBook = {
title: '리팩터링',
Expand All @@ -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],
},
};
9 changes: 5 additions & 4 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,8 +48,4 @@
.w-app {
@apply relative -left-[2rem] w-[calc(100%+4rem)];
}
.h-app {
height: 100vh;
height: 100dvh;
}
}
44 changes: 32 additions & 12 deletions src/v1/book/BookCover.tsx
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';
Expand All @@ -9,9 +11,10 @@ type BookCoverSize =
| 'medium'
| 'large'
| 'xlarge'
| '2xlarge';
| '2xlarge'
| 'fill';

type BookCoverProps = Required<
type BookCoverProps = Partial<
Pick<ComponentPropsWithoutRef<typeof Image>, 'src'>
> & {
title?: string;
Expand Down Expand Up @@ -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 (
<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}
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>
);
};
Expand Down
Loading

0 comments on commit a6ed111

Please sign in to comment.