diff --git a/src/components/ui/card/card.styles.ts b/src/components/ui/card/card.styles.ts index 90318e8..7ce1ce8 100644 --- a/src/components/ui/card/card.styles.ts +++ b/src/components/ui/card/card.styles.ts @@ -1,10 +1,10 @@ import { cva, type VariantProps } from 'class-variance-authority'; -export const cardFrame = cva('rounded-xl border border-gray-200 bg-white'); +const cardFrame = cva('rounded-xl border border-gray-200 bg-white'); -export const cardImageWrapper = cva('relative rounded-xl overflow-hidden'); +const cardImageWrapper = cva('relative rounded-xl overflow-hidden'); -export const cardHeading = cva('font-bold', { +const cardHeading = cva('font-bold', { variants: { size: { sm: 'text-heading-s', @@ -19,9 +19,9 @@ export const cardHeading = cva('font-bold', { defaultVariants: { size: 'md', status: 'open' }, }); -export const cardInfoLayout = cva('flex flex-nowrap items-start tablet:items-center gap-1.5'); +const cardInfoLayout = cva('flex flex-nowrap items-start tablet:items-center gap-1.5'); -export const cardInfoText = cva('text-caption tablet:text-body-s', { +const cardInfoText = cva('text-caption tablet:text-body-s', { variants: { status: { open: 'text-gray-500', @@ -31,7 +31,7 @@ export const cardInfoText = cva('text-caption tablet:text-body-s', { defaultVariants: { status: 'open' }, }); -export const cardInfoIcon = cva('', { +const cardInfoIcon = cva('', { variants: { status: { open: 'bg-red-300', @@ -41,11 +41,11 @@ export const cardInfoIcon = cva('', { defaultVariants: { status: 'open' }, }); -export const cardPayLayout = cva('flex items-center gap-x-3'); +const cardPayLayout = cva('flex items-center gap-x-3'); -export const cardBadge = cva('flex items-center gap-x-0.5 rounded-full'); +const cardBadge = cva('flex items-center gap-x-0.5 rounded-full'); -export const cardBadgeText = cva('whitespace-nowrap text-caption tablet:text-body-s'); +const cardBadgeText = cva('whitespace-nowrap text-caption tablet:text-body-s'); export const cardLayout = { frame: cardFrame, diff --git a/src/components/ui/dropdown/dropdown.tsx b/src/components/ui/dropdown/dropdown.tsx index 4934f74..9408ee9 100644 --- a/src/components/ui/dropdown/dropdown.tsx +++ b/src/components/ui/dropdown/dropdown.tsx @@ -29,7 +29,7 @@ const Dropdown = ({ className, onChange, }: DropdownProps) => { - const { value: isOpen, toggle, setClose } = useToggle(); + const { isOpen, toggle, setClose } = useToggle(); const [attachDropdownRef, dropdownRef] = useSafeRef(); const [attachTriggerRef, triggerRef] = useSafeRef(); const [attachListRef, listRef] = useSafeRef(); diff --git a/src/components/ui/filter/components/filterBody.tsx b/src/components/ui/filter/components/filterBody.tsx new file mode 100644 index 0000000..6196ca3 --- /dev/null +++ b/src/components/ui/filter/components/filterBody.tsx @@ -0,0 +1,105 @@ +import { filterLayout } from '@/components/ui/filter/filter.styles'; +import { Icon } from '@/components/ui/icon'; +import { DateInput, Input } from '@/components/ui/input'; +import { ADDRESS_CODE } from '@/constants/dropdown'; +import { parseRFC3339 } from '@/lib/utils/dateFormatter'; +import { formatNumber } from '@/lib/utils/formatNumber'; +import { FilterQueryParams } from '@/types/api'; +import { ChangeEvent, useMemo } from 'react'; + +interface FilterBodyProps { + formData: FilterQueryParams; + onChange: (updater: (prev: FilterQueryParams) => FilterQueryParams) => void; +} + +const FilterBody = ({ formData, onChange }: FilterBodyProps) => { + const startAt = parseRFC3339(formData.startsAtGte); + const pay = useMemo( + () => (formData.hourlyPayGte ? formatNumber(formData.hourlyPayGte) : ''), + [formData.hourlyPayGte] + ); + const locations = formData.address ?? []; + const locationList = ADDRESS_CODE; + + const addLocation = (loc: string) => { + if (locations.includes(loc)) return; + onChange(prev => ({ ...prev, address: [...locations, loc] })); + }; + + const removeLocation = (loc: string) => { + const next = locations.filter(v => v !== loc); + onChange(prev => ({ ...prev, address: next })); + }; + + const handleDateChange = (date: Date | null) => { + const rfc3339String = date?.toISOString(); + onChange(prev => ({ ...prev, startsAtGte: rfc3339String })); + }; + + const handlePayChange = ({ target }: ChangeEvent) => { + const digits = target.value.replace(/[^0-9]/g, ''); + onChange(prev => ({ ...prev, hourlyPayGte: Number(digits) })); + }; + + return ( +
    +
  • +

    위치

    +
      + {locationList.map(value => ( +
    • + +
    • + ))} +
    + {locations.length !== 0 && ( +
    + {locations.map(value => ( + + ))} +
    + )} +
  • + {/* @TODO DateInput 기능 완성 시 작업 */} + {/*
  • + +
  • */} +
  • + +

    이상부터

    +
  • +
+ ); +}; +export default FilterBody; diff --git a/src/components/ui/filter/components/filterFooter.tsx b/src/components/ui/filter/components/filterFooter.tsx new file mode 100644 index 0000000..eb5f2a9 --- /dev/null +++ b/src/components/ui/filter/components/filterFooter.tsx @@ -0,0 +1,21 @@ +import { Button } from '@/components/ui/button'; +import { filterLayout } from '@/components/ui/filter/filter.styles'; + +interface FilterFooterProps { + onReset: () => void; + onSubmit: () => void; +} +const FilterFooter = ({ onReset, onSubmit }: FilterFooterProps) => { + return ( +
+ + +
+ ); +}; + +export default FilterFooter; diff --git a/src/components/ui/filter/components/filterHeader.tsx b/src/components/ui/filter/components/filterHeader.tsx new file mode 100644 index 0000000..93898a5 --- /dev/null +++ b/src/components/ui/filter/components/filterHeader.tsx @@ -0,0 +1,15 @@ +import { filterLayout } from '@/components/ui/filter/filter.styles'; +import { Icon } from '@/components/ui/icon'; + +const FilterHeader = ({ onClose }: { onClose?: () => void }) => { + return ( +
+
상세 필터
+ +
+ ); +}; + +export default FilterHeader; diff --git a/src/components/ui/filter/filter.styles.ts b/src/components/ui/filter/filter.styles.ts new file mode 100644 index 0000000..1b360b4 --- /dev/null +++ b/src/components/ui/filter/filter.styles.ts @@ -0,0 +1,67 @@ +import { cn } from '@/lib/utils/cn'; +import { cva } from 'class-variance-authority'; + +const filterPosition = cva('bg-background w-full'); + +const filterStickyContent = cva('sticky left-0 flex border-gray-200'); + +const filterWrapper = cva( + cn( + filterPosition(), + 'fixed z-10 h-dvh overflow-hidden rounded-xl border border-gray-200 min-[480px]:absolute min-[480px]:h-fit min-[480px]:w-[390px]' + ) +); + +const filterPadding = cva('px-3 tablet:px-5'); + +const filterGap = cva('flex flex-col gap-6'); + +const filterHeader = cva( + cn( + filterPosition(), + filterStickyContent(), + filterPadding(), + 'top-0 items-center justify-between border-b pb-3 pt-6' + ) +); + +const filterBody = cva( + cn( + filterGap(), + filterPadding(), + 'scroll-bar py-3 h-[calc(100dvh-94px-61px)] min-[480px]:h-fit min-[480px]:max-h-[calc(600px)]' + ) +); + +const filterFooter = cva( + cn(filterPosition(), filterStickyContent(), filterPadding(), 'bottom-0 gap-2 border-t pb-6 pt-3') +); + +const filterLocationWrapper = cva( + 'scroll-bar flex h-64 flex-wrap gap-3 rounded-md border border-gray-200 bg-white p-5' +); + +const filterLocationText = cva( + 'shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-center text-body-m' +); + +const filterLocation = cva(cn(filterLocationText(), 'hover:bg-red-100 hover:text-red-500')); + +const filterLocationSelected = cva( + cn(filterLocationText(), 'flex items-center gap-2 bg-red-100 font-bold text-red-500') +); + +export const filterLayout = { + position: filterPosition, + stickyContent: filterStickyContent, + wrapper: filterWrapper, + padding: filterPadding, + header: filterHeader, + body: filterBody, + footer: filterFooter, + filterGap, + locationWrapper: filterLocationWrapper, + locationText: filterLocationText, + location: filterLocation, + locationSelected: filterLocationSelected, +}; diff --git a/src/components/ui/filter/filter.tsx b/src/components/ui/filter/filter.tsx new file mode 100644 index 0000000..c0a6fe3 --- /dev/null +++ b/src/components/ui/filter/filter.tsx @@ -0,0 +1,57 @@ +import { cn } from '@/lib/utils/cn'; +import { FilterQueryParams } from '@/types/api'; +import { useCallback, useEffect, useState } from 'react'; +import FilterBody from './components/filterBody'; +import FilterFooter from './components/filterFooter'; +import FilterHeader from './components/filterHeader'; +import { filterLayout } from './filter.styles'; + +interface FilterProps { + value: FilterQueryParams; + onSubmit: (next: FilterQueryParams) => void; + onClose?: () => void; + className: string; +} + +const INIT_DATA: FilterQueryParams = { + address: undefined, + startsAtGte: undefined, + hourlyPayGte: undefined, +}; + +export function normalizeFilter(q: FilterQueryParams): FilterQueryParams { + return { + address: q.address && q.address.length > 0 ? q.address : undefined, + startsAtGte: q.startsAtGte ?? undefined, + hourlyPayGte: typeof q.hourlyPayGte === 'number' ? q.hourlyPayGte : undefined, + }; +} + +const Filter = ({ value, onSubmit, onClose, className }: FilterProps) => { + const [draft, setDraft] = useState(value); + + // const handleSubmit = () => onSubmit(draft); + + const handleSubmit = useCallback(() => { + onSubmit(normalizeFilter(draft)); + onClose?.(); + }, [draft, onSubmit, onClose]); + + const handleReset = useCallback(() => { + setDraft(INIT_DATA); + onSubmit(INIT_DATA); + }, [onSubmit]); + + useEffect(() => { + setDraft(value); + }, [value]); + + return ( +
+ + + +
+ ); +}; +export default Filter; diff --git a/src/components/ui/filter/index.ts b/src/components/ui/filter/index.ts new file mode 100644 index 0000000..bb58a2c --- /dev/null +++ b/src/components/ui/filter/index.ts @@ -0,0 +1 @@ +export { default as Filter } from './filter'; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 77b7a1a..fcd78fe 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -11,5 +11,6 @@ export { Table } from '@/components/ui/table'; export { Button } from './button'; export { Notice, Post } from './card'; export { Dropdown } from './dropdown'; +export { Filter } from './filter'; export { Icon } from './icon'; export { Modal, Notification } from './modal'; diff --git a/src/components/ui/input/input.tsx b/src/components/ui/input/input.tsx index c50667d..5e9fc5c 100644 --- a/src/components/ui/input/input.tsx +++ b/src/components/ui/input/input.tsx @@ -30,9 +30,9 @@ export default function Input({