diff --git a/package-lock.json b/package-lock.json index 69f22928..c740b9db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4242,13 +4242,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.12", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz", - "integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==", + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -4385,9 +4388,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001762", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", - "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { diff --git a/src/app/App.tsx b/src/app/App.tsx index 989a8a39..c64679f7 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -46,6 +46,7 @@ import { NicknameEditPage } from '@/pages/my/profile/nickname' import { PasswordEditPage } from '@/pages/my/profile/password' import { SocialAccountPage } from '@/pages/my/profile/social' import { WithdrawPage } from '@/pages/my/withdraw' +import { ScrappedPostingsPage } from '@/pages/my/scrapped' import { ErrorPageRoute } from '@/pages/error' import { MobileLayout } from '@/shared/ui/MobileLayout' import { MobileLayoutWithDocbar } from '@/shared/ui/MobileLayoutWithDocbar' @@ -140,6 +141,10 @@ export function App() { element={} /> } /> + } + /> } diff --git a/src/assets/icons/job-lookup-map/Close.svg b/src/assets/icons/job-lookup-map/Close.svg new file mode 100644 index 00000000..b03b00c6 --- /dev/null +++ b/src/assets/icons/job-lookup-map/Close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/job-lookup-map/Filter.svg b/src/assets/icons/job-lookup-map/Filter.svg new file mode 100644 index 00000000..8042bb5d --- /dev/null +++ b/src/assets/icons/job-lookup-map/Filter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/job-lookup-map/MappinMuted.svg b/src/assets/icons/job-lookup-map/MappinMuted.svg new file mode 100644 index 00000000..5fc2955f --- /dev/null +++ b/src/assets/icons/job-lookup-map/MappinMuted.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/features/job-lookup-map/api/posting.ts b/src/features/job-lookup-map/api/posting.ts index 3a84faf3..3bc8e7fd 100644 --- a/src/features/job-lookup-map/api/posting.ts +++ b/src/features/job-lookup-map/api/posting.ts @@ -2,15 +2,21 @@ import axiosInstance from '@/shared/lib/axiosInstance' import type { CommonApiResponse } from '@/shared/types/common' import type { ApplyPostingRequest, - PostingListResponse, + FavoritePostingItem, + FavoritePostingListResponse, PostingDetailResponse, + AddressesResponse, + AddressItem, + PostingListResponse, } from '@/features/job-lookup-map/types/posting' +import type { PostingsListFilters } from '@/features/job-lookup-map/lib/postingFilters' + export type FetchPostingsParams = { pageSize: number cursor?: string searchKeyword?: string -} +} & PostingsListFilters function isCommonApiEnvelope( value: unknown @@ -23,60 +29,273 @@ function isCommonApiEnvelope( ) } -function isPostingDetailResponse( +function normalizePageCursor(cursor: unknown): string | null { + if (typeof cursor === 'string') { + return cursor !== '' ? cursor : null + } + if (cursor == null) return null + const asString = String(cursor) + return asString !== '' ? asString : null +} + +function parsePostingListItem( value: unknown -): value is PostingDetailResponse { - if (value === null || typeof value !== 'object') return false +): PostingListResponse['data'][number] | null { + if (value === null || typeof value !== 'object') return null const record = value as Record - const workspace = record.workspace - return ( - typeof record.id === 'number' && - typeof record.title === 'string' && - typeof record.description === 'string' && - typeof record.payAmount === 'number' && - typeof record.paymentType === 'string' && - typeof record.createdAt === 'string' && - typeof record.scrapped === 'boolean' && - Array.isArray(record.keywords) && - Array.isArray(record.schedules) && - workspace !== null && - typeof workspace === 'object' && - typeof (workspace as { id?: unknown }).id === 'number' - ) + const workspaceRaw = record.workspace + + // 목록 렌더에 필수인 필드만 엄격 검사. 나머지는 기본값으로 보정. + if ( + typeof record.id !== 'number' || + typeof record.title !== 'string' || + typeof record.payAmount !== 'number' || + workspaceRaw === null || + typeof workspaceRaw !== 'object' + ) { + return null + } + + const workspace = workspaceRaw as Record + if (typeof workspace.id !== 'number') return null + + const businessName = + typeof workspace.businessName === 'string' + ? workspace.businessName + : typeof workspace.name === 'string' + ? workspace.name + : '' + + return { + id: record.id, + title: record.title, + payAmount: record.payAmount, + paymentType: + typeof record.paymentType === 'string' ? record.paymentType : 'HOURLY', + createdAt: typeof record.createdAt === 'string' ? record.createdAt : '', + keywords: Array.isArray(record.keywords) + ? (record.keywords as PostingListResponse['data'][number]['keywords']) + : [], + schedules: Array.isArray(record.schedules) + ? (record.schedules as PostingListResponse['data'][number]['schedules']) + : [], + workspace: { + id: workspace.id, + businessName, + name: typeof workspace.name === 'string' ? workspace.name : businessName, + latitude: typeof workspace.latitude === 'number' ? workspace.latitude : 0, + longitude: + typeof workspace.longitude === 'number' ? workspace.longitude : 0, + fullAddress: + typeof workspace.fullAddress === 'string' ? workspace.fullAddress : '', + town: typeof workspace.town === 'string' ? workspace.town : '', + }, + scrapped: typeof record.scrapped === 'boolean' ? record.scrapped : false, + } } -function unwrapPostingDetailBody(body: unknown): PostingDetailResponse { - if (isCommonApiEnvelope(body)) { - if (!isPostingDetailResponse(body.data)) { - throw new Error('공고 상세 응답 형식이 올바르지 않습니다.') +function parseFavoritePostingItem(value: unknown): FavoritePostingItem | null { + if (value === null || typeof value !== 'object') return null + const record = value as Record + const posting = record.posting + if (posting === null || typeof posting !== 'object') return null + const postingRecord = posting as Record + + if ( + typeof record.id !== 'number' || + typeof postingRecord.id !== 'number' || + typeof postingRecord.title !== 'string' || + typeof postingRecord.payAmount !== 'number' + ) { + return null + } + + return { + id: record.id, + createdAt: typeof record.createdAt === 'string' ? record.createdAt : '', + posting: { + id: postingRecord.id, + businessName: + typeof postingRecord.businessName === 'string' + ? postingRecord.businessName + : '', + title: postingRecord.title, + payAmount: postingRecord.payAmount, + paymentType: + typeof postingRecord.paymentType === 'string' + ? postingRecord.paymentType + : 'HOURLY', + }, + } +} + +function normalizePage( + pageRaw: unknown, + fallbackCount: number +): PostingListResponse['page'] { + if (pageRaw === null || typeof pageRaw !== 'object') { + return { + cursor: null, + pageSize: fallbackCount, + totalCount: fallbackCount, } - return body.data } - if (isPostingDetailResponse(body)) { - return body + const page = pageRaw as Record + return { + cursor: normalizePageCursor(page.cursor), + pageSize: typeof page.pageSize === 'number' ? page.pageSize : fallbackCount, + totalCount: + typeof page.totalCount === 'number' ? page.totalCount : fallbackCount, + } +} + +function normalizePostingListResponse(value: unknown): PostingListResponse { + const payload = isCommonApiEnvelope(value) ? value.data : value + if (payload === null || typeof payload !== 'object') { + throw new Error('공고 목록을 불러오지 못했습니다.') + } + + const record = payload as Record + if (!Array.isArray(record.data)) { + throw new Error('공고 목록 응답 형식이 올바르지 않습니다.') + } + + const data = record.data + .map(parsePostingListItem) + .filter((item): item is PostingListResponse['data'][number] => item != null) + + // 항목이 있는데 전부 파싱 실패하면 스키마 문제로 보고 에러 처리 + if (record.data.length > 0 && data.length === 0) { + throw new Error('공고 목록 응답 형식이 올바르지 않습니다.') + } + + return { + data, + page: normalizePage(record.page, data.length), + } +} + +function unwrapPostingListBody(body: unknown): PostingListResponse { + return normalizePostingListResponse(body) +} + +function parseWorkspace( + value: unknown +): PostingDetailResponse['workspace'] | null { + if (value === null || typeof value !== 'object') return null + const workspace = value as Record + if (typeof workspace.id !== 'number') return null + + const businessName = + typeof workspace.businessName === 'string' + ? workspace.businessName + : typeof workspace.name === 'string' + ? workspace.name + : '' + + return { + id: workspace.id, + businessName, + name: typeof workspace.name === 'string' ? workspace.name : businessName, + latitude: typeof workspace.latitude === 'number' ? workspace.latitude : 0, + longitude: + typeof workspace.longitude === 'number' ? workspace.longitude : 0, + fullAddress: + typeof workspace.fullAddress === 'string' ? workspace.fullAddress : '', + town: typeof workspace.town === 'string' ? workspace.town : '', + } +} + +function parsePostingDetail(value: unknown): PostingDetailResponse | null { + if (value === null || typeof value !== 'object') return null + const record = value as Record + const workspace = parseWorkspace(record.workspace) + + if ( + typeof record.id !== 'number' || + typeof record.title !== 'string' || + typeof record.payAmount !== 'number' || + workspace == null + ) { + return null + } + + return { + id: record.id, + title: record.title, + description: + typeof record.description === 'string' ? record.description : '', + payAmount: record.payAmount, + paymentType: + typeof record.paymentType === 'string' ? record.paymentType : 'HOURLY', + createdAt: typeof record.createdAt === 'string' ? record.createdAt : '', + keywords: Array.isArray(record.keywords) + ? (record.keywords as PostingDetailResponse['keywords']) + : [], + schedules: Array.isArray(record.schedules) + ? (record.schedules as PostingDetailResponse['schedules']) + : [], + workspace, + scrapped: typeof record.scrapped === 'boolean' ? record.scrapped : false, } +} + +function unwrapPostingDetailPayload(payload: unknown): PostingDetailResponse { + if (payload !== null && typeof payload === 'object' && 'posting' in payload) { + const nested = (payload as { posting: unknown }).posting + const parsed = parsePostingDetail(nested) + if (parsed) return parsed + } + + const parsed = parsePostingDetail(payload) + if (parsed) return parsed - throw new Error('공고 상세를 불러오지 못했습니다.') + throw new Error('공고 상세 응답 형식이 올바르지 않습니다.') +} + +function unwrapPostingDetailBody(body: unknown): PostingDetailResponse { + if (isCommonApiEnvelope(body)) { + if (body.data == null) { + throw new Error('공고 상세를 불러오지 못했습니다.') + } + return unwrapPostingDetailPayload(body.data) + } + + return unwrapPostingDetailPayload(body) } export async function fetchPostings( params: FetchPostingsParams ): Promise { - const response = await axiosInstance.get( - '/app/postings', - { - params: { - pageSize: params.pageSize, - ...(params.cursor !== undefined && - params.cursor !== '' && { cursor: params.cursor }), - ...(params.searchKeyword?.trim() && { - searchKeyword: params.searchKeyword.trim(), - }), - }, - } - ) - return response.data + const { + pageSize, + cursor, + searchKeyword, + province, + district, + town, + minPayAmount, + maxPayAmount, + payAmountSort, + } = params + + const response = await axiosInstance.get('/app/postings', { + params: { + pageSize, + ...(cursor !== undefined && cursor !== '' && { cursor }), + ...(searchKeyword?.trim() && { + searchKeyword: searchKeyword.trim(), + }), + ...(province && { province }), + ...(district && { district }), + ...(town && { town }), + ...(minPayAmount != null && { minPayAmount }), + ...(maxPayAmount != null && { maxPayAmount }), + ...(payAmountSort != null && { payAmountSort }), + }, + }) + return unwrapPostingListBody(response.data) } export async function fetchPostingDetail( @@ -98,3 +317,75 @@ export async function applyPosting( body ) } + +function normalizeFavoritePostingListResponse( + value: unknown +): FavoritePostingListResponse { + const payload = isCommonApiEnvelope(value) ? value.data : value + if (payload === null || typeof payload !== 'object') { + throw new Error('스크랩 목록을 불러오지 못했습니다.') + } + + const record = payload as Record + if (!Array.isArray(record.data)) { + throw new Error('스크랩 목록 응답 형식이 올바르지 않습니다.') + } + + const data = record.data + .map(parseFavoritePostingItem) + .filter((item): item is FavoritePostingItem => item != null) + + if (record.data.length > 0 && data.length === 0) { + throw new Error('스크랩 목록 응답 형식이 올바르지 않습니다.') + } + + return { + data, + page: normalizePage(record.page, data.length), + } +} + +/** GET /app/users/me/postings/favorites — 사용자 공고 스크랩 목록 조회 */ +export async function fetchFavoritePostings(params: { + pageSize: number + cursor?: string +}): Promise { + const { pageSize, cursor } = params + + const response = await axiosInstance.get( + '/app/users/me/postings/favorites', + { + params: { + pageSize, + ...(cursor !== undefined && cursor !== '' && { cursor }), + }, + } + ) + return normalizeFavoritePostingListResponse(response.data) +} + +/** POST /app/users/me/postings/favorites/{postingId} — 사용자 공고 스크랩 등록 */ +export async function addFavoritePosting(postingId: number): Promise { + await axiosInstance.post>>( + `/app/users/me/postings/favorites/${postingId}` + ) +} + +/** DELETE /app/users/me/postings/favorites/{postingId} — 사용자 공고 스크랩 삭제 */ +export async function removeFavoritePosting(postingId: number): Promise { + await axiosInstance.delete>>( + `/app/users/me/postings/favorites/${postingId}` + ) +} + +/** GET /app/addresses — 단계별 행정구역 주소 조회 */ +export async function fetchAddresses(code?: string): Promise { + const response = await axiosInstance.get< + CommonApiResponse + >('/app/addresses', { + params: code ? { code } : undefined, + }) + + const addresses = response.data.data?.addresses + return Array.isArray(addresses) ? addresses : [] +} diff --git a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx index a9acf51a..528f03e5 100644 --- a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx +++ b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx @@ -1,100 +1,237 @@ -import ChevrondownIcon from '@/assets/icons/job-lookup-map/Chevrondown.svg?react' +import { forwardRef, useImperativeHandle, useState } from 'react' +import CloseIcon from '@/assets/icons/job-lookup-map/Close.svg?react' +import FilterIcon from '@/assets/icons/job-lookup-map/Filter.svg?react' +import { NearbyModeFilterDrawer } from '@/features/job-lookup-map/common/NearbyModeFilterDrawer' +import { RegionModeFilterDrawer } from '@/features/job-lookup-map/common/RegionModeFilterDrawer' +import { + DEFAULT_SORT_VALUE, + EMPTY_SALARY_FILTER, + countActiveFilters, + formatSalaryChipLabel, + formatSortChipLabel, + isSalaryFilterApplied, + isSortFilterApplied, + type SalaryFilterSelection, +} from '@/features/job-lookup-map/lib/postingFilters' +import { + EMPTY_REGION_SELECTION, + formatRegionChipLabel, + hasRegionFilterApplied, + type RegionSelection, +} from '@/features/job-lookup-map/lib/regionOptions' export type AlbaFindMode = 'nearby' | 'region' -export type AlbaFindFilterId = 'sort' | 'distance' | 'salary' - type AlbaFindCategoryBarProps = { mode: AlbaFindMode onModeChange: (mode: AlbaFindMode) => void - activeFilter: AlbaFindFilterId - onFilterChange: (id: AlbaFindFilterId) => void + regionSelection?: RegionSelection + onRegionChange?: (selection: RegionSelection) => void + sortValue?: string + onSortChange?: (value: string) => void + salaryFilter?: SalaryFilterSelection + onSalaryChange?: (selection: SalaryFilterSelection) => void } -const NEARBY_FILTER_ITEMS: { id: AlbaFindFilterId; label: string }[] = [ - { id: 'sort', label: '최신순' }, - { id: 'distance', label: '거리' }, - { id: 'salary', label: '급여' }, -] +export type AlbaFindCategoryBarRef = { + openFilters: () => void +} -const REGION_FILTER_ITEMS: { id: AlbaFindFilterId; label: string }[] = [ - { id: 'sort', label: '최신순' }, - { id: 'distance', label: '서울' }, - { id: 'salary', label: '전체' }, -] +function FilterIconButton({ + activeFilterCount, + onClick, +}: { + activeFilterCount: number + onClick: () => void +}) { + return ( + + ) +} -function getFilterItems(mode: AlbaFindMode) { - return mode === 'region' ? REGION_FILTER_ITEMS : NEARBY_FILTER_ITEMS +function ActiveFilterChip({ + label, + onRemove, +}: { + label: string + onRemove: () => void +}) { + return ( + + {label} + + + ) } -export function AlbaFindCategoryBar({ - mode, - onModeChange, - activeFilter, - onFilterChange, -}: AlbaFindCategoryBarProps) { - const filterItems = getFilterItems(mode) +export const AlbaFindCategoryBar = forwardRef< + AlbaFindCategoryBarRef, + AlbaFindCategoryBarProps +>(function AlbaFindCategoryBar( + { + mode, + onModeChange, + regionSelection = EMPTY_REGION_SELECTION, + onRegionChange, + sortValue = DEFAULT_SORT_VALUE, + onSortChange, + salaryFilter = EMPTY_SALARY_FILTER, + onSalaryChange, + }, + ref +) { + const [isRegionFilterDrawerOpen, setIsRegionFilterDrawerOpen] = + useState(false) + const [isNearbyFilterDrawerOpen, setIsNearbyFilterDrawerOpen] = + useState(false) + + useImperativeHandle( + ref, + () => ({ + openFilters: () => { + if (mode === 'region') { + setIsRegionFilterDrawerOpen(true) + return + } + setIsNearbyFilterDrawerOpen(true) + }, + }), + [mode] + ) + + const activeFilterCount = countActiveFilters({ + mode, + regionSelection, + sortValue, + salaryFilter, + }) + const regionChipLabel = formatRegionChipLabel(regionSelection) + const hasSortSelected = isSortFilterApplied(sortValue) + const hasSalarySelected = isSalaryFilterApplied(salaryFilter) + const sortChipLabel = formatSortChipLabel(sortValue) + const salaryChipLabel = formatSalaryChipLabel(salaryFilter) return ( -
-
- - -
+ + +
+ + {mode === 'region' ? ( +
+ setIsRegionFilterDrawerOpen(true)} + /> -
- {filterItems.map(({ id, label }, index) => { - const active = activeFilter === id - const showChevron = mode === 'nearby' || id === 'sort' - return ( -
- {index === 1 ? ( -
- ) : null} - -
- ) - })} + {hasRegionFilterApplied(regionSelection) && regionChipLabel ? ( + onRegionChange?.(EMPTY_REGION_SELECTION)} + /> + ) : null} +
+ ) : ( +
+ setIsNearbyFilterDrawerOpen(true)} + /> + + {hasSortSelected ? ( + onSortChange?.(DEFAULT_SORT_VALUE)} + /> + ) : null} + + {hasSalarySelected ? ( + onSalaryChange?.(EMPTY_SALARY_FILTER)} + /> + ) : null} +
+ )}
-
+ + { + onRegionChange?.(nextRegion) + onSortChange?.(nextSort) + onSalaryChange?.(nextSalary) + }} + /> + { + onSortChange?.(nextSort) + onSalaryChange?.(nextSalary) + }} + /> + ) -} +}) diff --git a/src/features/job-lookup-map/common/AlbaFindFilteredEmptyState.tsx b/src/features/job-lookup-map/common/AlbaFindFilteredEmptyState.tsx new file mode 100644 index 00000000..84b88f1b --- /dev/null +++ b/src/features/job-lookup-map/common/AlbaFindFilteredEmptyState.tsx @@ -0,0 +1,36 @@ +import MappinMutedIcon from '@/assets/icons/job-lookup-map/MappinMuted.svg?react' + +type AlbaFindFilteredEmptyStateProps = { + title?: string + description?: string + actionLabel: string + onAction: () => void +} + +export function AlbaFindFilteredEmptyState({ + title = '이 지역에 공고가 없어요', + description = '다른 지역을 선택하거나 조건을 바꿔보세요', + actionLabel, + onAction, +}: AlbaFindFilteredEmptyStateProps) { + return ( +
+
+ +
+

+ {title} +

+

+ {description} +

+ +
+ ) +} diff --git a/src/features/job-lookup-map/common/Albabox.tsx b/src/features/job-lookup-map/common/Albabox.tsx index 70809a5c..8228977f 100644 --- a/src/features/job-lookup-map/common/Albabox.tsx +++ b/src/features/job-lookup-map/common/Albabox.tsx @@ -10,7 +10,7 @@ export type AlbaboxProps = { wageAmount: string timeRange: string workDays: string - distance: string + town: string postedAgo: string saved: boolean likeCount?: string @@ -24,7 +24,7 @@ export function Albabox({ wageAmount, timeRange, workDays, - distance, + town, postedAgo, saved, likeCount, @@ -52,7 +52,7 @@ export function Albabox({

{storeName}

- {distance} + {town} · {postedAgo}
diff --git a/src/features/job-lookup-map/common/FilterChip.tsx b/src/features/job-lookup-map/common/FilterChip.tsx new file mode 100644 index 00000000..2af76c11 --- /dev/null +++ b/src/features/job-lookup-map/common/FilterChip.tsx @@ -0,0 +1,21 @@ +type FilterChipProps = { + selected: boolean + label: string + onClick: () => void +} + +export function FilterChip({ selected, label, onClick }: FilterChipProps) { + return ( + + ) +} diff --git a/src/features/job-lookup-map/common/FilterDrawerShell.tsx b/src/features/job-lookup-map/common/FilterDrawerShell.tsx new file mode 100644 index 00000000..422c1277 --- /dev/null +++ b/src/features/job-lookup-map/common/FilterDrawerShell.tsx @@ -0,0 +1,122 @@ +import type { ReactNode } from 'react' +import { Drawer } from 'vaul' + +function CloseIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +type FilterDrawerShellProps = { + open: boolean + onOpenChange: (open: boolean) => void + title: string + children: ReactNode + footer?: ReactNode | null +} + +export function FilterDrawerShell({ + open, + onOpenChange, + title, + children, + footer, +}: FilterDrawerShellProps) { + return ( + + + + +
+ {open ? ( + <> +
+ +
+ + {title} + + +
+ +
{children}
+ + {footer ?? null} + + ) : null} +
+ + + + ) +} + +export function FilterDrawerApplyFooter({ + onApply, + disabled = false, + showReset = false, + onReset, + applyLabel = '적용하기', +}: { + onApply: () => void + disabled?: boolean + showReset?: boolean + onReset?: () => void + applyLabel?: string +}) { + if (showReset) { + return ( +
+ + +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx b/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx new file mode 100644 index 00000000..84dbac62 --- /dev/null +++ b/src/features/job-lookup-map/common/NearbyModeFilterDrawer.tsx @@ -0,0 +1,101 @@ +import { + FilterDrawerApplyFooter, + FilterDrawerShell, +} from '@/features/job-lookup-map/common/FilterDrawerShell' +import { SortSalaryFilterSections } from '@/features/job-lookup-map/common/SortSalaryFilterSections' +import { useSortSalaryFilterDraft } from '@/features/job-lookup-map/hooks/useSortSalaryFilterDraft' +import type { SalaryFilterSelection } from '@/features/job-lookup-map/lib/postingFilters' + +type NearbyModeFilterValues = { + sortValue: string + salaryFilter: SalaryFilterSelection +} + +type NearbyModeFilterDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + value: NearbyModeFilterValues + onApply: (values: NearbyModeFilterValues) => void +} + +function NearbyModeFilterDrawerBody({ + value, + onOpenChange, + onApply, +}: { + value: NearbyModeFilterValues + onOpenChange: (open: boolean) => void + onApply: (values: NearbyModeFilterValues) => void +}) { + const { + sortDraft, + setSortDraft, + salaryDraft, + minInput, + maxInput, + handleSalaryPreset, + handleMinChange, + handleMaxChange, + resetSortSalary, + getNormalizedSalary, + } = useSortSalaryFilterDraft(value) + + const handleApply = () => { + onApply({ + sortValue: sortDraft, + salaryFilter: getNormalizedSalary(), + }) + onOpenChange(false) + } + + return ( + <> +
+ +
+ + + + ) +} + +export function NearbyModeFilterDrawer({ + open, + onOpenChange, + value, + onApply, +}: NearbyModeFilterDrawerProps) { + const resetKey = `${value.sortValue}-${value.salaryFilter.preset}-${value.salaryFilter.min ?? ''}-${value.salaryFilter.max ?? ''}` + + return ( + + {open ? ( + + ) : null} + + ) +} diff --git a/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx b/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx new file mode 100644 index 00000000..e4eec030 --- /dev/null +++ b/src/features/job-lookup-map/common/RegionModeFilterDrawer.tsx @@ -0,0 +1,301 @@ +import { useMemo, useState } from 'react' + +import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' +import { FilterChip } from '@/features/job-lookup-map/common/FilterChip' +import { + FilterDrawerApplyFooter, + FilterDrawerShell, +} from '@/features/job-lookup-map/common/FilterDrawerShell' +import { SortSalaryFilterSections } from '@/features/job-lookup-map/common/SortSalaryFilterSections' +import { useAddresses } from '@/features/job-lookup-map/hooks/useAddresses' +import { useSortSalaryFilterDraft } from '@/features/job-lookup-map/hooks/useSortSalaryFilterDraft' +import type { SalaryFilterSelection } from '@/features/job-lookup-map/lib/postingFilters' +import { + EMPTY_REGION_SELECTION, + REGION_STEPS, + getRegionOptionsForStep, + type RegionOption, + type RegionSelection, + type RegionStep, +} from '@/features/job-lookup-map/lib/regionOptions' + +type RegionModeFilterValues = { + regionSelection: RegionSelection + sortValue: string + salaryFilter: SalaryFilterSelection +} + +type RegionModeFilterDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + value: RegionModeFilterValues + onApply: (values: RegionModeFilterValues) => void +} + +function getStepLabel(step: RegionStep, draft: RegionSelection): string { + if (step === 'sido') { + return draft.sido && draft.sido !== '전국(전체)' ? draft.sido : '시/도' + } + if (step === 'sigungu') { + return draft.sigungu && draft.sigungu !== '전체' + ? draft.sigungu + : '시/군/구' + } + return draft.dong && draft.dong !== '전체' ? draft.dong : '읍/면/동' +} + +function RegionModeFilterDrawerBody({ + value, + onOpenChange, + onApply, +}: { + value: RegionModeFilterValues + onOpenChange: (open: boolean) => void + onApply: (values: RegionModeFilterValues) => void +}) { + const [regionDraft, setRegionDraft] = useState( + value.regionSelection + ) + const [regionStep, setRegionStep] = useState('sido') + const { + sortDraft, + setSortDraft, + salaryDraft, + minInput, + maxInput, + handleSalaryPreset, + handleMinChange, + handleMaxChange, + resetSortSalary, + getNormalizedSalary, + } = useSortSalaryFilterDraft(value) + + const parentCode = + regionStep === 'sigungu' + ? (regionDraft.sidoCode ?? undefined) + : regionStep === 'dong' + ? (regionDraft.sigunguCode ?? undefined) + : undefined + + const addressesEnabled = + regionStep === 'sido' || + (regionStep === 'sigungu' && regionDraft.sidoCode != null) || + (regionStep === 'dong' && regionDraft.sigunguCode != null) + + const { addresses, isLoading, isError } = useAddresses( + parentCode, + addressesEnabled + ) + + const regionOptions = useMemo( + () => getRegionOptionsForStep(regionStep, addresses), + [regionStep, addresses] + ) + + const selectedForStep = + regionStep === 'sido' + ? regionDraft.sido + : regionStep === 'sigungu' + ? regionDraft.sigungu + : regionDraft.dong + + const canGoToStep = (target: RegionStep) => { + if (target === 'sido') return true + if (target === 'sigungu') { + return regionDraft.sido != null && regionDraft.sido !== '전국(전체)' + } + return ( + regionDraft.sido != null && + regionDraft.sido !== '전국(전체)' && + regionDraft.sigungu != null && + regionDraft.sigungu !== '전체' + ) + } + + const handleRegionSelect = (option: RegionOption) => { + if (regionStep === 'sido') { + if (option.name === '전국(전체)') { + setRegionDraft({ + ...EMPTY_REGION_SELECTION, + sido: '전국(전체)', + sigungu: '전체', + dong: '전체', + }) + return + } + + setRegionDraft({ + sido: option.name, + sidoCode: option.code, + sigungu: null, + sigunguCode: null, + dong: null, + dongCode: null, + }) + setRegionStep('sigungu') + return + } + + if (regionStep === 'sigungu') { + if (option.name === '전체') { + setRegionDraft({ + ...regionDraft, + sigungu: '전체', + sigunguCode: null, + dong: '전체', + dongCode: null, + }) + return + } + + setRegionDraft({ + ...regionDraft, + sigungu: option.name, + sigunguCode: option.code, + dong: null, + dongCode: null, + }) + setRegionStep('dong') + return + } + + if (option.name === '전체') { + setRegionDraft({ + ...regionDraft, + dong: '전체', + dongCode: null, + }) + return + } + + setRegionDraft({ + ...regionDraft, + dong: option.name, + dongCode: option.code, + }) + } + + const handleReset = () => { + setRegionDraft(EMPTY_REGION_SELECTION) + setRegionStep('sido') + resetSortSalary() + } + + const handleApply = () => { + onApply({ + regionSelection: regionDraft, + sortValue: sortDraft, + salaryFilter: getNormalizedSalary(), + }) + onOpenChange(false) + } + + return ( + <> +
+
+

지역

+ +
+ {REGION_STEPS.map((item, index) => { + const active = regionStep === item.key + const enabled = canGoToStep(item.key) + return ( +
+ {index > 0 ? ( + + ) : null} + +
+ ) + })} +
+ +
+ {isLoading ? ( +

+ 지역 정보를 불러오는 중… +

+ ) : isError ? ( +

+ 지역 정보를 불러오지 못했습니다. +

+ ) : ( + regionOptions.map(option => ( + handleRegionSelect(option)} + /> + )) + )} +
+
+ + +
+ + + + ) +} + +export function RegionModeFilterDrawer({ + open, + onOpenChange, + value, + onApply, +}: RegionModeFilterDrawerProps) { + const resetKey = `${value.regionSelection.sido ?? ''}-${value.regionSelection.sigungu ?? ''}-${value.regionSelection.dong ?? ''}-${value.sortValue}-${value.salaryFilter.preset}-${value.salaryFilter.min ?? ''}-${value.salaryFilter.max ?? ''}` + + return ( + + {open ? ( + + ) : null} + + ) +} diff --git a/src/features/job-lookup-map/common/SortSalaryFilterSections.tsx b/src/features/job-lookup-map/common/SortSalaryFilterSections.tsx new file mode 100644 index 00000000..a9011d4e --- /dev/null +++ b/src/features/job-lookup-map/common/SortSalaryFilterSections.tsx @@ -0,0 +1,110 @@ +import { FilterChip } from '@/features/job-lookup-map/common/FilterChip' +import { SORT_OPTIONS } from '@/features/job-lookup-map/lib/postingFilters' +import type { SalaryFilterSelection } from '@/features/job-lookup-map/lib/postingFilters' + +type SortSalaryFilterSectionsProps = { + sortDraft: string + onSortChange: (value: string) => void + salaryDraft: SalaryFilterSelection + minInput: string + maxInput: string + onSalaryPreset: (preset: 'all' | 'custom') => void + onMinChange: (raw: string) => void + onMaxChange: (raw: string) => void + className?: string +} + +export function SortSalaryFilterSections({ + sortDraft, + onSortChange, + salaryDraft, + minInput, + maxInput, + onSalaryPreset, + onMinChange, + onMaxChange, + className, +}: SortSalaryFilterSectionsProps) { + const isCustomSalary = salaryDraft.preset === 'custom' + + return ( + <> +
+

정렬

+
+ {SORT_OPTIONS.map(option => ( + onSortChange(option.value)} + /> + ))} +
+
+ +
+

급여

+
+ onSalaryPreset('all')} + /> + onSalaryPreset('custom')} + /> +
+ + {isCustomSalary ? ( +
+

+ 최소 / 최대 시급 +

+
+ + ~ + +
+
+ ) : null} +
+ + ) +} diff --git a/src/features/job-lookup-map/hooks/useAddresses.ts b/src/features/job-lookup-map/hooks/useAddresses.ts new file mode 100644 index 00000000..0b85355e --- /dev/null +++ b/src/features/job-lookup-map/hooks/useAddresses.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query' + +import { fetchAddresses } from '@/features/job-lookup-map/api/posting' + +export function useAddresses(code?: string, enabled = true) { + const { data, isPending, isError, isFetching, refetch } = useQuery({ + queryKey: ['jobLookupMap', 'addresses', code ?? 'root'] as const, + queryFn: () => fetchAddresses(code), + enabled, + }) + + return { + addresses: data ?? [], + isLoading: isPending, + isFetching, + isError, + refetch, + } +} diff --git a/src/features/job-lookup-map/hooks/usePosting.ts b/src/features/job-lookup-map/hooks/usePosting.ts index c728a514..542f838b 100644 --- a/src/features/job-lookup-map/hooks/usePosting.ts +++ b/src/features/job-lookup-map/hooks/usePosting.ts @@ -1,28 +1,33 @@ import { useMemo } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' import { fetchPostings } from '@/features/job-lookup-map/api/posting' +import type { PostingsListFilters } from '@/features/job-lookup-map/lib/postingFilters' import type { Posting } from '@/features/job-lookup-map/types/posting' const PAGE_SIZE = 10 -export function usePostings() { +export function usePostings(filters?: PostingsListFilters) { + const listFilters = useMemo(() => filters ?? {}, [filters]) + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, + isFetching, isError, refetch, } = useInfiniteQuery({ - queryKey: ['jobLookupMap', 'postings', PAGE_SIZE] as const, + queryKey: ['jobLookupMap', 'postings', PAGE_SIZE, listFilters] as const, queryFn: ({ pageParam }) => fetchPostings({ pageSize: PAGE_SIZE, cursor: pageParam as string | undefined, + ...listFilters, }), initialPageParam: undefined as string | undefined, - getNextPageParam: lastPage => lastPage.page.cursor || undefined, + getNextPageParam: lastPage => lastPage.page.cursor ?? undefined, }) const postings = useMemo( @@ -39,6 +44,7 @@ export function usePostings() { hasNextPage: Boolean(hasNextPage), isFetchingNextPage, isLoading: isPending, + isFetching, isError, refetch, } diff --git a/src/features/job-lookup-map/hooks/usePostingDetail.ts b/src/features/job-lookup-map/hooks/usePostingDetail.ts index 0f5d3330..1f101db8 100644 --- a/src/features/job-lookup-map/hooks/usePostingDetail.ts +++ b/src/features/job-lookup-map/hooks/usePostingDetail.ts @@ -2,11 +2,12 @@ import { useQuery } from '@tanstack/react-query' import { fetchPostingDetail } from '@/features/job-lookup-map/api/posting' export function usePostingDetail(postingId: number | undefined) { - const { data, isPending, isError } = useQuery({ + const { data, isLoading, isError, isFetching } = useQuery({ queryKey: ['postingDetail', postingId] as const, queryFn: () => fetchPostingDetail(postingId!), enabled: postingId != null && postingId > 0, + retry: false, }) - return { data, isPending, isError } + return { data, isLoading, isError, isFetching } } diff --git a/src/features/job-lookup-map/hooks/useRemoveFavoritePosting.ts b/src/features/job-lookup-map/hooks/useRemoveFavoritePosting.ts new file mode 100644 index 00000000..c98ed27a --- /dev/null +++ b/src/features/job-lookup-map/hooks/useRemoveFavoritePosting.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { removeFavoritePosting } from '@/features/job-lookup-map/api/posting' + +export function useRemoveFavoritePosting() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (postingId: number) => removeFavoritePosting(postingId), + onSuccess: (_, postingId) => { + void queryClient.invalidateQueries({ + queryKey: ['jobLookupMap', 'favoritePostings'], + }) + void queryClient.invalidateQueries({ + queryKey: ['jobLookupMap', 'postings'], + }) + void queryClient.invalidateQueries({ + queryKey: ['postingDetail', postingId], + }) + }, + }) +} diff --git a/src/features/job-lookup-map/hooks/useScrappedPostings.ts b/src/features/job-lookup-map/hooks/useScrappedPostings.ts new file mode 100644 index 00000000..fa480bfd --- /dev/null +++ b/src/features/job-lookup-map/hooks/useScrappedPostings.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchFavoritePostings } from '@/features/job-lookup-map/api/posting' +import type { FavoritePostingItem } from '@/features/job-lookup-map/types/posting' + +const PAGE_SIZE = 10 + +export function useScrappedPostings() { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isFetching, + isError, + refetch, + } = useInfiniteQuery({ + queryKey: ['jobLookupMap', 'favoritePostings', PAGE_SIZE] as const, + queryFn: ({ pageParam }) => + fetchFavoritePostings({ + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page.cursor ?? undefined, + }) + + const favorites = useMemo( + () => + data?.pages.flatMap((page): FavoritePostingItem[] => page.data ?? []) ?? + [], + [data] + ) + + const totalCount = data?.pages[0]?.page.totalCount ?? 0 + + return { + favorites, + totalCount, + fetchNextPage, + hasNextPage: Boolean(hasNextPage), + isFetchingNextPage, + isLoading: isPending, + isFetching, + isError, + refetch, + } +} diff --git a/src/features/job-lookup-map/hooks/useSortSalaryFilterDraft.ts b/src/features/job-lookup-map/hooks/useSortSalaryFilterDraft.ts new file mode 100644 index 00000000..ccde0835 --- /dev/null +++ b/src/features/job-lookup-map/hooks/useSortSalaryFilterDraft.ts @@ -0,0 +1,96 @@ +import { useState } from 'react' + +import { + DEFAULT_SORT_VALUE, + EMPTY_SALARY_FILTER, + formatSalaryInput, + parseSalaryInput, + type SalaryFilterSelection, +} from '@/features/job-lookup-map/lib/postingFilters' + +type SortSalaryFilterDraft = { + sortValue: string + salaryFilter: SalaryFilterSelection +} + +export function useSortSalaryFilterDraft(initial: SortSalaryFilterDraft) { + const [sortDraft, setSortDraft] = useState(initial.sortValue) + const [salaryDraft, setSalaryDraft] = useState(initial.salaryFilter) + const [minInput, setMinInput] = useState( + formatSalaryInput(initial.salaryFilter.min) + ) + const [maxInput, setMaxInput] = useState( + formatSalaryInput(initial.salaryFilter.max) + ) + + const handleSalaryPreset = (preset: 'all' | 'custom') => { + if (preset === 'all') { + setSalaryDraft(EMPTY_SALARY_FILTER) + setMinInput('') + setMaxInput('') + return + } + + setSalaryDraft(prev => ({ + preset: 'custom', + min: prev.min, + max: prev.max, + })) + } + + const handleMinChange = (raw: string) => { + const min = parseSalaryInput(raw) + setMinInput(min != null ? formatSalaryInput(min) : '') + setSalaryDraft(prev => ({ + preset: 'custom', + min, + max: prev.max, + })) + } + + const handleMaxChange = (raw: string) => { + const max = parseSalaryInput(raw) + setMaxInput(max != null ? formatSalaryInput(max) : '') + setSalaryDraft(prev => ({ + preset: 'custom', + min: prev.min, + max, + })) + } + + const resetSortSalary = () => { + setSortDraft(DEFAULT_SORT_VALUE) + setSalaryDraft(EMPTY_SALARY_FILTER) + setMinInput('') + setMaxInput('') + } + + const getNormalizedSalary = (): SalaryFilterSelection => { + if ( + salaryDraft.min != null && + salaryDraft.max != null && + salaryDraft.min > salaryDraft.max + ) { + return { + ...salaryDraft, + min: salaryDraft.max, + max: salaryDraft.min, + } + } + + return salaryDraft + } + + return { + sortDraft, + setSortDraft, + salaryDraft, + minInput, + maxInput, + handleSalaryPreset, + handleMinChange, + handleMaxChange, + resetSortSalary, + getNormalizedSalary, + } +} diff --git a/src/features/job-lookup-map/hooks/useToggleFavoritePosting.ts b/src/features/job-lookup-map/hooks/useToggleFavoritePosting.ts new file mode 100644 index 00000000..5da32acf --- /dev/null +++ b/src/features/job-lookup-map/hooks/useToggleFavoritePosting.ts @@ -0,0 +1,70 @@ +import { useRef } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + addFavoritePosting, + removeFavoritePosting, +} from '@/features/job-lookup-map/api/posting' + +function invalidateFavoriteQueries( + queryClient: ReturnType, + postingId: number +) { + void queryClient.invalidateQueries({ + queryKey: ['jobLookupMap', 'favoritePostings'], + }) + void queryClient.invalidateQueries({ + queryKey: ['jobLookupMap', 'postings'], + }) + void queryClient.invalidateQueries({ + queryKey: ['postingDetail', postingId], + }) +} + +export function useToggleFavoritePosting() { + const queryClient = useQueryClient() + const inFlightIdsRef = useRef(new Set()) + + const addMutation = useMutation({ + mutationFn: (postingId: number) => addFavoritePosting(postingId), + onSuccess: (_, postingId) => { + invalidateFavoriteQueries(queryClient, postingId) + }, + }) + + const removeMutation = useMutation({ + mutationFn: (postingId: number) => removeFavoritePosting(postingId), + onSuccess: (_, postingId) => { + invalidateFavoriteQueries(queryClient, postingId) + }, + }) + + const toggleFavorite = (params: { + postingId: number + saved: boolean + onOptimistic?: (nextSaved: boolean) => void + onError?: (rollbackSaved: boolean) => void + onSettled?: () => void + }) => { + const { postingId, saved, onOptimistic, onError, onSettled } = params + if (inFlightIdsRef.current.has(postingId)) return false + + inFlightIdsRef.current.add(postingId) + const nextSaved = !saved + onOptimistic?.(nextSaved) + + const mutate = saved ? removeMutation.mutate : addMutation.mutate + mutate(postingId, { + onError: () => onError?.(saved), + onSettled: () => { + inFlightIdsRef.current.delete(postingId) + onSettled?.() + }, + }) + return true + } + + return { + toggleFavorite, + isPending: addMutation.isPending || removeMutation.isPending, + } +} diff --git a/src/features/job-lookup-map/lib/postingFilters.ts b/src/features/job-lookup-map/lib/postingFilters.ts new file mode 100644 index 00000000..2534a763 --- /dev/null +++ b/src/features/job-lookup-map/lib/postingFilters.ts @@ -0,0 +1,151 @@ +import type { RegionSelection } from '@/features/job-lookup-map/lib/regionOptions' +import { + hasRegionFilterApplied, + isRegionSelectionComplete, +} from '@/features/job-lookup-map/lib/regionOptions' + +export type AlbaFindMode = 'nearby' | 'region' + +export type SalaryPreset = 'all' | 'custom' + +export const SORT_OPTIONS = [ + { value: 'LATEST', label: '최신순', description: '최신순' }, + { value: 'PAY_AMOUNT', label: '급여순', description: '급여순' }, +] as const + +export type SalaryFilterSelection = { + preset: SalaryPreset + min: number | null + max: number | null +} + +export const EMPTY_SALARY_FILTER: SalaryFilterSelection = { + preset: 'all', + min: null, + max: null, +} + +export const DEFAULT_SORT_VALUE = 'LATEST' + +export function formatSortOptionLabel(option: { + value: string + description: string +}): string { + if (option.value === 'LATEST') return '최신순' + if (option.value === 'PAY_AMOUNT') return '급여순' + return option.description +} + +export function formatSalaryChipLabel( + selection: SalaryFilterSelection +): string { + if (selection.preset === 'custom') { + if (selection.min != null && selection.max != null) { + return `${selection.min.toLocaleString('ko-KR')}~${selection.max.toLocaleString('ko-KR')}원` + } + if (selection.min != null) { + return `${selection.min.toLocaleString('ko-KR')}원 이상` + } + if (selection.max != null) { + return `${selection.max.toLocaleString('ko-KR')}원 이하` + } + } + return '급여' +} + +export function isSalaryFilterApplied( + selection: SalaryFilterSelection +): boolean { + return ( + selection.preset !== 'all' || selection.min != null || selection.max != null + ) +} + +export function formatSortChipLabel(value: string): string { + const option = SORT_OPTIONS.find(item => item.value === value) + if (option) return formatSortOptionLabel(option) + return '최신순' +} + +export function isSortFilterApplied(value: string): boolean { + return value !== DEFAULT_SORT_VALUE +} + +export function countActiveFilters(params: { + regionSelection: RegionSelection + sortValue: string + salaryFilter: SalaryFilterSelection + mode: AlbaFindMode +}): number { + const { regionSelection, sortValue, salaryFilter, mode } = params + let count = 0 + if (mode === 'region' && hasRegionFilterApplied(regionSelection)) count += 1 + if (isSortFilterApplied(sortValue)) count += 1 + if (isSalaryFilterApplied(salaryFilter)) count += 1 + return count +} + +export function isListFilterApplied(params: { + mode: AlbaFindMode + regionSelection: RegionSelection + sortValue: string + salaryFilter: SalaryFilterSelection +}): boolean { + return countActiveFilters(params) > 0 +} + +export function parseSalaryInput(raw: string): number | null { + const digits = raw.replace(/[^\d]/g, '') + if (!digits) return null + const parsed = Number(digits) + return Number.isFinite(parsed) ? parsed : null +} + +export function formatSalaryInput(value: number | null): string { + if (value == null) return '' + return value.toLocaleString('ko-KR') +} + +export type PostingsListFilters = { + province?: string + district?: string + town?: string + minPayAmount?: number + maxPayAmount?: number + payAmountSort?: boolean +} + +export function buildPostingsListFilters(params: { + mode: AlbaFindMode + regionSelection: RegionSelection + sortValue: string + salaryFilter: SalaryFilterSelection +}): PostingsListFilters { + const { mode, regionSelection, sortValue, salaryFilter } = params + const filters: PostingsListFilters = {} + + if (mode === 'region' && isRegionSelectionComplete(regionSelection)) { + if (regionSelection.sido && regionSelection.sido !== '전국(전체)') { + filters.province = regionSelection.sido + } + if (regionSelection.sigungu && regionSelection.sigungu !== '전체') { + filters.district = regionSelection.sigungu + } + if (regionSelection.dong && regionSelection.dong !== '전체') { + filters.town = regionSelection.dong + } + } + + if (sortValue === 'PAY_AMOUNT') { + filters.payAmountSort = true + } + + if (salaryFilter.min != null) { + filters.minPayAmount = salaryFilter.min + } + if (salaryFilter.max != null) { + filters.maxPayAmount = salaryFilter.max + } + + return filters +} diff --git a/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts b/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts index f491ffff..5b22a0de 100644 --- a/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts +++ b/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts @@ -86,7 +86,7 @@ export function postingToAlbaboxProps( wageAmount: p.payAmount.toLocaleString('ko-KR'), timeRange, workDays, - distance: '-', + town: p.workspace.town?.trim() || '-', postedAgo: formatPostedAgo(p.createdAt), saved: p.scrapped, } diff --git a/src/features/job-lookup-map/lib/regionOptions.ts b/src/features/job-lookup-map/lib/regionOptions.ts new file mode 100644 index 00000000..edbc8961 --- /dev/null +++ b/src/features/job-lookup-map/lib/regionOptions.ts @@ -0,0 +1,88 @@ +import type { AddressItem } from '@/features/job-lookup-map/types/posting' + +export type RegionStep = 'sido' | 'sigungu' | 'dong' + +export type RegionSelection = { + sido: string | null + sigungu: string | null + dong: string | null + sidoCode: string | null + sigunguCode: string | null + dongCode: string | null +} + +export const EMPTY_REGION_SELECTION: RegionSelection = { + sido: null, + sigungu: null, + dong: null, + sidoCode: null, + sigunguCode: null, + dongCode: null, +} + +export const REGION_STEPS: { key: RegionStep; label: string }[] = [ + { key: 'sido', label: '시/도' }, + { key: 'sigungu', label: '시/군/구' }, + { key: 'dong', label: '읍/면/동' }, +] + +export type RegionOption = { + code: string | null + name: string +} + +export function formatRegionLabel(selection: RegionSelection): string { + if (!selection.sido || selection.sido === '전국(전체)') return '지역 선택' + if (!selection.sigungu || selection.sigungu === '전체') return selection.sido + if (!selection.dong || selection.dong === '전체') { + return `${selection.sido} ${selection.sigungu}` + } + return `${selection.sigungu} ${selection.dong}` +} + +export function formatRegionChipLabel( + selection: RegionSelection +): string | null { + if (!selection.sido || selection.sido === '전국(전체)') return null + if (!selection.sigungu || selection.sigungu === '전체') { + return selection.sido + } + if (!selection.dong || selection.dong === '전체') { + return `${selection.sido} ${selection.sigungu}` + } + return `${selection.sido} ${selection.sigungu}` +} + +export function hasRegionFilterApplied(selection: RegionSelection): boolean { + return ( + selection.sido != null && + selection.sido !== '전국(전체)' && + isRegionSelectionComplete(selection) + ) +} + +export function isRegionSelectionComplete(selection: RegionSelection): boolean { + return ( + selection.sido != null && + selection.sigungu != null && + selection.dong != null + ) +} + +export function getRegionOptionsForStep( + step: RegionStep, + addresses: AddressItem[] +): RegionOption[] { + const allOption: RegionOption = + step === 'sido' + ? { code: null, name: '전국(전체)' } + : { code: null, name: '전체' } + + return [ + allOption, + ...addresses.map(address => ({ + code: address.code, + name: address.name, + })), + ] +} diff --git a/src/features/job-lookup-map/types/posting.ts b/src/features/job-lookup-map/types/posting.ts index a867d4f2..f8f5fd23 100644 --- a/src/features/job-lookup-map/types/posting.ts +++ b/src/features/job-lookup-map/types/posting.ts @@ -15,8 +15,23 @@ export interface PostingDetailResponse { schedules: Schedule[] scrapped: boolean } + +export interface AddressItem { + code: string + name: string +} + +export interface AddressesResponse { + addresses: AddressItem[] +} + +export interface PostingSortOption { + value: string + description: string +} + export interface Page { - cursor: string + cursor: string | null pageSize: number totalCount: number } @@ -55,9 +70,31 @@ export interface Workspace { latitude: number longitude: number fullAddress: string + town: string } export interface ApplyPostingRequest { postingScheduleId: number description: string } + +/** `GET /app/users/me/postings/favorites` 응답의 공고 요약 */ +export interface FavoritePostingSummary { + id: number + businessName: string + title: string + payAmount: number + paymentType: string +} + +/** 스크랩(즐겨찾기) 한 건 */ +export interface FavoritePostingItem { + id: number + posting: FavoritePostingSummary + createdAt: string +} + +export interface FavoritePostingListResponse { + page: Page + data: FavoritePostingItem[] +} diff --git a/src/pages/my/components/MenuListItem.tsx b/src/pages/my/components/MenuListItem.tsx index 9d47e422..e0323e2d 100644 --- a/src/pages/my/components/MenuListItem.tsx +++ b/src/pages/my/components/MenuListItem.tsx @@ -6,6 +6,7 @@ interface MenuListItemProps { label: string onClick?: () => void isLast?: boolean + iconClassName?: string } export function MenuListItem({ @@ -13,6 +14,7 @@ export function MenuListItem({ label, onClick, isLast = false, + iconClassName, }: MenuListItemProps) { return ( +
+ +

{title}

+ +

+ 시급 {wageAmount}원 +

+ +

+ {savedAgoLabel} +

+ + ) +} diff --git a/src/pages/my/scrapped/index.tsx b/src/pages/my/scrapped/index.tsx new file mode 100644 index 00000000..4b52b2a3 --- /dev/null +++ b/src/pages/my/scrapped/index.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react' +import { generatePath, useNavigate } from 'react-router-dom' +import { useRemoveFavoritePosting } from '@/features/job-lookup-map/hooks/useRemoveFavoritePosting' +import { useScrappedPostings } from '@/features/job-lookup-map/hooks/useScrappedPostings' +import { formatPostedAgo } from '@/features/job-lookup-map/lib/postingToAlbaboxProps' +import { ScrappedPostingCard } from '@/pages/my/scrapped/components/ScrappedPostingCard' +import { ROUTES } from '@/shared/constants/routes' +import { shouldShowInfiniteListLoadMore } from '@/shared/lib/listLoadMoreVisibility' +import { ConfirmModal } from '@/shared/ui/common/ConfirmModal' +import { Navbar } from '@/shared/ui/common/Navbar' +import { MoreButton } from '@/shared/ui/common/MoreButton' +import { Spinner } from '@/shared/ui/Spinner' + +export function ScrappedPostingsPage() { + const navigate = useNavigate() + const { mutate: removeFavorite, isPending: isRemoving } = + useRemoveFavoritePosting() + const [pendingPostingId, setPendingPostingId] = useState(null) + + const { + favorites, + totalCount, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useScrappedPostings() + + const handleConfirmRemove = () => { + if (pendingPostingId == null) return + removeFavorite(pendingPostingId, { + onSettled: () => setPendingPostingId(null), + }) + } + + return ( +
+
+ +
+ +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +

+ 스크랩한 알바를 불러오지 못했습니다. +

+ ) : favorites.length === 0 ? ( +

+ 스크랩한 알바가 없습니다. +

+ ) : ( +
+ {favorites.map(item => ( + setPendingPostingId(item.posting.id)} + onClick={() => + navigate( + generatePath(ROUTES.USER.JOB_LOOKUP_MAP_DETAIL, { + postingId: String(item.posting.id), + }) + ) + } + /> + ))} + {shouldShowInfiniteListLoadMore(hasNextPage, totalCount) && ( + void fetchNextPage()} + disabled={isFetchingNextPage} + /> + )} +
+ )} +
+ + { + if (!isRemoving) setPendingPostingId(null) + }} + /> +
+ ) +} + +export default ScrappedPostingsPage diff --git a/src/pages/user/job-lookup-map-apply/index.tsx b/src/pages/user/job-lookup-map-apply/index.tsx index 91a22100..667cf698 100644 --- a/src/pages/user/job-lookup-map-apply/index.tsx +++ b/src/pages/user/job-lookup-map-apply/index.tsx @@ -111,7 +111,7 @@ export function JobLookupMapApplyPage() { const postingId = Number(postingIdParam) const idOk = Number.isFinite(postingId) && postingId > 0 - const { data, isPending, isError } = usePostingDetail( + const { data, isLoading, isError } = usePostingDetail( idOk ? postingId : undefined ) const [introduction, setIntroduction] = useState('') @@ -130,7 +130,7 @@ export function JobLookupMapApplyPage() { ? resolveApplyPostingError(submitError) : null - const showLoading = idOk && isPending && !data + const showLoading = idOk && isLoading && !data const showError = idOk && isError && !data const showEmpty = !idOk diff --git a/src/pages/user/job-lookup-map-detail/index.tsx b/src/pages/user/job-lookup-map-detail/index.tsx index c7897fdc..b35c8a88 100644 --- a/src/pages/user/job-lookup-map-detail/index.tsx +++ b/src/pages/user/job-lookup-map-detail/index.tsx @@ -1,8 +1,10 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { generatePath, useNavigate, useParams } from 'react-router-dom' import { ROUTES } from '@/shared/constants/routes' import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg?react' +import BookmarkIcon from '@/assets/icons/job-lookup-map/Bookmark.svg?react' import { usePostingDetail } from '@/features/job-lookup-map/hooks/usePostingDetail' +import { useToggleFavoritePosting } from '@/features/job-lookup-map/hooks/useToggleFavoritePosting' import { formatPostedAgo, formatWorkDaysForDisplay, @@ -34,9 +36,14 @@ export function JobLookupMapDetailPage() { const postingId = Number(postingIdParam) const idOk = Number.isFinite(postingId) && postingId > 0 - const { data, isPending, isError } = usePostingDetail( + const { data, isLoading, isError } = usePostingDetail( idOk ? postingId : undefined ) + const { toggleFavorite, isPending: isFavoritePending } = + useToggleFavoritePosting() + const [savedById, setSavedById] = useState>({}) + const saved = + (idOk ? savedById[postingId] : undefined) ?? data?.scrapped ?? false const schedule = data?.schedules?.[0] const workDaysLine = useMemo(() => { @@ -57,6 +64,18 @@ export function JobLookupMapDetailPage() { ? formatDurationHint(schedule.startTime, schedule.endTime) : null + const handleBookmarkClick = () => { + if (!idOk || isFavoritePending) return + toggleFavorite({ + postingId, + saved, + onOptimistic: nextSaved => + setSavedById(prev => ({ ...prev, [postingId]: nextSaved })), + onError: rollbackSaved => + setSavedById(prev => ({ ...prev, [postingId]: rollbackSaved })), + }) + } + return (
@@ -71,7 +90,23 @@ export function JobLookupMapDetailPage() {

알바 상세

-
+
{!idOk && ( @@ -89,7 +124,7 @@ export function JobLookupMapDetailPage() { )} - {idOk && isPending && !data && ( + {idOk && isLoading && !data && (

공고 정보를 불러오는 중… diff --git a/src/pages/user/job-lookup-map/index.tsx b/src/pages/user/job-lookup-map/index.tsx index 8e3e02d0..7c0d97c7 100644 --- a/src/pages/user/job-lookup-map/index.tsx +++ b/src/pages/user/job-lookup-map/index.tsx @@ -2,26 +2,41 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, type KeyboardEvent, } from 'react' import { generatePath, useNavigate } from 'react-router-dom' import { animate, motion, useMotionValue } from 'framer-motion' -import { AlbaFindCategoryBar } from '@/features/job-lookup-map/common/AlbaFindCategoryBar' -import { ROUTES } from '@/shared/constants/routes' -import type { - AlbaFindFilterId, - AlbaFindMode, +import { + AlbaFindCategoryBar, + type AlbaFindCategoryBarRef, } from '@/features/job-lookup-map/common/AlbaFindCategoryBar' +import { AlbaFindFilteredEmptyState } from '@/features/job-lookup-map/common/AlbaFindFilteredEmptyState' +import { ROUTES } from '@/shared/constants/routes' +import type { AlbaFindMode } from '@/features/job-lookup-map/common/AlbaFindCategoryBar' import { AlbaFindList } from '@/features/job-lookup-map/common/AlbaFindList' import { Albabox } from '@/features/job-lookup-map/common/Albabox' import { usePostings } from '@/features/job-lookup-map/hooks/usePosting' +import { useToggleFavoritePosting } from '@/features/job-lookup-map/hooks/useToggleFavoritePosting' import { usePostingMapMarkers } from '@/features/job-lookup-map/hooks/usePostingMapMarkers' import { usePostingSearch } from '@/features/job-lookup-map/hooks/usePostingSearch' import { moveMapToWorkspace } from '@/features/job-lookup-map/lib/moveMapToWorkspace' import { pickSearchTargetPosting } from '@/features/job-lookup-map/lib/pickSearchTargetPosting' import { postingToAlbaboxProps } from '@/features/job-lookup-map/lib/postingToAlbaboxProps' +import { + EMPTY_REGION_SELECTION, + isRegionSelectionComplete, + type RegionSelection, +} from '@/features/job-lookup-map/lib/regionOptions' +import { + DEFAULT_SORT_VALUE, + EMPTY_SALARY_FILTER, + buildPostingsListFilters, + isListFilterApplied, + type SalaryFilterSelection, +} from '@/features/job-lookup-map/lib/postingFilters' import { getNaverMaps, type NaverMapInstance, @@ -44,16 +59,41 @@ export function JobLookupMapPage() { const sheetRef = useRef(null) const [maxTranslateY, setMaxTranslateY] = useState(0) const [mode, setMode] = useState('nearby') - const [activeFilter, setActiveFilter] = useState('sort') + const [regionSelection, setRegionSelection] = useState( + EMPTY_REGION_SELECTION + ) + const [sortValue, setSortValue] = useState(DEFAULT_SORT_VALUE) + const [salaryFilter, setSalaryFilter] = + useState(EMPTY_SALARY_FILTER) const [bookmarkById, setBookmarkById] = useState>({}) const [searchQuery, setSearchQuery] = useState('') const [searchList, setSearchList] = useState(null) const loadMoreRef = useRef(null) + const categoryBarRef = useRef(null) const hasSetInitialSheetYRef = useRef(false) const y = useMotionValue(0) - const { postings, fetchNextPage, hasNextPage, isFetchingNextPage } = - usePostings() + const listFilters = useMemo( + () => + buildPostingsListFilters({ + mode, + regionSelection, + sortValue, + salaryFilter, + }), + [mode, regionSelection, sortValue, salaryFilter] + ) + + const { + postings, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = usePostings(listFilters) + + const { toggleFavorite } = useToggleFavoritePosting() const { search } = usePostingSearch() @@ -287,16 +327,66 @@ export function JobLookupMapPage() { }} className="absolute inset-x-0 bottom-[30px] z-[40] mx-auto flex h-[calc(100dvh-78px)] max-h-[calc(100dvh-78px)] w-full max-w-[428px] flex-col overflow-hidden rounded-t-[32px] border border-line-2 border-b-0 bg-white" > -

+ -
+
{ + setRegionSelection(selection) + setSearchList(null) + if (isRegionSelectionComplete(selection)) { + setMode('region') + } + }} + sortValue={sortValue} + onSortChange={value => { + setSortValue(value) + setSearchList(null) + }} + salaryFilter={salaryFilter} + onSalaryChange={selection => { + setSalaryFilter(selection) + setSearchList(null) + }} /> + {isLoading && displayedPostings.length === 0 ? ( +

+ 공고를 불러오는 중… +

+ ) : isError ? ( +

+ 공고를 불러오지 못했습니다. +

+ ) : displayedPostings.length === 0 ? ( + !isSearchActive && + isListFilterApplied({ + mode, + regionSelection, + sortValue, + salaryFilter, + }) ? ( + categoryBarRef.current?.openFilters()} + /> + ) : ( +

+ 조건에 맞는 공고가 없습니다. +

+ ) + ) : null} {displayedPostings.map(posting => { const base = postingToAlbaboxProps(posting) const saved = bookmarkById[posting.id] ?? posting.scrapped @@ -305,12 +395,22 @@ export function JobLookupMapPage() { key={posting.id} {...base} saved={saved} - onBookmarkClick={() => - setBookmarkById(prev => ({ - ...prev, - [posting.id]: !saved, - })) - } + onBookmarkClick={() => { + toggleFavorite({ + postingId: posting.id, + saved, + onOptimistic: nextSaved => + setBookmarkById(prev => ({ + ...prev, + [posting.id]: nextSaved, + })), + onError: rollbackSaved => + setBookmarkById(prev => ({ + ...prev, + [posting.id]: rollbackSaved, + })), + }) + }} onClick={() => { if (isSearchActive) { moveToPosting(posting) diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index 36753051..98bfa262 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -59,6 +59,7 @@ export const ROUTES = { PROFILE: '/my/profile', PROFILE_NICKNAME: '/my/profile/nickname', PROFILE_PASSWORD: '/my/profile/password', + SCRAPPED_POSTINGS: '/my/profile/scrapped', PROFILE_EMAIL: '/my/profile/email', PROFILE_SOCIAL: '/my/profile/social', WITHDRAW: '/my/withdraw',