diff --git a/src/common/components/SelectButton/index.tsx b/src/common/components/SelectButton/index.tsx index 57fe051f..492bc4d8 100644 --- a/src/common/components/SelectButton/index.tsx +++ b/src/common/components/SelectButton/index.tsx @@ -1,18 +1,29 @@ import type { PropsWithChildren } from 'react'; import * as s from './style.css'; +import type { TagType } from '@/libs/types/item'; +import { Symbol } from '@/common/components/TagOptionBtn'; interface Props extends PropsWithChildren { - active: boolean; onClick?: () => void; + selected?: TagType[]; } -const SelectButton = ({ children, active, onClick }: Props) => { +const SelectButton = ({ children, onClick, selected }: Props) => { + const isSelected = selected && selected.length > 0; // TODO: 자연스럽게 바뀌게 애니메이션 추가 return ( - ); }; diff --git a/src/common/components/SelectButton/style.css.ts b/src/common/components/SelectButton/style.css.ts index fad64dd4..12c8dda5 100644 --- a/src/common/components/SelectButton/style.css.ts +++ b/src/common/components/SelectButton/style.css.ts @@ -1,4 +1,4 @@ -import { cva } from '@styled-system/css'; +import { css, cva } from '@styled-system/css'; export const Container = cva({ base: { @@ -17,7 +17,7 @@ export const Container = cva({ flexShrink: 0, }, variants: { - active: { + isSelected: { true: { borderColor: 'main-54', bgColor: 'main-26', @@ -30,19 +30,20 @@ export const Container = cva({ }, }); -export const Icon = cva({ - base: { - color: '54', - fontSize: '0.875rem', - }, - variants: { - direction: { - up: { - transform: 'rotate(180deg)', - }, - down: { - transform: 'rotate(0deg)', - }, - }, - }, +export const Icon = css({ + color: '54', + fontSize: '0.875rem', +}); + +export const SelectedIconContainer = css({ + display: 'flex', + alignItems: 'center', + gap: '0.125rem', +}); + +export const SelectedIcon = css({ + fontSize: '0.75rem', + w: '0.75rem', + h: '0.75rem', + color: 'main', }); diff --git a/src/common/components/TagOptionBtn/index.tsx b/src/common/components/TagOptionBtn/index.tsx index 69301e15..bea5c604 100644 --- a/src/common/components/TagOptionBtn/index.tsx +++ b/src/common/components/TagOptionBtn/index.tsx @@ -3,10 +3,15 @@ import * as s from './style.css'; import { COLOR_MAP } from '@/libs/constants/colorMap'; import { type TagType, TAG_TYPES_MAP, isIconType, isColorType } from '@/libs/types/item'; import { TagIcon } from '@/features/post/components/TagIcon'; +import { cx } from '@styled-system/css'; -const Symbol = ({ type }: { type: TagType }) => { +interface SymbolProps { + type: TagType; + className?: string; +} +export const Symbol = ({ type, className }: SymbolProps) => { // 아이콘 - if (isIconType(type)) return ; + if (isIconType(type)) return ; // 컬러 if (isColorType(type)) { @@ -15,7 +20,7 @@ const Symbol = ({ type }: { type: TagType }) => { type === 'COLOR_OTHER' ? { backgroundImage: 'linear-gradient(180deg, #FF6164 0%, #FF9462 28.85%, #FFBE62 67.31%, #8BFF61 100%)' } : { backgroundColor: colorValue }; - return ; + return ; } // 아무 타입에도 속하지 않는 경우 : 아무 심볼 없음! @@ -36,7 +41,7 @@ const TagOptionBtn = ({ isSelected = false, onClick, type }: Props) => { + ); +}; + +export default PriceButton; diff --git a/src/features/home/components/Filter/PriceFilter/index.tsx b/src/features/home/components/Filter/PriceFilter/index.tsx new file mode 100644 index 00000000..294ce250 --- /dev/null +++ b/src/features/home/components/Filter/PriceFilter/index.tsx @@ -0,0 +1,103 @@ +import { MAX_PRICE } from '@/libs/constants'; +import { useSearchParams } from 'react-router'; + +import * as s from './style.css'; +import { useMemo } from 'react'; +import PriceButton from '@/features/home/components/Filter/PriceFilter/PriceButton'; +import CustomPriceInput from '@/features/home/components/Filter/PriceFilter/CustomPriceInput'; + +const PriceOptions = [ + { + label: '1만원 미만', + start: 0, + end: 9999, + }, + { + label: '1만원 - 2만원', + start: 10000, + end: 19999, + }, + { + label: '2만원 - 3만원', + start: 20000, + end: 29999, + }, + { + label: '3만원 - 4만원', + start: 30000, + end: 39999, + }, + { + label: '4만원 - 5만원', + start: 40000, + end: 49999, + }, + { label: '5만원 이상', start: 50000, end: MAX_PRICE }, +]; + +const PriceFilter = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const startPrice = Number(searchParams.get('start-price') || 0); + const endPrice = Number(searchParams.get('end-price') || MAX_PRICE); + + const resetPrice = () => { + setSearchParams(prev => { + prev.delete('start-price'); + prev.delete('end-price'); + return prev; + }); + }; + + const handlePriceChange = (start: number, end: number) => { + setSearchParams(prev => { + prev.set('start-price', start.toString()); + prev.set('end-price', end.toString()); + return prev; + }); + }; + + const isCustomPrice = useMemo(() => { + return ( + searchParams.get('start-price') !== null && + searchParams.get('end-price') !== null && + !PriceOptions.some(option => option.start === startPrice && option.end === endPrice) + ); + }, [searchParams, startPrice, endPrice]); + + return ( +
+
+ {PriceOptions.map(option => { + const isSelected = option.start === startPrice && option.end === endPrice; + return ( + { + if (isSelected) { + resetPrice(); + return; + } + handlePriceChange(option.start, option.end); + }} + label={option.label} + /> + ); + })} + { + if (isCustomPrice) { + resetPrice(); + return; + } + handlePriceChange(0, MAX_PRICE); + }} + label="직접 입력" + /> +
+ +
+ ); +}; +export default PriceFilter; diff --git a/src/features/home/components/Filter/PriceFilter/style.css.ts b/src/features/home/components/Filter/PriceFilter/style.css.ts new file mode 100644 index 00000000..653f10ab --- /dev/null +++ b/src/features/home/components/Filter/PriceFilter/style.css.ts @@ -0,0 +1,130 @@ +import { css, cva } from '@styled-system/css'; + +export const Container = css({ + display: 'flex', + flexDir: 'column', + gap: '0.625rem', +}); + +export const PriceTagButton = cva({ + base: { + width: 'full', + height: '3.12063rem', + p: '0.8125rem 0.6875rem', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + rounded: '0.5rem', + border: '1px solid', + transition: 'all 0.2s ease-in-out', + fontSize: '1rem', + letterSpacing: '-0.04rem', + }, + variants: { + isSelected: { + true: { + bgColor: 'main-26', + borderColor: 'main-54', + color: '100', + fontWeight: 600, + }, + false: { + bgColor: 'systemGray5', + borderColor: 'systemGray5', + color: '80', + fontWeight: 400, + }, + }, + }, +}); + +export const RightIcon = cva({ + base: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + w: '1.125rem', + h: '1.125rem', + flexShrink: 0, + rounded: 'full', + border: '1.5px solid', + transition: 'all 0.2s ease-in-out', + '& div': { + w: '0.625rem', + h: '0.625rem', + rounded: 'full', + transition: 'all 0.2s ease-in-out', + }, + }, + variants: { + isSelected: { + true: { + bgColor: 'main-54', + borderColor: 'main', + '& div': { + bgColor: 'main', + }, + }, + false: { + bgColor: '20', + borderColor: '54', + '& div': { + bgColor: 'transparent', + }, + }, + }, + }, +}); + +export const PriceWrapper = css({ + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + gap: '0.625rem', +}); + +export const CustomPriceInputContainer = cva({ + base: { + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + transition: 'all 0.2s ease-in-out', + }, + variants: { + active: { + true: { opacity: 1 }, + false: { opacity: 0.3 }, + }, + }, +}); + +export const CustomPriceInput = css({ + width: '8.75rem', + height: '2.25rem', + pr: '0.75rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + color: '100', + fontSize: '1rem', + letterSpacing: '-0.04rem', + gap: '0.25rem', + bgColor: 'systemGray5', + rounded: '0.375rem', + '& input': { + flexShrink: 0, + width: 'full', + height: 'full', + flexGrow: 1, + flexBasis: 0, + fontWeight: 600, + textAlign: 'right', + pl: '0.75rem', + '&:focus': { + outline: 'none', + }, + }, + '& span': { + flexShrink: 0, + fontWeight: 400, + }, +}); diff --git a/src/features/home/components/Filter/index.tsx b/src/features/home/components/Filter/index.tsx index 9de9fe73..91577eae 100644 --- a/src/features/home/components/Filter/index.tsx +++ b/src/features/home/components/Filter/index.tsx @@ -1,11 +1,13 @@ -import { useEffect, useRef } from 'react'; -import { Swiper, SwiperSlide, type SwiperRef } from 'swiper/react'; +import { useMemo } from 'react'; +import { cx } from '@styled-system/css'; +import { useSearchParams } from 'react-router'; import * as s from './style.css'; import FilterNavigator from '@/features/home/components/Filter/FilterNavigator'; import FilterContents from '@/features/home/components/Filter/FilterContents'; -import { FilterTypeArray, FilterTypeToIndexMap, type FilterType } from '@/features/home/types'; +import { FilterTypeArray, type FilterType } from '@/features/home/types'; +import { useGetItemCount } from '@/features/home/apis/useGetItemCount'; interface Props { state: FilterType; @@ -14,31 +16,31 @@ interface Props { close: () => void; } const Filter = ({ state, setState, itemCounts, close }: Props) => { - const swiperRef = useRef(null); + const [searchParams, setSearchParams] = useSearchParams(); - useEffect(() => { - // 스와이퍼와 state 동기화 - swiperRef.current?.swiper.slideTo(FilterTypeToIndexMap[state]); - }, [state]); + const selected = useMemo(() => { + return ( + FilterTypeArray.some(type => searchParams.getAll(type).length > 0) || + searchParams.get('start-price') !== null || + searchParams.get('end-price') !== null + ); + }, [searchParams]); + + const { data: totalCount } = useGetItemCount({}); + + const resetFilter = () => setSearchParams({}); return (
-
- setState(FilterTypeArray[swiper.realIndex])} - > - {FilterTypeArray.map(type => ( - - - - ))} - -
+ + diff --git a/src/features/home/components/Filter/style.css.ts b/src/features/home/components/Filter/style.css.ts index f29ccc01..cdf5b669 100644 --- a/src/features/home/components/Filter/style.css.ts +++ b/src/features/home/components/Filter/style.css.ts @@ -6,18 +6,44 @@ export const Container = css({ export const Wrapper = css({ width: 'full', - height: '25rem', display: 'flex', flexDir: 'column', justifyContent: 'space-between', alignItems: 'stretch', px: '1rem', + height: 'full', + flexGrow: 1, }); -export const SwiperWrapper = css({ - flexGrow: 1, - '& > div': { - height: 'full', +export const ResetButton = cva({ + base: { + display: 'flex', + height: '3.125rem', + p: '0.625rem 0.5rem', + justifyContent: 'center', + alignItems: 'center', + gap: '0.625rem', + width: 'full', + flexShrink: 0, + color: '80', + fontSize: '1rem', + fontWeight: 400, + lineHeight: 1.2, + letterSpacing: '-0.04rem', + '& strong': { + color: '100', + fontWeight: 700, + }, + }, + variants: { + visible: { + true: { + visibility: 'visible', + }, + false: { + visibility: 'hidden', + }, + }, }, }); diff --git a/src/features/home/components/SearchControls/index.tsx b/src/features/home/components/SearchControls/index.tsx index db46a769..be82bc82 100644 --- a/src/features/home/components/SearchControls/index.tsx +++ b/src/features/home/components/SearchControls/index.tsx @@ -10,11 +10,14 @@ import DatePickButton from '@/features/home/components/DatePickButton'; import SortTriggerButton from '@/features/home/components/SortControl'; import Filter from '@/features/home/components/Filter'; import { FilterTypeArray, FilterTypeMap, type FilterType } from '@/features/home/types'; +import { useSearchParams } from 'react-router'; +import type { TagType } from '@/libs/types/item'; interface Props { itemCounts: number; } const SearchControls = ({ itemCounts }: Props) => { + const [searchParams] = useSearchParams(); const [state, setState] = useState('product-type'); const { open, drawerState, close } = useDrawer(); @@ -40,11 +43,23 @@ const SearchControls = ({ itemCounts }: Props) => { {/* TODO: 디자인 적용, 필터 로직 추가 */}
- {FilterTypeArray.map(filter => ( - handleFilterClick(filter)}> - {FilterTypeMap[filter]} - - ))} + {FilterTypeArray.map(filter => { + const selected = (() => { + if (filter === 'price') { + if (searchParams.get('start-price') !== null && searchParams.get('end-price') !== null) { + return [searchParams.get('start-price') as TagType, searchParams.get('end-price') as TagType]; + } + return []; + } + return searchParams.getAll(filter) as TagType[]; + })(); + + return ( + handleFilterClick(filter)} selected={selected}> + {FilterTypeMap[filter]} + + ); + })}