From 49bd7773150b5206700a321b47acbe7ca91d4504 Mon Sep 17 00:00:00 2001 From: SeoHyun Kim Date: Sun, 22 Sep 2024 16:06:45 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EA=B2=80=EC=83=89=20=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 이전으로 버튼 라우팅 오류 수정 * feat: 실시간 검색어 랜덤 다섯개 선정 * feat: 검색 결과 공공 api 연결 * fix: 결과 렌더링 return문 빠진 오류 수정 * feat: 무한 스크롤 구현 - hook 작성 * feat: 무한 스크롤 로딩처리 * feat: 연관 검색어 로직 * refactor: SearchPage useRef 사용 및 리팩토링 * refactor: SearchResultPage 리팩토링 * feat: 연관 검색어 누르면 검색 결과 이동 * feat: 검색 결과 없음 뷰 구현 * feat: 좋아요 누름 구현 * fix: 이전으로 가기 관련 오류 수정 * feat: 연관 검색어 키워드 색 다르게 처리 * feat: 최근 검색어 setStorage 조건 추가 * feat: 공공 api 호출 IOS -> ETC * feat: placecard background linear gradient 추가 * feat: 장소 이름 말줄임표 적용 * feat: 검색 결과 없을시 인기 검색어 없애기 * feat: contentTypeId 추가 * chore: stylelint fix --- package.json | 1 + pnpm-lock.yaml | 3 + src/api/public/main.ts | 0 src/{api => apis}/index.ts | 4 + src/apis/public/search.ts | 33 +++++ src/assets/icon/icon-heart-fill-mono.svg | 3 + src/assets/icon/icon_big_info.svg | 11 ++ src/assets/icon/index.ts | 2 + src/components/PlaceCard.tsx | 68 +++++++-- src/hooks/use-async-effect.ts | 8 ++ src/hooks/use-infinite-scroll.ts | 46 +++++++ src/types/public.ts | 3 + src/types/search.ts | 39 ++++++ src/utils/storageSearchWord.ts | 1 + .../Search/components/RelatedWordList.tsx | 56 -------- .../Search/components/Result/SearchResult.tsx | 129 ++++++++++++++---- .../components/Search/PopularSearch.tsx | 30 ++-- src/views/Search/components/SearchBar.tsx | 86 ------------ .../components/SearchBar/RelatedWordList.tsx | 89 ++++++++++++ .../Search/components/SearchBar/SearchBar.tsx | 122 +++++++++++++++++ .../SearchBar/SearchBarContainer.tsx | 61 +++++++++ .../hooks/use-debounce-get-word-list.ts | 22 +++ src/views/Search/pages/SearchPage.tsx | 24 +--- src/views/Search/pages/SearchResultPage.tsx | 105 +++++++++----- 24 files changed, 704 insertions(+), 242 deletions(-) create mode 100644 src/api/public/main.ts rename src/{api => apis}/index.ts (56%) create mode 100644 src/apis/public/search.ts create mode 100644 src/assets/icon/icon-heart-fill-mono.svg create mode 100644 src/assets/icon/icon_big_info.svg create mode 100644 src/hooks/use-async-effect.ts create mode 100644 src/hooks/use-infinite-scroll.ts create mode 100644 src/types/public.ts create mode 100644 src/types/search.ts delete mode 100644 src/views/Search/components/RelatedWordList.tsx delete mode 100644 src/views/Search/components/SearchBar.tsx create mode 100644 src/views/Search/components/SearchBar/RelatedWordList.tsx create mode 100644 src/views/Search/components/SearchBar/SearchBar.tsx create mode 100644 src/views/Search/components/SearchBar/SearchBarContainer.tsx create mode 100644 src/views/Search/hooks/use-debounce-get-word-list.ts diff --git a/package.json b/package.json index e3e581f..4859c4d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@supabase/supabase-js": "^2.45.2", "axios": "^1.7.2", + "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b14f32b..2b3f22e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: axios: specifier: ^1.7.2 version: 1.7.3 + lodash: + specifier: ^4.17.21 + version: 4.17.21 react: specifier: ^18.3.1 version: 18.3.1 diff --git a/src/api/public/main.ts b/src/api/public/main.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/api/index.ts b/src/apis/index.ts similarity index 56% rename from src/api/index.ts rename to src/apis/index.ts index fe738f2..d55fc45 100644 --- a/src/api/index.ts +++ b/src/apis/index.ts @@ -5,3 +5,7 @@ const client: AxiosInstance = axios.create({ }); export default client; + +export const publicDataClient: AxiosInstance = axios.create({ + baseURL: import.meta.env.VITE_PUBLIC_DATA_BASE_URL, +}); diff --git a/src/apis/public/search.ts b/src/apis/public/search.ts new file mode 100644 index 0000000..d64d6b1 --- /dev/null +++ b/src/apis/public/search.ts @@ -0,0 +1,33 @@ +// 검색 관련 공공 데이터 API + +import { Response } from '@/types/public'; +import { SearchWord } from '@/types/search'; + +import { publicDataClient } from '..'; + +interface searchKeywordParams { + pageNo: number; + numOfRows: number; + MobileOS: 'IOS' | 'AND' | 'WIN' | 'ETC'; + keyword: string; + contentTypeId: number; +} + +export const getSearchKeyword = async (paramsInfo: searchKeywordParams) => { + let params = `MobileApp=UNITRIP&_type=json&arrange=O&serviceKey=${import.meta.env.VITE_PUBLIC_DATA_SERVICE_KEY}`; + + for (const [key, value] of Object.entries(paramsInfo)) { + params += `&${key}=${value}`; + } + + const { + data: { + response: { + body: { items }, + }, + }, + } = await publicDataClient.get>( + `/searchKeyword1?${params}`, + ); + return items; +}; diff --git a/src/assets/icon/icon-heart-fill-mono.svg b/src/assets/icon/icon-heart-fill-mono.svg new file mode 100644 index 0000000..b4aaabb --- /dev/null +++ b/src/assets/icon/icon-heart-fill-mono.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/icon_big_info.svg b/src/assets/icon/icon_big_info.svg new file mode 100644 index 0000000..2a3a035 --- /dev/null +++ b/src/assets/icon/icon_big_info.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icon/index.ts b/src/assets/icon/index.ts index db13a4c..ae5d419 100644 --- a/src/assets/icon/index.ts +++ b/src/assets/icon/index.ts @@ -47,6 +47,8 @@ export { default as HearingTypeIcon } from './icon_hearing_type.svg?react'; export { default as InfantTypeIcon } from './icon_infant_type.svg?react'; export { default as NoneTypeIcon } from './icon_none_type.svg?react'; export { default as PhysicalTypeIcon } from './icon_physical_type.svg?react'; +export { default as BigInfoIcon } from './icon_big_info.svg?react'; +export { default as HeartFillMonoIcon } from './icon-heart-fill-mono.svg?react'; // Universal icon export { default as AudioGuideActiveIcon } from './icon_audioguide_active.svg?react'; diff --git a/src/components/PlaceCard.tsx b/src/components/PlaceCard.tsx index 85e0160..18ae4da 100644 --- a/src/components/PlaceCard.tsx +++ b/src/components/PlaceCard.tsx @@ -1,37 +1,58 @@ import { css } from '@emotion/react'; +import { useState } from 'react'; import { Link } from 'react-router-dom'; -import { HeartMonoIcon, PinLocationMonoIcon } from '@/assets/icon'; +import { + HeartFillMonoIcon, + HeartMonoIcon, + PinLocationMonoIcon, +} from '@/assets/icon'; import { COLORS, FONTS } from '@/styles/constants'; interface PlaceCardProps { placeName: string; address: string; + imgSrc: string; + onClickHeart?: () => void; } /** * @param placeName 장소 이름 * @param address 주소 + * @param imgSrc 대표 사진 + * @param onClickHeart 하트 눌렀을 때 실행 함수 */ const PlaceCard = (props: PlaceCardProps) => { - const { placeName, address } = props; + const { placeName, address, imgSrc, onClickHeart = () => {} } = props; + + const [isHeart, setIsHeart] = useState(false); + + const handleOnClick = () => { + setIsHeart((prev) => !prev); + onClickHeart(); + }; + return ( - - -

{placeName}

-

- {address} -

+ +
+ +

{placeName}

+ {address && ( +

+ {address} +

+ )} +
); }; export default PlaceCard; -const cardContainerCss = css` +const cardContainerCss = (imgSrc: string, placeName: string) => css` display: flex; flex-direction: column; position: relative; @@ -40,16 +61,39 @@ const cardContainerCss = css` height: 16.8rem; border-radius: 1.2rem; - background-color: gray; + background-image: url(${imgSrc}); + background-size: cover; + background-position: center center; + background-color: ${placeName ? COLORS.gray4 : COLORS.gray2}; +`; + +const backgroundCss = css` + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: 16.8rem; + border-radius: 1.2rem; color: ${COLORS.white}; + + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.34) 100% + ); `; const titleCss = css` margin: 9.4rem 0 0 1.6rem; ${FONTS.H3}; + width: calc(100% - 1.6rem); text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; `; const addressCss = css` diff --git a/src/hooks/use-async-effect.ts b/src/hooks/use-async-effect.ts new file mode 100644 index 0000000..6589974 --- /dev/null +++ b/src/hooks/use-async-effect.ts @@ -0,0 +1,8 @@ +import { useEffect } from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const useAsyncEffect = (effect: () => Promise, deps: any[]) => { + useEffect(() => { + effect(); + }, deps); +}; diff --git a/src/hooks/use-infinite-scroll.ts b/src/hooks/use-infinite-scroll.ts new file mode 100644 index 0000000..335de01 --- /dev/null +++ b/src/hooks/use-infinite-scroll.ts @@ -0,0 +1,46 @@ +import { MutableRefObject, useEffect, useRef } from 'react'; + +interface InfiniteScrollParams { + options?: { root: Element | null; rootMargin: string; threshold: number }; + handleObserver: ( + observer: IntersectionObserver, + target: MutableRefObject, + page: MutableRefObject, + ) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + deps: any[]; +} + +/** + * @param options IntersectionObserver options + * @param handleObserver target 감지시 실행함수 + * @param deps useEffect 의존성 배열 + */ + +export const useInfiniteScroll = ({ + options, + handleObserver, + deps, +}: InfiniteScrollParams) => { + const target = useRef(null); + const page = useRef(1); + + useEffect(() => { + page.current = 1; + + const observer = new IntersectionObserver((entries, observer) => { + if (entries[0].intersectionRatio <= 0) return; + + handleObserver(observer, target, page); + }, options); + + //주시 시작 + target.current && observer.observe(target.current); + + return () => { + target.current && observer.unobserve(target.current); + }; + }, deps); + + return target; +}; diff --git a/src/types/public.ts b/src/types/public.ts new file mode 100644 index 0000000..73e5308 --- /dev/null +++ b/src/types/public.ts @@ -0,0 +1,3 @@ +export interface Response { + response: T; +} diff --git a/src/types/search.ts b/src/types/search.ts new file mode 100644 index 0000000..c2f3f9e --- /dev/null +++ b/src/types/search.ts @@ -0,0 +1,39 @@ +export interface SearchResItem { + cat2: string; + cat3: string; + tel: string; + modifiedtime: string; + sigungucode: string; + contentid: string; + mlevel: string; + title: string; + addr1: string; + addr2: string; + areacode: string; + booktour: string; + cat1: string; + firstimage2: string; + mapx: string; + mapy: string; + cpyrhtDivCd: string; + contenttypeid: string; + createdtime: string; + firstimage: string; +} + +export interface SearchWord { + header: { + resultCode: string; + resultMsg: string; + }; + body: { + numOfRows: number; + pageNo: number; + totalCount: number; + items: + | { + item: SearchResItem[]; + } + | ''; + }; +} diff --git a/src/utils/storageSearchWord.ts b/src/utils/storageSearchWord.ts index 61e01c2..3491ac2 100644 --- a/src/utils/storageSearchWord.ts +++ b/src/utils/storageSearchWord.ts @@ -8,6 +8,7 @@ export const getStorageSearchWord = (): string[] => { }; export const setStorageSearchWord = (newValue: string) => { + if (newValue === '') return; const previousList: string[] = JSON.parse(localStorage.getItem(key) || '[]'); // 이미 존재하는지 확인 diff --git a/src/views/Search/components/RelatedWordList.tsx b/src/views/Search/components/RelatedWordList.tsx deleted file mode 100644 index 08f46a7..0000000 --- a/src/views/Search/components/RelatedWordList.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { css } from '@emotion/react'; - -import { SearchMonoIcon } from '@/assets/icon'; -import { COLORS, FONTS } from '@/styles/constants'; - -interface RelatedWordListProps { - searchWord: string; -} - -const RelatedWordList = (props: RelatedWordListProps) => { - const { searchWord } = props; - console.log(searchWord); - - return ( -
    -
  • - -
  • -
  • - -
  • -
- ); -}; - -export default RelatedWordList; - -const containerCss = css` - display: flex; - flex-direction: column; - - margin-top: 2.4rem; -`; - -const wordCss = css` - display: flex; - gap: 2.2rem; - align-items: center; - - height: 5.9rem; - margin-left: 2.4rem; -`; - -const wordTextCss = css` - padding-top: 0.2rem; - - color: ${COLORS.brand1}; - - ${FONTS.Body3}; -`; diff --git a/src/views/Search/components/Result/SearchResult.tsx b/src/views/Search/components/Result/SearchResult.tsx index 51fcc93..f463b94 100644 --- a/src/views/Search/components/Result/SearchResult.tsx +++ b/src/views/Search/components/Result/SearchResult.tsx @@ -1,47 +1,118 @@ import { css } from '@emotion/react'; +import { MutableRefObject } from 'react'; +import { BigInfoIcon } from '@/assets/icon'; import PlaceCard from '@/components/PlaceCard'; +import { COLORS, FONTS } from '@/styles/constants'; +import { SearchResItem } from '@/types/search'; + +interface SearchResultProps { + placeList: SearchResItem[]; + targetElement: MutableRefObject; + loading: boolean; +} + +const SearchResult = (props: SearchResultProps) => { + const { placeList, targetElement, loading } = props; + console.log(loading); + + const renderPlaceList = () => { + if (placeList.length === 0) { + return ( + <> +
+ +
검색 결과가 없어요
+

+ 검색 필터를 바꾸거나 +
+ 다른 여행지를 검색해보세요! +

+
+ + ); + } else { + return placeList.map( + ({ contentid, title, addr1, addr2, firstimage, firstimage2 }) => { + return ( +
  • + {}} + /> +
  • + ); + }, + ); + } + }; -const SearchResult = () => { return ( -
      -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    + <> +
      + {renderPlaceList()} +
      + + {placeList.length >= 10 && ( + <> +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • + + )} +
    + ); }; export default SearchResult; -const containerCss = css` +const containerCss = (placeLength: number) => css` display: flex; gap: 1.2rem; flex-direction: column; - height: calc(100vh - 11rem); + height: ${placeLength > 0 ? 'calc(100vh - 11rem)' : 'fit-content'}; overflow-y: scroll; padding: 1.6rem 2rem 0; `; + +const lastTargetCss = css` + width: 100%; + height: 1px; +`; + +const noResultContainerCss = css` + display: flex; + align-items: center; + flex-direction: column; + + margin: 6rem 0 1.2rem; +`; + +const noResultTitleCss = css` + margin: 2rem 0 0.8rem; + + color: ${COLORS.gray9}; + text-align: center; + + ${FONTS.Body2}; +`; + +const noResultInfoCss = css` + color: ${COLORS.brand1}; + text-align: center; + ${FONTS.Small1}; +`; diff --git a/src/views/Search/components/Search/PopularSearch.tsx b/src/views/Search/components/Search/PopularSearch.tsx index f2b7416..8ff23a1 100644 --- a/src/views/Search/components/Search/PopularSearch.tsx +++ b/src/views/Search/components/Search/PopularSearch.tsx @@ -1,24 +1,36 @@ import { css } from '@emotion/react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { COLORS, FONTS } from '@/styles/constants'; import { setStorageSearchWord } from '@/utils/storageSearchWord'; +const WORD_LIST_DATA = [ + '비대면 관광', + '대전시립미술관', + '대전 휴양림', + '장미꽃 명소', + '가족과 함께', + '미술관', + '수목원', + '음악 분수', +]; + +const pickRandomWord = (array: string[]) => { + return array.sort(() => Math.random() - 0.5).slice(0, 5); +}; + const PopularSearch = () => { const navigate = useNavigate(); + const { pathname } = useLocation(); const handleOnClick = (searchWord: string) => { setStorageSearchWord(searchWord); - navigate(searchWord); + navigate(`/search/${searchWord}`, { + replace: pathname.startsWith('/search/'), + }); }; - const wordList = [ - '비대면 관광', - '대전시립미술관', - '대전 휴양림', - '장미꽃 명소', - '가족과 함께', - ].map((item, idx) => { + const wordList = pickRandomWord(WORD_LIST_DATA).map((item, idx) => { return (
  • - - {searchWord && ( - - )} - - ); -}; - -export default SearchBar; - -const containerCss = css` - display: flex; - justify-content: space-between; - position: relative; - - width: 100%; - padding: 0.8rem 2rem 0; -`; - -const inputCss = css` - width: 100%; - padding: 1.2rem 1.6rem; - margin-left: 1.2rem; - border: 1px solid ${COLORS.brand1}; - border-radius: 99.9rem; - - color: ${COLORS.gray9}; - ${FONTS.Body2}; - - &::placeholder { - color: ${COLORS.gray4}; - ${FONTS.Body2}; - } -`; - -const deleteButtonCss = css` - position: absolute; - top: 2.1rem; - right: 3.6rem; -`; diff --git a/src/views/Search/components/SearchBar/RelatedWordList.tsx b/src/views/Search/components/SearchBar/RelatedWordList.tsx new file mode 100644 index 0000000..4ee6965 --- /dev/null +++ b/src/views/Search/components/SearchBar/RelatedWordList.tsx @@ -0,0 +1,89 @@ +import { css } from '@emotion/react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { SearchMonoIcon } from '@/assets/icon'; +import { COLORS, FONTS } from '@/styles/constants'; +import { SearchResItem } from '@/types/search'; + +interface RelatedWordListProps { + searchWord: string; + relatedWordList: SearchResItem[]; + handleSearchInputValue: (value: string) => void; +} + +const RelatedWordList = (props: RelatedWordListProps) => { + const { searchWord, relatedWordList, handleSearchInputValue } = props; + + const navigate = useNavigate(); + const { pathname } = useLocation(); + + const handleOnClick = (title: string) => { + handleSearchInputValue(title); + navigate(`/search/${title}`, { replace: pathname.startsWith('/search/') }); + }; + + const renderRelatedWordList = () => { + return relatedWordList?.map(({ title, contentid }) => { + const searchWordIndex = title.indexOf(searchWord); + const beforeSearchWord = title.slice(0, searchWordIndex); + const afterSearchWord = title.slice(searchWordIndex + searchWord.length); + + return ( +
  • + +
  • + ); + }); + }; + + return
      {renderRelatedWordList()}
    ; +}; + +export default RelatedWordList; + +const containerCss = css` + display: flex; + flex-direction: column; + position: absolute; + z-index: 1000; + + width: 100vw; + + background-color: white; + min-height: 100vh; +`; + +const wordCss = css` + display: flex; + gap: 2.2rem; + align-items: center; + + height: 5.9rem; + margin-left: 2.4rem; +`; + +const wordTextCss = css` + overflow: hidden; + + width: calc(100vw - 24px - 6rem); + padding-top: 0.2rem; + + color: ${COLORS.gray5}; + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; + + ${FONTS.Body5}; +`; + +const keywordCss = css` + color: ${COLORS.brand1}; + ${FONTS.Body3}; +`; diff --git a/src/views/Search/components/SearchBar/SearchBar.tsx b/src/views/Search/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..c350938 --- /dev/null +++ b/src/views/Search/components/SearchBar/SearchBar.tsx @@ -0,0 +1,122 @@ +import { css } from '@emotion/react'; +import { DebouncedFunc } from 'lodash'; +import { ChangeEvent, KeyboardEvent, RefObject, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { ChevronLeftIcon, ResetXIcon } from '@/assets/icon'; +import { COLORS, FONTS } from '@/styles/constants'; +import { setStorageSearchWord } from '@/utils/storageSearchWord'; + +interface SearchBarProps { + searchInputRef: RefObject; + debounceGetWordList: DebouncedFunc<(searchWord: string) => Promise>; + resetRelatedWordList: () => void; + initialWord?: string; + handleSearchInputValue: (value: string) => void; +} + +const SearchBar = (props: SearchBarProps) => { + const { + searchInputRef, + debounceGetWordList, + resetRelatedWordList, + initialWord, + handleSearchInputValue, + } = props; + + const navigate = useNavigate(); + const { pathname } = useLocation(); + + const [showResetButton, setShowResetButton] = useState( + !!searchInputRef.current?.value || !!initialWord, + ); + + const handleOnClickPrevButton = () => { + navigate(-1); + resetRelatedWordList(); + }; + + const handleOnChange = (e: ChangeEvent) => { + const { value } = e.currentTarget; + + if (!value || initialWord === value) { + resetRelatedWordList(); + setShowResetButton(false); + return; + } + + debounceGetWordList(value); + setShowResetButton(true); + }; + + const handleOnClick = () => { + setShowResetButton(false); + resetRelatedWordList(); + handleSearchInputValue(''); + }; + + const handleOnKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && searchInputRef.current) { + const { value } = searchInputRef.current; + setStorageSearchWord(value); + resetRelatedWordList(); + navigate(`/search/${value}`, { + replace: pathname.startsWith('/search/'), + }); + } + }; + + return ( +
    + + + {showResetButton && ( + + )} +
    + ); +}; + +export default SearchBar; + +const containerCss = css` + display: flex; + justify-content: space-between; + position: relative; + + width: 100%; + padding: 0.8rem 2rem 0; +`; + +const inputCss = css` + width: 100%; + padding: 1.2rem 1.6rem; + margin-left: 1.2rem; + border: 1px solid ${COLORS.brand1}; + border-radius: 99.9rem; + + color: ${COLORS.gray9}; + ${FONTS.Body2}; + + &::placeholder { + color: ${COLORS.gray4}; + ${FONTS.Body2}; + } +`; + +const deleteButtonCss = css` + position: absolute; + top: 2.1rem; + right: 3.6rem; +`; diff --git a/src/views/Search/components/SearchBar/SearchBarContainer.tsx b/src/views/Search/components/SearchBar/SearchBarContainer.tsx new file mode 100644 index 0000000..e5a4c91 --- /dev/null +++ b/src/views/Search/components/SearchBar/SearchBarContainer.tsx @@ -0,0 +1,61 @@ +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; + +import { SearchResItem } from '@/types/search'; + +import { useDebounceGetWordList } from '../../hooks/use-debounce-get-word-list'; +import RelatedWordList from './RelatedWordList'; +import SearchBar from './SearchBar'; + +interface SearchBarContainerProps { + children: ReactNode; + initialWord?: string; +} + +const SearchBarContainer = (props: SearchBarContainerProps) => { + const { children, initialWord } = props; + + const searchInputRef = useRef(null); + + const [relatedWordList, setRelatedWordList] = useState([]); + + const debounceGetWordList = useDebounceGetWordList(setRelatedWordList); + + const handleSearchInputValue = (value: string) => { + if (!searchInputRef.current) return; + searchInputRef.current.value = value; + }; + + const resetRelatedWordList = useCallback(() => { + setRelatedWordList([]); + }, []); + + useEffect(() => { + if (!searchInputRef.current || !initialWord) return; + searchInputRef.current.value = initialWord; + }, [initialWord]); + + return ( + <> + + + {initialWord !== searchInputRef.current?.value && + searchInputRef.current?.value && ( + + )} + {!initialWord && relatedWordList.length === 0 && children} + {initialWord && children} + + ); +}; + +export default SearchBarContainer; diff --git a/src/views/Search/hooks/use-debounce-get-word-list.ts b/src/views/Search/hooks/use-debounce-get-word-list.ts new file mode 100644 index 0000000..301fbc1 --- /dev/null +++ b/src/views/Search/hooks/use-debounce-get-word-list.ts @@ -0,0 +1,22 @@ +import { debounce } from 'lodash'; +import { Dispatch, SetStateAction } from 'react'; + +import { getSearchKeyword } from '@/apis/public/search'; +import { SearchResItem } from '@/types/search'; + +export const useDebounceGetWordList = ( + setRelatedWordList: Dispatch>, +) => + debounce(async (searchWord: string) => { + const wordList = await getSearchKeyword({ + pageNo: 1, + numOfRows: 20, + MobileOS: 'ETC', + keyword: searchWord, + contentTypeId: 12, + }); + + if (typeof wordList === 'object') { + setRelatedWordList(wordList.item); + } else setRelatedWordList([]); + }, 600); diff --git a/src/views/Search/pages/SearchPage.tsx b/src/views/Search/pages/SearchPage.tsx index 353b834..44f2bbf 100644 --- a/src/views/Search/pages/SearchPage.tsx +++ b/src/views/Search/pages/SearchPage.tsx @@ -1,30 +1,16 @@ -import { useState } from 'react'; - import MenuBar from '@/components/MenuBar'; -import RelatedWordList from '../components/RelatedWordList'; import PopularSearch from '../components/Search/PopularSearch'; import RecentSearch from '../components/Search/RecentSearch'; -import SearchBar from '../components/SearchBar'; +import SearchBarContainer from '../components/SearchBar/SearchBarContainer'; const SearchPage = () => { - const [searchWord, setSearchWord] = useState(''); - - const handleSearchWord = (word: string) => { - setSearchWord(word); - }; - return ( <> - - {searchWord ? ( - - ) : ( - <> - - - - )} + + + + ); diff --git a/src/views/Search/pages/SearchResultPage.tsx b/src/views/Search/pages/SearchResultPage.tsx index c661954..1a80f2c 100644 --- a/src/views/Search/pages/SearchResultPage.tsx +++ b/src/views/Search/pages/SearchResultPage.tsx @@ -1,17 +1,19 @@ import { css } from '@emotion/react'; -import { useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { MutableRefObject, useEffect, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import { getSearchKeyword } from '@/apis/public/search'; import { SearchSetIcon } from '@/assets/icon'; import MenuBar from '@/components/MenuBar'; +import { useInfiniteScroll } from '@/hooks/use-infinite-scroll'; import { COLORS, FONTS } from '@/styles/constants'; +import { SearchResItem } from '@/types/search'; import { isGuideShown } from '@/utils/storageHideGuide'; -import RelatedWordList from '../components/RelatedWordList'; import Guide from '../components/Result/Guide'; import SearchResult from '../components/Result/SearchResult'; import FilterBottomSheet from '../components/Search/FilterBottomSheet'; -import SearchBar from '../components/SearchBar'; +import SearchBarContainer from '../components/SearchBar/SearchBarContainer'; import { createInitialFilterState, MAP_CATEGORY_FACILITIES, @@ -21,20 +23,66 @@ import { category } from '../types/category'; const SearchResultPage = () => { const { word: initialWord } = useParams(); - const [searchWord, setSearchWord] = useState(initialWord || ''); + const { pathname } = useLocation(); const [filterState, setFilterState] = useState(() => createInitialFilterState(), ); // modal, bottom sheet state + const [placeList, setPlaceList] = useState([]); const [showGuide, setShowGuide] = useState(() => isGuideShown()); const [isFilterOpen, setIsFilterOpen] = useState(false); // state handling func - const handleSearchWord = (word: string) => { - setSearchWord(word); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setPlaceList([]); + }, [pathname]); + + // 무한스크롤 + const options = { + root: null, + rootMargin: '0px', + threshold: 0, + }; + + const handleObserver = async ( + observer: IntersectionObserver, + target: MutableRefObject, + page: MutableRefObject, + ) => { + setLoading(true); + const pageNo = page.current; + + try { + const items = await getSearchKeyword({ + pageNo, + numOfRows: 10, + MobileOS: 'ETC', + keyword: pathname.split('/')[2], + contentTypeId: 12, + }); + + if (items === '') { + if (pageNo === 0) setPlaceList([]); + target.current && observer.unobserve(target.current); + } else { + setPlaceList((prev) => [...prev, ...items.item]); + page.current++; + } + } finally { + setLoading(false); + } }; + const targetElement = useInfiniteScroll({ + options, + handleObserver, + deps: [pathname], + }); + + // 검색 가이드 const handleSetShowGuide = (value: boolean) => { setShowGuide(value); }; @@ -72,30 +120,21 @@ const SearchResultPage = () => { }; return ( - <> -
    - + + + - {searchWord !== '' && searchWord !== initialWord ? ( - - ) : ( - <> - - - - )} - - {showGuide && } - -
    + + + {showGuide && } + {isFilterOpen && ( { handleFilterState={handleFilterState} /> )} - + ); }; export default SearchResultPage; +const containerCss = css` + position: relative; +`; + const buttonCss = css` display: flex; gap: 1.2rem;