diff --git a/src/api/applications.ts b/src/api/applications.ts new file mode 100644 index 0000000..96b3a35 --- /dev/null +++ b/src/api/applications.ts @@ -0,0 +1,41 @@ +import axiosInstance from '@/lib/axios'; +import type { ApiResponse } from '@/types/api'; +import { ApplicationItem, ApplicationListResponse } from '@/types/applications'; + +// 유저의 공고 지원 내역 전체 조회 +export async function getAllUserApplications({ + userId, + limit = 10, +}: { + userId: string; + limit?: number; +}) { + const results: ApiResponse[] = []; + let offset = 0; + let hasNext = true; + + while (hasNext) { + const { data } = await axiosInstance.get( + `/users/${userId}/applications`, + { params: { offset, limit } } + ); + + results.push(...data.items); + hasNext = data.hasNext; + offset += limit; + } + + return results; // 모든 페이지 합쳐 반환 +} + +// 가게의 특정 공고 지원 등록 +export const postApplication = async (shopId: string, noticeId: string) => { + await axiosInstance.post(`/shops/${shopId}/notices/${noticeId}/applications`); +}; + +// 가게의 특정 공고 지원 취소 +export const putApplication = async (shopId: string, noticeId: string, applicationId: string) => { + await axiosInstance.put(`/shops/${shopId}/notices/${noticeId}/applications/${applicationId}`, { + status: 'canceled', + }); +}; diff --git a/src/api/employer.ts b/src/api/employer.ts index 954fdc9..fe77fb8 100644 --- a/src/api/employer.ts +++ b/src/api/employer.ts @@ -23,7 +23,7 @@ export async function postShop(body: RegisterFormData) { export async function postPresignedUrl(imageName: string) { const { data } = await axios.post('/images', { name: imageName }); - console.log(data); + // console.log(data); return data.item.url; } diff --git a/src/api/index.ts b/src/api/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/features/index.ts b/src/components/features/index.ts index 8816e4e..17b0d29 100644 --- a/src/components/features/index.ts +++ b/src/components/features/index.ts @@ -1 +1 @@ -export { AllNoticeList, RecentNoticeList, RecommendedNoticeList } from './noticeList'; +export { NoticeListSection, RecentNoticeList } from './noticeList'; diff --git a/src/components/features/noticeList/allNoticeList.tsx b/src/components/features/noticeList/allNoticeList.tsx deleted file mode 100644 index 2e5ad73..0000000 --- a/src/components/features/noticeList/allNoticeList.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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/hooks/useRecentNotice.ts b/src/components/features/noticeList/hooks/useRecentNotice.ts new file mode 100644 index 0000000..a508dbc --- /dev/null +++ b/src/components/features/noticeList/hooks/useRecentNotice.ts @@ -0,0 +1,57 @@ +import type { NoticeCard, RecentNotice } from '@/types/notice'; +import { useCallback, useEffect, useState } from 'react'; + +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, + name: notice.name, + address1: notice.address1, + imageUrl: notice.imageUrl, + hourlyPay: notice.hourlyPay, + startsAt: notice.startsAt, + workhour: notice.workhour, + closed: notice.closed, + originalHourlyPay: notice.originalHourlyPay, + viewedAt: new Date().toISOString(), + }; + + // 기존 데이터 가져오기 + const stored = localStorage.getItem(RECENT_KEY); + let recentList: RecentNotice[] = stored ? JSON.parse(stored) : []; + + // 중복 제거 같은 noticeId면 제거 + recentList = recentList.filter(item => item.id !== current.id); + + // 최신 항목 맨 앞에 추가 + recentList.unshift(current); + + // 최대 6개까지만 저장 + if (recentList.length > 6) recentList = recentList.slice(0, 6); + + localStorage.setItem(RECENT_KEY, JSON.stringify(recentList)); + }, [notice]); + + return { handleRecentNotice }; +}; + +// 최근 본 공고 불러오기 +export function useRecentNoticeList() { + const [recentNotices, setRecentNotices] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem(RECENT_KEY); + if (stored) { + const parsed: RecentNotice[] = JSON.parse(stored); + setRecentNotices(parsed); + } + }, []); + + return { recentNotices }; +} diff --git a/src/components/features/noticeList/index.ts b/src/components/features/noticeList/index.ts index 414cc46..3129276 100644 --- a/src/components/features/noticeList/index.ts +++ b/src/components/features/noticeList/index.ts @@ -1,3 +1,3 @@ -export { default as AllNoticeList } from './allNoticeList'; +export { default as NoticeListSection } from './noticeListSection'; + 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 deleted file mode 100644 index e383609..0000000 --- a/src/components/features/noticeList/noticeList.styles.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 index 50ab3b7..a9d1acd 100644 --- a/src/components/features/noticeList/noticeList.tsx +++ b/src/components/features/noticeList/noticeList.tsx @@ -1,23 +1,18 @@ import { Post } from '@/components/ui'; import { Pagination } from '@/components/ui/pagination'; -import { NoticeQuery, PaginatedResponse } from '@/types/api'; -import { type PostCard } from '@/types/notice'; +import { useNotice } from '@/context/noticeProvider'; -interface NoticeListProps { - notices: PostCard[]; - pagination?: PaginatedResponse; - isLoading: boolean; - error: string | null; - fetchNotices?: (params?: Partial) => Promise; +interface NoticeProps { + q?: string; } -const NoticeList = ({ notices, isLoading, error, pagination, fetchNotices }: NoticeListProps) => { - +const NoticeList = ({ q }: NoticeProps) => { + const { notices, isLoading, error, pagination, fetchNotices } = useNotice(); if (error) { return
{error}
; } if (notices.length === 0) { - return
공고가 존재하지 않습니다
; + return
{q && q + '에 대한 '}공고가 존재하지 않습니다
; } return ( @@ -31,15 +26,13 @@ const NoticeList = ({ notices, isLoading, error, pagination, fetchNotices }: Not ))} )} - {pagination && ( - fetchNotices?.({ offset: next })} - className='mt-8 tablet:mt-10' - /> - )} + fetchNotices({ offset: next })} + className='mt-8 tablet:mt-10' + /> ); }; diff --git a/src/components/features/noticeList/noticeListFilter.tsx b/src/components/features/noticeList/noticeListFilter.tsx new file mode 100644 index 0000000..af815d8 --- /dev/null +++ b/src/components/features/noticeList/noticeListFilter.tsx @@ -0,0 +1,45 @@ +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'; + +const SORT_TO_API: Record = { + '마감 임박 순': 'time', + '시급 많은 순': 'pay', + '시간 적은 순': 'hour', + '가나다 순': 'shop', +}; + +const NoticeListFilter = () => { + const { fetchNotices, updateFilters, filters } = useNotice(); + const [sort, setSort] = useState('마감 임박 순'); + const appliedCount = getActiveFilterCount(filters); + + 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 NoticeListFilter; diff --git a/src/components/features/noticeList/noticeListSection.tsx b/src/components/features/noticeList/noticeListSection.tsx new file mode 100644 index 0000000..fc742cf --- /dev/null +++ b/src/components/features/noticeList/noticeListSection.tsx @@ -0,0 +1,52 @@ +import NoticeList from '@/components/features/noticeList/noticeList'; +import NoticeListFilter from '@/components/features/noticeList/noticeListFilter'; +import { Container } from '@/components/layout'; +import { useNotice } from '@/context/noticeProvider'; +import type { NoticeQuery } from '@/types/api'; +import { useEffect } from 'react'; + +interface NoticeListSectionProps { + title?: string; // 제목 + q?: string; // 검색어 + showFilter?: boolean; // 우측 필터 표시 여부 + initialFilters?: Partial; // 섹션 진입 시 적용할 초기 필터 +} + +const NoticeListSection = ({ + title = '전체 공고', + q, + showFilter, + initialFilters, +}: NoticeListSectionProps) => { + const { updateFilters, fetchNotices } = useNotice(); + // 섹션 진입 및 q/initialFilters 변경 시 초기 필터 반영하여 조회 + useEffect(() => { + const hasInitial = Boolean(initialFilters && Object.keys(initialFilters).length > 0); + if (hasInitial) { + updateFilters(initialFilters!); + fetchNotices(initialFilters); + } else { + fetchNotices(); + } + + // fetchNotices와 updateFilters 함수는 initialFilters값이 변경될때 새로만들어짐 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [q, initialFilters]); + return ( + +
+ {q ? ( +

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

+ ) : ( +

{title}

+ )} + {showFilter && } +
+ +
+ ); +}; + +export default NoticeListSection; diff --git a/src/components/features/noticeList/recentNoticeList.tsx b/src/components/features/noticeList/recentNoticeList.tsx index a477dfa..fbf9ecd 100644 --- a/src/components/features/noticeList/recentNoticeList.tsx +++ b/src/components/features/noticeList/recentNoticeList.tsx @@ -1,11 +1,20 @@ -import { noticeListLayout } from '@/components/features/noticeList/noticeList.styles'; import { Container } from '@/components/layout'; +import { Post } from '@/components/ui'; +import { useRecentNoticeList } from './hooks/useRecentNotice'; -// @TODO 최근에 본 공고 리스트 출력 const RecentNoticeList = () => { + const { recentNotices } = useRecentNoticeList(); + + if (recentNotices.length === 0) return null; + return ( -

최근에 본 공고

+

최근에 본 공고

+
+ {recentNotices.map(notice => ( + + ))} +
); }; diff --git a/src/components/features/noticeList/recommendedNoticeList.tsx b/src/components/features/noticeList/recommendedNoticeList.tsx deleted file mode 100644 index fb4bd15..0000000 --- a/src/components/features/noticeList/recommendedNoticeList.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 5a5cc7c..0000000 --- a/src/components/features/noticeList/searchResultList.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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/header/searchBar.tsx b/src/components/layout/header/searchBar.tsx index ca9b4be..e014dc4 100644 --- a/src/components/layout/header/searchBar.tsx +++ b/src/components/layout/header/searchBar.tsx @@ -1,12 +1,23 @@ -import { Icon } from '@/components/ui'; +import { Icon } from '@/components/ui/'; import { cn } from '@/lib/utils/cn'; import { useRouter } from 'next/router'; -import { ChangeEvent, FormEvent, useState } from 'react'; +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; const SearchBar = ({ initValue = '' }) => { const [keyword, setKeyword] = useState(initValue); const router = useRouter(); + // 라우트 변경 시 검색어 동기화 search 페이지면 검색어 동기화, 그 외 페이지 초기화 + useEffect(() => { + const isSearchPage = router.pathname === '/search'; + if (isSearchPage) { + const q = typeof router.query.q === 'string' ? router.query.q : ''; + setKeyword(q); + } else { + setKeyword(''); + } + }, [router.pathname, router.query.q]); + const handleChange = (e: ChangeEvent) => setKeyword(e.target.value); const handleSubmit = (e: FormEvent) => { diff --git a/src/components/ui/filter/components/filterBody.tsx b/src/components/ui/filter/components/filterBody.tsx index 9c0c7b2..b824af3 100644 --- a/src/components/ui/filter/components/filterBody.tsx +++ b/src/components/ui/filter/components/filterBody.tsx @@ -34,7 +34,23 @@ const FilterBody = ({ formData, onChange }: FilterBodyProps) => { const handleDateChange = (date: Date | string) => { if (typeof date === 'string') return; - const rfc3339String = date.toISOString(); + + const now = new Date(); + const selected = new Date(date); + + // 선택한 날짜가 오늘이라면, 현재 시각 기준으로 설정 + 60초로 서버 시차문제 방지 + if ( + selected.getFullYear() === now.getFullYear() && + selected.getMonth() === now.getMonth() && + selected.getDate() === now.getDate() + ) { + selected.setHours(now.getHours(), now.getMinutes(), now.getSeconds() + 60, 0); + } else { + // 미래 날짜면 00시로 + selected.setHours(0, 0, 0, 0); + } + + const rfc3339String = selected.toISOString(); onChange(prev => ({ ...prev, startsAtGte: rfc3339String })); }; diff --git a/src/context/appProvider.tsx b/src/context/appProvider.tsx index 32dff00..e22208c 100644 --- a/src/context/appProvider.tsx +++ b/src/context/appProvider.tsx @@ -1,11 +1,14 @@ import { ReactNode } from 'react'; import AuthProvider from './authProvider'; import { ToastProvider } from './toastContext'; +import { UserApplicationsProvider } from './userApplicationsProvider'; const AppProvider = ({ children }: { children: ReactNode }) => { return ( - {children} + + {children} + ); }; diff --git a/src/context/index.ts b/src/context/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/context/mockAuthProvider/authState.stories.tsx b/src/context/mockAuthProvider/authState_stories.tsx similarity index 98% rename from src/context/mockAuthProvider/authState.stories.tsx rename to src/context/mockAuthProvider/authState_stories.tsx index 704e937..5e2b00e 100644 --- a/src/context/mockAuthProvider/authState.stories.tsx +++ b/src/context/mockAuthProvider/authState_stories.tsx @@ -68,5 +68,3 @@ // // ], // // }; // export default {}; - -export default {}; diff --git a/src/context/noticeProvider.tsx b/src/context/noticeProvider.tsx index 587d59a..01472a5 100644 --- a/src/context/noticeProvider.tsx +++ b/src/context/noticeProvider.tsx @@ -3,7 +3,7 @@ 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'; +import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react'; interface NoticeContextValue { notices: PostCard[]; @@ -16,13 +16,12 @@ interface NoticeContextValue { reset: () => void; } -//현재 필터 상태(filters)의 초기값 == 현재 이 화면은 어떤 조건으로 공고를 보고 있는가를 나타내는 전역상태 +//현재 필터 상태(filters)의 초기값 const INIT_FILTER_DATA: NoticeQuery = { sort: 'time', - // startsAtGte: new Date().toISOString(), }; -export const NoticeContext = createContext(undefined); +const NoticeContext = createContext(undefined); // 맞춤 공고, 전체 공고, 검색된 공고등 공고 조회 관리 export const NoticeProvider = ({ children }: { children: ReactNode }) => { @@ -37,12 +36,31 @@ export const NoticeProvider = ({ children }: { children: ReactNode }) => { hasNext: false, }); + // sort=time일 때 startsAtGte를 항상 현재 시각으로 보정 + const changeTimeFilter = useCallback((q: Partial): Partial => { + if (q.sort !== 'time') return q; + const now = new Date(); + now.setSeconds(now.getSeconds() + 120); + const nowIso = now.toISOString(); + const startsAt = q.startsAtGte ? new Date(q.startsAtGte) : null; + if (!startsAt || isNaN(startsAt.getTime()) || startsAt.getTime() < now.getTime()) { + return { ...q, startsAtGte: nowIso }; + } + return q; + }, []); + // 공고 데이터 요청 함수. 파라미터를 넣으면 검색/정렬 가능 const fetchNotices: NoticeContextValue['fetchNotices'] = useCallback( async params => { try { setIsLoading(true); - const query = { ...filters, ...(params ?? {}) }; // 새 조건 덮어쓰기 (각 페이지별 상이한 조건 덮어쓰기) + setError(null); + const merged = { ...filters, ...(params ?? {}) }; + const query = changeTimeFilter(merged); // time 정렬 보정 + // 뒤로가기 등으로 과거 시각이 남지 않도록 보정된 startsAtGte를 상태에도 반영 + if (query.startsAtGte !== filters.startsAtGte || query.sort !== filters.sort) { + setFiltersState(prev => ({ ...prev, ...query })); + } const res = await axiosInstance.get('/notices', { params: query, paramsSerializer: { serialize: paramsSerializer }, @@ -57,8 +75,11 @@ export const NoticeProvider = ({ children }: { children: ReactNode }) => { count: res.data.count, hasNext: res.data.hasNext, }); - } catch (err) { - setError('공고를 불러오는 중 오류가 발생했습니다.'); + } catch { + setError(`공고를 불러오는 중 오류가 발생했습니다.`); + setNotices([]); + setPagination(prev => ({ ...prev, count: 0, hasNext: false })); + setFiltersState(INIT_FILTER_DATA); } finally { setIsLoading(false); } @@ -68,9 +89,12 @@ export const NoticeProvider = ({ children }: { children: ReactNode }) => { // 기존 필터를 유지하면서 filters 상태만 부분 업데이트 하여 특정 조건만 수정 // 사용자가 선택한 필터 조건을 Context 상태에 반영하는 함수 - const updateFilters: NoticeContextValue['updateFilters'] = useCallback(partial => { - setFiltersState(prev => ({ ...prev, ...partial })); - }, []); + const updateFilters: NoticeContextValue['updateFilters'] = useCallback( + partial => { + setFiltersState(prev => ({ ...prev, ...changeTimeFilter(partial) })); + }, + [changeTimeFilter] + ); // 공고와 필터 초기화 const reset = useCallback(() => { @@ -78,16 +102,19 @@ export const NoticeProvider = ({ children }: { children: ReactNode }) => { setFiltersState(INIT_FILTER_DATA); }, []); - const value = { - notices, - pagination, - isLoading, - error, - filters, - fetchNotices, - updateFilters, - reset, - }; + const value = useMemo( + () => ({ + notices, + pagination, + isLoading, + error, + filters, + fetchNotices, + updateFilters, + reset, + }), + [notices, pagination, isLoading, error, filters, fetchNotices, updateFilters, reset] + ); return {children}; }; @@ -97,13 +124,3 @@ export const useNotice = () => { 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/context/toastContext/toastContext.tsx b/src/context/toastContext/toastContext.tsx index 1aa5333..02dd415 100644 --- a/src/context/toastContext/toastContext.tsx +++ b/src/context/toastContext/toastContext.tsx @@ -43,7 +43,7 @@ const ToastProvider = ({ children }: { children: ReactNode }) => { message ? (
{message}
diff --git a/src/context/userApplicationsProvider.tsx b/src/context/userApplicationsProvider.tsx new file mode 100644 index 0000000..157f930 --- /dev/null +++ b/src/context/userApplicationsProvider.tsx @@ -0,0 +1,147 @@ +// context/UserApplicationsProvider.tsx +import { getAllUserApplications, postApplication, putApplication } from '@/api/applications'; +import useAuth from '@/hooks/useAuth'; +import { ApiResponse } from '@/types/api'; +import { ApplicationItem, ApplicationStatus } from '@/types/applications'; +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +interface UserApplicationsContextValue { + applications: ApiResponse[]; + isLoading: boolean; + error: string | null; + isApplied: (noticeId: string) => boolean; // 특정 공고 지원 여부 + applicationStatus: (noticeId: string) => ApplicationStatus | null; // 특정 공고의 지원 상태 + applyNotice: (shopId: string, noticeId: string) => Promise; // 공고 지원 + cancelNotice: (noticeId: string) => Promise; // 공고 취소 + refresh: () => Promise; // 전체 새로고침(fetch) +} + +const UserApplicationsContext = createContext(null); + +// 유저 공고 조회, 신청, 취소 (마이페이지, 상단 알림, 공고 상세 동일한 데이터 사용) +export const UserApplicationsProvider = ({ children }: { children: ReactNode }) => { + const { user } = useAuth(); + const [applications, setApplications] = useState[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 전체 신청 내역 불러오기 + const fetchAllApplications = useCallback(async () => { + if (!user?.id) return; + try { + setIsLoading(true); + setError(null); + const all = await getAllUserApplications({ userId: user.id, limit: 50 }); + setApplications(all); + } catch { + setError(`신청 내역을 불러오지 못했습니다`); + } finally { + setIsLoading(false); + } + }, [user]); + + // 로그인 유저 변경 시 fetch + useEffect(() => { + if (user) fetchAllApplications(); + else setApplications([]); + }, [user, fetchAllApplications]); + + // 특정 공고 지원 여부 + const isApplied = useCallback( + (noticeId: string) => + applications.some( + app => + app.item.notice.item.id === noticeId && + (app.item.status === 'pending' || app.item.status === 'accepted') + ), + [applications] + ); + + // 특정 공고의 지원 상태 반환 + const applicationStatus = useCallback( + (noticeId: string): ApplicationStatus | null => { + const found = applications.find(app => app.item.notice.item.id === noticeId); + return found ? found.item.status : null; + }, + [applications] + ); + + // 특정 공고 지원 + const applyNotice = useCallback( + async (shopId: string, noticeId: string) => { + if (!user?.id) { + setError('로그인이 필요합니다.'); + return; + } + await postApplication(shopId, noticeId); + await fetchAllApplications(); // 최신화 반영 + }, + [user, fetchAllApplications] + ); + + // 특정 공고 지원 취소 + const cancelNotice = useCallback( + async (noticeId: string) => { + if (!user?.id) { + setError('로그인이 필요합니다'); + return; + } + + const target = applications.find(app => app.item.notice.item.id === noticeId); + + if (!target) { + setError('신청 내역을 찾을 수 없습니다'); + return; + } + const shopId = target.item.shop.item.id; + const applicationId = target.item.id; + + await putApplication(shopId, noticeId, applicationId); + await fetchAllApplications(); // 최신화 반영 + }, + [applications, user, fetchAllApplications] + ); + + const value = useMemo( + () => ({ + applications, + isLoading, + error, + isApplied, + applicationStatus, + applyNotice, + cancelNotice, + refresh: fetchAllApplications, + }), + [ + applications, + isLoading, + error, + isApplied, + applicationStatus, + applyNotice, + cancelNotice, + fetchAllApplications, + ] + ); + + return ( + {children} + ); +}; + +export const useUserApplications = () => { + const context = useContext(UserApplicationsContext); + if (!context) { + throw new Error('useUserApplications는 Provider 안에서 사용해야 합니다.'); + } + return context; +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index b539752..52b1628 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}