diff --git a/src/Router.tsx b/src/Router.tsx index 4fb79e6..a65001f 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -5,6 +5,8 @@ import DetailPage from './views/Detail/pages/DetailPage'; import LoginCallBack from './views/Login/components/LoginCallBack'; import MainPage from './views/Main/pages/MainPage'; import Mypage from './views/Mypage/pages/Mypage'; +import SearchPage from './views/Search/pages/SearchPage'; +import SearchResultPage from './views/Search/pages/SearchResultPage'; const router = createBrowserRouter([ { @@ -16,6 +18,15 @@ const router = createBrowserRouter([ ], }, { path: '/detail', element: }, + { + path: '/search', + element: , + children: [{}], + }, + { + path: '/search/:word', + element: , + }, { path: '/mypage', element: , diff --git a/src/assets/icon/Chevron_Left.svg b/src/assets/icon/Chevron_Left.svg new file mode 100644 index 0000000..cad1537 --- /dev/null +++ b/src/assets/icon/Chevron_Left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/icon-check-fill.svg b/src/assets/icon/icon-check-fill.svg new file mode 100644 index 0000000..ef7c6ff --- /dev/null +++ b/src/assets/icon/icon-check-fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icon/icon-search-set.svg b/src/assets/icon/icon-search-set.svg new file mode 100644 index 0000000..cfc488b --- /dev/null +++ b/src/assets/icon/icon-search-set.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/icon-x-mono.svg b/src/assets/icon/icon-x-mono.svg index 8a9cc4f..608d35b 100644 --- a/src/assets/icon/icon-x-mono.svg +++ b/src/assets/icon/icon-x-mono.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/icon/index.ts b/src/assets/icon/index.ts index 69d826f..01d5162 100644 --- a/src/assets/icon/index.ts +++ b/src/assets/icon/index.ts @@ -19,7 +19,6 @@ export { default as MapPinIcon } from './icon_map_pin.svg?react'; export { default as StarIcon } from './icon_star_filled.svg?react'; export { default as Star1Icon } from './Star 1.svg?react'; export { default as UnitripIcon } from './UNITRIP.svg?react'; - export { default as ArrowToggleClosed } from './icon_arrow_toggle_closed.svg?react'; export { default as ArrowToggleOpen } from './icon_arrow_toggle_open.svg?react'; export { default as AudioGuideDefaultIcon } from './icon_audio_guide_default.svg?react'; @@ -70,6 +69,11 @@ export { default as VideoGuideSubtitleDefaultIcon } from './icon_vedio_guide_sub export { default as VideoGuideSubtitleNoneIcon } from './icon_video_guide_subtitle_none.svg?react'; export { default as WheelchairTicketOfficeDefaultIcon } from './icon_wheelchair_ticket_office_default.svg?react'; export { default as WheelchairTicketOfficeNoneIcon } from './icon_wheelchair_ticket_office_none.svg?react'; +export { default as ChevronLeftIcon } from './Chevron_Left.svg?react'; +export { default as ToggleXIcon } from './toggle-x.svg?react'; +export { default as ResetXIcon } from './reset-x.svg?react'; +export { default as SearchSetIcon } from './icon-search-set.svg?react'; +export { default as CheckFillIcon } from './icon-check-fill.svg?react'; export { default as ArrowRightIcon } from './icon-arrow-right.svg?react'; export { default as HeaderBackIcon } from './icon_header_back.svg?react'; diff --git a/src/assets/icon/reset-x.svg b/src/assets/icon/reset-x.svg new file mode 100644 index 0000000..a3aef32 --- /dev/null +++ b/src/assets/icon/reset-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icon/toggle-x.svg b/src/assets/icon/toggle-x.svg new file mode 100644 index 0000000..f8a2b7f --- /dev/null +++ b/src/assets/icon/toggle-x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx index 1ece1ac..b164fcd 100644 --- a/src/components/LoginModal.tsx +++ b/src/components/LoginModal.tsx @@ -97,4 +97,6 @@ const closeButtonCss = css` position: absolute; top: 2rem; right: 2rem; + + color: ${COLORS.gray5}; `; diff --git a/src/components/MenuBar.tsx b/src/components/MenuBar.tsx index 55dd616..6bff744 100644 --- a/src/components/MenuBar.tsx +++ b/src/components/MenuBar.tsx @@ -19,13 +19,14 @@ const PATH_MATCH = [ const MenuBar = () => { const { pathname } = useLocation(); + const firstPathname = `/${pathname.split('/')[1]}`; const menuList = PATH_MATCH.map(({ url, name, icon }) => { return ( {icon} {name} diff --git a/src/components/PlaceCard.tsx b/src/components/PlaceCard.tsx new file mode 100644 index 0000000..85e0160 --- /dev/null +++ b/src/components/PlaceCard.tsx @@ -0,0 +1,73 @@ +import { css } from '@emotion/react'; +import { Link } from 'react-router-dom'; + +import { HeartMonoIcon, PinLocationMonoIcon } from '@/assets/icon'; +import { COLORS, FONTS } from '@/styles/constants'; + +interface PlaceCardProps { + placeName: string; + address: string; +} + +/** + * @param placeName 장소 이름 + * @param address 주소 + */ + +const PlaceCard = (props: PlaceCardProps) => { + const { placeName, address } = props; + return ( + + +

{placeName}

+

+ {address} +

+ + ); +}; + +export default PlaceCard; + +const cardContainerCss = css` + display: flex; + flex-direction: column; + position: relative; + + width: 100%; + height: 16.8rem; + border-radius: 1.2rem; + + background-color: gray; + + color: ${COLORS.white}; +`; + +const titleCss = css` + margin: 9.4rem 0 0 1.6rem; + ${FONTS.H3}; + + text-align: left; +`; + +const addressCss = css` + display: flex; + gap: 0.5rem; + align-items: center; + + margin-left: 1.6rem; + + ${FONTS.Small1}; + + & > span { + padding-top: 0.1rem; + } +`; + +const iconCss = css` + position: absolute; + top: 1.6rem; + right: 1.6rem; +`; diff --git a/src/utils/storageHideGuide.ts b/src/utils/storageHideGuide.ts new file mode 100644 index 0000000..0f7a68b --- /dev/null +++ b/src/utils/storageHideGuide.ts @@ -0,0 +1,39 @@ +import { STORAGE_KEY } from '@/views/Search/constants/localStorageKey'; + +const key = STORAGE_KEY.hideSearchGuide; + +const getStorageHideGuide = () => { + const hideGuideTime = localStorage.getItem(key); + return hideGuideTime ? Number(hideGuideTime) : null; +}; + +// 24시간을 ms로 +const EXPIRATION_PERIOD = 24 * 60 * 60 * 1000; +export const setStorageHideGuide = () => { + const date = new Date(); + const expirationTime = date.getTime() + EXPIRATION_PERIOD; + localStorage.setItem(key, String(expirationTime)); +}; + +const removeStorageHideGuide = () => { + localStorage.removeItem(key); +}; + +export const isGuideShown = () => { + const hideGuideTime = getStorageHideGuide(); + const nowDate = new Date(); + + if (hideGuideTime) { + // 만료 X + if (nowDate.getTime() <= hideGuideTime) { + return false; + } + // 만료 O + else { + removeStorageHideGuide(); + return true; + } + } + // 만료 O + else return true; +}; diff --git a/src/utils/storageSearchWord.ts b/src/utils/storageSearchWord.ts new file mode 100644 index 0000000..61e01c2 --- /dev/null +++ b/src/utils/storageSearchWord.ts @@ -0,0 +1,36 @@ +import { STORAGE_KEY } from '@/views/Search/constants/localStorageKey'; + +const key = STORAGE_KEY.recentSearch; +const LIST_MAX_LENGTH = 10; + +export const getStorageSearchWord = (): string[] => { + return JSON.parse(localStorage.getItem(key) || '[]'); +}; + +export const setStorageSearchWord = (newValue: string) => { + const previousList: string[] = JSON.parse(localStorage.getItem(key) || '[]'); + + // 이미 존재하는지 확인 + const previousIndex = previousList.findIndex((item) => item === newValue); + + // 존재하면 삭제 + if (previousIndex !== -1) previousList.splice(previousIndex, 1); + // 존재하지 않으면 max length 확인 + else { + if (previousList.length === LIST_MAX_LENGTH) previousList.pop(); + } + + // 새로운 값 추가 + localStorage.setItem(key, JSON.stringify([newValue, ...previousList])); +}; + +export const removeStorageSearchWord = (newValue: string): string[] => { + const list: string[] = JSON.parse(localStorage.getItem(key) || '[]'); + + // 인덱스 찾아서 제거 + const previousIndex = list.findIndex((item) => item === newValue); + list.splice(previousIndex, 1); + localStorage.setItem(key, JSON.stringify([...list])); + + return list; +}; diff --git a/src/views/Search/components/RelatedWordList.tsx b/src/views/Search/components/RelatedWordList.tsx new file mode 100644 index 0000000..08f46a7 --- /dev/null +++ b/src/views/Search/components/RelatedWordList.tsx @@ -0,0 +1,56 @@ +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/Guide.tsx b/src/views/Search/components/Result/Guide.tsx new file mode 100644 index 0000000..f62ecf4 --- /dev/null +++ b/src/views/Search/components/Result/Guide.tsx @@ -0,0 +1,96 @@ +import { css } from '@emotion/react'; + +import { CheckFillIcon, XMonoIcon } from '@/assets/icon'; +import { COLORS, FONTS } from '@/styles/constants'; +import { setStorageHideGuide } from '@/utils/storageHideGuide'; + +interface GuideProps { + handleSetShowGuide: (value: boolean) => void; +} + +const Guide = (props: GuideProps) => { + const { handleSetShowGuide } = props; + + const hideGuideForADay = () => { + setStorageHideGuide(); + handleSetShowGuide(false); + }; + + return ( +
+
+ +
+
+

+ 내가 설정한 여행자 유형이 +
+ 필터에 반영되었어요! +

+ +
+
+ ); +}; + +export default Guide; + +const containerCss = css` + position: absolute; + top: 0; + left: 0; + z-index: 999; + + width: 100vw; + height: 100vh; +`; + +const section1Css = css` + position: relative; + + height: 6.4rem; + + background-color: rgb(82 82 82 / 72%); +`; + +const section2Css = css` + height: 100%; + margin-top: 4.6rem; + + background-color: rgb(82 82 82 / 72%); +`; + +const buttonCss = css` + position: absolute; + top: 2.5rem; + right: 2.4rem; + + color: ${COLORS.white}; +`; + +const textCss = css` + padding-top: 2rem; + margin-left: 2rem; + + color: ${COLORS.white}; + + ${FONTS.H5}; +`; + +const buttonTextCss = css` + display: flex; + gap: 0.6rem; + + margin: 2.8rem 0 0 2rem; + + color: ${COLORS.white}; + + ${FONTS.Body4}; +`; diff --git a/src/views/Search/components/Result/SearchResult.tsx b/src/views/Search/components/Result/SearchResult.tsx new file mode 100644 index 0000000..51fcc93 --- /dev/null +++ b/src/views/Search/components/Result/SearchResult.tsx @@ -0,0 +1,47 @@ +import { css } from '@emotion/react'; + +import PlaceCard from '@/components/PlaceCard'; + +const SearchResult = () => { + return ( +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ ); +}; + +export default SearchResult; + +const containerCss = css` + display: flex; + gap: 1.2rem; + flex-direction: column; + + height: calc(100vh - 11rem); + overflow-y: scroll; + + padding: 1.6rem 2rem 0; +`; diff --git a/src/views/Search/components/Search/PopularSearch.tsx b/src/views/Search/components/Search/PopularSearch.tsx new file mode 100644 index 0000000..f2b7416 --- /dev/null +++ b/src/views/Search/components/Search/PopularSearch.tsx @@ -0,0 +1,69 @@ +import { css } from '@emotion/react'; +import { useNavigate } from 'react-router-dom'; + +import { COLORS, FONTS } from '@/styles/constants'; +import { setStorageSearchWord } from '@/utils/storageSearchWord'; + +const PopularSearch = () => { + const navigate = useNavigate(); + + const handleOnClick = (searchWord: string) => { + setStorageSearchWord(searchWord); + navigate(searchWord); + }; + + const wordList = [ + '비대면 관광', + '대전시립미술관', + '대전 휴양림', + '장미꽃 명소', + '가족과 함께', + ].map((item, idx) => { + return ( +
  • + +
  • + ); + }); + + return ( +
    +

    지금 가장 인기있는 검색어

    +
      {wordList}
    +
    + ); +}; + +export default PopularSearch; + +const container = css` + margin-top: 3.6rem; +`; + +const title = css` + margin: 0 0 1rem 2rem; + + color: ${COLORS.brand1}; + + ${FONTS.Body2}; +`; + +const word = css` + display: flex; + gap: 2.6rem; + align-items: center; + + height: 4.4rem; + margin-left: 2.5rem; + + color: ${COLORS.gray6}; + + ${FONTS.Body4}; +`; + +const idxCss = css` + width: 0.9rem; +`; diff --git a/src/views/Search/components/Search/RecentSearch.tsx b/src/views/Search/components/Search/RecentSearch.tsx new file mode 100644 index 0000000..8b0f24a --- /dev/null +++ b/src/views/Search/components/Search/RecentSearch.tsx @@ -0,0 +1,103 @@ +import { css } from '@emotion/react'; +import { MouseEvent, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { ToggleXIcon } from '@/assets/icon'; +import { COLORS, FONTS } from '@/styles/constants'; +import { + getStorageSearchWord, + removeStorageSearchWord, + setStorageSearchWord, +} from '@/utils/storageSearchWord'; + +const RecentSearch = () => { + const navigate = useNavigate(); + const savedWordList = getStorageSearchWord(); + const [wordList, setWordList] = useState(savedWordList); + + const handleOnClickWordButton = (word: string) => { + setStorageSearchWord(word); + navigate(word); + }; + + const handleOnClickDeleteButton = ( + e: MouseEvent, + word: string, + ) => { + e.stopPropagation(); + const newList = removeStorageSearchWord(word); + setWordList(newList); + }; + + const wordButtonList = wordList.map((item) => { + return ( +
  • + + +
  • + ); + }); + + return ( + <> +

    최근 검색어

    +
      {wordButtonList}
    + + ); +}; + +export default RecentSearch; + +const title = css` + margin: 2.7rem 0 2rem 2rem; + + color: ${COLORS.brand1}; + + ${FONTS.Body2}; +`; + +const word = css` + display: flex; + gap: 0.5rem; + align-items: center; + + width: max-content; + height: 3.7rem; + padding: 0 0.9rem 0 1.5rem; + border-radius: 4rem; + + background-color: ${COLORS.gray1}; +`; + +const wordText = css` + padding-top: 0.2rem; + + color: ${COLORS.gray6}; + + ${FONTS.Body3} +`; + +const wordContainer = css` + display: flex; + gap: 1rem; + + width: 100vw; + overflow-x: auto; + + & > li:first-child { + margin-left: 2rem; + } + + & > li:last-child { + margin-right: 1rem; + } +`; diff --git a/src/views/Search/components/SearchBar.tsx b/src/views/Search/components/SearchBar.tsx new file mode 100644 index 0000000..3bd7803 --- /dev/null +++ b/src/views/Search/components/SearchBar.tsx @@ -0,0 +1,86 @@ +import { css } from '@emotion/react'; +import { ChangeEvent, KeyboardEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { ChevronLeftIcon, ResetXIcon } from '@/assets/icon'; +import { COLORS, FONTS } from '@/styles/constants'; +import { setStorageSearchWord } from '@/utils/storageSearchWord'; + +interface SearchBarProps { + searchWord: string; + handleSearchWord: (word: string) => void; +} + +const SearchBar = (props: SearchBarProps) => { + const { searchWord, handleSearchWord } = props; + + const navigate = useNavigate(); + + const handleOnChange = (e: ChangeEvent) => { + handleSearchWord(e.currentTarget.value); + }; + + const handleOnClick = () => { + handleSearchWord(''); + }; + + const handleOnKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + setStorageSearchWord(searchWord); + navigate(`/search/${searchWord}`); + } + }; + + 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/constants/localStorageKey.ts b/src/views/Search/constants/localStorageKey.ts new file mode 100644 index 0000000..3947e58 --- /dev/null +++ b/src/views/Search/constants/localStorageKey.ts @@ -0,0 +1,4 @@ +export const STORAGE_KEY = { + recentSearch: 'recent-search', + hideSearchGuide: 'hide-search-guide', +}; diff --git a/src/views/Search/pages/SearchPage.tsx b/src/views/Search/pages/SearchPage.tsx new file mode 100644 index 0000000..353b834 --- /dev/null +++ b/src/views/Search/pages/SearchPage.tsx @@ -0,0 +1,33 @@ +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'; + +const SearchPage = () => { + const [searchWord, setSearchWord] = useState(''); + + const handleSearchWord = (word: string) => { + setSearchWord(word); + }; + + return ( + <> + + {searchWord ? ( + + ) : ( + <> + + + + )} + + + ); +}; + +export default SearchPage; diff --git a/src/views/Search/pages/SearchResultPage.tsx b/src/views/Search/pages/SearchResultPage.tsx new file mode 100644 index 0000000..d6ce5cb --- /dev/null +++ b/src/views/Search/pages/SearchResultPage.tsx @@ -0,0 +1,69 @@ +import { css } from '@emotion/react'; +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { SearchSetIcon } from '@/assets/icon'; +import MenuBar from '@/components/MenuBar'; +import { COLORS, FONTS } from '@/styles/constants'; +import { isGuideShown } from '@/utils/storageHideGuide'; + +import RelatedWordList from '../components/RelatedWordList'; +import Guide from '../components/Result/Guide'; +import SearchResult from '../components/Result/SearchResult'; +import SearchBar from '../components/SearchBar'; + +const SearchResultPage = () => { + const { word: initialWord } = useParams(); + const [searchWord, setSearchWord] = useState(initialWord || ''); + + const [showGuide, setShowGuide] = useState(() => isGuideShown()); + + const handleSearchWord = (word: string) => { + setSearchWord(word); + }; + + const handleSetShowGuide = (value: boolean) => { + setShowGuide(value); + }; + + return ( +
    + + {searchWord !== '' && searchWord !== initialWord ? ( + + ) : ( + <> + + + + )} + + {showGuide && } + +
    + ); +}; + +export default SearchResultPage; + +const buttonCss = css` + display: flex; + gap: 1.2rem; + align-items: center; + + width: 100%; + height: 4.6rem; + padding-left: 2.3rem; + margin-top: 0.8rem; + + background-color: ${COLORS.gray1}; + + color: ${COLORS.brand1}; + + ${FONTS.Body3}; +`;