From a59c14a0e4a1fbdf2299cd2efa1b8b6c6fc52b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=ED=98=9C=EB=A0=B9?= Date: Tue, 10 Oct 2023 15:55:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=9D=ED=92=88=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=20=ED=9B=84=20=EC=84=A0=ED=83=9D=ED=95=9C?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=20=EB=B3=B4=EC=97=AC=EC=A3=BC=EB=8A=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/#460: 반려견 등록 화살표 삭제 * feat/#460: 선택한 필터 보여줄 때 난독화돼서 나오지 않도록 url복호화 * refactor/#460: 파일 이동 * feat/#460: 선택한 필터 목록 보여주는 기능 추가 * refactor/#460: 컴포넌트명 수정 * refactor/#460: 컴포넌트명 수정 * fix/#460: 잘못된 지표 설명 수정 * refactor/#460: iPhone13 mini에서 도움말 줄바꿈 없이 보이도록 수정 * refactor: else if 문 개선 * refactor: 클릭이벤트핸들러 SelectedFilterItem으로 이동 * refactor: string배열 KEYWORD_EN으로 대체 * refactor: 타입명과 일치하도록 category > keyword로 변수명 수정 * feat: Object.entries 타입 추론을 위한 오버로딩 타입 추가 * refactor: 쿼리스트링에 따라 필터 상태 업데이트 하도록 수정 --- .../components/@common/Header/UserProfile.tsx | 7 -- .../components/Food/BrandBlock/BrandBlock.tsx | 2 +- .../FilterBottomSheet/FilterBottomSheet.tsx | 41 ++++++---- .../FilterSelectionDisplay.tsx | 73 +++++++++++++++++ .../src/components/Food/FoodList/FoodList.tsx | 2 +- .../FoodSelectionGuideBanner.tsx | 1 + frontend/src/context/food.tsx | 2 +- frontend/src/hooks/food.ts | 78 ------------------- .../hooks/food/useFilterSelectionDisplay.ts | 31 ++++++++ frontend/src/hooks/food/useFoodListFilter.ts | 42 ++++++++++ .../hooks/food/useInfiniteFoodListScroll.ts | 50 ++++++++++++ frontend/src/types/common/global.d.ts | 2 + frontend/src/utils/getValidQueries.ts | 2 +- 13 files changed, 228 insertions(+), 105 deletions(-) create mode 100644 frontend/src/components/Food/FilterSelectionDisplay/FilterSelectionDisplay.tsx delete mode 100644 frontend/src/hooks/food.ts create mode 100644 frontend/src/hooks/food/useFilterSelectionDisplay.ts create mode 100644 frontend/src/hooks/food/useFoodListFilter.ts create mode 100644 frontend/src/hooks/food/useInfiniteFoodListScroll.ts diff --git a/frontend/src/components/@common/Header/UserProfile.tsx b/frontend/src/components/@common/Header/UserProfile.tsx index 944e29773..f2ef90b3c 100644 --- a/frontend/src/components/@common/Header/UserProfile.tsx +++ b/frontend/src/components/@common/Header/UserProfile.tsx @@ -1,6 +1,5 @@ import { styled } from 'styled-components'; -import BottomDropIcon from '@/assets/svg/bottom_drop_icon.svg'; import ZipgoLogo from '@/assets/svg/zipgo_logo_light.svg'; import { usePetProfile } from '@/context/petProfile/PetProfileContext'; import { useAuth } from '@/hooks/auth'; @@ -29,7 +28,6 @@ const UserProfile = () => { ) : ( 여기를 눌러 반려견을 등록해주세요. )} - ) : ( @@ -75,8 +73,3 @@ const Logo = styled.img` width: 11.3rem; height: 3.6rem; `; - -const Chevron = styled.img` - width: 1.2rem; - height: 0.6rem; -`; diff --git a/frontend/src/components/Food/BrandBlock/BrandBlock.tsx b/frontend/src/components/Food/BrandBlock/BrandBlock.tsx index 48008cc70..30010fa4e 100644 --- a/frontend/src/components/Food/BrandBlock/BrandBlock.tsx +++ b/frontend/src/components/Food/BrandBlock/BrandBlock.tsx @@ -95,7 +95,7 @@ const BrandBlock = (brandBlockProps: BrandBlockProps) => { {onResearchCenterTip && ( ( - - - - - 필터 - - - - - - - {({ openHandler }) => } - - - - + + + + + + 필터 + + + + + + + {({ openHandler }) => } + + + + + + ); @@ -117,6 +121,10 @@ const KeywordContent = (props: KeywordContentProps) => { export default FilterBottomSheet; +const FilterDialogAndFilterDisplayContainer = styled.div` + display: flex; +`; + const Layout = styled.div` position: fixed; z-index: 1001; @@ -141,6 +149,7 @@ const DialogTrigger = styled.button` gap: 0.4rem; align-items: center; + width: 8.8rem; padding: 1rem 1.6rem; background-color: transparent; diff --git a/frontend/src/components/Food/FilterSelectionDisplay/FilterSelectionDisplay.tsx b/frontend/src/components/Food/FilterSelectionDisplay/FilterSelectionDisplay.tsx new file mode 100644 index 000000000..f3e8f8e9b --- /dev/null +++ b/frontend/src/components/Food/FilterSelectionDisplay/FilterSelectionDisplay.tsx @@ -0,0 +1,73 @@ +import { styled } from 'styled-components'; + +import { useFilterSelectionDisplay } from '@/hooks/food/useFilterSelectionDisplay'; +import { invariantOf } from '@/utils/invariantOf'; + +const FilterSelectionDisplay = () => { + const { filterListQueryString, removeFilter } = useFilterSelectionDisplay(); + + return ( + + {Object.entries(invariantOf(filterListQueryString)).map(([keyword, values]) => + values?.split(',').map(value => ( + removeFilter(keyword, value)}> + {value} + + x + + + )), + )} + + ); +}; + +export default FilterSelectionDisplay; + +const SelectedFilterList = styled.ul` + scrollbar-width: none; + + overflow-x: scroll; + display: flex; + gap: 0.4rem; + align-items: center; + + width: calc(100% - 8.8rem - 1rem); + margin-left: 1rem; + + &::-webkit-scrollbar { + width: 0; + height: 0; + } +`; + +const SelectedFilterItem = styled.li` + cursor: pointer; + + overflow: hidden; + flex-shrink: 0; + + height: 3.2rem; + padding: 0.4rem; + + font-size: 1.2rem; + font-weight: 500; + line-height: 2.4rem; + color: ${({ theme }) => theme.color.grey400}; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const FilterToggleButton = styled.button` + cursor: pointer; + + display: inline-block; + + margin-left: 0.4rem; + + color: ${({ theme }) => theme.color.grey300}; + + background: none; + border: none; +`; diff --git a/frontend/src/components/Food/FoodList/FoodList.tsx b/frontend/src/components/Food/FoodList/FoodList.tsx index a68d766a6..bda1a8280 100644 --- a/frontend/src/components/Food/FoodList/FoodList.tsx +++ b/frontend/src/components/Food/FoodList/FoodList.tsx @@ -1,6 +1,6 @@ import { styled } from 'styled-components'; -import { useInfiniteFoodListScroll } from '@/hooks/food'; +import { useInfiniteFoodListScroll } from '@/hooks/food/useInfiniteFoodListScroll'; import FoodItem from '../FoodItem/FoodItem'; diff --git a/frontend/src/components/FoodSelectionGuideBanner/FoodSelectionGuideBanner.tsx b/frontend/src/components/FoodSelectionGuideBanner/FoodSelectionGuideBanner.tsx index 2ef42772a..91d63eb51 100644 --- a/frontend/src/components/FoodSelectionGuideBanner/FoodSelectionGuideBanner.tsx +++ b/frontend/src/components/FoodSelectionGuideBanner/FoodSelectionGuideBanner.tsx @@ -75,6 +75,7 @@ const BannerWrapper = styled.div` font-weight: 500; line-height: 2.4rem; color: ${({ theme }) => theme.color.white}; + letter-spacing: -0.7px; img { width: 100px; diff --git a/frontend/src/context/food.tsx b/frontend/src/context/food.tsx index 6dd9a1672..b5afedcf9 100644 --- a/frontend/src/context/food.tsx +++ b/frontend/src/context/food.tsx @@ -2,7 +2,7 @@ /* eslint-disable no-spaced-func */ import { createContext, useContext, useMemo } from 'react'; -import { useFoodListFilter } from '@/hooks/food'; +import { useFoodListFilter } from '@/hooks/food/useFoodListFilter'; import type { KeywordEn } from '@/types/food/client'; import { getValidProps, PropsWithRenderProps } from '@/utils/compound'; diff --git a/frontend/src/hooks/food.ts b/frontend/src/hooks/food.ts deleted file mode 100644 index 33c79d780..000000000 --- a/frontend/src/hooks/food.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { initialSelectedFilterList } from '@/context/food'; -import type { KeywordEn } from '@/types/food/client'; -import { parseCheckList } from '@/utils/parseCheckList'; - -import useValidQueryString from './common/useValidQueryString'; -import { useFoodListInfiniteQuery } from './query/food'; - -export const useFoodListFilter = () => { - const [selectedFilterList, setSelectedFilterList] = useState(initialSelectedFilterList); - - const parsedSelectedFilterList = parseCheckList(selectedFilterList); - - const toggleFilter = (keyword: KeywordEn, filter: string) => { - const targetFilterList = structuredClone(selectedFilterList)[keyword]; - const selected = targetFilterList.has(filter); - - selected ? targetFilterList.delete(filter) : targetFilterList.add(filter); - - setSelectedFilterList(prev => ({ ...prev, [keyword]: new Set(targetFilterList) })); - }; - - const resetSelectedFilterList = () => { - setSelectedFilterList(initialSelectedFilterList); - }; - - return { selectedFilterList, parsedSelectedFilterList, toggleFilter, resetSelectedFilterList }; -}; - -export const useInfiniteFoodListScroll = () => { - const queries = useValidQueryString([ - 'nutritionStandards', - 'mainIngredients', - 'brands', - 'functionalities', - ]); - - const queriesString = Object.values(queries).join(); - - const { - foodList, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - remove, - refetch, - ...restQuery - } = useFoodListInfiniteQuery(queries); - - const executeFoodListInfiniteQuery = useCallback( - (entries: IntersectionObserverEntry[]) => { - entries.forEach(entry => { - const canLoadMore = entry.isIntersecting && hasNextPage && !isFetchingNextPage; - - if (canLoadMore) fetchNextPage(); - }); - }, - [hasNextPage, isFetchingNextPage, fetchNextPage], - ); - - const targetRef = useRef(null); - - useEffect(() => { - const observer = new IntersectionObserver(executeFoodListInfiniteQuery, { threshold: 0.1 }); - - if (targetRef.current) observer.observe(targetRef.current); - - return () => observer.disconnect(); - }, [executeFoodListInfiniteQuery]); - - useEffect(() => { - remove(); - refetch(); - }, [queriesString, refetch, remove]); - - return { foodList, hasNextPage, refetch, targetRef, ...restQuery }; -}; diff --git a/frontend/src/hooks/food/useFilterSelectionDisplay.ts b/frontend/src/hooks/food/useFilterSelectionDisplay.ts new file mode 100644 index 000000000..f9584745d --- /dev/null +++ b/frontend/src/hooks/food/useFilterSelectionDisplay.ts @@ -0,0 +1,31 @@ +import { KEYWORD_EN } from '@/constants/food'; +import { generateQueryString } from '@/router/routes'; +import { KeywordEn } from '@/types/food/client'; + +import useEasyNavigate from '../@common/useEasyNavigate'; +import useValidQueryString from '../common/useValidQueryString'; + +export const useFilterSelectionDisplay = () => { + const { replaceQueryString } = useEasyNavigate(); + const filterListQueryString = useValidQueryString(KEYWORD_EN); + + const removeFilter = (keyword: KeywordEn, value: string) => { + const filterList = filterListQueryString[keyword]?.split(','); + + if (!filterList) return; + + if (filterList.includes(value)) { + filterList.splice(filterList.indexOf(value), 1); + + const updatedQueryString = filterList.join(','); + const newQueryString = generateQueryString({ + ...filterListQueryString, + [keyword]: updatedQueryString, + }); + + replaceQueryString(newQueryString, { exclude: [] }); + } + }; + + return { filterListQueryString, removeFilter }; +}; diff --git a/frontend/src/hooks/food/useFoodListFilter.ts b/frontend/src/hooks/food/useFoodListFilter.ts new file mode 100644 index 000000000..14e618861 --- /dev/null +++ b/frontend/src/hooks/food/useFoodListFilter.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; + +import { initialSelectedFilterList } from '@/context/food'; +import type { KeywordEn } from '@/types/food/client'; +import { invariantOf } from '@/utils/invariantOf'; +import { parseCheckList } from '@/utils/parseCheckList'; + +import { useFilterSelectionDisplay } from './useFilterSelectionDisplay'; + +export const useFoodListFilter = () => { + const { filterListQueryString } = useFilterSelectionDisplay(); + const [selectedFilterList, setSelectedFilterList] = useState(initialSelectedFilterList); + const parsedSelectedFilterList = parseCheckList(selectedFilterList); + + const toggleFilter = (keyword: KeywordEn, filter: string) => { + const targetFilterList = structuredClone(selectedFilterList)[keyword]; + const selected = targetFilterList.has(filter); + + selected ? targetFilterList.delete(filter) : targetFilterList.add(filter); + + setSelectedFilterList(prev => ({ ...prev, [keyword]: new Set(targetFilterList) })); + }; + + const resetSelectedFilterList = () => { + setSelectedFilterList(initialSelectedFilterList); + }; + + useEffect(() => { + const newFilterList = Object.entries(invariantOf(filterListQueryString)).reduce( + (newFilterList, [keyword, queryString]) => ({ + ...newFilterList, + [keyword]: new Set(queryString?.split(',')), + }), + initialSelectedFilterList, + ); + + setSelectedFilterList(prev => newFilterList); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [Object.values(filterListQueryString).join()]); + + return { selectedFilterList, parsedSelectedFilterList, toggleFilter, resetSelectedFilterList }; +}; diff --git a/frontend/src/hooks/food/useInfiniteFoodListScroll.ts b/frontend/src/hooks/food/useInfiniteFoodListScroll.ts new file mode 100644 index 000000000..7b5da09e1 --- /dev/null +++ b/frontend/src/hooks/food/useInfiniteFoodListScroll.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { KEYWORD_EN } from '@/constants/food'; +import useValidQueryString from '@/hooks/common/useValidQueryString'; +import { useFoodListInfiniteQuery } from '@/hooks/query/food'; +import type { KeywordEn } from '@/types/food/client'; + +export const useInfiniteFoodListScroll = () => { + const queries = useValidQueryString(KEYWORD_EN); + + const queriesString = Object.values(queries).join(); + + const { + foodList, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + remove, + refetch, + ...restQuery + } = useFoodListInfiniteQuery(queries); + + const executeFoodListInfiniteQuery = useCallback( + (entries: IntersectionObserverEntry[]) => { + entries.forEach(entry => { + const canLoadMore = entry.isIntersecting && hasNextPage && !isFetchingNextPage; + + if (canLoadMore) fetchNextPage(); + }); + }, + [hasNextPage, isFetchingNextPage, fetchNextPage], + ); + + const targetRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver(executeFoodListInfiniteQuery, { threshold: 0.1 }); + + if (targetRef.current) observer.observe(targetRef.current); + + return () => observer.disconnect(); + }, [executeFoodListInfiniteQuery]); + + useEffect(() => { + remove(); + refetch(); + }, [queriesString, refetch, remove]); + + return { foodList, hasNextPage, refetch, targetRef, ...restQuery }; +}; diff --git a/frontend/src/types/common/global.d.ts b/frontend/src/types/common/global.d.ts index d0683a5eb..ac7c8773c 100644 --- a/frontend/src/types/common/global.d.ts +++ b/frontend/src/types/common/global.d.ts @@ -1,6 +1,8 @@ declare global { export interface ObjectConstructor { keys(o: InvariantOf): Array; + + entries(o: InvariantOf): Array<[keyof T, T[keyof T]]>; } } diff --git a/frontend/src/utils/getValidQueries.ts b/frontend/src/utils/getValidQueries.ts index 6c9358c78..fbca21a99 100644 --- a/frontend/src/utils/getValidQueries.ts +++ b/frontend/src/utils/getValidQueries.ts @@ -1,7 +1,7 @@ import { getArrayMutationMethod } from './getArrayMutationMethod'; export const getValidQueries = (search: string, queryKeys: Readonly) => - search + decodeURIComponent(search) .replace(/^\?/, '') .split('&') .reduce>>((queries, keyValue, _, search) => {