Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/features/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AllNoticeList, RecentNoticeList, RecommendedNoticeList } from './noticeList';
70 changes: 70 additions & 0 deletions src/components/features/noticeList/allNoticeList.tsx
Original file line number Diff line number Diff line change
@@ -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<SortCode, sort> = {
'마감 임박 순': 'time',
'시급 많은 순': 'pay',
'시간 적은 순': 'hour',
'가나다 순': 'shop',
};

const AllNoticeList = () => {
const { notices, fetchNotices, updateFilters, filters, error, isLoading, pagination } =
useNotice();
const [sort, setSort] = useState<SortCode>('마감 임박 순');
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 (
<Container as='section' isPage>
<div className='mb-4 flex items-center justify-between tablet:mb-8'>
<h2 className={noticeListLayout.title({ className: 'mb-0 tablet:mb-0' })}>전체 공고</h2>
<div className='flex gap-3'>
<Dropdown
name='sort'
ariaLabel='공고 정렬 기준'
size='sm'
values={SORT_CODE}
selected={sort}
onChange={handleSort}
/>
<Filter
appliedCount={appliedCount}
value={filters}
onSubmit={handleFilter}
align='right'
/>
</div>
</div>
<NoticeList
notices={notices}
isLoading={isLoading}
error={error}
pagination={pagination}
fetchNotices={fetchNotices}
/>
</Container>
);
};
export default AllNoticeList;
3 changes: 3 additions & 0 deletions src/components/features/noticeList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as AllNoticeList } from './allNoticeList';
export { default as RecentNoticeList } from './recentNoticeList';
export { default as RecommendedNoticeList } from './recommendedNoticeList';
6 changes: 6 additions & 0 deletions src/components/features/noticeList/noticeList.styles.ts
Original file line number Diff line number Diff line change
@@ -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,
};
46 changes: 46 additions & 0 deletions src/components/features/noticeList/noticeList.tsx
Original file line number Diff line number Diff line change
@@ -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<NoticeQuery>) => Promise<void>;
}

const NoticeList = ({ notices, isLoading, error, pagination, fetchNotices }: NoticeListProps) => {

if (error) {
return <div> {error}</div>;
}
if (notices.length === 0) {
return <div>공고가 존재하지 않습니다</div>;
}

return (
<>
{isLoading ? (
<div>로딩중 .. 스켈레톤 UI 삽입예정</div>
) : (
<div className='grid gap-x-4 gap-y-8 sm:grid-cols-2 desktop:grid-cols-3'>
{notices.map(notice => (
<Post key={notice.id} notice={notice} />
))}
</div>
)}
{pagination && (
<Pagination
total={pagination.count}
limit={pagination.limit}
offset={pagination.offset}
onPageChange={next => fetchNotices?.({ offset: next })}
className='mt-8 tablet:mt-10'
/>
)}
</>
);
};
export default NoticeList;
12 changes: 12 additions & 0 deletions src/components/features/noticeList/recentNoticeList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { noticeListLayout } from '@/components/features/noticeList/noticeList.styles';
import { Container } from '@/components/layout';

// @TODO 최근에 본 공고 리스트 출력
const RecentNoticeList = () => {
return (
<Container as='section' isPage>
<h2 className={noticeListLayout.title()}>최근에 본 공고</h2>
</Container>
);
};
export default RecentNoticeList;
30 changes: 30 additions & 0 deletions src/components/features/noticeList/recommendedNoticeList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='bg-red-100'>
<Container as='section' isPage>
<h2 className={noticeListLayout.title()}>맞춤 공고</h2>
<NoticeList
notices={notices}
isLoading={isLoading}
error={error}
fetchNotices={fetchNotices}
/>
</Container>
</div>
);
};
export default RecommendedNoticeList;
21 changes: 21 additions & 0 deletions src/components/features/noticeList/searchResultList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='bg-red-100'>
<Container as='section' isPage>
<h2 className={noticeListLayout.title()}>
<span className='text-red-500'>{query}</span>에 대한 공고 목록
</h2>
<PostWrapper />
</Container>
</div>
);
};
export default SearchResultList;
3 changes: 1 addition & 2 deletions src/components/layout/container/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ const Container = ({ as: Component = 'div', isPage = false, className, children
return (
<Component
className={cn(
'relative z-[1]',
'mx-auto w-full max-w-[1028px] px-3',
'tablet:px-8',
isPage && "py-10 tablet:py-16" ,
isPage && 'py-10 tablet:py-16',
className
)}
>
Expand Down
5 changes: 4 additions & 1 deletion src/components/ui/card/notice/notice.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,12 +10,14 @@ interface NoticeProps<T extends Partial<NoticeCard>> {
notice: T;
variant?: NoticeVariant;
children: ReactNode;
className?: string;
}

const Notice = <T extends Partial<NoticeCard>>({
notice,
variant = 'notice',
children,
className,
}: NoticeProps<T>) => {
const {
hourlyPay,
Expand Down Expand Up @@ -63,7 +66,7 @@ const Notice = <T extends Partial<NoticeCard>>({
};

return (
<Container className={noticeWrapper()}>
<Container as='section' className={cn(noticeWrapper(), className)}>
{variant === 'notice' ? (
<RenderNotice items={noticeItem} buttonComponent={children} />
) : (
Expand Down
3 changes: 2 additions & 1 deletion src/components/ui/card/post/post.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const STATUS_LABEL = {

const Post = ({ notice }: PostProps) => {
const {
id,
hourlyPay,
startsAt,
workhour,
Expand All @@ -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 (
<Link href={href} className={postFrame()} aria-label={`${name} 공고 상세로 이동`}>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const Dropdown = <T extends string>({
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)]'
)}
>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/dropdown/hooks/useDropdownPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const useDropdownPosition = (triggerRef: RefObject<HTMLButtonElement>, threshold
useEffect(() => {
const trigger = triggerRef.current;
if (!trigger) return;

// 트리거 기준으로 아래쪽 여유 공간 < threshold 이면 위로, 아니면 아래로 배치
const updatePosition = () => {
const rect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
Expand Down
15 changes: 8 additions & 7 deletions src/components/ui/filter/components/filterBody.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 }));
};

Expand All @@ -50,7 +52,7 @@ const FilterBody = ({ formData, onChange }: FilterBodyProps) => {
<li key={value} className='w-full mobile:w-[calc(50%-6px)]'>
<button
type='button'
className={filterLayout.location()}
className={cn(filterLayout.location(), locations.includes(value) && 'text-red-400')}
onClick={() => addLocation(value)}
>
{value}
Expand All @@ -74,16 +76,15 @@ const FilterBody = ({ formData, onChange }: FilterBodyProps) => {
</div>
)}
</li>
{/* @TODO DateInput 기능 완성 시 작업 */}
{/* <li>
<li>
<DateInput
id='filterStartAt'
label='시작일'
className='gap-2'
value={startAt}
onChange={handleDateChange}
/>
</li> */}
</li>
<li className='flex items-end gap-3'>
<Input
id='filterPay'
Expand Down
11 changes: 9 additions & 2 deletions src/components/ui/filter/components/filterHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { filterLayout } from '@/components/ui/filter/filter.styles';
import { Icon } from '@/components/ui/icon';

const FilterHeader = ({ onClose }: { onClose?: () => void }) => {
interface FilterHeaderProps {
onClose?: () => void;
activeCount?: number;
}

const FilterHeader = ({ onClose, activeCount = 0 }: FilterHeaderProps) => {
return (
<div className={filterLayout.header()}>
<div className='text-heading-s font-bold'>상세 필터</div>
<div className='text-heading-s font-bold'>
상세 필터{activeCount > 0 && <>({activeCount})</>}
</div>
<button type='button' className='icon-btn' onClick={onClose} aria-label='상세 필터 닫기'>
<Icon iconName='close' decorative />
</button>
Expand Down
15 changes: 14 additions & 1 deletion src/components/ui/filter/filter.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -55,6 +67,7 @@ export const filterLayout = {
position: filterPosition,
stickyContent: filterStickyContent,
wrapper: filterWrapper,
placement: filterPlacement,
padding: filterPadding,
header: filterHeader,
body: filterBody,
Expand Down
Loading