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
2 changes: 1 addition & 1 deletion src/components/features/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { NoticeListSection, RecentNoticeList } from './noticeList';
export { CustomNotice, NoticeEmpty, NoticeListSection, RecentNoticeList } from './noticeList';
42 changes: 42 additions & 0 deletions src/components/features/noticeList/customNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Container, HorizontalScroll } from '@/components/layout';
import { Post, SkeletonUI } from '@/components/ui';
import useAuth from '@/hooks/useAuth';
import useCustomNotices from './hooks/useCustomNotices';

const CustomNoticeList = () => {
const { user } = useAuth();
const { notices, isLoading, error } = useCustomNotices(user?.address);

if (error) {
return <div>{error}</div>;
}

return (
<>
{isLoading ? (
<SkeletonUI
count={3}
className='min-h-[270px] target:min-h-[276px] desktop:min-h-[344px]'
/>
) : (
<HorizontalScroll as='ul' className='flex gap-x-4 gap-y-8 px-3 desktop:px-8'>
{notices.map(notice => (
<li key={notice.id} className='min-w-[310px] flex-initial'>
<Post notice={notice} />
</li>
))}
</HorizontalScroll>
)}
</>
);
};

const CustomNotice = () => (
<div className='bg-red-100'>
<Container as='section' isPage className='!px-0'>
<h2 className='mb-4 px-3 text-heading-l font-bold tablet:mb-8 tablet:px-8'>맞춤공고</h2>
<CustomNoticeList />
</Container>
</div>
);
export default CustomNotice;
55 changes: 55 additions & 0 deletions src/components/features/noticeList/hooks/useCustomNotices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import useAsync from '@/hooks/useAsync';
import axiosInstance from '@/lib/axios';
import { paramsSerializer } from '@/lib/utils/paramsSerializer';
import { toPostCard } from '@/lib/utils/parse';
import { NoticeQuery } from '@/types/api';
import { PostCard } from '@/types/notice';
import { useCallback, useEffect } from 'react';

const useCustomNotices = (address?: string) => {
const { data, isLoading, error, fetch } = useAsync<PostCard[]>();

const fetchCustom = useCallback(async () => {
const now = new Date();
now.setSeconds(now.getSeconds() + 15);

// 기본 쿼리
const baseQuery: NoticeQuery = {
sort: 'time',
startsAtGte: now.toISOString(),
limit: 3,
};

const firstQuery: NoticeQuery = address ? { ...baseQuery, address: [address] } : baseQuery;
const getCustom = axiosInstance
.get('/notices', {
params: firstQuery,
paramsSerializer: { serialize: paramsSerializer },
})
.then(async res => {
const items = res.data.items.map(toPostCard);
if (items.length === 0) {
const fallbackRes = await axiosInstance.get('/notices', {
params: baseQuery,
paramsSerializer: { serialize: paramsSerializer },
});
return fallbackRes.data.items.map(toPostCard);
}
return items;
});

await fetch(getCustom);
}, [address, fetch]);

useEffect(() => {
fetchCustom();
}, []);

return {
notices: data ?? [],
isLoading,
error,
fetchCustom,
};
};
export default useCustomNotices;
86 changes: 86 additions & 0 deletions src/components/features/noticeList/hooks/useNotices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import useAsync from '@/hooks/useAsync';
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 { useCallback, useState } from 'react';

const INIT_FILTER_DATA: NoticeQuery = {
sort: 'time',
};

const useNotices = (initialQuery: Partial<NoticeQuery> = {}) => {
const { data, isLoading, isInitialized, error, fetch } = useAsync<PostCard[]>();
const [filters, setFiltersState] = useState<NoticeQuery>(INIT_FILTER_DATA);
const [pagination, setPagination] = useState<PaginatedResponse>({
offset: 0,
limit: 6,
count: 0,
hasNext: false,
});

const changeTimeFilter = useCallback((q: Partial<NoticeQuery>): Partial<NoticeQuery> => {
const now = new Date();
now.setSeconds(now.getSeconds() + 15); // 서버 시간 오차 대비

// startsAtGte가 없거나, 현재보다 과거면 현재 시각으로 보정
if (!q.startsAtGte || new Date(q.startsAtGte) < now) {
return { ...q, startsAtGte: now.toISOString() };
}

return q;
}, []);

const fetchNotices = useCallback(
async (query?: Partial<NoticeQuery>) => {
// 검색 필터 업데이트
const mergedFilter: NoticeQuery = {
...filters, // 내부 초기값
limit: pagination.limit,
offset: pagination.offset,
...initialQuery, // 외부 초기값
...(query ?? {}), // fetchNotices 호출 시 추가 값
};
const queryUpdate = changeTimeFilter(mergedFilter) as NoticeQuery;
// 상태에도 반영하여 UI와 동기화

setFiltersState(prev => ({ ...prev, ...queryUpdate }));
// 필터기반 패치
const getNotices = axiosInstance
.get('/notices', {
params: queryUpdate,
paramsSerializer: { serialize: paramsSerializer },
})
.then(res => {
setPagination({
offset: res.data.offset,
limit: res.data.limit,
count: res.data.count,
hasNext: res.data.hasNext,
});
return res.data.items.map(toPostCard);
});

await fetch(getNotices);
},
[initialQuery, fetch, filters, changeTimeFilter]
);

const reset = useCallback(() => {
setFiltersState(INIT_FILTER_DATA);
fetchNotices(INIT_FILTER_DATA);
}, []);

return {
notices: data ?? [],
pagination,
isLoading,
isInitialized,
error,
fetchNotices,
filters,
reset,
};
};
export default useNotices;
6 changes: 2 additions & 4 deletions src/components/features/noticeList/hooks/useRecentNotice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ const RECENT_KEY = 'thejulge_recent';
// 최근 본 공고 저장
export const useRecentNotice = (notice: NoticeCard) => {
const handleRecentNotice = useCallback(() => {
if (!notice) return;

const current: RecentNotice = {
id: notice.id,
shopId: notice.shopId,
Expand Down Expand Up @@ -42,7 +40,7 @@ export const useRecentNotice = (notice: NoticeCard) => {
};

// 최근 본 공고 불러오기
export function useRecentNoticeList() {
export const useRecentNoticeList = () => {
const [recentNotices, setRecentNotices] = useState<RecentNotice[]>([]);

useEffect(() => {
Expand All @@ -54,4 +52,4 @@ export function useRecentNoticeList() {
}, []);

return { recentNotices };
}
};
5 changes: 3 additions & 2 deletions src/components/features/noticeList/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as NoticeListSection } from './noticeListSection';

export { default as CustomNotice } from './customNotice';
export { default as RecentNoticeList } from './recentNoticeList';
export { default as NoticeListSection } from './noticeListSection';
export { default as NoticeEmpty } from './noticeEmpty';
2 changes: 1 addition & 1 deletion src/components/features/noticeList/noticeEmpty.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const NoticeEmpty = ({ q, onReset }: NoticeEmptyProps) => {
홈으로 돌아가기
</Button>
) : (
<Button variant='secondary' size='sm' onClick={onReset}>
<Button variant='secondary' size='xs38' onClick={onReset}>
전체 공고 보기
</Button>
)}
Expand Down
44 changes: 19 additions & 25 deletions src/components/features/noticeList/noticeList.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,33 @@
import { Post } from '@/components/ui';
import { Pagination } from '@/components/ui/pagination';
import { useNotice } from '@/context/noticeProvider';
import { Post, SkeletonUI } from '@/components/ui';
import { ApiAsync } from '@/types/api';
import { PostCard } from '@/types/notice';
import NoticeEmpty from './noticeEmpty';

interface NoticeProps {
interface NoticeProps extends ApiAsync {
notices: PostCard[];
q?: string;
reset: () => void;
}

const NoticeList = ({ q }: NoticeProps) => {
const { notices, isLoading, error, pagination, fetchNotices } = useNotice();
const NoticeList = ({ notices, q, isLoading, isInitialized, reset, error }: NoticeProps) => {
if (error) {
return <div> {error}</div>;
}
if (!isInitialized || isLoading) {
if (q) return;
<SkeletonUI count={6} className='min-h-[270px] target:min-h-[276px] desktop:min-h-[344px]' />;
}

if (notices.length === 0) {
return <div>{q && q + '에 대한 '}공고가 존재하지 않습니다</div>;
return <NoticeEmpty q={q} onReset={() => reset()} />;
}

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
total={pagination.count}
limit={pagination.limit}
offset={pagination.offset}
onPageChange={next => fetchNotices({ offset: next })}
className='mt-8 tablet:mt-10'
/>
</>
<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>
);
};
export default NoticeList;
35 changes: 21 additions & 14 deletions src/components/features/noticeList/noticeListFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Dropdown, Filter } from '@/components/ui';
import { getActiveFilterCount } from '@/components/ui/filter/getActiveFilterCount';
import { SORT_CODE, type SortCode } from '@/constants/dropdown';
import { useNotice } from '@/context/noticeProvider';
import { FilterQuery, type sort } from '@/types/api';
import { useState } from 'react';
import { FilterQuery, NoticeQuery, type sort } from '@/types/api';
import { useMemo } from 'react';

const SORT_TO_API: Record<SortCode, sort> = {
'마감 임박 순': 'time',
Expand All @@ -12,20 +11,28 @@ const SORT_TO_API: Record<SortCode, sort> = {
'가나다 순': 'shop',
};

const NoticeListFilter = () => {
const { fetchNotices, updateFilters, filters } = useNotice();
const [sort, setSort] = useState<SortCode>('마감 임박 순');
interface NoticeListFilterProps {
filters: NoticeQuery;
onSortChange: (sort: sort) => void;
onFilterSubmit: (filter: FilterQuery) => void;
}

const NoticeListFilter = ({ filters, onSortChange, onFilterSubmit }: NoticeListFilterProps) => {
const selectedLabel = useMemo<SortCode>(() => {
const currentSort = filters.sort ?? 'time';
const entry = Object.entries(SORT_TO_API).find(([, v]) => v === currentSort);
return entry?.[0] as SortCode;
}, [filters.sort]);

const appliedCount = getActiveFilterCount(filters);

const handleSort = (label: SortCode) => {
const sort = SORT_TO_API[label];
fetchNotices({ sort });
setSort(label);
const handleSort = (next: SortCode) => {
const s = SORT_TO_API[next];
onSortChange(s);
};

const handleFilter = (q: FilterQuery) => {
updateFilters(q);
fetchNotices(q);
const handleFilter = (filter: FilterQuery) => {
onFilterSubmit(filter);
};

return (
Expand All @@ -35,7 +42,7 @@ const NoticeListFilter = () => {
ariaLabel='공고 정렬 기준'
size='sm'
values={SORT_CODE}
selected={sort}
selected={selectedLabel}
onChange={handleSort}
/>
<Filter appliedCount={appliedCount} value={filters} onSubmit={handleFilter} align='right' />
Expand Down
15 changes: 15 additions & 0 deletions src/components/features/noticeList/noticeListHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const NoticeListHeader = ({ q }: { q?: string }) => {
return (
<h2 className='text-heading-l font-bold'>
{q ? (
<>
<span className='text-red-500'>{q}</span>에 대한 공고 목록
</>
) : (
'전체공고'
)}
</h2>
);
};

export default NoticeListHeader;
Loading
Loading