diff --git a/src/components/features/index.ts b/src/components/features/index.ts index e69de29..8816e4e 100644 --- a/src/components/features/index.ts +++ b/src/components/features/index.ts @@ -0,0 +1 @@ +export { AllNoticeList, RecentNoticeList, RecommendedNoticeList } from './noticeList'; diff --git a/src/components/features/noticeList/allNoticeList.tsx b/src/components/features/noticeList/allNoticeList.tsx new file mode 100644 index 0000000..2e5ad73 --- /dev/null +++ b/src/components/features/noticeList/allNoticeList.tsx @@ -0,0 +1,70 @@ +import { noticeListLayout } from '@/components/features/noticeList/noticeList.styles'; +import { Container } from '@/components/layout'; +import { Dropdown, Filter } from '@/components/ui'; +import { getActiveFilterCount } from '@/components/ui/filter/getActiveFilterCount'; +import { SORT_CODE, type SortCode } from '@/constants/dropdown'; +import { FilterQuery, type sort } from '@/types/api'; +import { useEffect, useState } from 'react'; +import NoticeList from './noticeList'; +import { useNotice } from '@/context/noticeProvider'; + +const SORT_TO_API: Record = { + '마감 임박 순': 'time', + '시급 많은 순': 'pay', + '시간 적은 순': 'hour', + '가나다 순': 'shop', +}; + +const AllNoticeList = () => { + const { notices, fetchNotices, updateFilters, filters, error, isLoading, pagination } = + useNotice(); + const [sort, setSort] = useState('마감 임박 순'); + const appliedCount = getActiveFilterCount(filters); + + useEffect(() => { + fetchNotices(); + }, []); + + const handleSort = (label: SortCode) => { + const sort = SORT_TO_API[label]; + fetchNotices({ sort }); + setSort(label); + }; + + const handleFilter = (q: FilterQuery) => { + updateFilters(q); + fetchNotices(q); + }; + + return ( + +
+

전체 공고

+
+ + +
+
+ +
+ ); +}; +export default AllNoticeList; diff --git a/src/components/features/noticeList/index.ts b/src/components/features/noticeList/index.ts new file mode 100644 index 0000000..414cc46 --- /dev/null +++ b/src/components/features/noticeList/index.ts @@ -0,0 +1,3 @@ +export { default as AllNoticeList } from './allNoticeList'; +export { default as RecentNoticeList } from './recentNoticeList'; +export { default as RecommendedNoticeList } from './recommendedNoticeList'; diff --git a/src/components/features/noticeList/noticeList.styles.ts b/src/components/features/noticeList/noticeList.styles.ts new file mode 100644 index 0000000..e383609 --- /dev/null +++ b/src/components/features/noticeList/noticeList.styles.ts @@ -0,0 +1,6 @@ +import { cva } from 'class-variance-authority'; +const noticeListTitle = cva('text-heading-l font-bold mb-4 tablet:mb-8'); + +export const noticeListLayout = { + title: noticeListTitle, +}; diff --git a/src/components/features/noticeList/noticeList.tsx b/src/components/features/noticeList/noticeList.tsx new file mode 100644 index 0000000..50ab3b7 --- /dev/null +++ b/src/components/features/noticeList/noticeList.tsx @@ -0,0 +1,46 @@ +import { Post } from '@/components/ui'; +import { Pagination } from '@/components/ui/pagination'; +import { NoticeQuery, PaginatedResponse } from '@/types/api'; +import { type PostCard } from '@/types/notice'; + +interface NoticeListProps { + notices: PostCard[]; + pagination?: PaginatedResponse; + isLoading: boolean; + error: string | null; + fetchNotices?: (params?: Partial) => Promise; +} + +const NoticeList = ({ notices, isLoading, error, pagination, fetchNotices }: NoticeListProps) => { + + if (error) { + return
{error}
; + } + if (notices.length === 0) { + return
공고가 존재하지 않습니다
; + } + + return ( + <> + {isLoading ? ( +
로딩중 .. 스켈레톤 UI 삽입예정
+ ) : ( +
+ {notices.map(notice => ( + + ))} +
+ )} + {pagination && ( + fetchNotices?.({ offset: next })} + className='mt-8 tablet:mt-10' + /> + )} + + ); +}; +export default NoticeList; diff --git a/src/components/features/noticeList/recentNoticeList.tsx b/src/components/features/noticeList/recentNoticeList.tsx new file mode 100644 index 0000000..a477dfa --- /dev/null +++ b/src/components/features/noticeList/recentNoticeList.tsx @@ -0,0 +1,12 @@ +import { noticeListLayout } from '@/components/features/noticeList/noticeList.styles'; +import { Container } from '@/components/layout'; + +// @TODO 최근에 본 공고 리스트 출력 +const RecentNoticeList = () => { + return ( + +

최근에 본 공고

+
+ ); +}; +export default RecentNoticeList; diff --git a/src/components/features/noticeList/recommendedNoticeList.tsx b/src/components/features/noticeList/recommendedNoticeList.tsx new file mode 100644 index 0000000..fb4bd15 --- /dev/null +++ b/src/components/features/noticeList/recommendedNoticeList.tsx @@ -0,0 +1,30 @@ +import { noticeListLayout } from '@/components/features/noticeList/noticeList.styles'; +import { Container } from '@/components/layout'; +import { useNotice } from '@/context/noticeProvider'; +import useAuth from '@/hooks/useAuth'; +import { useEffect } from 'react'; +import NoticeList from './noticeList'; + +const RecommendedNoticeList = () => { + const { user } = useAuth(); + const { notices, fetchNotices, error, isLoading } = useNotice(); + const recommendedAddress = user?.address ?? '서울시 강남구'; + useEffect(() => { + fetchNotices({ limit: 3, address: [recommendedAddress] }); + }, []); + + return ( +
+ +

맞춤 공고

+ +
+
+ ); +}; +export default RecommendedNoticeList; diff --git a/src/components/features/noticeList/searchResultList.tsx b/src/components/features/noticeList/searchResultList.tsx new file mode 100644 index 0000000..5a5cc7c --- /dev/null +++ b/src/components/features/noticeList/searchResultList.tsx @@ -0,0 +1,21 @@ +import { noticeListLayout } from '@/components/features/noticeList/noticeList.styles'; +import { Container } from '@/components/layout'; +import PostWrapper from '@/components/ui/card/post/mockData/postWrapper'; +import { useSearchParams } from 'next/navigation'; + +// @TODO 상단 검색바에서 검색 시 해당 키워드로 검색결과 노출 +const SearchResultList = () => { + const searchParams = useSearchParams(); + const query = searchParams.get('q') ?? ''; + return ( +
+ +

+ {query}에 대한 공고 목록 +

+ +
+
+ ); +}; +export default SearchResultList; diff --git a/src/components/layout/container/container.tsx b/src/components/layout/container/container.tsx index a067966..3731977 100644 --- a/src/components/layout/container/container.tsx +++ b/src/components/layout/container/container.tsx @@ -16,10 +16,9 @@ const Container = ({ as: Component = 'div', isPage = false, className, children return ( diff --git a/src/components/ui/card/notice/notice.tsx b/src/components/ui/card/notice/notice.tsx index 342770d..7e4a866 100644 --- a/src/components/ui/card/notice/notice.tsx +++ b/src/components/ui/card/notice/notice.tsx @@ -1,4 +1,5 @@ import { Container } from '@/components/layout/container'; +import { cn } from '@/lib/utils/cn'; import { type NoticeCard, type NoticeVariant } from '@/types/notice'; import { ReactNode } from 'react'; import RenderNotice from './components/renderNotice'; @@ -9,12 +10,14 @@ interface NoticeProps> { notice: T; variant?: NoticeVariant; children: ReactNode; + className?: string; } const Notice = >({ notice, variant = 'notice', children, + className, }: NoticeProps) => { const { hourlyPay, @@ -63,7 +66,7 @@ const Notice = >({ }; return ( - + {variant === 'notice' ? ( ) : ( diff --git a/src/components/ui/card/post/post.tsx b/src/components/ui/card/post/post.tsx index f5e1ba6..c2fa836 100644 --- a/src/components/ui/card/post/post.tsx +++ b/src/components/ui/card/post/post.tsx @@ -19,6 +19,7 @@ const STATUS_LABEL = { const Post = ({ notice }: PostProps) => { const { + id, hourlyPay, startsAt, workhour, @@ -32,7 +33,7 @@ const Post = ({ notice }: PostProps) => { const status = getNoticeStatus(closed, startsAt); const { date, startTime, endTime } = getTime(startsAt, workhour); const statusVariant: CardStatusVariant = status === 'open' ? 'open' : 'inactive'; - const href = `/notices/${shopId}`; + const href = `/notices/${shopId}/${id}`; return ( diff --git a/src/components/ui/dropdown/dropdown.tsx b/src/components/ui/dropdown/dropdown.tsx index 9408ee9..1a741d7 100644 --- a/src/components/ui/dropdown/dropdown.tsx +++ b/src/components/ui/dropdown/dropdown.tsx @@ -85,7 +85,7 @@ const Dropdown = ({ role='listbox' aria-label={ariaLabel} className={cn( - 'scroll-bar absolute z-[1] max-h-56 w-full rounded-md border border-gray-300 bg-white shadow-inset-top', + 'scroll-bar absolute z-10 max-h-56 w-full rounded-md border border-gray-300 bg-white shadow-inset-top', position === 'top' ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+8px)]' )} > diff --git a/src/components/ui/dropdown/hooks/useDropdownPosition.ts b/src/components/ui/dropdown/hooks/useDropdownPosition.ts index 2b927a2..e51c5ba 100644 --- a/src/components/ui/dropdown/hooks/useDropdownPosition.ts +++ b/src/components/ui/dropdown/hooks/useDropdownPosition.ts @@ -6,7 +6,7 @@ const useDropdownPosition = (triggerRef: RefObject, threshold useEffect(() => { const trigger = triggerRef.current; if (!trigger) return; - + // 트리거 기준으로 아래쪽 여유 공간 < threshold 이면 위로, 아니면 아래로 배치 const updatePosition = () => { const rect = trigger.getBoundingClientRect(); const viewportHeight = window.innerHeight; diff --git a/src/components/ui/filter/components/filterBody.tsx b/src/components/ui/filter/components/filterBody.tsx index 3fcd9ea..9c0c7b2 100644 --- a/src/components/ui/filter/components/filterBody.tsx +++ b/src/components/ui/filter/components/filterBody.tsx @@ -1,7 +1,8 @@ import { filterLayout } from '@/components/ui/filter/filter.styles'; import { Icon } from '@/components/ui/icon'; -import { Input } from '@/components/ui/input'; +import { DateInput, Input } from '@/components/ui/input'; import { ADDRESS_CODE } from '@/constants/dropdown'; +import { cn } from '@/lib/utils/cn'; import { parseRFC3339 } from '@/lib/utils/dateFormatter'; import { formatNumber } from '@/lib/utils/formatNumber'; import { FilterQuery } from '@/types/api'; @@ -31,8 +32,9 @@ const FilterBody = ({ formData, onChange }: FilterBodyProps) => { onChange(prev => ({ ...prev, address: next })); }; - const handleDateChange = (date: Date | null) => { - const rfc3339String = date?.toISOString(); + const handleDateChange = (date: Date | string) => { + if (typeof date === 'string') return; + const rfc3339String = date.toISOString(); onChange(prev => ({ ...prev, startsAtGte: rfc3339String })); }; @@ -50,7 +52,7 @@ const FilterBody = ({ formData, onChange }: FilterBodyProps) => {
  • - {/* @TODO DateInput 기능 완성 시 작업 */} - {/*
  • +
  • { value={startAt} onChange={handleDateChange} /> -
  • */} +
  • void }) => { +interface FilterHeaderProps { + onClose?: () => void; + activeCount?: number; +} + +const FilterHeader = ({ onClose, activeCount = 0 }: FilterHeaderProps) => { return (
    -
    상세 필터
    +
    + 상세 필터{activeCount > 0 && <>({activeCount})} +
    diff --git a/src/components/ui/filter/filter.styles.ts b/src/components/ui/filter/filter.styles.ts index 1b360b4..8d6fc56 100644 --- a/src/components/ui/filter/filter.styles.ts +++ b/src/components/ui/filter/filter.styles.ts @@ -8,10 +8,22 @@ 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]' + 'fixed top-0 z-10 h-dvh overflow-hidden rounded-xl border border-gray-200 min-[480px]:absolute min-[480px]:h-fit min-[480px]:w-[390px] min-[480px]:top-[calc(100%+8px)]' ) ); +const filterPlacement = cva('', { + variants: { + align: { + left: 'left-0', + right: 'right-0', + }, + }, + defaultVariants: { + align: 'right', + }, +}); + const filterPadding = cva('px-3 tablet:px-5'); const filterGap = cva('flex flex-col gap-6'); @@ -55,6 +67,7 @@ export const filterLayout = { position: filterPosition, stickyContent: filterStickyContent, wrapper: filterWrapper, + placement: filterPlacement, padding: filterPadding, header: filterHeader, body: filterBody, diff --git a/src/components/ui/filter/filter.tsx b/src/components/ui/filter/filter.tsx index 5c8d835..5137a69 100644 --- a/src/components/ui/filter/filter.tsx +++ b/src/components/ui/filter/filter.tsx @@ -1,3 +1,8 @@ +import { DROPDOWN_STYLE } from '@/components/ui/dropdown/dropdown.styles'; +import useClickOutside from '@/hooks/useClickOutside'; +import useEscapeKey from '@/hooks/useEscapeKey'; +import useSafeRef from '@/hooks/useSafeRef'; +import useToggle from '@/hooks/useToggle'; import { cn } from '@/lib/utils/cn'; import { FilterQuery } from '@/types/api'; import { useCallback, useEffect, useState } from 'react'; @@ -5,12 +10,14 @@ import FilterBody from './components/filterBody'; import FilterFooter from './components/filterFooter'; import FilterHeader from './components/filterHeader'; import { filterLayout } from './filter.styles'; +import { getActiveFilterCount } from './getActiveFilterCount'; interface FilterProps { value: FilterQuery; + appliedCount: number; onSubmit: (next: FilterQuery) => void; - onClose?: () => void; - className: string; + className?: string; + align?: 'left' | 'right'; } const INIT_DATA: FilterQuery = { @@ -27,30 +34,52 @@ export function normalizeFilter(q: FilterQuery): FilterQuery { }; } -const Filter = ({ value, onSubmit, onClose, className }: FilterProps) => { +const Filter = ({ value, onSubmit, appliedCount, className, align = 'right' }: FilterProps) => { + const { isOpen, setClose, toggle } = useToggle(); const [draft, setDraft] = useState(value); - - // const handleSubmit = () => onSubmit(draft); + const [attachFilterRef, filterRef] = useSafeRef(); const handleSubmit = useCallback(() => { onSubmit(normalizeFilter(draft)); - onClose?.(); - }, [draft, onSubmit, onClose]); + setClose(); + }, [draft, onSubmit, setClose]); const handleReset = useCallback(() => { setDraft(INIT_DATA); - onSubmit(INIT_DATA); - }, [onSubmit]); + }, []); useEffect(() => { setDraft(value); }, [value]); + useClickOutside(filterRef, setClose); + useEscapeKey(setClose); + return ( -
    - - - +
    + +
    + + + +
    ); }; diff --git a/src/components/ui/filter/getActiveFilterCount.ts b/src/components/ui/filter/getActiveFilterCount.ts new file mode 100644 index 0000000..ffc0f7a --- /dev/null +++ b/src/components/ui/filter/getActiveFilterCount.ts @@ -0,0 +1,9 @@ +import { FilterQuery } from '@/types/api'; + +export function getActiveFilterCount(q: FilterQuery): number { + const addressCount = q.address ? q.address.length : 0; + const dateCount = q.startsAtGte ? 1 : 0; + const payCount = typeof q.hourlyPayGte === 'number' ? 1 : 0; + + return addressCount + dateCount + payCount; +} diff --git a/src/context/noticeProvider.tsx b/src/context/noticeProvider.tsx new file mode 100644 index 0000000..587d59a --- /dev/null +++ b/src/context/noticeProvider.tsx @@ -0,0 +1,109 @@ +import axiosInstance from '@/lib/axios'; +import { paramsSerializer } from '@/lib/utils/paramsSerializer'; +import { toPostCard } from '@/lib/utils/parse'; +import { NoticeQuery, PaginatedResponse } from '@/types/api'; +import { PostCard } from '@/types/notice'; +import { createContext, ReactNode, useCallback, useContext, useState } from 'react'; + +interface NoticeContextValue { + notices: PostCard[]; + pagination: PaginatedResponse; + isLoading: boolean; + error: string | null; + filters: NoticeQuery; + fetchNotices: (params?: Partial) => Promise; + updateFilters: (filters: Partial) => void; + reset: () => void; +} + +//현재 필터 상태(filters)의 초기값 == 현재 이 화면은 어떤 조건으로 공고를 보고 있는가를 나타내는 전역상태 +const INIT_FILTER_DATA: NoticeQuery = { + sort: 'time', + // startsAtGte: new Date().toISOString(), +}; + +export const NoticeContext = createContext(undefined); + +// 맞춤 공고, 전체 공고, 검색된 공고등 공고 조회 관리 +export const NoticeProvider = ({ children }: { children: ReactNode }) => { + const [notices, setNotices] = useState([]); // PostCard data + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFiltersState] = useState(INIT_FILTER_DATA); // 현재 페이지에서 적용 중인 필터 조건 + const [pagination, setPagination] = useState({ + offset: 0, + limit: 10, + count: 0, + hasNext: false, + }); + + // 공고 데이터 요청 함수. 파라미터를 넣으면 검색/정렬 가능 + const fetchNotices: NoticeContextValue['fetchNotices'] = useCallback( + async params => { + try { + setIsLoading(true); + const query = { ...filters, ...(params ?? {}) }; // 새 조건 덮어쓰기 (각 페이지별 상이한 조건 덮어쓰기) + const res = await axiosInstance.get('/notices', { + params: query, + paramsSerializer: { serialize: paramsSerializer }, + // 서버에서 원하는 형태로 직렬화 + }); + // 공고 목록 업데이트 + setNotices(res.data.items.map(toPostCard)); // toPostCard 컴포넌트에서 필요한 필드만 추출 + //페이지네이션 정보 업데이트 + setPagination({ + offset: res.data.offset, + limit: res.data.limit, + count: res.data.count, + hasNext: res.data.hasNext, + }); + } catch (err) { + setError('공고를 불러오는 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }, + [filters] + ); + + // 기존 필터를 유지하면서 filters 상태만 부분 업데이트 하여 특정 조건만 수정 + // 사용자가 선택한 필터 조건을 Context 상태에 반영하는 함수 + const updateFilters: NoticeContextValue['updateFilters'] = useCallback(partial => { + setFiltersState(prev => ({ ...prev, ...partial })); + }, []); + + // 공고와 필터 초기화 + const reset = useCallback(() => { + setNotices([]); + setFiltersState(INIT_FILTER_DATA); + }, []); + + const value = { + notices, + pagination, + isLoading, + error, + filters, + fetchNotices, + updateFilters, + reset, + }; + + return {children}; +}; + +export const useNotice = () => { + const context = useContext(NoticeContext); + if (!context) throw new Error('useContext는 NoticeContext 안에서 사용해야 합니다.'); + return context; +}; + +/** +fetchNotices(); // 기본호출 +fetchNotices({ address: ['서울시 서초구'] }); // 위치 필터 +fetchNotices({ sort: 'pay' }); // 정렬 변경 +fetchNotices({ limit: 3 }); // 맞춤공고 +fetchNotices({ offset: 20 }); // 페이지 3으로 이동 +fetchNotices({ keyword: '마라탕' }); // 검색결과 +fetchNotices({ keyword: '카페', sort: 'pay', address: ['서울시 서초구'] }); // + */ diff --git a/src/lib/utils/paramsSerializer.ts b/src/lib/utils/paramsSerializer.ts new file mode 100644 index 0000000..a99279c --- /dev/null +++ b/src/lib/utils/paramsSerializer.ts @@ -0,0 +1,13 @@ +export function paramsSerializer(params: Record): string { + const usp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + if (Array.isArray(value)) { + // address=강남구&address=서초구 형식으로 변경 + for (const v of value) usp.append(key, String(v)); + } else { + usp.append(key, String(value)); + } + } + return usp.toString(); +} diff --git a/src/lib/utils/parse.ts b/src/lib/utils/parse.ts new file mode 100644 index 0000000..70c4789 --- /dev/null +++ b/src/lib/utils/parse.ts @@ -0,0 +1,40 @@ +import { type NoticeCard, NoticeItemResponse, PostCard } from '@/types/notice'; + +export const toPostCard = (res: NoticeItemResponse): PostCard => { + const n = res.item; + const shop = n.shop.item; + + return { + id: n.id, + hourlyPay: n.hourlyPay, + startsAt: n.startsAt, + workhour: n.workhour, + closed: n.closed, + shopId: shop.id, + name: shop.name, + address1: shop.address1, + imageUrl: shop.imageUrl, + originalHourlyPay: shop.originalHourlyPay, + }; +}; + +export const toNoticeCard = (res: NoticeItemResponse): NoticeCard => { + const n = res.item; + const shop = n.shop.item; + + return { + id: n.id, + hourlyPay: n.hourlyPay, + startsAt: n.startsAt, + workhour: n.workhour, + description: n.description, + closed: n.closed, + shopId: shop.id, + name: shop.name, + category: shop.category, + address1: shop.address1, + shopDescription: shop.description, + imageUrl: shop.imageUrl, + originalHourlyPay: shop.originalHourlyPay, + }; +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index d71b207..b539752 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -17,7 +17,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) { (page => (
    -
    {page}
    +
    {page}