diff --git a/.eslintrc.json b/.eslintrc.json index 320e2e2a..05eaf54b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,49 +1,42 @@ { - "env": { - "browser": true, - "es6": true - }, - "extends": [ - "airbnb", - "airbnb/hooks", - "airbnb-typescript" - ], - "globals": { - "Atomics": "readonly", - "SharedArrayBuffer": "readonly" - }, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 2018, - "project": "./tsconfig.json" - }, - "plugins": [ - "react", - "@typescript-eslint" - ], - "rules": { - "react/react-in-jsx-scope": "off", - "linebreak-style" : "off", - "react/jsx-props-no-spreading": "off", - "react/require-default-props": "off", - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": ["../../*"], - "message": "Usage of relative parent imports is not allowed." - } - ] - } - ] - }, - "settings": { - "import/resolver": { - "typescript": {} - } - } -} \ No newline at end of file + "env": { + "browser": true, + "es6": true + }, + "extends": ["airbnb", "airbnb/hooks", "airbnb-typescript"], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 2018, + "project": "./tsconfig.json" + }, + "plugins": ["react", "@typescript-eslint"], + "rules": { + "react/react-in-jsx-scope": "off", + "linebreak-style": "off", + "react/jsx-props-no-spreading": "off", + "react/require-default-props": "off", + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["../../*"], + "message": "Usage of relative parent imports is not allowed." + } + ] + } + ] + }, + "settings": { + "import/resolver": { + "typescript": {} + } + } +} diff --git a/.stylelint.json b/.stylelint.json index 08d508b1..33d82a7c 100644 --- a/.stylelint.json +++ b/.stylelint.json @@ -1,12 +1,8 @@ { - "extends": [ - "stylelint-config-standard-scss" - ], - "plugin": [ - "stylelint-selector-bem-pattern" - ], + "extends": ["stylelint-config-standard-scss"], + "plugin": ["stylelint-selector-bem-pattern"], "rules": { "selector-class-pattern": null, "color-hex-length": null } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index b28ae39b..57ce9cf2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import Notice from 'pages/Notice'; import KakaoLogin from 'pages/Auth/OAuth/KakaoLogin'; import NaverLogin from 'pages/Auth/OAuth/NaverLogin'; import GoogleLogin from 'pages/Auth/OAuth/GoogleLogin'; +import NotFoundPage from 'pages/Search/components/NotFoundPage'; import FollowProfile from 'pages/Follow/components/FollowProfile'; export default function App(): JSX.Element { @@ -32,6 +33,7 @@ export default function App(): JSX.Element { }> } /> } /> + } /> } /> } /> } /> diff --git a/src/api/search/entity.ts b/src/api/search/entity.ts new file mode 100644 index 00000000..3c6fc834 --- /dev/null +++ b/src/api/search/entity.ts @@ -0,0 +1,41 @@ +export interface FetchTrendingsResponse { + trendings: string[] +} + +export interface SearchQueryParams { + searchText : string; +} + +export interface ShopsParams { + keyword: string; + location: Coords; +} + +export interface FetchShopsResponse { + content: Shop[]; +} + +interface Shop { + address: string, + dist: 0, + placeId: string, + placeName: string, + score: number, + shopId: number, + x: string, + y: string, +} + +export interface Coords { + latitude: number, + longitude: number +} + +export interface FetchAutoCompleteParams { + query: string; + location?: Coords; +} + +export interface FetchAutoCompleteResponse { + data: string[]; +} diff --git a/src/api/search/index.ts b/src/api/search/index.ts new file mode 100644 index 00000000..f371927f --- /dev/null +++ b/src/api/search/index.ts @@ -0,0 +1,33 @@ +import { + FetchShopsResponse, + FetchTrendingsResponse, + ShopsParams, + FetchAutoCompleteParams, + FetchAutoCompleteResponse, +} from './entity'; +import searchApi from './searchApiClient'; + +export const fetchTrendings = () => searchApi.get('/trending'); + +export const fetchShop = (shopId: string) => searchApi.get(`/shop?place_id=${shopId}`); + +export const fetchShops = (params: ShopsParams) => { + const { keyword, location } = params; + const url = `/shops?keyword=${keyword}`; + const requestBody = { + x: location?.latitude, + y: location?.longitude, + }; + + return searchApi.post(url, requestBody); +}; + +export const fetchAutoComplete = (params: FetchAutoCompleteParams) => { + const { query, location } = params; + const url = `/shops/auto-complete?query=${query}`; + const requestBody = { + x: location?.latitude, + y: location?.longitude, + }; + return searchApi.post(url, requestBody); +}; diff --git a/src/api/search/searchApiClient.ts b/src/api/search/searchApiClient.ts new file mode 100644 index 00000000..82054259 --- /dev/null +++ b/src/api/search/searchApiClient.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import { API_PATH } from 'config/constants'; + +const searchApi = axios.create({ + baseURL: `${API_PATH}`, + timeout: 2000, +}); + +searchApi.interceptors.request.use( + (config) => { + const accessToken = sessionStorage.getItem('accessToken'); + // eslint-disable-next-line no-param-reassign + if (config.headers && accessToken) config.headers.Authorization = `Bearer ${accessToken}`; + return config; + }, +); + +export default searchApi; diff --git a/src/assets/images/search/not-found-img.jpeg b/src/assets/images/search/not-found-img.jpeg new file mode 100644 index 00000000..ef5dc4f5 Binary files /dev/null and b/src/assets/images/search/not-found-img.jpeg differ diff --git a/src/assets/svg/search/delete.svg b/src/assets/svg/search/delete.svg new file mode 100644 index 00000000..390fe700 --- /dev/null +++ b/src/assets/svg/search/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/search/pointer.svg b/src/assets/svg/search/pointer.svg new file mode 100644 index 00000000..17129fc2 --- /dev/null +++ b/src/assets/svg/search/pointer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/pages/Search/components/NotFoundPage/NotFoundPage.module.scss b/src/pages/Search/components/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000..8e159f7f --- /dev/null +++ b/src/pages/Search/components/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,37 @@ +@use "src/utils/styles/mediaQuery.scss" as media; + +.not-found-page__container { + padding-top: 180px; + max-width: 980px; + margin: 0 auto; + + &__title { + width: 544px; + height: 100px; + color: #000; + font-family: SUIT, system-ui; + font-size: 40px; + font-weight: 400; + line-height: normal; + } + + &__description { + margin-top: 22px; + width: 240px; + height: 30px; + color: #666; + font-family: SUIT, system-ui; + font-size: 24px; + font-style: normal; + font-weight: 400; + line-height: normal; + } + + &__image { + display: flex; + margin-left: auto; + margin-top: 55px; + width: 676px; + height: 451px; + } +} diff --git a/src/pages/Search/components/NotFoundPage/index.tsx b/src/pages/Search/components/NotFoundPage/index.tsx new file mode 100644 index 00000000..3e76d213 --- /dev/null +++ b/src/pages/Search/components/NotFoundPage/index.tsx @@ -0,0 +1,23 @@ +import styles from 'pages/Search/components/NotFoundPage/NotFoundPage.module.scss'; +import img from 'assets/images/search/not-found-img.jpeg'; + +export default function NotFoundPage() { + return ( +
+
+
+ 해당 검색어와 관련된 + {' '} +
+ {' '} + 음식점/게시물을 찾을 수 없습니다. +
+
+
다시 한 번 검색해 보세요!
+
+ not-found +
+
+ + ); +} diff --git a/src/pages/Search/components/RelatedSearches/RelatedSearches.module.scss b/src/pages/Search/components/RelatedSearches/RelatedSearches.module.scss index 832ddbb2..40a898d8 100644 --- a/src/pages/Search/components/RelatedSearches/RelatedSearches.module.scss +++ b/src/pages/Search/components/RelatedSearches/RelatedSearches.module.scss @@ -2,8 +2,8 @@ .search { width: 100%; - height: 100vh; font-size: 32px; + position: relative; @include media.media-breakpoint-up(mobile) { max-width: 767px; @@ -11,11 +11,22 @@ } } -// 검색창 결과 리스트 .search-related-list { - height: calc(100vh - 180px); + position: absolute; + width: 890px; overflow: scroll; padding-top: 24px; + background-color: white; + border-radius: 24px; + box-shadow: 2px 3px 12px 1px rgba(0 0 0 / 10%); + padding-bottom: 15px; + top: -40px; + left: 15px; + z-index: 1; + + &::-webkit-scrollbar { + display: none; + } &__wrapper { text-decoration-line: none; @@ -25,8 +36,8 @@ &__item { display: flex; justify-content: space-between; - font-size: 14px; - height: 34px; + font-size: 20px; + height: 48px; padding-left: 52px; padding-right: 57.14px; align-items: center; @@ -34,9 +45,43 @@ &:hover { background: #eeeeee; + border-radius: 24px; + color: #ff7f23; } } + &__title { + display: flex; + } + + &__icon { + display: flex; + margin-right: 16px; + } + + &__autoController { + display: flex; + flex-direction: row-reverse; + margin-top: 25px; + align-items: center; + } + + &__autoButtonTitle { + display: flex; + font-size: 20px; + color: #ff7f23; + padding: 24px 40px 18px 13px; + + &.active { + color: #c4c4c4; + } + } + + &__toggleButton { + display: flex; + padding-top: 8px; + } + &__text--not-found { word-break: keep-all; width: 275px; diff --git a/src/pages/Search/components/RelatedSearches/components/RelatedItem.tsx b/src/pages/Search/components/RelatedSearches/components/RelatedItem.tsx index fa1d7e7e..edbe7b5d 100644 --- a/src/pages/Search/components/RelatedSearches/components/RelatedItem.tsx +++ b/src/pages/Search/components/RelatedSearches/components/RelatedItem.tsx @@ -1,21 +1,21 @@ import { Link } from 'react-router-dom'; import styles from 'pages/Search/components/RelatedSearches/RelatedSearches.module.scss'; -import { ReactComponent as ArrowUpIcon } from 'assets/svg/search/arrow-up.svg'; +import { ReactComponent as PointerIcon } from 'assets/svg/search/pointer.svg'; interface Props { - item: Item -} - -interface Item { - title: string + item: string; } export default function RelatedItem({ item }: Props) { return ( - +
  • - {item.title} - +
    +
    + +
    + {item} +
  • ); diff --git a/src/pages/Search/components/RelatedSearches/components/ToggleButton.module.scss b/src/pages/Search/components/RelatedSearches/components/ToggleButton.module.scss new file mode 100644 index 00000000..5e61982f --- /dev/null +++ b/src/pages/Search/components/RelatedSearches/components/ToggleButton.module.scss @@ -0,0 +1,33 @@ +.ToggleButton { + width: 40px; + height: 18px; + border-radius: 30px; + border: none; + cursor: pointer; + position: relative; + display: flex; + justify-content: center; + align-items: center; + transition: all 0.5s ease-in-out; + background-color: #fff1e8; + + &.active { + background-color: whitesmoke; + } +} + +.circle { + background-color: #f6bf54; + width: 21px; + height: 21px; + border-radius: 50px; + position: absolute; + left: 5%; + transition: all 0.2s ease-in-out; + + .ToggleButton.active & { + transform: translate(20px, 0); + transition: all 0.2s ease-in-out; + background-color: gainsboro; + } +} diff --git a/src/pages/Search/components/RelatedSearches/components/ToggleButton.tsx b/src/pages/Search/components/RelatedSearches/components/ToggleButton.tsx new file mode 100644 index 00000000..bda4fbe1 --- /dev/null +++ b/src/pages/Search/components/RelatedSearches/components/ToggleButton.tsx @@ -0,0 +1,14 @@ +import styles from 'pages/Search/components/RelatedSearches/components/ToggleButton.module.scss'; + +interface Props { + onClick: () => void; + isActive: boolean; +} + +export default function ToggleButton({ onClick, isActive }: Props) { + return ( + + ); +} diff --git a/src/pages/Search/components/RelatedSearches/index.tsx b/src/pages/Search/components/RelatedSearches/index.tsx index e2a0c4a1..b6fec0ff 100644 --- a/src/pages/Search/components/RelatedSearches/index.tsx +++ b/src/pages/Search/components/RelatedSearches/index.tsx @@ -1,8 +1,10 @@ import cn from 'utils/ts/classNames'; import styles from 'pages/Search/components/RelatedSearches/RelatedSearches.module.scss'; -import suggestion from 'pages/Search/static/suggestion'; import useSearchingMode from 'pages/Search/hooks/useSearchingMode'; +import useFetchAutoComplete from 'pages/SearchDetails/hooks/useFetchAutoComplete'; +import useBooleanState from 'utils/hooks/useBooleanState'; import RelatedItem from './components/RelatedItem'; +import ToggleButton from './components/ToggleButton'; interface Props { text: string, @@ -10,15 +12,30 @@ interface Props { export default function RelatedSearches({ text }: Props) { const isSearching = useSearchingMode(); + const { query: auto } = useFetchAutoComplete(text ?? ''); + const [isActive, , , toggle] = useBooleanState(false); + return (
      - {text === '' ? null : suggestion.filter((item) => item.title.includes(text)) - .map((item) => )} +
      +
      + 자동완성 +
      +
      + +
      +
      + { + text === '' || !Array.isArray(auto) + ? null + : auto.filter((item: string) => item.includes(text)) + .map((item) => ) + }
    ); diff --git a/src/pages/Search/components/SearchBar/SearchBar.module.scss b/src/pages/Search/components/SearchBar/SearchBar.module.scss index d3caf8af..23cb558d 100644 --- a/src/pages/Search/components/SearchBar/SearchBar.module.scss +++ b/src/pages/Search/components/SearchBar/SearchBar.module.scss @@ -10,9 +10,9 @@ height: 80px; padding-top: 168px; padding-left: 30px; - font-size: 32px; + font-size: 40px; font-weight: 300; - line-height: 40px; + line-height: 49px; white-space: pre-wrap; color: #000000; @@ -29,28 +29,32 @@ } .search-bar { + position: relative; display: flex; - border-radius: 15px; + border-radius: 100px; border: none; - height: 30px; + height: 62px; + width: 875px; align-items: center; - margin-top: 32px; + margin-top: 34px; margin-right: 16px; margin-left: 16px; background: #ffffff; box-shadow: 2px 3px 12px 1px rgba(0 0 0 / 10%); padding-left: 15px; + z-index: 6; &__input { width: 100%; - height: 15px; + height: 30px; border: none; - font-size: 14px; + font-size: 24px; line-height: 15px; + padding-left: 24px; &::placeholder { color: #666666; - font-size: 12px; + font-size: 24px; } &:focus { @@ -59,10 +63,9 @@ } &__icon { - width: 18px; - height: 18px; - padding-left: 15px; - padding-right: 14px; + width: 34px; + height: 34px; + padding: 20px; cursor: pointer; } @@ -76,14 +79,39 @@ .search-rolling-banner { display: flex; overflow: hidden; - margin-top: 8px; + margin-top: 24px; margin-right: 27px; margin-left: 27px; - height: 25px; - font-size: 12px; + height: 32px; + width: 875px; + font-size: 16px; + color: #666666; + position: relative; + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 30px; + background: linear-gradient(to right, rgba(255 255 255 / 100%), rgba(255 255 255 / 0%)); + backdrop-filter: blur(0.1px); + pointer-events: none; + z-index: 2; + } - strong { - margin-right: 16px; + &::after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 30px; + background: linear-gradient(to left, rgba(255 255 255 / 100%), rgba(255 255 255 / 0%)); + backdrop-filter: blur(0.1px); + pointer-events: none; + z-index: 2; } &__tag-list { diff --git a/src/pages/Search/components/SearchBar/SearchInput.tsx b/src/pages/Search/components/SearchBar/SearchInput.tsx index 7e4a5a5d..d8af1fac 100644 --- a/src/pages/Search/components/SearchBar/SearchInput.tsx +++ b/src/pages/Search/components/SearchBar/SearchInput.tsx @@ -1,14 +1,38 @@ import { ReactComponent as LensIcon } from 'assets/svg/search/lens.svg'; import styles from 'pages/Search/components/SearchBar/SearchBar.module.scss'; +import useFetchShops from 'pages/SearchDetails/hooks/useFetchShops'; +import { useNavigate } from 'react-router-dom'; interface Props { text: string, onChange: (e: React.ChangeEvent) => void } - export default function SearchInput({ text, onChange, }: Props) { + const { isFetching, data: shops, refetch } = useFetchShops(text ?? ''); + const navigate = useNavigate(); + + const handleSearchClick = async () => { + if (!text) { + console.log('Please enter a keyword to search.'); + return; + } + + await refetch(); + + if (isFetching) { + console.log('Fetching shops...'); + } else { + console.log('Shops fetched.'); + console.log(shops); + if (shops === undefined) { + console.log('No shops found.'); + navigate('/search/not-found'); + } + } + }; + return ( ); } diff --git a/src/pages/Search/hooks/useSearching.ts b/src/pages/Search/hooks/useSearching.ts new file mode 100644 index 00000000..593c1537 --- /dev/null +++ b/src/pages/Search/hooks/useSearching.ts @@ -0,0 +1,3 @@ +const useSerching = () => ({}); + +export default useSerching; diff --git a/src/pages/Search/index.tsx b/src/pages/Search/index.tsx index d09d055b..9d73c414 100644 --- a/src/pages/Search/index.tsx +++ b/src/pages/Search/index.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import styles from 'pages/Search/Search.module.scss'; import useMediaQuery from 'utils/hooks/useMediaQuery'; -import { useNavigate } from 'react-router-dom'; import Recommendation from './components/SearchBar/Recommendation'; import SearchInput from './components/SearchBar/SearchInput'; import RollingBanner from './components/SearchBar/RollingBanner'; @@ -11,12 +10,13 @@ import useSearchingMode from './hooks/useSearchingMode'; const useSearchForm = () => { const [text, setText] = useState(''); - const handleChange = (e : React.ChangeEvent) => { - setText((e.target.value)); + const handleChange = (e: React.ChangeEvent) => { + setText(e.target.value); }; return { - text, handleChange, + text, + handleChange, }; }; @@ -24,22 +24,15 @@ export default function Search(): JSX.Element { const { text, handleChange } = useSearchForm(); const isSearching = useSearchingMode(); const { isMobile } = useMediaQuery(); - const navigate = useNavigate(); return (
    {isMobile && } {isMobile ? !isSearching && : } -
    navigate(`/search/${text}`)}> - - - {isMobile ? !isSearching && : } + + {!isMobile && isSearching ? : }
    -
    ); diff --git a/src/pages/Search/static/recommend.tsx b/src/pages/Search/static/recommend.tsx index fb9deff8..5a2fd1fc 100644 --- a/src/pages/Search/static/recommend.tsx +++ b/src/pages/Search/static/recommend.tsx @@ -5,18 +5,18 @@ const RECOMMEND_TEXT = [ 어떤 음식 이 땡기나요? , -
    - 기름진 음식 - 이 -
    - 그리운 날! -
    , -
    - 추억의 장소 - 를 -
    - 검색을 통해 찾아보세요! -
    , + //
    + // 기름진 음식 + // 이 + //
    + // 그리운 날! + //
    , + //
    + // 추억의 장소 + // 를 + //
    + // 검색을 통해 찾아보세요! + //
    , ]; export default RECOMMEND_TEXT; diff --git a/src/pages/SearchDetails/hooks/useFetchAutoComplete.ts b/src/pages/SearchDetails/hooks/useFetchAutoComplete.ts new file mode 100644 index 00000000..18587ecb --- /dev/null +++ b/src/pages/SearchDetails/hooks/useFetchAutoComplete.ts @@ -0,0 +1,29 @@ +import useGeolocation from 'utils/hooks/useGeolocation'; +import { useQuery } from 'react-query'; +import { fetchAutoComplete } from 'api/search'; +import { FetchAutoCompleteParams } from 'api/search/entity'; + +const useFetchAutoComplete = (query: string) => { + const options = { + maximumAge: 1000, + }; + const { location } = useGeolocation(options); + + const params: FetchAutoCompleteParams = { query }; + if (location) { + params.location = location; + } + + const { + isLoading, isError, data, refetch, + } = useQuery(['shop', query], () => fetchAutoComplete(params), { enabled: !!query }); + + const isFetching = isLoading || !(location); + const shop = data?.data; + + return { + isFetching, isError, query: shop, refetch, + }; +}; + +export default useFetchAutoComplete;